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 /packages/conversation-store | |
| 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.
Diffstat (limited to 'packages/conversation-store')
| -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 |
3 files changed, 147 insertions, 4 deletions
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); + } + }); }); |
