fix(desktop): let explicit model switches escape broken config providers (#42241) (#46796)

When a desktop/dashboard session had no agent built yet and the user explicitly
picked a provider in the model picker, config.set('model', ...) would first try
to initialize the agent from the (possibly broken) config default provider —
failing before the user's explicit switch could take effect, trapping them on a
misconfigured default.

config.set now pre-parses the model flags: if an explicit --provider is present
and no agent exists yet, it skips the default-provider agent build and routes
straight through _apply_model_switch with the explicit provider. _apply_model_switch
gained a parsed_flags passthrough (avoids double-parsing) and only falls back to
resolve_runtime_provider(requested=None) when no explicit provider was given.

The desktop hook now sends config.set instead of slash.exec for active-session
model changes, so errors from the selected provider surface to the user instead
of being swallowed.

Co-authored-by: rodboev <rod.boev@gmail.com>
This commit is contained in:
Austin Pickett
2026-06-15 15:36:51 -04:00
committed by GitHub
co-authored by rodboev
parent 2a08b8c86f
commit ed20f5ed06
4 changed files with 212 additions and 20 deletions
@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
@@ -13,12 +13,51 @@ import {
import { useModelControls } from './use-model-controls'
const setGlobalModel = vi.fn()
const notifyError = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: vi.fn(),
setGlobalModel: vi.fn()
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
}))
describe('useModelControls.refreshCurrentModel', () => {
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
desktop: {
modelSwitchFailed: 'Model switch failed'
}
}
})
}))
vi.mock('@/store/notifications', () => ({
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
}))
type Controls = ReturnType<typeof useModelControls>
function Harness({
activeSessionId,
onReady,
requestGateway
}: {
activeSessionId: string | null
onReady: (controls: Controls) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const controls = useModelControls({
activeSessionId,
queryClient: new QueryClient(),
requestGateway
})
onReady(controls)
return null
}
describe('useModelControls', () => {
beforeEach(() => {
$activeSessionId.set(null)
setCurrentModel('')
@@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$activeSessionId.set(null)
setCurrentModel('')
@@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => {
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
expect($currentProvider.get()).toBe('deepseek')
})
it('routes active-session picker changes through config.set with an explicit provider', async () => {
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
let controls!: Controls
render(
<Harness
activeSessionId="session-1"
onReady={value => (controls = value)}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(requestGateway).toHaveBeenCalledWith('config.set', {
session_id: 'session-1',
key: 'model',
value: 'claude-sonnet-4.6 --provider anthropic'
})
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('keeps the global path on setGlobalModel when there is no active session', async () => {
setGlobalModel.mockResolvedValue(undefined)
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={vi.fn()}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
})
})
@@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
await requestGateway('config.set', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
key: 'model',
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {