fix(desktop): keep a mid-turn model pick painted in the composer (#74759)

The gateway now queues a model switch made during a turn and applies it
at the next turn start (#74756), but the desktop still bounced the pill
back to the old model: the post-switch refetch answered with the model
still running and repainted over the pick.

Skip that refetch when the switch was deferred — the apply publishes
session.info when it lands, and that is what re-syncs every surface.
An older gateway that still refuses with 4009 keeps the pick too rather
than rolling back and toasting at a user who did nothing wrong; it is
what the next turn runs anyway. Real failures still roll back and report.

The 4009 predicate lives beside the other gateway-compat probes in
lib/gateway-rpc.
This commit is contained in:
brooklyn! 2026-07-30 05:34:48 -05:00 committed by GitHub
parent d8a9c17dae
commit 937222f4ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 102 additions and 2 deletions

View file

@ -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(<Harness onReady={value => (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(<Harness onReady={value => (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(<Harness onReady={value => (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(<Harness onReady={value => (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)

View file

@ -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)

View file

@ -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)
}