MoA presets missing from desktop model picker
NousResearch/hermes-agent · cluster 53817 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #55259. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #55259. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #55259, #60757. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple bug reports and fixes addressing MoA/BeastMode presets being hidden from the desktop model picker, apparently related to an explicit_only filter change. Includes attempts to expose MoA presets as selectable virtual models and ensure remote model options load correctly.
Open issues0
Open PRs0
Fixes on record
-
#55259 fix(desktop): load remote model options before session
files:
apps/desktop/src/app/shell/model-menu-panel.test.tsx,apps/desktop/src/app/shell/model-menu-panel.tsx,apps/desktop/src/components/model-picker.tsx,apps/desktop/src/lib/model-options.test.ts,apps/desktop/src/lib/model-options.tsShow the diff
-
apps/desktop/src/app/shell/model-menu-panel.test.tsx@@ -14,35 +14,22 @@ beforeAll(() => { 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) + getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...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 - } -} +// MoA presets now arrive as the catalog's virtual `moa` provider row (the same +// payload a remote gateway's model.options returns), not the /api/model/moa +// REST config. +const MOA_PROVIDER = { models: ['default', 'BeastMode'], name: 'Mixture of Agents', slug: 'moa' } 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() } - }) + getGlobalModelOptions.mockResolvedValue({ providers: [MOA_PROVIDER] }) }) afterEach(() => { @@ -89,4 +76,27 @@ describe('ModelMenuPanel MoA presets', () => { const item = row.closest('[role="menuitem"]') ?? row.parentElement expect(item?.querySelector('.codicon-check')).not.toBeNull() }) + + it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => { + renderPanel() + + await findByText(document.body, 'MoA: BeastMode') + + // The provider group header would read "Mixture of Agents"; the presets + // section header reads "MoA presets". Only the latter should exist. + expect(document.body.textContent).toContain('MoA presets') + expect(document.body.textContent).not.toContain('Mixture of Agents') + }) + + it('renders presets from the catalog even before a session exists', async () => { + $activeSessionId.set('') + const onSelectModel = renderPanel() + + const row = await findByText(document.body, 'MoA: BeastMode') + fireEvent.click(row) + + // Pre-session picks are UI state shipped on the next session.create — the + // row must not be disabled and must still route through onSelectModel. + expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' }) + }) }) -
apps/desktop/src/app/shell/model-menu-panel.tsx@@ -16,8 +16,8 @@ import { } from '@/components/ui/dropdown-menu' import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' -import { getGlobalModelOptions, getMoaModels } from '@/hermes' import { useI18n } from '@/i18n' +import { requestModelOptions } from '@/lib/model-options' import { currentPickerSelection, displayModelName, @@ -42,7 +42,7 @@ import { $currentProvider, $currentReasoningEffort } from '@/store/session' -import type { MoaConfigResponse, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' +import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' @@ -82,18 +82,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const modelOptions = useQuery({ queryKey: ['model-options', activeSessionId || 'global'], - queryFn: (): Promise<ModelOptionsResponse> => { - if (gateway && activeSessionId) { - return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId }) - } - - return getGlobalModelOptions() - } - }) - - const moaOptions = useQuery({ - queryKey: ['moa-presets'], - queryFn: (): Promise<MoaConfigResponse> => getMoaModels() + // Gateway-first even with no session yet: a connected (possibly remote) + // gateway owns the model catalog, including virtual providers like `moa` + // that the local REST fallback can't know about (#53817). + queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId: activeSessionId }) }) const { model: optionsModel, provider: optionsProvider } = currentPickerSelection( @@ -112,9 +104,22 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const providers = modelOptions.data?.providers + // The catalog carries MoA presets as a virtual `moa` provider row. Render + // them in their dedicated section below and keep the row out of the main + // provider groups so presets don't show up twice. + const moaPresets = useMemo( + () => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [], + [providers] + ) + + const pickerProviders = useMemo( + () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], + [providers] + ) + const effectiveVisibleModels = useMemo( - () => effectiveVisibleKeys(visibleModels, providers ?? []), - [visibleModels, providers] + () => effectiveVisibleKeys(visibleModels, pickerProviders), + [visibleModels, pickerProviders] ) // The composer picker never persists the profile default. With a session it @@ -136,13 +141,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model try { const queryKey = ['model-options', activeSessionId || 'global'] - const next = - gateway && activeSessionId - ? await gateway.request<ModelOptionsResponse>('model.options', { - session_id: activeSessionId, - refresh: true - }) - : await getGlobalModelOptions({ refresh: true }) + const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId }) queryClient.setQueryData<ModelOptionsResponse>(queryKey, next) } catch { @@ -185,18 +184,20 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: 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. + // No session gate: like regular model rows, a pre-session pick is UI state + // shipped on the next session.create. const selectMoaPreset = async (preset: string) => { - if (!activeSessionId) { + if ((await switchTo(preset, 'moa')) === false) { return } - await switchTo(preset, 'moa') + closeMenu() } const groups = useMemo( () => - groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), - [providers, search, optionsModel, optionsProvider, effectiveVisibleModels] + groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), + [pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels] ) return ( @@ -222,7 +223,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model <DropdownMenuItem className={dropdownMenuRow} disabled> {error} </DropdownMenuItem> - ) : groups.length === 0 ? ( + ) : groups.length === 0 && moaPresets.length === 0 ? ( <DropdownMenuItem className={dropdownMenuRow} disabled> {copy.noModels} </DropdownMenuItem> @@ -322,16 +323,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model <DropdownMenuSeparator className="mx-0" /> - {moaOptions.data && Object.keys(moaOptions.data.presets ?? {}).length > 0 ? ( + {moaPresets.length > 0 ? ( <> <DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel> - {Object.keys(moaOptions.data.presets).map(preset => { - const isCurrentMoa = currentProvider === 'moa' && currentModel === preset + {moaPresets.map(preset => { + const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset return ( <DropdownMenuItem className={dropdownMenuRow} - disabled={!activeSessionId} key={`moa:${preset}`} onSelect={event => { event.preventDefault() -
apps/desktop/src/components/model-picker.tsx@@ -2,11 +2,11 @@ import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useI18n } from '@/i18n' +import { requestModelOptions } from '@/lib/model-options' import { currentPickerSelection } from '@/lib/model-status-label' -import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes' +import type { ModelOptionProvider, ModelPricing } from '@/types/hermes' import type { HermesGateway } from '../hermes' -import { getGlobalModelOptions } from '../hermes' import { cn } from '../lib/utils' import { startManualOnboarding } from '../store/onboarding' @@ -54,15 +54,7 @@ export function ModelPickerDialog({ const modelOptions = useQuery({ queryKey: ['model-options', sessionId || 'global'], - queryFn: () => { - if (gw && sessionId) { - return gw.request<ModelOptionsResponse>('model.options', { - session_id: sessionId - }) - } - - return getGlobalModelOptions() - }, + queryFn: () => requestModelOptions({ gateway: gw, sessionId }), enabled: open }) -
apps/desktop/src/lib/model-options.test.ts@@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getGlobalModelOptions } from '@/hermes' + +import { requestModelOptions } from './model-options' + +const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] } + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: vi.fn(() => Promise.resolve(globalOptions)) +})) + +describe('requestModelOptions', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('uses the connected gateway even before a session exists', async () => { + const gatewayPayload = { model: 'BeastMode', provider: 'moa', providers: [] } + + const gateway = { + request: vi.fn(() => Promise.resolve(gatewayPayload)) + } + + await expect(requestModelOptions({ gateway: gateway as never, sessionId: null })).resolves.toBe(gatewayPayload) + + expect(gateway.request).toHaveBeenCalledWith('model.options', {}) + expect(getGlobalModelOptions).not.toHaveBeenCalled() + }) + + it('passes the active session id and refresh flag through the gateway', async () => { + const gateway = { + request: vi.fn(() => Promise.resolve(globalOptions)) + } + + await requestModelOptions({ gateway: gateway as never, refresh: true, sessionId: 'session-1' }) + + expect(gateway.request).toHaveBeenCalledWith('model.options', { + refresh: true, + session_id: 'session-1' + }) + }) + + it('falls back to REST when no gateway is connected', async () => { + await requestModelOptions({ refresh: true }) + + expect(getGlobalModelOptions).toHaveBeenCalledWith({ refresh: true }) + }) +}) -
apps/desktop/src/lib/model-options.ts@@ -0,0 +1,25 @@ +import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes' + +interface ModelOptionsRequest { + gateway?: HermesGateway + refresh?: boolean + sessionId?: null | string +} + +export function requestModelOptions({ gateway, refresh = false, sessionId }: ModelOptionsRequest): Promise<ModelOptionsResponse> { + if (gateway) { + const params: Record<string, unknown> = {} + + if (sessionId) { + params.session_id = sessionId + } + + if (refresh) { + params.refresh = true + } + + return gateway.request<ModelOptionsResponse>('model.options', params) + } + + return getGlobalModelOptions(refresh ? { refresh: true } : undefined) +}
-
-
#60757 fix(desktop): keep configured MoA presets in model picker
files:
hermes_cli/inventory.py,tests/hermes_cli/test_inventory.pyShow the diff
-
hermes_cli/inventory.py@@ -340,13 +340,61 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list if slug == "moa": # MoA is a virtual routing mode, not an independently configured # provider. Hide it from explicit-only pickers unless it is the - # current provider (handled above). + # current provider (handled above) or the user explicitly wrote an + # enabled MoA preset into config.yaml. Use raw config so the + # DEFAULT_CONFIG preset does not make every desktop picker show MoA. + if _raw_config_has_enabled_moa_preset(): + kept.append(row) continue if is_provider_explicitly_configured(slug): kept.append(row) return kept +def _raw_config_has_enabled_moa_preset() -> bool: + """Return True when the user's raw config explicitly enables MoA. + + ``load_config()`` includes ``DEFAULT_CONFIG["moa"].presets.default`` for + everyone. Explicit-only model pickers must not treat that default as a user + choice, but they should keep MoA visible once the user has saved at least + one enabled preset (or an older flat MoA config) in their own config.yaml. + """ + try: + from hermes_cli.config import read_raw_config + + raw = read_raw_config() + except Exception: + return False + + if not isinstance(raw, dict): + return False + moa = raw.get("moa") + if not isinstance(moa, dict): + return False + + presets = moa.get("presets") + if isinstance(presets, dict): + for name, preset in presets.items(): + if not str(name or "").strip(): + continue + if not isinstance(preset, dict): + return True + if preset.get("enabled", True): + return True + return False + + legacy_keys = { + "reference_models", + "aggregator", + "reference_temperature", + "aggregator_temperature", + "max_tokens", + "reference_max_tokens", + "fanout", + } + return any(key in moa for key in legacy_keys) and bool(moa.get("enabled", True)) + + def _apply_picker_hints(rows: list[dict]) -> None: """Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row. -
tests/hermes_cli/test_inventory.py@@ -402,6 +402,7 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ ctx = _empty_ctx(provider="openai-codex", model="gpt-5.4") with ( _list_auth_returning(rows), + patch("hermes_cli.config.read_raw_config", return_value={}), patch( "hermes_cli.auth.is_provider_explicitly_configured", side_effect=lambda slug: slug == "gemini", @@ -416,6 +417,43 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ ] +def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset(): + rows = [ + {"slug": "moa", "name": "MoA", "models": ["review"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "virtual"}, + ] + ctx = _empty_ctx(provider="openrouter", model="anthropic/claude-opus-4.8") + raw_config = { + "moa": { + "active_preset": "review", + "presets": { + "review": { + "enabled": True, + "reference_models": [ + {"provider": "openai-codex", "model": "gpt-5.5"}, + ], + "aggregator": { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.8", + }, + }, + }, + }, + } + + with ( + _list_auth_returning(rows), + patch("hermes_cli.config.load_config", return_value=raw_config), + patch("hermes_cli.config.read_raw_config", return_value=raw_config), + patch("hermes_cli.auth.is_provider_explicitly_configured", return_value=False), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert [row["slug"] for row in payload["providers"]] == ["moa"] + assert payload["providers"][0]["models"] == ["review"] + + # ─── picker_hints ──────────────────────────────────────────────────────
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#61887 fix(moa): keep MoA presets in explicit-only desktop pickers (#61889) · superseded by another PR →
37a4cf9,#60757,#60722,#37,#61889
Closed issues · 5
- Closed#53817Desktop model picker does not show MoA/BeastMode from remote Linux gateway
- Closed#60722MoA presets hidden from Desktop model picker after explicit_only filter
- Closed#61036Desktop model picker hides MoA provider even when presets are configured — UX regression in v0.18.1+
- Closed#61423bug: MoA presets missing from Desktop model picker in v0.18.2
- Closed#61889[Bug]: MoA presets disappear from desktop model pickers after explicit_only change (#37a4cf900)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab