From 0a9a5f5e73e3a3476f79dd2254d34e3db4edde5e Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 10:11:55 +0200 Subject: [PATCH 1/3] fix: keep latest tool states when bootstrap sync races SSE --- app-prefixable/src/context/sync.tsx | 83 +++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx index 0b303b00..b84641fc 100644 --- a/app-prefixable/src/context/sync.tsx +++ b/app-prefixable/src/context/sync.tsx @@ -58,6 +58,70 @@ function sortParts(parts: Part[]): Part[] { return [...withId, ...withoutId] } +function toolEnd(part: Extract): number { + const state = part.state + if (state.status === "completed") return state.time.end + if (state.status === "error") return state.time.end + return 0 +} + +function toolStart(part: Extract): number { + const state = part.state + if (state.status === "running") return state.time.start + if (state.status === "completed") return state.time.start + if (state.status === "error") return state.time.start + return 0 +} + +function toolRank(part: Extract): number { + const state = part.state + if (state.status === "pending") return 1 + if (state.status === "running") return 2 + return 3 +} + +function mergePart(existing: Part, synced: Part): Part { + if (existing.type !== "tool") return synced + if (synced.type !== "tool") return synced + + const existingEnd = toolEnd(existing) + const syncedEnd = toolEnd(synced) + if (existingEnd > syncedEnd) return existing + if (syncedEnd > existingEnd) return synced + + const existingStart = toolStart(existing) + const syncedStart = toolStart(synced) + if (existingStart > syncedStart) return existing + if (syncedStart > existingStart) return synced + + const existingRank = toolRank(existing) + const syncedRank = toolRank(synced) + if (existingRank > syncedRank) return existing + if (syncedRank > existingRank) return synced + + return synced +} + +function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): MessageWithParts { + const map = new Map(existing.parts.map((part) => [part.id, part])) + const merged = synced.parts.map((part) => { + const current = map.get(part.id) + if (!current) return part + return mergePart(current, part) + }) + const ids = new Set(merged.map((part) => part.id)) + + for (const part of existing.parts) { + if (ids.has(part.id)) continue + merged.push(part) + } + + return { + info: synced.info, + parts: sortParts(merged), + } +} + function errorText(err: unknown) { if (err instanceof Error && err.message.trim()) return err.message return "Failed to bootstrap app state from API." @@ -405,19 +469,18 @@ export function SyncProvider(props: ParentProps) { setStore("message", sessionID, (existing: MessageWithParts[]) => { if (!existing || existing.length === 0) return synced - // Merge: use existing message if it has more recent parts - const merged = synced.map((s) => { - const e = existing.find((m) => m.info.id === s.info.id) - if (!e) return s - // Keep existing if it has more parts (SSE updates arrived) - return e.parts.length >= s.parts.length ? e : s + const map = new Map(existing.map((msg) => [msg.info.id, msg])) + const merged = synced.map((msg) => { + const current = map.get(msg.info.id) + if (!current) return msg + return mergeMessage(current, msg) }) + const ids = new Set(merged.map((msg) => msg.info.id)) // Add any messages from existing that aren't in synced (new SSE messages) - for (const e of existing) { - if (!merged.find((m) => m.info.id === e.info.id)) { - merged.push(e) - } + for (const msg of existing) { + if (ids.has(msg.info.id)) continue + merged.push(msg) } return merged.sort((a, b) => cmp(a.info.id, b.info.id)) From 50eca95e44f91593cf182d51bc9c326822d533dd Mon Sep 17 00:00:00 2001 From: Christian Geier Date: Mon, 20 Apr 2026 10:12:01 +0200 Subject: [PATCH 2/3] fix: route telegram session alarms by directory and improve bridge observability --- .../src/components/telegram-settings.tsx | 2 +- app-prefixable/src/pages/session.tsx | 42 +-- app-prefixable/src/pages/settings.tsx | 3 +- app-prefixable/src/utils/extended-api.ts | 13 +- app-prefixable/tests/telegram-bridge.test.ts | 4 +- shared/extended-api.ts | 112 +++++- shared/telegram-bridge.ts | 352 ++++++++++++++++-- shared/telegram-session-store.ts | 110 ++++-- 8 files changed, 529 insertions(+), 109 deletions(-) diff --git a/app-prefixable/src/components/telegram-settings.tsx b/app-prefixable/src/components/telegram-settings.tsx index 3f89c92c..b2e18e3f 100644 --- a/app-prefixable/src/components/telegram-settings.tsx +++ b/app-prefixable/src/components/telegram-settings.tsx @@ -274,7 +274,7 @@ export function TelegramSettings(props: Props) {

Config: {report().config.status === "ok" ? "loaded" : "invalid"}

Telegram API: {report().dependencies.telegramApi.status} - {report().dependencies.telegramApi.message}

OpenCode API: {report().dependencies.openCodeApi.status} - {report().dependencies.openCodeApi.message}

- 0}> + 1}>
{(report().dependencies.openCodeSources || []).map((item) => (

Source {item.sourceId}: {item.status} - {item.message}

diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx index f6c5eca7..cc9def3c 100644 --- a/app-prefixable/src/pages/session.tsx +++ b/app-prefixable/src/pages/session.tsx @@ -56,7 +56,7 @@ import { writeNotifyMap, writeNotifySessionEnabled, } from "../utils/notify"; -import { getSessionAlarm, resolveTelegramSourceId, setSessionAlarm } from "../utils/extended-api"; +import { getSessionAlarm, setSessionAlarm } from "../utils/extended-api"; import { sessionQuestionRequest } from "../utils/session-tree-request"; import { createRootSession } from "../utils/root-session"; import { @@ -560,29 +560,13 @@ export function Session() { })(), ); const [notifyDenied, setNotifyDenied] = createSignal(false); - const [notifySourceId, setNotifySourceId] = createSignal(); - const [notifySourceReady, setNotifySourceReady] = createSignal(false); const notifyVersion = { value: 0 }; const deniedTimer = { id: null as ReturnType | null }; onCleanup(() => { if (deniedTimer.id !== null) clearTimeout(deniedTimer.id) }); - createEffect(() => { - const dir = directory || base64Decode(params.dir); - setNotifySourceReady(false); - let cancelled = false; - onCleanup(() => { cancelled = true }); - resolveTelegramSourceId(url, dir).then((sourceId) => { - if (cancelled) return; - setNotifySourceId(sourceId); - setNotifySourceReady(true); - }); - }); - // Re-read notification state when session changes, and seed from server alarm state createEffect(() => { const id = params.id; - const sourceReady = notifySourceReady(); - const sourceId = notifySourceId(); const dir = directory || base64Decode(params.dir); setNotifyDenied(false); if (!id) { @@ -595,13 +579,12 @@ export function Session() { if (migrated) writeNotifyMap(map); const local = notifyEnabledForSession(map, id, dir); setNotifyEnabled(local); - if (!sourceReady) return; // Cancellation flag for stale responses let cancelled = false; const version = notifyVersion.value; onCleanup(() => { cancelled = true }); // Then fetch server-side alarm state asynchronously - getSessionAlarm(url, id, sourceId).then((state) => { + getSessionAlarm(url, id).then((state) => { // Skip if effect was cleaned up, session changed, or local state was updated if (cancelled || !state || params.id !== id) return; if (notifyVersion.value !== version) return; @@ -615,20 +598,15 @@ export function Session() { /** Mirror bell toggle to server-side alarm state (fire-and-forget). */ function syncAlarmToServer(id: string, enabled: boolean) { - const sourceId = notifySourceId(); - if (sourceId) { - setSessionAlarm(url, id, enabled, sourceId); - return; - } - const dir = directory || base64Decode(params.dir); - resolveTelegramSourceId(url, dir).then((resolved) => { - if (resolved) { - setNotifySourceId(resolved); - setSessionAlarm(url, id, enabled, resolved); - return; + const dir = directory || base64Decode(params.dir) + console.log("[session] syncing telegram session alarm", { sessionId: id, enabled, directory: dir }) + setSessionAlarm(url, id, enabled, undefined, dir).then((ok) => { + if (ok) { + console.log("[session] telegram session alarm synced", { sessionId: id, enabled, directory: dir }) + return } - console.warn("[session] skip setSessionAlarm: telegram source id unresolved", { id, dir }); - }); + console.warn("[session] telegram session alarm sync failed", { sessionId: id, enabled, directory: dir }) + }) } function toggleNotify() { diff --git a/app-prefixable/src/pages/settings.tsx b/app-prefixable/src/pages/settings.tsx index 89552493..dc6f79ea 100644 --- a/app-prefixable/src/pages/settings.tsx +++ b/app-prefixable/src/pages/settings.tsx @@ -138,7 +138,6 @@ export function Settings() { async function toggleTelegramAlarmChannel() { if (telegramAlarmSaving()) return const current = alarmChannels().telegram - if (!telegramAlarmReady() && !current) return setTelegramAlarmSaving(true) const target = !current @@ -2458,7 +2457,7 @@ Add your project-specific instructions here.