"""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(): # The sys.modules mocks only need to cover the *initial* import — once # tui_gateway.server is cached, they are inert. Keeping them active for # the whole test poisons any module first imported inside a test body: # e.g. hermes_cli.active_sessions would bind the mocked get_hermes_home # (a fixed shared path) forever, leaking active-session registry entries # across every later test in the process. Scope the patch to the import. 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") # Snapshot the RPC registry: several tests below stub handlers # ("slash.exec", "fast.ping", ...) directly in the module-level dict, # which is shared with every other test file in the process. methods = dict(mod._methods) real_stdout = mod._real_stdout yield mod # Reset module-level 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. Restoring the dicts in place gives the next # test a clean slate. mod._methods.clear() mod._methods.update(methods) mod._real_stdout = real_stdout for sid in list(mod._sessions): mod._close_session_by_id(sid, end_reason="test_cleanup") mod._pending.clear() mod._answers.clear() mod._live_transports.clear() def test_shared_fixture_cleanup_uses_full_session_teardown(server, monkeypatch): """The cross-file autouse cleanup must close every retained resource.""" from tests import conftest closed = {"worker": 0, "agent": 0, "lease": 0} class _Closable: def __init__(self, key): self.key = key def close(self): closed[self.key] += 1 class _Lease: def release(self): closed["lease"] += 1 monkeypatch.setattr(server, "_get_db", lambda: None) server._sessions["leaked"] = { "session_key": "leaked", "agent": _Closable("agent"), "slash_worker": _Closable("worker"), "active_session_lease": _Lease(), "history": [], } conftest._teardown_tui_server_sessions(server) assert server._sessions == {} assert closed == {"worker": 1, "agent": 1, "lease": 1} @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"