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 /packages/frontend/tests | |
| 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.
Diffstat (limited to 'packages/frontend/tests')
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 382 |
1 files changed, 380 insertions, 2 deletions
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); + }); +}); |
