summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 04:26:40 +0900
committerAdam Malczewski <[email protected]>2026-06-24 04:26:40 +0900
commitf2e452bbebc7d99d1ae9ba74b32334b85af7902d (patch)
treecc5052d574c05123ce930a09379a7d0a24d9a660 /packages/session-orchestrator/src
parent13eb34133d8fe64f9c73f8d394e0af790b54c6e5 (diff)
downloaddispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.tar.gz
dispatch-f2e452bbebc7d99d1ae9ba74b32334b85af7902d.zip
feat: persistent per-conversation model selection
A chat's selected provider + model is now persisted per conversation (like cwd and reasoningEffort). Opening a conversation in a new browser recalls the originally selected model instead of defaulting. - transport-contract 0.19.0→0.20.0: ModelResponse + SetModelRequest types for GET/PUT /conversations/:id/model. - conversation-store: getModel/setModel (model:<id> key, mirrors getReasoningEffort/setReasoningEffort); forkHistory copies model; empty string clears. - session-orchestrator: resolve model from persisted store when no per-turn override; persist the resolved model so it sticks; warm path parity. - transport-http: GET/PUT /conversations/:id/model endpoints with validation. 1433 vitest pass; tsc + biome clean.
Diffstat (limited to 'packages/session-orchestrator/src')
-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
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 [];
},