diff options
Diffstat (limited to 'packages/conversation-store/src/store.test.ts')
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 3192 |
1 files changed, 1627 insertions, 1565 deletions
diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index 65b7dc3..d336e0e 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -1,1603 +1,1665 @@ import type { - ChatMessage, - Logger, - Span, - StepId, - StorageNamespace, - TurnMetrics, + ChatMessage, + Logger, + Span, + StepId, + StorageNamespace, + TurnMetrics, } from "@dispatch/kernel"; import { beforeEach, describe, expect, it } from "vitest"; import { CONVERSATION_INDEX_KEY, chunkKey, metaKey } from "./keys.js"; import { createConversationStore, extractTitle } from "./store.js"; interface SpanEvent { - readonly kind: "span-open" | "span-close"; - readonly name: string; - readonly attrs?: Record<string, string | number | boolean | null> | undefined; - readonly conversationId?: string | undefined; + readonly kind: "span-open" | "span-close"; + readonly name: string; + readonly attrs?: Record<string, string | number | boolean | null> | undefined; + readonly conversationId?: string | undefined; } function createCapturingLogger(): { logger: Logger; events: SpanEvent[] } { - const events: SpanEvent[] = []; - - function createSpan(name: string, conversationId?: string | undefined): Span { - events.push({ kind: "span-open", name, conversationId }); - const span: Span = { - id: `span_${events.length}`, - log: createFakeLogger(conversationId), - setAttributes: () => {}, - addLink: () => {}, - child: (childName, attrs) => { - const child = createSpan(childName, conversationId); - if (attrs !== undefined) { - const prev = events[events.length - 1]; - if (prev !== undefined) { - events[events.length - 1] = { - ...prev, - attrs: attrs as Record<string, string | number | boolean | null>, - }; - } - } - return child; - }, - end: (outcome) => { - const attrs = outcome?.attrs as - | Record<string, string | number | boolean | null> - | undefined; - events.push({ kind: "span-close", name, attrs, conversationId }); - }, - }; - return span; - } - - function createFakeLogger(conversationId?: string | undefined): Logger { - return { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: (ctx) => createFakeLogger(ctx.conversationId ?? conversationId), - span: (name, attrs) => { - const span = createSpan(name, conversationId); - if (attrs !== undefined) { - const prev = events[events.length - 1]; - if (prev !== undefined) { - events[events.length - 1] = { - ...prev, - attrs: attrs as Record<string, string | number | boolean | null>, - }; - } - } - return span; - }, - }; - } - - return { logger: createFakeLogger(), events }; + const events: SpanEvent[] = []; + + function createSpan(name: string, conversationId?: string | undefined): Span { + events.push({ kind: "span-open", name, conversationId }); + const span: Span = { + id: `span_${events.length}`, + log: createFakeLogger(conversationId), + setAttributes: () => {}, + addLink: () => {}, + child: (childName, attrs) => { + const child = createSpan(childName, conversationId); + if (attrs !== undefined) { + const prev = events[events.length - 1]; + if (prev !== undefined) { + events[events.length - 1] = { + ...prev, + attrs: attrs as Record<string, string | number | boolean | null>, + }; + } + } + return child; + }, + end: (outcome) => { + const attrs = outcome?.attrs as + | Record<string, string | number | boolean | null> + | undefined; + events.push({ kind: "span-close", name, attrs, conversationId }); + }, + }; + return span; + } + + function createFakeLogger(conversationId?: string | undefined): Logger { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: (ctx) => createFakeLogger(ctx.conversationId ?? conversationId), + span: (name, attrs) => { + const span = createSpan(name, conversationId); + if (attrs !== undefined) { + const prev = events[events.length - 1]; + if (prev !== undefined) { + events[events.length - 1] = { + ...prev, + attrs: attrs as Record<string, string | number | boolean | null>, + }; + } + } + return span; + }, + }; + } + + return { logger: createFakeLogger(), events }; } 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)); - }, - }; + 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); - }); - - it("append assigns gap-free 1-based per-chunk seq", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { - role: "assistant", - chunks: [ - { type: "text", text: "first" }, - { type: "thinking", text: "hmm" }, - { type: "text", text: "second" }, - ], - }; - await store.append("conv1", [msg]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(3); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[2]?.seq).toBe(3); - }); - - it("seq continues monotonically across separate append calls", async () => { - const store = createConversationStore(storage); - const msg1: ChatMessage = { - role: "user", - chunks: [ - { type: "text", text: "a" }, - { type: "text", text: "b" }, - ], - }; - const msg2: ChatMessage = { - role: "assistant", - chunks: [ - { type: "text", text: "c" }, - { type: "text", text: "d" }, - { type: "text", text: "e" }, - ], - }; - await store.append("conv1", [msg1]); - await store.append("conv1", [msg2]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(5); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[2]?.seq).toBe(3); - expect(chunks[3]?.seq).toBe(4); - expect(chunks[4]?.seq).toBe(5); - }); - - it("loadSince() returns every StoredChunk ascending by seq, carrying role + chunk", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "world" }] }, - ]; - await store.append("conv1", messages); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(2); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[1]?.role).toBe("assistant"); - expect(chunks[1]?.chunk).toEqual({ type: "text", text: "world" }); - }); - - it("loadSince(sinceSeq=N) returns only chunks with seq > N", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "a" }] }, - { role: "assistant", chunks: [{ type: "text", text: "b" }] }, - { role: "user", chunks: [{ type: "text", text: "c" }] }, - ]; - await store.append("conv1", messages); - const chunks = await store.loadSince("conv1", 2); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.seq).toBe(3); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "c" }); - }); - - it("loadSince treats a non-positive / non-integer sinceSeq as 0 (from the start), honoring the contract", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "a" }] }, - { role: "assistant", chunks: [{ type: "text", text: "b" }] }, - { role: "user", chunks: [{ type: "text", text: "c" }] }, - ]; - await store.append("conv1", messages); - const all = [1, 2, 3]; - // Non-positive integers → from the start (already worked; now codified). - expect((await store.loadSince("conv1", 0)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", -2)).map((c) => c.seq)).toEqual(all); - // Non-integer values → from the start (the contract lie this fixes: - // a positive non-integer like 2.5 used to filter like sinceSeq=2). - expect((await store.loadSince("conv1", 2.5)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 2.7)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", -2.5)).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", Number.POSITIVE_INFINITY)).map((c) => c.seq)).toEqual( - all, - ); - expect((await store.loadSince("conv1", Number.NaN)).map((c) => c.seq)).toEqual(all); - }); - - it("load() round-trips the exact ChatMessage[] that was appended", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "read file" }] }, - { - role: "assistant", - chunks: [ - { type: "thinking", text: "let me think" }, - { type: "text", text: "I will read it" }, - { - type: "tool-call", - toolCallId: "call_rt", - toolName: "readFile", - input: { path: "/tmp/x" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_rt", - toolName: "readFile", - content: "file contents here", - isError: false, - }, - ], - }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toEqual(messages); - }); - - it("load() does not merge consecutive same-role messages", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "first user msg" }] }, - { role: "user", chunks: [{ type: "text", text: "second user msg" }] }, - { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, - ]; - await store.append("conv1", messages); - const result = await store.load("conv1"); - expect(result).toHaveLength(3); - expect(result).toEqual(messages); - expect(result[0]?.chunks[0]?.type === "text" ? result[0]?.chunks[0]?.text : null).toBe( - "first user msg", - ); - expect(result[1]?.chunks[0]?.type === "text" ? result[1]?.chunks[0]?.text : null).toBe( - "second user msg", - ); - }); - - it("reconcile still synthesizes a result for an interrupted tool-call on load", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { type: "text", text: "calling tool" }, - { - type: "tool-call", - toolCallId: "call_orphan", - toolName: "someTool", - input: { x: 1 }, - }, - ], - }, - ]; - 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]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk.toolCallId).toBe("call_orphan"); - expect(chunk.isError).toBe(true); - expect(chunk.content).toBe("interrupted: tool execution did not complete"); - } - }); - - it("load() skips a corrupt-JSON chunk row and reconciles the rest (no throw)", async () => { - // "Never leave the system broken": a single bad row must not brick the - // conversation. The corrupt chunk is skipped; the rest loads and reconcile - // still runs normally. Fake only the OUTERMOST edge (the injected storage) - // — no @dispatch/* mocks. - const { logger } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { type: "text", text: "calling" }, - { type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }, - ], - }, - ]; - await store.append("conv_corrupt", messages); - // Corrupt the assistant text chunk (seq 2) directly in storage. - await storage.set(chunkKey("conv_corrupt", 2), "{this is not valid json"); - - const result = await store.load("conv_corrupt"); - // No throw. The user message survives; the assistant message keeps its - // tool-call (its text chunk was the corrupt row, skipped); reconcile - // synthesizes the missing tool-result for the now-orphaned tool-call. - expect(result).toEqual([ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [{ type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_x", - toolName: "t", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }, - ]); - }); - - it("loadSince returns empty array for unknown conversation", async () => { - const store = createConversationStore(storage); - const result = await store.loadSince("nonexistent"); - expect(result).toEqual([]); - }); - - it("loadSince(0) returns all chunks", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { - role: "user", - chunks: [ - { type: "text", text: "a" }, - { type: "text", text: "b" }, - ], - }; - await store.append("conv1", [msg]); - const all = await store.loadSince("conv1", 0); - expect(all).toHaveLength(2); - 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); - } - }); + 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 boundaries across single-message appends (orchestrator pattern)", async () => { + // Regression: the orchestrator persists messages one at a time — + // append([user]) at turn start, then append([assistant]) or + // append([assistant, ...toolResults]) via onStepComplete. Since each + // append() call assigns msgIdx starting at 0, single-message appends all + // share msgIdx=0. load() must split on role changes too, not just msgIdx, + // or messages from different turns collapse into one. + const store = createConversationStore(storage); + const user1: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + const asst1: ChatMessage = { + role: "assistant", + chunks: [ + { type: "thinking", text: "greeting" }, + { type: "text", text: "Hi!" }, + ], + }; + const user2: ChatMessage = { role: "user", chunks: [{ type: "text", text: "read a file" }] }; + const asst2: ChatMessage = { + role: "assistant", + chunks: [{ type: "text", text: "Sure." }], + }; + const user3: ChatMessage = { role: "user", chunks: [{ type: "text", text: "thanks" }] }; + + // Each message appended individually — the real orchestrator pattern. + await store.append("conv1", [user1]); + await store.append("conv1", [asst1]); + await store.append("conv1", [user2]); + await store.append("conv1", [asst2]); + await store.append("conv1", [user3]); + + const result = await store.load("conv1"); + expect(result).toEqual([user1, asst1, user2, asst2, user3]); + }); + + it("preserves message boundaries with single-message appends + multi-message step (tool calls)", async () => { + // Regression: the full orchestrator pattern including tool calls. + // Turn: append([user]) → step: append([assistant{thinking,text,tool-call}, toolResult]) + // All single-message appends get msgIdx=0; the multi-message step gets 0,1. + const store = createConversationStore(storage); + const user: ChatMessage = { role: "user", chunks: [{ type: "text", text: "do it" }] }; + const asst: ChatMessage = { + role: "assistant", + chunks: [ + { type: "thinking", text: "calling a tool" }, + { type: "text", text: "ok" }, + { type: "tool-call", toolCallId: "c1", toolName: "t", input: {} }, + ], + }; + const toolResult: ChatMessage = { + role: "tool", + chunks: [ + { type: "tool-result", toolCallId: "c1", toolName: "t", content: "done", isError: false }, + ], + }; + + await store.append("conv1", [user]); + await store.append("conv1", [asst, toolResult]); + + const result = await store.load("conv1"); + expect(result).toEqual([user, asst, toolResult]); + }); + + 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); + }); + + it("append assigns gap-free 1-based per-chunk seq", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { + role: "assistant", + chunks: [ + { type: "text", text: "first" }, + { type: "thinking", text: "hmm" }, + { type: "text", text: "second" }, + ], + }; + await store.append("conv1", [msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(3); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[2]?.seq).toBe(3); + }); + + it("seq continues monotonically across separate append calls", async () => { + const store = createConversationStore(storage); + const msg1: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }; + const msg2: ChatMessage = { + role: "assistant", + chunks: [ + { type: "text", text: "c" }, + { type: "text", text: "d" }, + { type: "text", text: "e" }, + ], + }; + await store.append("conv1", [msg1]); + await store.append("conv1", [msg2]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(5); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[2]?.seq).toBe(3); + expect(chunks[3]?.seq).toBe(4); + expect(chunks[4]?.seq).toBe(5); + }); + + it("loadSince() returns every StoredChunk ascending by seq, carrying role + chunk", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "world" }] }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[1]?.role).toBe("assistant"); + expect(chunks[1]?.chunk).toEqual({ type: "text", text: "world" }); + }); + + it("loadSince(sinceSeq=N) returns only chunks with seq > N", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "a" }] }, + { role: "assistant", chunks: [{ type: "text", text: "b" }] }, + { role: "user", chunks: [{ type: "text", text: "c" }] }, + ]; + await store.append("conv1", messages); + const chunks = await store.loadSince("conv1", 2); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBe(3); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "c" }); + }); + + it("loadSince treats a non-positive / non-integer sinceSeq as 0 (from the start), honoring the contract", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "a" }] }, + { role: "assistant", chunks: [{ type: "text", text: "b" }] }, + { role: "user", chunks: [{ type: "text", text: "c" }] }, + ]; + await store.append("conv1", messages); + const all = [1, 2, 3]; + // Non-positive integers → from the start (already worked; now codified). + expect((await store.loadSince("conv1", 0)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", -2)).map((c) => c.seq)).toEqual(all); + // Non-integer values → from the start (the contract lie this fixes: + // a positive non-integer like 2.5 used to filter like sinceSeq=2). + expect((await store.loadSince("conv1", 2.5)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 2.7)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", -2.5)).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", Number.POSITIVE_INFINITY)).map((c) => c.seq)).toEqual( + all, + ); + expect((await store.loadSince("conv1", Number.NaN)).map((c) => c.seq)).toEqual(all); + }); + + it("load() round-trips the exact ChatMessage[] that was appended", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "read file" }] }, + { + role: "assistant", + chunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "I will read it" }, + { + type: "tool-call", + toolCallId: "call_rt", + toolName: "readFile", + input: { path: "/tmp/x" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_rt", + toolName: "readFile", + content: "file contents here", + isError: false, + }, + ], + }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toEqual(messages); + }); + + it("load() does not merge consecutive same-role messages", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "first user msg" }] }, + { role: "user", chunks: [{ type: "text", text: "second user msg" }] }, + { role: "assistant", chunks: [{ type: "text", text: "reply" }] }, + ]; + await store.append("conv1", messages); + const result = await store.load("conv1"); + expect(result).toHaveLength(3); + expect(result).toEqual(messages); + expect(result[0]?.chunks[0]?.type === "text" ? result[0]?.chunks[0]?.text : null).toBe( + "first user msg", + ); + expect(result[1]?.chunks[0]?.type === "text" ? result[1]?.chunks[0]?.text : null).toBe( + "second user msg", + ); + }); + + it("reconcile still synthesizes a result for an interrupted tool-call on load", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "calling tool" }, + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "someTool", + input: { x: 1 }, + }, + ], + }, + ]; + 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]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.toolCallId).toBe("call_orphan"); + expect(chunk.isError).toBe(true); + expect(chunk.content).toBe("interrupted: tool execution did not complete"); + } + }); + + it("load() skips a corrupt-JSON chunk row and reconciles the rest (no throw)", async () => { + // "Never leave the system broken": a single bad row must not brick the + // conversation. The corrupt chunk is skipped; the rest loads and reconcile + // still runs normally. Fake only the OUTERMOST edge (the injected storage) + // — no @dispatch/* mocks. + const { logger } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "calling" }, + { type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }, + ], + }, + ]; + await store.append("conv_corrupt", messages); + // Corrupt the assistant text chunk (seq 2) directly in storage. + await storage.set(chunkKey("conv_corrupt", 2), "{this is not valid json"); + + const result = await store.load("conv_corrupt"); + // No throw. The user message survives; the assistant message keeps its + // tool-call (its text chunk was the corrupt row, skipped); reconcile + // synthesizes the missing tool-result for the now-orphaned tool-call. + expect(result).toEqual([ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [{ type: "tool-call", toolCallId: "call_x", toolName: "t", input: {} }], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_x", + toolName: "t", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }, + ]); + }); + + it("loadSince returns empty array for unknown conversation", async () => { + const store = createConversationStore(storage); + const result = await store.loadSince("nonexistent"); + expect(result).toEqual([]); + }); + + it("loadSince(0) returns all chunks", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { + role: "user", + chunks: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }; + await store.append("conv1", [msg]); + const all = await store.loadSince("conv1", 0); + expect(all).toHaveLength(2); + 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); + } + }); }); describe("ConversationStore loadSince windowing", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - // Append `count` single-chunk user messages so seq runs 1..count, gap-free. - async function seed(store: ReturnType<typeof createConversationStore>, count: number) { - const messages: ChatMessage[] = []; - for (let i = 1; i <= count; i++) { - messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] }); - } - await store.append("conv1", messages); - } - - it("limit returns the newest N of the selection, ascending by seq", async () => { - const store = createConversationStore(storage); - await seed(store, 5); - const chunks = await store.loadSince("conv1", 0, { limit: 2 }); - expect(chunks.map((c) => c.seq)).toEqual([4, 5]); - }); - - it("limit >= selection size returns the whole selection (exact, not truncated)", async () => { - const store = createConversationStore(storage); - await seed(store, 3); - const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 }); - expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]); - const overAll = await store.loadSince("conv1", 0, { limit: 99 }); - expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]); - }); - - it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => { - const store = createConversationStore(storage); - await seed(store, 5); - const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 }); - expect(chunks.map((c) => c.seq)).toEqual([1, 2]); - }); - - it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => { - const store = createConversationStore(storage); - await seed(store, 6); - const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 }); - expect(chunks.map((c) => c.seq)).toEqual([3, 4]); - }); - - it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => { - const store = createConversationStore(storage); - await seed(store, 8); - const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 }); - expect(chunks.map((c) => c.seq)).toEqual([4, 5]); - }); - - it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => { - const store = createConversationStore(storage); - await seed(store, 4); - expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]); - expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]); - }); - - it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => { - const store = createConversationStore(storage); - await seed(store, 4); - const all = [1, 2, 3, 4]; - expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all); - expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all); - }); - - it("window omitted is identical to today's behavior (regression guard)", async () => { - const store = createConversationStore(storage); - await seed(store, 5); - const base = await store.loadSince("conv1", 1); - const withEmptyWindow = await store.loadSince("conv1", 1, {}); - // A caller whose window fields happen to be undefined (e.g. unset query - // params) — modelled as an optional-field record, not explicit `undefined` - // literals (which exactOptionalPropertyTypes rejects on the contract). - const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {}; - const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow); - expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]); - expect(withEmptyWindow).toEqual(base); - expect(withUndefinedFields).toEqual(base); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + // Append `count` single-chunk user messages so seq runs 1..count, gap-free. + async function seed(store: ReturnType<typeof createConversationStore>, count: number) { + const messages: ChatMessage[] = []; + for (let i = 1; i <= count; i++) { + messages.push({ role: "user", chunks: [{ type: "text", text: `m${i}` }] }); + } + await store.append("conv1", messages); + } + + it("limit returns the newest N of the selection, ascending by seq", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const chunks = await store.loadSince("conv1", 0, { limit: 2 }); + expect(chunks.map((c) => c.seq)).toEqual([4, 5]); + }); + + it("limit >= selection size returns the whole selection (exact, not truncated)", async () => { + const store = createConversationStore(storage); + await seed(store, 3); + const exactlyAll = await store.loadSince("conv1", 0, { limit: 3 }); + expect(exactlyAll.map((c) => c.seq)).toEqual([1, 2, 3]); + const overAll = await store.loadSince("conv1", 0, { limit: 99 }); + expect(overAll.map((c) => c.seq)).toEqual([1, 2, 3]); + }); + + it("beforeSeq bounds the selection exclusively (seq < beforeSeq)", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const chunks = await store.loadSince("conv1", 0, { beforeSeq: 3 }); + expect(chunks.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("sinceSeq + beforeSeq combine to sinceSeq < seq < beforeSeq", async () => { + const store = createConversationStore(storage); + await seed(store, 6); + const chunks = await store.loadSince("conv1", 2, { beforeSeq: 5 }); + expect(chunks.map((c) => c.seq)).toEqual([3, 4]); + }); + + it("beforeSeq + limit: newest N below the bound, ascending (page older history in)", async () => { + const store = createConversationStore(storage); + await seed(store, 8); + const chunks = await store.loadSince("conv1", 0, { beforeSeq: 6, limit: 2 }); + expect(chunks.map((c) => c.seq)).toEqual([4, 5]); + }); + + it("empty selection returns [] (beforeSeq=1, and sinceSeq past the tail)", async () => { + const store = createConversationStore(storage); + await seed(store, 4); + expect(await store.loadSince("conv1", 0, { beforeSeq: 1 })).toEqual([]); + expect(await store.loadSince("conv1", 4, { limit: 3 })).toEqual([]); + }); + + it("non-positive / non-integer limit and beforeSeq are treated as absent", async () => { + const store = createConversationStore(storage); + await seed(store, 4); + const all = [1, 2, 3, 4]; + expect((await store.loadSince("conv1", 0, { limit: 0 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { limit: -2 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { limit: 1.5 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: 0 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: -3 })).map((c) => c.seq)).toEqual(all); + expect((await store.loadSince("conv1", 0, { beforeSeq: 2.7 })).map((c) => c.seq)).toEqual(all); + }); + + it("window omitted is identical to today's behavior (regression guard)", async () => { + const store = createConversationStore(storage); + await seed(store, 5); + const base = await store.loadSince("conv1", 1); + const withEmptyWindow = await store.loadSince("conv1", 1, {}); + // A caller whose window fields happen to be undefined (e.g. unset query + // params) — modelled as an optional-field record, not explicit `undefined` + // literals (which exactOptionalPropertyTypes rejects on the contract). + const undefinedFieldsWindow: { beforeSeq?: number; limit?: number } = {}; + const withUndefinedFields = await store.loadSince("conv1", 1, undefinedFieldsWindow); + expect(base.map((c) => c.seq)).toEqual([2, 3, 4, 5]); + expect(withEmptyWindow).toEqual(base); + expect(withUndefinedFields).toEqual(base); + }); }); describe("ConversationStore metrics", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("appendMetrics → loadMetrics round-trips a TurnMetrics (usage + durationMs + steps)", async () => { - const store = createConversationStore(storage); - const stepId = "step_1" as StepId; - const metrics: TurnMetrics = { - turnId: "turn_abc", - usage: { inputTokens: 100, outputTokens: 50 }, - durationMs: 1234, - steps: [ - { - stepId, - usage: { inputTokens: 100, outputTokens: 50 }, - ttftMs: 200, - decodeMs: 800, - genTotalMs: 1000, - }, - ], - }; - await store.appendMetrics("conv1", metrics); - const result = await store.loadMetrics("conv1"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(metrics); - }); - - it("loadMetrics returns turns in append order", async () => { - const store = createConversationStore(storage); - const metrics1: TurnMetrics = { - turnId: "turn_first", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [], - }; - const metrics2: TurnMetrics = { - turnId: "turn_second", - usage: { inputTokens: 20, outputTokens: 10 }, - steps: [], - }; - const metrics3: TurnMetrics = { - turnId: "turn_third", - usage: { inputTokens: 30, outputTokens: 15 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics1); - await store.appendMetrics("conv1", metrics2); - await store.appendMetrics("conv1", metrics3); - const result = await store.loadMetrics("conv1"); - expect(result).toHaveLength(3); - expect(result[0]?.turnId).toBe("turn_first"); - expect(result[1]?.turnId).toBe("turn_second"); - expect(result[2]?.turnId).toBe("turn_third"); - }); - - it("loadMetrics returns [] for a conversation with no persisted metrics", async () => { - const store = createConversationStore(storage); - const result = await store.loadMetrics("nonexistent"); - expect(result).toEqual([]); - }); - - it("appendMetrics does not affect chunk load / loadSince", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - const messages = await store.load("conv1"); - expect(messages).toEqual([msg]); - - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - }); - - it("TurnMetrics with cache tokens + per-step ttft/decode/genTotal round-trips losslessly", async () => { - const store = createConversationStore(storage); - const stepId1 = "step_a" as StepId; - const stepId2 = "step_b" as StepId; - const metrics: TurnMetrics = { - turnId: "turn_cache", - usage: { - inputTokens: 500, - outputTokens: 200, - cacheReadTokens: 300, - cacheWriteTokens: 100, - }, - durationMs: 5000, - steps: [ - { - stepId: stepId1, - usage: { - inputTokens: 300, - outputTokens: 100, - cacheReadTokens: 200, - cacheWriteTokens: 50, - }, - ttftMs: 150, - decodeMs: 600, - genTotalMs: 750, - }, - { - stepId: stepId2, - usage: { - inputTokens: 200, - outputTokens: 100, - cacheReadTokens: 100, - cacheWriteTokens: 50, - }, - ttftMs: 100, - decodeMs: 400, - genTotalMs: 500, - }, - ], - }; - await store.appendMetrics("conv1", metrics); - const result = await store.loadMetrics("conv1"); - expect(result).toHaveLength(1); - expect(result[0]).toEqual(metrics); - expect(result[0]?.usage.cacheReadTokens).toBe(300); - expect(result[0]?.usage.cacheWriteTokens).toBe(100); - expect(result[0]?.steps[0]?.ttftMs).toBe(150); - expect(result[0]?.steps[0]?.decodeMs).toBe(600); - expect(result[0]?.steps[0]?.genTotalMs).toBe(750); - expect(result[0]?.steps[1]?.ttftMs).toBe(100); - expect(result[0]?.steps[1]?.decodeMs).toBe(400); - expect(result[0]?.steps[1]?.genTotalMs).toBe(500); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("appendMetrics → loadMetrics round-trips a TurnMetrics (usage + durationMs + steps)", async () => { + const store = createConversationStore(storage); + const stepId = "step_1" as StepId; + const metrics: TurnMetrics = { + turnId: "turn_abc", + usage: { inputTokens: 100, outputTokens: 50 }, + durationMs: 1234, + steps: [ + { + stepId, + usage: { inputTokens: 100, outputTokens: 50 }, + ttftMs: 200, + decodeMs: 800, + genTotalMs: 1000, + }, + ], + }; + await store.appendMetrics("conv1", metrics); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(metrics); + }); + + it("loadMetrics returns turns in append order", async () => { + const store = createConversationStore(storage); + const metrics1: TurnMetrics = { + turnId: "turn_first", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [], + }; + const metrics2: TurnMetrics = { + turnId: "turn_second", + usage: { inputTokens: 20, outputTokens: 10 }, + steps: [], + }; + const metrics3: TurnMetrics = { + turnId: "turn_third", + usage: { inputTokens: 30, outputTokens: 15 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics1); + await store.appendMetrics("conv1", metrics2); + await store.appendMetrics("conv1", metrics3); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(3); + expect(result[0]?.turnId).toBe("turn_first"); + expect(result[1]?.turnId).toBe("turn_second"); + expect(result[2]?.turnId).toBe("turn_third"); + }); + + it("loadMetrics returns [] for a conversation with no persisted metrics", async () => { + const store = createConversationStore(storage); + const result = await store.loadMetrics("nonexistent"); + expect(result).toEqual([]); + }); + + it("appendMetrics does not affect chunk load / loadSince", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + const messages = await store.load("conv1"); + expect(messages).toEqual([msg]); + + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); + + it("TurnMetrics with cache tokens + per-step ttft/decode/genTotal round-trips losslessly", async () => { + const store = createConversationStore(storage); + const stepId1 = "step_a" as StepId; + const stepId2 = "step_b" as StepId; + const metrics: TurnMetrics = { + turnId: "turn_cache", + usage: { + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 100, + }, + durationMs: 5000, + steps: [ + { + stepId: stepId1, + usage: { + inputTokens: 300, + outputTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 50, + }, + ttftMs: 150, + decodeMs: 600, + genTotalMs: 750, + }, + { + stepId: stepId2, + usage: { + inputTokens: 200, + outputTokens: 100, + cacheReadTokens: 100, + cacheWriteTokens: 50, + }, + ttftMs: 100, + decodeMs: 400, + genTotalMs: 500, + }, + ], + }; + await store.appendMetrics("conv1", metrics); + const result = await store.loadMetrics("conv1"); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(metrics); + expect(result[0]?.usage.cacheReadTokens).toBe(300); + expect(result[0]?.usage.cacheWriteTokens).toBe(100); + expect(result[0]?.steps[0]?.ttftMs).toBe(150); + expect(result[0]?.steps[0]?.decodeMs).toBe(600); + expect(result[0]?.steps[0]?.genTotalMs).toBe(750); + expect(result[0]?.steps[1]?.ttftMs).toBe(100); + expect(result[0]?.steps[1]?.decodeMs).toBe(400); + expect(result[0]?.steps[1]?.genTotalMs).toBe(500); + }); }); describe("ConversationStore reconcile.repair span", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("load() emits a reconcile.repair span when a dangling tool-call is repaired", async () => { - const { logger, events } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_dangle", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - await store.append("conv_span", messages); - await store.load("conv_span"); - - const spanOpens = events.filter((e) => e.kind === "span-open" && e.name === "reconcile.repair"); - const spanCloses = events.filter( - (e) => e.kind === "span-close" && e.name === "reconcile.repair", - ); - expect(spanOpens).toHaveLength(1); - expect(spanCloses).toHaveLength(1); - }); - - it("load() emits NO reconcile.repair span when the history is already valid", async () => { - const { logger, events } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, - ]; - await store.append("conv_valid", messages); - await store.load("conv_valid"); - - const repairSpans = events.filter((e) => e.name === "reconcile.repair"); - expect(repairSpans).toHaveLength(0); - }); - - it("the reconcile.repair span carries conversationId + a repair count attribute", async () => { - const { logger, events } = createCapturingLogger(); - const store = createConversationStore(storage, logger); - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_a", - toolName: "toolA", - input: {}, - }, - { - type: "tool-call", - toolCallId: "call_b", - toolName: "toolB", - input: {}, - }, - ], - }, - ]; - await store.append("conv_multi", messages); - await store.load("conv_multi"); - - const spanOpen = events.find((e) => e.kind === "span-open" && e.name === "reconcile.repair"); - expect(spanOpen).toBeDefined(); - if (spanOpen === undefined) throw new Error("expected spanOpen"); - expect(spanOpen.conversationId).toBe("conv_multi"); - expect(spanOpen.attrs).toBeDefined(); - if (spanOpen.attrs === undefined) throw new Error("expected attrs"); - expect(spanOpen.attrs.repairedCount).toBe(2); - expect(spanOpen.attrs.firstRepairedToolCallId).toBe("call_a"); - }); - - it("createConversationStore works with the logger omitted (optional)", async () => { - const store = createConversationStore(storage); - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do it" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_nolog", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - await store.append("conv_nolog", messages); - const result = await store.load("conv_nolog"); - expect(result).toHaveLength(3); - expect(result[2]?.role).toBe("tool"); - const chunk = result[2]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk.toolCallId).toBe("call_nolog"); - expect(chunk.isError).toBe(true); - } - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("load() emits a reconcile.repair span when a dangling tool-call is repaired", async () => { + const { logger, events } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_dangle", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + await store.append("conv_span", messages); + await store.load("conv_span"); + + const spanOpens = events.filter((e) => e.kind === "span-open" && e.name === "reconcile.repair"); + const spanCloses = events.filter( + (e) => e.kind === "span-close" && e.name === "reconcile.repair", + ); + expect(spanOpens).toHaveLength(1); + expect(spanCloses).toHaveLength(1); + }); + + it("load() emits NO reconcile.repair span when the history is already valid", async () => { + const { logger, events } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]; + await store.append("conv_valid", messages); + await store.load("conv_valid"); + + const repairSpans = events.filter((e) => e.name === "reconcile.repair"); + expect(repairSpans).toHaveLength(0); + }); + + it("the reconcile.repair span carries conversationId + a repair count attribute", async () => { + const { logger, events } = createCapturingLogger(); + const store = createConversationStore(storage, logger); + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_a", + toolName: "toolA", + input: {}, + }, + { + type: "tool-call", + toolCallId: "call_b", + toolName: "toolB", + input: {}, + }, + ], + }, + ]; + await store.append("conv_multi", messages); + await store.load("conv_multi"); + + const spanOpen = events.find((e) => e.kind === "span-open" && e.name === "reconcile.repair"); + expect(spanOpen).toBeDefined(); + if (spanOpen === undefined) throw new Error("expected spanOpen"); + expect(spanOpen.conversationId).toBe("conv_multi"); + expect(spanOpen.attrs).toBeDefined(); + if (spanOpen.attrs === undefined) throw new Error("expected attrs"); + expect(spanOpen.attrs.repairedCount).toBe(2); + expect(spanOpen.attrs.firstRepairedToolCallId).toBe("call_a"); + }); + + it("createConversationStore works with the logger omitted (optional)", async () => { + const store = createConversationStore(storage); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do it" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_nolog", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + await store.append("conv_nolog", messages); + const result = await store.load("conv_nolog"); + expect(result).toHaveLength(3); + expect(result[2]?.role).toBe("tool"); + const chunk = result[2]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.toolCallId).toBe("call_nolog"); + expect(chunk.isError).toBe(true); + } + }); }); describe("ConversationStore cwd", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("setCwd then getCwd returns the value", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/home/user/project"); - const result = await store.getCwd("conv1"); - expect(result).toBe("/home/user/project"); - }); - - it("getCwd returns null when never set", async () => { - const store = createConversationStore(storage); - const result = await store.getCwd("conv_unknown"); - expect(result).toBeNull(); - }); - - it("setCwd is an upsert (second set overwrites)", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/first/path"); - await store.setCwd("conv1", "/second/path"); - const result = await store.getCwd("conv1"); - expect(result).toBe("/second/path"); - }); - - it("cwd persists across a fresh store instance on the same db file", async () => { - const store1 = createConversationStore(storage); - await store1.setCwd("conv1", "/persisted/path"); - - const store2 = createConversationStore(storage); - const result = await store2.getCwd("conv1"); - expect(result).toBe("/persisted/path"); - }); - - it("cwd of one conversation does not leak into another", async () => { - const store = createConversationStore(storage); - await store.setCwd("convA", "/path/a"); - await store.setCwd("convB", "/path/b"); - expect(await store.getCwd("convA")).toBe("/path/a"); - expect(await store.getCwd("convB")).toBe("/path/b"); - }); - - it("setCwd then clearCwd → getCwd returns null", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/some/path"); - await store.clearCwd("conv1"); - expect(await store.getCwd("conv1")).toBeNull(); - }); - - it("clearCwd on a conversation that never had a cwd set → no error, getCwd null", async () => { - const store = createConversationStore(storage); - await expect(store.clearCwd("never-seen")).resolves.toBeUndefined(); - expect(await store.getCwd("never-seen")).toBeNull(); - }); - - it("clearCwd does not affect other conversations' cwds or other key spaces", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - await store.setCwd("conv1", "/path/one"); - await store.setCwd("conv2", "/path/two"); - await store.setReasoningEffort("conv1", "high"); - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - // Clear conv1's cwd only. - await store.clearCwd("conv1"); - - // conv1 cwd is gone, but conv2 cwd survives. - expect(await store.getCwd("conv1")).toBeNull(); - expect(await store.getCwd("conv2")).toBe("/path/two"); - - // Other key spaces on conv1 are untouched. - expect(await store.getReasoningEffort("conv1")).toBe("high"); - expect(await store.load("conv1")).toEqual([msg]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - const metricsResult = await store.loadMetrics("conv1"); - expect(metricsResult).toHaveLength(1); - expect(metricsResult[0]).toEqual(metrics); - }); - - it("clearCwd is idempotent (clearing twice is a no-op)", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/some/path"); - await store.clearCwd("conv1"); - // Second clear on an already-absent key — no error. - await expect(store.clearCwd("conv1")).resolves.toBeUndefined(); - expect(await store.getCwd("conv1")).toBeNull(); - }); - - it("setCwd after clearCwd re-persists the cwd (clear is a true delete, not a tombstone)", async () => { - const store = createConversationStore(storage); - await store.setCwd("conv1", "/first"); - await store.clearCwd("conv1"); - expect(await store.getCwd("conv1")).toBeNull(); - await store.setCwd("conv1", "/second"); - expect(await store.getCwd("conv1")).toBe("/second"); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("setCwd then getCwd returns the value", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/home/user/project"); + const result = await store.getCwd("conv1"); + expect(result).toBe("/home/user/project"); + }); + + it("getCwd returns null when never set", async () => { + const store = createConversationStore(storage); + const result = await store.getCwd("conv_unknown"); + expect(result).toBeNull(); + }); + + it("setCwd is an upsert (second set overwrites)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/first/path"); + await store.setCwd("conv1", "/second/path"); + const result = await store.getCwd("conv1"); + expect(result).toBe("/second/path"); + }); + + it("cwd persists across a fresh store instance on the same db file", async () => { + const store1 = createConversationStore(storage); + await store1.setCwd("conv1", "/persisted/path"); + + const store2 = createConversationStore(storage); + const result = await store2.getCwd("conv1"); + expect(result).toBe("/persisted/path"); + }); + + it("cwd of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setCwd("convA", "/path/a"); + await store.setCwd("convB", "/path/b"); + expect(await store.getCwd("convA")).toBe("/path/a"); + expect(await store.getCwd("convB")).toBe("/path/b"); + }); + + it("setCwd then clearCwd → getCwd returns null", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/some/path"); + await store.clearCwd("conv1"); + expect(await store.getCwd("conv1")).toBeNull(); + }); + + it("clearCwd on a conversation that never had a cwd set → no error, getCwd null", async () => { + const store = createConversationStore(storage); + await expect(store.clearCwd("never-seen")).resolves.toBeUndefined(); + expect(await store.getCwd("never-seen")).toBeNull(); + }); + + it("clearCwd does not affect other conversations' cwds or other key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/path/one"); + await store.setCwd("conv2", "/path/two"); + await store.setReasoningEffort("conv1", "high"); + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + // Clear conv1's cwd only. + await store.clearCwd("conv1"); + + // conv1 cwd is gone, but conv2 cwd survives. + expect(await store.getCwd("conv1")).toBeNull(); + expect(await store.getCwd("conv2")).toBe("/path/two"); + + // Other key spaces on conv1 are untouched. + expect(await store.getReasoningEffort("conv1")).toBe("high"); + expect(await store.load("conv1")).toEqual([msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); + + it("clearCwd is idempotent (clearing twice is a no-op)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/some/path"); + await store.clearCwd("conv1"); + // Second clear on an already-absent key — no error. + await expect(store.clearCwd("conv1")).resolves.toBeUndefined(); + expect(await store.getCwd("conv1")).toBeNull(); + }); + + it("setCwd after clearCwd re-persists the cwd (clear is a true delete, not a tombstone)", async () => { + const store = createConversationStore(storage); + await store.setCwd("conv1", "/first"); + await store.clearCwd("conv1"); + expect(await store.getCwd("conv1")).toBeNull(); + await store.setCwd("conv1", "/second"); + expect(await store.getCwd("conv1")).toBe("/second"); + }); }); describe("ConversationStore reasoning effort", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("setReasoningEffort then getReasoningEffort returns the level", async () => { - const store = createConversationStore(storage); - await store.setReasoningEffort("conv1", "high"); - const result = await store.getReasoningEffort("conv1"); - expect(result).toBe("high"); - }); - - it("getReasoningEffort returns null when never set", async () => { - const store = createConversationStore(storage); - const result = await store.getReasoningEffort("conv_unknown"); - expect(result).toBeNull(); - }); - - it("reasoning effort of one conversation does not leak into another", async () => { - const store = createConversationStore(storage); - await store.setReasoningEffort("convA", "low"); - await store.setReasoningEffort("convB", "max"); - expect(await store.getReasoningEffort("convA")).toBe("low"); - expect(await store.getReasoningEffort("convB")).toBe("max"); - }); - - it("setReasoningEffort is an upsert (second set overwrites)", async () => { - const store = createConversationStore(storage); - await store.setReasoningEffort("conv1", "medium"); - await store.setReasoningEffort("conv1", "xhigh"); - const result = await store.getReasoningEffort("conv1"); - expect(result).toBe("xhigh"); - }); - - it("reasoning effort persists across a fresh store instance on the same storage", async () => { - const store1 = createConversationStore(storage); - await store1.setReasoningEffort("conv1", "max"); - - const store2 = createConversationStore(storage); - const result = await store2.getReasoningEffort("conv1"); - expect(result).toBe("max"); - }); - - it("reasoning-effort keys do not collide with chunk/cwd/metrics key spaces", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - await store.setCwd("conv1", "/some/path"); - await store.setReasoningEffort("conv1", "low"); - - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - const messages = await store.load("conv1"); - expect(messages).toEqual([msg]); - - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - - expect(await store.getCwd("conv1")).toBe("/some/path"); - expect(await store.getReasoningEffort("conv1")).toBe("low"); - - const metricsResult = await store.loadMetrics("conv1"); - expect(metricsResult).toHaveLength(1); - expect(metricsResult[0]).toEqual(metrics); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("setReasoningEffort then getReasoningEffort returns the level", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("conv1", "high"); + const result = await store.getReasoningEffort("conv1"); + expect(result).toBe("high"); + }); + + it("getReasoningEffort returns null when never set", async () => { + const store = createConversationStore(storage); + const result = await store.getReasoningEffort("conv_unknown"); + expect(result).toBeNull(); + }); + + it("reasoning effort of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("convA", "low"); + await store.setReasoningEffort("convB", "max"); + expect(await store.getReasoningEffort("convA")).toBe("low"); + expect(await store.getReasoningEffort("convB")).toBe("max"); + }); + + it("setReasoningEffort is an upsert (second set overwrites)", async () => { + const store = createConversationStore(storage); + await store.setReasoningEffort("conv1", "medium"); + await store.setReasoningEffort("conv1", "xhigh"); + const result = await store.getReasoningEffort("conv1"); + expect(result).toBe("xhigh"); + }); + + it("reasoning effort persists across a fresh store instance on the same storage", async () => { + const store1 = createConversationStore(storage); + await store1.setReasoningEffort("conv1", "max"); + + const store2 = createConversationStore(storage); + const result = await store2.getReasoningEffort("conv1"); + expect(result).toBe("max"); + }); + + it("reasoning-effort keys do not collide with chunk/cwd/metrics key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/some/path"); + await store.setReasoningEffort("conv1", "low"); + + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + const messages = await store.load("conv1"); + expect(messages).toEqual([msg]); + + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + + expect(await store.getCwd("conv1")).toBe("/some/path"); + expect(await store.getReasoningEffort("conv1")).toBe("low"); + + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); }); describe("ConversationStore model", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("getModel returns null when never set", async () => { - const store = createConversationStore(storage); - expect(await store.getModel("conv_unknown")).toBeNull(); - }); - - it("setModel then getModel returns the model name", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - }); - - it("setModel is an upsert (second set overwrites with the latest)", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - await store.setModel("conv1", "openai/gpt-4o"); - expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); - }); - - it("setModel with an empty string clears the key (getModel returns null)", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - // Clear via the empty-string sentinel. - await store.setModel("conv1", ""); - expect(await store.getModel("conv1")).toBeNull(); - }); - - it("setModel('') on a never-set conversation is a no-op (idempotent clear)", async () => { - const store = createConversationStore(storage); - await expect(store.setModel("never-seen", "")).resolves.toBeUndefined(); - expect(await store.getModel("never-seen")).toBeNull(); - }); - - it("setModel after a clear re-persists the model (clear is a true delete, not a tombstone)", async () => { - const store = createConversationStore(storage); - await store.setModel("conv1", "umans/umans-glm-5.2"); - await store.setModel("conv1", ""); - expect(await store.getModel("conv1")).toBeNull(); - await store.setModel("conv1", "openai/gpt-4o"); - expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); - }); - - it("model of one conversation does not leak into another", async () => { - const store = createConversationStore(storage); - await store.setModel("convA", "umans/umans-glm-5.2"); - await store.setModel("convB", "openai/gpt-4o"); - expect(await store.getModel("convA")).toBe("umans/umans-glm-5.2"); - expect(await store.getModel("convB")).toBe("openai/gpt-4o"); - }); - - it("model persists across a fresh store instance on the same storage", async () => { - const store1 = createConversationStore(storage); - await store1.setModel("conv1", "umans/umans-glm-5.2"); - - const store2 = createConversationStore(storage); - expect(await store2.getModel("conv1")).toBe("umans/umans-glm-5.2"); - }); - - it("model keys do not collide with chunk/cwd/metrics/reasoning-effort key spaces", async () => { - const store = createConversationStore(storage); - const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; - await store.append("conv1", [msg]); - await store.setCwd("conv1", "/some/path"); - await store.setReasoningEffort("conv1", "low"); - await store.setModel("conv1", "umans/umans-glm-5.2"); - const metrics: TurnMetrics = { - turnId: "turn_iso", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - await store.appendMetrics("conv1", metrics); - - expect(await store.load("conv1")).toEqual([msg]); - const chunks = await store.loadSince("conv1"); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); - expect(await store.getCwd("conv1")).toBe("/some/path"); - expect(await store.getReasoningEffort("conv1")).toBe("low"); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - const metricsResult = await store.loadMetrics("conv1"); - expect(metricsResult).toHaveLength(1); - expect(metricsResult[0]).toEqual(metrics); - }); - - it("forkHistory copies the model to the target", async () => { - const store = createConversationStore(storage); - await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - await store.setModel("source", "umans/umans-glm-5.2"); - await store.forkHistory("source", "target"); - expect(await store.getModel("target")).toBe("umans/umans-glm-5.2"); - }); - - it("forkHistory copies a cleared (unset) model as absent (target reads null)", async () => { - const store = createConversationStore(storage); - await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - // No model set on source. - await store.forkHistory("source", "target"); - expect(await store.getModel("target")).toBeNull(); - }); - - it("replaceHistory preserves the model", async () => { - const store = createConversationStore(storage); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); - await store.setModel("conv1", "umans/umans-glm-5.2"); - await store.replaceHistory("conv1", [ - { role: "user", chunks: [{ type: "text", text: "replaced" }] }, - ]); - expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); - // History was replaced; the model survived the chunk-only sweep. - expect(await store.load("conv1")).toEqual([ - { role: "user", chunks: [{ type: "text", text: "replaced" }] }, - ]); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("getModel returns null when never set", async () => { + const store = createConversationStore(storage); + expect(await store.getModel("conv_unknown")).toBeNull(); + }); + + it("setModel then getModel returns the model name", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + }); + + it("setModel is an upsert (second set overwrites with the latest)", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + await store.setModel("conv1", "openai/gpt-4o"); + expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); + }); + + it("setModel with an empty string clears the key (getModel returns null)", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + // Clear via the empty-string sentinel. + await store.setModel("conv1", ""); + expect(await store.getModel("conv1")).toBeNull(); + }); + + it("setModel('') on a never-set conversation is a no-op (idempotent clear)", async () => { + const store = createConversationStore(storage); + await expect(store.setModel("never-seen", "")).resolves.toBeUndefined(); + expect(await store.getModel("never-seen")).toBeNull(); + }); + + it("setModel after a clear re-persists the model (clear is a true delete, not a tombstone)", async () => { + const store = createConversationStore(storage); + await store.setModel("conv1", "umans/umans-glm-5.2"); + await store.setModel("conv1", ""); + expect(await store.getModel("conv1")).toBeNull(); + await store.setModel("conv1", "openai/gpt-4o"); + expect(await store.getModel("conv1")).toBe("openai/gpt-4o"); + }); + + it("model of one conversation does not leak into another", async () => { + const store = createConversationStore(storage); + await store.setModel("convA", "umans/umans-glm-5.2"); + await store.setModel("convB", "openai/gpt-4o"); + expect(await store.getModel("convA")).toBe("umans/umans-glm-5.2"); + expect(await store.getModel("convB")).toBe("openai/gpt-4o"); + }); + + it("model persists across a fresh store instance on the same storage", async () => { + const store1 = createConversationStore(storage); + await store1.setModel("conv1", "umans/umans-glm-5.2"); + + const store2 = createConversationStore(storage); + expect(await store2.getModel("conv1")).toBe("umans/umans-glm-5.2"); + }); + + it("model keys do not collide with chunk/cwd/metrics/reasoning-effort key spaces", async () => { + const store = createConversationStore(storage); + const msg: ChatMessage = { role: "user", chunks: [{ type: "text", text: "hello" }] }; + await store.append("conv1", [msg]); + await store.setCwd("conv1", "/some/path"); + await store.setReasoningEffort("conv1", "low"); + await store.setModel("conv1", "umans/umans-glm-5.2"); + const metrics: TurnMetrics = { + turnId: "turn_iso", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + await store.appendMetrics("conv1", metrics); + + expect(await store.load("conv1")).toEqual([msg]); + const chunks = await store.loadSince("conv1"); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello" }); + expect(await store.getCwd("conv1")).toBe("/some/path"); + expect(await store.getReasoningEffort("conv1")).toBe("low"); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + const metricsResult = await store.loadMetrics("conv1"); + expect(metricsResult).toHaveLength(1); + expect(metricsResult[0]).toEqual(metrics); + }); + + it("forkHistory copies the model to the target", async () => { + const store = createConversationStore(storage); + await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + await store.setModel("source", "umans/umans-glm-5.2"); + await store.forkHistory("source", "target"); + expect(await store.getModel("target")).toBe("umans/umans-glm-5.2"); + }); + + it("forkHistory copies a cleared (unset) model as absent (target reads null)", async () => { + const store = createConversationStore(storage); + await store.append("source", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + // No model set on source. + await store.forkHistory("source", "target"); + expect(await store.getModel("target")).toBeNull(); + }); + + it("replaceHistory preserves the model", async () => { + const store = createConversationStore(storage); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); + await store.setModel("conv1", "umans/umans-glm-5.2"); + await store.replaceHistory("conv1", [ + { role: "user", chunks: [{ type: "text", text: "replaced" }] }, + ]); + expect(await store.getModel("conv1")).toBe("umans/umans-glm-5.2"); + // History was replaced; the model survived the chunk-only sweep. + expect(await store.load("conv1")).toEqual([ + { role: "user", chunks: [{ type: "text", text: "replaced" }] }, + ]); + }); }); describe("ConversationStore conversation metadata + list + title", () => { - let storage: StorageNamespace; - - beforeEach(() => { - storage = createMemoryStorage(); - }); - - it("listConversations: returns empty array when no conversations exist", async () => { - const store = createConversationStore(storage); - expect(await store.listConversations()).toEqual([]); - }); - - it("listConversations: returns conversations sorted by lastActivityAt desc", async () => { - let clock = 1000; - const store = createConversationStore(storage, undefined, () => clock); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); - clock = 2000; - await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "second" }] }]); - clock = 3000; - await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "third" }] }]); - // Bump conv1 to the most recent activity. - clock = 4000; - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); - - const list = await store.listConversations(); - expect(list.map((c) => c.id)).toEqual(["conv1", "conv3", "conv2"]); - }); - - it("listConversations: includes id + createdAt + lastActivityAt + title", async () => { - const store = createConversationStore(storage, undefined, () => 12345); - await store.append("convX", [{ role: "user", chunks: [{ type: "text", text: "my title" }] }]); - const list = await store.listConversations(); - expect(list).toHaveLength(1); - const first = list[0]; - if (first === undefined) throw new Error("expected list entry"); - expect(first).toEqual({ - id: "convX", - createdAt: 12345, - lastActivityAt: 12345, - title: "my title", - status: "idle", - workspaceId: "default", - }); - }); - - it("getConversationMeta: returns null for unknown conversation", async () => { - const store = createConversationStore(storage); - expect(await store.getConversationMeta("unknown")).toBeNull(); - }); - - it("getConversationMeta: returns metadata for known conversation", async () => { - const store = createConversationStore(storage, undefined, () => 7777); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - expect(await store.getConversationMeta("conv1")).toEqual({ - id: "conv1", - createdAt: 7777, - lastActivityAt: 7777, - title: "hello", - status: "idle", - workspaceId: "default", - }); - }); - - it("getConversationMeta: returns null on a corrupt meta row", async () => { - const store = createConversationStore(storage); - // Write a meta row with the wrong shape directly to storage. - await storage.set("conv:conv1:meta", "{not json"); - expect(await store.getConversationMeta("conv1")).toBeNull(); - }); - - it("setConversationTitle: updates the title", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); - await store.setConversationTitle("conv1", "custom title"); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.title).toBe("custom title"); - // createdAt + lastActivityAt are preserved (setTitle does not bump them). - expect(meta?.createdAt).toBe(1000); - expect(meta?.lastActivityAt).toBe(1000); - }); - - it("setConversationTitle: creates meta if conversation is new", async () => { - const store = createConversationStore(storage, undefined, () => 5000); - await store.setConversationTitle("convNew", "preset title"); - expect(await store.getConversationMeta("convNew")).toEqual({ - id: "convNew", - createdAt: 5000, - lastActivityAt: 5000, - title: "preset title", - status: "idle", - workspaceId: "default", - }); - // And the new conversation is discoverable in the index. - const list = await store.listConversations(); - expect(list.map((c) => c.id)).toEqual(["convNew"]); - }); - - it("append: auto-sets title from first user message", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [ - { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, - { role: "user", chunks: [{ type: "text", text: "hello world" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, - ]); - expect((await store.getConversationMeta("conv1"))?.title).toBe("hello world"); - }); - - it("append: truncates long titles to 80 chars", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - const longText = "x".repeat(100); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: longText }] }]); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.title).toBe(`${longText.slice(0, 80)}…`); - expect(meta?.title.length).toBe(81); - }); - - it("append: sets createdAt on first write, preserves on subsequent", async () => { - let clock = 1000; - const store = createConversationStore(storage, undefined, () => clock); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); - clock = 5000; - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.createdAt).toBe(1000); - expect(meta?.lastActivityAt).toBe(5000); - }); - - it("append: updates lastActivityAt on every write", async () => { - let clock = 1000; - const store = createConversationStore(storage, undefined, () => clock); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(1000); - clock = 2000; - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); - expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(2000); - clock = 3000; - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); - expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(3000); - }); - - it('append: title "Untitled" updated when first user message arrives in later append', async () => { - const store = createConversationStore(storage, undefined, () => 1000); - // First append — assistant only, no user message yet → "Untitled". - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "hi" }] }]); - expect((await store.getConversationMeta("conv1"))?.title).toBe("Untitled"); - // Second append — the first user message arrives → title is re-derived. - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "what now" }] }]); - expect((await store.getConversationMeta("conv1"))?.title).toBe("what now"); - }); - - it("append: a non-Untitled title is NOT overwritten by a later user message", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [ - { role: "user", chunks: [{ type: "text", text: "first question" }] }, - ]); - await store.append("conv1", [ - { role: "user", chunks: [{ type: "text", text: "second question" }] }, - ]); - // The title stays as the first user message; later user messages do not clobber. - expect((await store.getConversationMeta("conv1"))?.title).toBe("first question"); - }); - - it("append: does not add the same conversation to the index twice", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); - const list = await store.listConversations(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe("conv1"); - }); - - it("listConversations: skips index entries whose meta row is missing", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - // Manually corrupt the index by adding an id with no meta row. - await storage.set("conv-index", JSON.stringify(["conv1", "ghost"])); - const list = await store.listConversations(); - expect(list.map((c) => c.id)).toEqual(["conv1"]); - }); - - it("metadata persists across a fresh store instance on the same storage", async () => { - const clock = 1000; - const store1 = createConversationStore(storage, undefined, () => clock); - await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "persisted" }] }]); - - const store2 = createConversationStore(storage); - const meta = await store2.getConversationMeta("conv1"); - expect(meta).toEqual({ - id: "conv1", - createdAt: 1000, - lastActivityAt: 1000, - title: "persisted", - status: "idle", - workspaceId: "default", - }); - const list = await store2.listConversations(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe("conv1"); - }); - - describe("ConversationStore conversation status", () => { - it("new conversation defaults to idle", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - expect(await store.getConversationStatus("conv1")).toBe("idle"); - expect((await store.getConversationMeta("conv1"))?.status).toBe("idle"); - }); - - it("setConversationStatus updates status on existing conversation", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - - await store.setConversationStatus("conv1", "active"); - expect(await store.getConversationStatus("conv1")).toBe("active"); - - await store.setConversationStatus("conv1", "idle"); - expect(await store.getConversationStatus("conv1")).toBe("idle"); - - await store.setConversationStatus("conv1", "closed"); - expect(await store.getConversationStatus("conv1")).toBe("closed"); - }); - - it("setConversationStatus creates minimal row for unknown conversation", async () => { - const store = createConversationStore(storage, undefined, () => 2000); - await store.setConversationStatus("convNew", "closed"); - expect(await store.getConversationStatus("convNew")).toBe("closed"); - const meta = await store.getConversationMeta("convNew"); - expect(meta?.status).toBe("closed"); - expect(meta?.title).toBe("Untitled"); - }); - - it("setConversationStatus preserves other metadata", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); - await store.setConversationTitle("conv1", "custom title"); - - await store.setConversationStatus("conv1", "active"); - - const meta = await store.getConversationMeta("conv1"); - expect(meta?.title).toBe("custom title"); - expect(meta?.createdAt).toBe(1000); - expect(meta?.status).toBe("active"); - }); - - it("listConversations filters by status", async () => { - const store = createConversationStore(storage, undefined, () => 1000); - await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); - await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]); - await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); - - await store.setConversationStatus("conv1", "active"); - await store.setConversationStatus("conv2", "closed"); - - const activeOnly = await store.listConversations({ status: ["active"] }); - expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]); - - const idleOnly = await store.listConversations({ status: ["idle"] }); - expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]); - - const activeIdle = await store.listConversations({ status: ["active", "idle"] }); - expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]); - - const all = await store.listConversations(); - expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]); - }); - - it("status persists across a fresh store instance", async () => { - const store1 = createConversationStore(storage, undefined, () => 1000); - await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - await store1.setConversationStatus("conv1", "active"); - - const store2 = createConversationStore(storage); - expect(await store2.getConversationStatus("conv1")).toBe("active"); - }); - - it("old meta rows without status default to idle on read", async () => { - // Simulate a pre-status meta row written by an older version. - await storage.set( - metaKey("conv1"), - JSON.stringify({ - createdAt: 1000, - lastActivityAt: 1000, - title: "old", - }), - ); - await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"])); - - const store = createConversationStore(storage); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.status).toBe("idle"); - }); - }); - it("extractTitle: returns first user text", () => { - const messages: ChatMessage[] = [ - { role: "system", chunks: [{ type: "text", text: "sys" }] }, - { role: "assistant", chunks: [{ type: "text", text: "greeting" }] }, - { role: "user", chunks: [{ type: "text", text: "my question" }] }, - { role: "assistant", chunks: [{ type: "text", text: "answer" }] }, - ]; - expect(extractTitle(messages)).toBe("my question"); - }); - - it('extractTitle: returns "Untitled" when no user message', () => { - expect(extractTitle([])).toBe("Untitled"); - expect( - extractTitle([ - { role: "system", chunks: [{ type: "text", text: "sys" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, - ]), - ).toBe("Untitled"); - // A user message with no text chunk also yields "Untitled". - expect( - extractTitle([ - { - role: "user", - chunks: [ - { - type: "tool-result", - toolCallId: "c", - toolName: "t", - content: "x", - isError: false, - }, - ], - }, - ]), - ).toBe("Untitled"); - }); - - it("extractTitle: truncates to 80 chars", () => { - const exactly80 = "a".repeat(80); - const over80 = "a".repeat(81); - const wayOver = "The quick brown fox jumps over the lazy dog. ".repeat(10); - expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: exactly80 }] }])).toBe( - exactly80, - ); - expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: over80 }] }])).toBe( - `${over80.slice(0, 80)}…`, - ); - expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: wayOver }] }])).toBe( - `${wayOver.slice(0, 80)}…`, - ); - }); - - it("extractTitle: uses the first text chunk of the first user message", () => { - expect( - extractTitle([ - { - role: "user", - chunks: [ - { type: "text", text: "first chunk" }, - { type: "text", text: "second chunk" }, - ], - }, - ]), - ).toBe("first chunk"); - }); - - it("extractTitle: skips a user message with no text chunk, finds the next", () => { - expect( - extractTitle([ - { - role: "user", - chunks: [ - { - type: "tool-result", - toolCallId: "c", - toolName: "t", - content: "x", - isError: false, - }, - ], - }, - { role: "user", chunks: [{ type: "text", text: "real question" }] }, - ]), - ).toBe("real question"); - }); - - it("extractTitle: does not mutate the input", () => { - const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]; - const snapshot = JSON.stringify(messages); - extractTitle(messages); - expect(JSON.stringify(messages)).toBe(snapshot); - }); + let storage: StorageNamespace; + + beforeEach(() => { + storage = createMemoryStorage(); + }); + + it("listConversations: returns empty array when no conversations exist", async () => { + const store = createConversationStore(storage); + expect(await store.listConversations()).toEqual([]); + }); + + it("listConversations: returns conversations sorted by lastActivityAt desc", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); + clock = 2000; + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "second" }] }]); + clock = 3000; + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "third" }] }]); + // Bump conv1 to the most recent activity. + clock = 4000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); + + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["conv1", "conv3", "conv2"]); + }); + + it("listConversations: includes id + createdAt + lastActivityAt + title", async () => { + const store = createConversationStore(storage, undefined, () => 12345); + await store.append("convX", [{ role: "user", chunks: [{ type: "text", text: "my title" }] }]); + const list = await store.listConversations(); + expect(list).toHaveLength(1); + const first = list[0]; + if (first === undefined) throw new Error("expected list entry"); + expect(first).toEqual({ + id: "convX", + createdAt: 12345, + lastActivityAt: 12345, + title: "my title", + status: "idle", + workspaceId: "default", + }); + }); + + it("getConversationMeta: returns null for unknown conversation", async () => { + const store = createConversationStore(storage); + expect(await store.getConversationMeta("unknown")).toBeNull(); + }); + + it("getConversationMeta: returns metadata for known conversation", async () => { + const store = createConversationStore(storage, undefined, () => 7777); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + expect(await store.getConversationMeta("conv1")).toEqual({ + id: "conv1", + createdAt: 7777, + lastActivityAt: 7777, + title: "hello", + status: "idle", + workspaceId: "default", + }); + }); + + it("getConversationMeta: returns null on a corrupt meta row", async () => { + const store = createConversationStore(storage); + // Write a meta row with the wrong shape directly to storage. + await storage.set("conv:conv1:meta", "{not json"); + expect(await store.getConversationMeta("conv1")).toBeNull(); + }); + + it("setConversationTitle: updates the title", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "original" }] }]); + await store.setConversationTitle("conv1", "custom title"); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + // createdAt + lastActivityAt are preserved (setTitle does not bump them). + expect(meta?.createdAt).toBe(1000); + expect(meta?.lastActivityAt).toBe(1000); + }); + + it("setConversationTitle: creates meta if conversation is new", async () => { + const store = createConversationStore(storage, undefined, () => 5000); + await store.setConversationTitle("convNew", "preset title"); + expect(await store.getConversationMeta("convNew")).toEqual({ + id: "convNew", + createdAt: 5000, + lastActivityAt: 5000, + title: "preset title", + status: "idle", + workspaceId: "default", + }); + // And the new conversation is discoverable in the index. + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["convNew"]); + }); + + it("append: auto-sets title from first user message", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [ + { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, + { role: "user", chunks: [{ type: "text", text: "hello world" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("hello world"); + }); + + it("append: truncates long titles to 80 chars", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + const longText = "x".repeat(100); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: longText }] }]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe(`${longText.slice(0, 80)}…`); + expect(meta?.title.length).toBe(81); + }); + + it("append: sets createdAt on first write, preserves on subsequent", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "first" }] }]); + clock = 5000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "reply" }] }]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.lastActivityAt).toBe(5000); + }); + + it("append: updates lastActivityAt on every write", async () => { + let clock = 1000; + const store = createConversationStore(storage, undefined, () => clock); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(1000); + clock = 2000; + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(2000); + clock = 3000; + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + expect((await store.getConversationMeta("conv1"))?.lastActivityAt).toBe(3000); + }); + + it('append: title "Untitled" updated when first user message arrives in later append', async () => { + const store = createConversationStore(storage, undefined, () => 1000); + // First append — assistant only, no user message yet → "Untitled". + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "hi" }] }]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("Untitled"); + // Second append — the first user message arrives → title is re-derived. + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "what now" }] }]); + expect((await store.getConversationMeta("conv1"))?.title).toBe("what now"); + }); + + it("append: a non-Untitled title is NOT overwritten by a later user message", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [ + { role: "user", chunks: [{ type: "text", text: "first question" }] }, + ]); + await store.append("conv1", [ + { role: "user", chunks: [{ type: "text", text: "second question" }] }, + ]); + // The title stays as the first user message; later user messages do not clobber. + expect((await store.getConversationMeta("conv1"))?.title).toBe("first question"); + }); + + it("append: does not add the same conversation to the index twice", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv1", [{ role: "assistant", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + const list = await store.listConversations(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("conv1"); + }); + + it("listConversations: skips index entries whose meta row is missing", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + // Manually corrupt the index by adding an id with no meta row. + await storage.set("conv-index", JSON.stringify(["conv1", "ghost"])); + const list = await store.listConversations(); + expect(list.map((c) => c.id)).toEqual(["conv1"]); + }); + + it("metadata persists across a fresh store instance on the same storage", async () => { + const clock = 1000; + const store1 = createConversationStore(storage, undefined, () => clock); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "persisted" }] }]); + + const store2 = createConversationStore(storage); + const meta = await store2.getConversationMeta("conv1"); + expect(meta).toEqual({ + id: "conv1", + createdAt: 1000, + lastActivityAt: 1000, + title: "persisted", + status: "idle", + workspaceId: "default", + }); + const list = await store2.listConversations(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("conv1"); + }); + + describe("ConversationStore conversation status", () => { + it("new conversation defaults to idle", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + expect((await store.getConversationMeta("conv1"))?.status).toBe("idle"); + }); + + it("setConversationStatus updates status on existing conversation", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + + await store.setConversationStatus("conv1", "active"); + expect(await store.getConversationStatus("conv1")).toBe("active"); + + await store.setConversationStatus("conv1", "idle"); + expect(await store.getConversationStatus("conv1")).toBe("idle"); + + await store.setConversationStatus("conv1", "closed"); + expect(await store.getConversationStatus("conv1")).toBe("closed"); + }); + + it("setConversationStatus creates minimal row for unknown conversation", async () => { + const store = createConversationStore(storage, undefined, () => 2000); + await store.setConversationStatus("convNew", "closed"); + expect(await store.getConversationStatus("convNew")).toBe("closed"); + const meta = await store.getConversationMeta("convNew"); + expect(meta?.status).toBe("closed"); + expect(meta?.title).toBe("Untitled"); + }); + + it("setConversationStatus preserves other metadata", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]); + await store.setConversationTitle("conv1", "custom title"); + + await store.setConversationStatus("conv1", "active"); + + const meta = await store.getConversationMeta("conv1"); + expect(meta?.title).toBe("custom title"); + expect(meta?.createdAt).toBe(1000); + expect(meta?.status).toBe("active"); + }); + + it("listConversations filters by status", async () => { + const store = createConversationStore(storage, undefined, () => 1000); + await store.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "a" }] }]); + await store.append("conv2", [{ role: "user", chunks: [{ type: "text", text: "b" }] }]); + await store.append("conv3", [{ role: "user", chunks: [{ type: "text", text: "c" }] }]); + + await store.setConversationStatus("conv1", "active"); + await store.setConversationStatus("conv2", "closed"); + + const activeOnly = await store.listConversations({ status: ["active"] }); + expect(activeOnly.map((m) => m.id)).toEqual(["conv1"]); + + const idleOnly = await store.listConversations({ status: ["idle"] }); + expect(idleOnly.map((m) => m.id)).toEqual(["conv3"]); + + const activeIdle = await store.listConversations({ status: ["active", "idle"] }); + expect(activeIdle.map((m) => m.id)).toEqual(["conv1", "conv3"]); + + const all = await store.listConversations(); + expect(all.map((m) => m.id)).toEqual(["conv1", "conv2", "conv3"]); + }); + + it("status persists across a fresh store instance", async () => { + const store1 = createConversationStore(storage, undefined, () => 1000); + await store1.append("conv1", [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + await store1.setConversationStatus("conv1", "active"); + + const store2 = createConversationStore(storage); + expect(await store2.getConversationStatus("conv1")).toBe("active"); + }); + + it("old meta rows without status default to idle on read", async () => { + // Simulate a pre-status meta row written by an older version. + await storage.set( + metaKey("conv1"), + JSON.stringify({ + createdAt: 1000, + lastActivityAt: 1000, + title: "old", + }), + ); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(["conv1"])); + + const store = createConversationStore(storage); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.status).toBe("idle"); + }); + }); + it("extractTitle: returns first user text", () => { + const messages: ChatMessage[] = [ + { role: "system", chunks: [{ type: "text", text: "sys" }] }, + { role: "assistant", chunks: [{ type: "text", text: "greeting" }] }, + { role: "user", chunks: [{ type: "text", text: "my question" }] }, + { role: "assistant", chunks: [{ type: "text", text: "answer" }] }, + ]; + expect(extractTitle(messages)).toBe("my question"); + }); + + it('extractTitle: returns "Untitled" when no user message', () => { + expect(extractTitle([])).toBe("Untitled"); + expect( + extractTitle([ + { role: "system", chunks: [{ type: "text", text: "sys" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi" }] }, + ]), + ).toBe("Untitled"); + // A user message with no text chunk also yields "Untitled". + expect( + extractTitle([ + { + role: "user", + chunks: [ + { + type: "tool-result", + toolCallId: "c", + toolName: "t", + content: "x", + isError: false, + }, + ], + }, + ]), + ).toBe("Untitled"); + }); + + it("extractTitle: truncates to 80 chars", () => { + const exactly80 = "a".repeat(80); + const over80 = "a".repeat(81); + const wayOver = "The quick brown fox jumps over the lazy dog. ".repeat(10); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: exactly80 }] }])).toBe( + exactly80, + ); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: over80 }] }])).toBe( + `${over80.slice(0, 80)}…`, + ); + expect(extractTitle([{ role: "user", chunks: [{ type: "text", text: wayOver }] }])).toBe( + `${wayOver.slice(0, 80)}…`, + ); + }); + + it("extractTitle: uses the first text chunk of the first user message", () => { + expect( + extractTitle([ + { + role: "user", + chunks: [ + { type: "text", text: "first chunk" }, + { type: "text", text: "second chunk" }, + ], + }, + ]), + ).toBe("first chunk"); + }); + + it("extractTitle: skips a user message with no text chunk, finds the next", () => { + expect( + extractTitle([ + { + role: "user", + chunks: [ + { + type: "tool-result", + toolCallId: "c", + toolName: "t", + content: "x", + isError: false, + }, + ], + }, + { role: "user", chunks: [{ type: "text", text: "real question" }] }, + ]), + ).toBe("real question"); + }); + + it("extractTitle: does not mutate the input", () => { + const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hello" }] }]; + const snapshot = JSON.stringify(messages); + extractTitle(messages); + expect(JSON.stringify(messages)).toBe(snapshot); + }); }); |
