{"repo":"NousResearch/hermes-agent","public_id":"53817","display_label":"MoA presets missing from desktop model picker","summary":"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.","subsystem":"apps/desktop","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55259},"resolution":{"state":"partially_resolved","by":"pr-55259,pr-60757"},"freshness":{"state":"verified","last_verified_at":"2026-07-10T06:49:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2942857142857144},"fix_edges":[{"issue_number":53817,"pr_number":55259,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":56974,"pr_number":60757,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":60722,"pr_number":60757,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61889,"pr_number":60757,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":53817,"title":"Desktop model picker does not show MoA/BeastMode from remote Linux gateway","url":null,"state":"closed"},{"kind":"issue","number":60722,"title":"MoA presets hidden from Desktop model picker after explicit_only filter","url":null,"state":"closed"},{"kind":"issue","number":61036,"title":"Desktop model picker hides MoA provider even when presets are configured — UX regression in v0.18.1+","url":null,"state":"closed"},{"kind":"issue","number":61423,"title":"bug: MoA presets missing from Desktop model picker in v0.18.2","url":null,"state":"closed"},{"kind":"issue","number":61889,"title":"[Bug]: MoA presets disappear from desktop model pickers after explicit_only change (#37a4cf900)","url":null,"state":"closed"},{"kind":"pr","number":55259,"title":"fix(desktop): load remote model options before session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"apps/desktop/src/app/shell/model-menu-panel.test.tsx","hunks":["@@ -14,35 +14,22 @@ beforeAll(() => {\n   Element.prototype.releasePointerCapture = vi.fn()\n })\n \n-const getMoaModels = vi.fn()\n const getGlobalModelOptions = vi.fn()\n \n vi.mock('@/hermes', () => ({\n-  getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),\n-  getMoaModels: (...args: unknown[]) => getMoaModels(...args)\n+  getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args)\n }))\n \n-function moaPreset() {\n-  return {\n-    aggregator: { provider: 'deepseek', model: 'deepseek-v4-pro' },\n-    aggregator_temperature: 0.7,\n-    enabled: true,\n-    max_tokens: 4096,\n-    reference_models: [{ provider: 'zai', model: 'glm-5.2' }],\n-    reference_temperature: 0.7\n-  }\n-}\n+// MoA presets now arrive as the catalog's virtual `moa` provider row (the same\n+// payload a remote gateway's model.options returns), not the /api/model/moa\n+// REST config.\n+const MOA_PROVIDER = { models: ['default', 'BeastMode'], name: 'Mixture of Agents', slug: 'moa' }\n \n beforeEach(() => {\n   $activeSessionId.set('runtime-1')\n   $currentModel.set('')\n   $currentProvider.set('')\n-  getGlobalModelOptions.mockResolvedValue({ providers: [] })\n-  getMoaModels.mockResolvedValue({\n-    default_preset: 'default',\n-    active_preset: 'default',\n-    presets: { default: moaPreset(), BeastMode: moaPreset() }\n-  })\n+  getGlobalModelOptions.mockResolvedValue({ providers: [MOA_PROVIDER] })\n })\n \n afterEach(() => {","@@ -89,4 +76,27 @@ describe('ModelMenuPanel MoA presets', () => {\n     const item = row.closest('[role=\"menuitem\"]') ?? row.parentElement\n     expect(item?.querySelector('.codicon-check')).not.toBeNull()\n   })\n+\n+  it('keeps the virtual moa provider out of the main model groups (presets section only)', async () => {\n+    renderPanel()\n+\n+    await findByText(document.body, 'MoA: BeastMode')\n+\n+    // The provider group header would read \"Mixture of Agents\"; the presets\n+    // section header reads \"MoA presets\". Only the latter should exist.\n+    expect(document.body.textContent).toContain('MoA presets')\n+    expect(document.body.textContent).not.toContain('Mixture of Agents')\n+  })\n+\n+  it('renders presets from the catalog even before a session exists', async () => {\n+    $activeSessionId.set('')\n+    const onSelectModel = renderPanel()\n+\n+    const row = await findByText(document.body, 'MoA: BeastMode')\n+    fireEvent.click(row)\n+\n+    // Pre-session picks are UI state shipped on the next session.create — the\n+    // row must not be disabled and must still route through onSelectModel.\n+    expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' })\n+  })\n })"]},{"file":"apps/desktop/src/app/shell/model-menu-panel.tsx","hunks":["@@ -16,8 +16,8 @@ import {\n } from '@/components/ui/dropdown-menu'\n import { Skeleton } from '@/components/ui/skeleton'\n import type { HermesGateway } from '@/hermes'\n-import { getGlobalModelOptions, getMoaModels } from '@/hermes'\n import { useI18n } from '@/i18n'\n+import { requestModelOptions } from '@/lib/model-options'\n import {\n   currentPickerSelection,\n   displayModelName,","@@ -42,7 +42,7 @@ import {\n   $currentProvider,\n   $currentReasoningEffort\n } from '@/store/session'\n-import type { MoaConfigResponse, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'\n+import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'\n \n import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'\n ","@@ -82,18 +82,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n \n   const modelOptions = useQuery({\n     queryKey: ['model-options', activeSessionId || 'global'],\n-    queryFn: (): Promise<ModelOptionsResponse> => {\n-      if (gateway && activeSessionId) {\n-        return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })\n-      }\n-\n-      return getGlobalModelOptions()\n-    }\n-  })\n-\n-  const moaOptions = useQuery({\n-    queryKey: ['moa-presets'],\n-    queryFn: (): Promise<MoaConfigResponse> => getMoaModels()\n+    // Gateway-first even with no session yet: a connected (possibly remote)\n+    // gateway owns the model catalog, including virtual providers like `moa`\n+    // that the local REST fallback can't know about (#53817).\n+    queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId: activeSessionId })\n   })\n \n   const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(","@@ -112,9 +104,22 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n \n   const providers = modelOptions.data?.providers\n \n+  // The catalog carries MoA presets as a virtual `moa` provider row. Render\n+  // them in their dedicated section below and keep the row out of the main\n+  // provider groups so presets don't show up twice.\n+  const moaPresets = useMemo(\n+    () => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [],\n+    [providers]\n+  )\n+\n+  const pickerProviders = useMemo(\n+    () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],\n+    [providers]\n+  )\n+\n   const effectiveVisibleModels = useMemo(\n-    () => effectiveVisibleKeys(visibleModels, providers ?? []),\n-    [visibleModels, providers]\n+    () => effectiveVisibleKeys(visibleModels, pickerProviders),\n+    [visibleModels, pickerProviders]\n   )\n \n   // The composer picker never persists the profile default. With a session it","@@ -136,13 +141,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n     try {\n       const queryKey = ['model-options', activeSessionId || 'global']\n \n-      const next =\n-        gateway && activeSessionId\n-          ? await gateway.request<ModelOptionsResponse>('model.options', {\n-              session_id: activeSessionId,\n-              refresh: true\n-            })\n-          : await getGlobalModelOptions({ refresh: true })\n+      const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId })\n \n       queryClient.setQueryData<ModelOptionsResponse>(queryKey, next)\n     } catch {","@@ -185,18 +184,20 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n   // Previously this dispatched the one-shot `/moa` command, which ran a single\n   // turn through MoA and then silently reverted to the prior model (#54670) —\n   // the dropdown presented presets like persistent selections but they weren't.\n+  // No session gate: like regular model rows, a pre-session pick is UI state\n+  // shipped on the next session.create.\n   const selectMoaPreset = async (preset: string) => {\n-    if (!activeSessionId) {\n+    if ((await switchTo(preset, 'moa')) === false) {\n       return\n     }\n \n-    await switchTo(preset, 'moa')\n+    closeMenu()\n   }\n \n   const groups = useMemo(\n     () =>\n-      groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),\n-    [providers, search, optionsModel, optionsProvider, effectiveVisibleModels]\n+      groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),\n+    [pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels]\n   )\n \n   return (","@@ -222,7 +223,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n         <DropdownMenuItem className={dropdownMenuRow} disabled>\n           {error}\n         </DropdownMenuItem>\n-      ) : groups.length === 0 ? (\n+      ) : groups.length === 0 && moaPresets.length === 0 ? (\n         <DropdownMenuItem className={dropdownMenuRow} disabled>\n           {copy.noModels}\n         </DropdownMenuItem>","@@ -322,16 +323,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model\n \n       <DropdownMenuSeparator className=\"mx-0\" />\n \n-      {moaOptions.data && Object.keys(moaOptions.data.presets ?? {}).length > 0 ? (\n+      {moaPresets.length > 0 ? (\n         <>\n           <DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>\n-          {Object.keys(moaOptions.data.presets).map(preset => {\n-            const isCurrentMoa = currentProvider === 'moa' && currentModel === preset\n+          {moaPresets.map(preset => {\n+            const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset\n \n             return (\n               <DropdownMenuItem\n                 className={dropdownMenuRow}\n-                disabled={!activeSessionId}\n                 key={`moa:${preset}`}\n                 onSelect={event => {\n                   event.preventDefault()"]},{"file":"apps/desktop/src/components/model-picker.tsx","hunks":["@@ -2,11 +2,11 @@ import { useQuery } from '@tanstack/react-query'\n import { useState } from 'react'\n \n import { useI18n } from '@/i18n'\n+import { requestModelOptions } from '@/lib/model-options'\n import { currentPickerSelection } from '@/lib/model-status-label'\n-import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'\n+import type { ModelOptionProvider, ModelPricing } from '@/types/hermes'\n \n import type { HermesGateway } from '../hermes'\n-import { getGlobalModelOptions } from '../hermes'\n import { cn } from '../lib/utils'\n import { startManualOnboarding } from '../store/onboarding'\n ","@@ -54,15 +54,7 @@ export function ModelPickerDialog({\n \n   const modelOptions = useQuery({\n     queryKey: ['model-options', sessionId || 'global'],\n-    queryFn: () => {\n-      if (gw && sessionId) {\n-        return gw.request<ModelOptionsResponse>('model.options', {\n-          session_id: sessionId\n-        })\n-      }\n-\n-      return getGlobalModelOptions()\n-    },\n+    queryFn: () => requestModelOptions({ gateway: gw, sessionId }),\n     enabled: open\n   })\n "]},{"file":"apps/desktop/src/lib/model-options.test.ts","hunks":["@@ -0,0 +1,49 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+import { getGlobalModelOptions } from '@/hermes'\n+\n+import { requestModelOptions } from './model-options'\n+\n+const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] }\n+\n+vi.mock('@/hermes', () => ({\n+  getGlobalModelOptions: vi.fn(() => Promise.resolve(globalOptions))\n+}))\n+\n+describe('requestModelOptions', () => {\n+  afterEach(() => {\n+    vi.clearAllMocks()\n+  })\n+\n+  it('uses the connected gateway even before a session exists', async () => {\n+    const gatewayPayload = { model: 'BeastMode', provider: 'moa', providers: [] }\n+\n+    const gateway = {\n+      request: vi.fn(() => Promise.resolve(gatewayPayload))\n+    }\n+\n+    await expect(requestModelOptions({ gateway: gateway as never, sessionId: null })).resolves.toBe(gatewayPayload)\n+\n+    expect(gateway.request).toHaveBeenCalledWith('model.options', {})\n+    expect(getGlobalModelOptions).not.toHaveBeenCalled()\n+  })\n+\n+  it('passes the active session id and refresh flag through the gateway', async () => {\n+    const gateway = {\n+      request: vi.fn(() => Promise.resolve(globalOptions))\n+    }\n+\n+    await requestModelOptions({ gateway: gateway as never, refresh: true, sessionId: 'session-1' })\n+\n+    expect(gateway.request).toHaveBeenCalledWith('model.options', {\n+      refresh: true,\n+      session_id: 'session-1'\n+    })\n+  })\n+\n+  it('falls back to REST when no gateway is connected', async () => {\n+    await requestModelOptions({ refresh: true })\n+\n+    expect(getGlobalModelOptions).toHaveBeenCalledWith({ refresh: true })\n+  })\n+})"]},{"file":"apps/desktop/src/lib/model-options.ts","hunks":["@@ -0,0 +1,25 @@\n+import { getGlobalModelOptions, type HermesGateway, type ModelOptionsResponse } from '@/hermes'\n+\n+interface ModelOptionsRequest {\n+  gateway?: HermesGateway\n+  refresh?: boolean\n+  sessionId?: null | string\n+}\n+\n+export function requestModelOptions({ gateway, refresh = false, sessionId }: ModelOptionsRequest): Promise<ModelOptionsResponse> {\n+  if (gateway) {\n+    const params: Record<string, unknown> = {}\n+\n+    if (sessionId) {\n+      params.session_id = sessionId\n+    }\n+\n+    if (refresh) {\n+      params.refresh = true\n+    }\n+\n+    return gateway.request<ModelOptionsResponse>('model.options', params)\n+  }\n+\n+  return getGlobalModelOptions(refresh ? { refresh: true } : undefined)\n+}\n"]}]},{"kind":"pr","number":60757,"title":"fix(desktop): keep configured MoA presets in model picker","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/inventory.py","tests/hermes_cli/test_inventory.py"],"fix_hunks":[{"file":"hermes_cli/inventory.py","hunks":["@@ -340,13 +340,61 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list\n         if slug == \"moa\":\n             # MoA is a virtual routing mode, not an independently configured\n             # provider. Hide it from explicit-only pickers unless it is the\n-            # current provider (handled above).\n+            # current provider (handled above) or the user explicitly wrote an\n+            # enabled MoA preset into config.yaml. Use raw config so the\n+            # DEFAULT_CONFIG preset does not make every desktop picker show MoA.\n+            if _raw_config_has_enabled_moa_preset():\n+                kept.append(row)\n             continue\n         if is_provider_explicitly_configured(slug):\n             kept.append(row)\n     return kept\n \n \n+def _raw_config_has_enabled_moa_preset() -> bool:\n+    \"\"\"Return True when the user's raw config explicitly enables MoA.\n+\n+    ``load_config()`` includes ``DEFAULT_CONFIG[\"moa\"].presets.default`` for\n+    everyone. Explicit-only model pickers must not treat that default as a user\n+    choice, but they should keep MoA visible once the user has saved at least\n+    one enabled preset (or an older flat MoA config) in their own config.yaml.\n+    \"\"\"\n+    try:\n+        from hermes_cli.config import read_raw_config\n+\n+        raw = read_raw_config()\n+    except Exception:\n+        return False\n+\n+    if not isinstance(raw, dict):\n+        return False\n+    moa = raw.get(\"moa\")\n+    if not isinstance(moa, dict):\n+        return False\n+\n+    presets = moa.get(\"presets\")\n+    if isinstance(presets, dict):\n+        for name, preset in presets.items():\n+            if not str(name or \"\").strip():\n+                continue\n+            if not isinstance(preset, dict):\n+                return True\n+            if preset.get(\"enabled\", True):\n+                return True\n+        return False\n+\n+    legacy_keys = {\n+        \"reference_models\",\n+        \"aggregator\",\n+        \"reference_temperature\",\n+        \"aggregator_temperature\",\n+        \"max_tokens\",\n+        \"reference_max_tokens\",\n+        \"fanout\",\n+    }\n+    return any(key in moa for key in legacy_keys) and bool(moa.get(\"enabled\", True))\n+\n+\n def _apply_picker_hints(rows: list[dict]) -> None:\n     \"\"\"Add ``authenticated``/``auth_type``/``key_env``/``warning`` per row.\n "]},{"file":"tests/hermes_cli/test_inventory.py","hunks":["@@ -402,6 +402,7 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_\n     ctx = _empty_ctx(provider=\"openai-codex\", model=\"gpt-5.4\")\n     with (\n         _list_auth_returning(rows),\n+        patch(\"hermes_cli.config.read_raw_config\", return_value={}),\n         patch(\n             \"hermes_cli.auth.is_provider_explicitly_configured\",\n             side_effect=lambda slug: slug == \"gemini\",","@@ -416,6 +417,43 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_\n     ]\n \n \n+def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset():\n+    rows = [\n+        {\"slug\": \"moa\", \"name\": \"MoA\", \"models\": [\"review\"],\n+         \"total_models\": 1, \"is_current\": False, \"is_user_defined\": False,\n+         \"source\": \"virtual\"},\n+    ]\n+    ctx = _empty_ctx(provider=\"openrouter\", model=\"anthropic/claude-opus-4.8\")\n+    raw_config = {\n+        \"moa\": {\n+            \"active_preset\": \"review\",\n+            \"presets\": {\n+                \"review\": {\n+                    \"enabled\": True,\n+                    \"reference_models\": [\n+                        {\"provider\": \"openai-codex\", \"model\": \"gpt-5.5\"},\n+                    ],\n+                    \"aggregator\": {\n+                        \"provider\": \"openrouter\",\n+                        \"model\": \"anthropic/claude-opus-4.8\",\n+                    },\n+                },\n+            },\n+        },\n+    }\n+\n+    with (\n+        _list_auth_returning(rows),\n+        patch(\"hermes_cli.config.load_config\", return_value=raw_config),\n+        patch(\"hermes_cli.config.read_raw_config\", return_value=raw_config),\n+        patch(\"hermes_cli.auth.is_provider_explicitly_configured\", return_value=False),\n+    ):\n+        payload = build_models_payload(ctx, explicit_only=True)\n+\n+    assert [row[\"slug\"] for row in payload[\"providers\"]] == [\"moa\"]\n+    assert payload[\"providers\"][0][\"models\"] == [\"review\"]\n+\n+\n # ─── picker_hints ──────────────────────────────────────────────────────\n \n \n"]}]},{"kind":"pr","number":61887,"title":"fix(moa): keep MoA presets in explicit-only desktop pickers (#61889)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"37a4cf900","number":0},{"kind":"pr","value":"#60757","number":60757},{"kind":"pr","value":"#60722","number":60722},{"kind":"pr","value":"#37","number":37},{"kind":"pr","value":"#61889","number":61889},{"kind":"pr","value":"#60722","number":60722}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}