diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx new file mode 100644 index 00000000000..9cfb0998340 --- /dev/null +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -0,0 +1,92 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, findByText, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' +import { $activeSessionId, $currentModel, $currentProvider } from '@/store/session' + +import { ModelMenuPanel } from './model-menu-panel' + +// Radix calls these on open; jsdom doesn't implement them. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +const getMoaModels = vi.fn() +const getGlobalModelOptions = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args), + getMoaModels: (...args: unknown[]) => getMoaModels(...args) +})) + +function moaPreset() { + return { + aggregator: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + aggregator_temperature: 0.7, + enabled: true, + max_tokens: 4096, + reference_models: [{ provider: 'zai', model: 'glm-5.2' }], + reference_temperature: 0.7 + } +} + +beforeEach(() => { + $activeSessionId.set('runtime-1') + $currentModel.set('') + $currentProvider.set('') + getGlobalModelOptions.mockResolvedValue({ providers: [] }) + getMoaModels.mockResolvedValue({ + default_preset: 'default', + active_preset: 'default', + presets: { default: moaPreset(), BeastMode: moaPreset() } + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +function renderPanel(onSelectModel = vi.fn()) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + + + + ) + + return onSelectModel +} + +describe('ModelMenuPanel MoA presets', () => { + it('selecting a MoA preset switches PERSISTENTLY via onSelectModel (not the one-shot dispatch)', async () => { + const onSelectModel = renderPanel() + + // moaOptions is async (useQuery) — wait for the preset row to mount. + const row = await findByText(document.body, 'MoA: BeastMode') + fireEvent.click(row) + + // #54670: must route through the persistent model-switch path + // (config.set model=" --provider moa"), i.e. onSelectModel with + // provider 'moa', NOT a one-shot command.dispatch that reverts after a turn. + expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' }) + }) + + it('shows the check on the preset that matches the current moa selection', async () => { + $currentProvider.set('moa') + $currentModel.set('BeastMode') + renderPanel() + + const row = await findByText(document.body, 'MoA: BeastMode') + // The check codicon renders as a sibling within the same row item. + const item = row.closest('[role="menuitem"]') ?? row.parentElement + expect(item?.querySelector('.codicon-check')).not.toBeNull() + }) +}) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index b26ecb64ade..1d1d620a067 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -69,7 +69,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const [search, setSearch] = useState('') const [refreshing, setRefreshing] = useState(false) const queryClient = useQueryClient() - const [activeMoaPreset, setActiveMoaPreset] = useState('') // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing // the parent to rebuild the menu content (which would close the dropdown). @@ -180,13 +179,18 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model ) } - const toggleMoaPreset = async (preset: string) => { + // Selecting a MoA preset switches the session to it PERSISTENTLY, using the + // same path real provider selections use (config.set model=" + // --provider moa" via onSelectModel → the gateway's persistent switch_model). + // Previously this dispatched the one-shot `/moa` command, which ran a single + // turn through MoA and then silently reverted to the prior model (#54670) — + // the dropdown presented presets like persistent selections but they weren't. + const selectMoaPreset = async (preset: string) => { if (!activeSessionId) { return } - await requestGateway('command.dispatch', { name: 'moa', arg: preset, session_id: activeSessionId }) - setActiveMoaPreset(current => (current === preset ? '' : preset)) + await switchTo(preset, 'moa') } const groups = useMemo( @@ -321,22 +325,26 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model {moaOptions.data && Object.keys(moaOptions.data.presets ?? {}).length > 0 ? ( <> MoA presets - {Object.keys(moaOptions.data.presets).map(preset => ( - { - event.preventDefault() - void toggleMoaPreset(preset) - }} - > - MoA: {preset} - {activeMoaPreset === preset ? ( - - ) : null} - - ))} + {Object.keys(moaOptions.data.presets).map(preset => { + const isCurrentMoa = currentProvider === 'moa' && currentModel === preset + + return ( + { + event.preventDefault() + void selectMoaPreset(preset) + }} + > + MoA: {preset} + {isCurrentMoa ? ( + + ) : null} + + ) + })} ) : null}