diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 04:26:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 04:26:40 +0900 |
| commit | f2e452bbebc7d99d1ae9ba74b32334b85af7902d (patch) | |
| tree | cc5052d574c05123ce930a09379a7d0a24d9a660 /packages/conversation-store/src | |
| parent | 13eb34133d8fe64f9c73f8d394e0af790b54c6e5 (diff) | |
| download | dispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.tar.gz dispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.zip | |
feat: persistent per-conversation model selection
A chat's selected provider + model is now persisted per conversation (like cwd
and reasoningEffort). Opening a conversation in a new browser recalls the
originally selected model instead of defaulting.
- transport-contract 0.19.0→0.20.0: ModelResponse + SetModelRequest types
for GET/PUT /conversations/:id/model.
- conversation-store: getModel/setModel (model:<id> key, mirrors
getReasoningEffort/setReasoningEffort); forkHistory copies model; empty
string clears.
- session-orchestrator: resolve model from persisted store when no per-turn
override; persist the resolved model so it sticks; warm path parity.
- transport-http: GET/PUT /conversations/:id/model endpoints with validation.
1433 vitest pass; tsc + biome clean.
Diffstat (limited to 'packages/conversation-store/src')
| -rw-r--r-- | packages/conversation-store/src/extension.ts | 13 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 4 | ||||
| -rw-r--r-- | packages/conversation-store/src/store-workspace.test.ts | 131 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.test.ts | 185 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 107 |
5 files changed, 413 insertions, 27 deletions
diff --git a/packages/conversation-store/src/extension.ts b/packages/conversation-store/src/extension.ts index b5a1380..cd03077 100644 --- a/packages/conversation-store/src/extension.ts +++ b/packages/conversation-store/src/extension.ts @@ -14,9 +14,18 @@ export const manifest: Manifest = { export const extension: Extension = { manifest, - activate: (host: HostAPI) => { + activate: async (host: HostAPI) => { const storage = host.storage("conversation-store"); - const store = createConversationStore(storage, host.logger); + 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 }); + } + host.provideService(conversationStoreHandle, store); }, }; diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 98bd5d4..1fd1237 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -54,6 +54,10 @@ export function reasoningEffortKey(conversationId: string): string { return `conv:${conversationId}:reasoning-effort`; } +export function modelKey(conversationId: string): string { + return `conv:${conversationId}:model`; +} + export function compactThresholdKey(conversationId: string): string { return `conv:${conversationId}:compact-percent`; } diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts index 077cd9c..48c63e5 100644 --- a/packages/conversation-store/src/store-workspace.test.ts +++ b/packages/conversation-store/src/store-workspace.test.ts @@ -30,8 +30,8 @@ describe("WorkspaceStore", () => { clock = 1000; }); - function makeStore() { - return createConversationStore(storage, undefined, () => clock); + function makeStore(serverDefaultCwd?: string) { + return createConversationStore(storage, undefined, () => clock, serverDefaultCwd); } function userMessage(text: string): ChatMessage { @@ -231,26 +231,139 @@ describe("WorkspaceStore", () => { expect(meta?.status).toBe("idle"); }); - it("getEffectiveCwd explicit conversation", async () => { - const store = makeStore(); + 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 inherits workspace default", async () => { - const store = makeStore(); + 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 returns null when nothing set", async () => { - const store = makeStore(); + 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")).toBeNull(); + 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 () => { diff --git a/packages/conversation-store/src/store.test.ts b/packages/conversation-store/src/store.test.ts index c91bc40..5e8d26f 100644 --- a/packages/conversation-store/src/store.test.ts +++ b/packages/conversation-store/src/store.test.ts @@ -907,6 +907,69 @@ describe("ConversationStore cwd", () => { 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", () => { @@ -984,6 +1047,128 @@ describe("ConversationStore reasoning effort", () => { }); }); +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" }] }, + ]); + }); +}); + describe("ConversationStore conversation metadata + list + title", () => { let storage: StorageNamespace; diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 263275b..b70f706 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -1,3 +1,4 @@ +import { resolve as pathResolve } from "node:path"; import type { ChatMessage, Chunk, @@ -22,6 +23,7 @@ import { metricsKey, metricsPrefix, metricsSeqKey, + modelKey, parseSeq, reasoningEffortKey, seqKey, @@ -66,10 +68,21 @@ export interface ConversationStore { 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 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 @@ -102,10 +115,10 @@ export interface ConversationStore { ) => Promise<void>; /** * Fork (copy) the full conversation history from `sourceId` to `targetId`. - * Copies all chunks, metadata, cwd, and reasoning-effort. 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. + * 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. */ @@ -163,11 +176,35 @@ export interface ConversationStore { */ readonly setWorkspaceId: (conversationId: string, workspaceId: string) => Promise<void>; /** - * Resolve the effective cwd: explicit conversation cwd (`getCwd`) → - * workspace `defaultCwd` (`getWorkspaceId` + `getWorkspace`) → `null` (null - * = use server default). Returns `null` when neither is set. + * 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) => Promise<string | null>; + readonly getEffectiveCwd: ( + conversationId: string, + overrideCwd?: string, + ) => Promise<string | null>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -358,6 +395,7 @@ export function createConversationStore( storage: StorageNamespace, logger?: Logger, now: () => number = Date.now, + serverDefaultCwd: string = process.cwd(), ): ConversationStore { /** * Add `conversationId` to the persisted index (idempotent). The store is @@ -594,6 +632,14 @@ export function createConversationStore( } }, + 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 getReasoningEffort(conversationId) { return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; }, @@ -604,6 +650,26 @@ export function createConversationStore( 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 []; @@ -786,11 +852,13 @@ export function createConversationStore( } await ensureInIndex(targetId); - // Copy cwd + reasoning-effort (so the archive is self-contained). + // Copy cwd + reasoning-effort + model (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); }, async getCompactPercent(conversationId) { @@ -1048,15 +1116,22 @@ export function createConversationStore( await storage.set(metaKey(conversationId), JSON.stringify(row)); }, - async getEffectiveCwd(conversationId) { - // Explicit per-conversation cwd wins. - const explicit = await storage.get(cwdKey(conversationId)); - if (explicit !== null) return explicit; - // Otherwise fall through to the workspace's defaultCwd. + async getEffectiveCwd(conversationId, overrideCwd) { const workspaceId = await this.getWorkspaceId(conversationId); const workspace = await this.getWorkspace(workspaceId); - if (workspace === null) return null; - return workspace.defaultCwd; + 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); }, }; } |
