fix(config): prevent saves from dropping explicit null settings

Use a distinct stripped-node sentinel so valid null values survive configuration saves and migrations. Keep existing default comparisons and explicit-path preservation rules.

Scope-risk: narrow
Tested: 14 new cases pass; 7 fail on unmodified main. Broader macOS run: 1266 passed, 7 skipped across 61 files.
Not-tested: Full repository suite and native Linux/Windows execution.
This commit is contained in:
Shenrui Ma
2026-09-08 22:20:38 +08:00
committed by Teknium
parent 92d13e83d7
commit 93c5856bd5
2 changed files with 70 additions and 3 deletions

View File

@@ -1784,6 +1784,8 @@ def _strip_default_values(
when equal to the default. Dicts whose every child is stripped are removed entirely so
default-only subtrees never bloat ``config.yaml``."""
preserve_keys = {("_config_version",)} | set(preserve_keys or ())
# None is a valid authored value, not a signal to remove the node.
dropped = object()
def _strip(value: Any, default: Any, path: Tuple[str, ...]) -> Any:
if path in preserve_keys:
@@ -1791,10 +1793,11 @@ def _strip_default_values(
if isinstance(value, dict) and value:
default_dict = default if isinstance(default, dict) else {}
stripped = {k: _strip(v, default_dict.get(k), path + (k,)) for k, v in value.items()}
return {k: v for k, v in stripped.items() if v is not None} or None
return None if value == default else copy.deepcopy(value)
return {k: v for k, v in stripped.items() if v is not dropped} or dropped
return dropped if value == default else copy.deepcopy(value)
return _strip(config, defaults, ()) or {}
stripped = _strip(config, defaults, ())
return {} if stripped is dropped else stripped
def split_model_config_default(raw_default: Any) -> tuple[str, str]:

View File

@@ -1758,6 +1758,70 @@ class TestConfigNormalizationDoesNotOverwriteUserValues:
class TestExplicitNullConfigPreservation:
def test_first_save_keeps_null_instead_of_a_non_null_default(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setitem(DEFAULT_CONFIG, "x_first_save", {"optional": "default"})
config_path = tmp_path / "config.yaml"
assert not config_path.exists()
save_config({"x_first_save": {"optional": None}})
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["x_first_save"] == {"optional": None}
assert load_config()["x_first_save"]["optional"] is None
@pytest.mark.parametrize("operation", ["save", "partial_save", "migrate"])
def test_authored_nulls_survive_config_writes(self, tmp_path, monkeypatch, operation):
from hermes_cli.resource_limits import configured_nofile_soft_limit
from agent.agent_runtime_helpers import prompt_caching_disabled_from_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "config.yaml"
seed = {
"_config_version": DEFAULT_CONFIG["_config_version"] - (operation == "migrate"),
"runtime": {"nofile_soft_limit": None},
"prompt_caching": {"cache_ttl": None},
"x_null_preservation": {"nested": {"optional": None}, "keep": "value"},
}
config_path.write_text(yaml.safe_dump(seed), encoding="utf-8")
if operation == "migrate":
migrate_config(interactive=False, quiet=True)
elif operation == "partial_save":
save_config({"display": {"skin": "mono"}}, merge_existing=True)
else:
config = load_config()
config["display"]["skin"] = "mono"
save_config(config)
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["runtime"]["nofile_soft_limit"] is None
assert raw["prompt_caching"]["cache_ttl"] is None
assert raw["x_null_preservation"] == seed["x_null_preservation"]
assert "terminal" not in raw
assert configured_nofile_soft_limit() is None
assert prompt_caching_disabled_from_config() is True
if operation == "migrate":
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
else:
assert raw["display"]["skin"] == "mono"
@pytest.mark.parametrize("value", [None, False, 0, "", [], [None], {"optional": None}])
def test_authored_default_values_survive_stripping(self, value):
from hermes_cli.config import _strip_default_values
config = {"authored": value, "default_only": {"optional": None, "enabled": True}}
result = _strip_default_values(config, defaults=config, preserve_keys={("authored",)})
assert result == {"authored": value}
def test_only_default_nulls_produce_an_empty_mapping(self):
from hermes_cli.config import _strip_default_values
defaults = {"optional": None, "nested": {"optional": None}}
assert _strip_default_values(defaults, defaults=defaults) == {}
class TestCodexAppServerAutoConfig:
"""codex_app_server_auto ships a default and survives migration untouched."""