# NousResearch/hermes-agent · MoA presets missing from desktop model picker

## Open now

- 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.

## TL;DR

- 8 member(s): 0 open issue(s), 5 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #55259. Verify it addresses your case.
- **Recommended:** read PR #55259 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-55259,pr-60757`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #53817: Desktop model picker does not show MoA/BeastMode from remote Linux gateway
- #60722: MoA presets hidden from Desktop model picker after explicit_only filter
- #61036: Desktop model picker hides MoA provider even when presets are configured — UX regression in v0.18.1+
- #61423: bug: MoA presets missing from Desktop model picker in v0.18.2
- #61889: \[Bug\]: MoA presets disappear from desktop model pickers after explicit_only change (#37a4cf900)

## Prior & competing PRs

- **#55259: fix(desktop): load remote model options before session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.ts`
  - fix hunks:
  - `apps/desktop/src/app/shell/model-menu-panel.test.tsx`

    ```diff
    @@ -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(() => {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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 (
    ```

    ```diff
    @@ -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>
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/inventory.py`, `tests/hermes_cli/test_inventory.py`
  - fix hunks:
  - `hermes_cli/inventory.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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 ──────────────────────────────────────────────────────
     
     
    ```
- **#61887: fix(moa): keep MoA presets in explicit-only desktop pickers (#61889)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `37a4cf900`, `#60757`, `#60722`, `#37`, `#61889`, `#60722`

## Fix status

- Merged to main in PR #55259 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-10`.
- Subsystem: `apps/desktop`
