summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store
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 /packages/conversation-store
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.
Diffstat (limited to 'packages/conversation-store')
-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
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);
+ }
+ });
});