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}