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..21af3324a82f4 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 @@ -37,6 +37,18 @@ vi.mock('@/store/notifications', () => ({ type Controls = ReturnType +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { promise, resolve, reject } +} + function Harness({ activeSessionId, onReady, @@ -59,6 +71,7 @@ function Harness({ describe('useModelControls', () => { beforeEach(() => { + vi.clearAllMocks() $activeSessionId.set(null) setCurrentModel('') setCurrentProvider('') @@ -195,4 +208,58 @@ describe('useModelControls', () => { await result.current.refreshCurrentModel(true) expect($currentModel.get()).toBe('openai/gpt-5.5') }) + + it('retries a forced refresh until the profile default becomes available', async () => { + vi.useFakeTimers() + vi.mocked(getGlobalModelInfo) + .mockRejectedValueOnce(new Error('booting')) + .mockRejectedValueOnce(new Error('still booting')) + .mockResolvedValueOnce({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + const refreshPromise = result.current.refreshCurrentModel(true) + + await vi.runAllTimersAsync() + await refreshPromise + + expect(getGlobalModelInfo).toHaveBeenCalledTimes(3) + expect($currentModel.get()).toBe('openai/gpt-5.5') + expect($currentProvider.get()).toBe('openai-codex') + }) + + it('ignores stale forced refresh results when a newer profile refresh wins the race', async () => { + const first = deferred<{ model: string; provider: string }>() + const second = deferred<{ model: string; provider: string }>() + + vi.mocked(getGlobalModelInfo) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + const staleRefresh = result.current.refreshCurrentModel(true) + const latestRefresh = result.current.refreshCurrentModel(true) + + second.resolve({ model: 'anthropic/claude-sonnet-4.6', provider: 'anthropic' }) + await latestRefresh + + first.resolve({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) + await staleRefresh + + expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6') + expect($currentProvider.get()).toBe('anthropic') + }) }) 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..90ea5d27c2e80 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -1,5 +1,5 @@ import { type QueryClient } from '@tanstack/react-query' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' @@ -24,9 +24,18 @@ interface ModelControlsOptions { requestGateway: (method: string, params?: Record) => Promise } +const FORCE_REFRESH_RETRY_DELAYS_MS = [250, 750, 2_000] as const + +function delay(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} + export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { const { t } = useI18n() const copy = t.desktop + const refreshRequestIdRef = useRef(0) const updateModelOptionsCache = useCallback( (provider: string, model: string, includeGlobal: boolean) => { @@ -47,30 +56,55 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway // $currentModel) survives the lifecycle refreshes that fire on boot / fresh // draft / session events. A live session owns the footer, so skip entirely. const refreshCurrentModel = useCallback(async (force = false) => { - try { - if ($activeSessionId.get()) { - return - } + if ($activeSessionId.get()) { + return + } - if (!force && $currentModel.get()) { - return - } + if (!force && $currentModel.get()) { + return + } - const result = await getGlobalModelInfo() + const requestId = ++refreshRequestIdRef.current + const retryDelays = force ? FORCE_REFRESH_RETRY_DELAYS_MS : [] + + for (let attempt = 0; attempt <= retryDelays.length; attempt += 1) { + try { + const result = await getGlobalModelInfo() + + if (refreshRequestIdRef.current !== requestId) { + return + } + + if ($activeSessionId.get() || (!force && $currentModel.get())) { + return + } + + if (typeof result.model === 'string') { + setCurrentModel(result.model) + } + + if (typeof result.provider === 'string') { + setCurrentProvider(result.provider) + } - if ($activeSessionId.get() || (!force && $currentModel.get())) { return - } + } catch { + if (refreshRequestIdRef.current !== requestId) { + return + } - if (typeof result.model === 'string') { - setCurrentModel(result.model) - } + const retryDelay = retryDelays[attempt] + + if (typeof retryDelay !== 'number') { + return + } + + await delay(retryDelay) - if (typeof result.provider === 'string') { - setCurrentProvider(result.provider) + if (refreshRequestIdRef.current !== requestId) { + return + } } - } catch { - // The delayed session.info event still updates this once the agent is ready. } }, [])