diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 16:07:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 16:07:35 +0900 |
| commit | 904c6d7cc882ea6e092f03f9f487d80b75426440 (patch) | |
| tree | 0b97107a859f8d347071c01a6907c778dd9cd05a | |
| parent | efddee1edd2924725a4dd240894666ede97b67b9 (diff) | |
| download | dispatch-904c6d7cc882ea6e092f03f9f487d80b75426440.tar.gz dispatch-904c6d7cc882ea6e092f03f9f487d80b75426440.zip | |
feat(wire,kernel,conversation-store): step grouping via stepId for batched tool calls
Expose a per-step grouping key so a client can render a model's batched/parallel
tool calls (those emitted in one step) as one unit, on both the live stream and
replayed history. Key = branded StepId, derived turnId#stepIndex (0-based).
- [email protected]: required stepId on Turn{Tool,ToolResult}Event; optional stepId on
Tool{Call,Result}Chunk (generation provenance on the chunk, not the StoredChunk
envelope — StoredChunk unchanged). [email protected] (re-export bump).
- kernel-runtime: mint stepId per step; stamp on tool chunks + tool events.
- conversation-store: chunk-carried stepId round-trips append/load/loadSince for
free; reconcile copies it onto synthesized (interrupted) results.
- cli: stepId added to event test fixtures (renderer unchanged).
typecheck clean; 509 vitest + 89 bun; biome 0/0. FE courier reply + reference
snapshots regenerated in ../dispatch-web.
| -rw-r--r-- | GLOSSARY.md | 1 | ||||
| -rw-r--r-- | bun.lock | 7 | ||||
| -rw-r--r-- | packages/cli/package.json | 3 | ||||
| -rw-r--r-- | packages/cli/src/render.test.ts | 4 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.test.ts | 53 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.ts | 6 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 92 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/events.ts | 16 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.test.ts | 136 | ||||
| -rw-r--r-- | packages/kernel/src/runtime/run-turn.ts | 10 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 2 | ||||
| -rw-r--r-- | packages/wire/package.json | 2 | ||||
| -rw-r--r-- | packages/wire/src/index.ts | 55 | ||||
| -rw-r--r-- | tasks.md | 36 |
14 files changed, 406 insertions, 17 deletions
diff --git a/GLOSSARY.md b/GLOSSARY.md index 42cd557..0ec7ea2 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -17,6 +17,7 @@ | **conversationId** | The string identifier for a conversation. Threads multi-turn history; the `/chat` request field that continues an existing conversation. | tabId, sessionId, chatId | | **turn** | One user message → assistant response cycle (may span multiple steps). | — | | **step** | One LLM round-trip within a turn (may emit multiple tool calls). | iteration | +| **stepId** | The identifier of a step, stamped on each `tool-call`/`tool-result` event and tool chunk it produces, so a client groups a parallel/batched tool-call set by equality. Branded `StepId`; the runtime derives it deterministically as `<turnId>#<stepIndex>` (0-based). Generation provenance carried ON the tool chunk (unlike `seq`, which is a store-assigned sync cursor on the `StoredChunk` envelope). Treat as opaque. | batchId, step index (as the wire key) | | **tool call** | A model's request to run a tool within a step. | function call (when meaning a tool call) | | **chunk** | One ordered piece of a message (text, thinking, tool-call/result, etc.), append-only in the log. | block, segment | | **seq** | The monotonic, gap-free, per-conversation sequence number stamped on each chunk as it is appended to the log. The sync cursor: a client requests `?sinceSeq=N` to fetch only newer chunks. Storage/sync metadata, never message content. | cursor (when meaning the number), offset, index | @@ -23,6 +23,7 @@ "version": "0.0.0", "dependencies": { "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", }, }, "packages/conversation-store": { @@ -141,7 +142,7 @@ }, "packages/transport-contract": { "name": "@dispatch/transport-contract", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*", @@ -172,11 +173,11 @@ }, "packages/ui-contract": { "name": "@dispatch/ui-contract", - "version": "0.0.0", + "version": "0.1.0", }, "packages/wire": { "name": "@dispatch/wire", - "version": "0.0.0", + "version": "0.1.0", }, }, "packages": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 9b286fd..3d99629 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { - "@dispatch/transport-contract": "workspace:*" + "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*" } } diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts index bfdb791..c638584 100644 --- a/packages/cli/src/render.test.ts +++ b/packages/cli/src/render.test.ts @@ -1,4 +1,5 @@ import type { AgentEvent } from "@dispatch/transport-contract"; +import type { StepId } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { renderEvent } from "./render.js"; @@ -41,6 +42,7 @@ describe("renderEvent", () => { type: "tool-call", conversationId: "c", turnId: "t", + stepId: "t1#0" as StepId, toolCallId: "tc1", toolName: "read_file", input: { path: "/foo" }, @@ -67,6 +69,7 @@ describe("renderEvent", () => { type: "tool-result", conversationId: "c", turnId: "t", + stepId: "t1#0" as StepId, toolCallId: "tc1", toolName: "read_file", content: "file contents", @@ -82,6 +85,7 @@ describe("renderEvent", () => { type: "tool-result", conversationId: "c", turnId: "t", + stepId: "t1#0" as StepId, toolCallId: "tc1", toolName: "read_file", content: "not found", diff --git a/packages/conversation-store/src/reconcile.test.ts b/packages/conversation-store/src/reconcile.test.ts index f65b804..90d1157 100644 --- a/packages/conversation-store/src/reconcile.test.ts +++ b/packages/conversation-store/src/reconcile.test.ts @@ -1,4 +1,4 @@ -import type { ChatMessage } from "@dispatch/kernel"; +import type { ChatMessage, StepId } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { reconcile } from "./reconcile.js"; @@ -234,4 +234,55 @@ describe("reconcile", () => { expect(result[0]?.chunks).toHaveLength(3); expect(result[1]?.role).toBe("tool"); }); + + it("copies the originating tool-call's stepId onto a synthesized result", () => { + const stepId = "step_orphan" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_sid", + toolName: "someTool", + input: {}, + stepId, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + expect(result[1]?.role).toBe("tool"); + const chunk = result[1]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.stepId).toBe(stepId); + } + }); + + it("omits stepId when the dangling call has none", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_nosid", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + const chunk = result[1]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk).not.toHaveProperty("stepId"); + } + }); }); diff --git a/packages/conversation-store/src/reconcile.ts b/packages/conversation-store/src/reconcile.ts index 6dda9d8..a182c1e 100644 --- a/packages/conversation-store/src/reconcile.ts +++ b/packages/conversation-store/src/reconcile.ts @@ -23,13 +23,15 @@ export function reconcile(messages: readonly ChatMessage[]): ChatMessage[] { const result: ChatMessage[] = [...messages]; for (const call of orphaned) { - const synthesized: ToolResultChunk = { - type: "tool-result", + const base = { + type: "tool-result" as const, toolCallId: call.toolCallId, toolName: call.toolName, content: "interrupted: tool execution did not complete", isError: true, }; + const synthesized: ToolResultChunk = + call.stepId !== undefined ? { ...base, stepId: call.stepId } : base; result.push({ role: "tool", chunks: [synthesized] }); } diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 4257d2b..e00fdc2 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, StorageNamespace } from "@dispatch/kernel"; +import type { ChatMessage, StepId, StorageNamespace } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; import { createConversationStore } from "./store.js"; @@ -334,4 +334,94 @@ describe("ConversationStore", () => { expect(all[0]?.seq).toBe(1); expect(all[1]?.seq).toBe(2); }); + + it("append → loadSince preserves a tool chunk's stepId", async () => { + const store = createConversationStore(storage); + const stepId = "step_abc" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_sid", + toolName: "myTool", + input: {}, + stepId, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_sid", + toolName: "myTool", + content: "ok", + isError: false, + stepId, + }, + ], + }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(2); + const callChunk = chunks[0]?.chunk; + expect(callChunk?.type).toBe("tool-call"); + if (callChunk?.type === "tool-call") { + expect(callChunk.stepId).toBe(stepId); + } + const resultChunk = chunks[1]?.chunk; + expect(resultChunk?.type).toBe("tool-result"); + if (resultChunk?.type === "tool-result") { + expect(resultChunk.stepId).toBe(stepId); + } + }); + + it("load preserves a tool chunk's stepId", async () => { + const store = createConversationStore(storage); + const stepId = "step_xyz" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_lid", + toolName: "myTool", + input: { a: 1 }, + stepId, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_lid", + toolName: "myTool", + content: "done", + isError: false, + stepId, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(2); + const callChunk = result[0]?.chunks[0]; + expect(callChunk?.type).toBe("tool-call"); + if (callChunk?.type === "tool-call") { + expect(callChunk.stepId).toBe(stepId); + } + const resultChunk = result[1]?.chunks[0]; + expect(resultChunk?.type).toBe("tool-result"); + if (resultChunk?.type === "tool-result") { + expect(resultChunk.stepId).toBe(stepId); + } + }); }); diff --git a/packages/kernel/src/runtime/events.ts b/packages/kernel/src/runtime/events.ts index a209b00..deeb012 100644 --- a/packages/kernel/src/runtime/events.ts +++ b/packages/kernel/src/runtime/events.ts @@ -1,3 +1,4 @@ +import type { StepId } from "../contracts/conversation.js"; import type { AgentEvent } from "../contracts/events.js"; import type { Usage } from "../contracts/provider.js"; @@ -16,22 +17,33 @@ export function reasoningDeltaEvent( export function toolCallEvent( conversationId: string, turnId: string, + stepId: StepId, toolCallId: string, toolName: string, input: unknown, ): AgentEvent { - return { type: "tool-call", conversationId, turnId, toolCallId, toolName, input }; + return { type: "tool-call", conversationId, turnId, stepId, toolCallId, toolName, input }; } export function toolResultEvent( conversationId: string, turnId: string, + stepId: StepId, toolCallId: string, toolName: string, content: string, isError: boolean, ): AgentEvent { - return { type: "tool-result", conversationId, turnId, toolCallId, toolName, content, isError }; + return { + type: "tool-result", + conversationId, + turnId, + stepId, + toolCallId, + toolName, + content, + isError, + }; } export function toolOutputEvent( diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index 488a77e..42a846b 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -1689,4 +1689,140 @@ describe("runTurn", () => { } }); }); + + describe("stepId", () => { + it("tool-call and tool-result events carry stepId", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const toolCallEvt = events.find((e) => e.type === "tool-call"); + const toolResultEvt = events.find((e) => e.type === "tool-result"); + + expect(toolCallEvt).toBeDefined(); + expect(toolResultEvt).toBeDefined(); + + if (toolCallEvt?.type === "tool-call" && toolResultEvt?.type === "tool-result") { + expect(toolCallEvt.stepId).toBeDefined(); + expect(toolResultEvt.stepId).toBeDefined(); + expect(toolCallEvt.stepId).toBe(toolResultEvt.stepId); + } + }); + + it("tool calls in the SAME step share one stepId; a later step gets a different one", async () => { + const toolA = createFakeTool("a", async () => ({ content: "a-result" })); + const toolB = createFakeTool("b", async () => ({ content: "b-result" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} }, + { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "tool-call", toolCallId: "tc3", toolName: "a", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const { events, emit } = createCollectingEmit(); + + await runTurn({ + provider, + messages: [userMessage], + tools: [toolA, toolB], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit, + }); + + const toolCallEvts = events.filter((e) => e.type === "tool-call"); + expect(toolCallEvts.length).toBeGreaterThanOrEqual(2); + + const step0Calls = toolCallEvts.filter( + (e) => e.type === "tool-call" && (e.toolCallId === "tc1" || e.toolCallId === "tc2"), + ); + const step1Call = toolCallEvts.find((e) => e.type === "tool-call" && e.toolCallId === "tc3"); + + expect(step0Calls).toHaveLength(2); + if (step0Calls[0]?.type === "tool-call" && step0Calls[1]?.type === "tool-call") { + expect(step0Calls[0].stepId).toBe(step0Calls[1].stepId); + } + + if (step1Call?.type === "tool-call" && step0Calls[0]?.type === "tool-call") { + expect(step1Call.stepId).not.toBe(step0Calls[0].stepId); + } + }); + + it("tool chunks in the result carry stepId", async () => { + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + const result = await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "conv-1", + turnId: "turn-1", + emit: () => {}, + }); + + const toolCallMsg = result.messages.find( + (m) => m.role === "assistant" && m.chunks.some((c) => c.type === "tool-call"), + ); + const toolResultMsg = result.messages.find((m) => m.role === "tool"); + + expect(toolCallMsg).toBeDefined(); + expect(toolResultMsg).toBeDefined(); + + const tcChunk = toolCallMsg?.chunks.find((c) => c.type === "tool-call"); + const trChunk = toolResultMsg?.chunks[0]; + + expect(tcChunk?.type).toBe("tool-call"); + expect(trChunk?.type).toBe("tool-result"); + + if (tcChunk?.type === "tool-call" && trChunk?.type === "tool-result") { + expect(tcChunk.stepId).toBeDefined(); + expect(trChunk.stepId).toBeDefined(); + expect(tcChunk.stepId).toBe(trChunk.stepId); + } + }); + }); }); diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index 1e98351..b722f3f 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, Chunk } from "../contracts/conversation.js"; +import type { ChatMessage, Chunk, StepId } from "../contracts/conversation.js"; import type { Logger, Span } from "../contracts/logging.js"; import type { ProviderContract, ProviderEvent, Usage } from "../contracts/provider.js"; import type { EventEmitter, RunTurnInput, RunTurnResult } from "../contracts/runtime.js"; @@ -79,6 +79,7 @@ interface StepContext { readonly signal: AbortSignal; readonly conversationId: string; readonly turnId: string; + readonly stepId: StepId; readonly logger: Logger; readonly turnSpan: Span | undefined; readonly toolSpans: Map<string, Span>; @@ -122,11 +123,13 @@ function processEvent( toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, + stepId: ctx.stepId, }); ctx.emit( toolCallEvent( ctx.conversationId, ctx.turnId, + ctx.stepId, event.toolCallId, event.toolName, event.input, @@ -273,6 +276,7 @@ async function executeStep(ctx: StepContext): Promise<StepResult> { toolResultEvent( ctx.conversationId, ctx.turnId, + ctx.stepId, call.id, call.name, result.content, @@ -288,6 +292,7 @@ async function executeStep(ctx: StepContext): Promise<StepResult> { toolName: call.name, content: result.content, isError, + stepId: ctx.stepId, }, ], }); @@ -357,6 +362,8 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { break; } + const stepId = `${turnId}#${step}` as StepId; + const stepResult = await executeStep({ provider: input.provider, messages, @@ -367,6 +374,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { signal, conversationId, turnId, + stepId, logger: turnSpan?.log ?? logger ?? createNoopLogger(), turnSpan, toolSpans, diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 0c097db..4e5f382 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/transport-contract", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/wire/package.json b/packages/wire/package.json index 4d72a81..6098703 100644 --- a/packages/wire/package.json +++ b/packages/wire/package.json @@ -1,6 +1,6 @@ { "name": "@dispatch/wire", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "private": true, "main": "dist/index.js", diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index 82fb3ed..90213b4 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -14,7 +14,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" }; /** @@ -51,6 +60,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; } /** @@ -64,6 +85,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. */ @@ -98,9 +128,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; @@ -175,6 +207,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; @@ -185,6 +225,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; @@ -522,6 +522,42 @@ The dispatch-web orchestrator couriered `backend-handoff.md` (in the FE repo). R headers on all routes incl. the NDJSON stream). HTTP=24203, WS=24205; no WS origin allow-list. Commit `812621c`. +### FE ask — step grouping (`stepId`) for batched tool calls [~] IN PROGRESS +FE handoff (`../dispatch-web/backend-handoff.md` §3): render a model's parallel/batched tool +calls (the set emitted in ONE step) as a single grouped unit, on BOTH the live stream and +replayed history. Decisions (user, §5.2): **full scope (live + persisted)**; key = the +already-defined branded **`StepId`** derived `` `${turnId}#${stepIndex}` `` (0-based). + +**Design pivot from the investigation (read-only multi-knowledge agent):** step boundaries do +NOT align with message boundaries in `RunTurnResult.messages` (a 2-step turn returns +`[assistant(2 calls), tool(r1), tool(r2), assistant(text)]`), and persistence is result-driven +(`orchestrator.append(result.messages)` once at turn end). So a `StoredChunk`-ENVELOPE `stepId` +was infeasible without enlarging `RunTurnResult` + `append` + the orchestrator. Chosen shape: +carry `stepId` **on the tool `Chunk` variants** (`ToolCallChunk`/`ToolResultChunk`) — generation +provenance, intrinsic to the chunk, so it rides `append`/`load`/`reconcile` for FREE (zero change +to `RunTurnResult`, `append`, orchestrator). `seq` stays the envelope sync cursor; `stepId` is +on-chunk provenance (distinct concepts — doc'd in wire + GLOSSARY). + +- [x] **Contract (orchestrator):** `@dispatch/wire` — `stepId?: StepId` on `ToolCallChunk` + + `ToolResultChunk` (optional: old rows / non-turn chunks); `stepId: StepId` (required) on + `TurnToolCallEvent` + `TurnToolResultEvent`; `StepId` doc clarified (provenance vs cursor). + Wire compiles in isolation. GLOSSARY `stepId` row added. +- [ ] **Build wave (3 disjoint owner-agents, parallel — all compile against the authored wire):** + - **kernel-runtime:** mint `stepId=`${turnId}#${step}`` in the loop; stamp on tool-call/ + tool-result CHUNKS; thread to `toolCallEvent`/`toolResultEvent` factories. (kernel rules.) + - **conversation-store:** carry `stepId` on `PersistedChunkEntry` (append) + read back in + `loadSince`/`load`; `reconcile` copies a dangling call's `stepId` onto the synthesized + result. NO SQLite migration (KV JSON blob). +round-trip & reconcile tests. + - **cli:** add `stepId` to the `tool-call`/`tool-result` EVENT fixtures in `render.test.ts` + (renderer ignores the field — no logic change). + - **NOT touched:** session-orchestrator (envelope unchanged → its `StoredChunk` fake compiles; + chunk-carried stepId rides through), transport-{http,ws,contract} (pass-through), + storage-sqlite (generic KV). Confirm via post-wave full typecheck. +- [ ] **Post-wave (orchestrator):** full typecheck/test/biome; regen FE `.dispatch/{wire, + transport-contract}.reference.md`; bump `wire`+`transport-contract` minor; courier reply into + `../dispatch-web/backend-handoff-reply.md`. Live: tool turn → events carry `stepId`, two calls + in one step share it, persisted chunks carry it via `GET /conversations/:id`. + ### 3. dedup / storage growth (after frontend) The deferred trace-body de-duplication + rotation/compression (D5 volume-control + `prefix.fingerprint` + §6 retention strategy) — already designed in |
