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

0open issues
0open PRs
8items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #55259 fix(desktop): load remote model options before session

    merged to main

    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

    Show 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

    merged to main

    files: hermes_cli/inventory.py, tests/hermes_cli/test_inventory.py

    Show 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.00 means 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

At a glance

Subsystem
apps/desktop
Merged PRs
#55259, #60757
Closed issues
5
Attempts
1 PR closed without merging
Fix link checked
10 Jul 2026

For your agent

The same facts, typed.