* fix(desktop): keep the pre-session model pick selected in the picker The composer picker derived its "current" row from `model.options ?? store`, so model.options always won. Pre-session that query returns the PROFILE DEFAULT, not the sticky composer pick — so selecting a model before a session exists left the checkmark (and the picker's "current" line) on the default, making the pick look ignored even though the pill updated. Add `currentPickerSelection()`: with a live session the gateway's model.options is authoritative; pre-session the sticky `$currentModel`/`$currentProvider` wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog. * feat(desktop): global reasoning/speed defaults in Settings → Model The composer picker is now sticky-UI/per-session only and never writes the profile default (#46959), but Settings → Model had no reasoning/speed control and `agent.reasoning_effort` wasn't in the curated config surface at all (`service_tier` was buried in Advanced) — so there was nowhere to set the profile default that crons/subagents/messaging resolve from. Add capability-gated Reasoning (effort) + Fast controls beside the main model, gated by the applied model's reported capabilities (reasoning defaults on, fast off when unreported — same as the composer). They read/write `agent.reasoning_effort` and `agent.service_tier` by round-tripping the config record, matching the gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast). * refactor(desktop): don't open the reasoning select from its row label A <label> wrapping the Select forwarded text clicks to the trigger, opening the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so clicking its text toggles the switch (expected for a checkbox-like control).
195 lines
7.5 KiB
TypeScript
195 lines
7.5 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
|
|
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
|
|
// them to let the dropdown open in tests.
|
|
beforeAll(() => {
|
|
Element.prototype.scrollIntoView = vi.fn()
|
|
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
|
Element.prototype.releasePointerCapture = vi.fn()
|
|
})
|
|
|
|
const getGlobalModelInfo = vi.fn()
|
|
const getGlobalModelOptions = vi.fn()
|
|
const getAuxiliaryModels = vi.fn()
|
|
const setModelAssignment = vi.fn()
|
|
const getRecommendedDefaultModel = vi.fn()
|
|
const setEnvVar = vi.fn()
|
|
const getHermesConfigRecord = vi.fn()
|
|
const saveHermesConfig = vi.fn()
|
|
const startManualProviderOAuth = vi.fn()
|
|
|
|
vi.mock('@/hermes', () => ({
|
|
getGlobalModelInfo: () => getGlobalModelInfo(),
|
|
getGlobalModelOptions: () => getGlobalModelOptions(),
|
|
getAuxiliaryModels: () => getAuxiliaryModels(),
|
|
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
|
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
|
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
|
getHermesConfigRecord: () => getHermesConfigRecord(),
|
|
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
|
|
}))
|
|
|
|
vi.mock('@/store/onboarding', () => ({
|
|
startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug)
|
|
}))
|
|
|
|
beforeEach(() => {
|
|
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
|
getGlobalModelOptions.mockResolvedValue({
|
|
providers: [
|
|
{
|
|
name: 'Nous',
|
|
slug: 'nous',
|
|
models: ['hermes-4', 'hermes-4-mini'],
|
|
authenticated: true,
|
|
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
|
|
},
|
|
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
|
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
|
|
]
|
|
})
|
|
getAuxiliaryModels.mockResolvedValue({
|
|
main: { provider: 'nous', model: 'hermes-4' },
|
|
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
|
|
})
|
|
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
|
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
|
setEnvVar.mockResolvedValue({ ok: true })
|
|
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
|
|
saveHermesConfig.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
async function renderModelSettings() {
|
|
const { ModelSettings } = await import('./model-settings')
|
|
|
|
return render(<ModelSettings />)
|
|
}
|
|
|
|
describe('ModelSettings', () => {
|
|
it('loads the current main model and lists the full provider universe', async () => {
|
|
await renderModelSettings()
|
|
|
|
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
|
|
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
|
|
|
// Open the provider Select — every provider from the full payload should be
|
|
// listed, including the unconfigured one with its "set up" hint.
|
|
const triggers = await screen.findAllByRole('combobox')
|
|
fireEvent.click(triggers[0])
|
|
|
|
// "Nous" shows in both the trigger and the open list; the unconfigured
|
|
// provider + its setup hint are the unique signal of the full universe.
|
|
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
|
|
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
|
|
expect(await screen.findByText(/set up/)).toBeTruthy()
|
|
})
|
|
|
|
it('activates an unconfigured api_key provider inline by saving its key', async () => {
|
|
await renderModelSettings()
|
|
|
|
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
|
|
|
// Open the provider Select and pick the unconfigured provider.
|
|
const triggers = screen.getAllByRole('combobox')
|
|
fireEvent.click(triggers[0])
|
|
const deepseekOption = await screen.findByText(/DeepSeek/)
|
|
fireEvent.click(deepseekOption)
|
|
|
|
// The inline key input appears for an api_key provider that needs setup.
|
|
const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
|
|
fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
|
|
|
|
const activate = await screen.findByRole('button', { name: /Activate/ })
|
|
fireEvent.click(activate)
|
|
|
|
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
|
})
|
|
|
|
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
|
|
await renderModelSettings()
|
|
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
|
|
|
const fastSwitch = await screen.findByRole('switch')
|
|
fireEvent.click(fastSwitch)
|
|
|
|
await waitFor(() =>
|
|
expect(saveHermesConfig).toHaveBeenCalledWith(
|
|
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
|
|
)
|
|
)
|
|
})
|
|
|
|
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
|
|
getGlobalModelOptions.mockResolvedValueOnce({
|
|
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
|
|
})
|
|
|
|
await renderModelSettings()
|
|
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
|
|
|
expect(screen.queryByRole('switch')).toBeNull()
|
|
})
|
|
|
|
it('renders the auxiliary task rows', async () => {
|
|
await renderModelSettings()
|
|
|
|
expect(await screen.findByText('Vision')).toBeTruthy()
|
|
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
|
|
await renderModelSettings()
|
|
|
|
// One "Set to main" button per task slot; the first is Vision.
|
|
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
|
|
fireEvent.click(setToMainButtons[0])
|
|
|
|
await waitFor(() =>
|
|
expect(setModelAssignment).toHaveBeenCalledWith({
|
|
model: 'hermes-4',
|
|
provider: 'nous',
|
|
scope: 'auxiliary',
|
|
task: 'vision'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('warns when a main switch leaves auxiliary tasks pinned to another provider', async () => {
|
|
setModelAssignment.mockResolvedValueOnce({
|
|
provider: 'openrouter',
|
|
model: 'anthropic/claude-opus-4.7',
|
|
gateway_tools: [],
|
|
stale_aux: [{ task: 'compression', provider: 'nous', model: 'hermes-4' }]
|
|
})
|
|
|
|
await renderModelSettings()
|
|
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
|
|
|
|
const applyButton = await screen.findByRole('button', { name: 'Apply' })
|
|
fireEvent.click(applyButton)
|
|
|
|
// The switch-time notice names the pinned provider and offers a reset.
|
|
expect(await screen.findByText(/still run on/)).toBeTruthy()
|
|
expect(screen.getByText('nous')).toBeTruthy()
|
|
})
|
|
|
|
it('shows a persistent banner when a loaded aux slot mismatches the main provider', async () => {
|
|
getAuxiliaryModels.mockResolvedValueOnce({
|
|
main: { provider: 'nous', model: 'hermes-4' },
|
|
tasks: [{ task: 'curator', provider: 'openrouter', model: 'anthropic/claude-opus-4.7', base_url: '' }]
|
|
})
|
|
|
|
await renderModelSettings()
|
|
|
|
// Banner present on load, no switch required.
|
|
expect(await screen.findByText(/still run on/)).toBeTruthy()
|
|
})
|
|
})
|