{"repo":"NousResearch/hermes-agent","public_id":"63658","display_label":"MoA preset autosave corrupts settings with incomplete slots","summary":"Multiple bug reports and fixes address a problem where the MoA preset editor autosaves incomplete slot configurations, causing settings corruption or unexpected defaults. Fixes target both the desktop autosave behavior and API-level validation of incomplete MoA saves.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64976},"resolution":{"state":"partially_resolved","by":"pr-64976"},"freshness":{"state":"verified","last_verified_at":"2026-07-15T16:50:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33866666666666667},"fix_edges":[{"issue_number":63658,"pr_number":64976,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":64156,"pr_number":64976,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":63658,"title":"[Bug]: MoA reference disappears when changing provider because autosave persists an incomplete slot","url":null,"state":"closed"},{"kind":"issue","number":64156,"title":"[Bug]: MoA preset editor — changing reference provider triggers defaults explosion from half-filled autosave","url":null,"state":"closed"},{"kind":"issue","number":64385,"title":"[Bug]: Desktop MoA incomplete-slot autosave corrupts settings; hermes update on Windows wipes local fix and reintroduces bug","url":null,"state":"closed"},{"kind":"pr","number":63867,"title":"fix(desktop): preserve incomplete MoA edits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64976","number":64976},{"kind":"pr","value":"#64158","number":64158},{"kind":"pr","value":"#64976","number":64976},{"kind":"pr","value":"#63658","number":63658}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":64158,"title":"fix(desktop): prevent MoA autosave defaults explosion from half-filled slots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a61a0bc01","number":0},{"kind":"pr","value":"#64976","number":64976}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64976,"title":"fix(moa): reject half-filled MoA saves at the API boundary; hold desktop autosave until slots complete","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/settings/model-settings.test.tsx","apps/desktop/src/app/settings/model-settings.tsx","apps/desktop/src/types/hermes.ts","hermes_cli/moa_config.py","hermes_cli/web_server.py","tests/hermes_cli/test_moa_config.py","tests/hermes_cli/test_web_server.py","web/src/lib/api.ts"],"fix_hunks":[{"file":"apps/desktop/src/app/settings/model-settings.test.tsx","hunks":["@@ -186,3 +186,139 @@ describe('ModelSettings', () => {\n     expect(await screen.findByText(/still run on/)).toBeTruthy()\n   })\n })\n+\n+describe('ModelSettings MoA preset editor', () => {\n+  const moaConfig = () => ({\n+    default_preset: 'default',\n+    active_preset: '',\n+    presets: {\n+      default: {\n+        reference_models: [\n+          { provider: 'nous', model: 'hermes-4' },\n+          { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }\n+        ],\n+        aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },\n+        reference_temperature: 0,\n+        aggregator_temperature: 0,\n+        max_tokens: 4096,\n+        enabled: true\n+      }\n+    },\n+    reference_models: [\n+      { provider: 'nous', model: 'hermes-4' },\n+      { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' }\n+    ],\n+    aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' },\n+    reference_temperature: 0,\n+    aggregator_temperature: 0,\n+    max_tokens: 4096,\n+    enabled: true\n+  })\n+\n+  beforeEach(() => {\n+    getGlobalModelOptions.mockResolvedValue({\n+      providers: [\n+        {\n+          name: 'Nous',\n+          slug: 'nous',\n+          models: ['hermes-4', 'hermes-4-mini'],\n+          authenticated: true,\n+          capabilities: { 'hermes-4': { reasoning: true, fast: true } }\n+        },\n+        {\n+          name: 'OpenRouter',\n+          slug: 'openrouter',\n+          models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'],\n+          authenticated: true\n+        }\n+      ]\n+    })\n+    getMoaModels.mockResolvedValue(moaConfig())\n+    saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body))\n+  })\n+\n+  async function openReferenceEditor() {\n+    await renderModelSettings()\n+    expect(await screen.findByText('Reference 1')).toBeTruthy()\n+  }\n+\n+  function slotSelects() {\n+    // Combobox order in the MoA section (last 7 on the page): preset select,\n+    // then provider+model per reference (2 refs), then aggregator\n+    // provider+model. Reference 1's pair is therefore at -6 / -5.\n+    const all = screen.getAllByRole('combobox')\n+\n+    return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! }\n+  }\n+\n+  it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => {\n+    vi.useFakeTimers({ shouldAdvanceTime: true })\n+\n+    try {\n+      await openReferenceEditor()\n+\n+      fireEvent.click(slotSelects().ref1Provider)\n+      fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))\n+\n+      // Model was cleared by the provider change → config incomplete → the\n+      // debounced autosave must NOT fire, even well past the 600ms window.\n+      await vi.advanceTimersByTimeAsync(2000)\n+      expect(saveMoaModels).not.toHaveBeenCalled()\n+    } finally {\n+      vi.useRealTimers()\n+    }\n+  })\n+\n+  it('saves once the model pick completes the slot', async () => {\n+    vi.useFakeTimers({ shouldAdvanceTime: true })\n+\n+    try {\n+      await openReferenceEditor()\n+\n+      fireEvent.click(slotSelects().ref1Provider)\n+      fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' }))\n+      await vi.advanceTimersByTimeAsync(700)\n+\n+      fireEvent.click(slotSelects().ref1Model)\n+      fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' }))\n+      await vi.advanceTimersByTimeAsync(700)\n+\n+      expect(saveMoaModels).toHaveBeenCalledTimes(1)\n+      const sent = saveMoaModels.mock.calls[0][0] as ReturnType<typeof moaConfig>\n+      expect(sent.presets.default.reference_models[0]).toEqual({\n+        provider: 'openrouter',\n+        model: 'anthropic/claude-opus-4.8'\n+      })\n+      // The untouched slots ride along unchanged — nothing reverts to defaults.\n+      expect(sent.presets.default.reference_models[1]).toEqual({\n+        provider: 'openrouter',\n+        model: 'deepseek/deepseek-v4-pro'\n+      })\n+      expect(sent.presets.default.aggregator).toEqual({\n+        provider: 'openrouter',\n+        model: 'anthropic/claude-opus-4.8'\n+      })\n+    } finally {\n+      vi.useRealTimers()\n+    }\n+  })\n+\n+  it('does not clear the model or save when the same provider is re-selected', async () => {\n+    vi.useFakeTimers({ shouldAdvanceTime: true })\n+\n+    try {\n+      await openReferenceEditor()\n+\n+      fireEvent.click(slotSelects().ref1Provider)\n+      fireEvent.click(await screen.findByRole('option', { name: 'Nous' }))\n+      await vi.advanceTimersByTimeAsync(700)\n+\n+      // Radix treats re-picking the current value as a no-op (no\n+      // onValueChange), so nothing changes: no save, model still shown.\n+      expect(saveMoaModels).not.toHaveBeenCalled()\n+      expect(screen.getByText('nous · hermes-4')).toBeTruthy()\n+    } finally {\n+      vi.useRealTimers()\n+    }\n+  })\n+})"]},{"file":"apps/desktop/src/app/settings/model-settings.tsx","hunks":["@@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m\n export const withActive = (models: readonly string[], active: string): readonly string[] =>\n   active && !models.includes(active) ? [active, ...models] : models\n \n+// A slot is complete when both halves are chosen. Changing a slot's provider\n+// intentionally clears its model (see updateMoaSlot), so every provider change\n+// passes through an incomplete state while the user picks the new model.\n+export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim())\n+\n+// True when every slot in every preset is fully specified — the only state\n+// that is safe to persist. The backend rejects configs with half-filled slots\n+// (HTTP 422) instead of silently swapping the preset for hardcoded defaults\n+// (#64156), so the autosave must simply wait for the edit to finish rather\n+// than trying to \"repair\" the payload.\n+export const moaConfigComplete = (config: MoaConfigResponse): boolean =>\n+  Object.values(config.presets).every(\n+    preset =>\n+      preset.reference_models.length > 0 &&\n+      preset.reference_models.every(moaSlotComplete) &&\n+      moaSlotComplete(preset.aggregator)\n+  )\n+\n interface StaleAuxWarningProps {\n   applying: boolean\n   onReset: () => void","@@ -317,19 +335,44 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {\n     []\n   )\n \n+  // Guard against stale save responses overwriting newer state.\n+  const moaSaveGeneration = useRef(0)\n+\n   // Quiet debounced persist for inline MoA edits — mirrors the config page's\n   // autosave so slot/aggregator tweaks save themselves, matching the\n   // preset-level ops (set default / add / delete) that already persist on\n   // click. No `applying` spinner, so selecting stays responsive.\n+  //\n+  // While any slot is half-filled (provider picked, model pending) the save is\n+  // HELD, not sent: the previous complete config stays on disk and the next\n+  // edit that completes the slot flushes the whole preset. Every edit bumps\n+  // the generation so an in-flight response from an older save can never\n+  // repaint over the user's mid-edit state.\n   const scheduleMoaSave = useCallback((next: MoaConfigResponse) => {\n     if (moaSaveTimer.current) {\n       window.clearTimeout(moaSaveTimer.current)\n+      moaSaveTimer.current = null\n+    }\n+\n+    const generation = moaSaveGeneration.current + 1\n+    moaSaveGeneration.current = generation\n+\n+    if (!moaConfigComplete(next)) {\n+      return\n     }\n \n     moaSaveTimer.current = window.setTimeout(() => {\n       void saveMoaModels(next)\n-        .then(setMoa)\n-        .catch(err => setError(err instanceof Error ? err.message : String(err)))\n+        .then(saved => {\n+          if (moaSaveGeneration.current === generation) {\n+            setMoa(saved)\n+          }\n+        })\n+        .catch(err => {\n+          if (moaSaveGeneration.current === generation) {\n+            setError(err instanceof Error ? err.message : String(err))\n+          }\n+        })\n     }, 600)\n   }, [])\n ","@@ -359,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {\n   const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): MoaModelSlot => {\n     const next = { ...slot, ...patch }\n \n-    if (patch.provider) {\n+    // Picking a new provider invalidates the model choice (models are\n+    // per-provider). A same-provider update must not wipe the model — Radix\n+    // filters same-value changes, but programmatic callers may not.\n+    if (patch.provider && patch.provider !== slot.provider) {\n       next.model = ''\n     }\n ","@@ -368,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {\n \n   const saveMoa = useCallback(async (next: MoaConfigResponse) => {\n     const epoch = profileEpoch.current\n+\n+    // Explicit preset ops (set default / add / delete) supersede any pending\n+    // debounced slot autosave — cancel it and invalidate in-flight responses\n+    // so the two writers can't race each other's state.\n+    if (moaSaveTimer.current) {\n+      window.clearTimeout(moaSaveTimer.current)\n+      moaSaveTimer.current = null\n+    }\n+\n+    moaSaveGeneration.current += 1\n     setApplying(true)\n     setError('')\n ","@@ -991,11 +1047,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {\n                         <SelectValue placeholder={m.provider} />\n                       </SelectTrigger>\n                       <SelectContent>\n-                        {moaSlotProviderOptions.map(provider => (\n-                          <SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>\n-                            {provider.name}\n-                          </SelectItem>\n-                        ))}\n+                        {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {\n+                          const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)\n+\n+                          return (\n+                            <SelectItem key={slug} value={slug}>\n+                              {provider?.name || slug}\n+                            </SelectItem>\n+                          )\n+                        })}\n                       </SelectContent>\n                     </Select>\n                     <Select","@@ -1037,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {\n                 }\n                 description={\n                   <span className=\"font-mono text-[0.68rem]\">\n-                    {slot.provider} · {slot.model}\n+                    {slot.provider} · {slot.model || m.model}\n                   </span>\n                 }\n-                key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`}\n+                key={`${selectedMoaPreset}-${index}`}\n                 title={`Reference ${index + 1}`}\n               />\n             ))}","@@ -1070,11 +1130,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {\n                       <SelectValue placeholder={m.provider} />\n                     </SelectTrigger>\n                     <SelectContent>\n-                      {moaSlotProviderOptions.map(provider => (\n-                        <SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>\n-                          {provider.name}\n-                        </SelectItem>\n-                      ))}\n+                      {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {\n+                        const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)\n+\n+                        return (\n+                          <SelectItem key={slug} value={slug}>\n+                            {provider?.name || slug}\n+                          </SelectItem>\n+                        )\n+                      })}\n                     </SelectContent>\n                   </Select>\n                   <Select"]},{"file":"apps/desktop/src/types/hermes.ts","hunks":["@@ -857,6 +857,8 @@ export interface AuxiliaryModelsResponse {\n export interface MoaModelSlot {\n   provider: string\n   model: string\n+  /** Optional per-slot reasoning effort — round-tripped, not edited here. */\n+  reasoning_effort?: string\n }\n \n export interface MoaConfigResponse {","@@ -871,6 +873,10 @@ export interface MoaConfigResponse {\n       max_tokens: number\n       reference_models: MoaModelSlot[]\n       reference_temperature: number\n+      /** Optional advisor output cap — round-tripped, not edited here. */\n+      reference_max_tokens?: number | null\n+      /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */\n+      fanout?: string\n     }\n   >\n   aggregator: MoaModelSlot"]},{"file":"hermes_cli/moa_config.py","hunks":["@@ -110,6 +110,77 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None:\n     return clean\n \n \n+def _slot_problem(slot: Any) -> str | None:\n+    \"\"\"Return a human-readable problem for a slot ``_clean_slot`` would drop.\n+\n+    None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly\n+    so the write-boundary validator (``validate_moa_payload``) and the\n+    tolerant runtime normalizer can never disagree about what is acceptable.\n+    \"\"\"\n+    if not isinstance(slot, dict):\n+        return \"must be an object with 'provider' and 'model'\"\n+    provider = str(slot.get(\"provider\") or \"\").strip()\n+    model = str(slot.get(\"model\") or \"\").strip()\n+    if not provider and not model:\n+        return \"provider and model are required\"\n+    if not provider:\n+        return \"provider is required\"\n+    if not model:\n+        return f\"model is required (provider '{provider}' has no model selected)\"\n+    if provider.lower() == \"moa\":\n+        return \"the Mixture of Agents provider cannot be used inside a preset (recursive MoA)\"\n+    return None\n+\n+\n+def validate_moa_payload(raw: Any) -> list[str]:\n+    \"\"\"Return the problems ``normalize_moa_config`` would silently paper over.\n+\n+    ``normalize_moa_config`` is deliberately tolerant: at *read* time a\n+    hand-edited config must degrade to defaults rather than crash the agent.\n+    That same tolerance at *write* time is a corruption engine — a client that\n+    sends a half-filled slot gets its whole preset silently replaced with the\n+    hardcoded defaults (#64156). API write paths call this first and reject\n+    invalid payloads loudly instead of saving something the user never chose.\n+\n+    Returns a list of human-readable problems; empty means safe to save.\n+    \"\"\"\n+    if not isinstance(raw, dict):\n+        return [\"MoA config must be an object\"]\n+\n+    presets_raw = raw.get(\"presets\")\n+    if isinstance(presets_raw, dict) and presets_raw:\n+        presets: dict[Any, Any] = presets_raw\n+    else:\n+        # Legacy flat payload: the top-level object is the default preset.\n+        presets = {DEFAULT_MOA_PRESET_NAME: raw}\n+\n+    problems: list[str] = []\n+    for name, preset in presets.items():\n+        label = str(name or \"\").strip() or \"(unnamed)\"\n+        if not isinstance(preset, dict):\n+            problems.append(f\"preset '{label}': must be an object\")\n+            continue\n+\n+        refs = preset.get(\"reference_models\")\n+        if not isinstance(refs, list):\n+            refs = [refs] if isinstance(refs, dict) else []\n+        complete_refs = 0\n+        for index, slot in enumerate(refs):\n+            issue = _slot_problem(slot)\n+            if issue:\n+                problems.append(f\"preset '{label}' reference {index + 1}: {issue}\")\n+            else:\n+                complete_refs += 1\n+        if not complete_refs:\n+            problems.append(f\"preset '{label}': needs at least one complete reference model\")\n+\n+        agg_issue = _slot_problem(preset.get(\"aggregator\"))\n+        if agg_issue:\n+            problems.append(f\"preset '{label}' aggregator: {agg_issue}\")\n+\n+    return problems\n+\n+\n def _default_preset() -> dict[str, Any]:\n     return {\n         \"reference_models\": deepcopy(DEFAULT_MOA_REFERENCE_MODELS),"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -996,6 +996,9 @@ class ModelAssignment(BaseModel):\n class MoaModelSlot(BaseModel):\n     provider: str = \"\"\n     model: str = \"\"\n+    # Optional per-slot reasoning effort. Declared so a client round-tripping\n+    # the GET payload doesn't have it stripped at parse time and wiped on save.\n+    reasoning_effort: Optional[str] = None\n \n \n class MoaPresetPayload(BaseModel):","@@ -1006,6 +1009,11 @@ class MoaPresetPayload(BaseModel):\n     reference_temperature: Optional[float] = None\n     aggregator_temperature: Optional[float] = None\n     max_tokens: int = 4096\n+    # Newer per-preset knobs (see moa_config._normalize_preset). Optional so\n+    # older clients that never send them keep working; declared so clients\n+    # that round-trip the GET payload don't silently erase hand-set values.\n+    reference_max_tokens: [redacted:secret-assignment] = None\n+    fanout: Optional[str] = None\n     enabled: bool = True\n \n ","@@ -1020,6 +1028,8 @@ class MoaConfigPayload(BaseModel):\n     reference_temperature: Optional[float] = None\n     aggregator_temperature: Optional[float] = None\n     max_tokens: int = 4096\n+    reference_max_tokens: [redacted:secret-assignment] = None\n+    fanout: Optional[str] = None\n     enabled: bool = True\n     profile: Optional[str] = None\n ","@@ -5701,7 +5711,23 @@ def get_moa_models(profile: Optional[str] = None):\n def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):\n     \"\"\"Persist the Mixture-of-Agents provider/model slots.\"\"\"\n     try:\n-        from hermes_cli.moa_config import normalize_moa_config\n+        from hermes_cli.moa_config import normalize_moa_config, validate_moa_payload\n+\n+        def _slot_dict(slot: MoaModelSlot) -> dict:\n+            # Drop unset optionals so saved slots stay minimal ({provider, model}).\n+            return {k: v for k, v in slot.dict().items() if v is not None}\n+\n+        def _preset_dict(preset: MoaPresetPayload) -> dict:\n+            return {\n+                \"reference_models\": [_slot_dict(slot) for slot in preset.reference_models],\n+                \"aggregator\": _slot_dict(preset.aggregator),\n+                \"reference_temperature\": preset.reference_temperature,\n+                \"aggregator_temperature\": preset.aggregator_temperature,\n+                \"max_tokens\": preset.max_tokens,\n+                \"reference_max_tokens\": preset.reference_max_tokens,\n+                \"fanout\": preset.fanout,\n+                \"enabled\": preset.enabled,\n+            }\n \n         with _profile_scope(body.profile or profile):\n             cfg = load_config()","@@ -5709,27 +5735,35 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None):\n                 raw = {\n                     \"default_preset\": body.default_preset,\n                     \"active_preset\": body.active_preset,\n-                    \"presets\": {\n-                        name: {\n-                            \"reference_models\": [slot.dict() for slot in preset.reference_models],\n-                            \"aggregator\": preset.aggregator.dict(),\n-                            \"reference_temperature\": preset.reference_temperature,\n-                            \"aggregator_temperature\": preset.aggregator_temperature,\n-                            \"max_tokens\": preset.max_tokens,\n-                            \"enabled\": preset.enabled,\n-                        }\n-                        for name, preset in body.presets.items()\n-                    },\n+                    \"presets\": {name: _preset_dict(preset) for name, preset in body.presets.items()},\n                 }\n             else:\n-                raw = {\n-                    \"reference_models\": [slot.dict() for slot in body.reference_models],\n-                    \"aggregator\": body.aggregator.dict(),\n-                    \"reference_temperature\": body.reference_temperature,\n-                    \"aggregator_temperature\": body.aggregator_temperature,\n-                    \"max_tokens\": body.max_tokens,\n-                    \"enabled\": body.enabled,\n-                }\n+                raw = _preset_dict(\n+                    MoaPresetPayload(\n+                        reference_models=body.reference_models,\n+                        aggregator=body.aggregator,\n+                        reference_temperature=body.reference_temperature,\n+                        aggregator_temperature=body.aggregator_temperature,\n+                        max_tokens=[redacted:secret-assignment]\n+                        reference_max_tokens=[redacted:secret-assignment]\n+                        fanout=body.fanout,\n+                        enabled=body.enabled,\n+                    )\n+                )\n+\n+            # Reject-don't-repair: normalize_moa_config() silently swaps any\n+            # preset containing incomplete slots for the hardcoded defaults —\n+            # correct tolerance for hand-edited configs at READ time, silent\n+            # data loss at WRITE time (#64156: desktop autosave of a\n+            # half-filled slot replaced the user's whole preset). Refuse the\n+            # save loudly so no client can corrupt config through this route.\n+            problems = validate_moa_payload(raw)\n+            if problems:\n+                raise HTTPException(\n+                    status_code=422,\n+                    detail=\"Invalid MoA config: \" + \"; \".join(problems),\n+                )\n+\n             normalized = normalize_moa_config(raw)\n             cfg[\"moa\"] = normalized\n             save_config(cfg)"]},{"file":"tests/hermes_cli/test_moa_config.py","hunks":["@@ -304,3 +304,115 @@ def test_reference_max_tokens_in_flattened_view():\n     active preset's reference_max_tokens.\"\"\"\n     cfg = normalize_moa_config(_preset(reference_max_tokens=750))\n     assert cfg[\"reference_max_tokens\"] == 750\n+\n+\n+# ── validate_moa_payload (write-boundary validation, #64156) ─────────────────\n+#\n+# normalize_moa_config is deliberately tolerant at READ time (hand-edited\n+# configs degrade to defaults). validate_moa_payload is the strict WRITE-time\n+# counterpart: it must flag exactly the payloads normalize would silently\n+# repair, so API save paths reject them instead of corrupting user config.\n+\n+\n+def _valid_preset_payload():\n+    return {\n+        \"reference_models\": [{\"provider\": \"openrouter\", \"model\": \"deepseek/deepseek-v4-pro\"}],\n+        \"aggregator\": {\"provider\": \"openrouter\", \"model\": \"anthropic/claude-opus-4.8\"},\n+    }\n+\n+\n+def test_validate_moa_payload_accepts_complete_presets():\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    assert validate_moa_payload({\"presets\": {\"default\": _valid_preset_payload()}}) == []\n+\n+\n+def test_validate_moa_payload_accepts_legacy_flat_payload():\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    assert validate_moa_payload(_valid_preset_payload()) == []\n+\n+\n+def test_validate_moa_payload_flags_half_filled_reference_slot():\n+    \"\"\"The #64156 shape: provider picked, model still empty (mid-edit autosave).\"\"\"\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    preset = _valid_preset_payload()\n+    preset[\"reference_models\"].append({\"provider\": \"kilo\", \"model\": \"\"})\n+    problems = validate_moa_payload({\"presets\": {\"default\": preset}})\n+\n+    assert problems\n+    assert any(\"reference 2\" in p and \"model is required\" in p for p in problems)\n+\n+\n+def test_validate_moa_payload_flags_half_filled_aggregator():\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    preset = _valid_preset_payload()\n+    preset[\"aggregator\"] = {\"provider\": \"openrouter\", \"model\": \"\"}\n+    problems = validate_moa_payload({\"presets\": {\"default\": preset}})\n+\n+    assert any(\"aggregator\" in p and \"model is required\" in p for p in problems)\n+\n+\n+def test_validate_moa_payload_flags_empty_references():\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    preset = _valid_preset_payload()\n+    preset[\"reference_models\"] = []\n+    problems = validate_moa_payload({\"presets\": {\"default\": preset}})\n+\n+    assert any(\"at least one complete reference model\" in p for p in problems)\n+\n+\n+def test_validate_moa_payload_flags_recursive_moa_slot():\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    preset = _valid_preset_payload()\n+    preset[\"aggregator\"] = {\"provider\": \"MoA\", \"model\": \"default\"}\n+    problems = validate_moa_payload({\"presets\": {\"default\": preset}})\n+\n+    assert any(\"recursive MoA\" in p for p in problems)\n+\n+\n+def test_validate_moa_payload_names_the_broken_preset():\n+    \"\"\"Multi-preset payloads must say WHICH preset is broken.\"\"\"\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    problems = validate_moa_payload(\n+        {\n+            \"presets\": {\n+                \"good\": _valid_preset_payload(),\n+                \"broken\": {\n+                    \"reference_models\": [{\"provider\": \"\", \"model\": \"\"}],\n+                    \"aggregator\": {\"provider\": \"a\", \"model\": \"b\"},\n+                },\n+            }\n+        }\n+    )\n+\n+    assert problems\n+    assert all(\"'broken'\" in p for p in problems)\n+    assert not any(\"'good'\" in p for p in problems)\n+\n+\n+def test_validate_moa_payload_agrees_with_clean_slot():\n+    \"\"\"Contract: a payload validate accepts must survive normalize UNCHANGED in\n+    its slots — validate and _clean_slot can never disagree (else a payload\n+    could pass validation and still be swapped for defaults).\"\"\"\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    payload = {\"presets\": {\"p\": _valid_preset_payload()}}\n+    assert validate_moa_payload(payload) == []\n+\n+    cfg = normalize_moa_config(payload)\n+    assert cfg[\"presets\"][\"p\"][\"reference_models\"] == payload[\"presets\"][\"p\"][\"reference_models\"]\n+    assert cfg[\"presets\"][\"p\"][\"aggregator\"] == payload[\"presets\"][\"p\"][\"aggregator\"]\n+\n+\n+def test_validate_moa_payload_rejects_non_dict():\n+    from hermes_cli.moa_config import validate_moa_payload\n+\n+    assert validate_moa_payload(None)\n+    assert validate_moa_payload([1, 2])\n+    assert validate_moa_payload({\"presets\": {\"p\": \"not-a-dict\"}})"]},{"file":"tests/hermes_cli/test_web_server.py","hunks":["@@ -861,6 +861,72 @@ def test_put_moa_models_persists_provider_model_slots(self):\n         assert cfg[\"moa\"][\"reference_models\"] == payload[\"reference_models\"]\n         assert cfg[\"moa\"][\"aggregator\"] == payload[\"aggregator\"]\n \n+    def test_put_moa_models_rejects_half_filled_slot_with_422(self):\n+        \"\"\"#64156: a mid-edit autosave (provider picked, model empty) used to be\n+        silently normalized into the hardcoded default preset — the user's\n+        config was replaced without any error. The write path must reject it.\"\"\"\n+        from hermes_cli.config import load_config\n+\n+        original = load_config().get(\"moa\")\n+\n+        payload = {\n+            \"presets\": {\n+                \"default\": {\n+                    \"reference_models\": [{\"provider\": \"kilo\", \"model\": \"\"}],\n+                    \"aggregator\": {\"provider\": \"openrouter\", \"model\": \"anthropic/claude-opus-4.8\"},\n+                }\n+            }\n+        }\n+\n+        resp = self.client.put(\"/api/model/moa\", json=payload)\n+        assert resp.status_code == 422\n+        assert \"model is required\" in resp.json()[\"detail\"]\n+        # Config untouched — not swapped for defaults.\n+        assert load_config().get(\"moa\") == original\n+\n+    def test_put_moa_models_rejects_half_filled_aggregator_with_422(self):\n+        payload = {\n+            \"presets\": {\n+                \"default\": {\n+                    \"reference_models\": [{\"provider\": \"openrouter\", \"model\": \"deepseek/deepseek-v4-pro\"}],\n+                    \"aggregator\": {\"provider\": \"openrouter\", \"model\": \"\"},\n+                }\n+            }\n+        }\n+\n+        resp = self.client.put(\"/api/model/moa\", json=payload)\n+        assert resp.status_code == 422\n+        assert \"aggregator\" in resp.json()[\"detail\"]\n+\n+    def test_put_moa_models_round_trips_fanout_and_reference_max_tokens(self):\n+        \"\"\"GET → PUT round-trip must not erase newer per-preset knobs. The old\n+        Pydantic payload didn't declare fanout / reference_max_tokens, so any\n+        client save silently wiped hand-set values back to defaults.\"\"\"\n+        from hermes_cli.config import load_config\n+\n+        payload = {\n+            \"presets\": {\n+                \"default\": {\n+                    \"reference_models\": [{\"provider\": \"openrouter\", \"model\": \"deepseek/deepseek-v4-pro\"}],\n+                    \"aggregator\": {\"provider\": \"openrouter\", \"model\": \"anthropic/claude-opus-4.8\"},\n+                    \"fanout\": \"user_turn\",\n+                    \"reference_max_tokens\": 600,\n+                }\n+            }\n+        }\n+\n+        resp = self.client.put(\"/api/model/moa\", json=payload)\n+        assert resp.status_code == 200\n+\n+        saved = load_config()[\"moa\"][\"presets\"][\"default\"]\n+        assert saved[\"fanout\"] == \"user_turn\"\n+        assert saved[\"reference_max_tokens\"] == 600\n+\n+        # And the GET view carries them back to the client.\n+        fetched = self.client.get(\"/api/model/moa\").json()\n+        assert fetched[\"presets\"][\"default\"][\"fanout\"] == \"user_turn\"\n+        assert fetched[\"presets\"][\"default\"][\"reference_max_tokens\"] == 600\n+\n     # ── GET /api/media (remote image display) ───────────────────────────\n \n     def test_get_media_serves_image_in_root(self):"]},{"file":"web/src/lib/api.ts","hunks":["@@ -2301,6 +2301,8 @@ export interface AuxiliaryModelsResponse {\n export interface MoaModelSlot {\n   provider: string;\n   model: string;\n+  /** Optional per-slot reasoning effort — round-tripped, not edited here. */\n+  reasoning_effort?: string;\n }\n \n export interface MoaConfigResponse {","@@ -2312,6 +2314,10 @@ export interface MoaConfigResponse {\n     reference_temperature: number;\n     aggregator_temperature: number;\n     max_tokens: [redacted:secret-assignment]\n+    /** Optional advisor output cap — round-tripped, not edited here. */\n+    reference_max_tokens?: number | null;\n+    /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */\n+    fanout?: string;\n     enabled: boolean;\n   }>;\n   reference_models: MoaModelSlot[];\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}