| """Tests for tui_gateway JSON-RPC protocol plumbing.""" |
|
|
| import io |
| import json |
| import sys |
| import threading |
| import time |
| 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 |
| mod._sessions.clear() |
| mod._pending.clear() |
| mod._answers.clear() |
| mod._methods.clear() |
| importlib.reload(mod) |
|
|
|
|
| @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 |
|
|
|
|
| |
|
|
|
|
| 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"}, |
| } |
|
|
|
|
| |
|
|
|
|
| def test_write_json(capture): |
| server, buf = capture |
| assert server.write_json({"test": True}) |
| assert json.loads(buf.getvalue()) == {"test": True} |
|
|
|
|
| def test_write_json_broken_pipe(server): |
| class _Broken: |
| def write(self, _): raise BrokenPipeError |
| def flush(self): raise BrokenPipeError |
|
|
| server._real_stdout = _Broken() |
| assert server.write_json({"x": 1}) is False |
|
|
|
|
| |
|
|
|
|
| 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" |
|
|
|
|
| def test_emit_without_payload(capture): |
| server, buf = capture |
| server._emit("ping", "s2") |
|
|
| assert "payload" not in json.loads(buf.getvalue())["params"] |
|
|
|
|
| |
|
|
|
|
| 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" |
| |
| _, ev = server._pending[rid] |
| ev.set() |
|
|
| threading.Event().wait(0.1) |
| assert result[0] == "my_answer" |
|
|
|
|
| def test_clear_pending(server): |
| ev = threading.Event() |
| |
| server._pending["r1"] = ("sid-x", ev) |
| server._clear_pending() |
|
|
| assert ev.is_set() |
| assert server._answers["r1"] == "" |
|
|
|
|
| |
|
|
|
|
| def test_sess_missing(server): |
| _, err = server._sess({"session_id": "nope"}, "r1") |
| assert err["error"]["code"] == 4001 |
|
|
|
|
| def test_sess_found(server): |
| server._sessions["abc"] = {"agent": MagicMock()} |
| s, err = server._sess({"session_id": "abc"}, "r1") |
|
|
| assert s is not None |
| assert err is None |
|
|
|
|
| |
|
|
|
|
| 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_messages_as_conversation(self, _sid): |
| return [ |
| {"role": "user", "content": "hello"}, |
| {"role": "assistant", "content": "yo"}, |
| {"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: object()) |
| monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) |
| monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "test/model"}) |
|
|
| resp = server.handle_request( |
| { |
| "id": "r1", |
| "method": "session.resume", |
| "params": {"session_id": "20260409_010101_abc123", "cols": 100}, |
| } |
| ) |
|
|
| assert "error" not in resp |
| assert resp["result"]["message_count"] == 3 |
| assert resp["result"]["messages"] == [ |
| {"role": "user", "text": "hello"}, |
| {"role": "assistant", "text": "yo"}, |
| {"role": "tool", "name": "tool", "context": ""}, |
| ] |
|
|
|
|
| |
|
|
|
|
| def test_config_load_missing(server, tmp_path): |
| server._hermes_home = tmp_path |
| assert server._load_cfg() == {} |
|
|
|
|
| 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" |
|
|
|
|
| |
|
|
|
|
| @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 |
|
|
|
|
| @pytest.mark.parametrize("argv", [ |
| ["version"], |
| ["sessions", "list"], |
| ]) |
| def test_cli_exec_allowed(server, argv): |
| assert server._cli_exec_blocked(argv) is None |
|
|
|
|
| |
|
|
|
|
| def test_slash_exec_rejects_skill_commands(server): |
| """slash.exec must reject skill commands so the TUI falls through to command.dispatch.""" |
| |
| sid = "test-session" |
| server._sessions[sid] = {"session_key": sid, "agent": None} |
|
|
| |
| 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}, |
| }) |
|
|
| |
| assert "error" in resp |
| assert resp["error"]["code"] == 4018 |
| assert "skill command" in resp["error"]["message"] |
|
|
|
|
| @pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"]) |
| def test_slash_exec_rejects_pending_input_commands(server, cmd): |
| """slash.exec must reject commands that use _pending_input in the CLI.""" |
| sid = "test-session" |
| server._sessions[sid] = {"session_key": sid, "agent": None} |
|
|
| resp = server.handle_request({ |
| "id": "r1", |
| "method": "slash.exec", |
| "params": {"command": cmd, "session_id": sid}, |
| }) |
|
|
| assert "error" in resp |
| assert resp["error"]["code"] == 4018 |
| assert "pending-input 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_command_dispatch_queue_requires_arg(server): |
| """command.dispatch /queue without an argument returns an error.""" |
| sid = "test-session" |
| server._sessions[sid] = {"session_key": sid} |
|
|
| resp = server.handle_request({ |
| "id": "r2", |
| "method": "command.dispatch", |
| "params": {"name": "queue", "arg": "", "session_id": sid}, |
| }) |
|
|
| assert "error" in resp |
| assert resp["error"]["code"] == 4004 |
|
|
|
|
| def test_command_dispatch_steer_fallback_sends_message(server): |
| """command.dispatch /steer with no active agent falls back to send.""" |
| sid = "test-session" |
| server._sessions[sid] = {"session_key": sid, "agent": None} |
|
|
| resp = server.handle_request({ |
| "id": "r3", |
| "method": "command.dispatch", |
| "params": {"name": "steer", "arg": "focus on testing", "session_id": sid}, |
| }) |
|
|
| assert "error" not in resp |
| result = resp["result"] |
| assert result["type"] == "send" |
| assert result["message"] == "focus on testing" |
|
|
|
|
| def test_command_dispatch_retry_finds_last_user_message(server): |
| """command.dispatch /retry walks session['history'] to find the last user message.""" |
| sid = "test-session" |
| history = [ |
| {"role": "user", "content": "first question"}, |
| {"role": "assistant", "content": "first answer"}, |
| {"role": "user", "content": "second question"}, |
| {"role": "assistant", "content": "second answer"}, |
| ] |
| server._sessions[sid] = { |
| "session_key": sid, |
| "agent": None, |
| "history": history, |
| "history_lock": threading.Lock(), |
| "history_version": 0, |
| } |
|
|
| resp = server.handle_request({ |
| "id": "r4", |
| "method": "command.dispatch", |
| "params": {"name": "retry", "session_id": sid}, |
| }) |
|
|
| assert "error" not in resp |
| result = resp["result"] |
| assert result["type"] == "send" |
| assert result["message"] == "second question" |
| |
| assert len(server._sessions[sid]["history"]) == 2 |
| assert server._sessions[sid]["history"][-1]["role"] == "assistant" |
| assert server._sessions[sid]["history_version"] == 1 |
|
|
|
|
| def test_command_dispatch_retry_empty_history(server): |
| """command.dispatch /retry with empty history returns error.""" |
| sid = "test-session" |
| server._sessions[sid] = { |
| "session_key": sid, |
| "agent": None, |
| "history": [], |
| "history_lock": threading.Lock(), |
| "history_version": 0, |
| } |
|
|
| resp = server.handle_request({ |
| "id": "r5", |
| "method": "command.dispatch", |
| "params": {"name": "retry", "session_id": sid}, |
| }) |
|
|
| assert "error" in resp |
| assert resp["error"]["code"] == 4018 |
|
|
|
|
| def test_command_dispatch_retry_handles_multipart_content(server): |
| """command.dispatch /retry extracts text from multipart content lists.""" |
| sid = "test-session" |
| history = [ |
| {"role": "user", "content": [ |
| {"type": "text", "text": "analyze this"}, |
| {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} |
| ]}, |
| {"role": "assistant", "content": "I see the image."}, |
| ] |
| server._sessions[sid] = { |
| "session_key": sid, |
| "agent": None, |
| "history": history, |
| "history_lock": threading.Lock(), |
| "history_version": 0, |
| } |
|
|
| resp = server.handle_request({ |
| "id": "r6", |
| "method": "command.dispatch", |
| "params": {"name": "retry", "session_id": sid}, |
| }) |
|
|
| assert "error" not in resp |
| result = resp["result"] |
| assert result["type"] == "send" |
| assert result["message"] == "analyze this" |
|
|
|
|
| def test_command_dispatch_returns_skill_payload(server): |
| """command.dispatch returns structured skill payload for the TUI to send().""" |
| sid = "test-session" |
| server._sessions[sid] = {"session_key": sid} |
|
|
| fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}} |
| fake_msg = "Loaded skill content here" |
|
|
| with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills), \ |
| patch("agent.skill_commands.build_skill_invocation_message", return_value=fake_msg): |
| resp = server.handle_request({ |
| "id": "r2", |
| "method": "command.dispatch", |
| "params": {"name": "hermes-agent-dev", "session_id": sid}, |
| }) |
|
|
| assert "error" not in resp |
| result = resp["result"] |
| assert result["type"] == "skill" |
| assert result["message"] == fake_msg |
| assert result["name"] == "hermes-agent-dev" |
|
|
|
|
| |
|
|
|
|
| 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}} |
|
|
|
|
| def test_dispatch_offloads_long_handlers_and_emits_via_stdout(capture): |
| """Long handlers run on the pool and write their response via write_json.""" |
| server, buf = capture |
| server._methods["slash.exec"] = lambda rid, params: server._ok(rid, {"output": "hi"}) |
|
|
| resp = server.dispatch({"id": "r2", "method": "slash.exec", "params": {}}) |
| assert resp is None |
|
|
| for _ in range(50): |
| if buf.getvalue(): |
| break |
| time.sleep(0.01) |
|
|
| written = json.loads(buf.getvalue()) |
| assert written == {"jsonrpc": "2.0", "id": "r2", "result": {"output": "hi"}} |
|
|
|
|
| def test_dispatch_long_handler_does_not_block_fast_handler(server): |
| """A slow long handler must not prevent a concurrent fast handler from completing.""" |
| released = threading.Event() |
| server._methods["slash.exec"] = lambda rid, params: (released.wait(timeout=5), server._ok(rid, {"done": True}))[1] |
| server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True}) |
|
|
| t0 = time.monotonic() |
| assert server.dispatch({"id": "slow", "method": "slash.exec", "params": {}}) is None |
|
|
| fast_resp = server.dispatch({"id": "fast", "method": "fast.ping", "params": {}}) |
| fast_elapsed = time.monotonic() - t0 |
|
|
| assert fast_resp["result"] == {"pong": True} |
| assert fast_elapsed < 0.5, f"fast handler blocked for {fast_elapsed:.2f}s behind slow handler" |
|
|
| released.set() |
|
|
|
|
| def test_dispatch_long_handler_exception_produces_error_response(capture): |
| """An exception inside a pool-dispatched handler still yields a JSON-RPC error.""" |
| server, buf = capture |
|
|
| def boom(rid, params): |
| raise RuntimeError("kaboom") |
|
|
| server._methods["slash.exec"] = boom |
|
|
| server.dispatch({"id": "r3", "method": "slash.exec", "params": {}}) |
|
|
| for _ in range(50): |
| if buf.getvalue(): |
| break |
| time.sleep(0.01) |
|
|
| written = json.loads(buf.getvalue()) |
| assert written["id"] == "r3" |
| assert written["error"]["code"] == -32000 |
| assert "kaboom" in written["error"]["message"] |
|
|
|
|
| def test_dispatch_unknown_long_method_still_goes_inline(server): |
| """Method name not in _LONG_HANDLERS takes the sync path even if handler is slow.""" |
| server._methods["some.method"] = lambda rid, params: server._ok(rid, {"ok": True}) |
|
|
| resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}}) |
|
|
| assert resp["result"] == {"ok": True} |
|
|