fix(config): widen empty-section guard to _deep_merge in load_config

Sibling site of the load_cli_config fix (#58277): _deep_merge treated a
YAML-null section (terminal: with no value) as an override, replacing
the entire DEFAULT_CONFIG dict for that section with None. Every
downstream consumer expecting a mapping was a latent crash, and default
sub-keys were silently lost. A None override of a dict default is now
ignored, matching the CLI loader's behavior. Scalar-null overrides are
unchanged.
This commit is contained in:
teknium1 2026-07-09 17:37:45 -07:00 committed by Teknium
parent bdecf0ab94
commit 46613071e4
2 changed files with 36 additions and 0 deletions

View file

@ -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

View file

@ -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):