summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 16:07:35 +0900
committerAdam Malczewski <[email protected]>2026-06-07 16:07:35 +0900
commit904c6d7cc882ea6e092f03f9f487d80b75426440 (patch)
tree0b97107a859f8d347071c01a6907c778dd9cd05a
parentefddee1edd2924725a4dd240894666ede97b67b9 (diff)
downloaddispatch-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.md1
-rw-r--r--bun.lock7
-rw-r--r--packages/cli/package.json3
-rw-r--r--packages/cli/src/render.test.ts4
-rw-r--r--packages/conversation-store/src/reconcile.test.ts53
-rw-r--r--packages/conversation-store/src/reconcile.ts6
-rw-r--r--packages/conversation-store/src/store.test.ts92
-rw-r--r--packages/kernel/src/runtime/events.ts16
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts136
-rw-r--r--packages/kernel/src/runtime/run-turn.ts10
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/wire/package.json2
-rw-r--r--packages/wire/src/index.ts55
-rw-r--r--tasks.md36
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 |
diff --git a/bun.lock b/bun.lock
index dd7c6ff..bc33ba7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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;
diff --git a/tasks.md b/tasks.md
index 57faa79..9309c29 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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