mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
9ef66ea8c1
commit
d5decee5ac
4 changed files with 132 additions and 4 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue