diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7e87769f..cb6f9acf 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -74,6 +74,20 @@ export const DEFAULT_MODEL = 'claude-code'; */ export const CHAT_DEFAULT_MODEL = 'claude-code/sonnet'; +/** + * Is this stored preference actually a model, or just a harness? + * + * `claude-code` with no tier routes fine and is a legitimate value for the resume path — but as an answer + * to "which model should this chat use" it means "whatever the CLI picks", which is Opus. Accounts have it + * stored from before that distinction existed: on this machine `tasks.defaultModel` and + * `chat.defaultProjectModel` were both bare, which is why the picker showed `claude-code` and the change + * to `CHAT_DEFAULT_MODEL` appeared to do nothing. + * + * Rather than rewrite somebody's settings, a bare harness is READ as unset. The row keeps whatever it + * says; the meaning of "no tier chosen" is now "use the default", which is what it always meant. + */ +export const isTierlessModel = (model: string | null | undefined): boolean => !model || !model.includes('/'); + /** * What a commander runs on, regardless of the account's default. * @@ -871,7 +885,9 @@ async function handleChat( // Provided model, then the account default, then Sonnet. NOT `DEFAULT_MODEL` — that is the bare harness // and means "whatever Claude Code picks", which is Opus. See `CHAT_DEFAULT_MODEL`. - const requested = msg.model || (await getUserDefaultModel(userId)) || CHAT_DEFAULT_MODEL; + const accountDefault = await getUserDefaultModel(userId); + const chosen = msg.model || accountDefault; + const requested = isTierlessModel(chosen) ? CHAT_DEFAULT_MODEL : chosen!; /* A conversation may change TIER mid-flight; it may not change HARNESS. diff --git a/src/servers/api/tasks/pipeline-executor.ts b/src/servers/api/tasks/pipeline-executor.ts index ed534818..e5a4c360 100644 --- a/src/servers/api/tasks/pipeline-executor.ts +++ b/src/servers/api/tasks/pipeline-executor.ts @@ -28,7 +28,10 @@ async function resolveModel(userId: number): Promise { try { const settings = await getUserSettings(userId); const tasks = settings?.tasks as Record | undefined; - return (tasks?.defaultModel as string) || DEFAULT_MODEL; + // A bare `claude-code` is a harness, not a tier — read as unset. See `isTierlessModel` in + // `api/chat/websocket.ts`; this machine had exactly that stored, so the default never applied. + const stored = tasks?.defaultModel as string | undefined; + return stored && stored.includes('/') ? stored : DEFAULT_MODEL; } catch { return DEFAULT_MODEL; } diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index cab049b1..01573cc9 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -173,8 +173,11 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, useEffect(() => { if (existingSessionId || initialModel) return; - const defaultModel = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel; - if (defaultModel) setSelectedModel(defaultModel); + const stored = (projectScoped && settings?.chat?.defaultProjectModel) || settings?.chat?.defaultModel; + // A stored value with no `/` is the bare harness — a routing answer, not a tier. Selecting it would + // send it, and the server would then honour a "choice" that means "whatever the CLI picks", i.e. Opus. + // Leaving it unselected lets the server apply CHAT_DEFAULT_MODEL, which is the point of that constant. + if (stored && stored.includes('/')) setSelectedModel(stored); }, [existingSessionId, initialModel, settings, projectScoped]); // Sync selectedModel when initialModel changes (e.g. resuming a session)