mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
All 61 TUI-related tests green across 3 consecutive xdist runs.
tests/tui_gateway/test_protocol.py:
- rename `get_messages` → `get_messages_as_conversation` on mock DB (method
was renamed in the real backend, test was still stubbing the old name)
- update tool-message shape expectation: `{role, name, context}` matches
current `_history_to_messages` output, not the legacy `{role, text}`
tests/hermes_cli/test_tui_resume_flow.py:
- `cmd_chat` grew a first-run provider-gate that bailed to "Run: hermes
setup" before `_launch_tui` was ever reached; 3 tests stubbed
`_resolve_last_session` + `_launch_tui` but not the gate
- factored a `main_mod` fixture that stubs `_has_any_provider_configured`,
reused by all three tests
tests/test_tui_gateway_server.py:
- `test_config_set_personality_resets_history_and_returns_info` was flaky
under xdist because the real `_write_config_key` touches
`~/.hermes/config.yaml`, racing with any other worker that writes
config. Stub it in the test.
233 lines
6.9 KiB
Python
233 lines
6.9 KiB
Python
"""Tests for tui_gateway JSON-RPC protocol plumbing."""
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
import threading
|
|
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
|
|
|
|
|
|
# ── 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_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
|
|
|
|
|
|
# ── _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"
|
|
|
|
|
|
def test_emit_without_payload(capture):
|
|
server, buf = capture
|
|
server._emit("ping", "s2")
|
|
|
|
assert "payload" not in json.loads(buf.getvalue())["params"]
|
|
|
|
|
|
# ── 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"
|
|
server._pending[rid].set()
|
|
|
|
threading.Event().wait(0.1)
|
|
assert result[0] == "my_answer"
|
|
|
|
|
|
def test_clear_pending(server):
|
|
ev = threading.Event()
|
|
server._pending["r1"] = 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
|
|
|
|
|
|
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
|
|
|
|
|
|
# ── 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_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": ""},
|
|
]
|
|
|
|
|
|
# ── Config I/O ───────────────────────────────────────────────────────
|
|
|
|
|
|
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"
|
|
|
|
|
|
# ── _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
|
|
|
|
|
|
@pytest.mark.parametrize("argv", [
|
|
["version"],
|
|
["sessions", "list"],
|
|
])
|
|
def test_cli_exec_allowed(server, argv):
|
|
assert server._cli_exec_blocked(argv) is None
|