mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
d8a9c17dae
commit
937222f4ec
3 changed files with 102 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue