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 /src/core/chunks/groups.ts | |
| 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.
Diffstat (limited to 'src/core/chunks/groups.ts')
| -rw-r--r-- | src/core/chunks/groups.ts | 95 |
1 files changed, 95 insertions, 0 deletions
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; +} |
