Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/desktop/src/app/session/hooks/use-model-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ vi.mock('@/store/notifications', () => ({

type Controls = ReturnType<typeof useModelControls>

function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void

const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})

return { promise, resolve, reject }
}

function Harness({
activeSessionId,
onReady,
Expand All @@ -59,6 +71,7 @@ function Harness({

describe('useModelControls', () => {
beforeEach(() => {
vi.clearAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentProvider('')
Expand Down Expand Up @@ -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')
})
})
70 changes: 52 additions & 18 deletions apps/desktop/src/app/session/hooks/use-model-controls.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -24,9 +24,18 @@ interface ModelControlsOptions {
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}

const FORCE_REFRESH_RETRY_DELAYS_MS = [250, 750, 2_000] as const

function delay(ms: number): Promise<void> {
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) => {
Expand All @@ -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') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the fourth failed request retryDelays[attempt] is undefined, so this returns with $currentModel still empty; ModelPill then keeps rendering its spinner. Please surface a retryable failure/recovery action here and add an exhaustion-path test rather than leaving the terminal state indistinguishable from loading.

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.
}
}, [])

Expand Down
Loading