Files
hermes-agent/tests/cli/test_cli_mcp_config_watch.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

194 lines
7.5 KiB
Python

"""Tests for automatic MCP reload when config.yaml mcp_servers section changes."""
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
def _make_cli(tmp_path, mcp_servers=None, extra_config=None):
"""Create a minimal HermesCLI instance with mocked config."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
cfg = {"mcp_servers": mcp_servers or {}}
if extra_config:
cfg.update(extra_config)
obj.config = cfg
obj._agent_running = False
obj._last_config_check = 0.0
obj._config_mcp_servers = mcp_servers or {}
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("mcp_servers: {}\n")
obj._config_mtime = cfg_file.stat().st_mtime
obj._reload_mcp = MagicMock()
obj._busy_command = MagicMock()
obj._busy_command.return_value.__enter__ = MagicMock(return_value=None)
obj._busy_command.return_value.__exit__ = MagicMock(return_value=False)
obj._slow_command_status = MagicMock(return_value="reloading...")
return obj, cfg_file
class TestMCPConfigWatch:
def test_new_mcp_server_triggers_reload(self, tmp_path):
"""Adding a new MCP server to config triggers auto-reload."""
import hermes_yaml as yaml
obj, cfg_file = _make_cli(tmp_path, mcp_servers={})
# Simulate user adding a new MCP server to config.yaml
cfg_file.write_text(yaml.safe_dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}}))
obj._config_mtime = 0.0 # force stale mtime
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_called_once()
def test_removed_mcp_server_triggers_reload(self, tmp_path):
"""Removing an MCP server from config triggers auto-reload."""
import hermes_yaml as yaml
obj, cfg_file = _make_cli(tmp_path, mcp_servers={"github": {"url": "https://mcp.github.com"}})
# Simulate user removing the server
cfg_file.write_text(yaml.safe_dump({"mcp_servers": {}}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_called_once()
def test_optout_disables_auto_reload(self, tmp_path, capsys):
"""When mcp.auto_reload_on_config_change is False, a changed
mcp_servers section must NOT trigger an automatic reload — but the
change is still detected and the user is told how to apply it.
This protects the provider prompt cache: every automatic reload
rebuilds the agent tool surface and invalidates cached prefixes.
The toggle is the top-level ``mcp:`` section in config.yaml, and the
watcher reads it from the same freshly-parsed file it diffs — so
flipping the toggle and editing mcp_servers in one edit behaves
correctly.
"""
import hermes_yaml as yaml
obj, cfg_file = _make_cli(
tmp_path,
mcp_servers={},
)
# Simulate a changed mcp_servers section with auto-reload opted out.
cfg_file.write_text(yaml.safe_dump({
"mcp": {"auto_reload_on_config_change": False},
"mcp_servers": {"github": {"url": "https://mcp.github.com"}},
}))
obj._config_mtime = 0.0 # force stale mtime
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_not_called()
out = capsys.readouterr().out
assert "reload skipped" in out
assert "/reload-mcp" in out
assert "prompt cache" in out
def test_optout_updates_snapshot_so_reload_mcp_applies_cleanly(self, tmp_path):
"""After an opted-out change, the watcher must not re-notify every
tick: the snapshot is updated so the same content compares equal on
the next pass."""
import hermes_yaml as yaml
obj, cfg_file = _make_cli(tmp_path, mcp_servers={})
cfg_file.write_text(yaml.safe_dump({
"mcp": {"auto_reload_on_config_change": False},
"mcp_servers": {"github": {"url": "https://mcp.github.com"}},
}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
# Second pass: same file content, new mtime — no reload, no change.
obj._last_config_check = 0.0
obj._config_mtime = 0.0
obj._check_config_mcp_changes()
obj._reload_mcp.assert_not_called()
assert obj._config_mcp_servers == {"github": {"url": "https://mcp.github.com"}}
def test_optout_path_is_top_level_mcp_not_auxiliary(self, tmp_path):
"""Regression guard: the opt-out toggle is the top-level
``mcp.auto_reload_on_config_change`` key, NOT ``auxiliary.mcp``
(which holds side-LLM task provider settings).
A config that sets ONLY ``auxiliary.mcp.auto_reload_on_config_change:
false`` must NOT disable the reload."""
import hermes_yaml as yaml
obj, cfg_file = _make_cli(
tmp_path,
mcp_servers={},
)
cfg_file.write_text(yaml.safe_dump({
"auxiliary": {"mcp": {"auto_reload_on_config_change": False}},
"mcp_servers": {"github": {"url": "https://mcp.github.com"}},
}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
# Reload happened because the aux-task path is not the toggle.
obj._reload_mcp.assert_called()
def test_env_var_templates_do_not_false_positive_on_unrelated_saves(
self, tmp_path, monkeypatch, capsys
):
"""Regression for the '/reasoning triggers MCP reload' bug (#55701).
Init snapshots mcp_servers from the loaded config, which has been
through _expand_env_vars() — so ``${MCP_GH_API_KEY}`` is stored
expanded. The watcher re-parses the RAW yaml. Without expanding the
watcher side too, the comparison is always unequal whenever any
template is in use, so EVERY config.yaml rewrite (e.g.
save_config_value('agent.reasoning_effort', ...) from /reasoning)
fired a full MCP reconnect.
"""
import hermes_yaml as yaml
monkeypatch.setenv("MCP_GH_API_KEY", "sekrit-token")
raw_servers = {
"github": {
"url": "https://mcp.github.com",
"headers": {"Authorization": "Bearer ${MCP_GH_API_KEY}"},
}
}
expanded_servers = {
"github": {
"url": "https://mcp.github.com",
"headers": {"Authorization": "Bearer sekrit-token"},
}
}
# Init snapshot holds the EXPANDED form (as load_cli_config produces).
obj, cfg_file = _make_cli(tmp_path, mcp_servers=expanded_servers)
# Unrelated-key save: mcp_servers content identical (raw templates),
# only reasoning_effort changed — mtime moves.
cfg_file.write_text(yaml.safe_dump({
"agent": {"reasoning_effort": "high"},
"mcp_servers": raw_servers,
}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_not_called()
assert "MCP server config changed" not in capsys.readouterr().out