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.
194 lines
7.5 KiB
Python
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
|