The picker no longer touches the profile default. Model/effort/fast live as plain UI state persisted in localStorage, so a pick follows across Cmd+N and restarts instead of snapping back. New chats ship that state through session.create as per-session overrides; live chats still scope switches to the current session. Settings -> Model remains the only surface that writes the profile default. The gateway now accepts those session.create overrides, builds the agent with them directly, reflects them in the immediate session.info payload, and writes the chat's own model_config into the lazy DB row so reconnect/resume restores that chat instead of the global default.
199 lines
5.4 KiB
TypeScript
199 lines
5.4 KiB
TypeScript
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'
|
|
import {
|
|
$activeSessionId,
|
|
$currentModel,
|
|
$currentProvider,
|
|
setCurrentModel,
|
|
setCurrentProvider
|
|
} from '@/store/session'
|
|
|
|
import { useModelControls } from './use-model-controls'
|
|
|
|
const setGlobalModel = vi.fn()
|
|
const notifyError = vi.fn()
|
|
|
|
vi.mock('@/hermes', () => ({
|
|
getGlobalModelInfo: vi.fn(),
|
|
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
|
|
}))
|
|
|
|
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('')
|
|
setCurrentProvider('')
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.restoreAllMocks()
|
|
$activeSessionId.set(null)
|
|
setCurrentModel('')
|
|
setCurrentProvider('')
|
|
})
|
|
|
|
it('applies the global model when there is no active runtime session', async () => {
|
|
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
|
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')
|
|
})
|
|
|
|
it('does not clobber the active session footer state with global model info', async () => {
|
|
setCurrentModel('deepseek/deepseek-v4-pro')
|
|
setCurrentProvider('deepseek')
|
|
$activeSessionId.set('runtime-1')
|
|
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
|
model: 'openai/gpt-5.5',
|
|
provider: 'openai-codex'
|
|
})
|
|
|
|
const { result } = renderHook(() =>
|
|
useModelControls({
|
|
activeSessionId: 'runtime-1',
|
|
queryClient: new QueryClient(),
|
|
requestGateway: vi.fn()
|
|
})
|
|
)
|
|
|
|
await result.current.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',
|
|
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('stores a no-session pick as UI state with no gateway or global write', async () => {
|
|
const requestGateway = vi.fn()
|
|
let controls!: Controls
|
|
|
|
render(
|
|
<Harness
|
|
activeSessionId={null}
|
|
onReady={value => (controls = value)}
|
|
requestGateway={requestGateway}
|
|
/>
|
|
)
|
|
|
|
await expect(
|
|
controls.selectModel({
|
|
model: 'claude-sonnet-4.6',
|
|
provider: 'anthropic'
|
|
})
|
|
).resolves.toBe(true)
|
|
|
|
// The pick is plain UI state; session.create ships it later. Nothing touches
|
|
// the gateway or the profile default here.
|
|
expect($currentModel.get()).toBe('claude-sonnet-4.6')
|
|
expect($currentProvider.get()).toBe('anthropic')
|
|
expect(requestGateway).not.toHaveBeenCalled()
|
|
expect(setGlobalModel).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('seeds an empty composer model from global but never clobbers a pick', async () => {
|
|
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
|
|
|
|
const { result } = renderHook(() =>
|
|
useModelControls({
|
|
activeSessionId: null,
|
|
queryClient: new QueryClient(),
|
|
requestGateway: vi.fn()
|
|
})
|
|
)
|
|
|
|
// Empty → seeds the default.
|
|
await result.current.refreshCurrentModel()
|
|
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
|
|
|
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
|
|
// draft / session events.
|
|
setCurrentModel('anthropic/claude-sonnet-4.6')
|
|
setCurrentProvider('anthropic')
|
|
await result.current.refreshCurrentModel()
|
|
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
|
|
|
|
// A profile swap forces a reseed to the new profile's default.
|
|
await result.current.refreshCurrentModel(true)
|
|
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
|
})
|
|
})
|