From 8b28bdceb5f1389eed46cfbbc8c8a720e1aa60b3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 31 Aug 2026 19:52:09 -0500 Subject: [PATCH] fix(desktop): stop a stale composer model pinning every new chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../hooks/use-session-actions.test.tsx | 67 +++++++++++++++++++ .../hooks/use-session-actions/index.ts | 16 +++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 0e1fd625ab..256975b652 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 1c5c9510a3..635880cae6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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())