summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/conversation-store/src')
-rw-r--r--packages/conversation-store/src/extension.ts42
-rw-r--r--packages/conversation-store/src/index.ts8
-rw-r--r--packages/conversation-store/src/keys.ts60
-rw-r--r--packages/conversation-store/src/reconcile.test.ts812
-rw-r--r--packages/conversation-store/src/reconcile.ts176
-rw-r--r--packages/conversation-store/src/store-workspace.test.ts1451
-rw-r--r--packages/conversation-store/src/store.test.ts3192
-rw-r--r--packages/conversation-store/src/store.ts2569
8 files changed, 4332 insertions, 3978 deletions
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;
+ },
+ };
}