From d5decee5ac033600d8cdd5e7b5a45657a0483993 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Jul 2026 23:44:40 -0400 Subject: [PATCH] fix(desktop): reseed composer when a sticky manual pick was removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../session/hooks/use-model-controls.test.tsx | 41 +++++++++++++++++++ .../app/session/hooks/use-model-controls.ts | 22 ++++++++-- apps/desktop/src/lib/model-options.test.ts | 39 +++++++++++++++++- apps/desktop/src/lib/model-options.ts | 34 +++++++++++++++ 4 files changed, 132 insertions(+), 4 deletions(-) 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 2c8c4e1f6fbd..6e1f459d1c18 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 @@ -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') 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 5f0d351b6ec9..7d59b8e40875 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -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(['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 diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index 65bc180a7f8f..4bbc9fa08a87 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index 11ae63bf202c..36b9f47a4276 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -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