mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 01:51:44 +00:00
* fix(tui-gateway): harden stdio transport against half-closed pipes + SIGTERM races
`tui_gateway` reports `tui_gateway_crash.log` traces where the main
thread sits in `sys.stdin` while a worker holds `_stdout_lock` mid-
flush, and SIGTERM then calls `sys.exit(0)` while the lock is still
held — the interpreter shutdown stalls behind the wedged write.
Two narrowly scoped hardenings:
**`tui_gateway/transport.py`**
* Move JSON serialisation outside the lock — long messages no longer
block sibling writers while we serialise.
* Treat `BrokenPipeError`, `ValueError` ("I/O on closed file") and
generic `OSError` from both `write` and `flush` as "peer is gone":
return `False` instead of bubbling, matching what `write_json`'s
callers in `entry.py` already expect.
* Split `flush` into its own try block so a stuck flush never strands
a partial write or holds the lock indefinitely on its way out.
* Optional `HERMES_TUI_GATEWAY_NO_FLUSH=1` env knob to skip explicit
`flush()` entirely on environments where a half-closed read pipe
produces an indefinite kernel-level block. Default unchanged.
**`tui_gateway/entry.py`**
* `_log_signal` now spawns a 1-second daemon timer that calls
`os._exit(0)` if the orderly `sys.exit(0)` path is itself stuck
behind a wedged worker. Atexit handlers run inside the grace
window when they can; the timer is the safety net so a deadlocked
flush no longer strands the gateway process.
Tests:
* `test_write_json_closed_stream_returns_false` — ValueError path.
* `test_write_json_oserror_on_flush_returns_false` — OSError on flush
must not strand the lock; the write portion still landed before the
flush failure.
* `test_write_json_no_flush_env_skips_flush` — env knob bypass.
Validation: `scripts/run_tests.sh tests/tui_gateway/test_protocol.py`
(42/42 pass; one pre-existing failure on
`test_session_resume_returns_hydrated_messages` is unrelated to this
change — same `include_ancestors` mock kwarg issue tracked elsewhere).
`scripts/run_tests.sh tests/test_tui_gateway_server.py` 90/90 pass.
* review(copilot): tighten transport hardening comments + test cleanup
* review(copilot): narrow exception capture, configurable grace, simpler no-flush test
* fix(tui-gateway): narrow ValueError to closed-stream; surface UnicodeEncodeError
Copilot review on PR #17118: `UnicodeEncodeError` is a ValueError
subclass, so a non-UTF-8 stdout (mismatched PYTHONIOENCODING / locale)
would have been silently swallowed as 'peer gone' under
`except ValueError`. That hides a real environment bug.
Now:
- UnicodeEncodeError → log with exc_info (warning) and drop the frame
- ValueError where str(e) contains 'closed file' → peer gone, return False
- Any other ValueError → log loudly, drop frame (defensive, but visible)
Same shape applied to flush. Adds two regression tests.
* fix(tui-gateway): reserve write() False for peer-gone; re-raise programming errors
Round 2 Copilot review on PR #17118: `Transport.write()` returning
`False` is documented as 'peer is gone', and `entry.py` reacts by
calling `sys.exit(0)`. But the implementation also returned False
for non-IO conditions (non-JSON-safe payloads, UnicodeEncodeError,
unrelated ValueErrors), so a programming error or local env bug would
present as a clean disconnect — exactly the diagnosis pain we wanted
to eliminate.
Now:
- `json.dumps` failure → re-raises (TypeError/ValueError surfaces in crash log)
- `BrokenPipeError` → False (peer gone)
- `ValueError('...closed file...')` → False (peer gone)
- `UnicodeEncodeError` and any other ValueError → re-raise
- `OSError` → False (existing IO-failure semantics, debug-logged)
Tests updated to assert the re-raise behaviour and added a
non-serializable-payload regression test.
* fix(tui-gateway): narrow OSError to peer-gone errnos; honest test naming
Round 3 Copilot review on PR #17118:
- Docstring claimed False = peer gone, but generic OSError on write/flush
also returned False — meaning ENOSPC/EACCES/EIO would silently exit.
Added `_PEER_GONE_ERRNOS = {EPIPE, ECONNRESET, EBADF, ESHUTDOWN, +WSA}`
and narrowed the OSError handlers; non-peer-gone errnos re-raise.
Docstring now lists OSError as peer-gone branch with the errno set.
- The `_DISABLE_FLUSH` test was named after the env var but actually
patched the module constant. Renamed it to reflect the contract being
tested (skips flush when constant is true) AND added a real
end-to-end test that sets the env var, reloads transport.py, and
asserts the constant flips. Cleanup reload restores defaults so
parallel tests stay isolated.
Self-review (avoid round 4):
- Verified TeeTransport's secondary-swallow stays intentional.
- _log_signal grace path already covered by separate tests.
672 lines
22 KiB
Python
672 lines
22 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
|
|
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
|
|
|
|
|
|
def test_write_json_closed_stream_returns_false(server):
|
|
"""ValueError ('I/O on closed file') used to bubble up; treat as gone."""
|
|
|
|
class _Closed:
|
|
def write(self, _): raise ValueError("I/O operation on closed file")
|
|
def flush(self): raise ValueError("I/O operation on closed file")
|
|
|
|
server._real_stdout = _Closed()
|
|
assert server.write_json({"x": 1}) is False
|
|
|
|
|
|
def test_write_json_unicode_encode_error_re_raises(server):
|
|
"""A non-UTF-8 stdout encoding raises UnicodeEncodeError (a ValueError
|
|
subclass). It must NOT be swallowed as 'peer gone' — that would let
|
|
`entry.py` exit cleanly via the False path and hide the real config
|
|
bug. We re-raise so the existing crash-log infrastructure records it."""
|
|
|
|
class _AsciiOnly:
|
|
def write(self, line):
|
|
line.encode("ascii") # raises UnicodeEncodeError on non-ascii
|
|
def flush(self): pass
|
|
|
|
server._real_stdout = _AsciiOnly()
|
|
with pytest.raises(UnicodeEncodeError):
|
|
server.write_json({"msg": "héllo"})
|
|
|
|
|
|
def test_write_json_unrelated_value_error_re_raises(server):
|
|
"""Only ValueError('...closed file...') means peer gone. Other
|
|
ValueErrors are programming errors and must surface."""
|
|
|
|
class _BadValue:
|
|
def write(self, _): raise ValueError("something else entirely")
|
|
def flush(self): pass
|
|
|
|
server._real_stdout = _BadValue()
|
|
with pytest.raises(ValueError, match="something else entirely"):
|
|
server.write_json({"x": 1})
|
|
|
|
|
|
def test_write_json_non_serializable_payload_re_raises(server):
|
|
"""Non-JSON-safe payloads are programming errors — they must NOT be
|
|
silently dropped via the False path (which would trigger a clean exit
|
|
in entry.py and mask the real bug)."""
|
|
import io
|
|
|
|
server._real_stdout = io.StringIO()
|
|
with pytest.raises(TypeError):
|
|
server.write_json({"obj": object()})
|
|
|
|
|
|
def test_write_json_peer_gone_oserror_on_flush_returns_false(server):
|
|
"""A flush that raises a peer-gone OSError (EPIPE) must not strand
|
|
the lock or crash; it returns False so the dispatcher exits cleanly."""
|
|
import errno
|
|
|
|
written = []
|
|
|
|
class _FlushPeerGone:
|
|
def write(self, line): written.append(line)
|
|
def flush(self): raise OSError(errno.EPIPE, "broken pipe")
|
|
|
|
server._real_stdout = _FlushPeerGone()
|
|
assert server.write_json({"x": 1}) is False
|
|
assert written and json.loads(written[0]) == {"x": 1}
|
|
|
|
|
|
def test_write_json_non_peer_gone_oserror_re_raises(server):
|
|
"""Host I/O failures (ENOSPC, EACCES, EIO …) are NOT peer-gone — they
|
|
must re-raise so the crash log records them instead of looking like
|
|
a clean disconnect via the False path."""
|
|
import errno
|
|
|
|
class _DiskFull:
|
|
def write(self, _): raise OSError(errno.ENOSPC, "no space left")
|
|
def flush(self): pass
|
|
|
|
server._real_stdout = _DiskFull()
|
|
with pytest.raises(OSError, match="no space"):
|
|
server.write_json({"x": 1})
|
|
|
|
|
|
def test_write_json_skips_flush_when_disable_flush_true(monkeypatch):
|
|
"""`StdioTransport` skips flush when `_DISABLE_FLUSH` is true.
|
|
|
|
Tests the runtime *behaviour* via direct module-attr patch. The env
|
|
var → module constant wiring is covered by the dedicated env test
|
|
below; reloading server.py here would re-register atexit hooks and
|
|
recreate the worker pool.
|
|
"""
|
|
import importlib
|
|
|
|
transport_mod = importlib.import_module("tui_gateway.transport")
|
|
monkeypatch.setattr(transport_mod, "_DISABLE_FLUSH", True)
|
|
|
|
flushed = {"count": 0}
|
|
written = []
|
|
|
|
class _Stream:
|
|
def write(self, line): written.append(line)
|
|
def flush(self): flushed["count"] += 1
|
|
|
|
stream = _Stream()
|
|
transport = transport_mod.StdioTransport(lambda: stream, threading.Lock())
|
|
|
|
assert transport.write({"x": 1}) is True
|
|
assert flushed["count"] == 0
|
|
|
|
|
|
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"
|
|
|
|
|
|
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"
|
|
# _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"
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
# ── 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"]
|
|
|
|
|
|
@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_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)
|
|
|
|
|
|
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"
|
|
# Verify history was truncated: everything from last user message onward removed
|
|
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"
|
|
|
|
|
|
# ── 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}}
|
|
|
|
|
|
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}
|