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
786 lines
28 KiB
TypeScript
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))
|
|
})
|
|
})
|