hermes-agent/tests/tui_gateway/test_protocol.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

572 lines
20 KiB
Python

"""Tests for tui_gateway JSON-RPC protocol plumbing."""
import io
import json
import sys
import threading
import time
import types
from unittest.mock import MagicMock, patch
import pytest
_original_stdout = sys.stdout
@pytest.fixture(autouse=True)
def _restore_stdout():
yield
sys.stdout = _original_stdout
@pytest.fixture()
def server():
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
}):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate; _methods is NOT cleared because it's
# populated at module import time and re-registration only happens
# via reload (which we don't do).
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._live_transports.clear()
@pytest.fixture()
def capture(server):
"""Redirect server's real stdout to a StringIO and return (server, buf)."""
buf = io.StringIO()
server._real_stdout = buf
return server, buf
# ── JSON-RPC envelope ────────────────────────────────────────────────
def test_unknown_method(server):
resp = server.handle_request({"id": "1", "method": "bogus"})
assert resp["error"]["code"] == -32601
def test_ok_envelope(server):
assert server._ok("r1", {"x": 1}) == {
"jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
}
def test_err_envelope(server):
assert server._err("r2", 4001, "nope") == {
"jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
}
# ── write_json ───────────────────────────────────────────────────────
def test_write_json(capture):
server, buf = capture
assert server.write_json({"test": True})
assert json.loads(buf.getvalue()) == {"test": True}
def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
"""End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
`tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.
Reloads only the transport module — server.py is untouched so its
atexit hooks/worker pool stay intact."""
import importlib
monkeypatch.setenv("HERMES_TUI_GATEWAY_NO_FLUSH", "1")
transport_mod = importlib.reload(importlib.import_module("tui_gateway.transport"))
try:
assert transport_mod._DISABLE_FLUSH is True
finally:
# Restore the env-disabled state so other tests see the default.
monkeypatch.delenv("HERMES_TUI_GATEWAY_NO_FLUSH", raising=False)
importlib.reload(transport_mod)
# ── _emit ────────────────────────────────────────────────────────────
def test_emit_with_payload(capture):
server, buf = capture
server._emit("test.event", "s1", {"key": "val"})
msg = json.loads(buf.getvalue())
assert msg["method"] == "event"
assert msg["params"]["type"] == "test.event"
assert msg["params"]["session_id"] == "s1"
assert msg["params"]["payload"]["key"] == "val"
# ── Blocking prompt round-trip ───────────────────────────────────────
def test_block_and_respond(capture):
server, _ = capture
result = [None]
threading.Thread(
target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
).start()
for _ in range(100):
if server._pending:
break
threading.Event().wait(0.01)
rid = next(iter(server._pending))
server._answers[rid] = "my_answer"
# _pending values are (sid, Event) tuples — unpack to set the Event
_, ev = server._pending[rid]
ev.set()
threading.Event().wait(0.1)
assert result[0] == "my_answer"
@pytest.mark.parametrize(
"event",
["secret.request", "sudo.request", "clarify.request", "terminal.read.request"],
)
def test_sensitive_prompt_timeout_emits_expiry(capture, event):
server, buf = capture
assert server._block(event, "s1", {}, timeout=0) == ""
messages = [json.loads(line) for line in buf.getvalue().splitlines()]
request, expiry = [message["params"] for message in messages]
assert request["type"] == event
assert expiry["type"] == event.removesuffix(".request") + ".expire"
assert expiry["session_id"] == "s1"
assert expiry["payload"]["request_id"] == request["payload"]["request_id"]
@pytest.mark.parametrize(
("method", "value_key"),
[
("secret.respond", "value"),
("sudo.respond", "password"),
("clarify.respond", "answer"),
("terminal.read.respond", "text"),
],
)
def test_late_prompt_response_is_idempotent(server, method, value_key):
"""All four blocking bridges tolerate a late reply after their request has
expired — the `*.respond` returns a graceful `{"status": "expired"}` instead
of the raw 4009 protocol error a client would otherwise surface verbatim."""
response = server.handle_request(
{
"id": "late-response",
"method": method,
"params": {"request_id": "expired-request", value_key: ""},
}
)
assert response["result"] == {"status": "expired"}
def test_clear_pending(server):
ev = threading.Event()
# _pending values are (sid, Event) tuples
server._pending["r1"] = ("sid-x", ev)
server._clear_pending()
assert ev.is_set()
assert server._answers["r1"] == ""
# ── Session lookup ───────────────────────────────────────────────────
def test_sess_missing(server):
_, err = server._sess({"session_id": "nope"}, "r1")
assert err["error"]["code"] == 4001
# ── session.resume payload ────────────────────────────────────────────
def test_session_resume_returns_hydrated_messages(server, monkeypatch):
class _DB:
def get_session(self, _sid):
return {"id": "20260409_010101_abc123"}
def get_session_by_title(self, _title):
return None
def reopen_session(self, _sid):
return None
def get_resume_conversations(self, session_id):
return (
self.get_messages_as_conversation(session_id, repair_alternation=True),
self.get_messages_as_conversation(session_id, include_ancestors=True),
)
def get_ancestor_display_prefix(self, _sid):
return []
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
return [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "yo", "reasoning": "thoughts"},
{"role": "tool", "content": "searched"},
{"role": "assistant", "content": " "},
{"role": "assistant", "content": None},
{"role": "narrator", "content": "skip"},
]
monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kwargs: object())
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None)
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"})
resp = server.handle_request(
{
"id": "r1",
"method": "session.resume",
# eager_build: exercise the synchronous build path (this test
# monkeypatches _make_agent/_init_session/_session_info).
"params": {"session_id": "20260409_010101_abc123", "cols": 100, "eager_build": True},
}
)
assert "error" not in resp
assert resp["result"]["message_count"] == 3
assert resp["result"]["messages"] == [
{"role": "user", "text": "hello"},
{"role": "assistant", "text": "yo", "reasoning": "thoughts"},
{"role": "tool", "name": "tool", "context": ""},
]
def test_enforce_session_cap_evicts_oldest_detached_only(server, monkeypatch):
"""The LRU cap frees the least-recently-active DETACHED sessions when over
the limit, and never a live-transport / running / mid-build one."""
monkeypatch.setattr(server, "_load_cfg", lambda: {"max_live_sessions": 2})
evicted: list[str] = []
monkeypatch.setattr(
server, "_close_session_by_id", lambda sid, end_reason=None: evicted.append(sid)
)
def _ready() -> threading.Event:
ev = threading.Event()
ev.set()
return ev
detached = server._detached_ws_transport
live = object() # no _closed attr -> live transport, never evictable
server._sessions.clear()
server._sessions.update(
{
"old_detached": {"transport": detached, "last_active": 100.0, "agent_ready": _ready()},
"new_detached": {"transport": detached, "last_active": 300.0, "agent_ready": _ready()},
"running_detached": {
"transport": detached,
"last_active": 50.0,
"running": True,
"agent_ready": _ready(),
},
"focused_live": {"transport": live, "last_active": 200.0, "agent_ready": _ready()},
}
)
server._enforce_session_cap()
# 4 sessions, cap 2 -> evict 2. Only detached+idle+built are eligible, oldest
# first; the running one and the live-transport one are exempt.
assert evicted == ["old_detached", "new_detached"]
def test_sync_session_key_after_compress_reanchors_active_session_lease(
server, monkeypatch, tmp_path
):
home = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.active_sessions import (
active_session_registry_snapshot,
try_acquire_active_session,
)
lease, message = try_acquire_active_session(
session_id="session-old",
surface="tui",
config={"max_concurrent_sessions": 1},
metadata={"live_session_id": "ui-1"},
)
assert message is None
assert lease is not None
session = {
"active_session_lease": lease,
"agent": types.SimpleNamespace(session_id="session-new"),
"session_key": "session-old",
}
fake_approval = types.SimpleNamespace(
disable_session_yolo=lambda *_args, **_kwargs: None,
enable_session_yolo=lambda *_args, **_kwargs: None,
is_session_yolo_enabled=lambda *_args, **_kwargs: False,
register_gateway_notify=lambda *_args, **_kwargs: None,
unregister_gateway_notify=lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args, **_kwargs: None)
with patch.dict(sys.modules, {"tools.approval": fake_approval}):
server._sync_session_key_after_compress("ui-1", session)
snapshot = active_session_registry_snapshot()
assert session["session_key"] == "session-new"
assert lease.session_id == "session-new"
assert [entry["session_id"] for entry in snapshot] == ["session-new"]
lease.release()
def test_make_agent_accepts_list_system_prompt(server, monkeypatch):
captured = {}
class _Agent:
def __init__(self, **kwargs):
captured.update(kwargs)
self.model = kwargs.get("model", "")
monkeypatch.setitem(sys.modules, "run_agent", types.SimpleNamespace(AIAgent=_Agent))
monkeypatch.setitem(
sys.modules,
"hermes_cli.runtime_provider",
types.SimpleNamespace(
resolve_runtime_provider=lambda **_kwargs: {
"provider": "test",
"base_url": None,
"api_key": None,
"api_mode": None,
}
),
)
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"system_prompt": ["one", "two"]}})
monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test/model", "test"))
monkeypatch.setattr(server, "_get_db", lambda: None)
server._make_agent("sid", "session-key", session_id="session-key")
assert captured["ephemeral_system_prompt"] == "one\ntwo"
# ── Config I/O ───────────────────────────────────────────────────────
def test_config_roundtrip(server, tmp_path):
server._hermes_home = tmp_path
server._save_cfg({"model": "test/model"})
assert server._load_cfg()["model"] == "test/model"
# ── _cli_exec_blocked ────────────────────────────────────────────────
@pytest.mark.parametrize("argv", [
[],
["setup"],
["gateway"],
["sessions", "browse"],
["config", "edit"],
])
def test_cli_exec_blocked(server, argv):
assert server._cli_exec_blocked(argv) is not None
# ── slash.exec skill command interception ────────────────────────────
def test_slash_exec_rejects_skill_commands(server):
"""slash.exec must reject skill commands so the TUI falls through to command.dispatch."""
# Register a mock session
sid = "test-session"
server._sessions[sid] = {"session_key": sid, "agent": None}
# Mock scan_skill_commands to return a known skill
fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}}
with patch("agent.skill_commands.get_skill_commands", return_value=fake_skills):
resp = server.handle_request({
"id": "r1",
"method": "slash.exec",
"params": {"command": "hermes-agent-dev", "session_id": sid},
})
# Should return an error so the TUI's .catch() fires command.dispatch
assert "error" in resp
assert resp["error"]["code"] == 4018
assert "skill command" in resp["error"]["message"]
def test_command_dispatch_queue_sends_message(server):
"""command.dispatch /queue returns {type: 'send', message: ...} for the TUI."""
sid = "test-session"
server._sessions[sid] = {"session_key": sid}
resp = server.handle_request({
"id": "r1",
"method": "command.dispatch",
"params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid},
})
assert "error" not in resp
result = resp["result"]
assert result["type"] == "send"
assert result["message"] == "tell me about quantum computing"
def test_skills_manage_search_uses_tools_hub_sources(server):
result = type("Result", (), {
"description": "Build better terminal demos",
"name": "showroom",
})()
auth = MagicMock(return_value="auth")
router = MagicMock(return_value=["source"])
search = MagicMock(return_value=[result])
fake_hub = types.SimpleNamespace(
GitHubAuth=auth,
create_source_router=router,
unified_search=search,
)
with patch.dict(sys.modules, {"tools.skills_hub": fake_hub}):
resp = server.handle_request({
"id": "skills-search",
"method": "skills.manage",
"params": {"action": "search", "query": "showroom"},
})
assert "error" not in resp
assert resp["result"] == {
"results": [{"description": "Build better terminal demos", "name": "showroom"}]
}
auth.assert_called_once_with()
router.assert_called_once_with("auth")
search.assert_called_once_with("showroom", ["source"], source_filter="all", limit=20)
# ── dispatch(): pool routing for long handlers (#12546) ──────────────
def test_dispatch_runs_short_handlers_inline(server):
"""Non-long handlers return their response synchronously from dispatch()."""
server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})
resp = server.dispatch({"id": "r1", "method": "fast.ping", "params": {}})
assert resp == {"jsonrpc": "2.0", "id": "r1", "result": {"pong": True}}
@pytest.mark.parametrize("completion_method", ["complete.path", "complete.slash"])
def test_completion_handlers_are_pool_routed(completion_method, server):
"""complete.path/complete.slash must run on the pool, never the reader thread.
Regression for #21123: completion ran inline, so a slow git ls-files /
skill-scan blocked prompt.submit and froze the TUI for the 120s RPC timeout.
"""
assert completion_method in server._LONG_HANDLERS
def test_skin_live_switch_end_to_end(server, tmp_path, monkeypatch):
"""Real config + skin files: activating a skin (as `hermes config set` does)
makes the per-tool reconcile broadcast skin.changed with the resolved palette.
Exercises _load_cfg → _skin_sig → resolve_skin → _emit with no mocks in between."""
import hermes_cli.skin_engine as skin_engine
(tmp_path / "skins").mkdir()
(tmp_path / "skins" / "midnight.yaml").write_text(
"name: midnight\ndescription: t\ncolors:\n banner_title: '#00ffcc'\n background: '#001010'\n"
)
monkeypatch.setattr(skin_engine, "get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(server, "_hermes_home", tmp_path)
monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
server._cfg_cache = server._cfg_mtime = server._cfg_path = None
emitted = []
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
# Baseline (default) — seeds the signature.
(tmp_path / "config.yaml").write_text("display:\n skin: default\n")
server._broadcast_skin_if_changed()
emitted.clear()
# Activate midnight, as `hermes config set display.skin midnight` would.
time.sleep(0.01) # ensure the config mtime moves
(tmp_path / "config.yaml").write_text("display:\n skin: midnight\n")
server._broadcast_skin_if_changed()
assert [ev for ev, _ in emitted] == ["skin.changed"]
assert emitted[0][1]["name"] == "midnight"
assert emitted[0][1]["colors"]["banner_title"] == "#00ffcc"
def test_broadcast_skin_if_changed_on_any_signature_move(server, monkeypatch):
"""A skin the agent changes mid-turn goes live once per real move: a name
switch (incl. switch-then-revert) OR an in-place color edit to the active skin
(same name, new file mtime). An unchanged signature never re-broadcasts."""
emitted = []
# switch, no-op, switch, then a color edit (same name, bumped mtime).
sigs = iter([("neon", 1.0), ("neon", 1.0), ("forest", 1.0), ("forest", 2.0)])
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, payload)))
monkeypatch.setattr(server, "_last_skin_sig", None, raising=False)
monkeypatch.setattr(server, "_skin_sig", lambda: next(sigs))
monkeypatch.setattr(server, "resolve_skin", lambda: {"name": "x", "colors": {}})
for _ in range(4):
server._broadcast_skin_if_changed()
assert [ev for ev, _ in emitted] == ["skin.changed"] * 3
# ── global-event broadcast (session-less events reach every WS client) ──
class _RecordingTransport:
"""Minimal Transport stand-in that records the frames written to it."""
def __init__(self) -> None:
self.frames: list[dict] = []
def write(self, obj: dict) -> bool:
self.frames.append(obj)
return True
def close(self) -> None:
pass
def test_unregister_live_transport_stops_delivery(capture):
"""A disconnected peer (unregistered in the ws finally block) receives nothing
— and a stale write is never attempted against its closed socket."""
server, buf = capture
a = _RecordingTransport()
server.register_live_transport(a)
server.unregister_live_transport(a)
server._broadcast_global_event("skin.changed", {"name": "x"})
assert a.frames == []
# No live transports left → fell back to stdio.
assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed"