summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 23:41:00 +0900
committerAdam Malczewski <[email protected]>2026-06-04 23:41:00 +0900
commit8b9cc0ca1254e52c113998688f4a30c594f287c1 (patch)
tree5590e5307a8cdf4f23c7ec62a35c85464a2b0139
parent499e3299664da34facc9ec8d602f07b122293688 (diff)
downloaddispatch-8b9cc0ca1254e52c113998688f4a30c594f287c1.tar.gz
dispatch-8b9cc0ca1254e52c113998688f4a30c594f287c1.zip
feat(core-ext): conversation-store — append-only multi-turn persistence on StorageNamespace + pure reconcile (16 tests)
-rw-r--r--packages/conversation-store/package.json11
-rw-r--r--packages/conversation-store/src/extension.ts22
-rw-r--r--packages/conversation-store/src/index.ts4
-rw-r--r--packages/conversation-store/src/keys.ts27
-rw-r--r--packages/conversation-store/src/reconcile.test.ts237
-rw-r--r--packages/conversation-store/src/reconcile.ts37
-rw-r--r--packages/conversation-store/src/store.test.ts152
-rw-r--r--packages/conversation-store/src/store.ts43
-rw-r--r--packages/conversation-store/tsconfig.json6
-rw-r--r--tsconfig.json3
10 files changed, 542 insertions, 0 deletions
diff --git a/packages/conversation-store/package.json b/packages/conversation-store/package.json
new file mode 100644
index 0000000..6fe0468
--- /dev/null
+++ b/packages/conversation-store/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/conversation-store",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/conversation-store/src/extension.ts b/packages/conversation-store/src/extension.ts
new file mode 100644
index 0000000..05ba8ef
--- /dev/null
+++ b/packages/conversation-store/src/extension.ts
@@ -0,0 +1,22 @@
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import { conversationStoreHandle, createConversationStore } from "./store.js";
+
+export const manifest: Manifest = {
+ id: "conversation-store",
+ name: "Conversation Store",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ capabilities: { db: true },
+ contributes: { services: ["conversation-store/store"] },
+ activation: "eager",
+};
+
+export const extension: Extension = {
+ manifest,
+ activate: (host: HostAPI) => {
+ const storage = host.storage("conversation-store");
+ const store = createConversationStore(storage);
+ host.provideService(conversationStoreHandle, store);
+ },
+};
diff --git a/packages/conversation-store/src/index.ts b/packages/conversation-store/src/index.ts
new file mode 100644
index 0000000..46b281e
--- /dev/null
+++ b/packages/conversation-store/src/index.ts
@@ -0,0 +1,4 @@
+export { extension, manifest } from "./extension.js";
+export { reconcile } from "./reconcile.js";
+export type { ConversationStore } from "./store.js";
+export { conversationStoreHandle, createConversationStore } from "./store.js";
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts
new file mode 100644
index 0000000..5829a7c
--- /dev/null
+++ b/packages/conversation-store/src/keys.ts
@@ -0,0 +1,27 @@
+const SEQ_PAD = 10;
+
+export function seqKey(conversationId: string): string {
+ return `conv:${conversationId}:seq`;
+}
+
+export function msgKey(conversationId: string, seq: number): string {
+ return `conv:${conversationId}:msg:${String(seq).padStart(SEQ_PAD, "0")}`;
+}
+
+export function msgPrefix(conversationId: string): string {
+ return `conv:${conversationId}:msg:`;
+}
+
+export function parseSeq(raw: string | null): number {
+ if (raw === null) return 0;
+ const n = Number.parseInt(raw, 10);
+ return Number.isNaN(n) ? 0 : n;
+}
+
+export function parseMsgSeq(key: string): number {
+ const parts = key.split(":");
+ const last = parts[parts.length - 1];
+ if (last === undefined) return -1;
+ const n = Number.parseInt(last, 10);
+ return Number.isNaN(n) ? -1 : n;
+}
diff --git a/packages/conversation-store/src/reconcile.test.ts b/packages/conversation-store/src/reconcile.test.ts
new file mode 100644
index 0000000..f65b804
--- /dev/null
+++ b/packages/conversation-store/src/reconcile.test.ts
@@ -0,0 +1,237 @@
+import type { ChatMessage } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import { reconcile } from "./reconcile.js";
+
+describe("reconcile", () => {
+ it("returns empty array for empty input", () => {
+ expect(reconcile([])).toEqual([]);
+ });
+
+ it("passes through a complete conversation unchanged", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
+ ];
+ const result = reconcile(messages);
+ expect(result).toEqual(messages);
+ });
+
+ it("passes through a complete tool-call/tool-result pair unchanged", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "read file" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "readFile",
+ input: { path: "/tmp/foo" },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "readFile",
+ content: "file contents",
+ isError: false,
+ },
+ ],
+ },
+ { role: "assistant", chunks: [{ type: "text", text: "done" }] },
+ ];
+ const result = reconcile(messages);
+ expect(result).toEqual(messages);
+ });
+
+ it("synthesizes error result for orphaned tool-call", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "do something" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_orphan",
+ toolName: "someTool",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(3);
+ expect(result[2]).toEqual({
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_orphan",
+ toolName: "someTool",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ });
+ });
+
+ it("synthesizes results for multiple orphaned tool-calls", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_a",
+ toolName: "toolA",
+ input: {},
+ },
+ {
+ type: "tool-call",
+ toolCallId: "call_b",
+ toolName: "toolB",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(3);
+ expect(result[1]?.role).toBe("tool");
+ expect(result[2]?.role).toBe("tool");
+ const ids = result.slice(1).map((m) => {
+ const chunk = m.chunks[0];
+ return chunk?.type === "tool-result" ? chunk.toolCallId : null;
+ });
+ expect(ids).toEqual(["call_a", "call_b"]);
+ });
+
+ it("handles mixed resolved and orphaned tool-calls", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_resolved",
+ toolName: "toolResolved",
+ input: {},
+ },
+ {
+ type: "tool-call",
+ toolCallId: "call_orphan",
+ toolName: "toolOrphan",
+ input: {},
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_resolved",
+ toolName: "toolResolved",
+ content: "ok",
+ isError: false,
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(3);
+ expect(result[2]).toEqual({
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_orphan",
+ toolName: "toolOrphan",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ });
+ });
+
+ it("handles multiple turns with orphaned tool-calls in different turns", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "turn 1" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_t1",
+ toolName: "tool1",
+ input: {},
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_t1",
+ toolName: "tool1",
+ content: "result",
+ isError: false,
+ },
+ ],
+ },
+ { role: "user", chunks: [{ type: "text", text: "turn 2" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_t2",
+ toolName: "tool2",
+ input: {},
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(6);
+ expect(result[5]).toEqual({
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_t2",
+ toolName: "tool2",
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ },
+ ],
+ });
+ });
+
+ it("preserves thinking and text chunks alongside tool-calls", () => {
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ { type: "thinking", text: "let me think" },
+ { type: "text", text: "I will call a tool" },
+ {
+ type: "tool-call",
+ toolCallId: "call_x",
+ toolName: "toolX",
+ input: { a: 1 },
+ },
+ ],
+ },
+ ];
+ const result = reconcile(messages);
+ expect(result).toHaveLength(2);
+ expect(result[0]?.chunks).toHaveLength(3);
+ expect(result[1]?.role).toBe("tool");
+ });
+});
diff --git a/packages/conversation-store/src/reconcile.ts b/packages/conversation-store/src/reconcile.ts
new file mode 100644
index 0000000..6dda9d8
--- /dev/null
+++ b/packages/conversation-store/src/reconcile.ts
@@ -0,0 +1,37 @@
+import type { ChatMessage, ToolCallChunk, ToolResultChunk } from "@dispatch/kernel";
+
+export function reconcile(messages: readonly ChatMessage[]): ChatMessage[] {
+ const resolvedIds = new Set<string>();
+ for (const msg of messages) {
+ for (const chunk of msg.chunks) {
+ if (chunk.type === "tool-result") {
+ resolvedIds.add(chunk.toolCallId);
+ }
+ }
+ }
+
+ const orphaned: ToolCallChunk[] = [];
+ for (const msg of messages) {
+ if (msg.role !== "assistant") continue;
+ for (const chunk of msg.chunks) {
+ if (chunk.type === "tool-call" && !resolvedIds.has(chunk.toolCallId)) {
+ orphaned.push(chunk);
+ }
+ }
+ }
+
+ const result: ChatMessage[] = [...messages];
+
+ for (const call of orphaned) {
+ const synthesized: ToolResultChunk = {
+ type: "tool-result",
+ toolCallId: call.toolCallId,
+ toolName: call.toolName,
+ content: "interrupted: tool execution did not complete",
+ isError: true,
+ };
+ result.push({ role: "tool", chunks: [synthesized] });
+ }
+
+ return result;
+}
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts
new file mode 100644
index 0000000..60ebeb5
--- /dev/null
+++ b/packages/conversation-store/src/store.test.ts
@@ -0,0 +1,152 @@
+import type { ChatMessage, StorageNamespace } from "@dispatch/kernel";
+import { beforeEach, describe, expect, it } from "vitest";
+import { createConversationStore } from "./store.js";
+
+function createMemoryStorage(): StorageNamespace {
+ const data = new Map<string, string>();
+ return {
+ get: async (key) => data.get(key) ?? null,
+ set: async (key, value) => {
+ data.set(key, value);
+ },
+ delete: async (key) => {
+ data.delete(key);
+ },
+ has: async (key) => data.has(key),
+ keys: async (prefix) => {
+ const all = [...data.keys()];
+ if (!prefix) return all;
+ return all.filter((k) => k.startsWith(prefix));
+ },
+ };
+}
+
+describe("ConversationStore", () => {
+ let storage: StorageNamespace;
+
+ beforeEach(() => {
+ storage = createMemoryStorage();
+ });
+
+ it("returns empty array for unknown conversation", async () => {
+ const store = createConversationStore(storage);
+ const result = await store.load("nonexistent");
+ expect(result).toEqual([]);
+ });
+
+ it("round-trips a single message", async () => {
+ const store = createConversationStore(storage);
+ const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] };
+ await store.append("conv1", [msg]);
+ const result = await store.load("conv1");
+ expect(result).toEqual([msg]);
+ });
+
+ it("round-trips multiple messages in one append", async () => {
+ const store = createConversationStore(storage);
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hi" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hello" }] },
+ ];
+ await store.append("conv1", messages);
+ const result = await store.load("conv1");
+ expect(result).toEqual(messages);
+ });
+
+ it("accumulates messages across multiple appends", async () => {
+ const store = createConversationStore(storage);
+ const turn1: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "turn 1" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "reply 1" }] },
+ ];
+ const turn2: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "turn 2" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "reply 2" }] },
+ ];
+ await store.append("conv1", turn1);
+ await store.append("conv1", turn2);
+ const result = await store.load("conv1");
+ expect(result).toEqual([...turn1, ...turn2]);
+ });
+
+ it("preserves message ordering", async () => {
+ const store = createConversationStore(storage);
+ const messages: ChatMessage[] = [];
+ for (let i = 0; i < 10; i++) {
+ messages.push({ role: "user", chunks: [{ type: "text", text: `msg ${i}` }] });
+ }
+ await store.append("conv1", messages);
+ const result = await store.load("conv1");
+ expect(result).toEqual(messages);
+ for (let i = 0; i < 10; i++) {
+ const chunk = result[i]?.chunks[0];
+ expect(chunk?.type === "text" ? chunk.text : null).toBe(`msg ${i}`);
+ }
+ });
+
+ it("isolates conversations by id", async () => {
+ const store = createConversationStore(storage);
+ const msgA: ChatMessage = { role: "user", chunks: [{ type: "text", text: "A" }] };
+ const msgB: ChatMessage = { role: "user", chunks: [{ type: "text", text: "B" }] };
+ await store.append("convA", [msgA]);
+ await store.append("convB", [msgB]);
+ expect(await store.load("convA")).toEqual([msgA]);
+ expect(await store.load("convB")).toEqual([msgB]);
+ });
+
+ it("reconciles orphaned tool-calls on load", async () => {
+ const store = createConversationStore(storage);
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "do it" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "someTool",
+ input: {},
+ },
+ ],
+ },
+ ];
+ await store.append("conv1", messages);
+ const result = await store.load("conv1");
+ expect(result).toHaveLength(3);
+ expect(result[2]?.role).toBe("tool");
+ const chunk = result[2]?.chunks[0];
+ expect(chunk?.type === "tool-result" ? chunk.isError : null).toBe(true);
+ });
+
+ it("handles tool-call/tool-result round-trip", async () => {
+ const store = createConversationStore(storage);
+ const messages: ChatMessage[] = [
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "call_1",
+ toolName: "readFile",
+ input: { path: "/tmp/x" },
+ },
+ ],
+ },
+ {
+ role: "tool",
+ chunks: [
+ {
+ type: "tool-result",
+ toolCallId: "call_1",
+ toolName: "readFile",
+ content: "contents",
+ isError: false,
+ },
+ ],
+ },
+ ];
+ await store.append("conv1", messages);
+ const result = await store.load("conv1");
+ expect(result).toEqual(messages);
+ });
+});
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
new file mode 100644
index 0000000..694833f
--- /dev/null
+++ b/packages/conversation-store/src/store.ts
@@ -0,0 +1,43 @@
+import type { ChatMessage, StorageNamespace } from "@dispatch/kernel";
+import { defineService } from "@dispatch/kernel";
+import { msgKey, msgPrefix, parseSeq, seqKey } from "./keys.js";
+import { reconcile } from "./reconcile.js";
+
+export interface ConversationStore {
+ readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>;
+ readonly load: (conversationId: string) => Promise<ChatMessage[]>;
+}
+
+export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store");
+
+export function createConversationStore(storage: StorageNamespace): ConversationStore {
+ return {
+ async append(conversationId, messages) {
+ const raw = await storage.get(seqKey(conversationId));
+ let seq = parseSeq(raw);
+
+ for (const msg of messages) {
+ await storage.set(msgKey(conversationId, seq), JSON.stringify(msg));
+ seq++;
+ }
+
+ await storage.set(seqKey(conversationId), String(seq));
+ },
+
+ async load(conversationId) {
+ const prefix = msgPrefix(conversationId);
+ const keys = await storage.keys(prefix);
+ const sorted = [...keys].sort();
+
+ const raw: ChatMessage[] = [];
+ for (const key of sorted) {
+ const value = await storage.get(key);
+ if (value !== null) {
+ raw.push(JSON.parse(value) as ChatMessage);
+ }
+ }
+
+ return reconcile(raw);
+ },
+ };
+}
diff --git a/packages/conversation-store/tsconfig.json b/packages/conversation-store/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/conversation-store/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}
diff --git a/tsconfig.json b/tsconfig.json
index fa41981..0bb3a16 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -5,6 +5,9 @@
"path": "./packages/auth-apikey"
},
{
+ "path": "./packages/conversation-store"
+ },
+ {
"path": "./packages/kernel"
},
{