Files
hermes-webui/tests/test_agent_compat.py
Mike McHargueandClaude Opus 5 8abf95335b test+docs: exercise moved-name call sites; document the agent_compat contract
Review follow-up for the Hermes Agent moved-name fix.

Tests: replace the AST source guard with behavior tests that run each
affected WebUI entry point against two fake Agent shapes, pre_split (name on
the original module) and pointer_removed (name only in its new home):
approval session identity bind/reset, MCP runtime status, /reload-mcp,
Claude Code credential read, LM Studio reasoning options, and kanban
connections (closed after use), board counts, SSE event feed, and dispatch.
All 8 pointer_removed cases fail against the pre-fix call sites; the
pre_split cases pass on both.

Docs: ARCHITECTURE.md gains the api/agent_compat.py inventory entry and
section 4.9 covering ownership (Agent's compat_manifest.json), resolution
order, call-site rules, and the removal policy.

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

300 lines
11 KiB
Python

"""Hermes Agent names that moved modules keep working at every WebUI call site.
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.
The call-site tests run each affected WebUI entry point against two fake Agent
shapes: ``pre_split`` (the name still lives on its original module) and
``pointer_removed`` (the name lives only in its new home module, and the
original module no longer resolves it).
"""
import contextlib
import sqlite3
import sys
import threading
import types
import warnings
from types import SimpleNamespace
import pytest
from api.agent_compat import agent_attr
SHAPES = ("pre_split", "pointer_removed")
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 _install_agent(monkeypatch, shape, facade, moved, native=None):
"""Install a fake Agent module ``facade``.
``moved`` maps each new home module to the ``{name: object}`` it now owns.
``pre_split`` puts those names on ``facade``; ``pointer_removed`` installs
them only in their home modules. ``native`` names always stay on ``facade``.
"""
names = dict(native or {})
for home, attrs in moved.items():
if shape == "pre_split":
names.update(attrs)
else:
_install(monkeypatch, home, **attrs)
return _install(monkeypatch, facade, **names)
# ── resolver ─────────────────────────────────────────────────────────────────
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
# ── WebUI call sites ─────────────────────────────────────────────────────────
@pytest.mark.parametrize("shape", SHAPES)
def test_turn_session_identity_binds_and_resets_agent_approval_key(monkeypatch, shape):
import api.streaming as streaming
calls = []
def set_current_session_key(sid):
calls.append(("set", sid))
return f"token-{sid}"
def reset_current_session_key(token):
calls.append(("reset", token))
_install_agent(monkeypatch, shape, "tools.approval", {"tools.approval_context": {
"set_current_session_key": set_current_session_key,
"reset_current_session_key": reset_current_session_key,
}})
tokens = streaming._set_turn_session_identity("sess-1")
assert tokens.get("approval") == "token-sess-1"
streaming._reset_turn_session_identity(tokens)
assert calls == [("set", "sess-1"), ("reset", "token-sess-1")]
@pytest.mark.parametrize("shape", SHAPES)
def test_mcp_runtime_status_reads_agent_registry(monkeypatch, shape):
import api.routes as routes
statuses = [{"name": "alpha", "connected": True}, {"name": "beta", "connected": False}]
_install_agent(monkeypatch, shape, "tools.mcp_tool", {"tools.mcp_tool_discovery": {
"get_mcp_status": lambda: statuses,
}})
assert routes._mcp_runtime_status_by_name() == {"alpha": statuses[0], "beta": statuses[1]}
@pytest.mark.parametrize("shape", SHAPES)
def test_reload_mcp_command_shuts_down_and_rediscovers(monkeypatch, shape):
import api.commands as commands
servers = {"old": object()}
calls = []
def shutdown_mcp_servers():
calls.append("shutdown")
servers.clear()
def discover_mcp_tools():
calls.append("discover")
servers.update(old=object(), new=object())
return ["t1", "t2", "t3"]
_install_agent(
monkeypatch, shape, "tools.mcp_tool",
{
"tools.mcp_tool_lifecycle": {"shutdown_mcp_servers": shutdown_mcp_servers},
"tools.mcp_tool_discovery": {"discover_mcp_tools": discover_mcp_tools},
},
native={"_servers": servers, "_lock": threading.Lock()},
)
out = commands._run_reload_mcp_command()
assert calls == ["shutdown", "discover"]
assert "Reconnected: old" in out
assert "Added: new" in out
assert "3 tool(s) available across 2 server(s)" in out
@pytest.mark.parametrize("shape", SHAPES)
def test_claude_code_credentials_read_through_agent(monkeypatch, shape):
import api.oauth as oauth
creds = {"accessToken": "cc-access", "refreshToken": "cc-refresh"}
_install_agent(monkeypatch, shape, "agent.anthropic_adapter", {"agent.anthropic_credentials": {
"read_claude_code_credentials": lambda: creds,
"is_claude_code_token_valid": lambda value: value is creds,
}})
assert oauth._read_claude_code_credentials() is creds
@pytest.mark.parametrize("shape", SHAPES)
def test_lmstudio_reasoning_options_use_agent_probe(monkeypatch, shape):
import api.config as config
seen = []
def lmstudio_model_reasoning_options(model, base_url, api_key=None, timeout=5.0):
seen.append((model, base_url, timeout))
return ["low", "high"]
monkeypatch.setattr(config, "_lmstudio_reasoning_probe_options_fallback", lambda *a, **k: ["fallback"])
_install_agent(monkeypatch, shape, "hermes_cli.models", {"hermes_cli.models_local": {
"lmstudio_model_reasoning_options": lmstudio_model_reasoning_options,
}})
assert config._lmstudio_model_reasoning_options("qwen", "http://127.0.0.1:1234", timeout=2.0) == ["low", "high"]
assert seen == [("qwen", "http://127.0.0.1:1234", 2.0)]
_KANBAN_SQL = """
CREATE TABLE tasks (id INTEGER PRIMARY KEY, status TEXT);
INSERT INTO tasks (status) VALUES ('todo'), ('todo'), ('done'), ('archived');
CREATE TABLE task_events (
id INTEGER PRIMARY KEY, task_id TEXT, run_id TEXT, kind TEXT, payload TEXT, created_at INTEGER
);
INSERT INTO task_events VALUES (1, 't1', NULL, 'created', '{"a": 1}', 100), (2, 't2', 'r1', 'moved', NULL, 101);
"""
def _install_kanban_agent(monkeypatch, shape):
import api.kanban_bridge as bridge
def connect(*, board=None):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript(_KANBAN_SQL)
return conn
def connect_closing(*, board=None):
return contextlib.closing(connect(board=board))
def dispatch_once(conn, dry_run=False, max_spawn=8):
tasks = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
return {"dry_run": dry_run, "max_spawn": max_spawn, "tasks_seen": tasks}
kb = _install_agent(
monkeypatch, shape, "hermes_cli.kanban_db",
{
"hermes_cli.kanban_db_connect": {"connect": connect, "connect_closing": connect_closing},
"hermes_cli.kanban_db_dispatch": {"dispatch_once": dispatch_once},
},
native={"init_db": lambda *, board=None: None, "board_exists": lambda slug: True, "DEFAULT_BOARD": "default"},
)
pkg = types.ModuleType("hermes_cli")
pkg.kanban_db = kb
monkeypatch.setitem(sys.modules, "hermes_cli", pkg)
return bridge
@pytest.mark.parametrize("shape", SHAPES)
def test_kanban_connection_is_closed_after_use(monkeypatch, shape):
bridge = _install_kanban_agent(monkeypatch, shape)
with bridge._conn(board=None) as conn:
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 4
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1") # connect_closing, not a leaked raw connection
@pytest.mark.parametrize("shape", SHAPES)
def test_kanban_counts_and_event_feed_read_agent_db(monkeypatch, shape):
bridge = _install_kanban_agent(monkeypatch, shape)
assert bridge._board_counts_for_slug("default") == {"todo": 2, "done": 1}
cursor, events = bridge._kanban_sse_fetch_new(None, 0)
assert cursor == 2
assert [(e["id"], e["kind"], e["payload"]) for e in events] == [(1, "created", {"a": 1}), (2, "moved", None)]
@pytest.mark.parametrize("shape", SHAPES)
def test_kanban_dispatch_runs_agent_dispatcher(monkeypatch, shape):
bridge = _install_kanban_agent(monkeypatch, shape)
parsed = SimpleNamespace(path="/api/kanban/dispatch", query="dry_run=true&max=3")
assert bridge._dispatch_payload(parsed) == {"dry_run": True, "max_spawn": 3, "tasks_seen": 4}