diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index cd539da5a8a..8a3ceb589ab 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -241,6 +241,81 @@ describe('useModelControls', () => { expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) + it('keeps a mid-turn pick painted and skips the refetch that would repaint the old model', async () => { + // The gateway queues a switch made during a turn and applies it at the next + // turn start. Invalidating now would answer with the still-running model + // and overwrite the user's choice in the pill. + $activeSessionId.set('session-1') + const requestGateway = vi.fn(async () => ({ deferred: true, key: 'model', value: 'grok-4.5' }) as never) + const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries') + let controls!: Controls + + render( (controls = value)} requestGateway={requestGateway} />) + + await expect(controls.selectModel({ model: 'grok-4.5', provider: 'xai' })).resolves.toBe(true) + + expect($currentModel.get()).toBe('grok-4.5') + expect($currentProvider.get()).toBe('xai') + expect(invalidate).not.toHaveBeenCalled() + expect(notifyError).not.toHaveBeenCalled() + }) + + it('still refetches after a switch that applied immediately', async () => { + $activeSessionId.set('session-1') + const requestGateway = vi.fn(async () => ({ key: 'model', scope: 'session', value: 'grok-4.5' }) as never) + const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries') + let controls!: Controls + + render( (controls = value)} requestGateway={requestGateway} />) + + await controls.selectModel({ model: 'grok-4.5', provider: 'xai' }) + + expect(invalidate).toHaveBeenCalled() + }) + + it('keeps the pick when an OLDER gateway refuses a mid-turn switch', async () => { + // Pre-deferral backends answer 4009 instead of parking the pick. Rolling + // back would bounce the pill to the old model and toast an error at a user + // who did nothing wrong; the pick still applies to the next turn. + $activeSessionId.set('session-1') + setCurrentModel('fable-5') + setCurrentProvider('nous') + + const requestGateway = vi.fn(async () => { + throw new Error('session busy — /interrupt the current turn before switching models') + }) + + let controls!: Controls + + render( (controls = value)} requestGateway={requestGateway} />) + + await expect(controls.selectModel({ model: 'grok-4.5', provider: 'xai' })).resolves.toBe(true) + + expect($currentModel.get()).toBe('grok-4.5') + expect($currentProvider.get()).toBe('xai') + expect(notifyError).not.toHaveBeenCalled() + }) + + it('still rolls back and reports a real switch failure', async () => { + $activeSessionId.set('session-1') + setCurrentModel('fable-5') + setCurrentProvider('nous') + + const requestGateway = vi.fn(async () => { + throw new Error('no such model') + }) + + let controls!: Controls + + render( (controls = value)} requestGateway={requestGateway} />) + + await expect(controls.selectModel({ model: 'bogus', provider: 'xai' })).resolves.toBe(false) + + expect($currentModel.get()).toBe('fable-5') + expect($currentProvider.get()).toBe('nous') + expect(notifyError).toHaveBeenCalled() + }) + it('session-scopes MoA preset selections so they cannot persist as the global gateway default', async () => { $activeSessionId.set('session-1') const requestGateway = vi.fn(async () => ({ key: 'model', value: 'BeastMode' }) as never) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 1ccba715ce2..d3b9ee20a3d 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -4,6 +4,7 @@ import { useCallback, useRef } from 'react' import type { ModelSelection } from '@/app/shell/model-menu-panel' import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' +import { isBusySessionModelSwitch } from '@/lib/gateway-rpc' import { manualPickRemoved, modelOptionsQueryKey } from '@/lib/model-options' import { notifyError } from '@/store/notifications' import { $activeGatewayProfile } from '@/store/profile' @@ -206,16 +207,32 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO } try { - await requestGateway('config.set', { + const result = await requestGateway<{ deferred?: boolean }>('config.set', { session_id: liveSessionId, key: 'model', value: `${selection.model} --provider ${selection.provider} --session` }) - void queryClient.invalidateQueries({ queryKey: modelOptionsQueryKey(liveGatewayProfile, liveSessionId) }) + // A pick made DURING a turn is queued by the gateway and applied at the + // next turn start (`deferred`). Re-fetching now would answer with the + // model still running and repaint the old name over the user's choice — + // the switch publishes session.info when it lands, and that is what + // re-syncs every surface. + if (!result?.deferred) { + void queryClient.invalidateQueries({ queryKey: modelOptionsQueryKey(liveGatewayProfile, liveSessionId) }) + } return true } catch (err) { + // An OLDER gateway refuses a mid-turn switch outright (4009) instead of + // deferring it. Don't punish the user for a backend they haven't + // updated: keep the pick painted as the composer's selection, which is + // what the NEXT turn runs anyway. Current gateways never take this + // path — they answer `deferred`. + if (isBusySessionModelSwitch(err)) { + return true + } + if (touchesPrimary) { setCurrentModel(prevModel) setCurrentProvider(prevProvider) diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index 6cf298402f3..372cf0bc496 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -11,3 +11,11 @@ export function isMissingPendingPromptRequest(error: unknown, key: string): bool return message.toLowerCase().includes(`no pending ${key.toLowerCase()} request`) } + +/** True when a pre-deferral backend refused a mid-turn model switch (4009). + * Current gateways park the pick and answer `scope: "pending"` instead. */ +export function isBusySessionModelSwitch(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /session busy/i.test(message) && /switching models/i.test(message) +}