summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-27 18:35:18 +0900
committerAdam Malczewski <[email protected]>2026-05-27 18:35:18 +0900
commitca6ee91c5e1167b1929eedbb96c76dfa24e7d026 (patch)
treebc23acac2e7caaf2e59eacbc21bfc9b41f3c1458 /packages/frontend
parentda57842686ebfd157396551fc76d0c18f7676335 (diff)
downloaddispatch-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.json1
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte76
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte4
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts364
-rw-r--r--packages/frontend/src/lib/types.ts61
-rw-r--r--packages/frontend/tests/chat-store.test.ts408
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");
}
});
});