diff options
| author | Adam Malczewski <[email protected]> | 2026-10-15 08:27:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-15 08:27:26 +0900 |
| commit | 8676bd29452f7539622901cb5ae703a3fdaebd07 (patch) | |
| tree | 8472958c36ff915045605ee671152cdfeda39c61 /src/core/chunks | |
| parent | a8b15ac6a495eae27577e6161f320574bcf795f0 (diff) | |
| download | dispatch-web-8676bd29452f7539622901cb5ae703a3fdaebd07.tar.gz dispatch-web-8676bd29452f7539622901cb5ae703a3fdaebd07.zip | |
feat(tool-call-cards): pair calls and results in summarized collapsible bubbles
Diffstat (limited to 'src/core/chunks')
| -rw-r--r-- | src/core/chunks/groups.test.ts | 234 | ||||
| -rw-r--r-- | src/core/chunks/groups.ts | 124 | ||||
| -rw-r--r-- | src/core/chunks/index.ts | 2 |
3 files changed, 134 insertions, 226 deletions
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts index db632a3..cc2aa40 100644 --- a/src/core/chunks/groups.test.ts +++ b/src/core/chunks/groups.test.ts @@ -3,16 +3,16 @@ 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 => ({ +const text = (seq: number, role: Role, t: string): RenderedChunk => ({ seq, role, chunk: { type: "text", text: t }, - provisional, + provisional: false, }); - const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ seq, role: "assistant", + provisional, chunk: { type: "tool-call", toolCallId: id, @@ -20,12 +20,11 @@ const call = (seq: number, id: string, stepId?: string, provisional = false): Re input: { id }, ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), }, - provisional, }); - const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ seq, role: "tool", + provisional, chunk: { type: "tool-result", toolCallId: id, @@ -34,165 +33,120 @@ const result = (seq: number, id: string, stepId?: string, provisional = false): isError: false, ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), }, - provisional, }); +function toolGroups(rows: readonly RenderedChunk[]) { + return groupRenderedChunks(rows).map((g) => { + if (g.kind !== "tool") throw new Error("expected a tool group"); + return g; + }); +} + 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("passes non-tool chunks through unchanged and in order", () => { + const rows = [text(1, "user", "hi"), text(2, "assistant", "hello")]; + expect(groupRenderedChunks(rows)).toEqual(rows.map((chunk) => ({ kind: "single", chunk }))); }); - - 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"), + it("pairs a single call and result into one group", () => { + const c = call(1, "a", "s1"); + const r = result(2, "a", "s1"); + expect(toolGroups([c, r])).toEqual([ + { + kind: "tool", + entry: { toolCallId: "a", toolName: "tool-a", call: c.chunk, result: r.chunk }, + stepId: "s1", + provisional: false, + }, ]); - 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([ + it("renders parallel calls independently in call order, with out-of-order results", () => { + const groups = toolGroups([ call(1, "a", "s1"), call(2, "b", "s1"), - result(3, "a", "s1"), - result(4, "b", "s1"), + result(3, "b", "s1"), + result(4, "a", "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"); + expect(groups.map((g) => g.entry.toolCallId)).toEqual(["a", "b"]); + expect(groups.map((g) => g.entry.result?.content)).toEqual(["result-a", "result-b"]); }); - - it("positions the batch at the first call and keeps surrounding chunks in order", () => { + it("pairs legacy chunks without stepId and mismatching stepIds by toolCallId", () => { + const groups = toolGroups([ + call(1, "a"), + result(2, "a"), + call(3, "b", "s1"), + result(4, "b", "s2"), + ]); + expect(groups).toHaveLength(2); + expect(groups[0]?.entry.result).not.toBeNull(); + expect(groups[1]?.stepId).toBe("s1"); + }); + it("keeps surrounding text at its position and absorbs results", () => { 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"), + text(3, "assistant", "between"), + call(4, "b", "s1"), + result(5, "a"), + result(6, "b"), + text(7, "assistant", "after"), ]); - expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool", "single", "tool", "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("keeps pending calls and orphan results", () => { + const groups = toolGroups([call(1, "pending"), result(2, "orphan", "s2", true)]); + expect(groups[0]?.entry.result).toBeNull(); + expect(groups[1]?.entry.call).toBeNull(); + expect(groups[1]?.entry.toolName).toBe("tool-orphan"); + expect(groups[1]?.provisional).toBe(true); + expect(groups[1]?.stepId).toBe("s2"); }); - - 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"); + it.each([ + [true, false], + [false, true], + [false, false], + ])("combines call/result provisional flags (%s, %s)", (cp, rp) => { + expect(toolGroups([call(1, "a", "s1", cp), result(2, "a", "s1", rp)])[0]?.provisional).toBe( + cp || rp, + ); }); - - // --- Duplicate toolCallId hardening (fix-toolcall-duplicate-keys) --- - // A provider double-emission (e.g. openrouter/stealth/ox-alpha) can persist the - // SAME toolCallId twice within one step. The renderer must NEVER crash on that - // (Svelte `each_key_duplicate` aborts the reactive flush — the tab appears dead). - - it("dedupes duplicate toolCallIds within a batch", () => { - // The PII_REDACTED_CONVERSATION_SHORT_01 pattern: every id emitted twice, in order (seq 1,2 then 3,4). - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - call(3, "a", "s1"), - call(4, "b", "s1"), + it("prefers call provenance, falling back to result provenance", () => { + const groups = toolGroups([ + { ...call(1, "a", "s1"), turnId: "t1" }, + { ...result(2, "a", "s2"), turnId: "t2" }, + call(3, "b"), + { ...result(4, "b", "s3"), turnId: "t3" }, ]); - expect(groups).toHaveLength(1); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.entries).toHaveLength(2); - expect(g.entries.map((e) => e.call.toolCallId)).toEqual(["a", "b"]); - }); - - it("dedupes duplicate toolCallIds and pairs each unique call with its result", () => { - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - call(3, "a", "s1"), - call(4, "b", "s1"), - result(5, "a", "s1"), - result(6, "b", "s1"), - result(7, "a", "s1"), - result(8, "b", "s1"), + expect(groups.map((g) => [g.stepId, g.turnId])).toEqual([ + ["s1", "t1"], + ["s3", "t3"], ]); - expect(groups).toHaveLength(1); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - 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("keeps the first occurrence of a duplicate toolCallId and skips the second", () => { - const firstA = call(1, "a", "s1"); // first occurrence of a - const firstB = call(2, "b", "s1"); // first occurrence of b - const groups = groupRenderedChunks([ - firstA, - firstB, - call(3, "a", "s1"), // duplicate of a — skipped - call(4, "b", "s1"), // duplicate of b — skipped + it("dedupes calls/results across steps, first occurrence wins", () => { + const first = call(1, "a", "s1", true); + const firstResult = result(4, "a", "s1"); + const groups = toolGroups([ + first, + call(2, "a", "s2"), + call(3, "b"), + firstResult, + result(5, "a", "s2"), + result(6, "b"), + result(7, "b"), ]); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.entries).toHaveLength(2); - // The FIRST occurrence (by stream order) is the one kept — referential - // equality against the seq-1 / seq-2 chunks (the entry stores the inner - // chunk; the outer `seq` lives on the RenderedChunk wrapper). - expect(g.entries[0]?.call).toBe(firstA.chunk); - expect(g.entries[1]?.call).toBe(firstB.chunk); + expect(groups).toHaveLength(2); + expect(groups[0]?.entry.call).toBe(first.chunk); + expect(groups[0]?.entry.result).toBe(firstResult.chunk); + expect(groups[0]?.provisional).toBe(true); }); - - it("keeps the first (provisional) occurrence of a duplicate and marks the batch provisional", () => { - // One provisional + one committed chunk with the SAME id: the first - // (provisional) occurrence wins, and the batch stays provisional. - const prov = call(1, "a", "s1", true); - const groups = groupRenderedChunks([prov, call(2, "a", "s1")]); - 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(1); - expect(g.entries[0]?.call).toBe(prov.chunk); - expect(g.entries[0]?.result).toBeNull(); + it("dedupes orphan results too", () => { + expect(toolGroups([result(1, "a"), result(2, "a")])).toHaveLength(1); + }); + it("positions a card at its call even when its result came first", () => { + const c = call(3, "a"); + const groups = groupRenderedChunks([result(1, "a"), text(2, "assistant", "before call"), c]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool"]); }); }); diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts index 9df0fa5..c8493cc 100644 --- a/src/core/chunks/groups.ts +++ b/src/core/chunks/groups.ts @@ -1,111 +1,65 @@ 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; +/** One tool call and its response. Null call = unloaded history; null result = pending. */ +export interface ToolEntry { + readonly toolCallId: string; + readonly toolName: string; + readonly call: ToolCallChunk | null; 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 kind: "tool"; + readonly entry: ToolEntry; readonly provisional: boolean; - /** The producing turn (from the batch's first call chunk), when known. */ + readonly stepId?: string; readonly turnId?: string; }; /** - * 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. + * One independent group per call, at the call's position, absorbing its result + * by toolCallId (including legacy chunks without stepId). Orphan results stay + * visible at their own position. First occurrence wins for duplicate ids, so + * the stable t<toolCallId> render keys are always unique, even on corrupt replay. + * Pure: input → output; no DOM, effects, or 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>(); + const calls = new Map<string, RenderedChunk>(); + const results = new Map<string, RenderedChunk>(); 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); - } + const c = rc.chunk; + if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc); + if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc); } - // 3. Emit groups in stream order; each batch lands at its first call. const groups: RenderGroup[] = []; - const emittedSteps = new Set<string>(); + const emitted = 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[] = []; - // Dedupe by toolCallId within the batch: a provider double-emission (or any - // corrupted transcript) can carry the SAME call id on multiple tool-call - // chunks in one step. The first occurrence (stream order) wins; later ones - // are skipped so the keyed `{#each}` in the UI never sees duplicate keys - // (Svelte `each_key_duplicate` aborts the reactive flush — tab appears dead). - const seenCallIds = new Set<string>(); - let provisional = false; - let turnId: string | undefined; - for (const inner of rendered) { - if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { - if (seenCallIds.has(inner.chunk.toolCallId)) continue; - seenCallIds.add(inner.chunk.toolCallId); - const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; - entries.push({ call: inner.chunk, result }); - if (inner.provisional) provisional = true; - turnId ??= inner.turnId; - } - } - groups.push( - turnId !== undefined - ? { kind: "tool-batch", stepId, entries, provisional, turnId } - : { kind: "tool-batch", stepId, entries, provisional }, - ); + const c = rc.chunk; + if (c.type !== "tool-call" && c.type !== "tool-result") { + groups.push({ kind: "single", chunk: rc }); continue; } - - if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { - continue; // absorbed into its batch - } - - groups.push({ kind: "single", chunk: rc }); + if (emitted.has(c.toolCallId)) continue; + // A matching call owns the card's position, even if the result came first. + if (c.type === "tool-result" && calls.has(c.toolCallId)) continue; + emitted.add(c.toolCallId); + const callRow = calls.get(c.toolCallId); + const resultRow = results.get(c.toolCallId); + const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null; + const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null; + const stepId = call?.stepId ?? result?.stepId; + const turnId = callRow?.turnId ?? resultRow?.turnId; + groups.push({ + kind: "tool", + entry: { toolCallId: c.toolCallId, toolName: call?.toolName ?? c.toolName, call, result }, + provisional: Boolean(callRow?.provisional || resultRow?.provisional), + ...(stepId !== undefined ? { stepId } : {}), + ...(turnId !== undefined ? { turnId } : {}), + }); } - return groups; } diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index bdd6ce4..3822a79 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,4 +1,4 @@ -export type { RenderGroup, ToolBatchEntry } from "./groups"; +export type { RenderGroup, ToolEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; export { resolveImageUrl } from "./image-url"; export { |
