summaryrefslogtreecommitdiffhomepage
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
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.
-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
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts22
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts64
-rw-r--r--packages/session-orchestrator/src/pure.ts17
-rw-r--r--packages/session-orchestrator/src/queue.test.ts13
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts23
-rw-r--r--packages/transport-http/src/app.test.ts227
-rw-r--r--packages/transport-http/src/app.ts53
-rw-r--r--packages/transport-http/src/logic.ts27
14 files changed, 845 insertions, 43 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);
},
};
}
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 940538c..e2d3b6b 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -34,12 +34,14 @@ function createInMemoryStore(): ConversationStore & {
readonly metricsData: Map<string, TurnMetrics[]>;
readonly cwdData: Map<string, string>;
readonly effortData: Map<string, ReasoningEffort>;
+ readonly modelData: Map<string, string>;
readonly workspaceIdData: Map<string, string>;
} {
const data = new Map<string, ChatMessage[]>();
const metricsData = new Map<string, TurnMetrics[]>();
const cwdData = new Map<string, string>();
const effortData = new Map<string, ReasoningEffort>();
+ const modelData = new Map<string, string>();
const workspaceIdData = new Map<string, string>();
// Track conversations that have a meta row. In the real store, append,
// setWorkspaceId, setConversationStatus, setConversationTitle, and
@@ -52,6 +54,7 @@ function createInMemoryStore(): ConversationStore & {
metricsData,
cwdData,
effortData,
+ modelData,
workspaceIdData,
async append(conversationId, messages) {
knownConversations.add(conversationId);
@@ -94,6 +97,17 @@ function createInMemoryStore(): ConversationStore & {
async setReasoningEffort(conversationId, effort) {
effortData.set(conversationId, effort);
},
+ async getModel(conversationId) {
+ return modelData.get(conversationId) ?? null;
+ },
+ async setModel(conversationId, model) {
+ // Mirror the real store contract: an empty string clears the key.
+ if (model === "") {
+ modelData.delete(conversationId);
+ } else {
+ modelData.set(conversationId, model);
+ }
+ },
async listConversations() {
return [];
},
@@ -633,6 +647,10 @@ describe("turn-sealed event", () => {
return null;
},
async setReasoningEffort() {},
+ async getModel() {
+ return null;
+ },
+ async setModel() {},
async listConversations() {
return [];
},
@@ -1020,6 +1038,10 @@ describe("turn metrics persistence", () => {
return null;
},
async setReasoningEffort() {},
+ async getModel() {
+ return null;
+ },
+ async setModel() {},
async listConversations() {
return [];
},
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 6a49ee1..a533a16 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -25,6 +25,7 @@ import {
buildUserMessage,
defaultDispatchPolicy,
generateTurnId,
+ resolveModelName,
resolveReasoningEffort,
} from "./pure.js";
import type { ToolAssembly } from "./tools-filter.js";
@@ -411,14 +412,24 @@ export function createSessionOrchestrator(
);
const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
-
- const payloadPromise = Promise.all([effectiveCwdPromise, storedEffortPromise]).then(
- ([effectiveCwd]) => ({
+ // Resolve the persisted model (if any) in parallel with the other
+ // per-conversation reads. The effective model name is
+ // per-turn override → persisted → (undefined → default provider), the
+ // same resolution chain as `resolveReasoningEffort`.
+ const storedModelPromise = deps.conversationStore.getModel(conversationId);
+
+ const payloadPromise = Promise.all([
+ effectiveCwdPromise,
+ storedEffortPromise,
+ storedModelPromise,
+ ]).then(([effectiveCwd, _storedEffort, storedModel]) => {
+ const effectiveModelName = resolveModelName(modelName, storedModel);
+ return {
conversationId,
...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(modelName !== undefined ? { modelName } : {}),
- }),
- );
+ ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}),
+ };
+ });
payloadPromise.then((payload) => {
deps.emit?.(turnStarted, payload);
@@ -437,10 +448,11 @@ export function createSessionOrchestrator(
void (async () => {
let sealed = false;
try {
- const [effectiveCwd, storedEffort, isNewConversation] = await Promise.all([
+ const [effectiveCwd, storedEffort, isNewConversation, storedModel] = await Promise.all([
effectiveCwdPromise,
storedEffortPromise,
workspaceSetupPromise,
+ storedModelPromise,
]);
if (cwd !== undefined) {
@@ -448,6 +460,11 @@ export function createSessionOrchestrator(
}
const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort);
+ // Effective model name: per-turn override → persisted → undefined
+ // (→ default provider). Resolved here so every downstream consumer
+ // (resolveModel, system prompt, payload) sees the same model as if
+ // the caller had passed it explicitly.
+ const effectiveModelName = resolveModelName(modelName, storedModel);
const history = await deps.conversationStore.load(conversationId);
const userMsg = buildUserMessage(text);
@@ -461,19 +478,27 @@ export function createSessionOrchestrator(
let provider: ProviderContract;
let modelOverride: string | undefined;
- if (modelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(modelName);
+ if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(effectiveModelName);
if (resolved === undefined) {
emitToHub(conversationId, {
type: "error",
conversationId,
turnId,
- message: `unknown model: ${modelName}`,
+ message: `unknown model: ${effectiveModelName}`,
});
return;
}
provider = resolved.provider;
modelOverride = resolved.model;
+ // Persist the resolved model so it sticks for future turns
+ // and browser sessions (per-conversation model persistence).
+ // Only stamped when a model was actually used — NOT on the
+ // default-provider fallthrough (nothing to persist). Idempotent
+ // when the value is unchanged (re-stamps the same persisted
+ // model). The early `return` above means an unknown model is
+ // never persisted.
+ await deps.conversationStore.setModel(conversationId, effectiveModelName);
} else {
provider = deps.resolveProvider();
}
@@ -513,7 +538,7 @@ export function createSessionOrchestrator(
conversationId,
effectiveCwd ?? process.cwd(),
{
- ...(modelName !== undefined ? { model: modelName } : {}),
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
},
);
@@ -524,7 +549,7 @@ export function createSessionOrchestrator(
systemPrompt = meta.prompt;
} else {
systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
- ...(modelName !== undefined ? { model: modelName } : {}),
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
});
}
@@ -816,10 +841,19 @@ export function createWarmService(
let provider: ProviderContract;
let modelOverride: string | undefined;
- if (opts?.modelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(opts.modelName);
+ // Resolve the model the SAME way the real turn does: per-turn override
+ // → persisted per-conversation model → default provider. A mismatch here
+ // silently busts the prompt cache (the model block of the prompt prefix
+ // diverges from the real turn's). Warm is a probe — it does NOT persist
+ // (no setModel), it only reads so it sends the same model the next real
+ // turn will. See notes/observability-design.md §3.1.
+ const storedModel = await deps.conversationStore.getModel(conversationId);
+ const effectiveModelName = resolveModelName(opts?.modelName, storedModel);
+
+ if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(effectiveModelName);
if (resolved === undefined) {
- return { error: `unknown model: ${opts.modelName}` };
+ return { error: `unknown model: ${effectiveModelName}` };
}
provider = resolved.provider;
modelOverride = resolved.model;
diff --git a/packages/session-orchestrator/src/pure.ts b/packages/session-orchestrator/src/pure.ts
index 85edd14..9a31e17 100644
--- a/packages/session-orchestrator/src/pure.ts
+++ b/packages/session-orchestrator/src/pure.ts
@@ -21,6 +21,23 @@ export function resolveReasoningEffort(
return override ?? stored ?? "high";
}
+/**
+ * Resolve the model name for a turn:
+ * per-turn override → persisted per-conversation value → `undefined`.
+ *
+ * Unlike {@link resolveReasoningEffort}, there is NO default model name: when
+ * both the override and the persisted value are absent, this returns
+ * `undefined` and the caller falls through to `resolveProvider()` (the default
+ * provider). Returning `undefined` (rather than a sentinel) keeps the existing
+ * "no model override" code path untouched. Pure — no I/O, no ambient state.
+ */
+export function resolveModelName(
+ override: string | undefined,
+ stored: string | null,
+): string | undefined {
+ return override ?? stored ?? undefined;
+}
+
export function selectFirstProvider(
providers: ReadonlyMap<string, ProviderContract>,
): ProviderContract {
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index 71b1fb4..225d1af 100644
--- a/packages/session-orchestrator/src/queue.test.ts
+++ b/packages/session-orchestrator/src/queue.test.ts
@@ -25,16 +25,19 @@ function createInMemoryStore(): ConversationStore & {
readonly metricsData: Map<string, TurnMetrics[]>;
readonly cwdData: Map<string, string>;
readonly effortData: Map<string, ReasoningEffort>;
+ readonly modelData: Map<string, string>;
} {
const data = new Map<string, ChatMessage[]>();
const metricsData = new Map<string, TurnMetrics[]>();
const cwdData = new Map<string, string>();
const effortData = new Map<string, ReasoningEffort>();
+ const modelData = new Map<string, string>();
return {
data,
metricsData,
cwdData,
effortData,
+ modelData,
async append(conversationId, messages) {
const existing = data.get(conversationId) ?? [];
data.set(conversationId, [...existing, ...messages]);
@@ -75,6 +78,16 @@ function createInMemoryStore(): ConversationStore & {
async setReasoningEffort(conversationId, effort) {
effortData.set(conversationId, effort);
},
+ async getModel(conversationId) {
+ return modelData.get(conversationId) ?? null;
+ },
+ async setModel(conversationId, model) {
+ if (model === "") {
+ modelData.delete(conversationId);
+ } else {
+ modelData.set(conversationId, model);
+ }
+ },
async listConversations() {
return [];
},
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 71baa43..5c2a94b 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.19.0",
+ "version": "0.20.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index fcbd1b1..feeac0c 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -277,6 +277,29 @@ export interface SetReasoningEffortRequest {
readonly reasoningEffort: ReasoningEffort;
}
+// ─── Per-conversation model ──────────────────────────────────────────────────
+
+/**
+ * Response of `GET /conversations/:id/model`. `model` is the persisted model
+ * name in `<credentialName>/<model>` form, or null when never set (the server
+ * then resolves turns using the default provider + model).
+ */
+export interface ModelResponse {
+ readonly conversationId: string;
+ readonly model: string | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/model` — persists the conversation's sticky
+ * model selection (used for every later turn that does not carry a per-turn
+ * `ChatRequest.model` override). Pass `null` to clear the persisted selection.
+ * An unrecognized model name is not validated here (the provider resolves it
+ * at turn time; an unknown model → turn error, not a 400).
+ */
+export interface SetModelRequest {
+ readonly model: string | null;
+}
+
// ─── Conversation close (explicit tab close) ──────────────────────────────────
/**
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 7887695..153a63a 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -98,6 +98,7 @@ function createFakeConversationStore(
metricsStore: Map<string, TurnMetrics[]> = new Map(),
cwdStore: Map<string, string> = new Map(),
reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
+ modelStore: Map<string, string> = new Map(),
): ConversationStore {
return {
async append() {},
@@ -137,6 +138,16 @@ function createFakeConversationStore(
async setReasoningEffort(conversationId, effort) {
reasoningEffortStore.set(conversationId, effort);
},
+ async getModel(conversationId) {
+ return modelStore.get(conversationId) ?? null;
+ },
+ async setModel(conversationId, model) {
+ if (model === "") {
+ modelStore.delete(conversationId);
+ } else {
+ modelStore.set(conversationId, model);
+ }
+ },
async listConversations() {
return [];
},
@@ -2615,6 +2626,222 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
});
});
+describe("GET /conversations/:id/model", () => {
+ it("returns null when never set", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBeNull();
+ });
+
+ it("returns the model after PUT", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("returns null for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/unknown/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.model).toBeNull();
+ });
+});
+
+describe("PUT /conversations/:id/model", () => {
+ it("persists a non-empty model and returns it", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBe("umans/umans-glm-5.2");
+
+ // A subsequent GET reflects the persisted value.
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("clears the model when model is null and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // Preconditions: a model is set.
+ const before = await app.request("/conversations/conv1/model");
+ expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("clears the model when model is an empty string and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when model field is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("model");
+ });
+
+ it("returns 400 when model is a non-string non-null type", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: 42 }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("does not call store on validation failure", async () => {
+ let storeCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setModel() {
+ storeCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(storeCalled).toBe(false);
+ });
+});
+
describe("GET /conversations", () => {
const sampleConvos: ConversationMeta[] = [
{
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index bc8b9de..7fdbb00 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -13,6 +13,7 @@ import type {
LastMessageResponse,
LspServerInfo,
LspStatusResponse,
+ ModelResponse,
ModelsResponse,
OpenConversationResponse,
QueueResponse,
@@ -33,11 +34,13 @@ import {
computeCachePct,
computeExpectedCacheRate,
extractLastAssistantText,
+ isModelParseError,
isParseError,
isReasoningEffortParseError,
isSinceSeqError,
isWindowParamError,
parseChatBody,
+ parseModelBody,
parseQueueBody,
parseReasoningEffortBody,
parseSinceSeq,
@@ -613,6 +616,56 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.get("/conversations/:id/model", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const model = await opts.conversationStore.getModel(conversationId);
+ log.info("conversations: model read", {
+ conversationId,
+ hasModel: model !== null,
+ });
+ const body: ModelResponse = { conversationId, model };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: model read failure", { err });
+ return c.json({ error: "Failed to read conversation model" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/model", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/model: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = parseModelBody(body);
+ if (isModelParseError(parsed)) {
+ log.warn("conversations/model: validation failed", { reason: parsed.error });
+ return c.json({ error: parsed.error }, 400);
+ }
+
+ // A non-null non-empty model persists the selection; `null` or an empty
+ // string clears the key (the store treats an empty string as "delete").
+ // The response carries the resulting value: the model name, or null when
+ // cleared (mirroring how `getModel` returns null after a clear).
+ const resultModel = parsed !== null && parsed.length > 0 ? parsed : null;
+ const persistedValue = resultModel !== null ? resultModel : "";
+
+ try {
+ await opts.conversationStore.setModel(conversationId, persistedValue);
+ log.debug("conversations: model set", { conversationId, model: resultModel });
+ const response: ModelResponse = { conversationId, model: resultModel };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: model set failure", { err });
+ return c.json({ error: "Failed to set conversation model" }, 500);
+ }
+ });
+
app.get("/conversations/:id/lsp", async (c) => {
const conversationId = c.req.param("id");
try {
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index 843aeb8..948afb8 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -270,6 +270,33 @@ export function isReasoningEffortParseError(
}
/**
+ * Parse + validate a `PUT /conversations/:id/model` body (`SetModelRequest`).
+ * `model` must be present and either a string (any value, including the empty
+ * string — which clears the persisted selection) or `null`. A missing field or
+ * a non-string/non-null value → {@link ParseError}. There is no enum
+ * validation (the provider resolves model names at turn time).
+ *
+ * Returns the validated `model` value (`string | null`) on success.
+ */
+export function parseModelBody(body: unknown): string | null | ParseError {
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
+ const obj = body as Record<string, unknown>;
+ if (obj.model === undefined) {
+ return { error: "Field 'model' is required and must be a string or null" };
+ }
+ if (obj.model !== null && typeof obj.model !== "string") {
+ return { error: "Field 'model' must be a string or null" };
+ }
+ return obj.model as string | null;
+}
+
+export function isModelParseError(result: string | null | ParseError): result is ParseError {
+ return typeof result === "object" && result !== null && "error" in result;
+}
+
+/**
* Extract the text of the last assistant message's last `text` chunk — the
* "show me the last reply" affordance for `GET /conversations/:id/last`.
*