diff --git a/hermes_cli/config.py b/hermes_cli/config.py index aba0da380a..7fc80a6564 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -904,7 +904,11 @@ def get_missing_config_fields() -> List[Dict[str, Any]]: if key not in current: missing.append({"key": full_key, "default": default_value, "description": f"New config option: {full_key}"}) - elif isinstance(default_value, dict) and isinstance(current.get(key), dict): + elif ( + isinstance(default_value, dict) + and isinstance(current.get(key), dict) + and not (prefix == "moa" and key == "presets") + ): _check(default_value, current[key], full_key) _check(DEFAULT_CONFIG, load_config()) diff --git a/hermes_cli/web_routers/models.py b/hermes_cli/web_routers/models.py index f9c0390652..7a742fa845 100644 --- a/hermes_cli/web_routers/models.py +++ b/hermes_cli/web_routers/models.py @@ -310,7 +310,8 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None): # desktop's debounced PUT /api/config autosave races it, so the whole # span holds _CONFIG_MUTATION_LOCK or one of the two saves is dropped. with config_write_scope(body.profile or profile): - if body.presets: + legacy_flat_update = not body.presets + if not legacy_flat_update: raw = { "default_preset": body.default_preset, "active_preset": body.active_preset, @@ -339,9 +340,28 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None): # MoaConfigPayload; an editor round-trip must not reset it. raw["privacy_filter"] = existing_moa["privacy_filter"] normalized = normalize_moa_config(raw) + if legacy_flat_update and isinstance(existing_moa, dict): + existing_presets = existing_moa.get("presets") + existing_default = existing_moa.get("default_preset") + if isinstance(existing_presets, dict): + if isinstance(existing_default, str) and existing_default in existing_presets: + # Older clients update the flat default preset; keep the existing + # default name and any valid active selection from the stored map. + synthesized_default = normalized.get("default_preset") + if ( + isinstance(synthesized_default, str) + and synthesized_default in normalized["presets"] + ): + normalized["presets"][existing_default] = normalized["presets"].pop( + synthesized_default + ) + normalized["default_preset"] = existing_default + existing_active = existing_moa.get("active_preset") + if isinstance(existing_active, str) and existing_active in existing_presets: + normalized["active_preset"] = existing_active from hermes_cli import config as config_mod updated = config_mod._merge_partial_save(raw_cfg, {"moa": normalized}) - if body.presets: + if not legacy_flat_update: # ``_merge_partial_save`` retains undeclared metadata on surviving presets; # filter after that merge so omitted names are authoritative deletions. merged_presets = updated["moa"]["presets"] diff --git a/tests/hermes_cli/test_moa_preset_config_warnings.py b/tests/hermes_cli/test_moa_preset_config_warnings.py new file mode 100644 index 0000000000..cc55bcffbc --- /dev/null +++ b/tests/hermes_cli/test_moa_preset_config_warnings.py @@ -0,0 +1,17 @@ +"""A user's named MoA presets are not schema-defaulted config options.""" + +from hermes_cli import config as hermes_config + + +def test_missing_config_fields_ignores_user_named_presets_but_checks_schema_options(monkeypatch): + """Custom preset names must not be compared against the built-in ``default`` name.""" + monkeypatch.setattr( + hermes_config, + "load_config", + lambda: {"moa": {"presets": {"keep_a": {}}}, "terminal": {}}, + ) + + missing_keys = {field["key"] for field in hermes_config.get_missing_config_fields()} + + assert not any(key.startswith("moa.presets.") for key in missing_keys) + assert "terminal.backend" in missing_keys diff --git a/tests/hermes_cli/test_moa_preset_deletion.py b/tests/hermes_cli/test_moa_preset_deletion.py index 235c9b9731..f275646924 100644 --- a/tests/hermes_cli/test_moa_preset_deletion.py +++ b/tests/hermes_cli/test_moa_preset_deletion.py @@ -20,6 +20,7 @@ import subprocess import sys from pathlib import Path +import pytest import yaml from hermes_cli.web_models import MoaConfigPayload, MoaModelSlot, MoaPresetPayload @@ -216,3 +217,71 @@ def test_deletion_preserves_privacy_filter_and_retained_preset_metadata(tmp_path assert on_disk["save_traces"] is True assert on_disk["trace_dir"] == "/custom/traces" assert on_disk["presets"]["keep_a"]["operator_note"] == "retain me" + + +@pytest.mark.parametrize("include_presets", [False, True], ids=["missing-presets", "empty-presets"]) +def test_legacy_flat_update_preserves_named_presets_and_valid_default( + tmp_path, monkeypatch, include_presets +): + """Older flat clients preserve named presets and a valid persisted active selection.""" + home = tmp_path / ".hermes" + home.mkdir() + seeded = _three_presets() + seeded["active_preset"] = "keep_b" + _seed(home, monkeypatch, seeded) + + payload = { + "active_preset": "", + "reference_models": [MoaModelSlot(provider="openai-codex", model="gpt-5.8")], + "aggregator": MoaModelSlot(provider="anthropic", model="claude-opus-5"), + "enabled": True, + } + if include_presets: + payload["presets"] = {} + + set_moa_models(MoaConfigPayload(**payload)) + + moa = _on_disk(home)["moa"] + assert set(moa["presets"]) == {"keep_a", "doomed", "keep_b"} + assert moa["default_preset"] == "keep_a" + assert moa["active_preset"] == "keep_b" + assert moa["active_preset"] in moa["presets"] + assert "default" not in moa["presets"] + assert moa["presets"]["keep_a"]["reference_models"] == [ + {"provider": "openai-codex", "model": "gpt-5.8", "enabled": True} + ] + assert moa["presets"]["keep_a"]["aggregator"] == { + "provider": "anthropic", "model": "claude-opus-5" + } + # Legacy flat fields represent the saved default preset. Runtime resolution uses + # default_preset when no preset name is explicitly requested; active_preset is metadata. + from hermes_cli.moa_config import resolve_moa_preset + + assert resolve_moa_preset(moa)["reference_models"] == moa["presets"]["keep_a"]["reference_models"] + assert moa["presets"]["keep_b"] == seeded["presets"]["keep_b"] + assert moa["presets"]["doomed"] == seeded["presets"]["doomed"] + + +def test_legacy_flat_update_with_named_default_and_empty_map_does_not_fail(tmp_path, monkeypatch): + """A legacy flat payload's default_preset field cannot rename its synthesized map.""" + home = tmp_path / ".hermes" + home.mkdir() + _seed(home, monkeypatch, _three_presets()) + + result = set_moa_models( + MoaConfigPayload( + default_preset="keep_b", + presets={}, + reference_models=[MoaModelSlot(provider="openai-codex", model="gpt-5.8")], + aggregator=MoaModelSlot(provider="anthropic", model="claude-opus-5"), + enabled=True, + ) + ) + + moa = _on_disk(home)["moa"] + assert result["ok"] is True + assert set(moa["presets"]) == {"keep_a", "doomed", "keep_b"} + assert moa["default_preset"] == "keep_a" + assert moa["presets"]["keep_a"]["reference_models"] == [ + {"provider": "openai-codex", "model": "gpt-5.8", "enabled": True} + ]