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:
@@ -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]:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user