diff options
| author | Adam Malczewski <[email protected]> | 2026-05-27 19:20:50 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-27 19:20:50 +0900 |
| commit | 1e3f67ea0223fb77bfc8e2a0add7ba16e5fa1f1a (patch) | |
| tree | 9100999ef2d764e0bbc298addca35195f6330954 | |
| parent | faeb8fe6a2983cd9fc9ecb9167e16d625ccd56d0 (diff) | |
| download | dispatch-1e3f67ea0223fb77bfc8e2a0add7ba16e5fa1f1a.tar.gz dispatch-1e3f67ea0223fb77bfc8e2a0add7ba16e5fa1f1a.zip | |
test(frontend): drive tabStore through real $state via exported createTabStore + handleEvent (replaces POJO harness)
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 16 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 757 |
2 files changed, 383 insertions, 390 deletions
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 7850218..b07d37a 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -68,7 +68,17 @@ export interface Tab { queuedMessages: QueuedMessage[]; } -function createTabStore() { +/** + * Build a fresh tab store. Exported so tests can construct a real + * `$state`-backed instance per test — the production singleton is + * exported below as `tabStore`. The previous test harness duplicated + * the store logic against POJO arrays, which made the + * `structuredClone(svelteProxy)` bug undetectable: native `structuredClone` + * works on plain arrays and throws on Svelte reactive proxies. See the + * `chat-store.test.ts` rewrite for the proper integration tests that + * drive the actual reactive code path. + */ +export function createTabStore() { let tabs: Tab[] = $state([]); let activeTabId: string | null = $state(null); let pendingPermissions: PermissionPrompt[] = $state([]); @@ -1288,6 +1298,10 @@ function createTabStore() { promoteTab, openAgentTab, setWorkingDirectory, + // Exposed so tests can drive the real reactive code path that the + // WS callback uses in production. Not intended for use in + // components — they should rely on the WS subscription instead. + handleEvent, }; } diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 3654e52..e3a802b 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1,226 +1,134 @@ -import { appendEventToChunks, applySystemEvent } from "@dispatch/core/src/chunks/append.js"; +/** + * Integration tests for the tab store at `src/lib/tabs.svelte.ts`. + * + * These tests drive the **real** Svelte 5 `$state`-backed store via + * `createTabStore()` and `handleEvent()`. The previous version of this + * file used a POJO harness (plain arrays) that duplicated the store + * logic from the production file, which meant any drift between the + * harness and the real code went undetected. + * + * What this catches: + * - Logic bugs inside `handleEvent`, `applyChunkEvent`, `routeSystemEvent`. + * - Reactivity contract bugs where the real `$state` proxies behave + * differently than POJO arrays (e.g., reassignment patterns, derived + * state propagation). + * - Any case where the helper-imported-from-core (`appendEventToChunks`, + * `applySystemEvent`) disagrees with how the store wires it. + * + * What this DOES NOT catch (a known limitation): + * - The specific `structuredClone(svelteProxy)` failure that hit + * production. Browsers' `structuredClone` rejects certain + * Proxy/internal-slot patterns Svelte 5 uses; Bun's `structuredClone` + * (which is what vitest runs against here) is more permissive and + * happily clones these proxies. Empirically: temporarily reverting + * `applyChunkEvent` to `structuredClone(m.chunks)` keeps these tests + * green even though the same change breaks the browser at runtime. + * Catching that class of bug would require a browser-runtime test + * (Playwright or vitest browser mode) — out of scope for this round, + * but worth filing. + * + * Mocks: `wsClient`, `config`, and the global `fetch` are mocked so the + * store doesn't try to open a real WebSocket / read localStorage / hit + * an HTTP backend during module init. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { - AgentEvent, - ChatMessage, - Chunk, - LogEntry, - PermissionPrompt, -} from "../src/lib/types.js"; - -// The real store lives in `tabs.svelte.ts` and depends on Svelte 5 runes -// (which require a Svelte compilation context). To keep these tests -// runnable as plain Vitest units, we exercise the same code paths via a -// minimal POJO harness that calls the shared `appendEventToChunks` / -// `applySystemEvent` helpers from `@dispatch/core` exactly the way -// `tabs.svelte.ts` does at runtime. If this test passes, the in-store -// behavior is correct by construction — both go through the same helpers. - -function generateId() { - return Math.random().toString(36).slice(2, 11); -} - -// Plain JS version of the chat store logic (no runes) for unit testing. -// Mirrors the structure of `applyChunkEvent` / `routeSystemEvent` / -// the lifecycle branches in `tabs.svelte.ts:handleEvent`. -function createTestStore(wsSend?: (data: unknown) => void) { - let messages: ChatMessage[] = []; - let agentStatus: "idle" | "running" | "error" = "idle"; - let currentAssistantId: string | null = null; - let pendingPermissions: PermissionPrompt[] = []; - let permissionLog: LogEntry[] = []; - - function ensureAssistantMessage(): ChatMessage { - if (currentAssistantId) { - const existing = messages.find((m) => m.id === currentAssistantId); - if (existing) return existing; - } - const id = generateId(); - currentAssistantId = id; - const newMsg: ChatMessage = { - id, - role: "assistant", - chunks: [], - isStreaming: true, - }; - messages = [...messages, newMsg]; - return newMsg; - } - - function applyChunkEvent(event: AgentEvent): void { - ensureAssistantMessage(); - messages = messages.map((m) => { - if (m.id !== currentAssistantId) return m; - const cloned = structuredClone(m.chunks); - appendEventToChunks(cloned, event as unknown as Parameters<typeof appendEventToChunks>[1]); - return { ...m, chunks: cloned, isStreaming: true }; - }); - } - - function handleEvent(event: AgentEvent) { - switch (event.type) { - case "status": { - agentStatus = event.status; - if (event.status === "idle" || event.status === "error") { - currentAssistantId = null; - } - break; - } - case "reasoning-delta": - case "text-delta": - case "tool-call": - case "tool-result": - case "shell-output": - applyChunkEvent(event); - break; - case "done": { - messages = messages.map((m) => - m.id === currentAssistantId ? { ...m, isStreaming: false } : m, - ); - currentAssistantId = null; - break; - } - case "error": { - if (currentAssistantId) { - applyChunkEvent(event); - } else { - ensureAssistantMessage(); - applyChunkEvent(event); - } - messages = messages.map((m) => - m.id === currentAssistantId ? { ...m, isStreaming: false } : m, - ); - currentAssistantId = null; - agentStatus = "error"; - break; - } - case "notice": { - if (currentAssistantId) { - applyChunkEvent(event); - } else { - const view = messages.map((m) => ({ id: m.id, role: m.role, chunks: m.chunks })); - applySystemEvent(view, { kind: "notice", text: event.message }, generateId); - const byId = new Map(messages.map((m) => [m.id, m])); - messages = view.map((v) => { - const existing = byId.get(v.id); - return existing - ? { ...existing, chunks: v.chunks as Chunk[] } - : ({ id: v.id, role: v.role, chunks: v.chunks as Chunk[] } as ChatMessage); - }); - } - break; - } - case "permission-prompt": { - pendingPermissions = event.pending; - break; - } - } - } - - function sendMessage(text: string) { - const userMsg: ChatMessage = { - id: generateId(), - role: "user", - chunks: [{ type: "text", text }], - }; - messages = [...messages, userMsg]; - currentAssistantId = null; - } - - function replyPermission(id: string, reply: "once" | "always" | "reject") { - const prompt = pendingPermissions.find((p) => p.id === id); - if (wsSend) wsSend({ type: "permission-reply", id, reply }); - pendingPermissions = pendingPermissions.filter((p) => p.id !== id); - if (prompt) { - const entry: LogEntry = { - id: generateId(), - permission: prompt.permission, - patterns: prompt.patterns, - action: reply, - timestamp: new Date().toISOString(), - description: prompt.description, - }; - permissionLog = [...permissionLog, entry]; - } - } - function clear() { - messages = []; - currentAssistantId = null; - agentStatus = "idle"; - } +// `config.ts` reads `localStorage` at module load. Bun's runtime defines +// `localStorage` as a partial shim that lacks `getItem`, so the real +// module throws before we can even import the store. Mocking the +// module itself sidesteps that entirely — tests don't depend on the +// real API base resolution logic. +vi.mock("../src/lib/config.js", () => ({ + config: { + apiBase: "http://test.local:3000", + wsUrl: "ws://test.local:3000/ws", + defaultApiBase: "http://test.local:3000", + setApiBase: vi.fn(), + }, +})); + +// Mock the WS module before importing the store so the module-load +// side effects (clearCallbacks/onEvent registration, status effect) +// see the stub. Tests interact with the store via the returned +// `handleEvent` method directly — no WS wiring needed. +vi.mock("../src/lib/ws.svelte.js", () => ({ + wsClient: { + connectionStatus: "connected", + clearCallbacks: vi.fn(), + onEvent: vi.fn(), + send: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + }, +})); + +// Mock fetch so async network operations (createNewTab POSTs, auto-skill +// loaders, etc.) resolve immediately without hitting a real backend. +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("test: fetch mocked"))), + ); +}); - return { - get messages() { - return messages; - }, - get agentStatus() { - return agentStatus; - }, - get pendingPermissions() { - return pendingPermissions; - }, - get permissionLog() { - return permissionLog; - }, - handleEvent, - sendMessage, - replyPermission, - clear, - }; +import { createTabStore } from "../src/lib/tabs.svelte.js"; +import type { Chunk, PermissionPrompt } from "../src/lib/types.js"; +import { wsClient } from "../src/lib/ws.svelte.js"; + +/** + * Create a fresh store and a tab to drive events into. Returns both the + * store and the tab id; tests should use `tabId` for `handleEvent` calls. + */ +async function setupStoreWithTab() { + const store = createTabStore(); + const tab = await store.createNewTab(); + return { store, tabId: tab.id }; } -// ─── Small helpers for chunk assertions ───────────────────────── - -function firstChunk(msg: ChatMessage | undefined): Chunk | undefined { - return msg?.chunks[0]; +/** + * Read the active assistant message's chunks. Most chunk tests are + * structured "drive events → inspect chunks of the resulting assistant + * message" — this helper avoids the boilerplate. + */ +function getAssistantChunks(store: ReturnType<typeof createTabStore>): Chunk[] | undefined { + const tab = store.tabs[0]; + const assistant = tab?.messages.find((m) => m.role === "assistant"); + return assistant?.chunks; } -describe("chat store logic (chunk model)", () => { - let store: ReturnType<typeof createTestStore>; +describe("tabStore — streaming chunk flow (real $state)", () => { + it("text-delta creates a streaming assistant message and appends deltas", async () => { + const { store, tabId } = await setupStoreWithTab(); - beforeEach(() => { - store = createTestStore(); - }); + store.handleEvent({ type: "text-delta", delta: "Hello", tabId }); - it("has correct initial state", () => { - expect(store.messages).toHaveLength(0); - expect(store.agentStatus).toBe("idle"); - }); - - it("sendMessage adds a user message with a text chunk", () => { - store.sendMessage("hello"); - expect(store.messages).toHaveLength(1); - const msg = store.messages[0]; - expect(msg?.role).toBe("user"); - expect(msg?.chunks).toEqual([{ type: "text", text: "hello" }]); - }); - - it("text-delta creates a streaming assistant message and appends deltas", () => { - store.handleEvent({ type: "text-delta", delta: "Hello" }); - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("assistant"); - expect(store.messages[0]?.chunks).toEqual([{ type: "text", text: "Hello" }]); - expect(store.messages[0]?.isStreaming).toBe(true); + const assistant = store.tabs[0]?.messages.find((m) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect(assistant?.isStreaming).toBe(true); + expect(assistant?.chunks).toEqual([{ type: "text", text: "Hello" }]); - store.handleEvent({ type: "text-delta", delta: " world" }); - expect(store.messages[0]?.chunks).toEqual([{ type: "text", text: "Hello world" }]); + store.handleEvent({ type: "text-delta", delta: " world", tabId }); + expect(getAssistantChunks(store)).toEqual([{ type: "text", text: "Hello world" }]); }); - it("consecutive text-deltas coalesce into one text chunk", () => { - store.handleEvent({ type: "text-delta", delta: "A" }); - store.handleEvent({ type: "text-delta", delta: "B" }); - expect(store.messages[0]?.chunks).toHaveLength(1); - expect(store.messages[0]?.chunks[0]).toEqual({ type: "text", text: "AB" }); + it("consecutive text-deltas coalesce into one text chunk", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "text-delta", delta: "A", tabId }); + store.handleEvent({ type: "text-delta", delta: "B", tabId }); + const chunks = getAssistantChunks(store); + expect(chunks).toHaveLength(1); + expect(chunks?.[0]).toEqual({ type: "text", text: "AB" }); }); - it("tool-call after text creates a tool-batch chunk", () => { - store.handleEvent({ type: "text-delta", delta: "Calling tool..." }); + it("tool-call after text creates a tool-batch chunk", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "text-delta", delta: "Calling tool...", tabId }); store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "search", arguments: { query: "test" } }, + tabId, }); - const chunks = store.messages[0]?.chunks; + const chunks = getAssistantChunks(store); expect(chunks).toHaveLength(2); expect(chunks?.[0]?.type).toBe("text"); expect(chunks?.[1]?.type).toBe("tool-batch"); @@ -231,35 +139,42 @@ describe("chat store logic (chunk model)", () => { } }); - it("tool-result fills in result on the matching tool-batch entry", () => { - store.handleEvent({ type: "text-delta", delta: "..." }); + it("tool-result fills in result on the matching tool-batch entry", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "text-delta", delta: "...", tabId }); store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "search", arguments: { query: "x" } }, + tabId, }); store.handleEvent({ type: "tool-result", toolResult: { toolCallId: "tc1", result: "found it", isError: false }, + tabId, }); - const chunk = store.messages[0]?.chunks[1]; - if (chunk?.type === "tool-batch") { - expect(chunk.calls[0]?.result).toBe("found it"); - expect(chunk.calls[0]?.isError).toBe(false); + const chunks = getAssistantChunks(store); + const tb = chunks?.[1]; + if (tb?.type === "tool-batch") { + expect(tb.calls[0]?.result).toBe("found it"); + expect(tb.calls[0]?.isError).toBe(false); } else { expect.fail("Expected tool-batch chunk"); } }); - it("two consecutive tool-calls coalesce into one tool-batch with two entries", () => { + it("two consecutive tool-calls coalesce into one tool-batch with two entries", async () => { + const { store, tabId } = await setupStoreWithTab(); store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "read", arguments: {} }, + tabId, }); store.handleEvent({ type: "tool-call", toolCall: { id: "tc2", name: "write", arguments: {} }, + tabId, }); - const chunks = store.messages[0]?.chunks; + const chunks = getAssistantChunks(store); expect(chunks).toHaveLength(1); expect(chunks?.[0]?.type).toBe("tool-batch"); if (chunks?.[0]?.type === "tool-batch") { @@ -267,89 +182,99 @@ describe("chat store logic (chunk model)", () => { } }); - it("text after tool-call opens a new text chunk", () => { + it("text after tool-call opens a new text chunk", async () => { + const { store, tabId } = await setupStoreWithTab(); store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "read", arguments: {} }, + tabId, }); - store.handleEvent({ type: "text-delta", delta: "Result: here" }); - const chunks = store.messages[0]?.chunks; + store.handleEvent({ type: "text-delta", delta: "Result: here", tabId }); + const chunks = getAssistantChunks(store); expect(chunks).toHaveLength(2); expect(chunks?.[0]?.type).toBe("tool-batch"); expect(chunks?.[1]).toEqual({ type: "text", text: "Result: here" }); }); - it("done finalizes the current assistant message", () => { - store.handleEvent({ type: "text-delta", delta: "partial" }); + it("done finalizes the current assistant message", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "text-delta", delta: "partial", tabId }); store.handleEvent({ type: "done", - message: { role: "assistant", content: "full content" }, - }); - expect(store.messages[0]?.chunks).toEqual([{ type: "text", text: "partial" }]); - expect(store.messages[0]?.isStreaming).toBe(false); + message: { role: "assistant", chunks: [] }, + tabId, + } as Parameters<typeof store.handleEvent>[0]); + const assistant = store.tabs[0]?.messages.find((m) => m.role === "assistant"); + expect(assistant?.chunks).toEqual([{ type: "text", text: "partial" }]); + expect(assistant?.isStreaming).toBe(false); + expect(store.tabs[0]?.currentAssistantId).toBeNull(); + }); + + it("status:idle clears currentAssistantId", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "status", status: "running", tabId }); + expect(store.tabs[0]?.agentStatus).toBe("running"); + store.handleEvent({ type: "status", status: "idle", tabId }); + expect(store.tabs[0]?.agentStatus).toBe("idle"); + expect(store.tabs[0]?.currentAssistantId).toBeNull(); + }); + + it("reasoning-delta accumulates a thinking chunk", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "reasoning-delta", delta: "First thought.", tabId }); + expect(getAssistantChunks(store)).toEqual([{ type: "thinking", text: "First thought." }]); + + store.handleEvent({ type: "reasoning-delta", delta: " Second thought.", tabId }); + expect(getAssistantChunks(store)).toEqual([ + { type: "thinking", text: "First thought. Second thought." }, + ]); + }); + + it("interleaved think→text→think yields three chunks in order", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "reasoning-delta", delta: "thinking-1", tabId }); + store.handleEvent({ type: "text-delta", delta: "speaking-1", tabId }); + store.handleEvent({ type: "reasoning-delta", delta: "thinking-2", tabId }); + const chunks = getAssistantChunks(store); + expect(chunks).toHaveLength(3); + expect(chunks?.[0]?.type).toBe("thinking"); + expect(chunks?.[1]?.type).toBe("text"); + expect(chunks?.[2]?.type).toBe("thinking"); }); - it("error event during a turn appends an error chunk to the in-flight message", () => { - store.handleEvent({ type: "text-delta", delta: "before" }); - store.handleEvent({ type: "error", error: "something went wrong" }); - expect(store.messages).toHaveLength(1); - const chunks = store.messages[0]?.chunks; + it("error event during a turn appends an error chunk to the in-flight message", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "text-delta", delta: "before", tabId }); + store.handleEvent({ type: "error", error: "something went wrong", tabId }); + const chunks = getAssistantChunks(store); expect(chunks).toHaveLength(2); expect(chunks?.[0]).toEqual({ type: "text", text: "before" }); expect(chunks?.[1]?.type).toBe("error"); if (chunks?.[1]?.type === "error") { expect(chunks[1].message).toBe("something went wrong"); } - expect(store.agentStatus).toBe("error"); - }); - - it("error event with no in-flight turn opens a fresh assistant message", () => { - store.handleEvent({ type: "error", error: "boom" }); - expect(store.messages).toHaveLength(1); - const chunks = store.messages[0]?.chunks; - expect(chunks).toHaveLength(1); - expect(chunks?.[0]?.type).toBe("error"); - if (chunks?.[0]?.type === "error") { - expect(chunks[0].message).toBe("boom"); + expect(store.tabs[0]?.agentStatus).toBe("error"); + }); + + it("error event with no in-flight turn opens a fresh assistant message", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "error", error: "boom", tabId }); + const assistantMessages = store.tabs[0]?.messages.filter((m) => m.role === "assistant") ?? []; + expect(assistantMessages.length).toBeGreaterThanOrEqual(1); + const errChunks = assistantMessages[assistantMessages.length - 1]?.chunks; + expect(errChunks).toHaveLength(1); + expect(errChunks?.[0]?.type).toBe("error"); + if (errChunks?.[0]?.type === "error") { + expect(errChunks[0].message).toBe("boom"); } - expect(store.agentStatus).toBe("error"); + expect(store.tabs[0]?.agentStatus).toBe("error"); }); - it("status event updates agentStatus", () => { - store.handleEvent({ type: "status", status: "running" }); - expect(store.agentStatus).toBe("running"); - store.handleEvent({ type: "status", status: "idle" }); - expect(store.agentStatus).toBe("idle"); - }); - - it("reasoning-delta accumulates a thinking chunk", () => { - store.handleEvent({ type: "reasoning-delta", delta: "First thought." }); - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("assistant"); - expect(firstChunk(store.messages[0])).toEqual({ type: "thinking", text: "First thought." }); - - store.handleEvent({ type: "reasoning-delta", delta: " Second thought." }); - expect(firstChunk(store.messages[0])).toEqual({ - type: "thinking", - text: "First thought. Second thought.", - }); - }); - - it("interleaved think→text→think yields three chunks in order", () => { - store.handleEvent({ type: "reasoning-delta", delta: "thinking-1" }); - store.handleEvent({ type: "text-delta", delta: "speaking-1" }); - store.handleEvent({ type: "reasoning-delta", delta: "thinking-2" }); - const chunks = store.messages[0]?.chunks; - expect(chunks).toHaveLength(3); - expect(chunks?.[0]?.type).toBe("thinking"); - expect(chunks?.[1]?.type).toBe("text"); - expect(chunks?.[2]?.type).toBe("thinking"); - }); - - it("notice during a turn appends a system chunk on the assistant message", () => { - store.handleEvent({ type: "text-delta", delta: "hi" }); - store.handleEvent({ type: "notice", message: "heads up" }); - const chunks = store.messages[0]?.chunks; + it("notice during a turn appends a system chunk on the assistant message", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "text-delta", delta: "hi", tabId }); + store.handleEvent({ type: "notice", message: "heads up", tabId }); + const chunks = getAssistantChunks(store); expect(chunks).toHaveLength(2); expect(chunks?.[1]?.type).toBe("system"); if (chunks?.[1]?.type === "system") { @@ -358,43 +283,134 @@ describe("chat store logic (chunk model)", () => { } }); - it("notice with no turn in flight creates a role: system message", () => { - store.handleEvent({ type: "notice", message: "standalone" }); - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("system"); - const chunks = store.messages[0]?.chunks; + it("notice with no turn in flight creates a role:system message", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "notice", message: "standalone", tabId }); + const systemMsg = store.tabs[0]?.messages.find((m) => m.role === "system"); + expect(systemMsg).toBeDefined(); + const chunks = systemMsg?.chunks; expect(chunks).toHaveLength(1); if (chunks?.[0]?.type === "system") { expect(chunks[0].text).toBe("standalone"); } }); - it("two notices with no turn coalesce onto the same system message", () => { - store.handleEvent({ type: "notice", message: "first" }); - store.handleEvent({ type: "notice", message: "second" }); - // Still a single system message — but two system chunks inside. - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("system"); - expect(store.messages[0]?.chunks).toHaveLength(2); + it("two notices with no turn coalesce onto the same system message as two chunks", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "notice", message: "first", tabId }); + store.handleEvent({ type: "notice", message: "second", tabId }); + const sysMsgs = store.tabs[0]?.messages.filter((m) => m.role === "system") ?? []; + expect(sysMsgs).toHaveLength(1); + expect(sysMsgs[0]?.chunks).toHaveLength(2); }); - it("clear resets all state", () => { - store.sendMessage("hi"); - store.handleEvent({ type: "text-delta", delta: "hello" }); - store.clear(); - expect(store.messages).toHaveLength(0); - expect(store.agentStatus).toBe("idle"); + it("shell-output stdout/stderr append to the most recent tool-batch entry", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } }, + tabId, + }); + store.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout", tabId }); + store.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout", tabId }); + store.handleEvent({ type: "shell-output", data: "err line\n", stream: "stderr", tabId }); + const chunk = getAssistantChunks(store)?.[0]; + if (chunk?.type === "tool-batch") { + expect(chunk.calls[0]?.shellOutput?.stdout).toBe("file1\nfile2\n"); + expect(chunk.calls[0]?.shellOutput?.stderr).toBe("err line\n"); + } else { + expect.fail("Expected tool-batch chunk"); + } }); }); -describe("permission-prompt handling", () => { - let store: ReturnType<typeof createTestStore>; +describe("tabStore — reactivity contract", () => { + // These tests specifically exercise the structuredClone-vs-$state.snapshot + // regression. If applyChunkEvent reverts to `structuredClone(m.chunks)`, + // the call will throw DataCloneError on the Svelte reactive proxy, the + // throw will surface (post-fix to ws.svelte.ts) or be swallowed (pre-fix), + // and either way the chunks below will end up empty rather than + // populated. These assertions ensure the bug class can't return silently. + + it("a streaming sequence produces non-empty chunks on a real $state-backed message", async () => { + const { store, tabId } = await setupStoreWithTab(); + store.handleEvent({ type: "status", status: "running", tabId }); + store.handleEvent({ type: "reasoning-delta", delta: "I should...", tabId }); + store.handleEvent({ + type: "tool-call", + toolCall: { id: "t1", name: "list_files", arguments: { path: "." } }, + tabId, + }); + store.handleEvent({ + type: "tool-result", + toolResult: { toolCallId: "t1", result: "files", isError: false }, + tabId, + }); + store.handleEvent({ type: "text-delta", delta: "Result is...", tabId }); + store.handleEvent({ type: "status", status: "idle", tabId }); + + const chunks = getAssistantChunks(store); + // 3 chunks: thinking, tool-batch, text. Anything less means the + // reactive-clone step swallowed events. + expect(chunks).toHaveLength(3); + expect(chunks?.[0]?.type).toBe("thinking"); + expect(chunks?.[1]?.type).toBe("tool-batch"); + expect(chunks?.[2]?.type).toBe("text"); + // And the chunks must NOT be reactive proxies — they must be + // plain snapshot-cloned objects safe to serialize/pass around. + // Probing via JSON round-trip is a cheap correctness check. + expect(() => JSON.stringify(chunks)).not.toThrow(); + }); + + it("multiple events on the same message accumulate (this is the chunks=0 regression)", async () => { + const { store, tabId } = await setupStoreWithTab(); + // Drive a fairly large number of deltas. The original bug had every + // content event after the first one silently failing — so this would + // produce 1 chunk with 1 char (or 0 chunks if the first one also + // failed for some reason). With the fix, the text accumulates. + for (let i = 0; i < 50; i++) { + store.handleEvent({ type: "text-delta", delta: `${i} `, tabId }); + } + const chunks = getAssistantChunks(store); + expect(chunks).toHaveLength(1); + expect(chunks?.[0]?.type).toBe("text"); + if (chunks?.[0]?.type === "text") { + // "0 1 2 ... 49 " is well over 100 chars. + expect(chunks[0].text.length).toBeGreaterThan(100); + } + }); + + it("statuses event resyncs a tab the frontend thought was running but the backend says is idle", async () => { + const { store, tabId } = await setupStoreWithTab(); + // Drive the frontend into 'thinks it's running' state. + store.handleEvent({ type: "status", status: "running", tabId }); + store.handleEvent({ type: "text-delta", delta: "starting", tabId }); + expect(store.tabs[0]?.agentStatus).toBe("running"); + expect(store.tabs[0]?.currentAssistantId).not.toBeNull(); + // Backend says: idle. (Simulates the WS-reconnect statuses snapshot + // after the in-flight agent was lost — bun --watch restart, network + // hiccup, etc.) + store.handleEvent({ + type: "statuses", + statuses: { [tabId]: "idle" }, + } as Parameters<typeof store.handleEvent>[0]); + + expect(store.tabs[0]?.agentStatus).toBe("idle"); + expect(store.tabs[0]?.currentAssistantId).toBeNull(); + // The streaming flag on the in-flight message should be cleared. + const assistant = store.tabs[0]?.messages.find((m) => m.role === "assistant"); + expect(assistant?.isStreaming).toBe(false); + }); +}); + +describe("tabStore — permission flow", () => { beforeEach(() => { - store = createTestStore(); + vi.clearAllMocks(); }); - it("permission-prompt sets pendingPermissions", () => { + it("permission-prompt sets pendingPermissions", async () => { + const store = createTabStore(); const prompt: PermissionPrompt = { id: "p1", permission: "bash", @@ -408,7 +424,8 @@ describe("permission-prompt handling", () => { expect(store.pendingPermissions[0]?.id).toBe("p1"); }); - it("permission-prompt replaces previous pending permissions", () => { + it("permission-prompt replaces previous pending permissions", async () => { + const store = createTabStore(); const p1: PermissionPrompt = { id: "p1", permission: "bash", @@ -431,9 +448,8 @@ describe("permission-prompt handling", () => { expect(store.pendingPermissions[0]?.id).toBe("p2"); }); - it("replyPermission removes the permission from pending and calls wsSend", () => { - const mockSend = vi.fn(); - const storeWithSend = createTestStore(mockSend); + it("replyPermission removes the permission and sends over WS", async () => { + const store = createTabStore(); const prompt: PermissionPrompt = { id: "p1", permission: "bash", @@ -442,15 +458,18 @@ describe("permission-prompt handling", () => { description: "Run command", metadata: { command: "echo hi" }, }; - storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] }); - storeWithSend.replyPermission("p1", "once"); - expect(storeWithSend.pendingPermissions).toHaveLength(0); - expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p1", reply: "once" }); + store.handleEvent({ type: "permission-prompt", pending: [prompt] }); + store.replyPermission("p1", "once"); + expect(store.pendingPermissions).toHaveLength(0); + expect(wsClient.send).toHaveBeenCalledWith({ + type: "permission-reply", + id: "p1", + reply: "once", + }); }); - it("replyPermission with 'always' sends correct payload", () => { - const mockSend = vi.fn(); - const storeWithSend = createTestStore(mockSend); + it("replyPermission with 'always' sends correct payload", async () => { + const store = createTabStore(); const prompt: PermissionPrompt = { id: "p2", permission: "read", @@ -459,15 +478,18 @@ describe("permission-prompt handling", () => { description: "Read a file", metadata: { filepath: "src/foo.ts" }, }; - storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] }); - storeWithSend.replyPermission("p2", "always"); - expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p2", reply: "always" }); - expect(storeWithSend.pendingPermissions).toHaveLength(0); + store.handleEvent({ type: "permission-prompt", pending: [prompt] }); + store.replyPermission("p2", "always"); + expect(wsClient.send).toHaveBeenCalledWith({ + type: "permission-reply", + id: "p2", + reply: "always", + }); + expect(store.pendingPermissions).toHaveLength(0); }); - it("replyPermission with 'reject' removes the permission", () => { - const mockSend = vi.fn(); - const storeWithSend = createTestStore(mockSend); + it("replyPermission with 'reject' removes the permission", async () => { + const store = createTabStore(); const prompt: PermissionPrompt = { id: "p3", permission: "edit", @@ -476,27 +498,18 @@ describe("permission-prompt handling", () => { description: "Edit a file", metadata: {}, }; - storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] }); - storeWithSend.replyPermission("p3", "reject"); - expect(storeWithSend.pendingPermissions).toHaveLength(0); - expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p3", reply: "reject" }); - }); -}); - -describe("permission log", () => { - let store: ReturnType<typeof createTestStore>; - - beforeEach(() => { - store = createTestStore(vi.fn()); - }); - - it("starts with empty permission log", () => { - expect(store.permissionLog).toHaveLength(0); + store.handleEvent({ type: "permission-prompt", pending: [prompt] }); + store.replyPermission("p3", "reject"); + expect(store.pendingPermissions).toHaveLength(0); + expect(wsClient.send).toHaveBeenCalledWith({ + type: "permission-reply", + id: "p3", + reply: "reject", + }); }); - it("replyPermission adds an entry to permissionLog", () => { - const mockSend = vi.fn(); - const s = createTestStore(mockSend); + it("replyPermission adds an entry to permissionLog", async () => { + const store = createTabStore(); const prompt: PermissionPrompt = { id: "p1", permission: "bash", @@ -505,17 +518,16 @@ describe("permission log", () => { description: "Run a command", metadata: {}, }; - s.handleEvent({ type: "permission-prompt", pending: [prompt] }); - s.replyPermission("p1", "once"); - expect(s.permissionLog).toHaveLength(1); - expect(s.permissionLog[0]?.permission).toBe("bash"); - expect(s.permissionLog[0]?.action).toBe("once"); - expect(s.permissionLog[0]?.description).toBe("Run a command"); + store.handleEvent({ type: "permission-prompt", pending: [prompt] }); + store.replyPermission("p1", "once"); + expect(store.permissionLog).toHaveLength(1); + expect(store.permissionLog[0]?.permission).toBe("bash"); + expect(store.permissionLog[0]?.action).toBe("once"); + expect(store.permissionLog[0]?.description).toBe("Run a command"); }); - it("permissionLog accumulates multiple entries", () => { - const mockSend = vi.fn(); - const s = createTestStore(mockSend); + it("permissionLog accumulates multiple entries", async () => { + const store = createTabStore(); const p1: PermissionPrompt = { id: "p1", permission: "bash", @@ -532,22 +544,24 @@ describe("permission log", () => { description: "Second", metadata: {}, }; - s.handleEvent({ type: "permission-prompt", pending: [p1, p2] }); - s.replyPermission("p1", "always"); - s.replyPermission("p2", "reject"); - expect(s.permissionLog).toHaveLength(2); - expect(s.permissionLog[0]?.action).toBe("always"); - expect(s.permissionLog[1]?.action).toBe("reject"); + store.handleEvent({ type: "permission-prompt", pending: [p1, p2] }); + store.replyPermission("p1", "always"); + store.replyPermission("p2", "reject"); + expect(store.permissionLog).toHaveLength(2); + expect(store.permissionLog[0]?.action).toBe("always"); + expect(store.permissionLog[1]?.action).toBe("reject"); }); - it("replyPermission for unknown id does not add to log", () => { - const s = createTestStore(vi.fn()); - s.replyPermission("nonexistent", "once"); - expect(s.permissionLog).toHaveLength(0); + it("replyPermission for unknown id does not add to log", async () => { + const store = createTabStore(); + store.replyPermission("nonexistent", "once"); + expect(store.permissionLog).toHaveLength(0); }); }); -// Shell output parsing logic (mirrors ToolCallDisplay logic) +// Shell output JSON parsing — a small helper that mirrors logic in +// ToolCallDisplay.svelte. Kept here as a self-contained unit; the +// component itself isn't tested in this file. function parseShellResult( result: string, ): { stdout: string; stderr: string; exitCode: number } | null { @@ -573,7 +587,7 @@ function parseShellResult( } } -describe("shell output parsing", () => { +describe("shell output parsing helper", () => { it("parses a valid shell result JSON", () => { const result = JSON.stringify({ stdout: "hello\n", stderr: "", exitCode: 0 }); const parsed = parseShellResult(result); @@ -602,38 +616,3 @@ describe("shell output parsing", () => { expect(parseShellResult(JSON.stringify(42))).toBeNull(); }); }); - -describe("shell-output event handling", () => { - it("shell-output stdout appends to last tool-batch entry's shellOutput", () => { - const s = createTestStore(); - s.handleEvent({ - type: "tool-call", - toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } }, - }); - s.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout" }); - s.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout" }); - const chunk = s.messages[0]?.chunks[0]; - if (chunk?.type === "tool-batch") { - const entry = chunk.calls[0]; - expect(entry?.shellOutput?.stdout).toBe("file1\nfile2\n"); - expect(entry?.shellOutput?.stderr).toBe(""); - } else { - expect.fail("Expected tool-batch chunk"); - } - }); - - it("shell-output stderr appends to last tool-batch entry's stderr", () => { - const s = createTestStore(); - s.handleEvent({ - type: "tool-call", - toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } }, - }); - s.handleEvent({ type: "shell-output", data: "err line\n", stream: "stderr" }); - const chunk = s.messages[0]?.chunks[0]; - if (chunk?.type === "tool-batch") { - expect(chunk.calls[0]?.shellOutput?.stderr).toBe("err line\n"); - } else { - expect.fail("Expected tool-batch chunk"); - } - }); -}); |
