fix(desktop): stop a stale composer model pinning every new chat

Settings → Model while a chat is open flips the composer source to
'default' but leaves the live session's model painted. Sending that
value on session.create pinned every new chat and skipped model.default.

Only a manual composer pick is a per-session override.

Co-authored-by: Tharanee <tharanee@tharanee.net>
This commit is contained in:
Brooklyn Nicholson
2026-08-31 19:52:09 -05:00
committed by brooklyn!
parent f98f5e74e0
commit 8b28bdceb5
2 changed files with 79 additions and 4 deletions
@@ -60,6 +60,7 @@ import {
setCurrentCwd,
setCurrentFastMode,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
setCurrentReasoningEffort,
setMessages,
@@ -567,6 +568,7 @@ describe('createBackendSessionForSend profile routing', () => {
$currentFastMode.set(false)
$currentModel.set('')
$currentProvider.set('')
setCurrentModelSource('')
$currentReasoningEffort.set('')
setNewChatWorkspaceTarget(undefined)
vi.restoreAllMocks()
@@ -609,6 +611,67 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ source: 'desktop' })
})
// Regression (Settings → Model doesn't stick): a stale composer selection
// must not be shipped as a per-session override on a NEW chat.
//
// Saving Settings → Model while a session is live deliberately leaves
// $currentModel painted with the LIVE agent's model (applySavedMainModel
// keeps the live session authoritative) and only flips the source to
// 'default'. If session.create still sent that value, every new chat was
// pinned to the old model and the backend never resolved model.default —
// the user-visible "my default won't change" bug.
it('omits a default-sourced selection so the backend resolves model.default', async () => {
const params = await createWith(() => {
// What the composer holds after Settings saved a new default while a
// chat was open: the previous session's model, marked default-sourced.
setCurrentModel('openai/gpt-5.6-sol')
setCurrentProvider('openai-codex')
setCurrentModelSource('default')
})
expect(params).not.toHaveProperty('model')
expect(params).not.toHaveProperty('provider')
})
it('still sends an explicit manual pick as a per-session override', async () => {
const params = await createWith(() => {
setCurrentModel('anthropic/claude-opus-5')
setCurrentProvider('anthropic')
setCurrentModelSource('manual')
})
expect(params).toMatchObject({
model: 'anthropic/claude-opus-5',
provider: 'anthropic'
})
})
// An unset source is the first-run/cleared state — nothing the user picked,
// so it must not pin the session either.
it('omits the model when no selection source is recorded', async () => {
const params = await createWith(() => {
setCurrentModel('openai/gpt-5.6-sol')
setCurrentProvider('openai-codex')
setCurrentModelSource('')
})
expect(params).not.toHaveProperty('model')
expect(params).not.toHaveProperty('provider')
})
// Effort and fast mode are independent of the model-override decision.
it('keeps sending reasoning effort even when the model is omitted', async () => {
const params = await createWith(() => {
setCurrentModel('openai/gpt-5.6-sol')
setCurrentProvider('openai-codex')
setCurrentModelSource('default')
setCurrentReasoningEffort('high')
})
expect(params).not.toHaveProperty('model')
expect(params).toMatchObject({ reasoning_effort: 'high' })
})
it('passes the current workspace cwd into session.create', async () => {
const params = await createWith(() => {
$currentCwd.set('/remote/worktree')
@@ -658,6 +721,10 @@ describe('createBackendSessionForSend profile routing', () => {
setCurrentModel('anthropic/claude-sonnet-4.6')
setCurrentProvider('anthropic')
// A real composer pick marks the selection manual; this test drives the
// atoms directly, so set the source explicitly. Only a manual selection
// rides along as a per-session override.
setCurrentModelSource('manual')
setCurrentReasoningEffort('high')
setCurrentFastMode(false)
@@ -71,6 +71,7 @@ import {
$newChatWorkspaceTarget,
$sessions,
$yoloActive,
getCurrentModelSource,
getSessionOwnerHint,
type NewChatWorkspaceTarget,
resolveComposerSessionKey,
@@ -242,8 +243,9 @@ function reconcileAuthoritativeMessages(
// mode one backend serves every profile, so an omitted profile silently lands the
// chat on the launch (default) profile — the "rubberbands back to default" bug.
// A no-op for single-profile/local-pooled users (a backend resolves its own launch
// profile to None). The sticky UI model/effort/fast ride as per-session overrides,
// never the profile default (that lives in Settings → Model).
// profile to None). Effort/fast still ride as per-session overrides. Model and
// provider only ride when the composer source is 'manual' — a default-sourced
// value is a mirror of Settings → Model and must not pin the new chat.
async function desktopSessionCreateParams(
cwd: string,
capturedRoute = resolveNewChatOwnerRoute()
@@ -252,11 +254,17 @@ async function desktopSessionCreateParams(
// profile handshake below can yield long enough for background config/model
// refreshes to finish; reading atoms afterward would silently create the
// session with a different selection than the one the user submitted.
// Settings → Model while a session is live leaves $currentModel painted with
// the live agent (applySavedMainModel) and only flips the source to 'default'.
// Shipping that stale value as an override pins every new chat to the old
// model. Omit model/provider unless the source is 'manual'.
const isManualSelection = getCurrentModelSource() === 'manual'
const selection = {
effort: $currentReasoningEffort.get().trim(),
fast: $currentFastMode.get(),
model: $currentModel.get().trim(),
provider: $currentProvider.get().trim()
model: isManualSelection ? $currentModel.get().trim() : '',
provider: isManualSelection ? $currentProvider.get().trim() : ''
}
const profile = capturedRoute?.profile || $newChatProfile.get() || normalizeProfileKey($activeGatewayProfile.get())