Files
hermes-agent/tests/hermes_cli/test_config_effective.py

125 lines
4.3 KiB
Python

"""Invariants for ``hermes_cli.config_effective.load_user_config_effective`` — the one loader every
defaults-free config reader (gateway runtime, TUI gateway, cron, ``hermes send`` bridge, doctor,
bootstrap modules) goes through."""
import textwrap
import pytest
import yaml
@pytest.fixture
def homes(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
managed = tmp_path / "managed"
managed.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
monkeypatch.setenv("FIXTURE_USER_KEY", "user-secret")
monkeypatch.setenv("FIXTURE_MANAGED_URL", "https://managed.example")
_reset_caches()
return home, managed
def _reset_caches():
import hermes_cli.config as cfg
from hermes_cli import config_effective, managed_scope
cfg._LOAD_CONFIG_CACHE.clear()
cfg._RAW_CONFIG_CACHE.clear()
config_effective._EFFECTIVE_CACHE.clear()
config_effective._LAST_GOOD_USER_RAW.clear()
managed_scope.invalidate_managed_cache()
def _write(path, body):
path.write_text(textwrap.dedent(body), encoding="utf-8")
_reset_caches()
USER_YAML = """
model:
name: user/model
api_key: ${FIXTURE_USER_KEY}
provider: custom
display:
skin: user-skin
"""
MANAGED_YAML = """
model:
base_url: ${FIXTURE_MANAGED_URL}
display:
skin: managed-skin
"""
def test_effective_is_user_plus_managed_plus_env_with_no_defaults(homes):
"""Contract as a fixture: given user config.yaml X, managed overlay Y and env Z, the effective
dict is exactly this literal — ``${VAR}`` expanded on both layers, managed keys winning,
root ``provider`` migrated under ``model``, and no DEFAULT_CONFIG key introduced (a missing
key stays missing). Per-message gateway reads (and the system prompt built from them) are
pinned by this shape, not by re-running the implementation's primitives."""
from hermes_cli.config import DEFAULT_CONFIG
from hermes_cli.config_effective import load_user_config_effective
home, managed = homes
_write(home / "config.yaml", USER_YAML)
_write(managed / "config.yaml", MANAGED_YAML)
effective = load_user_config_effective(home / "config.yaml")
assert effective == {
"model": {
"default": "user/model",
"provider": "custom",
"api_key": "user-secret",
"base_url": "https://managed.example",
},
"display": {"skin": "managed-skin"},
}
assert "agent" in DEFAULT_CONFIG # would be present if defaults had been merged
def test_broken_yaml_serves_last_good_and_fail_closed_raises(homes):
"""A torn mid-edit write must not silently drop user overrides: the fail-open path serves the last
successfully parsed user file through the same pipeline; ``fail_closed`` surfaces the error to
callers that keep their own last-good state."""
from hermes_cli.config_effective import load_user_config_effective
home, _ = homes
_write(home / "config.yaml", USER_YAML)
good = load_user_config_effective(home / "config.yaml")
(home / "config.yaml").write_text("model: [unterminated", encoding="utf-8")
_reset_caches_keep_last_good()
assert load_user_config_effective(home / "config.yaml") == good
with pytest.raises(yaml.YAMLError): # the type _refresh_fallback_model's own last-good path keys on
load_user_config_effective(home / "config.yaml", fail_closed=True)
def test_good_backup_is_written_only_for_the_active_home(homes, tmp_path):
"""Reading ANOTHER profile's config (doctor, TUI cwd lookup) is a read: it must not create
``backups/config/`` inside that profile. The active home keeps the last-good copy."""
from hermes_cli.config_effective import load_user_config_effective
home, _ = homes
other = tmp_path / "other-profile"
other.mkdir()
_write(home / "config.yaml", USER_YAML)
_write(other / "config.yaml", USER_YAML)
load_user_config_effective(other / "config.yaml")
load_user_config_effective(home / "config.yaml")
assert not (other / "backups").exists()
assert list((home / "backups" / "config").glob("config.yaml.good.*"))
def _reset_caches_keep_last_good():
import hermes_cli.config as cfg
from hermes_cli import config_effective
cfg._RAW_CONFIG_CACHE.clear()
config_effective._EFFECTIVE_CACHE.clear()