diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 153fc5fb606..981533f15e7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -6243,6 +6243,12 @@ def _deep_merge(base: dict, override: dict) -> dict: Keys in *override* take precedence. If both values are dicts the merge recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will keep the default ``tts.elevenlabs.model_id`` intact. + + An empty section key in config.yaml (``terminal:`` with no value) parses + as YAML ``None``; treating that as an override would replace the entire + default dict with ``None`` and crash every downstream consumer that + expects a mapping (#58277). A ``None`` override of a dict default is + ignored — same as the key being absent. """ result = base.copy() for key, value in override.items(): @@ -6252,6 +6258,8 @@ def _deep_merge(base: dict, override: dict) -> dict: and isinstance(value, dict) ): result[key] = _deep_merge(result[key], value) + elif key in result and isinstance(result[key], dict) and value is None: + continue else: result[key] = value return result diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index dfc1c457cd3..0b1f1404bab 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -360,6 +360,34 @@ class TestLoadConfigParseFailure: assert capsys.readouterr().err == "" +class TestEmptyConfigSections: + """Empty section keys (``terminal:`` with no value) parse as YAML None + and must not replace the default dict for that section (#58277).""" + + def test_null_section_keeps_defaults_in_load_config(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text( + "model:\n default: test/custom\n" + "terminal:\n" + "display:\n" + ) + config = load_config() + assert config["model"]["default"] == "test/custom" + assert isinstance(config["terminal"], dict) + assert config["terminal"] == DEFAULT_CONFIG["terminal"] + assert isinstance(config["display"], dict) + + def test_null_override_of_non_dict_default_still_applies(self, tmp_path): + """None only shields dict defaults — explicit null for a scalar + key remains an override (unchanged behavior).""" + from hermes_cli.config import _deep_merge + + merged = _deep_merge({"scalar": 5, "section": {"a": 1}}, + {"scalar": None, "section": None}) + assert merged["scalar"] is None + assert merged["section"] == {"a": 1} + + class TestSaveAndLoadRoundtrip: @staticmethod def _deny_config_reads(config_path):