hermes-agent/tests/cli/test_cli_save_config_value.py
Teknium 353578faca
fix(config): persist runtime settings to HERMES_HOME/config.yaml, not the repo template
The wake-word ear reverted to disabled after every restart even when
closed enabled. Root cause is general, not wake-specific: save_config_value
followed load_cli_config's precedence, which falls back to the repo's
checked-in cli-config.yaml when HERMES_HOME/config.yaml doesn't exist yet.
On such installs (managed/desktop first launch), the toggle's persist wrote
wake_word.enabled=true into cli-config.yaml and returned success — but
every config reader (load_config -> get_hermes_home()/config.yaml,
including load_wake_word_config) reads only HERMES_HOME/config.yaml, so the
setting was invisible on the next launch. Same silent loss hit any runtime
persist (model switch, /reasoning, /fast, skin) on a config-less install.

save_config_value now always targets get_hermes_home()/config.yaml,
creating it if absent, and never writes the shipped repo template. Also
resolves HERMES_HOME live instead of the import-time _hermes_home constant
(profile-safe).

E2E verified: persist -> fresh module reload -> load_wake_word_config sees
enabled=true and wake_surface_enabled('gui') is True, for both a fresh
(no config.yaml) and an existing-config install.

- cli.py save_config_value: target user config, create if absent
- tests: 2 regression tests (creates user config when absent; never writes
  repo cli-config.yaml); fixture now sets HERMES_HOME. 11 file + 206
  adjacent config/model-switch tests green
2026-07-28 10:40:28 -07:00

200 lines
8 KiB
Python

