Files
hermes-webui/tests/test_agent_compat.py
T
Mike McHargueandClaude Opus 5 1f7e5e78da fix: resolve Hermes Agent names moved by the Sep 2026 decomposition
Hermes Agent moved several names the WebUI uses into sibling modules
(tools.approval_context, tools.mcp_tool_discovery/_lifecycle,
hermes_cli.kanban_db_connect/_dispatch, hermes_cli.models_local,
agent.anthropic_credentials). The old paths only resolved through
PLUGIN-COMPAT __getattr__ pointers that warn on every use and are removed
on schedule by reverting one commit.

With the pointers removed, every affected WebUI call site is wrapped in a
broad except, so nothing crashes; features silently degrade instead:
per-turn approval session-key binding is skipped, MCP discovery/status
return nothing, /reload-mcp reports "MCP runtime unavailable", Claude Code
credential linking returns None, every kanban connection raises
AttributeError, and the dispatcher reports "unavailable".

Add api.agent_compat.agent_attr, which resolves a moved name from the
original module's own namespace first (pre-split Agents and existing test
stubs), then from its new home (split Agents, with or without pointers),
then plain attribute access (non-module doubles). Importing only the new
modules would have broken pre-split Agents and bypassed the sys.modules
stubs the test suite installs on the original modules.

Names still native to their original module (_servers, _lock,
has_blocking_approval, is_session_yolo_enabled, register/unregister
gateway notify, the rest of kanban_db) are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:26:50 +00:00

128 lines
5.4 KiB
Python

"""api.agent_compat.agent_attr resolves Hermes Agent names that moved modules.
The Agent's September 2026 decomposition moved names such as
``tools.approval.set_current_session_key`` into sibling modules. The old paths
resolved only through warning ``__getattr__`` pointers that are removed on
schedule; with the pointers gone, the WebUI's broad ``except Exception`` call
sites silently dropped approval session binding, MCP discovery/status, Claude
Code credential linking, and every kanban connection.
"""
import sys
import types
import warnings
import pytest
from api.agent_compat import agent_attr
class _CompatWarning(FutureWarning):
pass
def _real(value):
return value
def _install(monkeypatch, name, **attrs):
mod = types.ModuleType(name)
for key, value in attrs.items():
setattr(mod, key, value)
monkeypatch.setitem(sys.modules, name, mod)
return mod
def _split_facade(monkeypatch, *, with_pointer):
"""An Agent after the split: the name lives in the home module; the facade
optionally keeps a warning PEP 562 pointer (the temporary compat layer)."""
_install(monkeypatch, "fakeagent_home", moved=_real)
facade = _install(monkeypatch, "fakeagent_facade", native=_real)
if with_pointer:
def __getattr__(name):
if name == "moved":
warnings.warn("`fakeagent_facade.moved` moved", _CompatWarning, stacklevel=2)
return _real
raise AttributeError(name)
facade.__getattr__ = __getattr__
return facade
def test_pre_split_agent_uses_original_module(monkeypatch):
orig = _install(monkeypatch, "fakeagent_facade", moved=lambda: "pre-split")
assert agent_attr("fakeagent_facade", "moved", "fakeagent_missing_home")() == "pre-split"
assert agent_attr(orig, "moved", "fakeagent_missing_home")() == "pre-split"
def test_split_agent_with_pointer_uses_home_without_compat_warning(monkeypatch):
_split_facade(monkeypatch, with_pointer=True)
with warnings.catch_warnings():
warnings.simplefilter("error", _CompatWarning)
assert agent_attr("fakeagent_facade", "moved", "fakeagent_home") is _real
def test_split_agent_after_pointer_removal_uses_home(monkeypatch):
facade = _split_facade(monkeypatch, with_pointer=False)
with pytest.raises(AttributeError):
_ = facade.moved # what `from facade import moved` hits after the removal
assert agent_attr(facade, "moved", "fakeagent_home") is _real
def test_stub_on_original_module_still_wins(monkeypatch):
_split_facade(monkeypatch, with_pointer=False)
stub = lambda: "stub" # noqa: E731
monkeypatch.setattr(sys.modules["fakeagent_facade"], "moved", stub, raising=False)
assert agent_attr("fakeagent_facade", "moved", "fakeagent_home") is stub
def test_non_module_double_uses_plain_attribute(monkeypatch):
class FakeKanbanDB:
def connect(self, *, board=None):
return board
double = FakeKanbanDB()
assert agent_attr(double, "connect", "fakeagent_home")(board="b") == "b"
assert agent_attr(double, "connect_closing", "fakeagent_home", None) is None
def test_default_when_name_is_absent_everywhere(monkeypatch):
_split_facade(monkeypatch, with_pointer=False)
assert agent_attr("fakeagent_facade", "nope", "fakeagent_home", None) is None
with pytest.raises(AttributeError):
agent_attr("fakeagent_facade", "nope", "fakeagent_home")
def test_missing_agent_raises_import_error_or_returns_default():
with pytest.raises(ImportError):
agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home")
assert agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home", None) is None
def test_webui_does_not_use_removed_old_agent_paths():
"""Guard the old paths the Agent removed from being reintroduced in api/."""
import ast
from pathlib import Path
moved_imports = {
"tools.approval": {"set_current_session_key", "reset_current_session_key"},
"tools.mcp_tool": {"discover_mcp_tools", "shutdown_mcp_servers", "get_mcp_status"},
"agent.anthropic_adapter": {"read_claude_code_credentials", "is_claude_code_token_valid"},
"hermes_cli.models": {"lmstudio_model_reasoning_options"},
}
moved_kb = {"connect", "connect_closing", "dispatch_once"}
offenders = []
for path in sorted((Path(__file__).resolve().parent.parent / "api").glob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if isinstance(node, ast.ImportFrom) and node.module in moved_imports:
offenders += [(path.name, node.lineno, a.name) for a in node.names
if a.name in moved_imports[node.module]]
elif (isinstance(node, ast.Attribute) and node.attr in moved_kb
and isinstance(node.value, ast.Name) and node.value.id == "kb"):
offenders.append((path.name, node.lineno, f"kb.{node.attr}"))
elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id in {"getattr", "hasattr"} and len(node.args) >= 2
and isinstance(node.args[0], ast.Name) and node.args[0].id == "kb"
and isinstance(node.args[1], ast.Constant) and node.args[1].value in moved_kb):
offenders.append((path.name, node.lineno, f"{node.func.id}(kb, {node.args[1].value!r})"))
assert not offenders, f"old agent paths (use api.agent_compat.agent_attr): {offenders}"