fix(desktop): reseed composer when a sticky manual pick was removed

A manual composer pick stays sticky across new chats (intended), but if that
model later disappears from the provider/config, every new chat kept trying the
dead model and 404'd. Fall back to the profile default in that one case only.

refreshCurrentModel now consults the model-options cache the composer already
populates (no extra fetch): a manual pick is preserved unless manualPickRemoved()
proves it's gone — provider present, non-empty catalog, model absent. An
unknown/absent provider, an empty (re-auth/unconfigured) list, or a not-yet-loaded
catalog all preserve the pick, so a still-valid selection is never clobbered.
This commit is contained in:
Brooklyn Nicholson 2026-07-18 23:44:40 -04:00
parent 9ef66ea8c1
commit d5decee5ac
4 changed files with 132 additions and 4 deletions

View file

@ -207,6 +207,47 @@ describe('useModelControls', () => {
expect($currentModel.get()).toBe('openai/gpt-5.5')
})
it('reseeds a sticky manual pick that was removed from the catalog', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
providers: [{ models: ['openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
// A manual pick whose model no longer exists on its provider.
setCurrentModel('openrouter/owl-alpha')
setCurrentProvider('openrouter')
setCurrentModelSource('manual')
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway: vi.fn() }))
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect(getCurrentModelSource()).toBe('default')
})
it('keeps a sticky manual pick that is still in the catalog', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
providers: [{ models: ['openrouter/glm-4.7', 'openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
setCurrentModel('openrouter/glm-4.7')
setCurrentProvider('openrouter')
setCurrentModelSource('manual')
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway: vi.fn() }))
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openrouter/glm-4.7')
expect(getCurrentModelSource()).toBe('manual')
})
it('refreshes legacy/default-derived composer state from the profile default', async () => {
setCurrentModel('openai/gpt-5.5')
setCurrentProvider('nous')

View file

@ -3,6 +3,7 @@ import { useCallback } from 'react'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { manualPickRemoved } from '@/lib/model-options'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
@ -58,13 +59,28 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
return
}
if (!force && $currentModel.get() && getCurrentModelSource() === 'manual') {
// A manual pick stays sticky UNLESS it was removed from the catalog (its
// model no longer exists on the provider), in which case keeping it would
// 404 every new chat — fall through to reseed from the profile default.
// Reads the model-options cache the composer already populated; an
// unknown/not-yet-loaded catalog conservatively preserves the pick.
const keepManualPick = () => {
if (force || !$currentModel.get() || getCurrentModelSource() !== 'manual') {
return false
}
const options = queryClient.getQueryData<ModelOptionsResponse>(['model-options', 'global'])
return !manualPickRemoved(options?.providers, $currentProvider.get(), $currentModel.get())
}
if (keepManualPick()) {
return
}
const result = await getGlobalModelInfo()
if ($activeSessionId.get() || (!force && $currentModel.get() && getCurrentModelSource() === 'manual')) {
if ($activeSessionId.get() || keepManualPick()) {
return
}
@ -82,7 +98,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
}, [])
}, [queryClient])
// Returns whether the switch succeeded so callers can await it before applying
// follow-up changes. The composer model is plain UI state: with no live

View file

@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelOptions } from '@/hermes'
import { requestModelOptions } from './model-options'
import { manualPickRemoved, requestModelOptions } from './model-options'
const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] }
@ -48,3 +48,40 @@ describe('requestModelOptions', () => {
expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true })
})
})
describe('manualPickRemoved', () => {
const providers = [
{ name: 'OpenRouter', slug: 'openrouter', models: ['owl-alpha', 'gpt-5.5'] },
{ name: 'Nous', slug: 'nous', models: [] } // present but unconfigured / re-auth
]
it('flags a pick whose model was dropped from a populated provider', () => {
expect(manualPickRemoved(providers, 'openrouter', 'nemotron-removed')).toBe(true)
})
it('keeps a pick that is still in the catalog', () => {
expect(manualPickRemoved(providers, 'openrouter', 'gpt-5.5')).toBe(false)
})
it('matches the provider by name as well as slug', () => {
expect(manualPickRemoved(providers, 'OpenRouter', 'gpt-5.5')).toBe(false)
expect(manualPickRemoved(providers, 'OpenRouter', 'gone')).toBe(true)
})
it('never clobbers when the provider is absent (ambiguous / deauth)', () => {
expect(manualPickRemoved(providers, 'anthropic', 'claude-sonnet-4.6')).toBe(false)
})
it('never clobbers when the provider has an empty model list (re-auth)', () => {
expect(manualPickRemoved(providers, 'nous', 'hermes-4')).toBe(false)
})
it('never clobbers on a not-yet-loaded or empty catalog', () => {
expect(manualPickRemoved(undefined, 'openrouter', 'gpt-5.5')).toBe(false)
expect(manualPickRemoved([], 'openrouter', 'gpt-5.5')).toBe(false)
})
it('never clobbers when there is no pick', () => {
expect(manualPickRemoved(providers, '', '')).toBe(false)
})
})

View file

@ -1,4 +1,38 @@
import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'
import type { ModelOptionProvider } from '@/types/hermes'
/**
* True only when a persisted **manual** composer pick has been removed from the
* catalog (its provider still ships models, but no longer this one) so a new
* chat would keep 404'ing the dead model. Deliberately conservative to never
* clobber a still-valid pick: an unknown/absent provider, an empty model list
* (re-auth / unconfigured), or a not-yet-loaded catalog all return false.
*/
export function manualPickRemoved(
providers: ModelOptionProvider[] | undefined,
provider: string,
model: string
): boolean {
if (!providers?.length || !provider || !model) {
return false
}
const row = providers.find(p => p.slug === provider || p.name === provider)
if (!row) {
return false
}
const models = row.models ?? []
// Empty list means the provider is present but unconfigured / awaiting
// re-auth, not that the model was dropped — leave the pick alone.
if (models.length === 0) {
return false
}
return !models.includes(model)
}
interface ModelOptionsRequest {
/** When false, include ambient/unconfigured providers (onboarding/setup