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