Files
hermes-agent/apps/desktop/src/app/settings/model-settings.test.tsx
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

786 lines
28 KiB
TypeScript

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
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 getMoaModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const saveMoaModels = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualLocalEndpoint = vi.fn()
const startManualOnboarding = vi.fn()
const startManualProviderOAuth = vi.fn()
let profileSwitchHandler: (() => void) | null = null
vi.mock('@/hermes', () => ({
getGlobalModelInfo: (profile?: null | string) => getGlobalModelInfo(profile),
getGlobalModelOptions: (opts?: unknown, profile?: null | string) => getGlobalModelOptions(opts, profile),
getAuxiliaryModels: (profile?: null | string) => getAuxiliaryModels(profile),
getApiRequestProfile: () => 'default',
getMoaModels: (profile?: null | string) => getMoaModels(profile),
profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default',
setModelAssignment: (body: unknown, profile?: null | string) =>
profile == null ? setModelAssignment(body) : setModelAssignment(body, profile),
getRecommendedDefaultModel: (slug: string, profile?: null | string) => getRecommendedDefaultModel(slug, profile),
saveMoaModels: (body: unknown) => saveMoaModels(body),
setEnvVar: (key: string, value: string, profile?: null | string) => setEnvVar(key, value, profile),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config),
setApiRequestProfile: () => {}
}))
vi.mock('@/store/onboarding', () => ({
startManualLocalEndpoint: () => startManualLocalEndpoint(),
startManualOnboarding: () => startManualOnboarding(),
startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug)
}))
vi.mock('../hooks/use-on-profile-switch', () => ({
useOnProfileSwitch: (handler: () => void) => {
profileSwitchHandler = handler
}
}))
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 } }
}
]
})
getAuxiliaryModels.mockResolvedValue({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
getMoaModels.mockResolvedValue(null)
setModelAssignment.mockResolvedValue({ ok: true, provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'nous', model: 'hermes-4', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
profileSwitchHandler = null
})
async function renderModelSettings(scopeProfile?: string) {
const { ModelSettings } = await import('./model-settings')
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
// The aux-task deep-link highlight reads useSearchParams, so the page
// needs a router context in tests (the app provides HashRouter at root).
<MemoryRouter>
<QueryClientProvider client={client}>
<ModelSettings scopeProfile={scopeProfile} />
</QueryClientProvider>
</MemoryRouter>
)
}
describe('ModelSettings profile scope', () => {
// #90549: the API helpers treat `null` as "deliberately target the
// primary/default profile". A page following the active profile must pass
// `undefined`, or every read repaints the primary's model and the user's
// change looks reverted.
it('follows the active profile (undefined, never null) when unscoped', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith(undefined))
expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, undefined)
expect(getAuxiliaryModels).toHaveBeenCalledWith(undefined)
expect(getMoaModels).toHaveBeenCalledWith(undefined)
})
it('reads through the explicit scope override when one is set', async () => {
await renderModelSettings('research')
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith('research'))
expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, 'research')
expect(getAuxiliaryModels).toHaveBeenCalledWith('research')
expect(getMoaModels).toHaveBeenCalledWith('research')
})
})
describe('ModelSettings', () => {
it('loads the current main model and lists configured providers only', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select — only configured providers should be listed.
const triggers = await screen.findAllByRole('combobox')
fireEvent.click(triggers[0])
// "Nous" shows in both the trigger and the open list.
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
expect(screen.queryByText(/DeepSeek/)).toBeNull()
})
it.each(['custom', 'local', 'custom:lab'])(
'opens local endpoint setup when %s has no inventory row',
async provider => {
getGlobalModelInfo.mockResolvedValueOnce({ provider, model: '' })
getGlobalModelOptions.mockResolvedValueOnce({ providers: [] })
await renderModelSettings()
const providerSelect = (await screen.findAllByRole('combobox'))[0]
expect(providerSelect.textContent).toContain(provider)
expect(screen.queryByText(/undefined/)).toBeNull()
expect(screen.queryByText(/signs in through your browser/)).toBeNull()
fireEvent.click(await screen.findByRole('button', { name: 'Set up provider' }))
expect(startManualLocalEndpoint).toHaveBeenCalledOnce()
expect(startManualOnboarding).not.toHaveBeenCalled()
expect(startManualProviderOAuth).not.toHaveBeenCalled()
}
)
it('opens the generic provider picker for an unknown provider with no inventory row', async () => {
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'retired-provider', model: '' })
getGlobalModelOptions.mockResolvedValueOnce({ providers: [] })
await renderModelSettings()
fireEvent.click(await screen.findByRole('button', { name: 'Set up provider' }))
expect(startManualOnboarding).toHaveBeenCalledOnce()
expect(startManualLocalEndpoint).not.toHaveBeenCalled()
expect(startManualProviderOAuth).not.toHaveBeenCalled()
})
it('deep-links a known OAuth provider row into its setup flow', async () => {
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'anthropic', model: '' })
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Anthropic',
slug: 'anthropic',
models: [],
authenticated: false,
auth_type: 'oauth'
}
]
})
await renderModelSettings()
fireEvent.click(await screen.findByRole('button', { name: 'Set up Anthropic' }))
expect(startManualProviderOAuth).toHaveBeenCalledWith('anthropic')
expect(startManualLocalEndpoint).not.toHaveBeenCalled()
expect(startManualOnboarding).not.toHaveBeenCalled()
})
it('activates FreeModel2API with its endpoint and makes its live model the main default', async () => {
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'freemodel2api', model: '' })
getGlobalModelOptions
.mockResolvedValueOnce({
providers: [
{
name: 'FreeModel2API',
slug: 'freemodel2api',
models: [],
authenticated: false,
auth_type: 'api_key',
key_env: 'FREEMODEL2API_API_KEY',
base_url_env: 'FREEMODEL2API_BASE_URL'
}
]
})
.mockResolvedValueOnce({
providers: [
{
name: 'FreeModel2API',
slug: 'freemodel2api',
models: ['gpt-5.4'],
authenticated: true,
auth_type: 'api_key',
key_env: 'FREEMODEL2API_API_KEY',
base_url_env: 'FREEMODEL2API_BASE_URL'
}
]
})
getRecommendedDefaultModel.mockResolvedValueOnce({
provider: 'freemodel2api',
model: 'gpt-5.4',
free_tier: null
})
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'freemodel2api',
model: 'gpt-5.4',
gateway_tools: []
})
await renderModelSettings('research')
fireEvent.change(await screen.findByPlaceholderText('Paste FREEMODEL2API_API_KEY'), {
target: { value: 'sk-fm2' }
})
fireEvent.change(screen.getByPlaceholderText('Enter FREEMODEL2API_BASE_URL'), {
target: { value: 'http://fm2.example/v1' }
})
fireEvent.click(screen.getByRole('button', { name: 'Activate' }))
await waitFor(() => {
expect(setEnvVar).toHaveBeenNthCalledWith(1, 'FREEMODEL2API_API_KEY', 'sk-fm2', 'research')
expect(setEnvVar).toHaveBeenNthCalledWith(2, 'FREEMODEL2API_BASE_URL', 'http://fm2.example/v1', 'research')
})
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith(
{ provider: 'freemodel2api', model: 'gpt-5.4', scope: 'main' },
'research'
)
)
})
it('replaces the selected provider and model when the active profile changes', async () => {
getGlobalModelInfo
.mockResolvedValueOnce({ provider: 'custom', model: 'local-a' })
.mockResolvedValueOnce({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions
.mockResolvedValueOnce({
providers: [
{
name: 'Custom A',
slug: 'custom',
models: ['local-a'],
authenticated: true
}
]
})
.mockResolvedValueOnce({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
}
]
})
await renderModelSettings()
expect((await screen.findAllByRole('combobox'))[0].textContent).toContain('Custom A')
await act(async () => {
profileSwitchHandler?.()
})
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.getAllByRole('combobox')[0].textContent).toContain('Nous'))
expect(screen.queryByRole('button', { name: 'Set up provider' })).toBeNull()
})
it('preserves a user-defined provider endpoint when applying the main model', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4'],
authenticated: true
},
{
name: 'Ollama',
slug: 'local-ollama',
models: ['qwen3:latest'],
authenticated: true,
is_user_defined: true,
api_url: 'http://localhost:11434/v1'
}
]
})
setModelAssignment.mockResolvedValueOnce({
ok: true,
provider: 'local-ollama',
model: 'qwen3:latest',
gateway_tools: []
})
await renderModelSettings()
const providerSelect = (await screen.findAllByRole('combobox'))[0]
fireEvent.click(providerSelect)
fireEvent.click(await screen.findByRole('option', { name: 'Ollama' }))
const modelSelect = (await screen.findAllByRole('combobox'))[1]
fireEvent.click(modelSelect)
fireEvent.click(await screen.findByRole('option', { name: 'qwen3:latest' }))
fireEvent.click(await screen.findByRole('button', { name: 'Apply' }))
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'qwen3:latest',
provider: 'local-ollama',
scope: 'main',
base_url: 'http://localhost:11434/v1'
})
)
})
it('writes the profile default speed (service_tier) as a sparse patch, never the cached snapshot', async () => {
// The cached record is a default-expanded snapshot; a CLI pin made after it
// loaded is not in it. Echoing the whole record back would reset that
// auxiliary slot to auto/'' (#95460) — only the edited key may be sent.
getHermesConfigRecord.mockResolvedValue({
agent: { reasoning_effort: 'medium', service_tier: 'normal' },
auxiliary: { curator: { provider: 'auto', model: '', reasoning_effort: 'high' } }
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() => expect(saveHermesConfig).toHaveBeenCalledWith({ agent: { 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()
// #97297 — the three canonical slots the backend serves must have rows too.
expect(screen.getByText('Triage specifier')).toBeTruthy()
expect(screen.getByText('Kanban decomposer')).toBeTruthy()
expect(screen.getByText('Profile describer')).toBeTruthy()
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
})
it('edits auxiliary reasoning effort below the selected model and applies it with the assignment', async () => {
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'nous', model: 'hermes-4', base_url: '', reasoning_effort: null }]
})
await renderModelSettings()
expect(screen.queryByRole('combobox', { name: 'Vision reasoning effort' })).toBeNull()
fireEvent.click((await screen.findAllByRole('button', { name: 'Change' }))[0])
const reasoningSelect = await screen.findByRole('combobox', { name: 'Vision reasoning effort' })
expect(reasoningSelect.compareDocumentPosition(await screen.findByRole('combobox', { name: 'Vision model' }))).toBe(
Node.DOCUMENT_POSITION_PRECEDING
)
fireEvent.click(reasoningSelect)
fireEvent.click(await screen.findByRole('option', { name: 'High' }))
const applyButtons = await screen.findAllByRole('button', { name: 'Apply' })
fireEvent.click(applyButtons.at(-1)!)
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'hermes-4',
provider: 'nous',
scope: 'auxiliary',
task: 'vision',
reasoning_effort: 'high'
})
)
})
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('carries the user-defined endpoint when an aux slot is set to a local main model', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [
{
name: 'Ollama',
slug: 'local-ollama',
models: ['qwen3:latest'],
authenticated: true,
is_user_defined: true,
api_url: 'http://localhost:11434/v1'
}
]
})
getGlobalModelInfo.mockResolvedValueOnce({ provider: 'local-ollama', model: 'qwen3:latest' })
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'local-ollama', model: 'qwen3:latest' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
await renderModelSettings()
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
fireEvent.click(setToMainButtons[0])
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'qwen3:latest',
provider: 'local-ollama',
scope: 'auxiliary',
task: 'vision',
base_url: 'http://localhost:11434/v1'
})
)
})
it('warns when a main switch leaves auxiliary tasks pinned to another provider', async () => {
setModelAssignment.mockResolvedValueOnce({
ok: true,
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()
})
it('does not warn when an aux slot uses the main alias', async () => {
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'main', model: 'kimi-k3', base_url: '' }]
})
await renderModelSettings()
await screen.findAllByRole('button', { name: 'Set to main' })
// 'main' is a backend-supported alias that tracks the active main provider
// (auxiliary_client._normalize_aux_provider) — it can never be a stale pin. #97310
expect(screen.queryByText(/still run on/)).toBeNull()
})
it('does not flag an aux slot pinned to a local/LAN endpoint and shows its base_url', async () => {
getAuxiliaryModels.mockResolvedValueOnce({
main: { provider: 'ollama-cloud', model: 'glm-5.3-flash' },
tasks: [
{
task: 'title_generation',
provider: 'openai',
model: 'llama3.2:3b',
base_url: 'http://byron.local:11434/v1',
local_endpoint: true
},
{
task: 'vision',
provider: 'openai',
model: 'gpt-4o-mini',
base_url: 'https://api.example.com/v1',
local_endpoint: false
}
]
})
await renderModelSettings()
// The public custom endpoint still bills a provider, so the banner stays —
// but it names only that one task, not the free LAN pin.
expect(await screen.findByText(/1 auxiliary task \(/)).toBeTruthy()
// The row shows where the pinned task actually points.
expect(screen.getByText(/http:\/\/byron\.local:11434\/v1/)).toBeTruthy()
})
})
describe('ModelSettings MoA preset editor', () => {
const moaConfig = () => ({
default_preset: 'default',
active_preset: '',
presets: {
default: {
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
enabled: true
}
},
reference_models: [
{ provider: 'nous', model: 'hermes-4' },
{ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }
],
aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },
reference_temperature: 0,
aggregator_temperature: 0,
enabled: true
})
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
{
name: 'OpenRouter',
slug: 'openrouter',
models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],
authenticated: true
}
]
})
getMoaModels.mockResolvedValue(moaConfig())
saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))
})
async function openReferenceEditor() {
await renderModelSettings()
expect(await screen.findByText('Reference 1')).toBeTruthy()
}
function slotSelects() {
// Combobox order in the MoA section (last 7 on the page): preset select,
// then provider+model per reference (2 refs), then aggregator
// provider+model. Reference 1's pair is therefore at -6 / -5.
const all = screen.getAllByRole('combobox')
return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }
}
it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
// Model was cleared by the provider change → config incomplete → the
// debounced autosave must NOT fire, even well past the 600ms window.
await vi.advanceTimersByTimeAsync(2000)
expect(saveMoaModels).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('saves once the model pick completes the slot', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))
await vi.advanceTimersByTimeAsync(700)
fireEvent.click(slotSelects().ref1Model)
fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledTimes(1)
const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>
expect(sent.presets.default.reference_models[0]).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
// The untouched slots ride along unchanged — nothing reverts to defaults.
expect(sent.presets.default.reference_models[1]).toEqual({
provider: 'openrouter',
model: 'deepseek/deepseek-v4-pro'
})
expect(sent.presets.default.aggregator).toEqual({
provider: 'openrouter',
model: 'anthropic/claude-opus-4.8'
})
} finally {
vi.useRealTimers()
}
})
it('does not clear the model or save when the same provider is re-selected', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(slotSelects().ref1Provider)
fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))
await vi.advanceTimersByTimeAsync(700)
// Radix treats re-picking the current value as a no-op (no
// onValueChange), so nothing changes: no save, model still shown.
expect(saveMoaModels).not.toHaveBeenCalled()
expect(screen.getByText('nous · hermes-4')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('autosaves the selected preset when its enabled switch is toggled', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(screen.getByRole('switch', { name: 'Enabled' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledWith(
expect.objectContaining({
presets: expect.objectContaining({
default: expect.objectContaining({ enabled: false })
})
})
)
} finally {
vi.useRealTimers()
}
})
it('saves a disabled reference model without removing it (per-slot enabled toggle)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
await openReferenceEditor()
fireEvent.click(screen.getByRole('switch', { name: 'Disable reference 1' }))
await vi.advanceTimersByTimeAsync(700)
expect(saveMoaModels).toHaveBeenCalledWith(
expect.objectContaining({
presets: expect.objectContaining({
default: expect.objectContaining({
reference_models: [
expect.objectContaining({ provider: 'nous', model: 'hermes-4', enabled: false }),
expect.objectContaining({ provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' })
]
})
})
})
)
} finally {
vi.useRealTimers()
}
})
})
describe('ModelSettings code-skew 503', () => {
const skewError = new Error(
'Error invoking remote method \'hermes:api\': Error: 503: {"detail":"Restart required: This process is running code from 08b4875f4a but the checkout on disk is now 48d2528066. The model picker would risk a stale-module crash — restart the Desktop-owned backend to load the new code (use Restart backend in Hermes Desktop, or quit and reopen the app)"}'
)
afterEach(() => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('unwraps the stale-backend 503 instead of dumping IPC JSON', async () => {
getGlobalModelOptions.mockRejectedValueOnce(skewError)
await renderModelSettings()
await waitFor(() => {
expect(screen.getByText(/running old code after an update/i)).toBeTruthy()
})
expect(screen.getByRole('button', { name: 'Restart backend' })).toBeTruthy()
expect(screen.queryByText(/hermes:api/)).toBeNull()
expect(screen.queryByText(/systemctl/)).toBeNull()
})
it('recycles the Desktop-owned backend and reloads the catalog', async () => {
const recycleBackend = vi.fn().mockResolvedValue({ ok: true })
;(window as unknown as { hermesDesktop: { recycleBackend: typeof recycleBackend } }).hermesDesktop = {
recycleBackend
}
getGlobalModelOptions.mockRejectedValueOnce(skewError)
await renderModelSettings()
await waitFor(() => expect(screen.getByRole('button', { name: 'Restart backend' })).toBeTruthy())
fireEvent.click(screen.getByRole('button', { name: 'Restart backend' }))
await waitFor(() => expect(recycleBackend).toHaveBeenCalledWith(undefined))
await waitFor(() => expect(getGlobalModelOptions.mock.calls.length).toBeGreaterThan(1))
})
})