/** * `applyAdvancedConfig` — the advanced editor's save, which persists ONLY the * dirty sections and reports per-section outcomes back to the dialog. * * Two contracts live here: * * - **Inherit clears the pin.** A dirty model section with both fields empty * means "inherit the gateway default", which is a `config unset model`, not * a configure payload. Its success/failure has to merge into the same * `applied` map as the RPC sections, or the dialog toasts a contradictory * "saved" over a failed clear. * - **#95293 on this surface.** The gateway now guards data-policy / * expensive models for `profiles.configure` too, answering * `confirm_required` + `confirm_message`. Bots does NOT route through * use-model-controls, so before the fix that response matched no branch and * the pick silently dropped. It must go through the SAME shared handler the * core picker uses (one applier, no forked confirm UI), must not count as a * FAILED section while it is merely pending, and Confirm must resend ONLY * the model section with `confirm_expensive_model: true`. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { applyAdvancedConfig, emptyAdvancedState } from './profile-config' import type { RosterRow } from './types' type AdvancedConfigState = ReturnType /** The payload the shared core confirm flow receives — the same object the * model picker hands it. */ interface ModelSwitchConfirmArgs { confirmMessage: string finish: () => void requestConfirmed: () => Promise<{ applied?: Record } | undefined> } const { confirmMock, hostMock, invalidateMock } = vi.hoisted(() => ({ confirmMock: vi.fn((_args: ModelSwitchConfirmArgs) => 'notification-1'), hostMock: { getGateway: () => 'ambient-gateway', request: vi.fn(), requestProfile: vi.fn(), state: { connectionId: { get: () => 'local' }, gateway: { get: () => 'open' }, profile: { get: () => 'default' } } }, invalidateMock: vi.fn() })) vi.mock('@hermes/plugin-sdk', async () => { const { atom } = await import('nanostores') return { atom, Button: () => null, Checkbox: () => null, GlyphSpinner: () => null, host: hostMock, Input: () => null, McpTab: undefined, queryClient: { invalidateQueries: invalidateMock }, ScrollArea: () => null, Select: () => null, SelectContent: () => null, SelectItem: () => null, SelectTrigger: () => null, SelectValue: () => null, SkillsView: undefined, surfaceModelSwitchConfirm: confirmMock, Textarea: () => null, ToolsetConfigPanel: undefined, useQuery: vi.fn(() => ({ data: undefined, error: null, isLoading: false })), useValue: vi.fn() } }) vi.mock('./shared', () => ({ getPluginCtx: () => null, ID: 'hermes-bots' })) // The SOUL protocol append has its own suite; here it must not rewrite the // text under the assertions. vi.mock('./soul', () => ({ ensureMessagingProtocol: (soul: string) => soul })) const bot = { name: 'zeta' } as RosterRow const dirtyModel = (patch: Partial = {}): AdvancedConfigState => ({ ...emptyAdvancedState(), dirtyModel: true, loaded: true, model: 'muse-spark-1.2-contributor', provider: 'opencode-go', ...patch }) /** Every RPC in order, with params frozen at call time. */ const routed: Array<{ method: string; params: Record }> = [] function respondWith(handler: (method: string, params: Record) => unknown) { hostMock.request.mockImplementation(async (method: string, params: Record) => { routed.push({ method, params: structuredClone(params ?? {}) }) return handler(method, params) }) } beforeEach(() => { vi.clearAllMocks() routed.length = 0 respondWith(() => ({})) }) describe('selecting Inherit', () => { it('clears the profile model assignment through the CLI', async () => { respondWith(() => ({ blocked: false, code: 0, output: 'Unset model' })) const result = await applyAdvancedConfig(bot, dirtyModel({ model: '', provider: '' })) expect(routed).toEqual([ { method: 'cli.exec', params: { argv: ['--profile', 'zeta', 'config', 'unset', 'model'] } } ]) expect(result).toEqual({ applied: { model: true }, ok: true }) }) it('merges the clear with the other dirty sections’ outcomes', async () => { respondWith(method => (method === 'cli.exec' ? { blocked: false, code: 0 } : { applied: { soul: true } })) const result = await applyAdvancedConfig( bot, dirtyModel({ dirtySoul: true, model: '', provider: '', soul: '# Ops' }) ) expect(routed.map(call => call.method)).toEqual(['cli.exec', 'profiles.configure']) expect(routed[1].params).toEqual({ name: 'zeta', soul: '# Ops' }) expect(result).toMatchObject({ applied: { model: true, soul: true }, ok: true }) }) it('reports a rejected clear as a failed section', async () => { respondWith(() => ({ blocked: false, code: 1, output: 'Config key not set' })) await expect(applyAdvancedConfig(bot, dirtyModel({ model: '', provider: '' }))).resolves.toEqual({ applied: { model: false }, ok: false }) }) it('treats a thrown clear the same as a rejected one', async () => { hostMock.request.mockRejectedValue(new Error('gateway down')) await expect(applyAdvancedConfig(bot, dirtyModel({ model: '', provider: '' }))).resolves.toEqual({ applied: { model: false }, ok: false }) }) it('refuses a half-filled model section outright', async () => { // A provider with no model (or the reverse) is neither a pin nor a clear. await expect(applyAdvancedConfig(bot, dirtyModel({ model: '' }))).resolves.toEqual({ applied: { model: false }, ok: false }) expect(routed).toHaveLength(0) }) }) describe('a guarded model switch (#95293)', () => { it('surfaces the SHARED confirm flow instead of silently dropping the pick', async () => { respondWith(() => ({ applied: {}, confirm_message: 'CONTRIBUTOR TIER: this model may train on your data.', confirm_required: true, ok: true })) const result = await applyAdvancedConfig(bot, dirtyModel()) expect(routed).toHaveLength(1) expect(routed[0].method).toBe('profiles.configure') expect(routed[0].params.model).toBe('muse-spark-1.2-contributor') expect(routed[0].params.confirm_expensive_model).toBeFalsy() expect(confirmMock).toHaveBeenCalledTimes(1) expect(confirmMock.mock.calls[0][0].confirmMessage).toMatch(/CONTRIBUTOR TIER/) // Pending confirmation is NOT a failed section — the editor must not toast // "Some sections failed: model" while the confirm toast is still up. expect(result.applied?.model).not.toBe(false) expect(result.ok).toBe(true) }) it('resends ONLY the model section on Confirm', async () => { let calls = 0 respondWith(() => { calls += 1 return calls === 1 ? { applied: {}, confirm_message: 'guarded', confirm_required: true, ok: true } : { applied: { model: true }, ok: true } }) await applyAdvancedConfig(bot, dirtyModel({ dirtySoul: true, soul: '# Zeta' })) const confirmed = await confirmMock.mock.calls[0][0].requestConfirmed() expect(routed).toHaveLength(2) expect(routed[1].method).toBe('profiles.configure') expect(Object.keys(routed[1].params).sort()).toEqual(['confirm_expensive_model', 'model', 'name', 'provider']) expect(routed[1].params).toMatchObject({ confirm_expensive_model: true, model: 'muse-spark-1.2-contributor', name: 'zeta', provider: 'opencode-go' }) expect(confirmed?.applied?.model).toBe(true) }) it('refreshes the roster once the confirmed switch lands', async () => { respondWith(() => ({ applied: {}, confirm_message: 'guarded', confirm_required: true, ok: true })) await applyAdvancedConfig(bot, dirtyModel()) confirmMock.mock.calls[0][0].finish() expect(invalidateMock).toHaveBeenCalled() }) it('leaves an unguarded save single-shot', async () => { respondWith(() => ({ applied: { model: true }, ok: true })) const result = await applyAdvancedConfig(bot, dirtyModel()) expect(routed).toHaveLength(1) expect(confirmMock).not.toHaveBeenCalled() expect(result).toMatchObject({ applied: { model: true }, ok: true }) }) })