summaryrefslogtreecommitdiffhomepage
path: root/packages/conversation-store/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 04:26:40 +0900
committerAdam Malczewski <[email protected]>2026-06-24 04:26:40 +0900
commitf2e452bbebc7d99d1ae9ba74b32334b85af7902d (patch)
treecc5052d574c05123ce930a09379a7d0a24d9a660 /packages/conversation-store/src
parent13eb34133d8fe64f9c73f8d394e0af790b54c6e5 (diff)
downloaddispatch-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.ts13
-rw-r--r--packages/conversation-store/src/keys.ts4
-rw-r--r--packages/conversation-store/src/store-workspace.test.ts131
-rw-r--r--packages/conversation-store/src/store.test.ts185
-rw-r--r--packages/conversation-store/src/store.ts107
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);
},
};
}