diff options
| author | Adam Malczewski <[email protected]> | 2026-05-27 18:35:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-27 18:35:18 +0900 |
| commit | ca6ee91c5e1167b1929eedbb96c76dfa24e7d026 (patch) | |
| tree | bc23acac2e7caaf2e59eacbc21bfc9b41f3c1458 /packages/frontend | |
| parent | da57842686ebfd157396551fc76d0c18f7676335 (diff) | |
| download | dispatch-ca6ee91c5e1167b1929eedbb96c76dfa24e7d026.tar.gz dispatch-ca6ee91c5e1167b1929eedbb96c76dfa24e7d026.zip | |
refactor: ChatMessage.chunks[] union — interleaved thinking, tool batching, error/system chunks
Diffstat (limited to 'packages/frontend')
| -rw-r--r-- | packages/frontend/package.json | 1 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatMessage.svelte | 76 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolCallDisplay.svelte | 4 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 364 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 61 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 408 |
6 files changed, 514 insertions, 400 deletions
diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5228e4a..453df07 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -31,6 +31,7 @@ } }, "dependencies": { + "@dispatch/core": "workspace:*", "dompurify": "^3.4.5", "highlight.js": "^11.11.1", "marked": "^18.0.4", diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte index c6b6034..0c85349 100644 --- a/packages/frontend/src/lib/components/ChatMessage.svelte +++ b/packages/frontend/src/lib/components/ChatMessage.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { appSettings } from "../settings.svelte.js"; import { tabStore } from "../tabs.svelte.js"; -import type { ChatMessage } from "../types.js"; +import type { ChatMessage, Chunk, SystemChunkKind } from "../types.js"; import MarkdownRenderer from "./MarkdownRenderer.svelte"; import ToolCallDisplay from "./ToolCallDisplay.svelte"; @@ -21,37 +21,67 @@ function cancelQueued() { void tabStore.cancelQueuedMessage(tabId, queuedMessageId); } } + +function chunkKey(chunk: Chunk, i: number): string { + if (chunk.type === "tool-batch") { + // Stable-ish: first call id + count keeps re-renders sane while streaming. + return `tb-${chunk.calls[0]?.id ?? i}-${chunk.calls.length}`; + } + return `${chunk.type}-${i}`; +} + +const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = { + notice: "Notice", + "model-changed": "Model changed", + "config-reload": "Config reload", + cancelled: "Cancelled", +}; </script> -{#if isSystem} - <div class="flex justify-center my-2"> - <div class="badge badge-ghost gap-1 text-xs opacity-60"> - {#each message.content as segment} - {#if segment.type === "text"} - {segment.text} - {/if} - {/each} - </div> - </div> -{:else} -<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}"> - <div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}"> - {#if message.thinking} +{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)} + {#each chunks as chunk, i (chunkKey(chunk, i))} + {#if chunk.type === "text"} + <MarkdownRenderer text={chunk.text} {streaming} /> + {:else if chunk.type === "thinking"} <div class="collapse collapse-arrow mb-2 p-1"> <input type="checkbox" checked={appSettings.autoExpandThinking} /> <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div> <div class="collapse-content text-sm opacity-60 italic p-0"> - <p class="whitespace-pre-wrap mt-1">{message.thinking}</p> + <p class="whitespace-pre-wrap mt-1">{chunk.text}</p> </div> </div> + {:else if chunk.type === "tool-batch"} + {#each chunk.calls as call (call.id)} + <ToolCallDisplay toolCall={call} /> + {/each} + {:else if chunk.type === "error"} + <div class="alert alert-error my-2 py-2 px-3 text-sm rounded border border-error/60 bg-error/10 text-error"> + <div class="flex flex-col gap-0.5 w-full"> + <span class="break-words">{chunk.message}</span> + {#if chunk.statusCode !== undefined} + <span class="text-xs opacity-70">status {chunk.statusCode}</span> + {/if} + </div> + </div> + {:else if chunk.type === "system"} + <div class="my-1 text-xs italic opacity-50 flex gap-1 items-baseline"> + <span class="font-semibold not-italic">{SYSTEM_KIND_LABEL[chunk.kind]}:</span> + <span class="break-words">{chunk.text}</span> + </div> {/if} - {#each message.content as segment, i (segment.type === "tool-call" ? segment.id : i)} - {#if segment.type === "text"} - <MarkdownRenderer text={segment.text} streaming={message.isStreaming} /> - {:else if segment.type === "tool-call"} - <ToolCallDisplay toolCall={segment} /> - {/if} - {/each} + {/each} +{/snippet} + +{#if isSystem} + <div class="flex justify-center my-2 px-4"> + <div class="max-w-full text-center"> + {@render renderChunks(message.chunks, false)} + </div> + </div> +{:else} +<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}"> + <div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}"> + {@render renderChunks(message.chunks, message.isStreaming)} {#if message.isStreaming} <span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle rounded-sm"></span> {/if} diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte index 7c7aef6..1b4ebca 100644 --- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte +++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte @@ -1,8 +1,8 @@ <script lang="ts"> import { tabStore } from "../tabs.svelte.js"; -import type { ToolCallDisplay } from "../types.js"; +import type { ToolBatchEntry } from "../types.js"; -const { toolCall }: { toolCall: ToolCallDisplay } = $props(); +const { toolCall }: { toolCall: ToolBatchEntry } = $props(); let isExpanded = $state(false); diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 5d3ab63..e08c02f 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -1,9 +1,19 @@ +// Import the chunk-builder helpers directly from core so the frontend store +// and the backend agent share the exact same wire-format logic. Deep import +// is intentional: the core barrel pulls in node-only deps (chokidar, etc.) +// that don't belong in the browser bundle. +import { + appendEventToChunks, + applySystemEvent, + type IdentifiedMessage, + type SystemEventLike, +} from "@dispatch/core/src/chunks/append.js"; import { config } from "./config.js"; import { appSettings } from "./settings.svelte.js"; import type { AgentEvent, ChatMessage, - ContentSegment, + Chunk, DebugInfo, LogEntry, PermissionPrompt, @@ -177,7 +187,6 @@ function createTabStore() { id?: string; role: string; contentJson: string; - thinking: string | null; }>; }) : { messages: [] }; @@ -188,8 +197,7 @@ function createTabStore() { { id: m.id ?? generateId(), role: m.role as ChatMessage["role"], - content: JSON.parse(m.contentJson) as ContentSegment[], - thinking: m.thinking ?? undefined, + chunks: JSON.parse(m.contentJson) as Chunk[], isStreaming: false, }, ]; @@ -268,8 +276,7 @@ function createTabStore() { const newMsg: ChatMessage = { id, role: "assistant", - content: [], - thinking: "", + chunks: [], isStreaming: true, }; updateTab(tabId, { @@ -285,6 +292,75 @@ function createTabStore() { updateTab(tabId, { messages: updater(tab.messages) }); } + /** + * Apply a content-producing event to the in-flight assistant message via the + * shared core helper. + * + * Reactivity contract: `appendEventToChunks` mutates the chunks array in + * place, but Svelte 5 `$state` only triggers updates when we reassign at the + * `tabs` array level. We deep-clone the message's chunks via + * `structuredClone`, mutate the clone, then write it back through + * `updateMessages` — which rebuilds the parent arrays. This is the same + * pattern the old per-event handlers used (shallow copy + new arrays), just + * centralized through the core helper. + */ + function applyChunkEvent(tabId: string, event: AgentEvent): void { + ensureAssistantMessage(tabId); + const tab = getTabById(tabId); + if (!tab) return; + const currentId = tab.currentAssistantId; + if (!currentId) return; + updateMessages(tabId, (msgs) => + msgs.map((m) => { + if (m.id !== currentId) return m; + const cloned = structuredClone(m.chunks); + // The frontend's local AgentEvent is structurally compatible with + // core's for every variant the helper cares about; the variants + // where shapes differ (tab-created, done, status, message-*) are + // all in the helper's no-op branch. + appendEventToChunks(cloned, event as unknown as Parameters<typeof appendEventToChunks>[1]); + return { ...m, chunks: cloned, isStreaming: true }; + }), + ); + } + + /** + * Route a system event when there's no in-flight assistant turn. Wraps + * `applySystemEvent` from core, which either appends a `system` chunk to + * the most recent `role: "system"` message or creates a new one. + */ + function routeSystemEvent(tabId: string, sysEvent: SystemEventLike): void { + const tab = getTabById(tabId); + if (!tab) return; + // We need to mutate the messages array (applySystemEvent does in-place + // push). Build a shallow-cloned IdentifiedMessage[] view, run the + // helper, then write it back. We construct fresh ChatMessage objects + // for any newly-pushed messages by reading the resulting view. + const view: IdentifiedMessage[] = tab.messages.map((m) => ({ + id: m.id, + role: m.role, + chunks: structuredClone(m.chunks), + })); + applySystemEvent(view, sysEvent, generateId); + + // Reconcile: rebuild the ChatMessage array from the view, preserving + // existing message metadata (isStreaming, debugInfo) where IDs match. + const byId = new Map(tab.messages.map((m) => [m.id, m])); + const rebuilt: ChatMessage[] = view.map((v) => { + const existing = byId.get(v.id); + if (existing) { + return { ...existing, role: v.role, chunks: v.chunks as Chunk[] }; + } + return { + id: v.id, + role: v.role, + chunks: v.chunks as Chunk[], + isStreaming: false, + }; + }); + updateTab(tabId, { messages: rebuilt }); + } + function handleEvent(event: AgentEvent & { tabId?: string }): void { const tabId = event.tabId; @@ -302,84 +378,13 @@ function createTabStore() { } break; } - case "reasoning-delta": { - if (!tabId) break; - ensureAssistantMessage(tabId); - const tab = getTabById(tabId); - if (!tab) break; - updateMessages(tabId, (msgs) => - msgs.map((m) => - m.id === tab.currentAssistantId - ? { ...m, thinking: (m.thinking ?? "") + event.delta } - : m, - ), - ); - break; - } - case "text-delta": { - if (!tabId) break; - ensureAssistantMessage(tabId); - const tab2 = getTabById(tabId); - if (!tab2) break; - updateMessages(tabId, (msgs) => - msgs.map((m) => { - if (m.id !== tab2.currentAssistantId) return m; - const segments = [...m.content]; - const last = segments[segments.length - 1]; - if (last && last.type === "text") { - segments[segments.length - 1] = { ...last, text: last.text + event.delta }; - } else { - segments.push({ type: "text", text: event.delta }); - } - return { ...m, content: segments, isStreaming: true }; - }), - ); - break; - } - case "tool-call": { + case "reasoning-delta": + case "text-delta": + case "tool-call": + case "tool-result": + case "shell-output": { if (!tabId) break; - ensureAssistantMessage(tabId); - const tab3 = getTabById(tabId); - if (!tab3) break; - updateMessages(tabId, (msgs) => - msgs.map((m) => { - if (m.id !== tab3.currentAssistantId) return m; - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - }, - ]; - return { ...m, content: segments }; - }), - ); - break; - } - case "tool-result": { - if (!tabId) break; - const tab4 = getTabById(tabId); - if (!tab4) break; - updateMessages(tabId, (msgs) => - msgs.map((m) => { - if (m.id !== tab4.currentAssistantId) return m; - return { - ...m, - content: m.content.map((seg) => { - if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { - return { - ...seg, - result: event.toolResult.result, - isError: event.toolResult.isError, - }; - } - return seg; - }), - }; - }), - ); + applyChunkEvent(tabId, event); break; } case "done": { @@ -393,49 +398,77 @@ function createTabStore() { break; } case "error": { - if (tabId) { - const errMsg: ChatMessage = { - id: generateId(), - role: "assistant", - content: [{ type: "text", text: `Error: ${event.error}` }], - isStreaming: false, - debugInfo: makeDebugInfo({ error: event.error }), - }; - const tab6 = getTabById(tabId); - if (tab6) { - updateTab(tabId, { - messages: [...tab6.messages, errMsg], - currentAssistantId: null, - agentStatus: "error", - }); + if (!tabId) break; + const errTab = getTabById(tabId); + if (!errTab) break; + if (errTab.currentAssistantId) { + // In-flight turn: append the error as a chunk on the + // assistant message via the shared helper. Mark debug info + // on the message for parity with the previous behavior. + applyChunkEvent(tabId, event); + updateMessages(tabId, (msgs) => + msgs.map((m) => + m.id === errTab.currentAssistantId + ? { + ...m, + isStreaming: false, + debugInfo: makeDebugInfo({ error: event.error }), + } + : m, + ), + ); + } else { + // No turn in flight: open a new assistant message holding + // only the error chunk. We do this by ensuring an assistant + // message then funneling through applyChunkEvent, which + // guarantees the chunk shape matches the helper's output. + ensureAssistantMessage(tabId); + applyChunkEvent(tabId, event); + const afterTab = getTabById(tabId); + if (afterTab?.currentAssistantId) { + const newId = afterTab.currentAssistantId; + updateMessages(tabId, (msgs) => + msgs.map((m) => + m.id === newId + ? { + ...m, + isStreaming: false, + debugInfo: makeDebugInfo({ error: event.error }), + } + : m, + ), + ); } } + updateTab(tabId, { currentAssistantId: null, agentStatus: "error" }); break; } case "notice": { - if (tabId) { - const noticeMsg: ChatMessage = { - id: generateId(), - role: "assistant", - content: [{ type: "text", text: event.message }], - isStreaming: false, - debugInfo: makeDebugInfo({ notice: event.message }), - }; - const tabN = getTabById(tabId); - if (tabN) { - updateTab(tabId, { - messages: [...tabN.messages, noticeMsg], - currentAssistantId: null, - }); - } + if (!tabId) break; + const noticeTab = getTabById(tabId); + if (!noticeTab) break; + if (noticeTab.currentAssistantId) { + applyChunkEvent(tabId, event); + } else { + routeSystemEvent(tabId, { kind: "notice", text: event.message }); } break; } case "model-changed": { - if (tabId) { - updateTab(tabId, { - keyId: event.keyId, - modelId: event.modelId, + if (!tabId) break; + const mcTab2 = getTabById(tabId); + if (!mcTab2) break; + // Always update the tab's active key/model. Additionally emit + // a `system` chunk to record the switch at its temporal + // position (in the assistant turn if one is in flight; else + // in a standalone system message). + updateTab(tabId, { keyId: event.keyId, modelId: event.modelId }); + if (mcTab2.currentAssistantId) { + applyChunkEvent(tabId, event); + } else { + routeSystemEvent(tabId, { + kind: "model-changed", + text: `Switched to ${event.modelId} (${event.keyId})`, }); } break; @@ -455,36 +488,17 @@ function createTabStore() { setTimeout(() => { configReloaded = false; }, 2500); - break; - } - case "shell-output": { - if (!tabId) break; - const tab7 = getTabById(tabId); - if (!tab7) break; - updateMessages(tabId, (msgs) => - msgs.map((m) => { - if (m.id !== tab7.currentAssistantId) return m; - const segments = [...m.content]; - for (let i = segments.length - 1; i >= 0; i--) { - const seg = segments[i]; - if (seg && seg.type === "tool-call") { - segments[i] = { - ...seg, - shellOutput: { - stdout: - (seg.shellOutput?.stdout ?? "") + - (event.stream === "stdout" ? event.data : ""), - stderr: - (seg.shellOutput?.stderr ?? "") + - (event.stream === "stderr" ? event.data : ""), - }, - }; - break; - } - } - return { ...m, content: segments }; - }), - ); + // If a tab + turn is in flight, also record the reload as a + // system chunk for honest history. If no turn is in flight we + // could route to a system message, but config-reload is a + // global signal not scoped to any tab — only the active tab, + // if any, gets the chunk. + if (tabId) { + const crTab = getTabById(tabId); + if (crTab?.currentAssistantId) { + applyChunkEvent(tabId, event); + } + } break; } case "tab-created": { @@ -546,7 +560,7 @@ function createTabStore() { const userMsg: ChatMessage = { id: `queued-${mqEvent.messageId}`, role: "user", - content: [{ type: "text", text: mqEvent.message }], + chunks: [{ type: "text", text: mqEvent.message }], }; updateTab(tabId, { messages: [...(tabAfterQm?.messages ?? []), userMsg] }); } @@ -767,7 +781,7 @@ function createTabStore() { const userMsg: ChatMessage = { id: generateId(), role: "user", - content: [{ type: "text", text }], + chunks: [{ type: "text", text }], }; // If the agent is currently running, we expect the POST to be queued. @@ -862,7 +876,13 @@ function createTabStore() { const errMsg: ChatMessage = { id: generateId(), role: "assistant", - content: [{ type: "text", text: `Error: Failed to send message (HTTP ${res.status})` }], + chunks: [ + { + type: "error", + message: `Failed to send message (HTTP ${res.status})`, + statusCode: res.status, + }, + ], isStreaming: false, debugInfo: makeDebugInfo({ error: `HTTP ${res.status}`, @@ -915,7 +935,7 @@ function createTabStore() { const errMsg: ChatMessage = { id: generateId(), role: "assistant", - content: [{ type: "text", text: "Error: Could not reach the server" }], + chunks: [{ type: "error", message: "Could not reach the server" }], isStreaming: false, debugInfo: makeDebugInfo({ error: err instanceof Error ? err.message : String(err) }), }; @@ -1085,21 +1105,37 @@ function createTabStore() { for (const msg of tab.messages) { const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant"; lines.push(`--- ${role} ---`); - if (msg.thinking) lines.push(` [Thinking]: ${msg.thinking}`); - for (const seg of msg.content) { - if (seg.type === "text") lines.push(seg.text); - else if (seg.type === "tool-call") { - lines.push(` [Tool: ${seg.name}]`); - if (seg.result !== undefined) { - const result = String(seg.result); - if (result.length > TOOL_RESULT_MAX) { - lines.push( - ` Result: ${result.slice(0, TOOL_RESULT_MAX)}... [truncated, ${result.length} chars total]`, - ); - } else { - lines.push(` Result: ${result}`); + for (const chunk of msg.chunks) { + switch (chunk.type) { + case "text": + lines.push(chunk.text); + break; + case "thinking": + lines.push(` [Thinking]: ${chunk.text}`); + break; + case "tool-batch": + for (const call of chunk.calls) { + lines.push(` [Tool: ${call.name}]`); + if (call.result !== undefined) { + const result = String(call.result); + if (result.length > TOOL_RESULT_MAX) { + lines.push( + ` Result: ${result.slice(0, TOOL_RESULT_MAX)}... [truncated, ${result.length} chars total]`, + ); + } else { + lines.push(` Result: ${result}`); + } + } } + break; + case "error": { + const code = chunk.statusCode !== undefined ? ` (HTTP ${chunk.statusCode})` : ""; + lines.push(` [Error${code}]: ${chunk.message}`); + break; } + case "system": + lines.push(` [${chunk.kind}]: ${chunk.text}`); + break; } } lines.push(""); diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 2e6b219..1512b39 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -1,12 +1,3 @@ -export interface ToolCallDisplay { - id: string; - name: string; - arguments: Record<string, unknown>; - result?: string; - isError?: boolean; - shellOutput?: { stdout: string; stderr: string }; -} - export interface DebugInfo { timestamp: string; error?: string; @@ -20,15 +11,57 @@ export interface DebugInfo { httpBody?: string; } -export type ContentSegment = - | { type: "text"; text: string } - | ({ type: "tool-call" } & ToolCallDisplay); +/** + * Mirror of the core `Chunk` union (see packages/core/src/types/index.ts). + * + * Wire-format symmetry MUST be kept with core. If you change one, change + * the other. The frontend store calls into the shared + * `appendEventToChunks` helper from core so the two stay in lockstep. + */ +export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk; + +export interface TextChunk { + type: "text"; + text: string; +} + +export interface ThinkingChunk { + type: "thinking"; + text: string; +} + +export interface ToolBatchChunk { + type: "tool-batch"; + calls: ToolBatchEntry[]; +} + +export interface ToolBatchEntry { + id: string; + name: string; + arguments: Record<string, unknown>; + result?: string; + isError?: boolean; + shellOutput?: { stdout: string; stderr: string }; +} + +export interface ErrorChunk { + type: "error"; + message: string; + statusCode?: number; +} + +export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled"; + +export interface SystemChunk { + type: "system"; + kind: SystemChunkKind; + text: string; +} export interface ChatMessage { id: string; role: "user" | "assistant" | "system"; - content: ContentSegment[]; - thinking?: string; + chunks: Chunk[]; isStreaming?: boolean; debugInfo?: DebugInfo; } diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 978bce1..3654e52 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1,22 +1,28 @@ +import { appendEventToChunks, applySystemEvent } from "@dispatch/core/src/chunks/append.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentEvent, ContentSegment, LogEntry, PermissionPrompt } from "../src/lib/types.js"; - -// We test the logic inline since runes require svelte compilation context. -// The chat store logic is tested via a plain reimplementation of the same logic. +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); } -interface ChatMessage { - id: string; - role: "user" | "assistant"; - content: ContentSegment[]; - thinking?: string; - isStreaming?: boolean; -} - -// Plain JS version of the chat store logic (no runes) for unit testing +// 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"; @@ -24,27 +30,31 @@ function createTestStore(wsSend?: (data: unknown) => void) { let pendingPermissions: PermissionPrompt[] = []; let permissionLog: LogEntry[] = []; - function getCurrentAssistantMessage(): ChatMessage | null { - if (!currentAssistantId) return null; - return messages.find((m) => m.id === currentAssistantId) ?? null; + 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 ensureCurrentAssistantMessage(): ChatMessage { - let msg = getCurrentAssistantMessage(); - if (!msg) { - const id = generateId(); - currentAssistantId = id; - const newMsg: ChatMessage = { - id, - role: "assistant", - content: [], - thinking: "", - isStreaming: true, - }; - messages = [...messages, newMsg]; - msg = newMsg; - } - return msg; + 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) { @@ -56,123 +66,52 @@ function createTestStore(wsSend?: (data: unknown) => void) { } break; } - case "reasoning-delta": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { ...m, thinking: (m.thinking ?? "") + event.delta }; - } - return m; - }); + case "reasoning-delta": + case "text-delta": + case "tool-call": + case "tool-result": + case "shell-output": + applyChunkEvent(event); break; - } - case "text-delta": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - const segments = [...m.content]; - const last = segments[segments.length - 1]; - if (last && last.type === "text") { - segments[segments.length - 1] = { ...last, text: last.text + event.delta }; - } else { - segments.push({ type: "text", text: event.delta }); - } - return { ...m, content: segments, isStreaming: true }; - } - return m; - }); - break; - } - case "tool-call": { - ensureCurrentAssistantMessage(); - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - const segments: ContentSegment[] = [ - ...m.content, - { - type: "tool-call", - id: event.toolCall.id, - name: event.toolCall.name, - arguments: event.toolCall.arguments, - }, - ]; - return { ...m, content: segments }; - } - return m; - }); - break; - } - case "tool-result": { - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { - ...m, - content: m.content.map((seg) => { - if (seg.type === "tool-call" && seg.id === event.toolResult.toolCallId) { - return { - ...seg, - result: event.toolResult.result, - isError: event.toolResult.isError, - }; - } - return seg; - }), - }; - } - return m; - }); - break; - } case "done": { - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { ...m, isStreaming: false }; - } - return m; - }); + messages = messages.map((m) => + m.id === currentAssistantId ? { ...m, isStreaming: false } : m, + ); currentAssistantId = null; break; } case "error": { - messages = [ - ...messages, - { - id: generateId(), - role: "assistant", - content: [{ type: "text", text: `Error: ${event.error}` }] as ContentSegment[], - isStreaming: false, - }, - ]; + 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 "permission-prompt": { - pendingPermissions = event.pending; + 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 "shell-output": { - messages = messages.map((m) => { - if (m.id === currentAssistantId) { - return { - ...m, - content: m.content.map((seg, i) => { - if (seg.type === "tool-call" && i === m.content.length - 1) { - const prev = seg.shellOutput ?? { stdout: "", stderr: "" }; - return { - ...seg, - shellOutput: - event.stream === "stdout" - ? { ...prev, stdout: prev.stdout + event.data } - : { ...prev, stderr: prev.stderr + event.data }, - }; - } - return seg; - }), - }; - } - return m; - }); + case "permission-prompt": { + pendingPermissions = event.pending; break; } } @@ -182,7 +121,7 @@ function createTestStore(wsSend?: (data: unknown) => void) { const userMsg: ChatMessage = { id: generateId(), role: "user", - content: [{ type: "text", text }], + chunks: [{ type: "text", text }], }; messages = [...messages, userMsg]; currentAssistantId = null; @@ -231,7 +170,13 @@ function createTestStore(wsSend?: (data: unknown) => void) { }; } -describe("chat store logic", () => { +// ─── Small helpers for chunk assertions ───────────────────────── + +function firstChunk(msg: ChatMessage | undefined): Chunk | undefined { + return msg?.chunks[0]; +} + +describe("chat store logic (chunk model)", () => { let store: ReturnType<typeof createTestStore>; beforeEach(() => { @@ -243,49 +188,50 @@ describe("chat store logic", () => { expect(store.agentStatus).toBe("idle"); }); - it("sendMessage adds a user message", () => { + it("sendMessage adds a user message with a text chunk", () => { store.sendMessage("hello"); expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("user"); - expect(store.messages[0]?.content).toEqual([{ type: "text", text: "hello" }]); + 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]?.content).toEqual([{ type: "text", text: "Hello" }]); + expect(store.messages[0]?.chunks).toEqual([{ type: "text", text: "Hello" }]); expect(store.messages[0]?.isStreaming).toBe(true); store.handleEvent({ type: "text-delta", delta: " world" }); - expect(store.messages[0]?.content).toEqual([{ type: "text", text: "Hello world" }]); + expect(store.messages[0]?.chunks).toEqual([{ type: "text", text: "Hello world" }]); }); - it("text-delta appends to last text segment in same segment", () => { + it("consecutive text-deltas coalesce into one text chunk", () => { store.handleEvent({ type: "text-delta", delta: "A" }); store.handleEvent({ type: "text-delta", delta: "B" }); - // Should be one text segment, not two - expect(store.messages[0]?.content).toHaveLength(1); - expect(store.messages[0]?.content[0]).toEqual({ type: "text", text: "AB" }); + expect(store.messages[0]?.chunks).toHaveLength(1); + expect(store.messages[0]?.chunks[0]).toEqual({ type: "text", text: "AB" }); }); - it("tool-call inserts as a segment after text", () => { + it("tool-call after text creates a tool-batch chunk", () => { store.handleEvent({ type: "text-delta", delta: "Calling tool..." }); store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "search", arguments: { query: "test" } }, }); - const content = store.messages[0]?.content; - expect(content).toHaveLength(2); - expect(content?.[0]?.type).toBe("text"); - expect(content?.[1]?.type).toBe("tool-call"); - if (content?.[1]?.type === "tool-call") { - expect(content[1].name).toBe("search"); - expect(content[1].id).toBe("tc1"); + const chunks = store.messages[0]?.chunks; + expect(chunks).toHaveLength(2); + expect(chunks?.[0]?.type).toBe("text"); + expect(chunks?.[1]?.type).toBe("tool-batch"); + if (chunks?.[1]?.type === "tool-batch") { + expect(chunks[1].calls).toHaveLength(1); + expect(chunks[1].calls[0]?.id).toBe("tc1"); + expect(chunks[1].calls[0]?.name).toBe("search"); } }); - it("tool-result fills in result on matching tool-call segment", () => { + it("tool-result fills in result on the matching tool-batch entry", () => { store.handleEvent({ type: "text-delta", delta: "..." }); store.handleEvent({ type: "tool-call", @@ -295,14 +241,16 @@ describe("chat store logic", () => { type: "tool-result", toolResult: { toolCallId: "tc1", result: "found it", isError: false }, }); - const tc = store.messages[0]?.content[1]; - if (tc?.type === "tool-call") { - expect(tc.result).toBe("found it"); - expect(tc.isError).toBe(false); + 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); + } else { + expect.fail("Expected tool-batch chunk"); } }); - it("tool-call goes after previous tool-call, preserving both", () => { + it("two consecutive tool-calls coalesce into one tool-batch with two entries", () => { store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "read", arguments: {} }, @@ -311,22 +259,24 @@ describe("chat store logic", () => { type: "tool-call", toolCall: { id: "tc2", name: "write", arguments: {} }, }); - const content = store.messages[0]?.content; - expect(content).toHaveLength(2); - expect(content?.[0]?.type).toBe("tool-call"); - expect(content?.[1]?.type).toBe("tool-call"); + const chunks = store.messages[0]?.chunks; + expect(chunks).toHaveLength(1); + expect(chunks?.[0]?.type).toBe("tool-batch"); + if (chunks?.[0]?.type === "tool-batch") { + expect(chunks[0].calls).toHaveLength(2); + } }); - it("text after tool-call creates new text segment", () => { + it("text after tool-call opens a new text chunk", () => { store.handleEvent({ type: "tool-call", toolCall: { id: "tc1", name: "read", arguments: {} }, }); store.handleEvent({ type: "text-delta", delta: "Result: here" }); - const content = store.messages[0]?.content; - expect(content).toHaveLength(2); - expect(content?.[0]?.type).toBe("tool-call"); - expect(content?.[1]).toEqual({ type: "text", text: "Result: here" }); + const chunks = store.messages[0]?.chunks; + 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", () => { @@ -335,16 +285,33 @@ describe("chat store logic", () => { type: "done", message: { role: "assistant", content: "full content" }, }); - expect(store.messages[0]?.content).toEqual([{ type: "text", text: "partial" }]); + expect(store.messages[0]?.chunks).toEqual([{ type: "text", text: "partial" }]); expect(store.messages[0]?.isStreaming).toBe(false); }); - it("error event adds an error message and sets status to error", () => { + 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); - expect(store.messages[0]?.content).toEqual([ - { type: "text", text: "Error: something went wrong" }, - ]); + const chunks = store.messages[0]?.chunks; + 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.agentStatus).toBe("error"); }); @@ -355,6 +322,62 @@ describe("chat store logic", () => { 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; + expect(chunks).toHaveLength(2); + expect(chunks?.[1]?.type).toBe("system"); + if (chunks?.[1]?.type === "system") { + expect(chunks[1].kind).toBe("notice"); + expect(chunks[1].text).toBe("heads up"); + } + }); + + 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; + 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("clear resets all state", () => { store.sendMessage("hi"); store.handleEvent({ type: "text-delta", delta: "hello" }); @@ -362,16 +385,6 @@ describe("chat store logic", () => { expect(store.messages).toHaveLength(0); expect(store.agentStatus).toBe("idle"); }); - - it("reasoning-delta accumulates thinking text on current assistant message", () => { - store.handleEvent({ type: "reasoning-delta", delta: "First thought." }); - expect(store.messages).toHaveLength(1); - expect(store.messages[0]?.role).toBe("assistant"); - expect(store.messages[0]?.thinking).toBe("First thought."); - - store.handleEvent({ type: "reasoning-delta", delta: " Second thought." }); - expect(store.messages[0]?.thinking).toBe("First thought. Second thought."); - }); }); describe("permission-prompt handling", () => { @@ -591,7 +604,7 @@ describe("shell output parsing", () => { }); describe("shell-output event handling", () => { - it("shell-output stdout appends to last tool-call shellOutput", () => { + it("shell-output stdout appends to last tool-batch entry's shellOutput", () => { const s = createTestStore(); s.handleEvent({ type: "tool-call", @@ -599,27 +612,28 @@ describe("shell-output event handling", () => { }); s.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout" }); s.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout" }); - const seg = s.messages[0]?.content[0]; - if (seg?.type === "tool-call") { - expect(seg.shellOutput?.stdout).toBe("file1\nfile2\n"); - expect(seg.shellOutput?.stderr).toBe(""); + 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-call segment"); + expect.fail("Expected tool-batch chunk"); } }); - it("shell-output stderr appends to last tool-call shellOutput stderr", () => { + 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 seg = s.messages[0]?.content[0]; - if (seg?.type === "tool-call") { - expect(seg.shellOutput?.stderr).toBe("err line\n"); + 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-call segment"); + expect.fail("Expected tool-batch chunk"); } }); }); |
