diff options
| -rw-r--r-- | packages/api/src/agent-manager.ts | 37 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 156 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 27 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 20 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 222 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 22 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 382 | ||||
| -rw-r--r-- | plan-bg-restore.md | 1294 | ||||
| -rw-r--r-- | report.md | 53 |
9 files changed, 2161 insertions, 52 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index efdd732..92caf81 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -37,6 +37,7 @@ import { resolveApiKey, type SkillDefinition, type SystemChunkKind, + type TabStatusSnapshot, TaskList, updateMessage, validateConfig, @@ -711,10 +712,40 @@ export class AgentManager { return this.tabAgents.get(tabId)?.status ?? "idle"; } - getAllStatuses(): Record<string, AgentStatus> { - const result: Record<string, AgentStatus> = {}; + /** + * Snapshot of every tab the manager is currently tracking. Sent on WS + * connect and via GET /status so a freshly-loaded frontend can + * reconstruct any in-flight assistant turn without missing the chunks + * that arrived before its WS handshake completed. + * + * For each running tab, the snapshot includes: + * - status: "running" + * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks` + * (the live chunk array the streaming loop appends to). The + * consumer owns this copy and may mutate it freely. + * - currentAssistantId: the DB id of the in-flight assistant message + * row. The frontend aligns its local assistant message id with + * this so the next `done` event lands on the right message. + * + * For idle/error tabs, only `status` is present. Tabs not in + * `this.tabAgents` (e.g. tabs in the DB that have never been touched + * since server start) are absent from the returned record — the + * caller infers their status from the DB row (always "idle" at rest). + */ + getAllStatuses(): Record<string, TabStatusSnapshot> { + const result: Record<string, TabStatusSnapshot> = {}; for (const [tabId, tabAgent] of this.tabAgents.entries()) { - result[tabId] = tabAgent.status; + const snap: TabStatusSnapshot = { status: tabAgent.status }; + if (tabAgent.status === "running") { + if (tabAgent.currentChunks) { + // Defensive shallow copy: callers may serialize/mutate. + snap.currentChunks = [...tabAgent.currentChunks]; + } + if (tabAgent.currentAssistantId) { + snap.currentAssistantId = tabAgent.currentAssistantId; + } + } + result[tabId] = snap; } return result; } diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 71d43d8..ba14cad 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -658,4 +658,160 @@ describe("AgentManager", () => { expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] }); expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] }); }); + + // ─── getAllStatuses snapshot shape (for browser-reopen restore) ──── + // + // The snapshot enriches the legacy `Record<string, AgentStatus>` shape + // with per-tab in-flight context so a fresh frontend can render the + // streaming assistant message correctly after a reload. + + it("getAllStatuses returns an empty record when no tabs are tracked", () => { + const manager = new AgentManager(); + expect(manager.getAllStatuses()).toEqual({}); + }); + + it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => { + const manager = new AgentManager(); + // Drive a full turn so the tab gets registered; default mock run + // settles back to idle by the time `await` resolves. + await manager.processMessage("tab-idle", "hi"); + const snap = manager.getAllStatuses(); + expect(snap["tab-idle"]).toBeDefined(); + expect(snap["tab-idle"]?.status).toBe("idle"); + expect(snap["tab-idle"]).not.toHaveProperty("currentChunks"); + expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId"); + }); + + it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => { + const manager = new AgentManager(); + // Reach into the private map to set up a synthetic running state. + // Justification: there is no public API to enter a sustained + // "running" state without actually streaming, and we want to + // assert the snapshot shape — not the streaming pipeline. + const inner = manager as unknown as { + tabAgents: Map< + string, + { + agent: null; + status: "running" | "idle" | "error"; + keyId: null; + modelId: null; + taskList: { onChange: (cb: unknown) => void }; + messageQueue: unknown[]; + queueListeners: unknown[]; + shellStore: unknown; + transcriptStore: unknown; + currentChunks: Array<{ type: string; text?: string }> | null; + currentAssistantId: string | null; + } + >; + }; + inner.tabAgents.set("tab-running", { + agent: null, + status: "running", + keyId: null, + modelId: null, + taskList: { onChange: () => {} }, + messageQueue: [], + queueListeners: [], + shellStore: {}, + transcriptStore: {}, + currentChunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "partial answer" }, + ], + currentAssistantId: "assistant-msg-id-7", + }); + + const snap = manager.getAllStatuses(); + expect(snap["tab-running"]).toBeDefined(); + expect(snap["tab-running"]?.status).toBe("running"); + expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7"); + expect(snap["tab-running"]?.currentChunks).toEqual([ + { type: "thinking", text: "let me think" }, + { type: "text", text: "partial answer" }, + ]); + }); + + it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => { + const manager = new AgentManager(); + const inner = manager as unknown as { + tabAgents: Map< + string, + { + agent: null; + status: "running"; + keyId: null; + modelId: null; + taskList: { onChange: (cb: unknown) => void }; + messageQueue: unknown[]; + queueListeners: unknown[]; + shellStore: unknown; + transcriptStore: unknown; + currentChunks: Array<{ type: string; text?: string }>; + currentAssistantId: string; + } + >; + }; + const liveChunks = [{ type: "text", text: "live" }]; + inner.tabAgents.set("tab-copy", { + agent: null, + status: "running", + keyId: null, + modelId: null, + taskList: { onChange: () => {} }, + messageQueue: [], + queueListeners: [], + shellStore: {}, + transcriptStore: {}, + currentChunks: liveChunks, + currentAssistantId: "msg-x", + }); + + const snap = manager.getAllStatuses(); + // Mutate the snapshot's array + snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" }); + // Live array must be untouched + expect(liveChunks).toEqual([{ type: "text", text: "live" }]); + }); + + it("getAllStatuses omits currentChunks when a running tab has none yet", () => { + const manager = new AgentManager(); + const inner = manager as unknown as { + tabAgents: Map< + string, + { + agent: null; + status: "running"; + keyId: null; + modelId: null; + taskList: { onChange: (cb: unknown) => void }; + messageQueue: unknown[]; + queueListeners: unknown[]; + shellStore: unknown; + transcriptStore: unknown; + currentChunks: null; + currentAssistantId: null; + } + >; + }; + inner.tabAgents.set("tab-early", { + agent: null, + status: "running", + keyId: null, + modelId: null, + taskList: { onChange: () => {} }, + messageQueue: [], + queueListeners: [], + shellStore: {}, + transcriptStore: {}, + currentChunks: null, + currentAssistantId: null, + }); + + const snap = manager.getAllStatuses(); + expect(snap["tab-early"]?.status).toBe("running"); + expect(snap["tab-early"]).not.toHaveProperty("currentChunks"); + expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId"); + }); }); diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index e59eb68..0df56ca 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -95,6 +95,33 @@ export interface ToolResult { export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key"; +/** + * Per-tab snapshot of live state, sent on WS connect and via + * `GET /status`. Carries enough information for a freshly-loaded + * frontend to reconstruct any in-flight assistant message. + * + * - `status` — always present; mirrors the in-memory `TabAgent.status`. + * - `currentChunks` — the live in-flight `Chunk[]` for the running + * assistant turn. Present iff `status === "running"` AND + * `TabAgent.currentChunks` is non-null. Defensively copied at + * snapshot time; the consumer owns the array. + * - `currentAssistantId` — DB id of the in-flight assistant message + * (the row that the eventual `flushAssistant` call will write/update). + * Present iff `status === "running"` AND `TabAgent.currentAssistantId` + * is set. The frontend uses this to align its local assistant + * message id with the persisted id so subsequent `done` and reload + * paths line up. + * + * Not part of `AgentEvent` itself: the `statuses` payload is a WS- + * connect-level snapshot, not an event the `Agent` emits. The frontend + * mirrors this type in `packages/frontend/src/lib/types.ts`. + */ +export interface TabStatusSnapshot { + status: AgentStatus; + currentChunks?: Chunk[]; + currentAssistantId?: string; +} + export type AgentEvent = | { type: "status"; status: AgentStatus } | { type: "text-delta"; delta: string } diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 591266c..49db4ac 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -82,16 +82,24 @@ onMount(() => { document.documentElement.setAttribute("data-theme", saved); } - // Connect WebSocket + // Connect WebSocket in parallel with hydration. The `statuses` + // snapshot delivered on WS open is idempotent against + // already-hydrated tabs (the handler reconciles per-tab). wsClient.connect(); - // Initial models fetch + // Initial models fetch (fire-and-forget; UI tolerates models + // arriving later than tabs). fetchModels(); - // Create initial tab - if (tabStore.tabs.length === 0) { - tabStore.createNewTab(); - } + // Restore tabs from the backend. The user's previous session is + // the source of truth; only fall back to a fresh tab if nothing + // was restored (first-ever load, or DB was wiped, or HTTP failed). + void (async () => { + const restored = await tabStore.hydrateFromBackend(); + if (restored === 0 && tabStore.tabs.length === 0) { + await tabStore.createNewTab(); + } + })(); return () => { wsClient.disconnect(); diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 1d60e0b..326d288 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -18,6 +18,7 @@ import type { LogEntry, PermissionPrompt, QueuedMessage, + TabStatusSnapshot, TaskItem, } from "./types.js"; import { wsClient } from "./ws.svelte.js"; @@ -399,6 +400,167 @@ export function createTabStore() { } } + /** + * Hydrate the tab store from the backend on app mount. Restores the + * full list of open tabs (every row with `is_open = 1` in the DB), + * loads each tab's persisted message history, and seeds the in-flight + * assistant message for any tab the backend is currently streaming. + * + * Wire calls: + * - GET /tabs → list of open tabs in `position` order + * - GET /tabs/:id/messages → persisted ChatMessage[] for each + * - GET /status → in-flight TabStatusSnapshot map + * + * Failure modes (all log + continue with whatever was successfully + * hydrated; callers fall back to creating a fresh tab if the final + * `tabs` array is empty): + * - /tabs request fails → no tabs restored + * - /tabs/:id/messages fails → that tab restored with empty messages + * - /status fails → tabs restored, in-flight streaming will be + * lost (will surface as a static "running" status until the next + * event arrives); harmless because the WS will broadcast `statuses` + * on reconnect anyway. + * + * Returns the number of tabs hydrated (0 on total failure, ≥1 on + * partial or full success). Caller uses this to decide whether to + * create a fresh tab. + * + * Idempotency: if `tabs.length > 0` when called, returns 0 without + * touching state — the caller already has tabs from elsewhere (e.g. + * a hot-reload that preserved Svelte state). + */ + async function hydrateFromBackend(): Promise<number> { + if (tabs.length > 0) return 0; + + // 1. Fetch the list of open tabs from the DB. + let tabRows: Array<{ + id: string; + title: string; + keyId?: string | null; + modelId?: string | null; + parentTabId?: string | null; + }> = []; + try { + const res = await fetch(`${config.apiBase}/tabs`); + if (!res.ok) return 0; + const data = (await res.json()) as { tabs?: typeof tabRows }; + tabRows = Array.isArray(data.tabs) ? data.tabs : []; + } catch { + return 0; + } + + if (tabRows.length === 0) return 0; + + // 2. Fetch the in-flight snapshot. Failure is non-fatal. + let statusMap: Record<string, TabStatusSnapshot> = {}; + try { + const res = await fetch(`${config.apiBase}/status`); + if (res.ok) { + const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> }; + if (data.statuses && typeof data.statuses === "object") { + statusMap = data.statuses; + } + } + } catch { + // Non-fatal: tabs still restore with idle status. + } + + // 3. For each tab, fetch its persisted messages in parallel. + const messageFetches = tabRows.map(async (row) => { + try { + const res = await fetch(`${config.apiBase}/tabs/${row.id}/messages`); + if (!res.ok) return { id: row.id, messages: [] as ChatMessage[] }; + const data = (await res.json()) as { + messages?: Array<{ id?: string; role: string; chunks?: Chunk[] }>; + }; + const messages: ChatMessage[] = (data.messages ?? []).map((m) => ({ + id: m.id ?? generateId(), + role: m.role as ChatMessage["role"], + chunks: Array.isArray(m.chunks) ? m.chunks : [], + isStreaming: false, + })); + return { id: row.id, messages }; + } catch { + return { id: row.id, messages: [] as ChatMessage[] }; + } + }); + + const messagesByTab = new Map<string, ChatMessage[]>(); + for (const result of await Promise.all(messageFetches)) { + messagesByTab.set(result.id, result.messages); + } + + // 4. Build the Tab objects, splicing in the in-flight snapshot for + // running tabs. + const restored: Tab[] = tabRows.map((row) => { + const snap = statusMap[row.id]; + const messages = messagesByTab.get(row.id) ?? []; + const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle"; + + let currentAssistantId: string | null = null; + let finalMessages = messages; + + if (agentStatus === "running" && snap?.currentAssistantId) { + currentAssistantId = snap.currentAssistantId; + // Find or create the in-flight assistant message. If the DB + // already has a row with this id (the backend appended on + // first flush and we picked it up via /tabs/:id/messages), + // merge the snapshot chunks on top — the snapshot is the + // live source of truth and may have chunks the DB doesn't. + // If there's no matching row, append a new in-flight + // assistant message holding only the snapshot chunks. + const existingIdx = finalMessages.findIndex((m) => m.id === snap.currentAssistantId); + if (existingIdx >= 0) { + finalMessages = finalMessages.map((m, i) => + i === existingIdx + ? { + ...m, + chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks, + isStreaming: true, + } + : m, + ); + } else { + finalMessages = [ + ...finalMessages, + { + id: snap.currentAssistantId, + role: "assistant", + chunks: snap.currentChunks ? [...snap.currentChunks] : [], + isStreaming: true, + }, + ]; + } + } + + return { + id: row.id, + title: row.title, + messages: finalMessages, + agentStatus, + keyId: row.keyId ?? null, + modelId: row.modelId ?? null, + reasoningEffort: "max", + currentAssistantId, + tasks: [], + injectedSkills: [], + parentTabId: row.parentTabId ?? null, + persistent: true, + agentSlug: null, + agentScope: null, + agentModels: null, + workingDirectory: null, + queuedMessages: [], + }; + }); + + tabs = restored; + // Activate the first restored tab (the list is already ordered by + // `position` from the backend). + activeTabId = restored[0]?.id ?? null; + return restored.length; + } + function handleEvent(event: AgentEvent & { tabId?: string }): void { const tabId = event.tabId; @@ -417,25 +579,62 @@ export function createTabStore() { break; } case "statuses": { - // WS (re)connect snapshot. For any tab where the frontend thought - // we were running but the backend says otherwise, we missed the - // finishing events while disconnected — pull the persisted - // chunks from the API to recover. Also sync agentStatus and - // clear in-flight pointers on every desyncing tab. + // WS (re)connect snapshot. The shape was widened to + // TabStatusSnapshot (status + optional currentChunks + + // optional currentAssistantId) so the frontend can seed + // in-flight assistant messages on browser reopen. const backend = event.statuses; for (const t of tabs) { - const backendStatus = backend[t.id] ?? "idle"; + const snap = backend[t.id]; + const backendStatus = snap?.status ?? "idle"; + + // Desync case: frontend thought it was streaming, backend + // has already moved on. Pull the persisted chunks so the + // final answer shows up. if (t.agentStatus === "running" && backendStatus !== "running") { void reloadTabMessagesFromApi(t.id); } + + // Status alignment. if (t.agentStatus !== backendStatus) { updateTab(t.id, { agentStatus: backendStatus }); } - if (backendStatus !== "running" && t.currentAssistantId) { - // Mark any in-flight assistant message as no-longer-streaming; - // `reloadTabMessagesFromApi` (if it ran) will replace the - // whole messages array, but if no reload was triggered we - // still want streaming flags cleared. + + if (backendStatus === "running") { + // Seed the in-flight assistant message from the snapshot. + // This handles the "browser just reopened mid-stream" + // path: the DB only has chunks up to the last + // flushAssistant call, but the snapshot has the live + // in-memory currentChunks. + if (snap?.currentAssistantId) { + const targetId = snap.currentAssistantId; + updateTab(t.id, { currentAssistantId: targetId }); + updateMessages(t.id, (msgs) => { + const idx = msgs.findIndex((m) => m.id === targetId); + if (idx >= 0) { + return msgs.map((m, i) => + i === idx + ? { + ...m, + chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks, + isStreaming: true, + } + : m, + ); + } + return [ + ...msgs, + { + id: targetId, + role: "assistant", + chunks: snap.currentChunks ? [...snap.currentChunks] : [], + isStreaming: true, + }, + ]; + }); + } + } else if (t.currentAssistantId) { + // Not running: clear streaming flags. updateMessages(t.id, (msgs) => msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)), ); @@ -1303,6 +1502,7 @@ export function createTabStore() { // WS callback uses in production. Not intended for use in // components — they should rely on the WS subscription instead. handleEvent, + hydrateFromBackend, }; } diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 6051810..22837a0 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -76,13 +76,33 @@ export interface ChatMessage { export type ConnectionStatus = "connecting" | "connected" | "disconnected"; +/** + * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts). + * + * Sent on every WS (re)connect and via `GET /status`. The frontend uses + * this to: + * - reconcile its in-memory `agentStatus` with the backend's truth + * after a disconnect window; + * - reconstruct the in-flight assistant message for any tab the + * backend is currently streaming, so the user sees the partial + * thinking / text without waiting for the next delta. + * + * Wire-format symmetry MUST be kept with core. If you change one, + * change the other. + */ +export interface TabStatusSnapshot { + status: "idle" | "running" | "error"; + currentChunks?: Chunk[]; + currentAssistantId?: string; +} + export type AgentEvent = | { type: "status"; status: "idle" | "running" | "error" } // Sent on every WS (re)connect: a snapshot of every tab the backend is // currently tracking and its live status. The frontend uses this to // detect desync after a reconnect (e.g. bun --watch restart killed the // in-flight agent state, frontend missed `done` / `status:idle` events). - | { type: "statuses"; statuses: Record<string, "idle" | "running" | "error"> } + | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> } | { type: "text-delta"; delta: string } | { type: "reasoning-delta"; delta: string } | { type: "reasoning-end"; metadata?: Record<string, unknown> } diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 47a6c97..41adb84 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -449,8 +449,8 @@ describe("tabStore — reactivity contract", () => { // hiccup, etc.) store.handleEvent({ type: "statuses", - statuses: { [tabId]: "idle" }, - } as Parameters<typeof store.handleEvent>[0]); + statuses: { [tabId]: { status: "idle" } }, + }); expect(store.tabs[0]?.agentStatus).toBe("idle"); expect(store.tabs[0]?.currentAssistantId).toBeNull(); @@ -672,3 +672,381 @@ describe("shell output parsing helper", () => { expect(parseShellResult(JSON.stringify(42))).toBeNull(); }); }); + +// ─── hydrateFromBackend ───────────────────────────────────────── +// +// Verifies the browser-reopen restore path: GET /tabs + GET /status + +// GET /tabs/:id/messages combined into the in-memory tab store with +// in-flight chunks seeded for any running tab. + +describe("hydrateFromBackend", () => { + it("restores tabs from /tabs with their persisted messages", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { id: "t1", title: "First", keyId: null, modelId: null, parentTabId: null }, + { id: "t2", title: "Second", keyId: "k", modelId: "m", parentTabId: null }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ statuses: {} }), + }); + } + if (url.endsWith("/tabs/t1/messages")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + messages: [ + { id: "m1", role: "user", chunks: [{ type: "text", text: "hello" }] }, + { + id: "m2", + role: "assistant", + chunks: [{ type: "text", text: "hi back" }], + }, + ], + }), + }); + } + if (url.endsWith("/tabs/t2/messages")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ messages: [] }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(2); + expect(store.tabs.length).toBe(2); + expect(store.tabs[0]?.id).toBe("t1"); + expect(store.tabs[0]?.messages.length).toBe(2); + expect(store.tabs[1]?.id).toBe("t2"); + expect(store.tabs[1]?.messages.length).toBe(0); + expect(store.activeTabId).toBe("t1"); + }); + + it("seeds the in-flight assistant message from /status for a running tab", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { id: "tr", title: "Running tab", keyId: null, modelId: null, parentTabId: null }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + statuses: { + tr: { + status: "running", + currentAssistantId: "live-msg-id", + currentChunks: [ + { type: "thinking", text: "still thinking" }, + { type: "text", text: "partial " }, + ], + }, + }, + }), + }); + } + if (url.endsWith("/tabs/tr/messages")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + messages: [{ id: "u1", role: "user", chunks: [{ type: "text", text: "go" }] }], + }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(1); + const tab = store.tabs[0]; + expect(tab?.agentStatus).toBe("running"); + expect(tab?.currentAssistantId).toBe("live-msg-id"); + // Two messages: the user message + the seeded in-flight assistant. + expect(tab?.messages.length).toBe(2); + const inflight = tab?.messages.find((m) => m.id === "live-msg-id"); + expect(inflight).toBeDefined(); + expect(inflight?.isStreaming).toBe(true); + expect(inflight?.chunks).toEqual([ + { type: "thinking", text: "still thinking" }, + { type: "text", text: "partial " }, + ]); + }); + + it("returns 0 and leaves tabs empty when /tabs fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(0); + expect(store.tabs.length).toBe(0); + }); + + it("returns 0 and leaves tabs empty when /tabs returns an empty array", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ tabs: [] }) }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(0); + expect(store.tabs.length).toBe(0); + }); + + it("is a no-op when the store already has tabs (idempotency)", async () => { + const store = createTabStore(); + // Pretend the store already has a tab (e.g. from a hot-reload). + // We do this by reaching into the store via the public API. + // Use the create path with mocked fetch failure (existing + // `createNewTab` already tolerates fetch failure — adds locally). + // (beforeEach already stubs fetch to reject, so createNewTab will + // proceed past the failed POST and add the tab locally.) + await store.createNewTab(); + expect(store.tabs.length).toBe(1); + + // Now swap to a fetch that would lie about there being 3 tabs; + // hydrateFromBackend must NOT call it. We use a fresh mock that + // rejects to catch any stray background async calls too. + let hydrateCallCount = 0; + const sentinelFetch = vi.fn((url: string) => { + // Allow background auto-agent/skill fetches that fire from + // createNewTab's void async closure (autoSelectDefaultAgent, + // autoCheckDefaultSkills) — they use /agents and /skills paths, + // not /tabs. Reject them so they don't interfere. + if (url.includes("/agents") || url.includes("/skills")) { + return Promise.reject(new Error("test: background fetch ignored")); + } + // Any /tabs call would mean hydrateFromBackend ran — count it. + hydrateCallCount++; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tabs: [{ id: "x" }, { id: "y" }, { id: "z" }] }), + }); + }); + vi.stubGlobal("fetch", sentinelFetch); + const n = await store.hydrateFromBackend(); + expect(n).toBe(0); + expect(store.tabs.length).toBe(1); + expect(hydrateCallCount).toBe(0); + }); + + it("restores a tab with an idle status when /status omits it", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [{ id: "ti", title: "Idle", keyId: null, modelId: null, parentTabId: null }], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.endsWith("/tabs/ti/messages")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(1); + expect(store.tabs[0]?.agentStatus).toBe("idle"); + expect(store.tabs[0]?.currentAssistantId).toBeNull(); + }); + + it("restores a tab with empty messages when /tabs/:id/messages fails (per-tab failure isolation)", async () => { + // The hydrateFromBackend implementation wraps each per-tab + // messages fetch in a try/catch so one tab's failure can't + // destroy the whole restore pass. This test covers BOTH failure + // modes the try/catch protects against: + // - response.ok === false (HTTP error like 500) + // - the fetch rejects (network error) + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { + id: "tA", + title: "Healthy", + keyId: null, + modelId: null, + parentTabId: null, + }, + { + id: "tB", + title: "Broken (HTTP 500)", + keyId: null, + modelId: null, + parentTabId: null, + }, + { + id: "tC", + title: "Broken (network)", + keyId: null, + modelId: null, + parentTabId: null, + }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.endsWith("/tabs/tA/messages")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + messages: [{ id: "msg-a", role: "user", chunks: [{ type: "text", text: "ok" }] }], + }), + }); + } + if (url.endsWith("/tabs/tB/messages")) { + // HTTP error path: response is not ok. + return Promise.resolve({ ok: false, json: () => Promise.resolve({}) }); + } + if (url.endsWith("/tabs/tC/messages")) { + // Network error path: the fetch itself rejects. + return Promise.reject(new Error("simulated network failure")); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(3); + + // Healthy tab restored with its message. + const tA = store.tabs.find((t) => t.id === "tA"); + expect(tA?.messages.length).toBe(1); + expect(tA?.messages[0]?.chunks).toEqual([{ type: "text", text: "ok" }]); + + // Both broken tabs restored with empty message lists — neither + // crashed the hydration nor leaked an error chunk into the UI. + const tB = store.tabs.find((t) => t.id === "tB"); + expect(tB).toBeDefined(); + expect(tB?.messages.length).toBe(0); + expect(tB?.agentStatus).toBe("idle"); + + const tC = store.tabs.find((t) => t.id === "tC"); + expect(tC).toBeDefined(); + expect(tC?.messages.length).toBe(0); + expect(tC?.agentStatus).toBe("idle"); + }); +}); + +// ─── statuses WS event with the wider TabStatusSnapshot shape ─── +// +// The handler must reconcile snapshot.status against the local tab, +// and (when running) seed currentChunks into the in-flight assistant +// message. + +describe("handleEvent statuses with TabStatusSnapshot", () => { + it("seeds the in-flight assistant message when a running snapshot arrives", async () => { + const store = createTabStore(); + // Manually add a tab to the store via the existing createNewTab path + // (fetch was mocked to reject in beforeEach; createNewTab tolerates). + // We then drive a statuses event. + await store.createNewTab(); + const tabId = store.tabs[0]?.id; + if (!tabId) throw new Error("test fixture: tab id missing"); + + store.handleEvent({ + type: "statuses", + statuses: { + [tabId]: { + status: "running", + currentAssistantId: "live-x", + currentChunks: [{ type: "text", text: "live data" }], + }, + }, + }); + + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab?.agentStatus).toBe("running"); + expect(tab?.currentAssistantId).toBe("live-x"); + const inflight = tab?.messages.find((m) => m.id === "live-x"); + expect(inflight).toBeDefined(); + expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]); + expect(inflight?.isStreaming).toBe(true); + }); + + it("clears in-flight pointers when snapshot says the tab is idle", async () => { + const store = createTabStore(); + await store.createNewTab(); + const tabId = store.tabs[0]?.id; + if (!tabId) throw new Error("test fixture: tab id missing"); + + // First put the tab into a running state with an in-flight message. + store.handleEvent({ + type: "statuses", + statuses: { + [tabId]: { + status: "running", + currentAssistantId: "msg-a", + currentChunks: [{ type: "text", text: "x" }], + }, + }, + }); + expect(store.tabs.find((t) => t.id === tabId)?.currentAssistantId).toBe("msg-a"); + + // Now snapshot says idle. + store.handleEvent({ + type: "statuses", + statuses: { [tabId]: { status: "idle" } }, + }); + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab?.agentStatus).toBe("idle"); + expect(tab?.currentAssistantId).toBeNull(); + const msgA = tab?.messages.find((m) => m.id === "msg-a"); + expect(msgA?.isStreaming).toBe(false); + }); +}); diff --git a/plan-bg-restore.md b/plan-bg-restore.md new file mode 100644 index 0000000..e622669 --- /dev/null +++ b/plan-bg-restore.md @@ -0,0 +1,1294 @@ +# Plan: Background-running agents + layout restore on browser reopen + +> **Audience**: this plan is consumed by two flash subagents running in parallel, +> plus a Gemini review pass. Flash agents are weak and cheap — every code shape, +> import path, function signature, expected behavior, and test assertion is +> spelled out below. Do NOT improvise. Do NOT rename anything. Do NOT touch +> files outside your segment's "Files owned" list. + +--- + +## 1. Spec (the goal) + +Three user-visible behaviors: + +1. **Browser-close keeps agents alive.** If the user closes the browser + window / reloads the page / loses the network, any running agent + continues processing on the backend. No cancellation is triggered. + +2. **Layout restore on browser reopen.** When the page next loads, every + tab that existed at the time the window was closed is restored, in + the same order, with full message history. Tabs whose agents finished + while disconnected appear with the completed message. Tabs whose + agents are still running appear streaming live (the in-flight + assistant message is reconstructed from the backend's in-memory + `currentChunks` plus any new deltas). + +3. **Explicit tab-close cancels + forgets.** Clicking the X on a tab in + the sidebar still cancels the running agent (existing behavior) and + also prevents that tab from being restored next time (existing + behavior — `DELETE /tabs/:id` already archives the row by setting + `is_open = 0`). + +The only NEW work is in Behavior 2. Behaviors 1 and 3 already work on +the current `dev` branch; the implementation must preserve them. + +--- + +## 2. Current state (verified findings) + +The investigation report from explore agent (`task_id ses_19461dcf5ffe4r7wLyAji7Bn5b`) is the canonical +source. Key facts the implementation MUST respect: + +### 2.1 Backend +- The `tabs` table has columns `id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at` (`packages/core/src/db/index.ts:77-88`). +- `archiveTab(id)` sets `is_open = 0` — never hard-deletes. Used by `DELETE /tabs/:id`. (`packages/core/src/db/tabs.ts:118-124`). +- `listOpenTabs()` returns rows where `is_open = 1`, ordered by `position` (`packages/core/src/db/tabs.ts:80-86`). +- `agentManager.tabAgents` is the in-memory `Map<string, TabAgent>` where each TabAgent tracks `agent`, `status`, `currentChunks: Chunk[] | null`, `currentAssistantId: string | null`, `messageQueue: QueuedMessage[]` (`packages/api/src/agent-manager.ts:137-177`). +- `getAllStatuses()` currently returns `Record<string, AgentStatus>` — just the strings (`packages/api/src/agent-manager.ts:714-720`). +- `processMessage` is fire-and-forget: `app.ts:77-79` calls `.processMessage(...).catch(console.error)` and returns immediately. +- WS `onClose` does NOT stop agents — it only unsubscribes the per-client event listener (`packages/api/src/index.ts:58-66`). +- `flushAssistant` (the per-turn DB write) is only called at turn-end (`done`) or on system events — NOT on every delta. Mid-stream chunks live in memory only. + +### 2.2 Frontend +- `App.svelte:onMount` currently does: `wsClient.connect()` → `fetchModels()` → `if (tabStore.tabs.length === 0) tabStore.createNewTab()`. **It never reads existing backend tabs.** Every page load is a clean slate. +- Tab state lives only in Svelte `$state` — zero `localStorage` / `IndexedDB` persistence of tab ids. +- The existing `statuses` WS event handler (`tabs.svelte.ts:419-446`) reconciles status drift but only for tabs the frontend already has in `$state`. Tabs the frontend doesn't know about are silently ignored. + +### 2.3 The wire shapes (the flash agents MUST match these exactly) +- `GET /tabs` → `{ tabs: TabRow[] }` where each `TabRow = { id, title, keyId, modelId, parentTabId, status, isOpen, position, createdAt, updatedAt }`. **Note camelCase** — the DB function `listOpenTabs` translates snake_case to camelCase. +- `GET /tabs/:id/messages` → `{ messages: Array<{ id, role, chunks, ... }> }`. +- `GET /status` → `{ status, messageCount, statuses }`. The `statuses` field's shape is being changed in this plan. +- `DELETE /tabs/:id` → `{ success: true }`. Already cancels + archives. Unchanged. +- `POST /tabs` body `{ id?: string; title?: string }` → returns the new tab. Unchanged. + +--- + +## 3. Design + +### 3.1 Strategy + +The backend already runs agents independently of WS subscribers (Behavior 1 +works for free). The persistent record (DB) already survives across browser +sessions (Behavior 2's data is already on disk). The X-button already +cancels and archives (Behavior 3 works for free). + +So the entire feature reduces to: **on browser reopen, the frontend must +fetch the persisted tabs and rebuild the UI state, and for any tab that's +still streaming it must pick up the live event flow without losing the +chunks that were emitted before the WS handshake completed.** + +### 3.2 The mid-stream catch-up problem + +If a tab is `running` at the moment the new browser session connects, the +DB has chunks from the most recent `flushAssistant` call (turn-end or +system event), NOT the live in-memory `currentChunks` array. The frontend +needs that in-memory array to render the streaming assistant message +correctly. + +**Solution**: enrich the existing `statuses` snapshot (sent over both +`GET /status` HTTP and the WS `onOpen`) to include `currentChunks` and +`currentAssistantId` for every running tab. The frontend uses this +snapshot to seed the in-flight assistant message before live deltas +arrive. The race is safe because: + +1. JS is single-threaded — reading `currentChunks` and serializing it in + the WS `onOpen` handler is atomic with respect to other event-loop + ticks. +2. The frontend treats the snapshot as authoritative initial state for + the in-flight assistant message; subsequent live deltas append on top + via the existing `applyChunkEvent` path. + +### 3.3 What's NOT in scope + +- Per-delta DB flushing (not needed — snapshot covers in-flight state). +- Persisting queued messages across server restart (server restart kills + the in-memory agent anyway; queued messages were always best-effort). +- Subagent (`parentTabId != null`) ordering changes — they restore the + same way as user tabs; the existing `TabBar.svelte` already separates + parent vs child rows. +- LocalStorage cache of tabs (the backend DB is the source of truth). +- Migrating any DB schema (the existing schema already supports + everything we need via `is_open`). + +--- + +## 4. Phase plan + +``` +Phase 0 (sequential, I do it) + ├─ Add shared `TabStatusSnapshot` type in packages/core/src/types/index.ts + ├─ Re-export from packages/core/src/index.ts + └─ Verify core typecheck + biome + +Phase 1 (parallel, two flash agents) + ├─ Segment A: Backend — flash agent A + │ ├─ packages/api/src/agent-manager.ts + │ └─ packages/api/tests/agent-manager.test.ts + │ + └─ Segment B: Frontend — flash agent B + ├─ packages/frontend/src/lib/types.ts (mirror type) + ├─ packages/frontend/src/lib/tabs.svelte.ts (hydrateFromBackend + statuses handler update) + ├─ packages/frontend/src/App.svelte (onMount sequencing) + └─ packages/frontend/tests/chat-store.test.ts + +Phase 2 (sequential, I do it) + ├─ Run typecheck on all three packages + ├─ Run tests on all three packages + ├─ Run biome + └─ Sanity-check the integration manually + +Phase 3 (I do it) + ├─ Launch gemini subagent for read-only review (writes report.md only) + ├─ Do my own review in parallel + └─ WAIT for gemini to finish before applying any fixes + +Phase 4 (I do it, possibly spawning more flash agents) + └─ Apply fixes from gemini + self-review +``` + +--- + +## 5. Phase 0 — Shared type (main agent only) + +### 5.1 File: `packages/core/src/types/index.ts` + +Add the following ABOVE the existing `AgentEvent` discriminated union (i.e. +in the "Agent Status & Events" section, immediately after `AgentStatus` +type definition): + +```ts +/** + * Snapshot of a single tab's live state, sent on WS connect and via + * `GET /status`. Carries enough information for a freshly-loaded + * frontend to reconstruct any in-flight assistant message. + * + * - `status` — always present; mirrors the in-memory `TabAgent.status`. + * - `currentChunks` — the live in-flight `Chunk[]` for the running + * assistant turn. Present iff `status === "running"` AND + * `TabAgent.currentChunks` is non-null. Defensively copied at + * snapshot time; the consumer owns the array. + * - `currentAssistantId` — DB id of the in-flight assistant message + * (the row that the eventual `flushAssistant` call will write/update). + * Present iff `status === "running"` AND `TabAgent.currentAssistantId` + * is set. The frontend uses this to align its local assistant message + * id with the persisted id so subsequent `done` / reload paths line up. + */ +export interface TabStatusSnapshot { + status: AgentStatus; + currentChunks?: Chunk[]; + currentAssistantId?: string; +} +``` + +Then UPDATE the existing `statuses` variant of `AgentEvent` (currently around line 134, but verify with grep on `"statuses"` literal) to use the new shape: + +```ts +// before: +// | { type: "statuses"; statuses: Record<string, AgentStatus> } +// after: + | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> } +``` + +### 5.2 File: `packages/core/src/index.ts` + +If `TabStatusSnapshot` is not already re-exported via a `export * from "./types"` (it almost certainly already is — verify by reading the file), no change needed. Otherwise add it to the explicit type re-export list. + +### 5.3 Verification + +```sh +bun run --cwd packages/core typecheck +bun run --cwd packages/core test +bunx biome check packages/core +``` + +All three must be clean. If they're not, fix the type issue before +proceeding to Phase 1. + +--- + +## 6. Phase 1 — Parallel implementation + +> **CRITICAL FOR FLASH AGENTS**: read your segment in full BEFORE touching +> any file. Every file path, function signature, and code block is exact. +> Do not improvise. If something is ambiguous, leave a `TODO(plan-bg-restore):` +> comment instead of guessing. + +### 6.A SEGMENT A — Backend + +**Files owned (exclusive write access):** +- `packages/api/src/agent-manager.ts` +- `packages/api/tests/agent-manager.test.ts` + +**Files allowed to READ (do not modify):** +- `packages/core/src/types/index.ts` (already has `TabStatusSnapshot` from Phase 0) +- `packages/api/src/app.ts` (already calls `agentManager.getAllStatuses()` — its consumption pattern shows the contract) +- `packages/api/src/index.ts` (already calls `agentManager.getAllStatuses()` in the WS `onOpen` — same contract) + +#### Task A.1 — Add the snapshot import + +At the top of `packages/api/src/agent-manager.ts`, locate the existing import block from `@dispatch/core`. Add `TabStatusSnapshot` to that type-import list. Example: + +```ts +// Before (illustrative; merge with the existing import in place): +import type { AgentEvent, AgentStatus, Chunk, ChatMessage } from "@dispatch/core"; + +// After: +import type { AgentEvent, AgentStatus, Chunk, ChatMessage, TabStatusSnapshot } from "@dispatch/core"; +``` + +#### Task A.2 — Rewrite `getAllStatuses` + +Find the existing method at `packages/api/src/agent-manager.ts:714-720`. It currently reads: + +```ts +getAllStatuses(): Record<string, AgentStatus> { + const result: Record<string, AgentStatus> = {}; + for (const [tabId, tabAgent] of this.tabAgents.entries()) { + result[tabId] = tabAgent.status; + } + return result; +} +``` + +Replace with: + +```ts +/** + * Snapshot of every tab the manager is currently tracking. Sent on WS + * connect and via GET /status so a freshly-loaded frontend can + * reconstruct any in-flight assistant turn without missing the chunks + * that arrived before its WS handshake completed. + * + * For each running tab, the snapshot includes: + * - status: "running" + * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks` + * (the live chunk array the streaming loop appends to). The + * consumer owns this copy and may mutate it freely. + * - currentAssistantId: the DB id of the in-flight assistant message + * row. The frontend aligns its local assistant message id with + * this so the next `done` event lands on the right message. + * + * For idle/error tabs, only `status` is present. Tabs not in + * `this.tabAgents` (e.g. tabs in the DB that have never been touched + * since server start) are absent from the returned record — the + * caller infers their status from the DB row (always "idle" at rest). + */ +getAllStatuses(): Record<string, TabStatusSnapshot> { + const result: Record<string, TabStatusSnapshot> = {}; + for (const [tabId, tabAgent] of this.tabAgents.entries()) { + const snap: TabStatusSnapshot = { status: tabAgent.status }; + if (tabAgent.status === "running") { + if (tabAgent.currentChunks) { + // Defensive shallow copy: callers may serialize/mutate. + snap.currentChunks = [...tabAgent.currentChunks]; + } + if (tabAgent.currentAssistantId) { + snap.currentAssistantId = tabAgent.currentAssistantId; + } + } + result[tabId] = snap; + } + return result; +} +``` + +**DO NOT** add a separate `getTabSnapshot(tabId)` method. The single +`getAllStatuses` covers every consumer. + +#### Task A.3 — Tests + +In `packages/api/tests/agent-manager.test.ts`, add a new `describe` block at the end of the existing top-level `describe("AgentManager", ...)`. Place it as the LAST sub-section, AFTER the existing "done event includes a thinking chunk with metadata in its message" test and AFTER the "History pre-population on Agent (re)construction" tests already in place. Use this exact code: + +```ts + // ─── getAllStatuses snapshot shape (for browser-reopen restore) ──── + // + // The snapshot enriches the legacy `Record<string, AgentStatus>` shape + // with per-tab in-flight context so a fresh frontend can render the + // streaming assistant message correctly after a reload. + + it("getAllStatuses returns an empty record when no tabs are tracked", () => { + const manager = new AgentManager(); + expect(manager.getAllStatuses()).toEqual({}); + }); + + it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => { + const manager = new AgentManager(); + // Drive a full turn so the tab gets registered; default mock run + // settles back to idle by the time `await` resolves. + await manager.processMessage("tab-idle", "hi"); + const snap = manager.getAllStatuses(); + expect(snap["tab-idle"]).toBeDefined(); + expect(snap["tab-idle"]?.status).toBe("idle"); + expect(snap["tab-idle"]).not.toHaveProperty("currentChunks"); + expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId"); + }); + + it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => { + const manager = new AgentManager(); + // Reach into the private map to set up a synthetic running state. + // Justification: there is no public API to enter a sustained + // "running" state without actually streaming, and we want to + // assert the snapshot shape — not the streaming pipeline. + const inner = manager as unknown as { + tabAgents: Map<string, { + agent: null; + status: "running" | "idle" | "error"; + keyId: null; + modelId: null; + taskList: { onChange: (cb: unknown) => void }; + messageQueue: unknown[]; + queueListeners: unknown[]; + shellStore: unknown; + transcriptStore: unknown; + currentChunks: Array<{ type: string; text?: string }> | null; + currentAssistantId: string | null; + }>; + }; + inner.tabAgents.set("tab-running", { + agent: null, + status: "running", + keyId: null, + modelId: null, + taskList: { onChange: () => {} }, + messageQueue: [], + queueListeners: [], + shellStore: {}, + transcriptStore: {}, + currentChunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "partial answer" }, + ], + currentAssistantId: "assistant-msg-id-7", + }); + + const snap = manager.getAllStatuses(); + expect(snap["tab-running"]).toBeDefined(); + expect(snap["tab-running"]?.status).toBe("running"); + expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7"); + expect(snap["tab-running"]?.currentChunks).toEqual([ + { type: "thinking", text: "let me think" }, + { type: "text", text: "partial answer" }, + ]); + }); + + it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => { + const manager = new AgentManager(); + const inner = manager as unknown as { + tabAgents: Map<string, { + agent: null; + status: "running"; + keyId: null; + modelId: null; + taskList: { onChange: (cb: unknown) => void }; + messageQueue: unknown[]; + queueListeners: unknown[]; + shellStore: unknown; + transcriptStore: unknown; + currentChunks: Array<{ type: string; text?: string }>; + currentAssistantId: string; + }>; + }; + const liveChunks = [{ type: "text", text: "live" }]; + inner.tabAgents.set("tab-copy", { + agent: null, + status: "running", + keyId: null, + modelId: null, + taskList: { onChange: () => {} }, + messageQueue: [], + queueListeners: [], + shellStore: {}, + transcriptStore: {}, + currentChunks: liveChunks, + currentAssistantId: "msg-x", + }); + + const snap = manager.getAllStatuses(); + // Mutate the snapshot's array + snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" }); + // Live array must be untouched + expect(liveChunks).toEqual([{ type: "text", text: "live" }]); + }); + + it("getAllStatuses omits currentChunks when a running tab has none yet", () => { + const manager = new AgentManager(); + const inner = manager as unknown as { + tabAgents: Map<string, { + agent: null; + status: "running"; + keyId: null; + modelId: null; + taskList: { onChange: (cb: unknown) => void }; + messageQueue: unknown[]; + queueListeners: unknown[]; + shellStore: unknown; + transcriptStore: unknown; + currentChunks: null; + currentAssistantId: null; + }>; + }; + inner.tabAgents.set("tab-early", { + agent: null, + status: "running", + keyId: null, + modelId: null, + taskList: { onChange: () => {} }, + messageQueue: [], + queueListeners: [], + shellStore: {}, + transcriptStore: {}, + currentChunks: null, + currentAssistantId: null, + }); + + const snap = manager.getAllStatuses(); + expect(snap["tab-early"]?.status).toBe("running"); + expect(snap["tab-early"]).not.toHaveProperty("currentChunks"); + expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId"); + }); +``` + +#### Task A.4 — Verify + +```sh +bun run --cwd packages/api typecheck # must pass +bun run --cwd packages/api test # all tests must pass (existing + new) +bunx biome check packages/api # must be clean (no errors) +``` + +If biome reports formatting issues, run `bunx biome check --write packages/api` and re-verify. + +#### Task A.5 — What NOT to do + +- **DO NOT** modify `packages/api/src/index.ts`. The WS `onOpen` already calls `agentManager.getAllStatuses()` — the wire payload changes automatically when the return type changes. +- **DO NOT** modify `packages/api/src/app.ts`. The `GET /status` route already calls `agentManager.getAllStatuses()` — same automatic propagation. +- **DO NOT** modify the frontend. Segment B owns it. +- **DO NOT** change any existing test cases. Only add the new ones described above. +- **DO NOT** add or change the `getStatus()` (singular, deprecated) method. Leave it as-is. +- **DO NOT** add or change `getTabStatus(tabId)`. Leave it returning `AgentStatus`. + +--- + +### 6.B SEGMENT B — Frontend + +**Files owned (exclusive write access):** +- `packages/frontend/src/lib/types.ts` +- `packages/frontend/src/lib/tabs.svelte.ts` +- `packages/frontend/src/App.svelte` +- `packages/frontend/tests/chat-store.test.ts` + +**Files allowed to READ (do not modify):** +- `packages/core/src/types/index.ts` (already has `TabStatusSnapshot` from Phase 0; you'll mirror the type) +- `packages/frontend/src/lib/config.ts` (gives you `config.apiBase`) +- `packages/frontend/src/lib/ws.svelte.ts` (gives you `wsClient.connect()`) + +#### Task B.1 — Mirror the `TabStatusSnapshot` type in the frontend + +The frontend deliberately mirrors core's type shapes locally (see the existing `Chunk` and `AgentEvent` types in `packages/frontend/src/lib/types.ts:21-127`). Add the snapshot type next to those. + +In `packages/frontend/src/lib/types.ts`, find the existing `AgentEvent` discriminated union (currently at line 79). The `statuses` variant currently reads: + +```ts +| { type: "statuses"; statuses: Record<string, "idle" | "running" | "error"> } +``` + +Replace this single variant with: + +```ts +| { type: "statuses"; statuses: Record<string, TabStatusSnapshot> } +``` + +Then add the `TabStatusSnapshot` interface immediately before the `AgentEvent` union (i.e. between the existing `ConnectionStatus` type and the `AgentEvent` union): + +```ts +/** + * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts). + * + * Sent on every WS (re)connect and via `GET /status`. The frontend uses + * this to: + * - reconcile its in-memory `agentStatus` with the backend's truth + * after a disconnect window; + * - reconstruct the in-flight assistant message for any tab the + * backend is currently streaming, so the user sees the partial + * thinking / text without waiting for the next delta. + * + * Wire-format symmetry MUST be kept with core. If you change one, + * change the other. + */ +export interface TabStatusSnapshot { + status: "idle" | "running" | "error"; + currentChunks?: Chunk[]; + currentAssistantId?: string; +} +``` + +#### Task B.2 — Add `hydrateFromBackend()` to the tab store + +In `packages/frontend/src/lib/tabs.svelte.ts`, add a new function `hydrateFromBackend()` and export it from the store. Place the function definition adjacent to the existing `openAgentTab` and `reloadTabMessagesFromApi` (around line 172-400 of the file). + +The function should be defined inside the `createTabStore` factory (or wherever the existing exported functions like `createNewTab`, `closeTab` live). It should be exported by adding it to the returned object literal at the bottom of `createTabStore`. + +Exact behavior (see also Task B.3 and B.4 for the integration): + +```ts +/** + * Hydrate the tab store from the backend on app mount. Restores the + * full list of open tabs (every row with `is_open = 1` in the DB), + * loads each tab's persisted message history, and seeds the in-flight + * assistant message for any tab the backend is currently streaming. + * + * Wire calls: + * - GET /tabs → list of open tabs in `position` order + * - GET /tabs/:id/messages → persisted ChatMessage[] for each + * - GET /status → in-flight TabStatusSnapshot map + * + * Failure modes (all log + continue with whatever was successfully + * hydrated; callers fall back to creating a fresh tab if the final + * `tabs` array is empty): + * - /tabs request fails → no tabs restored + * - /tabs/:id/messages fails → that tab restored with empty messages + * - /status fails → tabs restored, in-flight streaming will be + * lost (will surface as a static "running" status until the next + * event arrives); harmless because the WS will broadcast `statuses` + * on reconnect anyway. + * + * Returns the number of tabs hydrated (0 on total failure, ≥1 on + * partial or full success). Caller uses this to decide whether to + * create a fresh tab. + * + * Idempotency: if `tabs.length > 0` when called, returns 0 without + * touching state — the caller already has tabs from elsewhere (e.g. + * a hot-reload that preserved Svelte state). + */ +async function hydrateFromBackend(): Promise<number> { + if (tabs.length > 0) return 0; + + // 1. Fetch the list of open tabs from the DB. + let tabRows: Array<{ + id: string; + title: string; + keyId?: string | null; + modelId?: string | null; + parentTabId?: string | null; + }> = []; + try { + const res = await fetch(`${config.apiBase}/tabs`); + if (!res.ok) return 0; + const data = (await res.json()) as { tabs?: typeof tabRows }; + tabRows = Array.isArray(data.tabs) ? data.tabs : []; + } catch { + return 0; + } + + if (tabRows.length === 0) return 0; + + // 2. Fetch the in-flight snapshot. Failure is non-fatal. + let statusMap: Record<string, TabStatusSnapshot> = {}; + try { + const res = await fetch(`${config.apiBase}/status`); + if (res.ok) { + const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> }; + if (data.statuses && typeof data.statuses === "object") { + statusMap = data.statuses; + } + } + } catch { + // Non-fatal: tabs still restore with idle status. + } + + // 3. For each tab, fetch its persisted messages in parallel. + const messageFetches = tabRows.map(async (row) => { + try { + const res = await fetch(`${config.apiBase}/tabs/${row.id}/messages`); + if (!res.ok) return { id: row.id, messages: [] as ChatMessage[] }; + const data = (await res.json()) as { + messages?: Array<{ id?: string; role: string; chunks?: Chunk[] }>; + }; + const messages: ChatMessage[] = (data.messages ?? []).map((m) => ({ + id: m.id ?? generateId(), + role: m.role as ChatMessage["role"], + chunks: Array.isArray(m.chunks) ? m.chunks : [], + isStreaming: false, + })); + return { id: row.id, messages }; + } catch { + return { id: row.id, messages: [] as ChatMessage[] }; + } + }); + + const messagesByTab = new Map<string, ChatMessage[]>(); + for (const result of await Promise.all(messageFetches)) { + messagesByTab.set(result.id, result.messages); + } + + // 4. Build the Tab objects, splicing in the in-flight snapshot for + // running tabs. + const restored: Tab[] = tabRows.map((row) => { + const snap = statusMap[row.id]; + const messages = messagesByTab.get(row.id) ?? []; + const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle"; + + let currentAssistantId: string | null = null; + let finalMessages = messages; + + if (agentStatus === "running" && snap?.currentAssistantId) { + currentAssistantId = snap.currentAssistantId; + // Find or create the in-flight assistant message. If the DB + // already has a row with this id (the backend appended on + // first flush and we picked it up via /tabs/:id/messages), + // merge the snapshot chunks on top — the snapshot is the + // live source of truth and may have chunks the DB doesn't. + // If there's no matching row, append a new in-flight + // assistant message holding only the snapshot chunks. + const existingIdx = finalMessages.findIndex((m) => m.id === snap.currentAssistantId); + if (existingIdx >= 0) { + finalMessages = finalMessages.map((m, i) => + i === existingIdx + ? { + ...m, + chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks, + isStreaming: true, + } + : m, + ); + } else { + finalMessages = [ + ...finalMessages, + { + id: snap.currentAssistantId, + role: "assistant", + chunks: snap.currentChunks ? [...snap.currentChunks] : [], + isStreaming: true, + }, + ]; + } + } + + return { + id: row.id, + title: row.title, + messages: finalMessages, + agentStatus, + keyId: row.keyId ?? null, + modelId: row.modelId ?? null, + reasoningEffort: "max", + currentAssistantId, + tasks: [], + injectedSkills: [], + parentTabId: row.parentTabId ?? null, + persistent: true, + agentSlug: null, + agentScope: null, + agentModels: null, + workingDirectory: null, + queuedMessages: [], + }; + }); + + tabs = restored; + // Activate the first restored tab (the list is already ordered by + // `position` from the backend). + activeTabId = restored[0]?.id ?? null; + return restored.length; +} +``` + +Then add `hydrateFromBackend` to the returned object at the bottom of `createTabStore` so it's accessible via `tabStore.hydrateFromBackend()`. Find the existing `return { ... }` block (it lists `createNewTab`, `switchTab`, `closeTab`, etc.) and add `hydrateFromBackend,` to it. **DO NOT** reorder existing keys. + +#### Task B.3 — Update the WS `statuses` handler + +In `packages/frontend/src/lib/tabs.svelte.ts`, find the existing `case "statuses":` block inside `handleEvent` (currently around line 419-446). It currently reads: + +```ts +case "statuses": { + const backend = event.statuses; + for (const t of tabs) { + const backendStatus = backend[t.id] ?? "idle"; + if (t.agentStatus === "running" && backendStatus !== "running") { + void reloadTabMessagesFromApi(t.id); + } + if (t.agentStatus !== backendStatus) { + updateTab(t.id, { agentStatus: backendStatus }); + } + if (backendStatus !== "running" && t.currentAssistantId) { + updateMessages(t.id, (msgs) => + msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)), + ); + updateTab(t.id, { currentAssistantId: null }); + } + } + break; +} +``` + +Replace this entire `case "statuses":` block with the following: + +```ts +case "statuses": { + // WS (re)connect snapshot. The shape was widened to + // TabStatusSnapshot (status + optional currentChunks + + // optional currentAssistantId) so the frontend can seed + // in-flight assistant messages on browser reopen. + const backend = event.statuses; + for (const t of tabs) { + const snap = backend[t.id]; + const backendStatus = snap?.status ?? "idle"; + + // Desync case: frontend thought it was streaming, backend + // has already moved on. Pull the persisted chunks so the + // final answer shows up. + if (t.agentStatus === "running" && backendStatus !== "running") { + void reloadTabMessagesFromApi(t.id); + } + + // Status alignment. + if (t.agentStatus !== backendStatus) { + updateTab(t.id, { agentStatus: backendStatus }); + } + + if (backendStatus === "running") { + // Seed the in-flight assistant message from the snapshot. + // This handles the "browser just reopened mid-stream" + // path: the DB only has chunks up to the last + // flushAssistant call, but the snapshot has the live + // in-memory currentChunks. + if (snap?.currentAssistantId) { + const targetId = snap.currentAssistantId; + updateTab(t.id, { currentAssistantId: targetId }); + updateMessages(t.id, (msgs) => { + const idx = msgs.findIndex((m) => m.id === targetId); + if (idx >= 0) { + return msgs.map((m, i) => + i === idx + ? { + ...m, + chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks, + isStreaming: true, + } + : m, + ); + } + return [ + ...msgs, + { + id: targetId, + role: "assistant", + chunks: snap.currentChunks ? [...snap.currentChunks] : [], + isStreaming: true, + }, + ]; + }); + } + } else if (t.currentAssistantId) { + // Not running: clear streaming flags. + updateMessages(t.id, (msgs) => + msgs.map((m) => + m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m, + ), + ); + updateTab(t.id, { currentAssistantId: null }); + } + } + break; +} +``` + +**DO NOT** modify any other case in the `handleEvent` switch. + +#### Task B.4 — Update `App.svelte` onMount to hydrate + +In `packages/frontend/src/App.svelte`, find the existing `onMount` (currently lines 78-99). It reads: + +```ts +onMount(() => { + // Apply saved theme + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + document.documentElement.setAttribute("data-theme", saved); + } + + // Connect WebSocket + wsClient.connect(); + + // Initial models fetch + fetchModels(); + + // Create initial tab + if (tabStore.tabs.length === 0) { + tabStore.createNewTab(); + } + + return () => { + wsClient.disconnect(); + }; +}); +``` + +Replace its body so it (1) hydrates tabs from the backend BEFORE deciding to create a fresh tab, and (2) connects the WS in parallel with hydration (since hydration uses HTTP, the WS connect can race ahead — the WS `statuses` handler is now idempotent for already-restored tabs): + +```ts +onMount(() => { + // Apply saved theme + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + document.documentElement.setAttribute("data-theme", saved); + } + + // Connect WebSocket in parallel with hydration. The `statuses` + // snapshot delivered on WS open is idempotent against + // already-hydrated tabs (the handler reconciles per-tab). + wsClient.connect(); + + // Initial models fetch (fire-and-forget; UI tolerates models + // arriving later than tabs). + fetchModels(); + + // Restore tabs from the backend. The user's previous session is + // the source of truth; only fall back to a fresh tab if nothing + // was restored (first-ever load, or DB was wiped, or HTTP failed). + void (async () => { + const restored = await tabStore.hydrateFromBackend(); + if (restored === 0 && tabStore.tabs.length === 0) { + await tabStore.createNewTab(); + } + })(); + + return () => { + wsClient.disconnect(); + }; +}); +``` + +**DO NOT** change the `<script>` imports unless the `void (async () => { ... })()` pattern triggers a lint warning — in which case wrap in a named async function expression instead. + +#### Task B.5 — Tests + +In `packages/frontend/tests/chat-store.test.ts`, append a new `describe` block at the very end of the file (after the existing tests' closing brace). + +The test fixture pattern in this file already mocks `fetch`. You will override the mock per-test using `vi.stubGlobal("fetch", ...)`. + +Add the following tests (with their imports merged into the existing top-of-file import block as needed): + +```ts +// ─── hydrateFromBackend ───────────────────────────────────────── +// +// Verifies the browser-reopen restore path: GET /tabs + GET /status + +// GET /tabs/:id/messages combined into the in-memory tab store with +// in-flight chunks seeded for any running tab. + +describe("hydrateFromBackend", () => { + it("restores tabs from /tabs with their persisted messages", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { id: "t1", title: "First", keyId: null, modelId: null, parentTabId: null }, + { id: "t2", title: "Second", keyId: "k", modelId: "m", parentTabId: null }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ statuses: {} }), + }); + } + if (url.endsWith("/tabs/t1/messages")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + messages: [ + { id: "m1", role: "user", chunks: [{ type: "text", text: "hello" }] }, + { + id: "m2", + role: "assistant", + chunks: [{ type: "text", text: "hi back" }], + }, + ], + }), + }); + } + if (url.endsWith("/tabs/t2/messages")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ messages: [] }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(2); + expect(store.tabs.length).toBe(2); + expect(store.tabs[0]?.id).toBe("t1"); + expect(store.tabs[0]?.messages.length).toBe(2); + expect(store.tabs[1]?.id).toBe("t2"); + expect(store.tabs[1]?.messages.length).toBe(0); + expect(store.activeTabId).toBe("t1"); + }); + + it("seeds the in-flight assistant message from /status for a running tab", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [ + { id: "tr", title: "Running tab", keyId: null, modelId: null, parentTabId: null }, + ], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + statuses: { + tr: { + status: "running", + currentAssistantId: "live-msg-id", + currentChunks: [ + { type: "thinking", text: "still thinking" }, + { type: "text", text: "partial " }, + ], + }, + }, + }), + }); + } + if (url.endsWith("/tabs/tr/messages")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + messages: [{ id: "u1", role: "user", chunks: [{ type: "text", text: "go" }] }], + }), + }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(1); + const tab = store.tabs[0]; + expect(tab?.agentStatus).toBe("running"); + expect(tab?.currentAssistantId).toBe("live-msg-id"); + // Two messages: the user message + the seeded in-flight assistant. + expect(tab?.messages.length).toBe(2); + const inflight = tab?.messages.find((m) => m.id === "live-msg-id"); + expect(inflight).toBeDefined(); + expect(inflight?.isStreaming).toBe(true); + expect(inflight?.chunks).toEqual([ + { type: "thinking", text: "still thinking" }, + { type: "text", text: "partial " }, + ]); + }); + + it("returns 0 and leaves tabs empty when /tabs fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(0); + expect(store.tabs.length).toBe(0); + }); + + it("returns 0 and leaves tabs empty when /tabs returns an empty array", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ tabs: [] }) }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(0); + expect(store.tabs.length).toBe(0); + }); + + it("is a no-op when the store already has tabs (idempotency)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("should not be called"))), + ); + const store = createTabStore(); + // Pretend the store already has a tab (e.g. from a hot-reload). + // We do this by reaching into the store via the public API. + // Use the create path with mocked fetch failure (existing + // `createNewTab` already tolerates fetch failure — adds locally). + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("test: createNewTab fetch reject"))), + ); + await store.createNewTab(); + expect(store.tabs.length).toBe(1); + + // Now swap to a fetch that would lie about there being 3 tabs; + // hydrateFromBackend must NOT call it. + const sentinelFetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tabs: [{ id: "x" }, { id: "y" }, { id: "z" }] }), + }), + ); + vi.stubGlobal("fetch", sentinelFetch); + const n = await store.hydrateFromBackend(); + expect(n).toBe(0); + expect(store.tabs.length).toBe(1); + expect(sentinelFetch).not.toHaveBeenCalled(); + }); + + it("restores a tab with an idle status when /status omits it", async () => { + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + if (url.endsWith("/tabs")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tabs: [{ id: "ti", title: "Idle", keyId: null, modelId: null, parentTabId: null }], + }), + }); + } + if (url.endsWith("/status")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); + } + if (url.endsWith("/tabs/ti/messages")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) }); + } + return Promise.reject(new Error(`unexpected fetch ${url}`)); + }), + ); + const store = createTabStore(); + const n = await store.hydrateFromBackend(); + expect(n).toBe(1); + expect(store.tabs[0]?.agentStatus).toBe("idle"); + expect(store.tabs[0]?.currentAssistantId).toBeNull(); + }); +}); + +// ─── statuses WS event with the wider TabStatusSnapshot shape ─── +// +// The handler must reconcile snapshot.status against the local tab, +// and (when running) seed currentChunks into the in-flight assistant +// message. + +describe("handleEvent statuses with TabStatusSnapshot", () => { + it("seeds the in-flight assistant message when a running snapshot arrives", () => { + const store = createTabStore(); + // Manually add a tab to the store via the existing createNewTab path + // (fetch was mocked to reject in beforeEach; createNewTab tolerates). + // We then drive a statuses event. + void store.createNewTab(); + const tabId = store.tabs[0]?.id; + if (!tabId) throw new Error("test fixture: tab id missing"); + + store.handleEvent({ + type: "statuses", + statuses: { + [tabId]: { + status: "running", + currentAssistantId: "live-x", + currentChunks: [{ type: "text", text: "live data" }], + }, + }, + }); + + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab?.agentStatus).toBe("running"); + expect(tab?.currentAssistantId).toBe("live-x"); + const inflight = tab?.messages.find((m) => m.id === "live-x"); + expect(inflight).toBeDefined(); + expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]); + expect(inflight?.isStreaming).toBe(true); + }); + + it("clears in-flight pointers when snapshot says the tab is idle", () => { + const store = createTabStore(); + void store.createNewTab(); + const tabId = store.tabs[0]?.id; + if (!tabId) throw new Error("test fixture: tab id missing"); + + // First put the tab into a running state with an in-flight message. + store.handleEvent({ + type: "statuses", + statuses: { + [tabId]: { + status: "running", + currentAssistantId: "msg-a", + currentChunks: [{ type: "text", text: "x" }], + }, + }, + }); + expect(store.tabs.find((t) => t.id === tabId)?.currentAssistantId).toBe("msg-a"); + + // Now snapshot says idle. + store.handleEvent({ + type: "statuses", + statuses: { [tabId]: { status: "idle" } }, + }); + const tab = store.tabs.find((t) => t.id === tabId); + expect(tab?.agentStatus).toBe("idle"); + expect(tab?.currentAssistantId).toBeNull(); + const msgA = tab?.messages.find((m) => m.id === "msg-a"); + expect(msgA?.isStreaming).toBe(false); + }); +}); +``` + +You may need to import `createTabStore` and the `TabStatusSnapshot` type — check the existing imports at the top of the test file. The store factory and types should already be importable from the locations the existing tests use. + +#### Task B.6 — Verify + +```sh +bun run --cwd packages/frontend typecheck # svelte-check, must pass +bun run --cwd packages/frontend test # all tests must pass +bunx biome check packages/frontend # must be clean +``` + +If biome reports formatting issues, run `bunx biome check --write packages/frontend` and re-verify. + +#### Task B.7 — What NOT to do + +- **DO NOT** modify any backend file. Segment A owns those. +- **DO NOT** rename, delete, or reorder any existing exported functions on the tab store. +- **DO NOT** modify `createNewTab`, `closeTab`, `openAgentTab`, `reloadTabMessagesFromApi`, or any other existing function. Only ADD `hydrateFromBackend` and CHANGE the body of the `case "statuses":` block in `handleEvent`. +- **DO NOT** touch the WebSocket lifecycle (`wsClient.connect` / `disconnect`). The `App.svelte` change is the only WS-related edit. +- **DO NOT** add a `beforeunload` handler or any "I'm leaving" signal. The whole point of Behavior 1 is that the agent keeps running silently. +- **DO NOT** change `tabs.svelte.ts:openAgentTab` even if it looks similar to `hydrateFromBackend`. They serve different purposes (single-tab opening vs full restore). +- **DO NOT** add localStorage persistence of tab ids. The backend DB is the source of truth. + +--- + +## 7. Phase 2 — Integration check (main agent) + +After both flash agents report success, the main agent runs: + +```sh +# from /home/tradam/projects/dispatch +bun run --cwd packages/core typecheck +bun run --cwd packages/core test +bun run --cwd packages/api typecheck +bun run --cwd packages/api test +bun run --cwd packages/frontend typecheck +bun run --cwd packages/frontend test +bun run check # root-level biome +``` + +All seven commands must be green. If any fail, classify the failure: +- **Type drift across the segment boundary** (e.g. shape of `TabStatusSnapshot` differs between core mirror and frontend mirror) → fix in Phase 4 with a targeted edit. +- **Test failure inside a single segment** → flash agent missed a case; re-launch that segment's flash agent with the failure log. +- **Test failure crossing segments** → main agent fixes directly. + +Also do a manual smoke read: +- Open `packages/api/src/agent-manager.ts:714` and confirm `getAllStatuses` returns `Record<string, TabStatusSnapshot>`. +- Open `packages/frontend/src/lib/tabs.svelte.ts` and confirm `hydrateFromBackend` exists, is exported, and is called from `App.svelte:onMount`. +- Open `packages/frontend/src/App.svelte` and confirm the onMount await-and-fallback pattern. + +--- + +## 8. Phase 3 — Review + +### 8.1 Launch Gemini (read-only) + +Spawn the `gemini` subagent with this prompt: + +> Review the implementation of the "background agents + layout restore" feature against the spec in `plan-bg-restore.md`. Read every file referenced in Phase 1 (Segment A files and Segment B files). Verify: +> +> 1. `getAllStatuses` in `packages/api/src/agent-manager.ts` matches Task A.2 exactly — including the defensive shallow copy of `currentChunks` and the conditional omission for non-running tabs. +> 2. The frontend `TabStatusSnapshot` mirror in `packages/frontend/src/lib/types.ts` is structurally identical to the core type. +> 3. `hydrateFromBackend` in `packages/frontend/src/lib/tabs.svelte.ts` correctly handles every failure mode listed in Task B.2 (no fatal throws, partial-failure tolerance, idempotency guard). +> 4. The WS `statuses` handler in `tabs.svelte.ts` correctly seeds in-flight chunks for running tabs and clears streaming pointers for idle/error tabs, without regressing the existing desync recovery path (`reloadTabMessagesFromApi` should still fire when frontend thinks running but backend says idle). +> 5. `App.svelte:onMount` calls `hydrateFromBackend` before falling back to `createNewTab`, AND only creates a fresh tab when `restored === 0 && tabStore.tabs.length === 0`. +> 6. Tests cover: empty DB; partial failure of `/tabs/:id/messages`; in-flight snapshot seeding; idempotency when tabs already exist; clearing in-flight pointers on idle snapshot. +> 7. Behavior 1 (browser close ≠ agent stop) is preserved — verify no new `beforeunload`, `unload`, or WS-close-triggered `stopTab` calls were introduced. +> 8. Behavior 3 (X-close cancels + archives) is preserved — verify `closeTab` in `tabs.svelte.ts` still calls `DELETE /tabs/:id` and that the backend `DELETE /tabs/:id` route still calls both `agentManager.deleteTab` and `archiveTab`. +> +> Write your findings to `report.md` in the repo root. DO NOT modify any source file. Categorize each finding as Block / Ship-with-followup / Nit. Quote `file_path:line_number` for every finding. + +### 8.2 Self-review (in parallel with gemini) + +While gemini runs, the main agent independently reviews the same files +against the spec. Note any issues to compare against gemini's output. + +### 8.3 Wait for gemini + +**Hard requirement**: do NOT apply any fixes until gemini's report.md +exists and has been read. The user explicitly asked for this gate. + +--- + +## 9. Phase 4 — Apply fixes + +### 9.1 Combine findings + +Merge self-review + gemini findings into a single deduplicated list, +classified Block / Ship-with-followup / Nit. Resolve disagreements by +re-reading the relevant code; default to following the spec. + +### 9.2 Apply + +- **Block** issues: fix immediately. +- **Ship-with-followup**: fix if trivial; otherwise file in `wishlist.md` and proceed. +- **Nit**: fix in batch with main agent edits; do not spawn flash agents for these. + +If a Block fix touches multiple files in disjoint segments, may spawn +flash agents again (with a fresh, narrow prompt). Otherwise the main +agent does the edits. + +### 9.3 Re-verify + +Run the Phase 2 verification commands again. All must be green. + +### 9.4 Await user direction + +Do NOT commit. Do NOT push. Surface the diff stat and a summary of what +changed. Wait for the user to say "commit and push" or equivalent. + +--- + +## 10. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| `currentChunks` race between read and serialize | Single-threaded JS; the snapshot is built synchronously in `getAllStatuses`. Defensive copy further isolates the consumer. | +| WS `statuses` event arrives during `hydrateFromBackend` and double-applies state | `hydrateFromBackend` checks `tabs.length > 0` and bails; conversely, the WS `statuses` handler is per-tab idempotent (uses snapshot as authoritative state, doesn't append duplicates). | +| Hydration restores an archived tab if `is_open` was flipped after a crash | `listOpenTabs` filters on `is_open = 1`. No path in this plan touches that filter. | +| User has 50 tabs and hydration is slow | `messageFetches` runs in `Promise.all`; bottleneck is total DB row count, not request count. Acceptable for v1. If it becomes a problem, add `?limit=N` to `/tabs/:id/messages`. | +| Subagent (`parentTabId != null`) tabs restore with `persistent: true` and never auto-clean | Existing `persistent: true` semantics in `createNewTab` already apply to user tabs; subagent tabs created via WS `tab-created` keep their non-persistent flag. Hydration restores them as persistent (matching the "they survived a reload, the user clearly cares") — acceptable. | +| The `currentAssistantId` from the snapshot doesn't match any DB message id | `hydrateFromBackend` and the `statuses` handler both append a new in-flight message in this case. Subsequent `done` will close it correctly. | + +--- + +## 11. Acceptance checklist + +Before declaring done, the main agent verifies: + +- [ ] Open a tab, send a message that triggers a long response, close the browser DURING the response, reopen the browser: the message completes in the restored tab (visible in chat history). +- [ ] Open a tab, send a message that triggers a long response, close the browser DURING the response, immediately reopen: the response is streaming live (the user sees deltas appearing). +- [ ] Open three tabs, close the browser, reopen: all three tabs appear in the same order, with their messages. +- [ ] Open three tabs, click the X on the middle one, close the browser, reopen: two tabs remain (not three) — the closed one was archived. +- [ ] Open a tab, send a message, click the X on the tab DURING the response, reopen the browser: the tab is gone AND the agent didn't keep running (verifiable by checking that the tab's last assistant message has a `cancelled` system chunk in the DB, and that no subsequent assistant content was appended). +- [ ] First-ever browser load with no tabs in the DB: a fresh empty tab is created (the existing default UX). @@ -1,36 +1,31 @@ -# Code Review — DeepSeek `reasoning_content` fix +# Background Agents + Layout Restore Review ## Verdict -**Ship-with-followups.** The removal of the v4-era middleware correctly resolves the primary bug: `reasoning_content` is now passed to the AI SDK as `{ type: "reasoning" }` parts, which the v6 SDK successfully serializes to the wire format instead of burying it in a dead `providerMetadata` key. However, the fix strictly relies on the AI SDK's native serialization, which introduces a critical edge case for empty reasoning blocks that the reference OpenCode implementation explicitly handles. +SHIP -## Bugs found (must-fix) -*(None blocking the immediate happy-path fix, but see edge cases below)* +## Block-level findings +None. The implementation adheres strictly to the `plan-bg-restore.md` specification across all tiers (core, API, and frontend). -## Edge cases / risks -* **The Empty-Reasoning Edge Case:** DeepSeek (and other models) can occasionally emit an empty reasoning block. In Dispatch, `toModelMessages` will correctly emit `{ type: "reasoning", text: "" }`. However, `@ai-sdk/[email protected]` strips `reasoning_content` entirely if the text is empty: - ```javascript - // node_modules/@ai-sdk/openai-compatible/dist/index.mjs:245 - ...reasoning.length > 0 ? { reasoning_content: reasoning } : {}, - ``` - If DeepSeek emitted an empty reasoning block in a previous turn, the SDK will drop `reasoning_content` from the subsequent request payload. DeepSeek requires this field to be present (even if empty) during a thinking-mode session and will crash with the exact same "must be passed back" error. +## Ship-with-followup findings +None. -## opencode reference parity gaps -* **Forced empty string serialization:** The OpenCode reference implementation specifically works around the SDK's empty-string stripping behavior in `packages/opencode/src/provider/transform.ts:233-241`: - ```typescript - // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty - // reasoning_content which still needs to be sent back in subsequent requests. - ``` - It enforces this by stripping the `reasoning` parts and injecting the `providerOptions.openaiCompatible[field]` directly. Dispatch's fix misses this workaround because it relies 100% on the AI SDK's native serialization. +## Nits +None. -## Test coverage gaps -* **Empty Reasoning Handling:** The new `agent.test.ts` integration test ("openai-compatible reasoning round-trip...") only tests the happy path with non-empty reasoning (`text: "let me reason about this"`). It should include a test asserting that `text: ""` still results in a valid request (which currently would fail against the wire, though testing this fully might require mocking the provider wire payload rather than just `streamText` arguments). -* **Tool calls + Reasoning:** There is no test verifying that an assistant message with both reasoning and a tool call correctly passes both to `streamText` for the `openai-compatible` provider (though the logic in `toModelMessages` handles it). -* **Multiple reasoning blocks:** The AI SDK concatenates multiple reasoning parts natively, but an integration test verifying that Dispatch's `toModelMessages` correctly emits all of them would be valuable. +## What was checked +- **A. getAllStatuses correctness**: PASS. The method in `packages/api/src/agent-manager.ts:714-751` returns `Record<string, TabStatusSnapshot>`. It conditionally includes `currentChunks` and `currentAssistantId` only for `running` tabs and performs a defensive shallow copy `[...tabAgent.currentChunks]`. +- **B. Frontend TabStatusSnapshot mirror**: PASS. `packages/frontend/src/lib/types.ts:96-100` mirrors the core interface exactly. +- **C. hydrateFromBackend**: PASS. `packages/frontend/src/lib/tabs.svelte.ts:432-562` implements the full hydration flow (GET /tabs -> GET /status -> parallel GET /tabs/:id/messages). It handles failure modes without throwing, implements the required idempotency check (`tabs.length > 0`), and correctly seeds in-flight messages. +- **D. WS statuses handler**: PASS. `packages/frontend/src/lib/tabs.svelte.ts:581-644` reconciles status, seeds in-flight chunks for running tabs, and clears pointers for idle tabs. The desync recovery path (`reloadTabMessagesFromApi`) is preserved. +- **E. App.svelte onMount**: PASS. `packages/frontend/src/App.svelte:78-105` sequences `hydrateFromBackend` before the fallback `createNewTab` and only creates a fresh tab if hydration yields nothing. WS connection lifecycle is preserved. +- **F. Behavior preservation**: PASS. No new `beforeunload` or `unload` handlers were added. WS `onClose` in `packages/api/src/index.ts:60-66` correctly only unsubscribes. Explicit tab close in `tabs.svelte.ts` still calls `DELETE /tabs/:id`, which cancels and archives as before. +- **G. Test coverage**: PASS. + - API tests in `agent-manager.test.ts` cover empty state, idle snapshot, running snapshot, and defensive copy. + - Frontend tests in `chat-store.test.ts` cover successful restore, in-flight seeding, failure tolerance, and idempotency. +- **H. Race conditions**: PASS. `hydrateFromBackend` idempotency and the per-tab reconcile logic in the WS handler mitigate potential races between HTTP and WS data. +- **I. Wire-shape symmetry**: PASS. The frontend mirror matches the core definition. +- **J. Type-only vs runtime imports**: PASS. `agent-manager.ts:40` uses `type TabStatusSnapshot`. -## Style / consistency -* **Clean removal:** The deletion of the obsolete middleware and the cleanup of `packages/core/src/llm/provider.ts` is idiomatic and cleanly delegates responsibility to the v6 AI SDK. -* **Comments:** The new block comment in `provider.ts` accurately explains why the old middleware broke DeepSeek and why the SDK handles it natively now. - -## Other observations -* **Redacted Reasoning:** The removed middleware stripped `redacted-reasoning` types. This is no longer necessary because Dispatch's `toModelMessages` only knows how to emit `text`, `reasoning`, and `tool-call` parts; it intrinsically filters out any other internal chunk types. This means no regressions are introduced regarding redacted reasoning. -* **Empty part filtering for Anthropic:** The `applyAnthropicStructuralNormalisations` function correctly strips empty reasoning/text parts, but this is safely gated behind `usesAnthropicSDK`, so it will not interfere with DeepSeek's `openai-compatible` path.
\ No newline at end of file +## What was NOT checked +- Performance with extremely high tab counts (>100) was not verified empirically, though the implementation uses `Promise.all` for message fetching to minimize latency. +- Direct database state verification (the review was limited to code analysis and existing test coverage). |
