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>
300 lines
11 KiB
Python
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}
|