diff options
| author | Adam Malczewski <[email protected]> | 2026-05-28 08:21:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-28 08:21:23 +0900 |
| commit | d2e2e67425e5106025ee8082a0768989b5de814f (patch) | |
| tree | 831858182b4b3083beeb0cfa84968b5b73ded575 | |
| parent | 2f14260bb0f1a51d51e516feda285b68f793ae1b (diff) | |
| download | dispatch-d2e2e67425e5106025ee8082a0768989b5de814f.tar.gz dispatch-d2e2e67425e5106025ee8082a0768989b5de814f.zip | |
feat: restore tab layout + in-flight chunks on browser reopen; agents keep running in background
Implements the 'background-running agents + restore-layout-on-reopen'
feature. Full design and parallel-implementation plan in
`plan-bg-restore.md`; Gemini code review (SHIP verdict, no findings) in
`report.md`.
User-visible behaviors:
1. Browser-close keeps agents alive. If an agent is mid-stream when
the browser closes / reloads / loses the network, it continues
processing on the backend. (This was already the case in code —
agents run fire-and-forget in app.ts:77-79 — but it was previously
pointless because the UI never restored the tab to receive the
output.)
2. Layout restore on browser reopen. Every tab that existed at the
time the window was closed is restored, in original `position`
order, with full persisted 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` (sent over the wire on connect) and
accumulates new deltas as they arrive.
3. Explicit tab-close cancels + forgets. Clicking the X still
cancels the agent (existing `stopTab` in DELETE /tabs/:id) and
archives the row (`is_open = 0`), so it is not restored. No
change to that path.
The gap that the implementation closes: previously, App.svelte:onMount
unconditionally called `createNewTab()` with a fresh UUID, ignoring
every existing row in the `tabs` table. Every browser open was a
clean slate. The DB had the conversation history but no way for the
UI to discover it.
Implementation:
• New `TabStatusSnapshot` interface in
packages/core/src/types/index.ts (auto-exported via existing
`export * from "./types"`):
interface TabStatusSnapshot {
status: AgentStatus;
currentChunks?: Chunk[]; // present iff running
currentAssistantId?: string; // present iff running
}
• `agent-manager.ts:getAllStatuses()` rewritten to return
`Record<string, TabStatusSnapshot>` (was
`Record<string, AgentStatus>`). For running tabs only, attaches a
defensive shallow copy of `tabAgent.currentChunks` (the live
streaming array the per-message loop appends to) plus the DB id
of the in-flight assistant message. The defensive copy is the
consumer's to mutate. Idle / error tabs get `{ status }` only.
`GET /status` and the WS `onOpen` snapshot both pick up the new
shape automatically — neither call site changed.
• Frontend mirror of `TabStatusSnapshot` in
packages/frontend/src/lib/types.ts; `AgentEvent.statuses` variant
updated to use `Record<string, TabStatusSnapshot>`.
• New `hydrateFromBackend()` on the tab store
(packages/frontend/src/lib/tabs.svelte.ts). Sequence on app
mount:
1. Bail with 0 if `tabs.length > 0` (hot-reload idempotency).
2. GET /tabs → list of `is_open=1` rows in
`position` order.
3. GET /status → in-flight TabStatusSnapshot map.
4. GET /tabs/:id/messages for each tab in parallel via
Promise.all → persisted ChatMessage[].
5. Build the Tab objects, splicing the snapshot's live
chunks into the in-flight assistant message for every
running tab (two paths: merge into the existing DB row
with matching id, or append a fresh in-flight message
if no row matches).
6. `tabs = restored; activeTabId = restored[0]?.id ?? null;`
Every fetch is wrapped in try/catch so one tab's failure can't
destroy the whole restore pass.
• WS `statuses` handler in `tabs.svelte.ts:handleEvent` rewritten
for the new shape. Still fires `reloadTabMessagesFromApi` on the
desync case (frontend thinks running, backend says idle — the
pre-existing recovery path is preserved). When backend says
running, seeds in-flight chunks into the assistant message
matching `snap.currentAssistantId` (creating it if needed).
When backend says non-running, clears `isStreaming` on the
previous in-flight message and nulls `currentAssistantId`.
• `App.svelte:onMount` now awaits `tabStore.hydrateFromBackend()`
before deciding whether to fall back to `createNewTab()`.
Fallback condition is the doubly-defensive
`restored === 0 && tabStore.tabs.length === 0`. `wsClient.connect()`
fires in parallel with hydration — the resulting WS `statuses`
event is per-tab idempotent against the hydrated state, so there
is no race even if it arrives mid-hydration.
What was NOT done (deliberately, deferred to wishlist):
• Pre-existing inconsistency: core `AgentStatus` includes
"waiting_for_key" but frontend `TabStatusSnapshot.status` uses
only the existing 3-state pattern ("idle" | "running" | "error").
Not introduced here; mirrored the existing precedent.
• Restored tabs use defaults for `reasoningEffort`, `agentSlug`,
`agentScope`, `agentModels`, `workingDirectory` — these are not
in the DB `tabs` schema. Future schema expansion.
• Per-delta DB flushing — not needed; the in-memory snapshot
covers the gap between flushAssistant calls.
• LocalStorage cache of tab ids — backend DB is the source of truth.
Process notes:
• Implemented via parallel programmer subagents (flash agents were
requested but unavailable in this environment — substituted with
"programmer" agents, which share the "reads a plan, implements a
single step" charter). Backend (Segment A: getAllStatuses + 5
tests) and frontend (Segment B: types + hydrateFromBackend +
statuses handler + onMount + 8 tests) ran disjoint-file-ownership
in parallel.
• Gemini code review (yolo mode for tool access, explicit
prompt-level write restriction to `report.md` only) returned a
SHIP verdict with no findings against the plan.
• Self-review surfaced one followup gap that Gemini's earlier
plan-mode pass also caught: no explicit test for
`/tabs/:id/messages` failure isolation. Added a test covering
both HTTP-500 and network-error variants alongside a healthy
tab, asserting per-tab failures don't destroy the whole restore.
Tests:
• api/tests/agent-manager.test.ts: +5 (snapshot empty record,
idle-tab field omission, running-tab field inclusion, defensive
copy invariant, omits chunks for running tab with null
currentChunks). 31 total (was 26).
• frontend/tests/chat-store.test.ts: +9 (restore-with-messages,
in-flight seeding, /tabs failure → 0 returned, empty /tabs
array, idempotency when tabs already exist, idle-status when
/status omits, running-snapshot statuses handler seeding,
idle-snapshot statuses handler clearing, per-tab failure
isolation across HTTP-500 and network-error). 44 total (was 35).
Totals: 243 tests across 3 packages all green; typecheck clean on
core + api + frontend; biome clean across 124 files.
| -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). |
