"""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 from pathlib import Path 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"}, } @pytest.mark.parametrize("kind", ["legacy", "hard-only", "dynamic-getattr"]) def test_session_interrupt_uses_explicit_stop_compatibility(server, monkeypatch, kind): calls = [] class _Legacy: def interrupt(self): calls.append("legacy") class _HardOnly: def hard_interrupt(self): calls.append("hard") class _Dynamic: def interrupt(self): calls.append("legacy") def __getattr__(self, name): if name == "hard_interrupt": return lambda: calls.append("fabricated-hard") raise AttributeError(name) agent = { "legacy": _Legacy(), "hard-only": _HardOnly(), "dynamic-getattr": _Dynamic(), }[kind] session = { "agent": agent, "history_lock": threading.Lock(), "running": True, "queued_prompt": "later", "session_key": "session-key", "_run_thread": None, } monkeypatch.setattr(server, "_tts_stream_stop", lambda: None) monkeypatch.setattr(server, "_sess_nowait", lambda _params, _rid: (session, None)) monkeypatch.setattr(server, "_sess", lambda _params, _rid: (session, None)) monkeypatch.setattr(server, "_session_uses_compute_host", lambda _session: False) monkeypatch.setattr(server, "_clear_pending", lambda _sid: None) response = server._methods["session.interrupt"]( "stop", {"session_id": "ui-session"} ) assert response["result"]["status"] == "interrupted" assert calls == ["hard" if kind == "hard-only" else "legacy"] # ── 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_live_session_payload_replays_pending_approval(server, monkeypatch): """A reattached client receives the approval that was emitted while detached.""" from tools import approval session = { "agent": types.SimpleNamespace(), "cols": 80, "created_at": 1.0, "history": [], "history_lock": threading.Lock(), "running": True, "session_key": "stored-session", } first = { "choices": ["once", "deny"], "command": "rm -rf /tmp/example", "description": "recursive delete", } second = {"command": "rm -rf /tmp/later", "description": "later"} saved_queue = approval._gateway_queues.pop("stored-session", None) approval._gateway_queues["stored-session"] = [ approval._ApprovalEntry(first), approval._ApprovalEntry(second), ] monkeypatch.setattr(server, "_approval_request_payload", lambda data: dict(data or {})) try: payload = server._live_session_payload("runtime-session", session) finally: approval._gateway_queues.pop("stored-session", None) if saved_queue is not None: approval._gateway_queues["stored-session"] = saved_queue assert payload["pending_approval"] is not first replayed = payload["pending_approval"] # request_id is injected by _ApprovalEntry so reconnecting clients can # correlate their approval.respond with the exact queued request. assert replayed.pop("request_id") assert replayed == first def test_live_session_payload_replays_pending_clarify(server): """A reattached client also receives a clarify question emitted while detached.""" session = { "agent": types.SimpleNamespace(), "cols": 80, "created_at": 1.0, "history": [], "history_lock": threading.Lock(), "running": True, "session_key": "stored-session", } clarify_payload = { "choices": ["staging", "production"], "question": "Which deployment target?", "request_id": "rid-clarify", } with server._prompt_lock: server._pending["rid-clarify"] = ("runtime-session", threading.Event()) server._pending_prompt_payloads["rid-clarify"] = ( "clarify.request", dict(clarify_payload), ) try: payload = server._live_session_payload("runtime-session", session) other = server._live_session_payload("other-session", session) finally: with server._prompt_lock: server._pending.pop("rid-clarify", None) server._pending_prompt_payloads.pop("rid-clarify", None) assert payload["pending_clarify"] == clarify_payload # Snapshot, not a live reference into the registry. assert payload["pending_clarify"] is not clarify_payload # Scoped to the owning runtime session only. assert "pending_clarify" not in other 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"} # ── clarify batch (multi-question) bridge ──────────────────────────── def _drain_batch_block(server, qids, timeout=5, payload=None): """Run a batch _block on a worker thread and return (thread, result box, emitted request payload). The caller resolves questions via handle_request and then joins.""" box = {} def run(): box["answer"] = server._block( "clarify.request", "s1", dict(payload or {"questions": [{"qid": q, "question": q} for q in qids]}), timeout=timeout, batch_qids=list(qids), ) thread = threading.Thread(target=run, daemon=True) thread.start() # Wait for the request to be registered so respond calls can find it. deadline = time.monotonic() + 2 while time.monotonic() < deadline: with server._prompt_lock: if server._batch_clarify: rid = next(iter(server._batch_clarify)) return thread, box, rid time.sleep(0.01) raise AssertionError("batch clarify request never registered") def test_clarify_batch_resolves_when_all_questions_locked(capture): server, buf = capture thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) first = server.handle_request({ "id": "a1", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q1", "answer": "beta"}, }) assert first["result"]["status"] == "ok" assert first["result"]["remaining"] == ["q0"] assert thread.is_alive() # one question left — still blocking second = server.handle_request({ "id": "a2", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "alpha"}, }) assert second["result"]["status"] == "ok" assert second["result"]["remaining"] == [] thread.join(timeout=5) assert not thread.is_alive() assert json.loads(box["answer"]) == {"answers": {"q0": "alpha", "q1": "beta"}} def test_clarify_batch_answer_update_overwrites_before_completion(server): thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) server.handle_request({ "id": "a1", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "first"}, }) server.handle_request({ "id": "a2", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "changed"}, }) server.handle_request({ "id": "a3", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q1", "answer": "done"}, }) thread.join(timeout=5) assert json.loads(box["answer"])["answers"]["q0"] == "changed" def test_clarify_batch_empty_answer_is_a_locked_skip(server): """Skipping one question locks an empty answer — it counts toward completion instead of leaving the batch waiting.""" thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) server.handle_request({ "id": "a1", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": ""}, }) server.handle_request({ "id": "a2", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q1", "answer": "kept"}, }) thread.join(timeout=5) assert json.loads(box["answer"]) == {"answers": {"q0": "", "q1": "kept"}} def test_clarify_batch_unknown_question_id_rejected(server): thread, box, rid = _drain_batch_block(server, ["q0"]) response = server.handle_request({ "id": "bad", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q9", "answer": "x"}, }) assert response["error"]["code"] == 4002 server.handle_request({ "id": "ok", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "fine"}, }) thread.join(timeout=5) def test_clarify_batch_timeout_keeps_locked_answers(capture): """Locked answers survive the deadline: the tool sees the partials plus timed_out instead of an empty string.""" server, buf = capture thread, box, rid = _drain_batch_block(server, ["q0", "q1"], timeout=1) server.handle_request({ "id": "a1", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "kept"}, }) thread.join(timeout=10) assert not thread.is_alive() result = json.loads(box["answer"]) assert result == {"answers": {"q0": "kept"}, "timed_out": True} # The expire notification still fires for the un-finished batch. messages = [json.loads(line) for line in buf.getvalue().splitlines()] assert any(m["params"]["type"] == "clarify.expire" for m in messages) def test_clarify_batch_cancel_all_returns_empty(server): """A respond without question_id cancels the whole batch (Esc path).""" thread, box, rid = _drain_batch_block(server, ["q0", "q1"]) server.handle_request({ "id": "cancel", "method": "clarify.respond", "params": {"request_id": rid, "answer": ""}, }) thread.join(timeout=5) assert box["answer"] == "" def test_clarify_batch_late_question_respond_is_idempotent(server): response = server.handle_request({ "id": "late", "method": "clarify.respond", "params": {"request_id": "gone", "question_id": "q0", "answer": "x"}, }) assert response["result"] == {"status": "expired"} def test_clarify_batch_state_cleared_after_resolution(server): thread, box, rid = _drain_batch_block(server, ["q0"]) server.handle_request({ "id": "a", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "x"}, }) thread.join(timeout=5) with server._prompt_lock: assert rid not in server._batch_clarify assert rid not in server._pending def test_clarify_block_helper_builds_batch_payload(capture): """_clarify_block forwards only wire fields (qid/question/choices/ multi_select) — the tool-side normalized entries carry extra keys the renderer must not see.""" server, buf = capture normalized = [ { "qid": "q0", "id": "approach", "question": "Which?", "choices": ["a (Recommended)", "b"], "choices_offered": ["a", "b"], "multi_select": False, }, ] box = {} def run(): box["answer"] = server._clarify_block("s1", "", None, questions=normalized) thread = threading.Thread(target=run, daemon=True) thread.start() deadline = time.monotonic() + 2 rid = None while time.monotonic() < deadline and rid is None: with server._prompt_lock: rid = next(iter(server._batch_clarify), None) time.sleep(0.01) assert rid server.handle_request({ "id": "a", "method": "clarify.respond", "params": {"request_id": rid, "question_id": "q0", "answer": "a"}, }) thread.join(timeout=5) messages = [json.loads(line) for line in buf.getvalue().splitlines()] request = messages[0]["params"] assert request["type"] == "clarify.request" sent = request["payload"]["questions"][0] assert set(sent) == {"qid", "question", "choices", "multi_select"} assert "id" not in sent and "choices_offered" not in sent def test_approval_pending_replays_unresolved_requests(server, monkeypatch): from tools import approval server._sessions["ui-1"] = {"session_key": "agent-1", "history": []} pending = [{"request_id": "req-1", "command": "danger"}] monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: pending if key == "agent-1" else []) response = server.handle_request( {"id": "r1", "method": "approval.pending", "params": {"session_id": "ui-1"}} ) assert response["result"] == {"approvals": pending} def test_approval_received_acknowledges_exact_request(server, monkeypatch): from tools import approval server._sessions["ui-1"] = {"session_key": "agent-1", "history": []} calls = [] monkeypatch.setattr( approval, "ack_gateway_approval", lambda key, request_id: calls.append((key, request_id)) or True, ) response = server.handle_request( { "id": "r2", "method": "approval.received", "params": {"session_id": "ui-1", "request_id": "req-1"}, } ) assert response["result"] == {"acknowledged": True} assert calls == [("agent-1", "req-1")] def test_approval_response_correlates_request_id(server, monkeypatch): from tools import approval server._sessions["ui-1"] = {"session_key": "agent-1", "history": []} calls = [] monkeypatch.setattr( approval, "resolve_gateway_approval", lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1, ) response = server.handle_request( { "id": "r3", "method": "approval.respond", "params": {"session_id": "ui-1", "request_id": "req-1", "choice": "once"}, } ) assert response["result"] == {"resolved": 1} assert calls == [("agent-1", "once", {"resolve_all": False, "request_id": "req-1"})] def test_approval_respond_falls_back_to_request_id_lookup(server, monkeypatch): """A stale live sid must not 4001 an approval answer when the request_id resolves to a live session (durable-identity fallback, #91684).""" from tools import approval live = {"session_key": "agent-live", "history": []} server._sessions["ui-live"] = live calls = [] monkeypatch.setattr( approval, "list_gateway_approvals", lambda key: [{"request_id": "req-91684"}] if key == "agent-live" else [], ) monkeypatch.setattr( approval, "resolve_gateway_approval", lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1, ) response = server.handle_request( { "id": "r-fallback", "method": "approval.respond", "params": { "session_id": "gone-sid", "request_id": "req-91684", "choice": "once", }, } ) assert response["result"] == {"resolved": 1} assert calls == [ ("agent-live", "once", {"resolve_all": False, "request_id": "req-91684"}) ] def test_approval_respond_falls_back_to_stored_session_id(server, monkeypatch): """session_id holding a STORED id maps to the live runtime record.""" from tools import approval live = {"session_key": "stored-91684", "history": []} server._sessions["ui-stored"] = live calls = [] monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: []) monkeypatch.setattr( approval, "resolve_gateway_approval", lambda key, choice, **kwargs: calls.append((key, choice, kwargs)) or 1, ) response = server.handle_request( { "id": "r-stored", "method": "approval.respond", "params": {"session_id": "stored-91684", "choice": "deny"}, } ) assert response["result"] == {"resolved": 1} assert calls == [ ("stored-91684", "deny", {"resolve_all": False, "request_id": None}) ] def test_approval_respond_4001_when_nothing_resolves(server, monkeypatch): from tools import approval monkeypatch.setattr(approval, "list_gateway_approvals", lambda key: []) response = server.handle_request( { "id": "r-nope", "method": "approval.respond", "params": {"session_id": "nope", "request_id": "req-x", "choice": "once"}, } ) assert response["error"]["code"] == 4001 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_session_resume_rejects_runaway_transcript_before_history_load( server, monkeypatch ): class _DB: def get_session(self, sid): return {"id": sid, "message_count": 20_001} def get_session_by_title(self, _title): return None def resolve_resume_session_id(self, sid): return sid def reopen_session(self, _sid): raise AssertionError("oversized session must be rejected before reopen") monkeypatch.setattr(server, "_get_db", lambda: _DB()) response = server.handle_request( { "id": "r1", "method": "session.resume", "params": { "session_id": "runaway-session", "omit_messages": True, }, } ) assert response["error"]["code"] == 4130 assert "safe resume limit is 20000" in response["error"]["message"] def test_session_resume_deferred_and_omitted_paths_guard_the_tip_only(server, monkeypatch): """A deep compression lineage behind a small tip must open on Desktop. Desktop's cold resume sends ``defer_history`` + ``omit_messages`` and pages the transcript over REST, so the process only ever holds the tip segment. Counting the whole lineage there returned 4130 for the healthiest sessions (85 compaction segments / ~29k rows / ~700-row tip: Bot Chat stuck on "Waking up…"). The guard must count what each path loads. """ calls = [] class _DB: def get_session(self, sid): return {"id": sid, "message_count": 28_730} def get_session_by_title(self, _title): return None def resolve_resume_session_id(self, sid): return sid def assert_resume_safe(self, sid, max_messages=None, *, tip_only=False): calls.append(tip_only) if not tip_only: from hermes_state import SessionResumeTooLargeError raise SessionResumeTooLargeError(20_001, 20_000) return 666 def reopen_session(self, _sid): raise RuntimeError("stop before history load") monkeypatch.setattr(server, "_get_db", lambda: _DB()) monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None) for params in ( {"defer_history": True, "omit_messages": True, "source": "desktop"}, {"omit_messages": True}, {"lazy": True}, ): calls.clear() response = server.handle_request( { "id": "r-tip", "method": "session.resume", "params": {"session_id": "deep-lineage", **params}, } ) err = response.get("error") or {} assert err.get("code") != 4130, params assert calls == [True], params # The non-deferred, non-omitted resume materializes the full lineage in # memory, so it keeps the lineage-wide bound. calls.clear() response = server.handle_request( {"id": "r-full", "method": "session.resume", "params": {"session_id": "deep-lineage"}} ) assert response["error"]["code"] == 4130 assert calls == [False] def test_deferred_hydration_falls_back_to_tip_when_lineage_exceeds_limit(server, monkeypatch): """The hydration worker never loads a lineage the guard would refuse.""" import threading from hermes_state import SessionResumeTooLargeError tip = [{"role": "user", "content": "tip"}] reads = [] class _DB: def reopen_session(self, _sid): return True def assert_resume_safe(self, sid, max_messages=None, *, tip_only=False): if not tip_only: raise SessionResumeTooLargeError(20_001, 20_000) return 1 def get_resume_conversations(self, _sid): reads.append("lineage") raise AssertionError("must not materialize the runaway lineage") def get_ancestor_display_prefix(self, _sid): reads.append("prefix") raise AssertionError("must not materialize the runaway lineage") def get_messages_as_conversation(self, sid, **kwargs): reads.append(("tip", kwargs.get("repair_alternation"))) return list(tip) built = threading.Event() monkeypatch.setattr(server, "_start_agent_build", lambda _sid, _session: built.set()) monkeypatch.setattr(server, "_maybe_schedule_auto_continue", lambda *_a, **_k: None) session = server._deferred_session_record( "deep-lineage", cols=80, cwd="/tmp", history=[], lease=None ) session["resume_history_ready"] = threading.Event() session["resume_hydrating"] = True session["resume_message_count"] = 28_730 server._sessions["hyd"] = session try: server._schedule_resume_hydration("hyd", "deep-lineage", _DB()) assert session["resume_history_ready"].wait(timeout=5) assert built.wait(timeout=5) assert session.get("resume_history_error") is None assert session["history"] == tip assert session["display_history_prefix"] == [] assert session["resume_message_count"] == 1 assert reads == [("tip", True)] finally: server._sessions.pop("hyd", None) def test_session_resume_guard_failure_fails_open(server, monkeypatch): """A transient guard error must not block resume (fail open, log only).""" reopened = [] class _DB: def get_session(self, sid): return {"id": sid} def get_session_by_title(self, _title): return None def resolve_resume_session_id(self, sid): return sid def assert_resume_safe(self, _sid): raise RuntimeError("database is locked") def reopen_session(self, sid): reopened.append(sid) return True monkeypatch.setattr(server, "_get_db", lambda: _DB()) response = server.handle_request( { "id": "r-open", "method": "session.resume", "params": { "session_id": "transient-guard-session", "omit_messages": True, }, } ) # The guard must not block: no 4130, and any downstream failure must not # be the guard's own "resume safety check failed" error. Reopen being # attempted proves execution moved past the guard. err = response.get("error") or {} assert err.get("code") != 4130 assert "resume safety check failed" not in str(err.get("message", "")) assert reopened == ["transient-guard-session"] def test_session_resume_active_turn_payload_matches_desktop_fixture(server, monkeypatch): """A live resume serializes the exact timer payload consumed by Desktop.""" fixture = json.loads( (Path(__file__).parents[1] / "fixtures" / "session-resume-active-turn.json").read_text( encoding="utf-8" ) ) class _DB: def get_session(self, session_id): return {"id": session_id} def get_session_by_title(self, _title): return None def resolve_resume_session_id(self, session_id): return session_id active_turn = { "assistant": "partial answer", "started_at": fixture["turn_started_at"], "streaming": True, "user": "current prompt", } server._sessions[fixture["session_id"]] = { "agent": types.SimpleNamespace(session_id=fixture["session_key"]), "created_at": fixture["started_at"], "history": [{"content": "earlier prompt", "role": "user"}], "history_lock": threading.Lock(), "inflight_turn": active_turn, "running": True, "session_key": fixture["session_key"], } monkeypatch.setattr(server, "_get_db", lambda: _DB()) monkeypatch.setattr(server, "_session_info", lambda _agent: fixture["info"]) # JSON round-trip the real RPC envelope: the desktop fixture must stay # faithful to what the gateway actually serializes, not a copied shape. response = json.loads( json.dumps( server.handle_request( { "id": "resume-running", "method": "session.resume", "params": {"session_id": fixture["session_key"]}, } ) ) ) result = response["result"] assert result["running"] is True assert result["turn_started_at"] == active_turn["started_at"] assert result == fixture 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, predicate=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_slash_exec_scopes_skill_lookup_to_session_profile(server, tmp_path): """slash.exec must resolve get_skill_commands() against the session's own profile_home rather than the gateway process's ambient HERMES_HOME (#88023). A Desktop session that switches profiles mid-session shares the same gateway process, so a skill declared only under the new profile's skills.external_dirs must still be recognized here — else the command falls through to the slash-worker dead path instead of routing to command.dispatch. """ import agent.skill_commands as sc_mod empty_local_dir = tmp_path / "no-local-skills" empty_local_dir.mkdir() profile_b = tmp_path / "profile_b" external_b = tmp_path / "external_b" profile_b.mkdir() skill_dir = external_b / "b-only" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "---\nname: b-only\ndescription: Only in profile b.\n---\n\n# b-only\n\nDo the thing.\n" ) (profile_b / "config.yaml").write_text( f"skills:\n external_dirs:\n - {external_b}\n" ) sid = "test-session-profile-b" server._sessions[sid] = { "session_key": sid, "agent": None, "profile_home": str(profile_b), } with ( patch("tools.skills_tool.SKILLS_DIR", empty_local_dir), patch.object(sc_mod, "_skill_commands", {}), patch.object(sc_mod, "_skill_commands_platform", None), patch.object(sc_mod, "_skill_commands_home", None), ): resp = server.handle_request({ "id": "r1", "method": "slash.exec", "params": {"command": "b-only", "session_id": sid}, }) # The gateway's own HERMES_HOME (the test-isolation tempdir, no # skills.external_dirs) has no "b-only" skill — the only way this # resolves is by scoping the lookup to the session's profile_home. 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 @pytest.mark.parametrize( "voice_or_wake_method", ["voice.toggle", "voice.record", "voice.tts", "wake.start", "wake.status"], ) def test_voice_and_wake_handlers_are_pool_routed(voice_or_wake_method, server): """Voice and wake RPCs must run on the pool, never the WS reader thread. Regression: voice.toggle (status) triggers check_voice_requirements() → STT provider auto-detect → a SYNCHRONOUS faster-whisper lazy install (uv/pip subprocess, up to a 300s timeout). Inline on the WS reader loop it blocked prompt.submit / session.list frames queued behind it — the desktop showed sent messages that never reached the agent. Same bug class as #21123 / #50005: anything that can stall for seconds must stay off the reader thread. wake.start and wake.status share the same STT lazy-install path via check_wake_word_requirements() → _stt_ready() → _get_provider(), and wake.start additionally calls lazy_deps.ensure() for wake-word engine deps. The desktop polls wake.status on every gateway-ready. """ assert voice_or_wake_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", encoding="utf-8") 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", encoding="utf-8") 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"