diff options
Diffstat (limited to 'packages/conversation-store')
| -rw-r--r-- | packages/conversation-store/package.json | 20 | ||||
| -rw-r--r-- | packages/conversation-store/src/extension.ts | 42 | ||||
| -rw-r--r-- | packages/conversation-store/src/index.ts | 8 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 60 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.test.ts | 812 | ||||
| -rw-r--r-- | packages/conversation-store/src/reconcile.ts | 176 | ||||
| -rw-r--r-- | packages/conversation-store/src/store-workspace.test.ts | 1451 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 3192 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 2569 | ||||
| -rw-r--r-- | packages/conversation-store/tsconfig.json | 8 |
10 files changed, 4346 insertions, 3992 deletions
diff --git a/packages/conversation-store/package.json b/packages/conversation-store/package.json index ff6cd22..3b1f6a2 100644 --- a/packages/conversation-store/package.json +++ b/packages/conversation-store/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/conversation-store", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/conversation-store", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/conversation-store/src/extension.ts b/packages/conversation-store/src/extension.ts index cd03077..b86ffa7 100644 --- a/packages/conversation-store/src/extension.ts +++ b/packages/conversation-store/src/extension.ts @@ -2,30 +2,30 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { conversationStoreHandle, createConversationStore } from "./store.js"; export const manifest: Manifest = { - id: "conversation-store", - name: "Conversation Store", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - capabilities: { db: true }, - contributes: { services: ["conversation-store/store"] }, - activation: "eager", + id: "conversation-store", + name: "Conversation Store", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + capabilities: { db: true }, + contributes: { services: ["conversation-store/store"] }, + activation: "eager", }; export const extension: Extension = { - manifest, - activate: async (host: HostAPI) => { - const storage = host.storage("conversation-store"); - const store = createConversationStore(storage, host.logger, undefined, process.cwd()); + manifest, + activate: async (host: HostAPI) => { + const storage = host.storage("conversation-store"); + const store = createConversationStore(storage, host.logger, undefined, process.cwd()); - const stale = await store.listConversations({ status: ["active"] }); - for (const m of stale) { - await store.setConversationStatus(m.id, "idle"); - } - if (stale.length > 0) { - host.logger.info("conversation-store: boot-sweep", { resetCount: stale.length }); - } + const stale = await store.listConversations({ status: ["active"] }); + for (const m of stale) { + await store.setConversationStatus(m.id, "idle"); + } + if (stale.length > 0) { + host.logger.info("conversation-store: boot-sweep", { resetCount: stale.length }); + } - host.provideService(conversationStoreHandle, store); - }, + host.provideService(conversationStoreHandle, store); + }, }; diff --git a/packages/conversation-store/src/index.ts b/packages/conversation-store/src/index.ts index 9e78b94..1fc2c33 100644 --- a/packages/conversation-store/src/index.ts +++ b/packages/conversation-store/src/index.ts @@ -4,8 +4,8 @@ export type { ReconcileReport, ReconcileResult } from "./reconcile.js"; export { reconcile, reconcileWithReport } from "./reconcile.js"; export type { ConversationStore } from "./store.js"; export { - conversationStoreHandle, - createConversationStore, - extractTitle, - isValidWorkspaceSlug, + conversationStoreHandle, + createConversationStore, + extractTitle, + isValidWorkspaceSlug, } from "./store.js"; diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 061871e..6ec2bc5 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -1,77 +1,85 @@ const SEQ_PAD = 10; export function seqKey(conversationId: string): string { - return `conv:${conversationId}:seq`; + return `conv:${conversationId}:seq`; } export function chunkKey(conversationId: string, seq: number): string { - return `conv:${conversationId}:chunk:${String(seq).padStart(SEQ_PAD, "0")}`; + return `conv:${conversationId}:chunk:${String(seq).padStart(SEQ_PAD, "0")}`; } export function chunkPrefix(conversationId: string): string { - return `conv:${conversationId}:chunk:`; + return `conv:${conversationId}:chunk:`; } export function parseSeq(raw: string | null): number { - if (raw === null) return 0; - const n = Number.parseInt(raw, 10); - return Number.isNaN(n) ? 0 : n; + if (raw === null) return 0; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? 0 : n; } export function parseChunkSeq(key: string): number { - const parts = key.split(":"); - const last = parts[parts.length - 1]; - if (last === undefined) return -1; - const n = Number.parseInt(last, 10); - return Number.isNaN(n) ? -1 : n; + const parts = key.split(":"); + const last = parts[parts.length - 1]; + if (last === undefined) return -1; + const n = Number.parseInt(last, 10); + return Number.isNaN(n) ? -1 : n; } export function metricsSeqKey(conversationId: string): string { - return `conv:${conversationId}:metrics-seq`; + return `conv:${conversationId}:metrics-seq`; } export function metricsKey(conversationId: string, ordinal: number): string { - return `conv:${conversationId}:metrics:${String(ordinal).padStart(SEQ_PAD, "0")}`; + return `conv:${conversationId}:metrics:${String(ordinal).padStart(SEQ_PAD, "0")}`; } export function metricsPrefix(conversationId: string): string { - return `conv:${conversationId}:metrics:`; + return `conv:${conversationId}:metrics:`; } export function parseMetricsOrdinal(key: string): number { - const parts = key.split(":"); - const last = parts[parts.length - 1]; - if (last === undefined) return -1; - const n = Number.parseInt(last, 10); - return Number.isNaN(n) ? -1 : n; + const parts = key.split(":"); + const last = parts[parts.length - 1]; + if (last === undefined) return -1; + const n = Number.parseInt(last, 10); + return Number.isNaN(n) ? -1 : n; } export function cwdKey(conversationId: string): string { - return `conv:${conversationId}:cwd`; + return `conv:${conversationId}:cwd`; } export function computerKey(conversationId: string): string { - return `conv:${conversationId}:computer`; + return `conv:${conversationId}:computer`; } export function reasoningEffortKey(conversationId: string): string { - return `conv:${conversationId}:reasoning-effort`; + return `conv:${conversationId}:reasoning-effort`; } export function modelKey(conversationId: string): string { - return `conv:${conversationId}:model`; + return `conv:${conversationId}:model`; } export function compactThresholdKey(conversationId: string): string { - return `conv:${conversationId}:compact-percent`; + return `conv:${conversationId}:compact-percent`; } +/** Per-conversation image transcription cache (JSON map of imageUrl → transcription). */ +export function imageTranscriptionsKey(conversationId: string): string { + return `conv:${conversationId}:image-transcriptions`; +} + +/** Global vision settings (image compaction limit + compaction model). */ +export const VISION_SETTINGS_KEY = "vision-settings"; + export function metaKey(conversationId: string): string { - return `conv:${conversationId}:meta`; + return `conv:${conversationId}:meta`; } export function workspaceKey(workspaceId: string): string { - return `workspace:${workspaceId}`; + return `workspace:${workspaceId}`; } export const CONVERSATION_INDEX_KEY = "conv-index"; diff --git a/packages/conversation-store/src/reconcile.test.ts b/packages/conversation-store/src/reconcile.test.ts index 78b808e..25b47d5 100644 --- a/packages/conversation-store/src/reconcile.test.ts +++ b/packages/conversation-store/src/reconcile.test.ts @@ -3,418 +3,432 @@ import { describe, expect, it } from "vitest"; import { reconcile, reconcileWithReport } from "./reconcile.js"; describe("reconcile", () => { - it("returns empty array for empty input", () => { - expect(reconcile([])).toEqual([]); - }); + it("returns empty array for empty input", () => { + expect(reconcile([])).toEqual([]); + }); - it("passes through a complete conversation unchanged", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, - ]; - const result = reconcile(messages); - expect(result).toEqual(messages); - }); + it("passes through a complete conversation unchanged", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, + ]; + const result = reconcile(messages); + expect(result).toEqual(messages); + }); - it("passes through a complete tool-call/tool-result pair unchanged", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "read file" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_1", - toolName: "readFile", - input: { path: "/tmp/foo" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "readFile", - content: "file contents", - isError: false, - }, - ], - }, - { role: "assistant", chunks: [{ type: "text", text: "done" }] }, - ]; - const result = reconcile(messages); - expect(result).toEqual(messages); - }); + it("passes through a complete tool-call/tool-result pair unchanged", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "read file" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "readFile", + input: { path: "/tmp/foo" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "readFile", + content: "file contents", + isError: false, + }, + ], + }, + { role: "assistant", chunks: [{ type: "text", text: "done" }] }, + ]; + const result = reconcile(messages); + expect(result).toEqual(messages); + }); - it("synthesizes error result for orphaned tool-call", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "do something" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_orphan", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(3); - expect(result[2]).toEqual({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_orphan", - toolName: "someTool", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }); - }); + it("synthesizes error result for orphaned tool-call", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "do something" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(3); + expect(result[2]).toEqual({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_orphan", + toolName: "someTool", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }); + }); - it("synthesizes results for multiple orphaned tool-calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_a", - toolName: "toolA", - input: {}, - }, - { - type: "tool-call", - toolCallId: "call_b", - toolName: "toolB", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(3); - expect(result[1]?.role).toBe("tool"); - expect(result[2]?.role).toBe("tool"); - const ids = result.slice(1).map((m) => { - const chunk = m.chunks[0]; - return chunk?.type === "tool-result" ? chunk.toolCallId : null; - }); - expect(ids).toEqual(["call_a", "call_b"]); - }); + it("synthesizes results for multiple orphaned tool-calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_a", + toolName: "toolA", + input: {}, + }, + { + type: "tool-call", + toolCallId: "call_b", + toolName: "toolB", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(3); + expect(result[1]?.role).toBe("tool"); + expect(result[2]?.role).toBe("tool"); + const ids = result.slice(1).map((m) => { + const chunk = m.chunks[0]; + return chunk?.type === "tool-result" ? chunk.toolCallId : null; + }); + expect(ids).toEqual(["call_a", "call_b"]); + }); - it("handles mixed resolved and orphaned tool-calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_resolved", - toolName: "toolResolved", - input: {}, - }, - { - type: "tool-call", - toolCallId: "call_orphan", - toolName: "toolOrphan", - input: {}, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_resolved", - toolName: "toolResolved", - content: "ok", - isError: false, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(3); - expect(result[2]).toEqual({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_orphan", - toolName: "toolOrphan", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }); - }); + it("handles mixed resolved and orphaned tool-calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_resolved", + toolName: "toolResolved", + input: {}, + }, + { + type: "tool-call", + toolCallId: "call_orphan", + toolName: "toolOrphan", + input: {}, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_resolved", + toolName: "toolResolved", + content: "ok", + isError: false, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(3); + expect(result[2]).toEqual({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_orphan", + toolName: "toolOrphan", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }); + }); - it("handles multiple turns with orphaned tool-calls in different turns", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "turn 1" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_t1", - toolName: "tool1", - input: {}, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_t1", - toolName: "tool1", - content: "result", - isError: false, - }, - ], - }, - { role: "user", chunks: [{ type: "text", text: "turn 2" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_t2", - toolName: "tool2", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(6); - expect(result[5]).toEqual({ - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_t2", - toolName: "tool2", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }); - }); + it("handles multiple turns with orphaned tool-calls in different turns", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "turn 1" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_t1", + toolName: "tool1", + input: {}, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_t1", + toolName: "tool1", + content: "result", + isError: false, + }, + ], + }, + { role: "user", chunks: [{ type: "text", text: "turn 2" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_t2", + toolName: "tool2", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(6); + expect(result[5]).toEqual({ + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_t2", + toolName: "tool2", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }); + }); - it("preserves thinking and text chunks alongside tool-calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "thinking", text: "let me think" }, - { type: "text", text: "I will call a tool" }, - { - type: "tool-call", - toolCallId: "call_x", - toolName: "toolX", - input: { a: 1 }, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(2); - expect(result[0]?.chunks).toHaveLength(3); - expect(result[1]?.role).toBe("tool"); - }); + it("preserves thinking and text chunks alongside tool-calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "thinking", text: "let me think" }, + { type: "text", text: "I will call a tool" }, + { + type: "tool-call", + toolCallId: "call_x", + toolName: "toolX", + input: { a: 1 }, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + expect(result[0]?.chunks).toHaveLength(3); + expect(result[1]?.role).toBe("tool"); + }); - it("copies the originating tool-call's stepId onto a synthesized result", () => { - const stepId = "step_orphan" as StepId; - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_sid", - toolName: "someTool", - input: {}, - stepId, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(2); - expect(result[1]?.role).toBe("tool"); - const chunk = result[1]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk.stepId).toBe(stepId); - } - }); + it("copies the originating tool-call's stepId onto a synthesized result", () => { + const stepId = "step_orphan" as StepId; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_sid", + toolName: "someTool", + input: {}, + stepId, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + expect(result[1]?.role).toBe("tool"); + const chunk = result[1]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk.stepId).toBe(stepId); + } + }); - it("omits stepId when the dangling call has none", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_nosid", - toolName: "someTool", - input: {}, - }, - ], - }, - ]; - const result = reconcile(messages); - expect(result).toHaveLength(2); - const chunk = result[1]?.chunks[0]; - if (chunk === undefined) throw new Error("expected chunk"); - expect(chunk.type).toBe("tool-result"); - if (chunk.type === "tool-result") { - expect(chunk).not.toHaveProperty("stepId"); - } - }); + it("omits stepId when the dangling call has none", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_nosid", + toolName: "someTool", + input: {}, + }, + ], + }, + ]; + const result = reconcile(messages); + expect(result).toHaveLength(2); + const chunk = result[1]?.chunks[0]; + if (chunk === undefined) throw new Error("expected chunk"); + expect(chunk.type).toBe("tool-result"); + if (chunk.type === "tool-result") { + expect(chunk).not.toHaveProperty("stepId"); + } + }); - // --- Layer 1: read-time self-repair of broken chats (error chunks) --- + // --- Layer 1: read-time self-repair of broken chats (error chunks) --- - it("reconcile strips error-only trailing assistant message", () => { - // The 77574596/102587c0 shape: [user, assistant{error}] -> [user]. - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hi" }] }, - { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(1); - expect(report.repairedCount).toBe(0); - }); + it("reconcile strips error-only trailing assistant message", () => { + // The 77574596/102587c0 shape: [user, assistant{error}] -> [user]. + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hi" }] }, + { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([{ role: "user", chunks: [{ type: "text", text: "hi" }] }]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(1); + expect(report.repairedCount).toBe(0); + }); - it("reconcile strips error chunk but keeps sibling text", () => { - // assistant{text,error} -> assistant{text}. - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "hello" }, - { type: "error", message: "boom" }, - ], - }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([{ role: "assistant", chunks: [{ type: "text", text: "hello" }] }]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(0); - expect(report.repairedCount).toBe(0); - }); + it("reconcile strips error chunk but keeps sibling text", () => { + // assistant{text,error} -> assistant{text}. + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "hello" }, + { type: "error", message: "boom" }, + ], + }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([{ role: "assistant", chunks: [{ type: "text", text: "hello" }] }]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(0); + expect(report.repairedCount).toBe(0); + }); - it("reconcile drops assistant message left empty after stripping error", () => { - // assistant{error} only -> dropped entirely. - const messages: ChatMessage[] = [ - { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(1); - expect(report.repairedCount).toBe(0); - }); + it("reconcile drops assistant message left empty after stripping error", () => { + // assistant{error} only -> dropped entirely. + const messages: ChatMessage[] = [ + { role: "assistant", chunks: [{ type: "error", message: "boom" }] }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(1); + expect(report.repairedCount).toBe(0); + }); - it("reconcile keeps tool-call + strips error", () => { - // assistant{tool-call,error} with a matching result -> assistant{tool-call}. - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }, - { type: "error", message: "boom" }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "t", - content: "ok", - isError: false, - }, - ], - }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([ - { - role: "assistant", - chunks: [{ type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "t", - content: "ok", - isError: false, - }, - ], - }, - ]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(0); - expect(report.repairedCount).toBe(0); // the tool-call has a matching result - }); + it("reconcile keeps tool-call + strips error", () => { + // assistant{tool-call,error} with a matching result -> assistant{tool-call}. + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }, + { type: "error", message: "boom" }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "t", + content: "ok", + isError: false, + }, + ], + }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([ + { + role: "assistant", + chunks: [{ type: "tool-call", toolCallId: "call_1", toolName: "t", input: {} }], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "t", + content: "ok", + isError: false, + }, + ], + }, + ]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(0); + expect(report.repairedCount).toBe(0); // the tool-call has a matching result + }); - it("reconcile strips error and still synthesizes a result for an orphaned tool-call", () => { - // Ordering guard: strip error chunks first, then run orphaned-tool-call - // synthesis on what remains. assistant{tool-call,error} with NO result -> - // the error is stripped, the tool-call survives, and a result is synthesized. - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "go" }] }, - { - role: "assistant", - chunks: [ - { type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }, - { type: "error", message: "boom" }, - ], - }, - ]; - const { messages: result, report } = reconcileWithReport(messages); - expect(result).toEqual([ - { role: "user", chunks: [{ type: "text", text: "go" }] }, - { - role: "assistant", - chunks: [{ type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_orph", - toolName: "t", - content: "interrupted: tool execution did not complete", - isError: true, - }, - ], - }, - ]); - expect(report.strippedErrorChunks).toBe(1); - expect(report.droppedEmptyMessages).toBe(0); - expect(report.repairedCount).toBe(1); - expect(report.repairedToolCallIds).toEqual(["call_orph"]); - }); + it("reconcile strips error and still synthesizes a result for an orphaned tool-call", () => { + // Ordering guard: strip error chunks first, then run orphaned-tool-call + // synthesis on what remains. assistant{tool-call,error} with NO result -> + // the error is stripped, the tool-call survives, and a result is synthesized. + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "go" }] }, + { + role: "assistant", + chunks: [ + { type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }, + { type: "error", message: "boom" }, + ], + }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual([ + { role: "user", chunks: [{ type: "text", text: "go" }] }, + { + role: "assistant", + chunks: [{ type: "tool-call", toolCallId: "call_orph", toolName: "t", input: {} }], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_orph", + toolName: "t", + content: "interrupted: tool execution did not complete", + isError: true, + }, + ], + }, + ]); + expect(report.strippedErrorChunks).toBe(1); + expect(report.droppedEmptyMessages).toBe(0); + expect(report.repairedCount).toBe(1); + expect(report.repairedToolCallIds).toEqual(["call_orph"]); + }); + + it("reconcile preserves a thinking-only assistant message", () => { + // Regression: an assistant message with only a thinking chunk (no text, + // no tool-call) was being dropped by the hasContent check. Thinking IS + // valid content — the model's reasoning must survive a load/reconcile + // cycle so it appears in the conversation history. + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "thinking", text: "just thinking..." }] }, + ]; + const { messages: result, report } = reconcileWithReport(messages); + expect(result).toEqual(messages); + expect(report.droppedEmptyMessages).toBe(0); + }); }); diff --git a/packages/conversation-store/src/reconcile.ts b/packages/conversation-store/src/reconcile.ts index ea33904..2d2b68d 100644 --- a/packages/conversation-store/src/reconcile.ts +++ b/packages/conversation-store/src/reconcile.ts @@ -1,107 +1,107 @@ import type { ChatMessage, ToolCallChunk, ToolResultChunk } from "@dispatch/kernel"; export interface ReconcileReport { - readonly repairedCount: number; - readonly repairedToolCallIds: readonly string[]; - /** Number of `error` chunks stripped from assistant messages. */ - readonly strippedErrorChunks: number; - /** Number of assistant messages dropped after stripping left them empty. */ - readonly droppedEmptyMessages: number; + readonly repairedCount: number; + readonly repairedToolCallIds: readonly string[]; + /** Number of `error` chunks stripped from assistant messages. */ + readonly strippedErrorChunks: number; + /** Number of assistant messages dropped after stripping left them empty. */ + readonly droppedEmptyMessages: number; } export interface ReconcileResult { - readonly messages: ChatMessage[]; - readonly report: ReconcileReport; + readonly messages: ChatMessage[]; + readonly report: ReconcileReport; } export function reconcileWithReport(messages: readonly ChatMessage[]): ReconcileResult { - // Phase 1: strip `error` chunks from assistant messages. An error chunk is a - // failed-generation marker, never valid provider content — removing it here - // (on load, before any provider sees the messages) auto-repairs broken chats - // with no DB surgery (append-only storage untouched). - let strippedErrorChunks = 0; - const stripped: ChatMessage[] = []; - for (const msg of messages) { - if (msg.role === "assistant" && msg.chunks.some((c) => c.type === "error")) { - const filtered = msg.chunks.filter((chunk) => { - if (chunk.type === "error") { - strippedErrorChunks++; - return false; - } - return true; - }); - stripped.push({ role: msg.role, chunks: filtered }); - } else { - stripped.push(msg); - } - } + // Phase 1: strip `error` chunks from assistant messages. An error chunk is a + // failed-generation marker, never valid provider content — removing it here + // (on load, before any provider sees the messages) auto-repairs broken chats + // with no DB surgery (append-only storage untouched). + let strippedErrorChunks = 0; + const stripped: ChatMessage[] = []; + for (const msg of messages) { + if (msg.role === "assistant" && msg.chunks.some((c) => c.type === "error")) { + const filtered = msg.chunks.filter((chunk) => { + if (chunk.type === "error") { + strippedErrorChunks++; + return false; + } + return true; + }); + stripped.push({ role: msg.role, chunks: filtered }); + } else { + stripped.push(msg); + } + } - // Phase 2: drop assistant messages left with neither `text` nor `tool-call` - // chunks after stripping (the now-empty error-only message). This is what - // unblocks continuation: such a message serializes to nothing a provider - // understands. Safe: it ends with no tool-call, so it is NEVER followed by a - // `tool` message — no "tool-without-preceding-assistant-tool_calls" 400. - let droppedEmptyMessages = 0; - const pruned: ChatMessage[] = []; - for (const msg of stripped) { - if (msg.role === "assistant") { - const hasContent = msg.chunks.some( - (chunk) => chunk.type === "text" || chunk.type === "tool-call", - ); - if (!hasContent) { - droppedEmptyMessages++; - continue; - } - } - pruned.push(msg); - } + // Phase 2: drop assistant messages left with neither `text` nor `tool-call` + // chunks after stripping (the now-empty error-only message). This is what + // unblocks continuation: such a message serializes to nothing a provider + // understands. Safe: it ends with no tool-call, so it is NEVER followed by a + // `tool` message — no "tool-without-preceding-assistant-tool_calls" 400. + let droppedEmptyMessages = 0; + const pruned: ChatMessage[] = []; + for (const msg of stripped) { + if (msg.role === "assistant") { + const hasContent = msg.chunks.some( + (chunk) => chunk.type === "text" || chunk.type === "tool-call" || chunk.type === "thinking", + ); + if (!hasContent) { + droppedEmptyMessages++; + continue; + } + } + pruned.push(msg); + } - // Phase 3: orphaned-tool-call synthesis (unchanged) on what remains. - const resolvedIds = new Set<string>(); - for (const msg of pruned) { - for (const chunk of msg.chunks) { - if (chunk.type === "tool-result") { - resolvedIds.add(chunk.toolCallId); - } - } - } + // Phase 3: orphaned-tool-call synthesis (unchanged) on what remains. + const resolvedIds = new Set<string>(); + for (const msg of pruned) { + for (const chunk of msg.chunks) { + if (chunk.type === "tool-result") { + resolvedIds.add(chunk.toolCallId); + } + } + } - const orphaned: ToolCallChunk[] = []; - for (const msg of pruned) { - if (msg.role !== "assistant") continue; - for (const chunk of msg.chunks) { - if (chunk.type === "tool-call" && !resolvedIds.has(chunk.toolCallId)) { - orphaned.push(chunk); - } - } - } + const orphaned: ToolCallChunk[] = []; + for (const msg of pruned) { + if (msg.role !== "assistant") continue; + for (const chunk of msg.chunks) { + if (chunk.type === "tool-call" && !resolvedIds.has(chunk.toolCallId)) { + orphaned.push(chunk); + } + } + } - const result: ChatMessage[] = [...pruned]; + const result: ChatMessage[] = [...pruned]; - for (const call of orphaned) { - const base = { - type: "tool-result" as const, - toolCallId: call.toolCallId, - toolName: call.toolName, - content: "interrupted: tool execution did not complete", - isError: true, - }; - const synthesized: ToolResultChunk = - call.stepId !== undefined ? { ...base, stepId: call.stepId } : base; - result.push({ role: "tool", chunks: [synthesized] }); - } + for (const call of orphaned) { + const base = { + type: "tool-result" as const, + toolCallId: call.toolCallId, + toolName: call.toolName, + content: "interrupted: tool execution did not complete", + isError: true, + }; + const synthesized: ToolResultChunk = + call.stepId !== undefined ? { ...base, stepId: call.stepId } : base; + result.push({ role: "tool", chunks: [synthesized] }); + } - return { - messages: result, - report: { - repairedCount: orphaned.length, - repairedToolCallIds: orphaned.map((c) => c.toolCallId), - strippedErrorChunks, - droppedEmptyMessages, - }, - }; + return { + messages: result, + report: { + repairedCount: orphaned.length, + repairedToolCallIds: orphaned.map((c) => c.toolCallId), + strippedErrorChunks, + droppedEmptyMessages, + }, + }; } export function reconcile(messages: readonly ChatMessage[]): ChatMessage[] { - return reconcileWithReport(messages).messages; + return reconcileWithReport(messages).messages; } diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts index 3926c94..59ea990 100644 --- a/packages/conversation-store/src/store-workspace.test.ts +++ b/packages/conversation-store/src/store-workspace.test.ts @@ -3,674 +3,803 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createConversationStore, isValidWorkspaceSlug } from "./store.js"; function createMemoryStorage(): StorageNamespace { - const data = new Map<string, string>(); - return { - get: async (key) => data.get(key) ?? null, - set: async (key, value) => { - data.set(key, value); - }, - delete: async (key) => { - data.delete(key); - }, - has: async (key) => data.has(key), - keys: async (prefix) => { - const all = [...data.keys()]; - if (!prefix) return all; - return all.filter((k) => k.startsWith(prefix)); - }, - }; + 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("WorkspaceStore", () => { - let storage: StorageNamespace; - let clock: number; - - beforeEach(() => { - storage = createMemoryStorage(); - clock = 1000; - }); - - function makeStore(serverDefaultCwd?: string) { - return createConversationStore(storage, undefined, () => clock, serverDefaultCwd); - } - - function userMessage(text: string): ChatMessage { - return { role: "user", chunks: [{ type: "text", text }] }; - } - - it("ensureWorkspace creates with defaults", async () => { - const store = makeStore(); - clock = 1000; - const ws = await store.ensureWorkspace("my-work"); - expect(ws).toEqual({ - id: "my-work", - title: "my-work", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("ensureWorkspace returns existing as-is", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const ws = await store.ensureWorkspace("my-work", { - title: "New Title", - defaultCwd: "/ignored", - }); - expect(ws).toEqual({ - id: "my-work", - title: "my-work", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("ensureWorkspace with custom title/defaultCwd", async () => { - const store = makeStore(); - clock = 3000; - const ws = await store.ensureWorkspace("my-work", { - title: "Custom", - defaultCwd: "/projects/dispatch", - }); - expect(ws).toEqual({ - id: "my-work", - title: "Custom", - defaultCwd: "/projects/dispatch", - defaultComputerId: null, - createdAt: 3000, - lastActivityAt: 3000, - }); - }); - - it("getWorkspace synthesizes default", async () => { - const store = makeStore(); - const ws = await store.getWorkspace("default"); - expect(ws).toEqual({ - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - }); - - it("getWorkspace returns null for unknown", async () => { - const store = makeStore(); - expect(await store.getWorkspace("unknown")).toBeNull(); - }); - - it("setWorkspaceTitle renames", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const ws = await store.setWorkspaceTitle("my-work", "Renamed"); - expect(ws).toEqual({ - id: "my-work", - title: "Renamed", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("setWorkspaceDefaultCwd sets and clears", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const setWs = await store.setWorkspaceDefaultCwd("my-work", "/some/path"); - expect(setWs.defaultCwd).toBe("/some/path"); - expect(setWs.lastActivityAt).toBe(1000); // does not bump on defaultCwd change - const cleared = await store.setWorkspaceDefaultCwd("my-work", null); - expect(cleared.defaultCwd).toBeNull(); - }); - - it("deleteWorkspace closes conversations", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("work-a"); - await store.setWorkspaceId("conv1", "work-a"); - await store.setWorkspaceId("conv2", "work-a"); - await store.setWorkspaceId("conv3", "default"); - - clock = 2000; - await store.append("conv1", [userMessage("hi 1")]); - await store.append("conv2", [userMessage("hi 2")]); - await store.append("conv3", [userMessage("hi 3")]); - - const result = await store.deleteWorkspace("work-a"); - expect(result.closedCount).toBe(2); - - const meta1 = await store.getConversationMeta("conv1"); - expect(meta1?.status).toBe("closed"); - expect(meta1?.workspaceId).toBe("default"); - - const meta2 = await store.getConversationMeta("conv2"); - expect(meta2?.status).toBe("closed"); - expect(meta2?.workspaceId).toBe("default"); - - const meta3 = await store.getConversationMeta("conv3"); - expect(meta3?.status).toBe("idle"); - expect(meta3?.workspaceId).toBe("default"); - - expect(await store.getWorkspace("work-a")).toBeNull(); - }); - - it("deleteWorkspace throws for default", async () => { - const store = makeStore(); - await expect(store.deleteWorkspace("default")).rejects.toThrow(); - }); - - it("listWorkspaces sorted by lastActivityAt desc", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("alpha"); - clock = 2000; - await store.ensureWorkspace("beta"); - clock = 3000; - await store.ensureWorkspace("gamma"); - - const list = await store.listWorkspaces(); - expect(list.map((w) => w.id)).toEqual(["gamma", "beta", "alpha", "default"]); - }); - - it("listWorkspaces includes conversationCount", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("work-a"); - await store.ensureWorkspace("work-b"); - await store.setWorkspaceId("a1", "work-a"); - await store.setWorkspaceId("a2", "work-a"); - await store.setWorkspaceId("b1", "work-b"); - await store.append("lonely", [userMessage("hi")]); // defaults to "default" - - const list = await store.listWorkspaces(); - const counts = Object.fromEntries(list.map((w) => [w.id, w.conversationCount])); - expect(counts["work-a"]).toBe(2); - expect(counts["work-b"]).toBe(1); - expect(counts.default).toBe(1); - }); - - it("listWorkspaces always includes default", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("only"); - // No append or explicit default creation — default is synthesized. - const list = await store.listWorkspaces(); - const ids = list.map((w) => w.id); - expect(ids).toContain("default"); - const defaultWs = list.find((w) => w.id === "default"); - expect(defaultWs).toEqual({ - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - conversationCount: 0, - }); - }); - - it("getWorkspaceId returns default for legacy", async () => { - const store = makeStore(); - await store.append("conv1", [userMessage("hi")]); - expect(await store.getWorkspaceId("conv1")).toBe("default"); - expect(await store.getWorkspaceId("never-seen")).toBe("default"); - }); - - it("setWorkspaceId persists and reads back", async () => { - const store = makeStore(); - clock = 1000; - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getWorkspaceId("conv1")).toBe("my-work"); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.workspaceId).toBe("my-work"); - expect(meta?.status).toBe("idle"); - }); - - it("getEffectiveCwd: absolute conversation cwd overrides workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "/explicit/path"); - expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); - }); - - it("getEffectiveCwd: workspace defaultCwd used when conversation cwd is unset (bug fix)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); - }); - - it("getEffectiveCwd: serverDefaultCwd fallback when both conversation and workspace cwd are null", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); - }); - - it("getEffectiveCwd: relative conversation cwd resolved against workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "subdir"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/subdir"); - }); - - it("getEffectiveCwd: relative conversation cwd resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "subdir"); - expect(await store.getEffectiveCwd("conv1")).toBe("/server/default/subdir"); - }); - - it("getEffectiveCwd: relative cwd with nested segments resolved against workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "a/b/c"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/a/b/c"); - }); - - it("getEffectiveCwd: relative cwd with .. segments normalizes via path.resolve", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root/sub" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "../sibling"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/sibling"); - }); - - it("getEffectiveCwd: default workspace (no defaultCwd) falls through to serverDefaultCwd", async () => { - const store = makeStore("/server/default"); - // No explicit workspace assignment — defaults to "default" workspace - // which has defaultCwd null. - expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); - }); - - // --- overrideCwd (per-turn cwd override) --- - - it("getEffectiveCwd: overrideCwd absolute (starts with /) returned as-is, overriding workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - // An absolute override wins outright, even over a workspace defaultCwd. - expect(await store.getEffectiveCwd("conv1", "/override/abs")).toBe("/override/abs"); - }); - - it("getEffectiveCwd: overrideCwd relative resolved against workspace defaultCwd", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/workspace/root/subdir"); - }); - - it("getEffectiveCwd: overrideCwd relative resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/server/default/subdir"); - }); - - it("getEffectiveCwd: overrideCwd does NOT read the persisted getCwd (override wins over persisted)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - // Persist a cwd that differs from the override — the override must win. - await store.setCwd("conv1", "/persisted/path"); - expect(await store.getEffectiveCwd("conv1", "override-rel")).toBe( - "/workspace/root/override-rel", - ); - }); - - it("getEffectiveCwd: overrideCwd omitted behaves as today (uses persisted cwd)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "persisted-rel"); - // No second arg — persisted cwd is used. - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/persisted-rel"); - }); - - it("clearCwd → getEffectiveCwd falls through to workspace defaultCwd (un-shadows it)", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setCwd("conv1", "/explicit/path"); - // Before clear: the conversation cwd shadows the workspace defaultCwd. - expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); - // After clear: the workspace defaultCwd is used (fall-through). - await store.clearCwd("conv1"); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); - }); - - it("getEffectiveCwd: an empty-string cwd does NOT fall through (proving clear ≠ setCwd(''))", async () => { - const store = makeStore("/server/default"); - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); - await store.setWorkspaceId("conv1", "my-work"); - // An empty string is a non-null explicit cwd — it is resolved (not - // treated as absent), so it does NOT fall through to the workspace - // defaultCwd. This is the gap clearCwd fixes. - await store.setCwd("conv1", ""); - expect(await store.getCwd("conv1")).toBe(""); - // path.resolve("/workspace/default", "") === "/workspace/default" — - // but this is a RELATIVE cwd resolution, not a fall-through. The point - // is that getCwd returns "" (not null), so the relative branch runs. - // With a clearCwd, getCwd returns null and the fall-through branch runs. - await store.clearCwd("conv1"); - expect(await store.getCwd("conv1")).toBeNull(); - expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); - }); - - it("listConversations filtered by workspaceId", async () => { - const store = makeStore(); - await store.ensureWorkspace("work-a"); - await store.ensureWorkspace("work-b"); - await store.append("a1", [userMessage("a1")]); - await store.append("a2", [userMessage("a2")]); - await store.append("b1", [userMessage("b1")]); - await store.setWorkspaceId("a1", "work-a"); - await store.setWorkspaceId("a2", "work-a"); - await store.setWorkspaceId("b1", "work-b"); - - const aConvs = await store.listConversations({ workspaceId: "work-a" }); - expect(aConvs.map((c) => c.id).sort()).toEqual(["a1", "a2"]); - - const bConvs = await store.listConversations({ workspaceId: "work-b" }); - expect(bConvs.map((c) => c.id)).toEqual(["b1"]); - }); - - it("append updates workspace lastActivityAt", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - await store.setWorkspaceId("conv1", "my-work"); - clock = 3000; - await store.append("conv1", [userMessage("hi")]); - const ws = await store.getWorkspace("my-work"); - expect(ws?.lastActivityAt).toBe(3000); - }); - - it("forkHistory copies workspaceId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("source", "my-work"); - await store.append("source", [userMessage("hello")]); - await store.forkHistory("source", "target"); - const targetMeta = await store.getConversationMeta("target"); - expect(targetMeta?.workspaceId).toBe("my-work"); - }); - - it("replaceHistory preserves workspaceId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - await store.append("conv1", [userMessage("original")]); - await store.replaceHistory("conv1", [userMessage("replaced")]); - const meta = await store.getConversationMeta("conv1"); - expect(meta?.workspaceId).toBe("my-work"); - }); + let storage: StorageNamespace; + let clock: number; + + beforeEach(() => { + storage = createMemoryStorage(); + clock = 1000; + }); + + function makeStore(serverDefaultCwd?: string) { + return createConversationStore(storage, undefined, () => clock, serverDefaultCwd); + } + + function userMessage(text: string): ChatMessage { + return { role: "user", chunks: [{ type: "text", text }] }; + } + + it("ensureWorkspace creates with defaults", async () => { + const store = makeStore(); + clock = 1000; + const ws = await store.ensureWorkspace("my-work"); + expect(ws).toEqual({ + id: "my-work", + title: "my-work", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("ensureWorkspace returns existing as-is", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const ws = await store.ensureWorkspace("my-work", { + title: "New Title", + defaultCwd: "/ignored", + }); + expect(ws).toEqual({ + id: "my-work", + title: "my-work", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("ensureWorkspace with custom title/defaultCwd", async () => { + const store = makeStore(); + clock = 3000; + const ws = await store.ensureWorkspace("my-work", { + title: "Custom", + defaultCwd: "/projects/dispatch", + }); + expect(ws).toEqual({ + id: "my-work", + title: "Custom", + defaultCwd: "/projects/dispatch", + defaultComputerId: null, + starred: false, + createdAt: 3000, + lastActivityAt: 3000, + }); + }); + + it("getWorkspace synthesizes default", async () => { + const store = makeStore(); + const ws = await store.getWorkspace("default"); + expect(ws).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + }); + }); + + it("getWorkspace returns null for unknown", async () => { + const store = makeStore(); + expect(await store.getWorkspace("unknown")).toBeNull(); + }); + + it("setWorkspaceTitle renames", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const ws = await store.setWorkspaceTitle("my-work", "Renamed"); + expect(ws).toEqual({ + id: "my-work", + title: "Renamed", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("setWorkspaceDefaultCwd sets and clears", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const setWs = await store.setWorkspaceDefaultCwd("my-work", "/some/path"); + expect(setWs.defaultCwd).toBe("/some/path"); + expect(setWs.lastActivityAt).toBe(1000); // does not bump on defaultCwd change + const cleared = await store.setWorkspaceDefaultCwd("my-work", null); + expect(cleared.defaultCwd).toBeNull(); + }); + + it("deleteWorkspace closes conversations", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("work-a"); + await store.setWorkspaceId("conv1", "work-a"); + await store.setWorkspaceId("conv2", "work-a"); + await store.setWorkspaceId("conv3", "default"); + + clock = 2000; + await store.append("conv1", [userMessage("hi 1")]); + await store.append("conv2", [userMessage("hi 2")]); + await store.append("conv3", [userMessage("hi 3")]); + + const result = await store.deleteWorkspace("work-a"); + expect(result.closedCount).toBe(2); + + const meta1 = await store.getConversationMeta("conv1"); + expect(meta1?.status).toBe("closed"); + expect(meta1?.workspaceId).toBe("default"); + + const meta2 = await store.getConversationMeta("conv2"); + expect(meta2?.status).toBe("closed"); + expect(meta2?.workspaceId).toBe("default"); + + const meta3 = await store.getConversationMeta("conv3"); + expect(meta3?.status).toBe("idle"); + expect(meta3?.workspaceId).toBe("default"); + + expect(await store.getWorkspace("work-a")).toBeNull(); + }); + + it("deleteWorkspace throws for default", async () => { + const store = makeStore(); + await expect(store.deleteWorkspace("default")).rejects.toThrow(); + }); + + it("listWorkspaces sorted by lastActivityAt desc", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("alpha"); + clock = 2000; + await store.ensureWorkspace("beta"); + clock = 3000; + await store.ensureWorkspace("gamma"); + + const list = await store.listWorkspaces(); + expect(list.map((w) => w.id)).toEqual(["gamma", "beta", "alpha", "default"]); + }); + + it("listWorkspaces includes conversationCount", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("work-a"); + await store.ensureWorkspace("work-b"); + await store.setWorkspaceId("a1", "work-a"); + await store.setWorkspaceId("a2", "work-a"); + await store.setWorkspaceId("b1", "work-b"); + await store.append("lonely", [userMessage("hi")]); // defaults to "default" + + const list = await store.listWorkspaces(); + const counts = Object.fromEntries(list.map((w) => [w.id, w.conversationCount])); + expect(counts["work-a"]).toBe(2); + expect(counts["work-b"]).toBe(1); + expect(counts.default).toBe(1); + }); + + it("listWorkspaces always includes default", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("only"); + // No append or explicit default creation — default is synthesized. + const list = await store.listWorkspaces(); + const ids = list.map((w) => w.id); + expect(ids).toContain("default"); + const defaultWs = list.find((w) => w.id === "default"); + expect(defaultWs).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + }); + + it("getWorkspaceId returns default for legacy", async () => { + const store = makeStore(); + await store.append("conv1", [userMessage("hi")]); + expect(await store.getWorkspaceId("conv1")).toBe("default"); + expect(await store.getWorkspaceId("never-seen")).toBe("default"); + }); + + it("setWorkspaceId persists and reads back", async () => { + const store = makeStore(); + clock = 1000; + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getWorkspaceId("conv1")).toBe("my-work"); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.workspaceId).toBe("my-work"); + expect(meta?.status).toBe("idle"); + }); + + it("getEffectiveCwd: absolute conversation cwd overrides workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "/explicit/path"); + expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); + }); + + it("getEffectiveCwd: workspace defaultCwd used when conversation cwd is unset (bug fix)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); + }); + + it("getEffectiveCwd: serverDefaultCwd fallback when both conversation and workspace cwd are null", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); + }); + + it("getEffectiveCwd: relative conversation cwd resolved against workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "subdir"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/subdir"); + }); + + it("getEffectiveCwd: relative conversation cwd resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "subdir"); + expect(await store.getEffectiveCwd("conv1")).toBe("/server/default/subdir"); + }); + + it("getEffectiveCwd: relative cwd with nested segments resolved against workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "a/b/c"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/a/b/c"); + }); + + it("getEffectiveCwd: relative cwd with .. segments normalizes via path.resolve", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root/sub" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "../sibling"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/sibling"); + }); + + it("getEffectiveCwd: default workspace (no defaultCwd) falls through to serverDefaultCwd", async () => { + const store = makeStore("/server/default"); + // No explicit workspace assignment — defaults to "default" workspace + // which has defaultCwd null. + expect(await store.getEffectiveCwd("conv1")).toBe("/server/default"); + }); + + // --- overrideCwd (per-turn cwd override) --- + + it("getEffectiveCwd: overrideCwd absolute (starts with /) returned as-is, overriding workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + // An absolute override wins outright, even over a workspace defaultCwd. + expect(await store.getEffectiveCwd("conv1", "/override/abs")).toBe("/override/abs"); + }); + + it("getEffectiveCwd: overrideCwd relative resolved against workspace defaultCwd", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/workspace/root/subdir"); + }); + + it("getEffectiveCwd: overrideCwd relative resolved against serverDefaultCwd when workspace defaultCwd is null", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveCwd("conv1", "subdir")).toBe("/server/default/subdir"); + }); + + it("getEffectiveCwd: overrideCwd does NOT read the persisted getCwd (override wins over persisted)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + // Persist a cwd that differs from the override — the override must win. + await store.setCwd("conv1", "/persisted/path"); + expect(await store.getEffectiveCwd("conv1", "override-rel")).toBe( + "/workspace/root/override-rel", + ); + }); + + it("getEffectiveCwd: overrideCwd omitted behaves as today (uses persisted cwd)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "persisted-rel"); + // No second arg — persisted cwd is used. + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/root/persisted-rel"); + }); + + it("clearCwd → getEffectiveCwd falls through to workspace defaultCwd (un-shadows it)", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setCwd("conv1", "/explicit/path"); + // Before clear: the conversation cwd shadows the workspace defaultCwd. + expect(await store.getEffectiveCwd("conv1")).toBe("/explicit/path"); + // After clear: the workspace defaultCwd is used (fall-through). + await store.clearCwd("conv1"); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); + }); + + it("getEffectiveCwd: an empty-string cwd does NOT fall through (proving clear ≠ setCwd(''))", async () => { + const store = makeStore("/server/default"); + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/default" }); + await store.setWorkspaceId("conv1", "my-work"); + // An empty string is a non-null explicit cwd — it is resolved (not + // treated as absent), so it does NOT fall through to the workspace + // defaultCwd. This is the gap clearCwd fixes. + await store.setCwd("conv1", ""); + expect(await store.getCwd("conv1")).toBe(""); + // path.resolve("/workspace/default", "") === "/workspace/default" — + // but this is a RELATIVE cwd resolution, not a fall-through. The point + // is that getCwd returns "" (not null), so the relative branch runs. + // With a clearCwd, getCwd returns null and the fall-through branch runs. + await store.clearCwd("conv1"); + expect(await store.getCwd("conv1")).toBeNull(); + expect(await store.getEffectiveCwd("conv1")).toBe("/workspace/default"); + }); + + it("listConversations filtered by workspaceId", async () => { + const store = makeStore(); + await store.ensureWorkspace("work-a"); + await store.ensureWorkspace("work-b"); + await store.append("a1", [userMessage("a1")]); + await store.append("a2", [userMessage("a2")]); + await store.append("b1", [userMessage("b1")]); + await store.setWorkspaceId("a1", "work-a"); + await store.setWorkspaceId("a2", "work-a"); + await store.setWorkspaceId("b1", "work-b"); + + const aConvs = await store.listConversations({ workspaceId: "work-a" }); + expect(aConvs.map((c) => c.id).sort()).toEqual(["a1", "a2"]); + + const bConvs = await store.listConversations({ workspaceId: "work-b" }); + expect(bConvs.map((c) => c.id)).toEqual(["b1"]); + }); + + it("append updates workspace lastActivityAt", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + await store.setWorkspaceId("conv1", "my-work"); + clock = 3000; + await store.append("conv1", [userMessage("hi")]); + const ws = await store.getWorkspace("my-work"); + expect(ws?.lastActivityAt).toBe(3000); + }); + + it("forkHistory copies workspaceId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("source", "my-work"); + await store.append("source", [userMessage("hello")]); + await store.forkHistory("source", "target"); + const targetMeta = await store.getConversationMeta("target"); + expect(targetMeta?.workspaceId).toBe("my-work"); + }); + + it("replaceHistory preserves workspaceId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + await store.append("conv1", [userMessage("original")]); + await store.replaceHistory("conv1", [userMessage("replaced")]); + const meta = await store.getConversationMeta("conv1"); + expect(meta?.workspaceId).toBe("my-work"); + }); + + // --- starred (priority for concurrency limiting) --- + + it("ensureWorkspace creates with starred: false", async () => { + const store = makeStore(); + const ws = await store.ensureWorkspace("star-work"); + expect(ws.starred).toBe(false); + }); + + it("setWorkspaceStarred true persists and reads back", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("star-work"); + clock = 2000; + const ws = await store.setWorkspaceStarred("star-work", true); + expect(ws.starred).toBe(true); + expect(ws.id).toBe("star-work"); + const reRead = await store.getWorkspace("star-work"); + expect(reRead?.starred).toBe(true); + }); + + it("setWorkspaceStarred false unstars a previously starred workspace", async () => { + const store = makeStore(); + await store.ensureWorkspace("star-work"); + await store.setWorkspaceStarred("star-work", true); + await store.setWorkspaceStarred("star-work", false); + const ws = await store.getWorkspace("star-work"); + expect(ws?.starred).toBe(false); + }); + + it("setWorkspaceStarred creates the workspace if missing", async () => { + const store = makeStore(); + clock = 5000; + const ws = await store.setWorkspaceStarred("brand-new", true); + expect(ws).toEqual({ + id: "brand-new", + title: "brand-new", + defaultCwd: null, + defaultComputerId: null, + starred: true, + createdAt: 5000, + lastActivityAt: 5000, + }); + }); + + it("setWorkspaceStarred preserves title/defaultCwd/defaultComputerId", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work", { + title: "Custom", + defaultCwd: "/projects", + defaultComputerId: "myserver", + }); + clock = 2000; + const ws = await store.setWorkspaceStarred("my-work", true); + expect(ws.title).toBe("Custom"); + expect(ws.defaultCwd).toBe("/projects"); + expect(ws.defaultComputerId).toBe("myserver"); + expect(ws.starred).toBe(true); + expect(ws.createdAt).toBe(1000); + }); + + it("listWorkspaces includes starred field", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("alpha"); + await store.setWorkspaceStarred("alpha", true); + clock = 2000; + await store.ensureWorkspace("beta"); + const list = await store.listWorkspaces(); + const alpha = list.find((w) => w.id === "alpha"); + expect(alpha?.starred).toBe(true); + const beta = list.find((w) => w.id === "beta"); + expect(beta?.starred).toBe(false); + }); + + it("setWorkspaceTitle preserves starred state", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceStarred("my-work", true); + const ws = await store.setWorkspaceTitle("my-work", "Renamed"); + expect(ws.starred).toBe(true); + expect(ws.title).toBe("Renamed"); + }); + + it("setWorkspaceDefaultCwd preserves starred state", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceStarred("my-work", true); + const ws = await store.setWorkspaceDefaultCwd("my-work", "/new/path"); + expect(ws.starred).toBe(true); + expect(ws.defaultCwd).toBe("/new/path"); + }); + + it("setWorkspaceDefaultComputerId preserves starred state", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceStarred("my-work", true); + const ws = await store.setWorkspaceDefaultComputerId("my-work", "new-host"); + expect(ws.starred).toBe(true); + expect(ws.defaultComputerId).toBe("new-host"); + }); + + it("a legacy WorkspaceRow without starred reads back as false", async () => { + const store = makeStore(); + // Simulate a legacy row persisted before `starred` existed. + await storage.set( + "workspace:legacy", + JSON.stringify({ + title: "legacy", + defaultCwd: "/legacy/cwd", + defaultComputerId: null, + createdAt: 100, + lastActivityAt: 200, + }), + ); + const ws = await store.getWorkspace("legacy"); + expect(ws?.starred).toBe(false); + }); }); describe("ComputerStore", () => { - let storage: StorageNamespace; - let clock: number; - - beforeEach(() => { - storage = createMemoryStorage(); - clock = 1000; - }); - - function makeStore() { - return createConversationStore(storage, undefined, () => clock); - } - - // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) --- - - it("setComputerId/getComputerId round-trips an alias", async () => { - const store = makeStore(); - expect(await store.getComputerId("conv1")).toBeNull(); - await store.setComputerId("conv1", "myserver"); - expect(await store.getComputerId("conv1")).toBe("myserver"); - }); - - it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => { - const store = makeStore(); - await store.setComputerId("conv1", "myserver"); - expect(await store.getComputerId("conv1")).toBe("myserver"); - // null is the "local" sentinel: it clears the persisted key so it does - // NOT linger to shadow the workspace defaultComputerId. - await store.setComputerId("conv1", null); - expect(await store.getComputerId("conv1")).toBeNull(); - // idempotent — clearing an already-absent key is a no-op. - await store.setComputerId("conv1", null); - expect(await store.getComputerId("conv1")).toBeNull(); - }); - - it("clearComputerId is idempotent and un-shadows the workspace default", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "per-conv-host"); - expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); - // After clear: the workspace defaultComputerId is used (fall-through). - await store.clearComputerId("conv1"); - expect(await store.getComputerId("conv1")).toBeNull(); - expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); - // idempotent — deleting an already-absent key is a no-op. - await store.clearComputerId("conv1"); - expect(await store.getComputerId("conv1")).toBeNull(); - }); - - // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) --- - - it("setWorkspaceDefaultComputerId sets and clears", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work"); - clock = 2000; - const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); - expect(setWs.defaultComputerId).toBe("remote-host"); - // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd). - expect(setWs.lastActivityAt).toBe(1000); - const cleared = await store.setWorkspaceDefaultComputerId("my-work", null); - expect(cleared.defaultComputerId).toBeNull(); - }); - - it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => { - const store = makeStore(); - clock = 5000; - const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host"); - expect(ws).toEqual({ - id: "brand-new", - title: "brand-new", - defaultCwd: null, - defaultComputerId: "remote-host", - createdAt: 5000, - lastActivityAt: 5000, - }); - }); - - it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => { - const store = makeStore(); - clock = 1000; - await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); - clock = 2000; - const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); - expect(ws.defaultCwd).toBe("/workspace/root"); - expect(ws.defaultComputerId).toBe("remote-host"); - }); - - it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => { - const store = makeStore(); - const ws = await store.getWorkspace("default"); - expect(ws).toEqual({ - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - // And it surfaces null in listWorkspaces too. - const list = await store.listWorkspaces(); - const defaultWs = list.find((w) => w.id === "default"); - expect(defaultWs?.defaultComputerId).toBeNull(); - }); - - // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) --- - - it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "per-conv-host"); - expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); - }); - - it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); - }); - - it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work"); - await store.setWorkspaceId("conv1", "my-work"); - expect(await store.getEffectiveComputer("conv1")).toBeNull(); - }); - - it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => { - const store = makeStore(); - // No explicit workspace assignment — defaults to "default" workspace - // which has defaultComputerId null. - expect(await store.getEffectiveComputer("conv1")).toBeNull(); - }); - - it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "per-conv-host"); - // Before clear: the conversation computerId shadows the workspace default. - expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); - // After clear: the workspace defaultComputerId is used (fall-through). - await store.clearComputerId("conv1"); - expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); - }); - - // --- overrideAlias (per-turn computer override, mirror overrideCwd) --- - - it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - // A string override wins outright, even over a workspace defaultComputerId. - expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); - }); - - it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "persisted-host"); - // The override must win over the persisted computerId. - expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); - }); - - it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "persisted-host"); - // An explicit null override = "local for this turn": it wins outright and - // does NOT fall through to the persisted value or the workspace default. - expect(await store.getEffectiveComputer("conv1", null)).toBeNull(); - }); - - it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => { - const store = makeStore(); - await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); - await store.setWorkspaceId("conv1", "my-work"); - await store.setComputerId("conv1", "persisted-host"); - // No second arg — persisted computerId is used. - expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host"); - }); - - // --- round-trip through persistence (parse/toWorkspace) --- - - it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => { - const store = makeStore(); - clock = 1000; - // Create with a defaultComputerId via ensureWorkspace, then read it back - // (exercises parseWorkspaceRow -> toWorkspace round-trip). - const created = await store.ensureWorkspace("remote-work", { - title: "Remote", - defaultComputerId: "prod-server", - }); - expect(created.defaultComputerId).toBe("prod-server"); - const roundTripped = await store.getWorkspace("remote-work"); - expect(roundTripped).toEqual({ - id: "remote-work", - title: "Remote", - defaultCwd: null, - defaultComputerId: "prod-server", - createdAt: 1000, - lastActivityAt: 1000, - }); - }); - - it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => { - const store = makeStore(); - // Simulate a legacy row persisted before defaultComputerId existed: - // write a raw WorkspaceRow JSON lacking the field, then read it back. - await storage.set( - "workspace:legacy", - JSON.stringify({ - title: "legacy", - defaultCwd: "/legacy/cwd", - createdAt: 100, - lastActivityAt: 200, - }), - ); - const ws = await store.getWorkspace("legacy"); - expect(ws).toEqual({ - id: "legacy", - title: "legacy", - defaultCwd: "/legacy/cwd", - defaultComputerId: null, - createdAt: 100, - lastActivityAt: 200, - }); - }); + let storage: StorageNamespace; + let clock: number; + + beforeEach(() => { + storage = createMemoryStorage(); + clock = 1000; + }); + + function makeStore() { + return createConversationStore(storage, undefined, () => clock); + } + + // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) --- + + it("setComputerId/getComputerId round-trips an alias", async () => { + const store = makeStore(); + expect(await store.getComputerId("conv1")).toBeNull(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + }); + + it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => { + const store = makeStore(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + // null is the "local" sentinel: it clears the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + // idempotent — clearing an already-absent key is a no-op. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + it("clearComputerId is idempotent and un-shadows the workspace default", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + // idempotent — deleting an already-absent key is a no-op. + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) --- + + it("setWorkspaceDefaultComputerId sets and clears", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(setWs.defaultComputerId).toBe("remote-host"); + // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd). + expect(setWs.lastActivityAt).toBe(1000); + const cleared = await store.setWorkspaceDefaultComputerId("my-work", null); + expect(cleared.defaultComputerId).toBeNull(); + }); + + it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => { + const store = makeStore(); + clock = 5000; + const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host"); + expect(ws).toEqual({ + id: "brand-new", + title: "brand-new", + defaultCwd: null, + defaultComputerId: "remote-host", + starred: false, + createdAt: 5000, + lastActivityAt: 5000, + }); + }); + + it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + clock = 2000; + const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(ws.defaultCwd).toBe("/workspace/root"); + expect(ws.defaultComputerId).toBe("remote-host"); + }); + + it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => { + const store = makeStore(); + const ws = await store.getWorkspace("default"); + expect(ws).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + }); + // And it surfaces null in listWorkspaces too. + const list = await store.listWorkspaces(); + const defaultWs = list.find((w) => w.id === "default"); + expect(defaultWs?.defaultComputerId).toBeNull(); + }); + + // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) --- + + it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + }); + + it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => { + const store = makeStore(); + // No explicit workspace assignment — defaults to "default" workspace + // which has defaultComputerId null. + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + // Before clear: the conversation computerId shadows the workspace default. + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + // --- overrideAlias (per-turn computer override, mirror overrideCwd) --- + + it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + // A string override wins outright, even over a workspace defaultComputerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // The override must win over the persisted computerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // An explicit null override = "local for this turn": it wins outright and + // does NOT fall through to the persisted value or the workspace default. + expect(await store.getEffectiveComputer("conv1", null)).toBeNull(); + }); + + it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // No second arg — persisted computerId is used. + expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host"); + }); + + // --- round-trip through persistence (parse/toWorkspace) --- + + it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => { + const store = makeStore(); + clock = 1000; + // Create with a defaultComputerId via ensureWorkspace, then read it back + // (exercises parseWorkspaceRow -> toWorkspace round-trip). + const created = await store.ensureWorkspace("remote-work", { + title: "Remote", + defaultComputerId: "prod-server", + }); + expect(created.defaultComputerId).toBe("prod-server"); + const roundTripped = await store.getWorkspace("remote-work"); + expect(roundTripped).toEqual({ + id: "remote-work", + title: "Remote", + defaultCwd: null, + defaultComputerId: "prod-server", + starred: false, + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => { + const store = makeStore(); + // Simulate a legacy row persisted before defaultComputerId existed: + // write a raw WorkspaceRow JSON lacking the field, then read it back. + await storage.set( + "workspace:legacy", + JSON.stringify({ + title: "legacy", + defaultCwd: "/legacy/cwd", + createdAt: 100, + lastActivityAt: 200, + }), + ); + const ws = await store.getWorkspace("legacy"); + expect(ws).toEqual({ + id: "legacy", + title: "legacy", + defaultCwd: "/legacy/cwd", + defaultComputerId: null, + starred: false, + createdAt: 100, + lastActivityAt: 200, + }); + }); }); describe("isValidWorkspaceSlug", () => { - it("accepts valid slugs", () => { - expect(isValidWorkspaceSlug("my-work")).toBe(true); - expect(isValidWorkspaceSlug("default")).toBe(true); - expect(isValidWorkspaceSlug("a1b2")).toBe(true); - }); - - it("rejects invalid slugs", () => { - expect(isValidWorkspaceSlug("My-Work")).toBe(false); - expect(isValidWorkspaceSlug("-leading")).toBe(false); - expect(isValidWorkspaceSlug("trailing-")).toBe(false); - expect(isValidWorkspaceSlug("")).toBe(false); - expect(isValidWorkspaceSlug("a".repeat(41))).toBe(false); - expect(isValidWorkspaceSlug("has space")).toBe(false); - }); + it("accepts valid slugs", () => { + expect(isValidWorkspaceSlug("my-work")).toBe(true); + expect(isValidWorkspaceSlug("default")).toBe(true); + expect(isValidWorkspaceSlug("a1b2")).toBe(true); + }); + + it("rejects invalid slugs", () => { + expect(isValidWorkspaceSlug("My-Work")).toBe(false); + expect(isValidWorkspaceSlug("-leading")).toBe(false); + expect(isValidWorkspaceSlug("trailing-")).toBe(false); + expect(isValidWorkspaceSlug("")).toBe(false); + expect(isValidWorkspaceSlug("a".repeat(41))).toBe(false); + expect(isValidWorkspaceSlug("has space")).toBe(false); + }); }); 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); + }); }); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 2fd0a0c..41df92f 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,266 +1,304 @@ import { resolve as pathResolve } from "node:path"; import type { - ChatMessage, - Chunk, - ConversationMeta, - ConversationStatus, - Logger, - ReasoningEffort, - Role, - StorageNamespace, - StoredChunk, - TurnMetrics, + ChatMessage, + Chunk, + ConversationMeta, + ConversationStatus, + Logger, + ReasoningEffort, + Role, + StorageNamespace, + StoredChunk, + TurnMetrics, } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { - CONVERSATION_INDEX_KEY, - chunkKey, - chunkPrefix, - compactThresholdKey, - computerKey, - cwdKey, - metaKey, - metricsKey, - metricsPrefix, - metricsSeqKey, - modelKey, - parseSeq, - reasoningEffortKey, - seqKey, - workspaceKey, + CONVERSATION_INDEX_KEY, + chunkKey, + chunkPrefix, + compactThresholdKey, + computerKey, + cwdKey, + imageTranscriptionsKey, + metaKey, + metricsKey, + metricsPrefix, + metricsSeqKey, + modelKey, + parseSeq, + reasoningEffortKey, + seqKey, + VISION_SETTINGS_KEY, + workspaceKey, } from "./keys.js"; import { reconcileWithReport } from "./reconcile.js"; export interface ConversationStore { - readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>; - readonly load: (conversationId: string) => Promise<ChatMessage[]>; - /** - * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, - * ascending by seq. The raw append-order log; NOT reconciled (a dangling - * tool-call is returned as-is — repair is a turn-path concern). - * - * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; - * omitted/`0`/non-positive/non-integer = from the start). When - * `window.beforeSeq` is given it is an exclusive upper bound - * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. - * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the - * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` - * chunks is returned whole (exact, not truncated). - * - **Omitted = unchanged** — `window` absent (or both its fields undefined) - * is byte-identical to the pre-windowing behavior, so existing callers that - * pass no third argument are unaffected. - * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or - * `beforeSeq`) is treated as ABSENT (full selection); this method never - * throws on bad window input. The transport validates and 400s upstream. - * - * Seq numbering is 1-based and gap-free, so a client derives "older chunks - * exist" purely from the oldest returned `seq > 1`; there is deliberately no - * `earliestSeq`/high-water-mark API. - */ - readonly loadSince: ( - conversationId: string, - sinceSeq?: number, - window?: { readonly beforeSeq?: number; readonly limit?: number }, - ) => Promise<readonly StoredChunk[]>; - readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; - readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; - /** The persisted working directory for a conversation, or null if never set. */ - readonly getCwd: (conversationId: string) => Promise<string | null>; - /** Persist (upsert) the working directory for a conversation. */ - readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; - /** Clear (delete) the persisted working directory for a conversation. */ - readonly clearCwd: (conversationId: string) => Promise<void>; - /** - * The persisted computer (SSH config `Host` alias) for a conversation, or - * `null` if never set (local). The computer analog of `getCwd`. - */ - readonly getComputerId: (conversationId: string) => Promise<string | null>; - /** - * Persist (upsert) the computer for a conversation. Passing `null` clears - * the persisted selection (idempotent) — `null` is the "local" sentinel - * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors - * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). - */ - readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>; - /** Clear (delete) the persisted computer for a conversation. */ - readonly clearComputerId: (conversationId: string) => Promise<void>; - /** The persisted reasoning-effort level for a conversation, or null if never set. */ - readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; - /** Persist (upsert) the reasoning-effort level for a conversation. */ - readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise<void>; - /** The persisted model name for a conversation, or null if never set. */ - readonly getModel: (conversationId: string) => Promise<string | null>; - /** - * Persist (upsert) the model name for a conversation (a model name in - * `<credentialName>/<model>` form). Passing an empty string clears the - * persisted selection (idempotent) — this is how transport-http clears via - * `PUT /conversations/:id/model` with a `null` body. - */ - readonly setModel: (conversationId: string, model: string) => Promise<void>; - /** - * List all known conversations, sorted by `lastActivityAt` descending (most - * recent first). Metadata (createdAt, lastActivityAt, title) is tracked - * automatically on append; title defaults to the first user message. - */ - readonly listConversations: (filter?: { - readonly status?: readonly ConversationStatus[]; - readonly workspaceId?: string; - }) => Promise<readonly ConversationMeta[]>; - /** Single conversation metadata, or null if unknown. */ - readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>; - /** Set/update the human-readable title for a conversation. */ - readonly setConversationTitle: (conversationId: string, title: string) => Promise<void>; - /** Get the lifecycle status of a conversation, or null if unknown. */ - readonly getConversationStatus: (conversationId: string) => Promise<ConversationStatus | null>; - /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ - readonly setConversationStatus: ( - conversationId: string, - status: ConversationStatus, - ) => Promise<void>; - /** - * Replace the entire conversation history with the given messages. Deletes - * all existing chunks, resets the seq counter, and appends the new messages. - * Used by compaction to replace old history with a summary + recent messages. - * Metadata (createdAt, title, status) is preserved. - */ - readonly replaceHistory: ( - conversationId: string, - messages: readonly ChatMessage[], - ) => Promise<void>; - /** - * Fork (copy) the full conversation history from `sourceId` to `targetId`. - * Copies all chunks, metadata, cwd, reasoning-effort, and model. The - * target's status is set to "closed" (it's an archive) and `compactedFrom` - * is set to `sourceId`. Used by compaction to preserve the pre-compaction - * history non-destructively before replacing it with a summary. - */ - readonly forkHistory: (sourceId: string, targetId: string) => Promise<void>; - /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ - readonly getCompactPercent: (conversationId: string) => Promise<number | null>; - /** Set the compact percent (0-100, 0 = manual only). */ - readonly setCompactPercent: (conversationId: string, percent: number) => Promise<void>; - /** - * Set the `compactedFrom` field on a conversation's metadata, pointing to - * the archive conversation that holds the pre-compaction history. - */ - readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise<void>; - /** - * Returns the workspace, or synthesizes `"default"` if `id === "default"` - * and it was never persisted (title `"default"`, defaultCwd `null`, - * timestamps `0`). Returns `null` for any other non-existent id. - */ - readonly getWorkspace: (id: string) => Promise<Workspace | null>; - /** - * Create-on-miss: if absent, create with `title = opts.title ?? id`, - * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`. - * If present, return as-is (ignore `opts`). The `"default"` workspace is - * always returned as-is (never re-created). This is the `PUT - * /workspaces/:id` handler. - */ - readonly ensureWorkspace: ( - id: string, - opts?: { - readonly title?: string; - readonly defaultCwd?: string | null; - readonly defaultComputerId?: string | null; - }, - ) => Promise<Workspace>; - /** Rename a workspace. Creates the workspace if missing. */ - readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>; - /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ - readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>; - /** - * Set/clear a workspace's default computer (SSH alias). Creates the - * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. - * `null` = local (no SSH). - */ - readonly setWorkspaceDefaultComputerId: ( - id: string, - defaultComputerId: string | null, - ) => Promise<Workspace>; - /** - * Delete a workspace: (1) find all conversations with `workspaceId === id`, - * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, - * (3) delete the workspace entity. Returns `closedCount`. Throws if `id - * === "default"`. - */ - readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>; - /** - * All workspaces sorted by `lastActivityAt` descending. Each entry includes - * `conversationCount`. Always includes `"default"` (synthesized if not - * persisted, with the count of legacy/unassigned conversations). - */ - readonly listWorkspaces: () => Promise<readonly WorkspaceEntry[]>; - /** - * Returns the conversation's workspaceId, or `"default"` if the - * conversation has no workspaceId persisted (or doesn't exist). - */ - readonly getWorkspaceId: (conversationId: string) => Promise<string>; - /** - * Persist the conversation's workspace assignment. If the conversation - * doesn't exist yet, create a minimal metadata row (like - * `setConversationStatus` does). - */ - readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise<void>; - /** - * Resolve the effective working directory for a conversation: - * - * 1. **Absolute conversation cwd** — an explicit per-conversation cwd - * (`getCwd`, or `overrideCwd` when provided) that starts with `/` - * overrides outright. - * 2. **Relative conversation cwd** — an explicit cwd that does NOT start - * with `/` is resolved against the workspace `defaultCwd` (or - * `serverDefaultCwd` when the workspace has no `defaultCwd`) via - * `path.resolve`. - * 3. **No conversation cwd** — the workspace `defaultCwd` is used. - * 4. **Neither set** — the `serverDefaultCwd` (defaulting to - * `process.cwd()` at construction time) is used. - * - * The workspace is resolved via `getWorkspaceId` (falling back to - * `"default"`) + `getWorkspace`. - * - * @param overrideCwd — an explicit cwd to resolve INSTEAD of the persisted - * `getCwd` value. When provided (not `undefined`), it is fed through the - * same algorithm above (absolute → returned as-is; relative → resolved - * against the workspace `defaultCwd`). Used by the session-orchestrator - * for a per-turn cwd override (sent by the client on `chat.send`) so a - * transient relative cwd is resolved the same way a persisted one is, - * instead of being resolved against `process.cwd()`. When omitted, the - * persisted `getCwd` is read as today. - */ - readonly getEffectiveCwd: ( - conversationId: string, - overrideCwd?: string, - ) => Promise<string | null>; - /** - * Resolve the effective computer (SSH alias) for a conversation — the - * computer analog of `getEffectiveCwd`. Resolution ladder: - * - * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) - * wins outright, EVEN when `null` (explicitly local for this turn — it - * does NOT fall through). - * 2. **Persisted per-conversation `computerId`** — `getComputerId`. - * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` - * (falling back to `"default"`) + `getWorkspace`. - * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). - * - * Returns the alias STRING (or `null`); it does NOT validate the alias - * exists in `~/.ssh/config` (validation happens at connect time — a stale - * alias yields a clear connect error rather than silently falling back to - * local). - * - * @param overrideAlias — an explicit alias to resolve INSTEAD of the - * persisted `getComputerId` value. When provided (not `undefined`), it - * is returned as-is (string or `null`), short-circuiting the rest of the - * ladder. Used by the session-orchestrator for a per-turn computer - * override (sent by the client on `chat.send`). When omitted, the - * persisted `getComputerId` is read as today. - */ - readonly getEffectiveComputer: ( - conversationId: string, - overrideAlias?: string | null, - ) => Promise<string | null>; + readonly append: (conversationId: string, messages: readonly ChatMessage[]) => Promise<void>; + readonly load: (conversationId: string) => Promise<ChatMessage[]>; + /** + * Read the conversation's persisted chunks as a SELECTION + optional WINDOW, + * ascending by seq. The raw append-order log; NOT reconciled (a dangling + * tool-call is returned as-is — repair is a turn-path concern). + * + * - **Selection** — `sinceSeq` is an exclusive lower bound (`seq > sinceSeq`; + * omitted/`0`/non-positive/non-integer = from the start). When + * `window.beforeSeq` is given it is an exclusive upper bound + * (`seq < beforeSeq`). Together: `sinceSeq < seq < beforeSeq`. + * - **Window** — `window.limit` returns only the NEWEST `limit` chunks of the + * selection; the result STAYS ASCENDING by seq. A selection with ≤ `limit` + * chunks is returned whole (exact, not truncated). + * - **Omitted = unchanged** — `window` absent (or both its fields undefined) + * is byte-identical to the pre-windowing behavior, so existing callers that + * pass no third argument are unaffected. + * - **Garbage-in is forgiving** — a non-positive or non-integer `limit` (or + * `beforeSeq`) is treated as ABSENT (full selection); this method never + * throws on bad window input. The transport validates and 400s upstream. + * + * Seq numbering is 1-based and gap-free, so a client derives "older chunks + * exist" purely from the oldest returned `seq > 1`; there is deliberately no + * `earliestSeq`/high-water-mark API. + */ + readonly loadSince: ( + conversationId: string, + sinceSeq?: number, + window?: { readonly beforeSeq?: number; readonly limit?: number }, + ) => Promise<readonly StoredChunk[]>; + readonly appendMetrics: (conversationId: string, metrics: TurnMetrics) => Promise<void>; + readonly loadMetrics: (conversationId: string) => Promise<readonly TurnMetrics[]>; + /** The persisted working directory for a conversation, or null if never set. */ + readonly getCwd: (conversationId: string) => Promise<string | null>; + /** Persist (upsert) the working directory for a conversation. */ + readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; + /** Clear (delete) the persisted working directory for a conversation. */ + readonly clearCwd: (conversationId: string) => Promise<void>; + /** + * The persisted computer (SSH config `Host` alias) for a conversation, or + * `null` if never set (local). The computer analog of `getCwd`. + */ + readonly getComputerId: (conversationId: string) => Promise<string | null>; + /** + * Persist (upsert) the computer for a conversation. Passing `null` clears + * the persisted selection (idempotent) — `null` is the "local" sentinel + * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors + * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). + */ + readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>; + /** Clear (delete) the persisted computer for a conversation. */ + readonly clearComputerId: (conversationId: string) => Promise<void>; + /** The persisted reasoning-effort level for a conversation, or null if never set. */ + readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; + /** Persist (upsert) the reasoning-effort level for a conversation. */ + readonly setReasoningEffort: (conversationId: string, effort: ReasoningEffort) => Promise<void>; + /** The persisted model name for a conversation, or null if never set. */ + readonly getModel: (conversationId: string) => Promise<string | null>; + /** + * Persist (upsert) the model name for a conversation (a model name in + * `<credentialName>/<model>` form). Passing an empty string clears the + * persisted selection (idempotent) — this is how transport-http clears via + * `PUT /conversations/:id/model` with a `null` body. + */ + readonly setModel: (conversationId: string, model: string) => Promise<void>; + /** + * List all known conversations, sorted by `lastActivityAt` descending (most + * recent first). Metadata (createdAt, lastActivityAt, title) is tracked + * automatically on append; title defaults to the first user message. + */ + readonly listConversations: (filter?: { + readonly status?: readonly ConversationStatus[]; + readonly workspaceId?: string; + }) => Promise<readonly ConversationMeta[]>; + /** Single conversation metadata, or null if unknown. */ + readonly getConversationMeta: (conversationId: string) => Promise<ConversationMeta | null>; + /** Set/update the human-readable title for a conversation. */ + readonly setConversationTitle: (conversationId: string, title: string) => Promise<void>; + /** Get the lifecycle status of a conversation, or null if unknown. */ + readonly getConversationStatus: (conversationId: string) => Promise<ConversationStatus | null>; + /** Set the lifecycle status of a conversation. Creates a minimal metadata row if missing. */ + readonly setConversationStatus: ( + conversationId: string, + status: ConversationStatus, + ) => Promise<void>; + /** + * Replace the entire conversation history with the given messages. Deletes + * all existing chunks, resets the seq counter, and appends the new messages. + * Used by compaction to replace old history with a summary + recent messages. + * Metadata (createdAt, title, status) is preserved. + */ + readonly replaceHistory: ( + conversationId: string, + messages: readonly ChatMessage[], + ) => Promise<void>; + /** + * Fork (copy) the full conversation history from `sourceId` to `targetId`. + * Copies all chunks, metadata, cwd, reasoning-effort, and model. The + * target's status is set to "closed" (it's an archive) and `compactedFrom` + * is set to `sourceId`. Used by compaction to preserve the pre-compaction + * history non-destructively before replacing it with a summary. + */ + readonly forkHistory: (sourceId: string, targetId: string) => Promise<void>; + /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ + readonly getCompactPercent: (conversationId: string) => Promise<number | null>; + /** Set the compact percent (0-100, 0 = manual only). */ + readonly setCompactPercent: (conversationId: string, percent: number) => Promise<void>; + /** + * Get the per-conversation image transcription cache: a map of image URL → + * transcription text. Used by the vision handoff to avoid re-transcribing + * old images that were compacted to text on a previous turn. Returns an + * empty map when none are cached. + */ + readonly getImageTranscriptions: (conversationId: string) => Promise<ReadonlyMap<string, string>>; + /** + * Upsert a single image transcription into the per-conversation cache. + * Merges with any existing transcriptions (does NOT replace the whole map). + */ + readonly setImageTranscription: ( + conversationId: string, + imageUrl: string, + transcription: string, + ) => Promise<void>; + /** + * Get the global vision settings (image compaction limit + compaction model). + * The limit defaults to 10 when never set; the compaction model defaults to + * null (auto-select). Shared across ALL conversations and vision models. + */ + readonly getVisionSettings: () => Promise<{ + readonly imageLimit: number; + readonly compactionModel: string | null; + }>; + /** Set the global vision image compaction limit (0 = disabled). */ + readonly setVisionImageLimit: (limit: number) => Promise<void>; + /** Set the global vision compaction model (null = auto-select). */ + readonly setVisionCompactionModel: (model: string | null) => Promise<void>; + /** + * Set the `compactedFrom` field on a conversation's metadata, pointing to + * the archive conversation that holds the pre-compaction history. + */ + readonly setCompactedFrom: (conversationId: string, newConversationId: string) => Promise<void>; + /** + * Returns the workspace, or synthesizes `"default"` if `id === "default"` + * and it was never persisted (title `"default"`, defaultCwd `null`, + * timestamps `0`). Returns `null` for any other non-existent id. + */ + readonly getWorkspace: (id: string) => Promise<Workspace | null>; + /** + * Create-on-miss: if absent, create with `title = opts.title ?? id`, + * `defaultCwd = opts.defaultCwd ?? null`, `createdAt/lastActivityAt = now`. + * If present, return as-is (ignore `opts`). The `"default"` workspace is + * always returned as-is (never re-created). This is the `PUT + * /workspaces/:id` handler. + */ + readonly ensureWorkspace: ( + id: string, + opts?: { + readonly title?: string; + readonly defaultCwd?: string | null; + readonly defaultComputerId?: string | null; + }, + ) => Promise<Workspace>; + /** Rename a workspace. Creates the workspace if missing. */ + readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>; + /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ + readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>; + /** + * Set/clear a workspace's default computer (SSH alias). Creates the + * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. + * `null` = local (no SSH). + */ + readonly setWorkspaceDefaultComputerId: ( + id: string, + defaultComputerId: string | null, + ) => Promise<Workspace>; + /** + * Star or unstar a workspace. Creates the workspace if missing (like + * `setWorkspaceTitle`). Starred workspaces receive PRIORITY in the + * concurrency limiter queue — their agents jump ahead of agents from + * non-starred workspaces (oldest-agent-first within each group). + */ + readonly setWorkspaceStarred: (id: string, starred: boolean) => Promise<Workspace>; + /** + * Delete a workspace: (1) find all conversations with `workspaceId === id`, + * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, + * (3) delete the workspace entity. Returns `closedCount`. Throws if `id + * === "default"`. + */ + readonly deleteWorkspace: (id: string) => Promise<{ closedCount: number }>; + /** + * All workspaces sorted by `lastActivityAt` descending. Each entry includes + * `conversationCount`. Always includes `"default"` (synthesized if not + * persisted, with the count of legacy/unassigned conversations). + */ + readonly listWorkspaces: () => Promise<readonly WorkspaceEntry[]>; + /** + * Returns the conversation's workspaceId, or `"default"` if the + * conversation has no workspaceId persisted (or doesn't exist). + */ + readonly getWorkspaceId: (conversationId: string) => Promise<string>; + /** + * Persist the conversation's workspace assignment. If the conversation + * doesn't exist yet, create a minimal metadata row (like + * `setConversationStatus` does). + */ + readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise<void>; + /** + * Resolve the effective working directory for a conversation: + * + * 1. **Absolute conversation cwd** — an explicit per-conversation cwd + * (`getCwd`, or `overrideCwd` when provided) that starts with `/` + * overrides outright. + * 2. **Relative conversation cwd** — an explicit cwd that does NOT start + * with `/` is resolved against the workspace `defaultCwd` (or + * `serverDefaultCwd` when the workspace has no `defaultCwd`) via + * `path.resolve`. + * 3. **No conversation cwd** — the workspace `defaultCwd` is used. + * 4. **Neither set** — the `serverDefaultCwd` (defaulting to + * `process.cwd()` at construction time) is used. + * + * The workspace is resolved via `getWorkspaceId` (falling back to + * `"default"`) + `getWorkspace`. + * + * @param overrideCwd — an explicit cwd to resolve INSTEAD of the persisted + * `getCwd` value. When provided (not `undefined`), it is fed through the + * same algorithm above (absolute → returned as-is; relative → resolved + * against the workspace `defaultCwd`). Used by the session-orchestrator + * for a per-turn cwd override (sent by the client on `chat.send`) so a + * transient relative cwd is resolved the same way a persisted one is, + * instead of being resolved against `process.cwd()`. When omitted, the + * persisted `getCwd` is read as today. + */ + readonly getEffectiveCwd: ( + conversationId: string, + overrideCwd?: string, + ) => Promise<string | null>; + /** + * Resolve the effective computer (SSH alias) for a conversation — the + * computer analog of `getEffectiveCwd`. Resolution ladder: + * + * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) + * wins outright, EVEN when `null` (explicitly local for this turn — it + * does NOT fall through). + * 2. **Persisted per-conversation `computerId`** — `getComputerId`. + * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` + * (falling back to `"default"`) + `getWorkspace`. + * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). + * + * Returns the alias STRING (or `null`); it does NOT validate the alias + * exists in `~/.ssh/config` (validation happens at connect time — a stale + * alias yields a clear connect error rather than silently falling back to + * local). + * + * @param overrideAlias — an explicit alias to resolve INSTEAD of the + * persisted `getComputerId` value. When provided (not `undefined`), it + * is returned as-is (string or `null`), short-circuiting the rest of the + * ladder. Used by the session-orchestrator for a per-turn computer + * override (sent by the client on `chat.send`). When omitted, the + * persisted `getComputerId` is read as today. + */ + readonly getEffectiveComputer: ( + conversationId: string, + overrideAlias?: string | null, + ) => Promise<string | null>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -270,9 +308,9 @@ export const conversationStoreHandle = defineService<ConversationStore>("convers * non-positive / non-integer / undefined input. Keeps `loadSince` total. */ function positiveInt(value: number | undefined): number | undefined { - if (value === undefined) return undefined; - if (!Number.isInteger(value) || value <= 0) return undefined; - return value; + if (value === undefined) return undefined; + if (!Number.isInteger(value) || value <= 0) return undefined; + return value; } /** @@ -285,16 +323,16 @@ function positiveInt(value: number | undefined): number | undefined { * ever passed (omitted / `0` / non-negative integers). */ function sinceSeqBase(value: number | undefined): number { - if (value === undefined) return 0; - if (!Number.isInteger(value) || value < 0) return 0; - return value; + if (value === undefined) return 0; + if (!Number.isInteger(value) || value < 0) return 0; + return value; } interface PersistedChunkEntry { - readonly chunk: Chunk; - readonly role: Role; - readonly msgIdx: number; - readonly chunkIdx: number; + readonly chunk: Chunk; + readonly role: Role; + readonly msgIdx: number; + readonly chunkIdx: number; } /** @@ -302,16 +340,16 @@ interface PersistedChunkEntry { * Maps to `ConversationMeta` (from `@dispatch/wire`) by adding the `id`. */ interface ConversationMetaRow { - readonly createdAt: number; - readonly lastActivityAt: number; - readonly title: string; - readonly status: ConversationStatus; - readonly compactedFrom?: string; - /** - * The workspace this conversation belongs to. Absent on legacy rows - * (read as `"default"`). Persisted only when explicitly assigned. - */ - readonly workspaceId?: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly title: string; + readonly status: ConversationStatus; + readonly compactedFrom?: string; + /** + * The workspace this conversation belongs to. Absent on legacy rows + * (read as `"default"`). Persisted only when explicitly assigned. + */ + readonly workspaceId?: string; } /** @@ -319,16 +357,22 @@ interface ConversationMetaRow { * is the key, so it is not duplicated in the row. */ interface WorkspaceRow { - readonly title: string; - readonly defaultCwd: string | null; - /** - * The workspace's default computer (SSH config `Host` alias) — the computer - * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this - * workspace inherit it when they set no `computerId` of their own. - */ - readonly defaultComputerId: string | null; - readonly createdAt: number; - readonly lastActivityAt: number; + readonly title: string; + readonly defaultCwd: string | null; + /** + * The workspace's default computer (SSH config `Host` alias) — the computer + * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this + * workspace inherit it when they set no `computerId` of their own. + */ + readonly defaultComputerId: string | null; + /** + * Whether the workspace is starred by the user. Starred workspaces receive + * PRIORITY in the concurrency limiter queue. Defaults to `false` on legacy + * rows (normalized by `parseWorkspaceRow`). + */ + readonly starred: boolean; + readonly createdAt: number; + readonly lastActivityAt: number; } /** Maximum title length (in characters) before truncation with an ellipsis. */ @@ -344,15 +388,15 @@ const TITLE_MAX = 80; * persisting. */ export function extractTitle(messages: readonly ChatMessage[]): string { - for (const msg of messages) { - if (msg.role !== "user") continue; - for (const chunk of msg.chunks) { - if (chunk.type === "text") { - return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; - } - } - } - return "Untitled"; + for (const msg of messages) { + if (msg.role !== "user") continue; + for (const chunk of msg.chunks) { + if (chunk.type === "text") { + return chunk.text.length > TITLE_MAX ? `${chunk.text.slice(0, TITLE_MAX)}…` : chunk.text; + } + } + } + return "Untitled"; } /** @@ -360,44 +404,44 @@ export function extractTitle(messages: readonly ChatMessage[]): string { * parse / shape failure so callers can treat a corrupt row as missing. */ function parseMetaRow(raw: string): ConversationMetaRow | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as ConversationMetaRow).createdAt !== "number" || - typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || - typeof (parsed as ConversationMetaRow).title !== "string" - ) { - return null; - } - const row = parsed as ConversationMetaRow; - const status: ConversationStatus = - row.status === "active" || row.status === "closed" ? row.status : "idle"; - return { - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status, - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}), - }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as ConversationMetaRow).createdAt !== "number" || + typeof (parsed as ConversationMetaRow).lastActivityAt !== "number" || + typeof (parsed as ConversationMetaRow).title !== "string" + ) { + return null; + } + const row = parsed as ConversationMetaRow; + const status: ConversationStatus = + row.status === "active" || row.status === "closed" ? row.status : "idle"; + return { + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status, + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + ...(row.workspaceId !== undefined ? { workspaceId: row.workspaceId } : {}), + }; } function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { - return { - id, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status: row.status, - workspaceId: row.workspaceId ?? "default", - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - }; + return { + id, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status: row.status, + workspaceId: row.workspaceId ?? "default", + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + }; } /** @@ -406,7 +450,7 @@ function toMeta(id: string, row: ConversationMetaRow): ConversationMeta { * to validate before hitting the store. Pure (input → boolean). */ export function isValidWorkspaceSlug(id: string): boolean { - return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id); + return /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(id); } /** The always-present, non-deletable default workspace id. */ @@ -417,897 +461,994 @@ const DEFAULT_WORKSPACE_ID = "default"; * shape failure so callers can treat a corrupt row as missing. */ function parseWorkspaceRow(raw: string): WorkspaceRow | null { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as WorkspaceRow).title !== "string" || - typeof (parsed as WorkspaceRow).createdAt !== "number" || - typeof (parsed as WorkspaceRow).lastActivityAt !== "number" - ) { - return null; - } - const row = parsed as WorkspaceRow; - // `defaultCwd` may be null OR a string; treat anything else as null. - const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; - // `defaultComputerId` may be null OR a string; treat anything else as null - // (mirrors `defaultCwd`). Absent on legacy rows → null (local). - const defaultComputerId = - typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; - return { - title: row.title, - defaultCwd, - defaultComputerId, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as WorkspaceRow).title !== "string" || + typeof (parsed as WorkspaceRow).createdAt !== "number" || + typeof (parsed as WorkspaceRow).lastActivityAt !== "number" + ) { + return null; + } + const row = parsed as WorkspaceRow; + // `defaultCwd` may be null OR a string; treat anything else as null. + const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; + // `defaultComputerId` may be null OR a string; treat anything else as null + // (mirrors `defaultCwd`). Absent on legacy rows → null (local). + const defaultComputerId = + typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; + // `starred` may be absent on legacy rows; treat anything non-boolean as false. + const starred = row.starred === true; + return { + title: row.title, + defaultCwd, + defaultComputerId, + starred, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + }; } function toWorkspace(id: string, row: WorkspaceRow): Workspace { - return { - id, - title: row.title, - defaultCwd: row.defaultCwd, - defaultComputerId: row.defaultComputerId, - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - }; + return { + id, + title: row.title, + defaultCwd: row.defaultCwd, + defaultComputerId: row.defaultComputerId, + starred: row.starred === true, + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + }; } export function createConversationStore( - storage: StorageNamespace, - logger?: Logger, - now: () => number = Date.now, - serverDefaultCwd: string = process.cwd(), + storage: StorageNamespace, + logger?: Logger, + now: () => number = Date.now, + serverDefaultCwd: string = process.cwd(), ): ConversationStore { - /** - * Add `conversationId` to the persisted index (idempotent). The store is - * not highly concurrent — the session-orchestrator serializes turns per - * conversation — so a simple read-modify-write suffices; `listConversations` - * deduplicates on read in case of a race on this update. - */ - async function ensureInIndex(conversationId: string): Promise<void> { - const raw = await storage.get(CONVERSATION_INDEX_KEY); - let ids: string[]; - if (raw === null) { - ids = []; - } else { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - parsed = []; - } - ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; - } - if (ids.includes(conversationId)) return; - ids.push(conversationId); - await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); - } - - /** - * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt. - */ - async function readWorkspaceRow(id: string): Promise<WorkspaceRow | null> { - const raw = await storage.get(workspaceKey(id)); - if (raw === null) return null; - return parseWorkspaceRow(raw); - } - - /** - * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on - * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt - * = ts`) so that the first activity in any workspace — including the - * synthesized `"default"` — is recorded. Does NOT touch `title` or - * `defaultCwd` on an existing row. - */ - async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise<void> { - const existing = await readWorkspaceRow(workspaceId); - const row: WorkspaceRow = - existing === null - ? { - title: workspaceId, - defaultCwd: null, - defaultComputerId: null, - createdAt: ts, - lastActivityAt: ts, - } - : { - title: existing.title, - defaultCwd: existing.defaultCwd, - defaultComputerId: existing.defaultComputerId, - createdAt: existing.createdAt, - lastActivityAt: ts, - }; - await storage.set(workspaceKey(workspaceId), JSON.stringify(row)); - } - - return { - async append(conversationId, messages) { - const raw = await storage.get(seqKey(conversationId)); - let seq = parseSeq(raw) + 1; - - for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { - const msg = messages[msgIdx]; - if (msg === undefined) continue; - for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { - const chunk = msg.chunks[chunkIdx]; - if (chunk === undefined) continue; - const entry: PersistedChunkEntry = { - chunk, - role: msg.role, - msgIdx, - chunkIdx, - }; - await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); - seq++; - } - } - - await storage.set(seqKey(conversationId), String(seq - 1)); - - // Metadata upsert: track createdAt/lastActivityAt/title and keep the - // conversation discoverable in the index. - const ts = now(); - let conversationWorkspaceId = DEFAULT_WORKSPACE_ID; - const metaRaw = await storage.get(metaKey(conversationId)); - if (metaRaw === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: extractTitle(messages), - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - } else { - const existing = parseMetaRow(metaRaw); - if (existing === null) { - // Corrupt row — rewrite from scratch using this append. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: extractTitle(messages), - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - } else { - conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID; - const title = - existing.title === "Untitled" || existing.title === "" - ? extractTitle(messages) - : existing.title; - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: ts, - title, - status: existing.status, - ...(existing.compactedFrom !== undefined - ? { compactedFrom: existing.compactedFrom } - : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - } - } - // Bump the owning workspace's lastActivityAt to this append's time. - await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts); - }, - - async load(conversationId) { - const prefix = chunkPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const messages: ChatMessage[] = []; - let currentChunks: Chunk[] = []; - let currentRole: Role | undefined; - let currentMsgIdx = -1; - - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - let entry: PersistedChunkEntry; - try { - entry = JSON.parse(value) as PersistedChunkEntry; - } catch (err) { - // "Never leave the system broken": a single corrupt/unparseable - // row must not brick the whole conversation. Skip it (append-only - // storage untouched) and let reconcile run on the rest. loadSince - // is intentionally NOT hardened here — it is the raw FE read path. - if (logger !== undefined) { - logger.warn("skipping corrupt chunk row", { - conversationId, - key, - error: err instanceof Error ? err.message : String(err), - }); - } - continue; - } - - if (entry.msgIdx !== currentMsgIdx) { - if (currentMsgIdx >= 0 && currentRole !== undefined) { - messages.push({ role: currentRole, chunks: currentChunks }); - } - currentChunks = []; - currentRole = entry.role; - currentMsgIdx = entry.msgIdx; - } - - currentChunks.push(entry.chunk); - } - - if (currentMsgIdx >= 0 && currentRole !== undefined) { - messages.push({ role: currentRole, chunks: currentChunks }); - } - - const { messages: repaired, report } = reconcileWithReport(messages); - - const hasReconcileActivity = - report.repairedCount > 0 || - report.strippedErrorChunks > 0 || - report.droppedEmptyMessages > 0; - if (hasReconcileActivity && logger !== undefined) { - const child = logger.child({ conversationId }); - const span = child.span("reconcile.repair", { - repairedCount: report.repairedCount, - firstRepairedToolCallId: report.repairedToolCallIds[0] ?? null, - strippedErrorChunks: report.strippedErrorChunks, - droppedEmptyMessages: report.droppedEmptyMessages, - }); - span.end(); - } - - return repaired; - }, - - async loadSince(conversationId, sinceSeq, window) { - const prefix = chunkPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const result: StoredChunk[] = []; - const minSeq = sinceSeqBase(sinceSeq); - // Forgiving: a non-positive / non-integer bound is treated as ABSENT. - const beforeSeq = positiveInt(window?.beforeSeq); - const limit = positiveInt(window?.limit); - - for (const key of sorted) { - const seq = parseSeq(key.split(":").pop() ?? null); - if (seq <= minSeq) continue; - if (beforeSeq !== undefined && seq >= beforeSeq) continue; - const value = await storage.get(key); - if (value === null) continue; - const entry = JSON.parse(value) as PersistedChunkEntry; - result.push({ seq, role: entry.role, chunk: entry.chunk }); - } - - // Window: keep only the NEWEST `limit` chunks, still ascending by seq. - if (limit !== undefined && result.length > limit) { - return result.slice(result.length - limit); - } - - return result; - }, - - async appendMetrics(conversationId, metrics) { - const raw = await storage.get(metricsSeqKey(conversationId)); - const ordinal = parseSeq(raw) + 1; - await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); - await storage.set(metricsSeqKey(conversationId), String(ordinal)); - }, - - async loadMetrics(conversationId) { - const prefix = metricsPrefix(conversationId); - const keys = await storage.keys(prefix); - const sorted = [...keys].sort(); - - const result: TurnMetrics[] = []; - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - result.push(JSON.parse(value) as TurnMetrics); - } - - return result; - }, - - async getCwd(conversationId) { - return await storage.get(cwdKey(conversationId)); - }, - - async setCwd(conversationId, cwd) { - await storage.set(cwdKey(conversationId), cwd); - if (logger !== undefined) { - logger.debug("cwd set", { conversationId }); - } - }, - - async clearCwd(conversationId) { - // Idempotent: deleting an already-absent key is a no-op (no error). - await storage.delete(cwdKey(conversationId)); - if (logger !== undefined) { - logger.debug("cwd cleared", { conversationId }); - } - }, - - async getComputerId(conversationId) { - return await storage.get(computerKey(conversationId)); - }, - - async setComputerId(conversationId, alias) { - // `null` is the "local" sentinel: clear the persisted key so it does - // NOT linger to shadow the workspace defaultComputerId. Idempotent - // (deleting an already-absent key is a no-op). Mirrors `setModel`'s - // clear-on-sentinel pattern. - if (alias === null) { - await storage.delete(computerKey(conversationId)); - if (logger !== undefined) { - logger.debug("computer cleared", { conversationId }); - } - return; - } - await storage.set(computerKey(conversationId), alias); - if (logger !== undefined) { - logger.debug("computer set", { conversationId }); - } - }, - - async clearComputerId(conversationId) { - // Idempotent: deleting an already-absent key is a no-op (no error). - await storage.delete(computerKey(conversationId)); - if (logger !== undefined) { - logger.debug("computer cleared", { conversationId }); - } - }, - - async getReasoningEffort(conversationId) { - return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; - }, - - async setReasoningEffort(conversationId, effort) { - await storage.set(reasoningEffortKey(conversationId), effort); - if (logger !== undefined) { - logger.debug("reasoning-effort set", { conversationId }); - } - }, - - async getModel(conversationId) { - return await storage.get(modelKey(conversationId)); - }, - - async setModel(conversationId, model) { - if (model === "") { - // Idempotent clear: an empty model clears the persisted - // selection. Deleting an already-absent key is a no-op. - await storage.delete(modelKey(conversationId)); - if (logger !== undefined) { - logger.debug("model cleared", { conversationId }); - } - return; - } - await storage.set(modelKey(conversationId), model); - if (logger !== undefined) { - logger.debug("model set", { conversationId }); - } - }, - async listConversations(filter) { - const raw = await storage.get(CONVERSATION_INDEX_KEY); - if (raw === null) return []; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return []; - } - if (!Array.isArray(parsed)) return []; - // Deduplicate (in case of a race on the index update) while preserving - // first-seen order. - const seen = new Set<string>(); - const ids: string[] = []; - for (const v of parsed) { - if (typeof v !== "string" || seen.has(v)) continue; - seen.add(v); - ids.push(v); - } - - const statusFilter = filter?.status; - const workspaceFilter = filter?.workspaceId; - const metas: ConversationMeta[] = []; - for (const id of ids) { - const metaRaw = await storage.get(metaKey(id)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; - if (workspaceFilter !== undefined) { - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - if (wsId !== workspaceFilter) continue; - } - metas.push(toMeta(id, row)); - } - // Sort by lastActivityAt descending (most recent first). Stable sort - // keeps first-seen (index) order for ties. - return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); - }, - - async getConversationMeta(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return null; - const row = parseMetaRow(raw); - if (row === null) return null; - return toMeta(conversationId, row); - }, - - async setConversationTitle(conversationId, title) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Title set before any message was appended — create a minimal row. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title, - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - // Corrupt row — rewrite from scratch with this title. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title, - status: "idle", - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - // Preserve createdAt + lastActivityAt + status; update only the title. - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title, - status: existing.status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async getConversationStatus(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return null; - const row = parseMetaRow(raw); - if (row === null) return null; - return row.status; - }, - - async setConversationStatus(conversationId, status) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Status set before any message was appended — create a minimal row. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: existing.title, - status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async replaceHistory(conversationId, messages) { - // Delete all existing chunks. - const keys = await storage.keys(chunkPrefix(conversationId)); - for (const k of keys) { - await storage.delete(k); - } - // Reset the seq counter so the new messages start from seq 1. - await storage.set(seqKey(conversationId), "0"); - // Append the new messages (re-uses the append logic for seq - // numbering + metadata upsert). - await this.append(conversationId, messages); - }, - - async forkHistory(sourceId, targetId) { - // Copy all chunks from source to target, re-numbered from seq 1. - const keys = await storage.keys(chunkPrefix(sourceId)); - const sorted = [...keys].sort(); - let seq = 1; - for (const key of sorted) { - const value = await storage.get(key); - if (value === null) continue; - await storage.set(chunkKey(targetId, seq), value); - seq++; - } - await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0))); - - // Copy metadata with archive title + closed status. - // Inherit compactedFrom from the source so archives chain: - // A → Y → X (each archive points to the previous one). - const metaRaw = await storage.get(metaKey(sourceId)); - if (metaRaw !== null) { - const existing = parseMetaRow(metaRaw); - if (existing !== null) { - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: `Archive: ${existing.title}`, - status: "closed", - ...(existing.compactedFrom !== undefined - ? { compactedFrom: existing.compactedFrom } - : {}), - ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), - }; - await storage.set(metaKey(targetId), JSON.stringify(row)); - } - } - await ensureInIndex(targetId); - - // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). - const cwd = await storage.get(cwdKey(sourceId)); - if (cwd !== null) await storage.set(cwdKey(targetId), cwd); - const effort = await storage.get(reasoningEffortKey(sourceId)); - if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); - const model = await storage.get(modelKey(sourceId)); - if (model !== null) await storage.set(modelKey(targetId), model); - const computerId = await storage.get(computerKey(sourceId)); - if (computerId !== null) await storage.set(computerKey(targetId), computerId); - }, - - async getCompactPercent(conversationId) { - const raw = await storage.get(compactThresholdKey(conversationId)); - if (raw === null) return null; - const n = Number.parseInt(raw, 10); - return Number.isNaN(n) ? null : n; - }, - - async setCompactPercent(conversationId, percent) { - await storage.set(compactThresholdKey(conversationId), String(percent)); - if (logger !== undefined) { - logger.debug("compact-percent set", { conversationId, percent }); - } - }, - - async setCompactedFrom(conversationId, newConversationId) { - const raw = await storage.get(metaKey(conversationId)); - const existing = raw !== null ? parseMetaRow(raw) : null; - const ts = now(); - const row: ConversationMetaRow = existing ?? { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - }; - await storage.set( - metaKey(conversationId), - JSON.stringify({ ...row, compactedFrom: newConversationId }), - ); - }, - - async getWorkspace(id) { - const row = await readWorkspaceRow(id); - if (row !== null) return toWorkspace(id, row); - // Synthesize the always-present "default" workspace when it was - // never persisted (title "default", defaultCwd null, defaultComputerId - // null [local], timestamps 0). - if (id === DEFAULT_WORKSPACE_ID) { - return { - id: DEFAULT_WORKSPACE_ID, - title: DEFAULT_WORKSPACE_ID, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - } - return null; - }, - - async ensureWorkspace(id, opts) { - const existing = await readWorkspaceRow(id); - if (existing !== null) return toWorkspace(id, existing); - // Absent — create with defaults. The synthesized "default" is also - // materialized here when first explicitly ensured. - const ts = now(); - const row: WorkspaceRow = { - title: opts?.title ?? id, - defaultCwd: opts?.defaultCwd ?? null, - defaultComputerId: opts?.defaultComputerId ?? null, - createdAt: ts, - lastActivityAt: ts, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceTitle(id, title) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title, - defaultCwd: base.defaultCwd, - defaultComputerId: base.defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceDefaultCwd(id, defaultCwd) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title: base.title, - defaultCwd, - defaultComputerId: base.defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - const existing = await readWorkspaceRow(id); - const ts = now(); - const base = - existing === null - ? { - title: id, - defaultCwd: null as string | null, - defaultComputerId: null as string | null, - createdAt: ts, - lastActivityAt: ts, - } - : existing; - const row: WorkspaceRow = { - title: base.title, - defaultCwd: base.defaultCwd, - defaultComputerId, - createdAt: base.createdAt, - lastActivityAt: base.lastActivityAt, - }; - await storage.set(workspaceKey(id), JSON.stringify(row)); - return toWorkspace(id, row); - }, - - async deleteWorkspace(id) { - if (id === DEFAULT_WORKSPACE_ID) { - throw new Error('The "default" workspace cannot be deleted.'); - } - // (1) Find all conversations with workspaceId === id, (2) set each - // to status "closed" and reassign workspaceId to "default". - let closedCount = 0; - const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); - if (indexRaw !== null) { - let parsed: unknown; - try { - parsed = JSON.parse(indexRaw); - } catch { - parsed = []; - } - const ids = Array.isArray(parsed) - ? (parsed.filter((v) => typeof v === "string") as string[]) - : []; - for (const convId of ids) { - const metaRaw = await storage.get(metaKey(convId)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - if (wsId !== id) continue; - const updated: ConversationMetaRow = { - createdAt: row.createdAt, - lastActivityAt: row.lastActivityAt, - title: row.title, - status: "closed", - ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), - workspaceId: DEFAULT_WORKSPACE_ID, - }; - await storage.set(metaKey(convId), JSON.stringify(updated)); - closedCount++; - } - } - // (3) Delete the workspace entity. - await storage.delete(workspaceKey(id)); - return { closedCount }; - }, - - async listWorkspaces() { - // Collect persisted workspace rows via the `workspace:` key prefix. - const wsPrefix = "workspace:"; - const wsKeys = await storage.keys(wsPrefix); - const byId = new Map<string, Workspace>(); - for (const key of wsKeys) { - // Key shape: `workspace:<id>`. Strip the prefix to recover the id. - const id = key.slice(wsPrefix.length); - if (id.length === 0) continue; - const raw = await storage.get(key); - if (raw === null) continue; - const row = parseWorkspaceRow(raw); - if (row === null) continue; - byId.set(id, toWorkspace(id, row)); - } - // Always include "default" (synthesized if not persisted). - if (!byId.has(DEFAULT_WORKSPACE_ID)) { - byId.set(DEFAULT_WORKSPACE_ID, { - id: DEFAULT_WORKSPACE_ID, - title: DEFAULT_WORKSPACE_ID, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }); - } - // Count conversations per workspace by scanning the index + meta. - const counts = new Map<string, number>(); - for (const id of byId.keys()) counts.set(id, 0); - const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); - if (indexRaw !== null) { - let parsed: unknown; - try { - parsed = JSON.parse(indexRaw); - } catch { - parsed = []; - } - const ids = Array.isArray(parsed) - ? (parsed.filter((v) => typeof v === "string") as string[]) - : []; - for (const convId of ids) { - const metaRaw = await storage.get(metaKey(convId)); - if (metaRaw === null) continue; - const row = parseMetaRow(metaRaw); - if (row === null) continue; - const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; - counts.set(wsId, (counts.get(wsId) ?? 0) + 1); - } - } - const entries: WorkspaceEntry[] = []; - for (const [id, ws] of byId) { - entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 }); - } - // Sort by lastActivityAt descending (most recent first). Stable sort - // keeps insertion order for ties. - return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt); - }, - - async getWorkspaceId(conversationId) { - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) return DEFAULT_WORKSPACE_ID; - const row = parseMetaRow(raw); - if (row === null) return DEFAULT_WORKSPACE_ID; - return row.workspaceId ?? DEFAULT_WORKSPACE_ID; - }, - - async setWorkspaceId(conversationId, workspaceId) { - const ts = now(); - const raw = await storage.get(metaKey(conversationId)); - if (raw === null) { - // Conversation doesn't exist yet — create a minimal metadata row - // (like setConversationStatus does), with the workspace assigned. - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const existing = parseMetaRow(raw); - if (existing === null) { - const row: ConversationMetaRow = { - createdAt: ts, - lastActivityAt: ts, - title: "Untitled", - status: "idle", - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - await ensureInIndex(conversationId); - return; - } - const row: ConversationMetaRow = { - createdAt: existing.createdAt, - lastActivityAt: existing.lastActivityAt, - title: existing.title, - status: existing.status, - ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), - workspaceId, - }; - await storage.set(metaKey(conversationId), JSON.stringify(row)); - }, - - async getEffectiveCwd(conversationId, overrideCwd) { - const workspaceId = await this.getWorkspaceId(conversationId); - const workspace = await this.getWorkspace(workspaceId); - const workspaceCwd = workspace?.defaultCwd ?? null; - // When an explicit override is given, resolve IT instead of the - // persisted cwd — it is always a string, never null. - const conversationCwd = - overrideCwd !== undefined ? overrideCwd : await this.getCwd(conversationId); - - if (conversationCwd === null) { - return workspaceCwd ?? serverDefaultCwd; - } - if (conversationCwd.startsWith("/")) { - return conversationCwd; - } - return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); - }, - - async getEffectiveComputer(conversationId, overrideAlias) { - const workspaceId = await this.getWorkspaceId(conversationId); - const workspace = await this.getWorkspace(workspaceId); - const workspaceComputerId = workspace?.defaultComputerId ?? null; - // When an explicit override is given, it wins outright — even `null` - // (explicitly local for this turn) does NOT fall through to the - // persisted / workspace values. - if (overrideAlias !== undefined) { - return overrideAlias; - } - // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). - const computerId = await this.getComputerId(conversationId); - return computerId ?? workspaceComputerId; - }, - }; + /** + * Add `conversationId` to the persisted index (idempotent). The store is + * not highly concurrent — the session-orchestrator serializes turns per + * conversation — so a simple read-modify-write suffices; `listConversations` + * deduplicates on read in case of a race on this update. + */ + async function ensureInIndex(conversationId: string): Promise<void> { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + let ids: string[]; + if (raw === null) { + ids = []; + } else { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = []; + } + ids = Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; + } + if (ids.includes(conversationId)) return; + ids.push(conversationId); + await storage.set(CONVERSATION_INDEX_KEY, JSON.stringify(ids)); + } + + /** + * Read a persisted {@link WorkspaceRow} by id, or `null` if absent/corrupt. + */ + async function readWorkspaceRow(id: string): Promise<WorkspaceRow | null> { + const raw = await storage.get(workspaceKey(id)); + if (raw === null) return null; + return parseWorkspaceRow(raw); + } + + /** + * Bump a workspace's `lastActivityAt` to `ts`. Creates the workspace row on + * miss (with `title = id`, `defaultCwd = null`, `createdAt/lastActivityAt + * = ts`) so that the first activity in any workspace — including the + * synthesized `"default"` — is recorded. Does NOT touch `title` or + * `defaultCwd` on an existing row. + */ + async function bumpWorkspaceLastActivityAt(workspaceId: string, ts: number): Promise<void> { + const existing = await readWorkspaceRow(workspaceId); + const row: WorkspaceRow = + existing === null + ? { + title: workspaceId, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: ts, + lastActivityAt: ts, + } + : { + title: existing.title, + defaultCwd: existing.defaultCwd, + defaultComputerId: existing.defaultComputerId, + starred: existing.starred, + createdAt: existing.createdAt, + lastActivityAt: ts, + }; + await storage.set(workspaceKey(workspaceId), JSON.stringify(row)); + } + + return { + async append(conversationId, messages) { + const raw = await storage.get(seqKey(conversationId)); + let seq = parseSeq(raw) + 1; + + for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) { + const msg = messages[msgIdx]; + if (msg === undefined) continue; + for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) { + const chunk = msg.chunks[chunkIdx]; + if (chunk === undefined) continue; + const entry: PersistedChunkEntry = { + chunk, + role: msg.role, + msgIdx, + chunkIdx, + }; + await storage.set(chunkKey(conversationId, seq), JSON.stringify(entry)); + seq++; + } + } + + await storage.set(seqKey(conversationId), String(seq - 1)); + + // Metadata upsert: track createdAt/lastActivityAt/title and keep the + // conversation discoverable in the index. + const ts = now(); + let conversationWorkspaceId = DEFAULT_WORKSPACE_ID; + const metaRaw = await storage.get(metaKey(conversationId)); + if (metaRaw === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + const existing = parseMetaRow(metaRaw); + if (existing === null) { + // Corrupt row — rewrite from scratch using this append. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: extractTitle(messages), + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + } else { + conversationWorkspaceId = existing.workspaceId ?? DEFAULT_WORKSPACE_ID; + const title = + existing.title === "Untitled" || existing.title === "" + ? extractTitle(messages) + : existing.title; + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: ts, + title, + status: existing.status, + ...(existing.compactedFrom !== undefined + ? { compactedFrom: existing.compactedFrom } + : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + } + } + // Bump the owning workspace's lastActivityAt to this append's time. + await bumpWorkspaceLastActivityAt(conversationWorkspaceId, ts); + }, + + async load(conversationId) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const messages: ChatMessage[] = []; + let currentChunks: Chunk[] = []; + let currentRole: Role | undefined; + let currentMsgIdx = -1; + + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + let entry: PersistedChunkEntry; + try { + entry = JSON.parse(value) as PersistedChunkEntry; + } catch (err) { + // "Never leave the system broken": a single corrupt/unparseable + // row must not brick the whole conversation. Skip it (append-only + // storage untouched) and let reconcile run on the rest. loadSince + // is intentionally NOT hardened here — it is the raw FE read path. + if (logger !== undefined) { + logger.warn("skipping corrupt chunk row", { + conversationId, + key, + error: err instanceof Error ? err.message : String(err), + }); + } + continue; + } + + // A message boundary is detected when EITHER the msgIdx changes OR the + // role changes. The msgIdx alone is insufficient because append() assigns + // it as a LOCAL index (reset to 0 for each call) — so consecutive + // single-message appends (e.g. the orchestrator's per-step persistence: + // append([user]) then append([assistant]) then append([user])...) all + // share msgIdx=0 and would collapse into one message without the role + // check. The role check restores correct boundaries for the common + // alternating user/assistant/tool pattern. + if (entry.msgIdx !== currentMsgIdx || entry.role !== currentRole) { + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + currentChunks = []; + currentRole = entry.role; + currentMsgIdx = entry.msgIdx; + } + + currentChunks.push(entry.chunk); + } + + if (currentMsgIdx >= 0 && currentRole !== undefined) { + messages.push({ role: currentRole, chunks: currentChunks }); + } + + const { messages: repaired, report } = reconcileWithReport(messages); + + const hasReconcileActivity = + report.repairedCount > 0 || + report.strippedErrorChunks > 0 || + report.droppedEmptyMessages > 0; + if (hasReconcileActivity && logger !== undefined) { + const child = logger.child({ conversationId }); + const span = child.span("reconcile.repair", { + repairedCount: report.repairedCount, + firstRepairedToolCallId: report.repairedToolCallIds[0] ?? null, + strippedErrorChunks: report.strippedErrorChunks, + droppedEmptyMessages: report.droppedEmptyMessages, + }); + span.end(); + } + + return repaired; + }, + + async loadSince(conversationId, sinceSeq, window) { + const prefix = chunkPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: StoredChunk[] = []; + const minSeq = sinceSeqBase(sinceSeq); + // Forgiving: a non-positive / non-integer bound is treated as ABSENT. + const beforeSeq = positiveInt(window?.beforeSeq); + const limit = positiveInt(window?.limit); + + for (const key of sorted) { + const seq = parseSeq(key.split(":").pop() ?? null); + if (seq <= minSeq) continue; + if (beforeSeq !== undefined && seq >= beforeSeq) continue; + const value = await storage.get(key); + if (value === null) continue; + const entry = JSON.parse(value) as PersistedChunkEntry; + result.push({ seq, role: entry.role, chunk: entry.chunk }); + } + + // Window: keep only the NEWEST `limit` chunks, still ascending by seq. + if (limit !== undefined && result.length > limit) { + return result.slice(result.length - limit); + } + + return result; + }, + + async appendMetrics(conversationId, metrics) { + const raw = await storage.get(metricsSeqKey(conversationId)); + const ordinal = parseSeq(raw) + 1; + await storage.set(metricsKey(conversationId, ordinal), JSON.stringify(metrics)); + await storage.set(metricsSeqKey(conversationId), String(ordinal)); + }, + + async loadMetrics(conversationId) { + const prefix = metricsPrefix(conversationId); + const keys = await storage.keys(prefix); + const sorted = [...keys].sort(); + + const result: TurnMetrics[] = []; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + result.push(JSON.parse(value) as TurnMetrics); + } + + return result; + }, + + async getCwd(conversationId) { + return await storage.get(cwdKey(conversationId)); + }, + + async setCwd(conversationId, cwd) { + await storage.set(cwdKey(conversationId), cwd); + if (logger !== undefined) { + logger.debug("cwd set", { conversationId }); + } + }, + + async clearCwd(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(cwdKey(conversationId)); + if (logger !== undefined) { + logger.debug("cwd cleared", { conversationId }); + } + }, + + async getComputerId(conversationId) { + return await storage.get(computerKey(conversationId)); + }, + + async setComputerId(conversationId, alias) { + // `null` is the "local" sentinel: clear the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. Idempotent + // (deleting an already-absent key is a no-op). Mirrors `setModel`'s + // clear-on-sentinel pattern. + if (alias === null) { + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + return; + } + await storage.set(computerKey(conversationId), alias); + if (logger !== undefined) { + logger.debug("computer set", { conversationId }); + } + }, + + async clearComputerId(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + }, + + async getReasoningEffort(conversationId) { + return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; + }, + + async setReasoningEffort(conversationId, effort) { + await storage.set(reasoningEffortKey(conversationId), effort); + if (logger !== undefined) { + logger.debug("reasoning-effort set", { conversationId }); + } + }, + + async getModel(conversationId) { + return await storage.get(modelKey(conversationId)); + }, + + async setModel(conversationId, model) { + if (model === "") { + // Idempotent clear: an empty model clears the persisted + // selection. Deleting an already-absent key is a no-op. + await storage.delete(modelKey(conversationId)); + if (logger !== undefined) { + logger.debug("model cleared", { conversationId }); + } + return; + } + await storage.set(modelKey(conversationId), model); + if (logger !== undefined) { + logger.debug("model set", { conversationId }); + } + }, + async listConversations(filter) { + const raw = await storage.get(CONVERSATION_INDEX_KEY); + if (raw === null) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + // Deduplicate (in case of a race on the index update) while preserving + // first-seen order. + const seen = new Set<string>(); + const ids: string[] = []; + for (const v of parsed) { + if (typeof v !== "string" || seen.has(v)) continue; + seen.add(v); + ids.push(v); + } + + const statusFilter = filter?.status; + const workspaceFilter = filter?.workspaceId; + const metas: ConversationMeta[] = []; + for (const id of ids) { + const metaRaw = await storage.get(metaKey(id)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + if (statusFilter !== undefined && !statusFilter.includes(row.status)) continue; + if (workspaceFilter !== undefined) { + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (wsId !== workspaceFilter) continue; + } + metas.push(toMeta(id, row)); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps first-seen (index) order for ties. + return metas.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + }, + + async getConversationMeta(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return toMeta(conversationId, row); + }, + + async setConversationTitle(conversationId, title) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Title set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + // Corrupt row — rewrite from scratch with this title. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title, + status: "idle", + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + // Preserve createdAt + lastActivityAt + status; update only the title. + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title, + status: existing.status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getConversationStatus(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return null; + const row = parseMetaRow(raw); + if (row === null) return null; + return row.status; + }, + + async setConversationStatus(conversationId, status) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Status set before any message was appended — create a minimal row. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async replaceHistory(conversationId, messages) { + // Delete all existing chunks. + const keys = await storage.keys(chunkPrefix(conversationId)); + for (const k of keys) { + await storage.delete(k); + } + // Reset the seq counter so the new messages start from seq 1. + await storage.set(seqKey(conversationId), "0"); + // Append the new messages (re-uses the append logic for seq + // numbering + metadata upsert). + await this.append(conversationId, messages); + }, + + async forkHistory(sourceId, targetId) { + // Copy all chunks from source to target, re-numbered from seq 1. + const keys = await storage.keys(chunkPrefix(sourceId)); + const sorted = [...keys].sort(); + let seq = 1; + for (const key of sorted) { + const value = await storage.get(key); + if (value === null) continue; + await storage.set(chunkKey(targetId, seq), value); + seq++; + } + await storage.set(seqKey(targetId), String(Math.max(seq - 1, 0))); + + // Copy metadata with archive title + closed status. + // Inherit compactedFrom from the source so archives chain: + // A → Y → X (each archive points to the previous one). + const metaRaw = await storage.get(metaKey(sourceId)); + if (metaRaw !== null) { + const existing = parseMetaRow(metaRaw); + if (existing !== null) { + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: `Archive: ${existing.title}`, + status: "closed", + ...(existing.compactedFrom !== undefined + ? { compactedFrom: existing.compactedFrom } + : {}), + ...(existing.workspaceId !== undefined ? { workspaceId: existing.workspaceId } : {}), + }; + await storage.set(metaKey(targetId), JSON.stringify(row)); + } + } + await ensureInIndex(targetId); + + // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). + const cwd = await storage.get(cwdKey(sourceId)); + if (cwd !== null) await storage.set(cwdKey(targetId), cwd); + const effort = await storage.get(reasoningEffortKey(sourceId)); + if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); + const model = await storage.get(modelKey(sourceId)); + if (model !== null) await storage.set(modelKey(targetId), model); + const computerId = await storage.get(computerKey(sourceId)); + if (computerId !== null) await storage.set(computerKey(targetId), computerId); + }, + + async getCompactPercent(conversationId) { + const raw = await storage.get(compactThresholdKey(conversationId)); + if (raw === null) return null; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? null : n; + }, + + async setCompactPercent(conversationId, percent) { + await storage.set(compactThresholdKey(conversationId), String(percent)); + if (logger !== undefined) { + logger.debug("compact-percent set", { conversationId, percent }); + } + }, + + async getImageTranscriptions(conversationId) { + const raw = await storage.get(imageTranscriptionsKey(conversationId)); + if (raw === null) return new Map(); + try { + const obj = JSON.parse(raw) as Record<string, string>; + return new Map(Object.entries(obj)); + } catch { + return new Map(); + } + }, + + async setImageTranscription(conversationId, imageUrl, transcription) { + const existing = await this.getImageTranscriptions(conversationId); + const merged = new Map(existing); + merged.set(imageUrl, transcription); + const obj: Record<string, string> = {}; + for (const [k, v] of merged) obj[k] = v; + await storage.set(imageTranscriptionsKey(conversationId), JSON.stringify(obj)); + }, + + async getVisionSettings() { + const raw = await storage.get(VISION_SETTINGS_KEY); + if (raw === null) return { imageLimit: 10, compactionModel: null }; + try { + const obj = JSON.parse(raw) as { imageLimit?: number; compactionModel?: string | null }; + return { + imageLimit: typeof obj.imageLimit === "number" ? obj.imageLimit : 10, + compactionModel: obj.compactionModel ?? null, + }; + } catch { + return { imageLimit: 10, compactionModel: null }; + } + }, + + async setVisionImageLimit(limit) { + const current = await this.getVisionSettings(); + const obj = { imageLimit: limit, compactionModel: current.compactionModel }; + await storage.set(VISION_SETTINGS_KEY, JSON.stringify(obj)); + }, + + async setVisionCompactionModel(model) { + const current = await this.getVisionSettings(); + const obj = { imageLimit: current.imageLimit, compactionModel: model }; + await storage.set(VISION_SETTINGS_KEY, JSON.stringify(obj)); + }, + + async setCompactedFrom(conversationId, newConversationId) { + const raw = await storage.get(metaKey(conversationId)); + const existing = raw !== null ? parseMetaRow(raw) : null; + const ts = now(); + const row: ConversationMetaRow = existing ?? { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + }; + await storage.set( + metaKey(conversationId), + JSON.stringify({ ...row, compactedFrom: newConversationId }), + ); + }, + + async getWorkspace(id) { + const row = await readWorkspaceRow(id); + if (row !== null) return toWorkspace(id, row); + // Synthesize the always-present "default" workspace when it was + // never persisted (title "default", defaultCwd null, defaultComputerId + // null [local], starred false, timestamps 0). + if (id === DEFAULT_WORKSPACE_ID) { + return { + id: DEFAULT_WORKSPACE_ID, + title: DEFAULT_WORKSPACE_ID, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + }; + } + return null; + }, + + async ensureWorkspace(id, opts) { + const existing = await readWorkspaceRow(id); + if (existing !== null) return toWorkspace(id, existing); + // Absent — create with defaults. The synthesized "default" is also + // materialized here when first explicitly ensured. + const ts = now(); + const row: WorkspaceRow = { + title: opts?.title ?? id, + defaultCwd: opts?.defaultCwd ?? null, + defaultComputerId: opts?.defaultComputerId ?? null, + starred: false, + createdAt: ts, + lastActivityAt: ts, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceTitle(id, title) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + starred: false as boolean, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title, + defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, + starred: base.starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultCwd(id, defaultCwd) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + starred: false as boolean, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd, + defaultComputerId: base.defaultComputerId, + starred: base.starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + starred: false as boolean, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId, + starred: base.starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceStarred(id, starred) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, + starred, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + if (logger !== undefined) { + logger.debug("workspace starred set", { workspaceId: id, starred }); + } + return toWorkspace(id, row); + }, + + async deleteWorkspace(id) { + if (id === DEFAULT_WORKSPACE_ID) { + throw new Error('The "default" workspace cannot be deleted.'); + } + // (1) Find all conversations with workspaceId === id, (2) set each + // to status "closed" and reassign workspaceId to "default". + let closedCount = 0; + const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); + if (indexRaw !== null) { + let parsed: unknown; + try { + parsed = JSON.parse(indexRaw); + } catch { + parsed = []; + } + const ids = Array.isArray(parsed) + ? (parsed.filter((v) => typeof v === "string") as string[]) + : []; + for (const convId of ids) { + const metaRaw = await storage.get(metaKey(convId)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + if (wsId !== id) continue; + const updated: ConversationMetaRow = { + createdAt: row.createdAt, + lastActivityAt: row.lastActivityAt, + title: row.title, + status: "closed", + ...(row.compactedFrom !== undefined ? { compactedFrom: row.compactedFrom } : {}), + workspaceId: DEFAULT_WORKSPACE_ID, + }; + await storage.set(metaKey(convId), JSON.stringify(updated)); + closedCount++; + } + } + // (3) Delete the workspace entity. + await storage.delete(workspaceKey(id)); + return { closedCount }; + }, + + async listWorkspaces() { + // Collect persisted workspace rows via the `workspace:` key prefix. + const wsPrefix = "workspace:"; + const wsKeys = await storage.keys(wsPrefix); + const byId = new Map<string, Workspace>(); + for (const key of wsKeys) { + // Key shape: `workspace:<id>`. Strip the prefix to recover the id. + const id = key.slice(wsPrefix.length); + if (id.length === 0) continue; + const raw = await storage.get(key); + if (raw === null) continue; + const row = parseWorkspaceRow(raw); + if (row === null) continue; + byId.set(id, toWorkspace(id, row)); + } + // Always include "default" (synthesized if not persisted). + if (!byId.has(DEFAULT_WORKSPACE_ID)) { + byId.set(DEFAULT_WORKSPACE_ID, { + id: DEFAULT_WORKSPACE_ID, + title: DEFAULT_WORKSPACE_ID, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + }); + } + // Count conversations per workspace by scanning the index + meta. + const counts = new Map<string, number>(); + for (const id of byId.keys()) counts.set(id, 0); + const indexRaw = await storage.get(CONVERSATION_INDEX_KEY); + if (indexRaw !== null) { + let parsed: unknown; + try { + parsed = JSON.parse(indexRaw); + } catch { + parsed = []; + } + const ids = Array.isArray(parsed) + ? (parsed.filter((v) => typeof v === "string") as string[]) + : []; + for (const convId of ids) { + const metaRaw = await storage.get(metaKey(convId)); + if (metaRaw === null) continue; + const row = parseMetaRow(metaRaw); + if (row === null) continue; + const wsId = row.workspaceId ?? DEFAULT_WORKSPACE_ID; + counts.set(wsId, (counts.get(wsId) ?? 0) + 1); + } + } + const entries: WorkspaceEntry[] = []; + for (const [id, ws] of byId) { + entries.push({ ...ws, conversationCount: counts.get(id) ?? 0 }); + } + // Sort by lastActivityAt descending (most recent first). Stable sort + // keeps insertion order for ties. + return entries.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + }, + + async getWorkspaceId(conversationId) { + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) return DEFAULT_WORKSPACE_ID; + const row = parseMetaRow(raw); + if (row === null) return DEFAULT_WORKSPACE_ID; + return row.workspaceId ?? DEFAULT_WORKSPACE_ID; + }, + + async setWorkspaceId(conversationId, workspaceId) { + const ts = now(); + const raw = await storage.get(metaKey(conversationId)); + if (raw === null) { + // Conversation doesn't exist yet — create a minimal metadata row + // (like setConversationStatus does), with the workspace assigned. + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const existing = parseMetaRow(raw); + if (existing === null) { + const row: ConversationMetaRow = { + createdAt: ts, + lastActivityAt: ts, + title: "Untitled", + status: "idle", + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + await ensureInIndex(conversationId); + return; + } + const row: ConversationMetaRow = { + createdAt: existing.createdAt, + lastActivityAt: existing.lastActivityAt, + title: existing.title, + status: existing.status, + ...(existing.compactedFrom !== undefined ? { compactedFrom: existing.compactedFrom } : {}), + workspaceId, + }; + await storage.set(metaKey(conversationId), JSON.stringify(row)); + }, + + async getEffectiveCwd(conversationId, overrideCwd) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceCwd = workspace?.defaultCwd ?? null; + // When an explicit override is given, resolve IT instead of the + // persisted cwd — it is always a string, never null. + const conversationCwd = + overrideCwd !== undefined ? overrideCwd : await this.getCwd(conversationId); + + if (conversationCwd === null) { + return workspaceCwd ?? serverDefaultCwd; + } + if (conversationCwd.startsWith("/")) { + return conversationCwd; + } + return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); + }, + + async getEffectiveComputer(conversationId, overrideAlias) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceComputerId = workspace?.defaultComputerId ?? null; + // When an explicit override is given, it wins outright — even `null` + // (explicitly local for this turn) does NOT fall through to the + // persisted / workspace values. + if (overrideAlias !== undefined) { + return overrideAlias; + } + // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). + const computerId = await this.getComputerId(conversationId); + return computerId ?? workspaceComputerId; + }, + }; } diff --git a/packages/conversation-store/tsconfig.json b/packages/conversation-store/tsconfig.json index ff99a43..44ed916 100644 --- a/packages/conversation-store/tsconfig.json +++ b/packages/conversation-store/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] } |
