From 8a17d824f7433375995cae38dbd603660695b232 Mon Sep 17 00:00:00 2001 From: mars Date: Fri, 19 Jun 2026 11:17:15 +0200 Subject: [PATCH] fix(desktop): honor OpenRouter Fusion profile default --- .../session/hooks/use-model-controls.test.tsx | 101 ++++++++++++++++++ .../app/session/hooks/use-model-controls.ts | 86 ++++++++++++--- .../src/lib/model-status-label.test.ts | 2 + apps/desktop/src/lib/model-status-label.ts | 10 ++ 4 files changed, 185 insertions(+), 14 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 f7765de04c595..0f1617a52895b 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 @@ -59,6 +59,7 @@ function Harness({ describe('useModelControls', () => { beforeEach(() => { + window.localStorage.clear() $activeSessionId.set(null) setCurrentModel('') setCurrentProvider('') @@ -67,6 +68,7 @@ describe('useModelControls', () => { afterEach(() => { cleanup() vi.restoreAllMocks() + window.localStorage.clear() $activeSessionId.set(null) setCurrentModel('') setCurrentProvider('') @@ -195,4 +197,103 @@ describe('useModelControls', () => { await result.current.refreshCurrentModel(true) expect($currentModel.get()).toBe('openai/gpt-5.5') }) + + it('reseeds stale composer state when the profile default changes', async () => { + vi.mocked(getGlobalModelInfo).mockResolvedValueOnce({ + model: 'openai/gpt-5.5', + provider: 'openai-codex' + }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + await result.current.refreshCurrentModel() + expect($currentModel.get()).toBe('openai/gpt-5.5') + expect($currentProvider.get()).toBe('openai-codex') + + vi.mocked(getGlobalModelInfo).mockResolvedValueOnce({ + model: 'openrouter/fusion', + provider: 'openrouter' + }) + + await result.current.refreshCurrentModel() + + expect($currentModel.get()).toBe('openrouter/fusion') + expect($currentProvider.get()).toBe('openrouter') + }) + + it('replaces the legacy desktop default slug when no baseline has been recorded', async () => { + setCurrentModel('gpt-5.5') + setCurrentProvider('openai-codex') + vi.mocked(getGlobalModelInfo).mockResolvedValue({ + model: 'openrouter/fusion', + provider: 'openrouter' + }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + await result.current.refreshCurrentModel() + + expect($currentModel.get()).toBe('openrouter/fusion') + expect($currentProvider.get()).toBe('openrouter') + }) + + it('replaces the legacy desktop OpenAI-prefixed default when no baseline has been recorded', async () => { + setCurrentModel('openai/gpt-5.5') + setCurrentProvider('openai-codex') + vi.mocked(getGlobalModelInfo).mockResolvedValue({ + model: 'openrouter/fusion', + provider: 'openrouter' + }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + await result.current.refreshCurrentModel() + + expect($currentModel.get()).toBe('openrouter/fusion') + expect($currentProvider.get()).toBe('openrouter') + }) + + it('does not clobber a same-profile user pick on routine refresh', async () => { + vi.mocked(getGlobalModelInfo).mockResolvedValue({ + model: 'openrouter/fusion', + provider: 'openrouter' + }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + await result.current.refreshCurrentModel() + + await result.current.selectModel({ + model: 'anthropic/claude-sonnet-4.6', + provider: 'openrouter' + }) + await result.current.refreshCurrentModel() + + expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6') + expect($currentProvider.get()).toBe('openrouter') + }) }) 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 50788b1e0befe..2111cd8794db4 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 { persistString, storedString } from '@/lib/storage' import { notifyError } from '@/store/notifications' import { $activeSessionId, @@ -18,6 +19,50 @@ interface ModelSelection { provider: string } +const COMPOSER_DEFAULT_BASELINE_KEY = 'hermes.desktop.composer.default-baseline' + +const LEGACY_DESKTOP_DEFAULTS: readonly ModelSelection[] = [ + { model: 'gpt-5.5', provider: 'openai-codex' }, + { model: 'openai/gpt-5.5', provider: 'openai-codex' } +] + +function sameSelection(left: ModelSelection, right: ModelSelection): boolean { + return left.model === right.model && left.provider === right.provider +} + +function normalizedSelection(selection: ModelSelection): ModelSelection { + return { + model: selection.model.trim(), + provider: selection.provider.trim() + } +} + +function readComposerDefaultBaseline(): ModelSelection | null { + const raw = storedString(COMPOSER_DEFAULT_BASELINE_KEY) + + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) as Partial + const model = typeof parsed.model === 'string' ? parsed.model.trim() : '' + const provider = typeof parsed.provider === 'string' ? parsed.provider.trim() : '' + + return model ? { model, provider } : null + } catch { + return null + } +} + +function writeComposerDefaultBaseline(selection: ModelSelection): void { + persistString(COMPOSER_DEFAULT_BASELINE_KEY, JSON.stringify(normalizedSelection(selection))) +} + +function isLegacyDesktopDefault(selection: ModelSelection): boolean { + return LEGACY_DESKTOP_DEFAULTS.some(defaultSelection => sameSelection(selection, defaultSelection)) +} + interface ModelControlsOptions { activeSessionId: string | null queryClient: QueryClient @@ -42,33 +87,46 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway ) // Seed the composer's model state from the profile default. `force` reseeds - // for a profile swap (the new profile has its own default); otherwise this - // only fills an EMPTY selection so a user's pick (plain UI state in - // $currentModel) survives the lifecycle refreshes that fire on boot / fresh - // draft / session events. A live session owns the footer, so skip entirely. + // for a profile swap (the new profile has its own default). Routine refreshes + // may also reseed when the composer still matches the last default we seeded + // and the profile default has changed; once a user picks a different model, + // that plain UI state survives boot / fresh draft / session-event refreshes. + // A live session owns the footer, so skip entirely. const refreshCurrentModel = useCallback(async (force = false) => { try { if ($activeSessionId.get()) { return } - if (!force && $currentModel.get()) { - return - } - const result = await getGlobalModelInfo() + const nextDefault = normalizedSelection({ + model: typeof result.model === 'string' ? result.model : '', + provider: typeof result.provider === 'string' ? result.provider : '' + }) - if ($activeSessionId.get() || (!force && $currentModel.get())) { + if ($activeSessionId.get() || !nextDefault.model) { return } - if (typeof result.model === 'string') { - setCurrentModel(result.model) + const current = normalizedSelection({ + model: $currentModel.get(), + provider: $currentProvider.get() + }) + const baseline = readComposerDefaultBaseline() + const shouldReseed = + force || + !current.model || + sameSelection(current, nextDefault) || + (baseline != null && sameSelection(current, baseline) && !sameSelection(baseline, nextDefault)) || + (baseline == null && isLegacyDesktopDefault(current) && !sameSelection(current, nextDefault)) + + if (!shouldReseed) { + return } - if (typeof result.provider === 'string') { - setCurrentProvider(result.provider) - } + setCurrentModel(nextDefault.model) + setCurrentProvider(nextDefault.provider) + writeComposerDefaultBaseline(nextDefault) } catch { // The delayed session.info event still updates this once the agent is ready. } diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index f46282d00b21d..4d5266d7c3e1c 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -8,6 +8,7 @@ describe('model-status-label', () => { expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5') expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro') expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5') + expect(displayModelName('openrouter/fusion')).toBe('OpenRouter Fusion') }) it('strips trailing date-pin snapshots from the display name', () => { @@ -30,6 +31,7 @@ describe('model-status-label', () => { it('always surfaces the effort (default medium) so the level is visible', () => { expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med') expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med') + expect(formatModelStatusLabel('openrouter/fusion')).toBe('OpenRouter Fusion · Med') }) it('returns just the placeholder name when there is no model', () => { diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 9b0e8df7a6496..41ad785c76bb3 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -51,6 +51,10 @@ const VARIANT_TAGS: ReadonlyArray = [ [/-latest$/i, 'Latest'] ] +const MODEL_DISPLAY_NAMES: Record = { + 'openrouter/fusion': 'OpenRouter Fusion' +} + const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim() function prettifyBase(base: string): string { @@ -72,6 +76,12 @@ function prettifyBase(base: string): string { /** Split a model id into a clean display name plus an optional grayed variant * tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */ export function modelDisplayParts(model: string): { name: string; tag: string } { + const override = MODEL_DISPLAY_NAMES[model.trim().toLowerCase()] + + if (override) { + return { name: override, tag: '' } + } + let base = modelBaseId(model) let tag = ''