"""Tests for save_config_value() in cli.py — atomic write behavior."""
from pathlib import Path
from unittest.mock import MagicMock
import yaml
import pytest
class TestSaveConfigValueAtomic:
"""save_config_value() must use atomic round-trip YAML updates."""
@pytest.fixture
def config_env(self, tmp_path, monkeypatch):
"""Isolated config environment with a writable config.yaml."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.dump({
"model": {"default": "test-model", "provider": "openrouter"},
"display": {"skin": "default"},
}))
# save_config_value resolves the target live via get_hermes_home(), so
# point HERMES_HOME at the temp dir (the _hermes_home import-time
# constant is no longer consulted).
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr("cli._hermes_home", hermes_home)
return config_path
def test_calls_roundtrip_yaml_update(self, config_env, monkeypatch):
"""save_config_value must preserve user-edited YAML structure."""
mock_update = MagicMock()
monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", mock_update)
from cli import save_config_value
save_config_value("display.skin", "mono")
mock_update.assert_called_once_with(config_env, "display.skin", "mono")
def test_preserves_existing_keys(self, config_env):
"""Writing a new key must not clobber existing config entries."""
from cli import save_config_value
save_config_value("agent.max_turns", 50)
result = yaml.safe_load(config_env.read_text())
assert result["model"]["default"] == "test-model"
assert result["model"]["provider"] == "openrouter"
assert result["display"]["skin"] == "default"
assert result["agent"]["max_turns"] == 50
def test_creates_nested_keys(self, config_env):
"""Dot-separated paths create intermediate dicts as needed."""
from cli import save_config_value
save_config_value("auxiliary.compression.model", "google/gemini-3-flash-preview")
result = yaml.safe_load(config_env.read_text())
assert result["auxiliary"]["compression"]["model"] == "google/gemini-3-flash-preview"
def test_overwrites_existing_value(self, config_env):
"""Updating an existing key replaces the value."""
from cli import save_config_value
save_config_value("display.skin", "ares")
result = yaml.safe_load(config_env.read_text())
assert result["display"]["skin"] == "ares"
def test_preserves_env_ref_templates_in_unrelated_fields(self, config_env):
"""The /model --global persistence path must not inline env-backed secrets."""
config_env.write_text(yaml.dump({
"custom_providers": [{
"name": "tuzi",
"api_key": "${TU_ZI_API_KEY}",
"model": "claude-opus-4-6",
}],
"model": {"default": "test-model", "provider": "openrouter"},
}))
from cli import save_config_value
save_config_value("model.default", "doubao-pro")
result = yaml.safe_load(config_env.read_text())
assert result["model"]["default"] == "doubao-pro"
assert result["custom_providers"][0]["api_key"] == "${TU_ZI_API_KEY}"
def test_model_write_runs_shared_cron_drift_warning(self, config_env, monkeypatch):
warning = MagicMock()
monkeypatch.setattr(
"hermes_cli.config.warn_unpinned_cron_jobs_after_model_config_change",
warning,
)
from cli import save_config_value
assert save_config_value("model.default", "new-model") is True
warning.assert_called_once_with("model.default", "new-model")
def test_preserves_comments_after_config_mutation(self, config_env):
"""CLI config writes should not strip existing user comments."""
config_env.write_text(
"# user selected model\n"
"model:\n"
" # keep this provider note\n"
" provider: openrouter\n"
"display:\n"
" skin: default # inline skin note\n",
encoding="utf-8",
)
from cli import save_config_value
save_config_value("display.skin", "mono")
text = config_env.read_text(encoding="utf-8")
result = yaml.safe_load(text)
assert result["display"]["skin"] == "mono"
assert "# user selected model" in text
assert "# keep this provider note" in text
assert "# inline skin note" in text
def test_preserves_readable_unicode_after_config_mutation(self, config_env):
"""Non-ASCII prompts should remain readable instead of \\u-escaped."""
config_env.write_text(
"agent:\n"
" system_prompt: 你好,保持中文输出\n"
"display:\n"
" skin: default\n",
encoding="utf-8",
)
from cli import save_config_value
save_config_value("display.skin", "mono")
text = config_env.read_text(encoding="utf-8")
result = yaml.safe_load(text)
assert result["agent"]["system_prompt"] == "你好,保持中文输出"
assert "你好,保持中文输出" in text
assert "\\u4f60" not in text
def test_file_not_truncated_on_error(self, config_env, monkeypatch):
"""If atomic_yaml_write raises, the original file is untouched."""
original_content = config_env.read_text()
def exploding_write(*args, **kwargs):
raise OSError("disk full")
monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", exploding_write)
from cli import save_config_value
result = save_config_value("display.skin", "broken")
assert result is False
assert config_env.read_text() == original_content
class TestSaveConfigValueTargetsUserConfig:
"""Regression: persisted runtime settings must land in HERMES_HOME/config.yaml
(which config readers actually read), never the repo's cli-config.yaml.
This was the "wake-word ear reverts to disabled after restart" bug: on an
install whose HERMES_HOME/config.yaml did not exist yet, save_config_value
fell back to the checked-in cli-config.yaml. The toggle reported success, but
startup read HERMES_HOME/config.yaml and never saw the setting."""
def test_creates_user_config_when_absent(self, tmp_path, monkeypatch):
# Fresh HERMES_HOME with NO config.yaml (managed/desktop first launch).
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from cli import save_config_value
assert save_config_value("wake_word.enabled", True) is True
config_path = hermes_home / "config.yaml"
assert config_path.exists(), "user config.yaml must be created, not skipped"
result = yaml.safe_load(config_path.read_text())
assert result["wake_word"]["enabled"] is True
def test_does_not_write_repo_cli_config(self, tmp_path, monkeypatch):
# Even when the repo's cli-config.yaml exists, the write goes to the
# user config, so a runtime setting is never buried in the shipped file.
import cli as cli_module
repo_cli_config = Path(cli_module.__file__).parent / "cli-config.yaml"
before = repo_cli_config.read_text() if repo_cli_config.exists() else None
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from cli import save_config_value
save_config_value("wake_word.enabled", True)
# The repo template is untouched…
after = repo_cli_config.read_text() if repo_cli_config.exists() else None
assert after == before
# …and the value landed in the user config.
result = yaml.safe_load((hermes_home / "config.yaml").read_text())
assert result["wake_word"]["enabled"] is True