diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 04:26:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 04:26:40 +0900 |
| commit | f2e452bbebc7d99d1ae9ba74b32334b85af7902d (patch) | |
| tree | cc5052d574c05123ce930a09379a7d0a24d9a660 /packages/transport-http | |
| parent | 13eb34133d8fe64f9c73f8d394e0af790b54c6e5 (diff) | |
| download | dispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.tar.gz dispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.zip | |
feat: persistent per-conversation model selection
A chat's selected provider + model is now persisted per conversation (like cwd
and reasoningEffort). Opening a conversation in a new browser recalls the
originally selected model instead of defaulting.
- transport-contract 0.19.0→0.20.0: ModelResponse + SetModelRequest types
for GET/PUT /conversations/:id/model.
- conversation-store: getModel/setModel (model:<id> key, mirrors
getReasoningEffort/setReasoningEffort); forkHistory copies model; empty
string clears.
- session-orchestrator: resolve model from persisted store when no per-turn
override; persist the resolved model so it sticks; warm path parity.
- transport-http: GET/PUT /conversations/:id/model endpoints with validation.
1433 vitest pass; tsc + biome clean.
Diffstat (limited to 'packages/transport-http')
| -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 |
3 files changed, 307 insertions, 0 deletions
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`. * |
