diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 16:22:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 16:22:31 +0900 |
| commit | 17bc0a2cdaeefd4974f785c907d3515a38d45363 (patch) | |
| tree | 1834867d2f0ad5e82fbb985d7f602d8e1dffdb42 | |
| parent | 635cb6de7342ac87b27243652b1ad3b3a133d6a4 (diff) | |
| download | dispatch-web-17bc0a2cdaeefd4974f785c907d3515a38d45363.tar.gz dispatch-web-17bc0a2cdaeefd4974f785c907d3515a38d45363.zip | |
feat(chat): group batched tool calls into one DaisyUI list
Consume the backend's new stepId grouping key (wire/transport-contract
0.1.0 -> 0.2.0). foldEvent copies event.stepId onto live tool chunks so
live and replay group identically. New pure selector groupRenderedChunks
(core/chunks) folds a step's 2+ tool calls into one tool-batch group,
pairing each call with its result by toolCallId; single/no-stepId calls
stay as cards. ChatView renders a batch as a DaisyUI list (list-row per
pair). Fixtures updated for the now-required event stepId.
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 10 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 63 | ||||
| -rw-r--r-- | src/core/chunks/groups.test.ts | 125 | ||||
| -rw-r--r-- | src/core/chunks/groups.ts | 95 | ||||
| -rw-r--r-- | src/core/chunks/index.ts | 2 | ||||
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 12 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 2 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 4 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 3 | ||||
| -rw-r--r-- | src/features/chat/store.test.ts | 4 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 56 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 143 |
12 files changed, 448 insertions, 71 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 3a7a59c..fcc2cbf 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -5,9 +5,15 @@ > hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — > this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes. -> Depends on `@dispatch/wire` (see `wire.reference.md`) + `@dispatch/ui-contract` +> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes. +> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/ui-contract` > (see `ui-contract.reference.md`). +> +> **0.2.0 change (step grouping):** no shape change HERE — this contract's own types are +> identical. It only re-exports the bumped `@dispatch/wire`, whose `AgentEvent` tool variants +> now carry a required `stepId` and whose tool `Chunk`s carry an optional `stepId`. The +> `chat.delta` events streamed over WS and the `ConversationHistoryResponse.chunks` you already +> consume therefore now carry the step grouping key (see `wire.reference.md`). ## Endpoints (backend, confirmed live — CORS wildcard `*`, HTTP port 24203, WS port 24205) diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index ccf07bd..ed95351 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,7 +4,13 @@ > types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission > prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever `@dispatch/wire` changes. +> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever `@dispatch/wire` changes. +> +> **0.2.0 change (step grouping):** `ToolCallChunk`/`ToolResultChunk` gained an OPTIONAL +> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` gained a REQUIRED `stepId: StepId`. +> A `StepId` is the per-step grouping key for batched/parallel tool calls — group by equality. +> Live: read `event.stepId`. Replay: read `storedChunk.chunk.stepId` (NOT the envelope; absent on +> pre-0.2.0 rows / non-tool chunks — tolerate absence). `StoredChunk` envelope is UNCHANGED. ```ts /** @@ -23,7 +29,16 @@ export type Role = "system" | "user" | "assistant" | "tool"; /** Opaque identifier for a turn (one user→assistant cycle). */ export type TurnId = string & { readonly __brand: "TurnId" }; -/** Opaque identifier for a step (one LLM round-trip within a turn). */ +/** + * Opaque identifier for a step (one LLM round-trip within a turn). It is the + * authoritative grouping key for the tool calls a model batches together in a + * single step (parallel/batched calls): every `tool-call`/`tool-result` event + * and every persisted tool chunk (`ToolCallChunk`/`ToolResultChunk`) from the + * same step carries the SAME `stepId`, so a client groups a batch purely by + * equality — identically on the live stream and in replayed history. Per-turn + * unique and gap-free in step order; treat it as opaque (do not parse it). The + * runtime derives it deterministically from the turn id + 0-based step index. + */ export type StepId = string & { readonly __brand: "StepId" }; /** @@ -60,6 +75,18 @@ export interface ToolCallChunk { readonly toolCallId: string; readonly toolName: string; readonly input: unknown; + /** + * The step that produced this call — generation provenance stamped by the + * runtime when the model emits the call (NOT storage metadata like `seq`, + * which is why it lives on the chunk and travels with it through persistence + * and replay). Tool calls a model batches together in one step share the same + * `stepId`: the grouping key for rendering a parallel batch as one unit, and + * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional: + * absent on chunks reconstructed outside a turn and on rows persisted before + * this field existed, so a consumer must tolerate its absence (render + * ungrouped). + */ + readonly stepId?: StepId; } /** @@ -73,6 +100,15 @@ export interface ToolResultChunk { readonly toolName: string; readonly content: string; readonly isError: boolean; + /** + * The step that produced the originating call — equal to the `stepId` on the + * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result` + * AgentEvent, so a consumer groups a step's calls with their results. + * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`). + * Optional for the same reasons; `reconcile` copies it from the originating + * call onto a synthesized (interrupted) result. + */ + readonly stepId?: StepId; } /** An error that occurred during generation or tool dispatch. */ @@ -107,9 +143,11 @@ export interface ChatMessage { * sync cursor, assigned in append order) and records the `role` of the message * it belongs to. This makes a flat seq-ordered stream both incrementally * syncable ("give me chunks after seq N") and regroupable into messages by the - * client. `chunk` is the pure content unit, unchanged — `Chunk` itself never - * carries storage metadata (it is also passed to/from the provider, which has - * no use for a cursor). + * client. `chunk` is the content unit — `Chunk` carries no storage/sync cursor + * (`seq` lives here on the envelope, not on the chunk, since it is assigned by + * the store and the provider has no use for it). A chunk MAY still carry + * generation provenance assigned at production time (e.g. a tool chunk's + * `stepId`), which is intrinsic to the content and so travels with it. */ export interface StoredChunk { readonly seq: number; @@ -184,6 +222,14 @@ export interface TurnToolCallEvent { readonly type: "tool-call"; readonly conversationId: string; readonly turnId: string; + /** + * The step that produced this call. Tool calls a model batches together in + * one step share the same `stepId` — the grouping key for rendering a + * parallel batch as one unit. Matches the `stepId` on the matching + * `tool-result` event and on the persisted tool chunk + * (`StoredChunk.chunk.stepId`). + */ + readonly stepId: StepId; readonly toolCallId: string; readonly toolName: string; readonly input: unknown; @@ -194,6 +240,13 @@ export interface TurnToolResultEvent { readonly type: "tool-result"; readonly conversationId: string; readonly turnId: string; + /** + * The step that produced the originating call. Equal to the `stepId` on the + * matching `tool-call` event (same `toolCallId`) and on the persisted tool + * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with + * their results. + */ + readonly stepId: StepId; readonly toolCallId: string; readonly toolName: string; readonly content: string; diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts new file mode 100644 index 0000000..fbfda83 --- /dev/null +++ b/src/core/chunks/groups.test.ts @@ -0,0 +1,125 @@ +import type { Role, StepId } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { groupRenderedChunks } from "./groups"; +import type { RenderedChunk } from "./types"; + +const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({ + seq, + role, + chunk: { type: "text", text: t }, + provisional, +}); + +const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: id, + toolName: `tool-${id}`, + input: { id }, + ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), + }, + provisional, +}); + +const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ + seq, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: id, + toolName: `tool-${id}`, + content: `result-${id}`, + isError: false, + ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), + }, + provisional, +}); + +describe("groupRenderedChunks", () => { + it("returns no groups for an empty stream", () => { + expect(groupRenderedChunks([])).toEqual([]); + }); + + it("passes non-tool chunks through as single groups, in order", () => { + const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]); + expect(groups).toHaveLength(2); + expect(groups.every((g) => g.kind === "single")).toBe(true); + }); + + it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => { + const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.kind)).toEqual(["single", "single"]); + }); + + it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => { + const groups = groupRenderedChunks([ + call(1, "a"), + call(2, "b"), + result(3, "a"), + result(4, "b"), + ]); + expect(groups).toHaveLength(4); + expect(groups.every((g) => g.kind === "single")).toBe(true); + }); + + it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => { + const groups = groupRenderedChunks([ + call(1, "a", "s1"), + call(2, "b", "s1"), + result(3, "a", "s1"), + result(4, "b", "s1"), + ]); + expect(groups).toHaveLength(1); + const g = groups[0]; + if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(g.stepId).toBe("s1"); + expect(g.entries).toHaveLength(2); + expect(g.entries[0]?.call.toolCallId).toBe("a"); + expect(g.entries[0]?.result?.content).toBe("result-a"); + expect(g.entries[1]?.call.toolCallId).toBe("b"); + expect(g.entries[1]?.result?.content).toBe("result-b"); + }); + + it("positions the batch at the first call and keeps surrounding chunks in order", () => { + const groups = groupRenderedChunks([ + text(1, "assistant", "before"), + call(2, "a", "s1"), + call(3, "b", "s1"), + result(4, "a", "s1"), + result(5, "b", "s1"), + text(6, "assistant", "after"), + ]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); + }); + + it("marks the batch provisional when any of its calls/results is provisional", () => { + const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]); + const g = groups[0]; + if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(g.provisional).toBe(true); + expect(g.entries).toHaveLength(2); + expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet) + }); + + it("batches one step while leaving a different single-call step ungrouped", () => { + const groups = groupRenderedChunks([ + call(1, "a", "s1"), + call(2, "b", "s1"), + call(3, "c", "s2"), + result(4, "a", "s1"), + result(5, "b", "s1"), + result(6, "c", "s2"), + ]); + expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]); + const batch = groups[0]; + if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(batch.entries).toHaveLength(2); + // the s2 single call + its result remain as separate single groups + const singles = groups.slice(1); + expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call"); + expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result"); + }); +}); diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts new file mode 100644 index 0000000..6dc7e10 --- /dev/null +++ b/src/core/chunks/groups.ts @@ -0,0 +1,95 @@ +import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire"; +import type { RenderedChunk } from "./types"; + +/** + * One tool call within a batch, paired with its result (matched by `toolCallId`). + * `result` is null while the call is still pending (no result chunk yet). + */ +export interface ToolBatchEntry { + readonly call: ToolCallChunk; + readonly result: ToolResultChunk | null; +} + +/** + * A render group: either a single rendered chunk (rendered as today) or a batch + * of tool calls the model emitted together in one step (shared `stepId`), to be + * rendered as one grouped unit. + */ +export type RenderGroup = + | { readonly kind: "single"; readonly chunk: RenderedChunk } + | { + readonly kind: "tool-batch"; + readonly stepId: string; + readonly entries: readonly ToolBatchEntry[]; + readonly provisional: boolean; + }; + +/** + * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId` + * (the backend's authoritative batch key) where the step has 2+ calls become one + * `tool-batch` group, positioned at the first call and pairing each call with its + * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on + * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0 + * replay rows) and every non-tool chunk render as `single` groups, in order. + * + * Pure: input → output, no DOM, no Svelte. + */ +export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] { + // 1. Steps that batched 2+ tool calls. + const callsPerStep = new Map<string, number>(); + for (const rc of rendered) { + if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) { + callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1); + } + } + const batchSteps = new Set<string>(); + for (const [stepId, count] of callsPerStep) { + if (count >= 2) batchSteps.add(stepId); + } + + // 2. toolCallIds belonging to a batch (so their results are absorbed), and a + // lookup of result chunks by toolCallId for pairing. + const batchCallIds = new Set<string>(); + const resultByCallId = new Map<string, ToolResultChunk>(); + for (const rc of rendered) { + const chunk = rc.chunk; + if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { + batchCallIds.add(chunk.toolCallId); + } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) { + resultByCallId.set(chunk.toolCallId, chunk); + } + } + + // 3. Emit groups in stream order; each batch lands at its first call. + const groups: RenderGroup[] = []; + const emittedSteps = new Set<string>(); + for (const rc of rendered) { + const chunk = rc.chunk; + + if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { + const stepId = chunk.stepId; + if (emittedSteps.has(stepId)) continue; + emittedSteps.add(stepId); + + const entries: ToolBatchEntry[] = []; + let provisional = false; + for (const inner of rendered) { + if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { + const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; + entries.push({ call: inner.chunk, result }); + if (inner.provisional) provisional = true; + } + } + groups.push({ kind: "tool-batch", stepId, entries, provisional }); + continue; + } + + if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { + continue; // absorbed into its batch + } + + groups.push({ kind: "single", chunk: rc }); + } + + return groups; +} diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index 67739bc..0718c0d 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,3 +1,5 @@ +export type { RenderGroup, ToolBatchEntry } from "./groups"; +export { groupRenderedChunks } from "./groups"; export { appendUserMessage, applyHistory, foldEvent, initialState } from "./reducer"; export { selectChunks, selectMessages } from "./selectors"; export type { diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index b7165e4..7ecc349 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -1,4 +1,5 @@ import type { + StepId, StoredChunk, TurnDoneEvent, TurnErrorEvent, @@ -39,6 +40,7 @@ const toolCall = ( toolCallId: string, toolName: string, input: unknown, + stepId = "s0", ): TurnToolCallEvent => ({ type: "tool-call", conversationId: "c1", @@ -46,6 +48,7 @@ const toolCall = ( toolCallId, toolName, input, + stepId: stepId as StepId, }); const toolResult = ( @@ -53,6 +56,7 @@ const toolResult = ( toolCallId: string, toolName: string, content: string, + stepId = "s0", ): TurnToolResultEvent => ({ type: "tool-result", conversationId: "c1", @@ -61,6 +65,7 @@ const toolResult = ( toolName, content, isError: false, + stepId: stepId as StepId, }); const usageEvent = (turnId: string, inputTokens: number, outputTokens: number): TurnUsageEvent => ({ @@ -161,15 +166,17 @@ describe("foldEvent — tool-call then tool-result", () => { it("tool-call then tool-result render in order", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" })); - s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0")); + s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0")); expect(s.provisional).toHaveLength(2); expect(s.provisional[0]?.role).toBe("assistant"); + // foldEvent copies the event's stepId onto the chunk (grouping key). expect(s.provisional[0]?.chunk).toEqual({ type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { cmd: "ls" }, + stepId: "t1#0", }); expect(s.provisional[1]?.role).toBe("tool"); expect(s.provisional[1]?.chunk).toEqual({ @@ -178,6 +185,7 @@ describe("foldEvent — tool-call then tool-result", () => { toolName: "bash", content: "file.txt", isError: false, + stepId: "t1#0", }); }); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index d3b999d..1dcfa39 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -106,6 +106,7 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, + stepId: event.stepId, }; return { ...state, @@ -122,6 +123,7 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript toolName: event.toolName, content: event.content, isError: event.isError, + stepId: event.stepId, }; return { ...state, diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index c0f276f..50b7f35 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -1,5 +1,5 @@ import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract"; -import type { AgentEvent, StoredChunk } from "@dispatch/wire"; +import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { assertAgentEventExhaustive, @@ -36,6 +36,7 @@ describe("classifies every AgentEvent type", () => { toolCallId: "tc1", toolName: "read", input: {}, + stepId: "t1#0" as StepId, }, { type: "tool-result", @@ -45,6 +46,7 @@ describe("classifies every AgentEvent type", () => { toolName: "read", content: "ok", isError: false, + stepId: "t1#0" as StepId, }, { type: "tool-output", diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index f1e8e29..4f2091a 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -1,4 +1,5 @@ -export type { RenderedChunk } from "../../core/chunks"; +export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chunks"; +export { groupRenderedChunks } from "../../core/chunks"; export type { ChatTransport, HistorySync } from "./ports"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index de60b14..71781ac 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1,4 +1,4 @@ -import type { AgentEvent, StoredChunk } from "@dispatch/wire"; +import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import { createChatStore } from "./store.svelte"; import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers"; @@ -327,6 +327,7 @@ describe("createChatStore", () => { toolCallId: "tc1", toolName: "read_file", input: { path: "/tmp/test.txt" }, + stepId: "t1#0" as StepId, }), ); store.handleDelta( @@ -338,6 +339,7 @@ describe("createChatStore", () => { toolName: "read_file", content: "file contents", isError: false, + stepId: "t1#0" as StepId, }), ); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 2099257..43822a7 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -1,3 +1,4 @@ +import type { StepId } from "@dispatch/wire"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -158,6 +159,61 @@ describe("ChatView", () => { expect(log.children).toHaveLength(0); }); + it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 3, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + + // One DaisyUI list with two rows (one per call), not separate cards. + const lists = container.querySelectorAll("ul.list"); + expect(lists).toHaveLength(1); + expect(container.querySelectorAll("ul.list > li.list-row")).toHaveLength(2); + + // Both call names + the available result are shown; the result is absorbed + // (no standalone tool-result card). + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + }); + it("thinking <details> stays open across a streaming update", async () => { const initial: RenderedChunk[] = [ { diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 0234852..60da571 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -1,70 +1,95 @@ <script lang="ts"> - import type { RenderedChunk } from "../index"; + import { groupRenderedChunks, type RenderedChunk } from "../index"; let { chunks }: { chunks: readonly RenderedChunk[] } = $props(); + + const groups = $derived(groupRenderedChunks(chunks)); </script> -<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite"> - {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)} - {#if rendered.role === "user"} - <!-- User: a speech bubble, left-aligned --> - <div class="chat chat-start"> - <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}> - {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> - {/if} - </div> +{#snippet chunkRow(rendered: RenderedChunk)} + {#if rendered.role === "user"} + <!-- User: a speech bubble, left-aligned --> + <div class="chat chat-start"> + <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}> + {#if rendered.chunk.type === "text"} + <p>{rendered.chunk.text}</p> + {/if} </div> - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - <!-- Tool: a regular (non-speech) card. Nested in the chat-start grid via - a transparent, padding-stripped chat-bubble shim so the card inherits - the same left offset as the bubble bodies (no magic margin). --> - <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0"> - <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}> - {#if rendered.chunk.type === "tool-call"} - <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"> - <strong>{rendered.chunk.toolName}</strong> - <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> - </div> - {:else} - <div - class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm" - class:text-error={rendered.chunk.isError} - > - <strong>{rendered.chunk.toolName}</strong> - <pre class="text-xs mt-1">{rendered.chunk.content}</pre> - </div> - {/if} - </div> + </div> + {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + <!-- Single tool call/result: a regular (non-speech) card. Nested in the + chat-start grid via a transparent, padding-stripped chat-bubble shim so + the card inherits the same left offset as the bubble bodies. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0"> + <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}> + {#if rendered.chunk.type === "tool-call"} + <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"> + <strong>{rendered.chunk.toolName}</strong> + <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> + </div> + {:else} + <div + class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm" + class:text-error={rendered.chunk.isError} + > + <strong>{rendered.chunk.toolName}</strong> + <pre class="text-xs mt-1">{rendered.chunk.content}</pre> + </div> + {/if} </div> - {:else} - <!-- Assistant / system / error: an INVISIBLE speech bubble — the same - DaisyUI chat-start grid as the user bubble, so it inherits the - identical left spacing (incl. the small leading gap). Transparent - bg means no visible body and no visible tail; full width capped to - a readable column. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> - <div - class="chat-bubble w-full bg-transparent" - class:opacity-50={rendered.provisional} - > - {#if rendered.chunk.type === "text"} + </div> + {:else} + <!-- Assistant / system / error: an INVISIBLE speech bubble — same chat-start + grid as the user bubble, so it inherits identical left spacing. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> + <div class="chat-bubble w-full bg-transparent" class:opacity-50={rendered.provisional}> + {#if rendered.chunk.type === "text"} + <p>{rendered.chunk.text}</p> + {:else if rendered.chunk.type === "thinking"} + <details> + <summary>Thinking</summary> <p>{rendered.chunk.text}</p> - {:else if rendered.chunk.type === "thinking"} - <details> - <summary>Thinking</summary> - <p>{rendered.chunk.text}</p> - </details> - {:else if rendered.chunk.type === "error"} - <div class="text-error" role="alert"> - {rendered.chunk.message} - {#if rendered.chunk.code} - <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> - {/if} - </div> - {:else if rendered.chunk.type === "system"} - <div class="text-sm opacity-70">{rendered.chunk.text}</div> - {/if} + </details> + {:else if rendered.chunk.type === "error"} + <div class="text-error" role="alert"> + {rendered.chunk.message} + {#if rendered.chunk.code} + <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> + {/if} + </div> + {:else if rendered.chunk.type === "system"} + <div class="text-sm opacity-70">{rendered.chunk.text}</div> + {/if} + </div> + </div> + {/if} +{/snippet} + +<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite"> + {#each groups as group, i (group.kind === "tool-batch" ? `b${group.stepId}` : group.chunk.seq != null ? `c${group.chunk.seq}` : `p${i}`)} + {#if group.kind === "single"} + {@render chunkRow(group.chunk)} + {:else} + <!-- Batched tool calls (one step): a single bubble holding a DaisyUI list, + one row per call paired with its result. Same chat-start grid shim as + the single tool card so it lines up with the other messages. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0"> + <div class="chat-bubble bg-transparent" class:opacity-50={group.provisional}> + <ul class="list w-fit max-w-full rounded-box bg-base-200 text-sm"> + {#each group.entries as entry (entry.call.toolCallId)} + <li class="list-row"> + <div> + <strong>{entry.call.toolName}</strong> + <pre class="text-xs mt-1">{JSON.stringify(entry.call.input, null, 2)}</pre> + {#if entry.result} + <pre + class="text-xs mt-1" + class:text-error={entry.result.isError}>{entry.result.content}</pre> + {/if} + </div> + </li> + {/each} + </ul> </div> </div> {/if} |
