diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 23:41:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 23:41:00 +0900 |
| commit | 8b9cc0ca1254e52c113998688f4a30c594f287c1 (patch) | |
| tree | 5590e5307a8cdf4f23c7ec62a35c85464a2b0139 | |
| parent | 499e3299664da34facc9ec8d602f07b122293688 (diff) | |
| download | dispatch-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.json | 11 | ||||
| -rw-r--r-- | packages/conversation-store/src/extension.ts | 22 | ||||
| -rw-r--r-- | packages/conversation-store/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 27 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.test.ts | 237 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.ts | 37 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 152 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 43 | ||||
| -rw-r--r-- | packages/conversation-store/tsconfig.json | 6 | ||||
| -rw-r--r-- | tsconfig.json | 3 |
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" }, { |
