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 | |
| 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.
| -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 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 22 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 64 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/pure.ts | 17 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 13 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 2 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 23 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 227 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 53 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 27 |
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`. * |
