Files
hermes-agent/tests/hermes_cli/test_plugin_events.py
teknium1 be59f0411e fix(plugins): broadcast_plugin_event from the turn-isolation child reaches the Desktop
Review finding (MAJOR-2): with dashboard.turn_isolation the plugin's agent-side
code (tools, hooks) runs in the compute-host child, where _live_transports is
empty and _broadcast_global_event dropped the frame at logger.debug. The child
now registers its _HostTransport as a live transport for the life of run_host,
so a session-less broadcast rides the existing host pipe; the parent bridge
(_relay_compute_host_rpc) recognises an event frame with no session_id and
fans it out via _broadcast_global_event instead of write_json, which would
have dropped it on hermes serve's stdio.

Minor: the late `from tui_gateway.server import …` could raise ImportError in
a plugin-only process despite the "safe from any handler" docstring — caught
and logged as a warning.

Docs: the SDK page now tables exactly which process each call site runs in
and what it reaches (hermes serve / compute-host child / stdio TUI / gateway
run + chat + cron = nobody).
2026-09-24 02:03:03 -07:00

134 lines
5.2 KiB
Python

"""Public event bridge for plugin backends (#116305 item 8, salvage of #116419).
A plugin backend pushes events to its own desktop half through
``hermes_cli.plugin_events`` instead of importing
``tui_gateway.server._broadcast_global_event``.
"""
from __future__ import annotations
import pytest
from hermes_cli import plugin_events
class _Peer:
"""A connected client as the gateway sees it (``tui_gateway.transport.Transport``)."""
def __init__(self):
self.frames: list[dict] = []
def write(self, obj: dict) -> bool:
self.frames.append(obj)
return True
def close(self) -> None:
pass
def test_broadcast_reaches_a_registered_client_as_a_namespaced_global_event():
import tui_gateway.server as server
peer = _Peer()
server.register_live_transport(peer)
try:
plugin_events.broadcast_plugin_event("rss-reader", "feed.updated", {"count": 3})
plugin_events.broadcast_plugin_event("kanban", "changed")
finally:
server.unregister_live_transport(peer)
assert peer.frames == [
{"jsonrpc": "2.0", "method": "event",
"params": {"type": "plugin.rss-reader.feed.updated", "session_id": "", "payload": {"count": 3}}},
{"jsonrpc": "2.0", "method": "event",
"params": {"type": "plugin.kanban.changed", "session_id": "", "payload": {}}},
]
def test_broadcast_from_a_turn_isolation_child_reaches_the_parent_gateways_clients(monkeypatch):
"""``dashboard.turn_isolation`` runs plugin agent-side code (tools, hooks, slash commands) in the
compute-host child, where no client is connected: the frame must ride the host pipe to the parent
``hermes serve`` and fan out there instead of dying at ``logger.debug``."""
import io
import json
import os
import threading
import tui_gateway.server as server
from tui_gateway import compute_host
monkeypatch.setenv("HERMES_COMPUTE_HOST_HEARTBEAT_SECS", "0")
monkeypatch.setenv("HERMES_COMPUTE_HOST_CHILD", "0")
child_stdout = io.StringIO()
stdin_r, stdin_w = os.pipe()
child = threading.Thread(
target=compute_host.run_host, kwargs={"stdin": os.fdopen(stdin_r), "stdout": child_stdout}, daemon=True)
child.start()
try:
deadline = threading.Event()
for _ in range(100): # the child registers its transport right after ``hello``
if any(isinstance(t, compute_host._HostTransport) for t in list(server._live_transports)):
break
deadline.wait(0.05)
plugin_events.broadcast_plugin_event("rss-reader", "feed.updated", {"count": 3})
finally:
os.close(stdin_w)
child.join(timeout=10)
assert not child.is_alive()
assert not any(isinstance(t, compute_host._HostTransport) for t in list(server._live_transports))
piped = [json.loads(line) for line in child_stdout.getvalue().splitlines()]
relayed = [f for f in piped if f.get("type") == "rpc"]
assert len(relayed) == 1 and relayed[0]["sid"] == ""
# Parent side: the supervisor hands the child's frame to the bridge, which must broadcast it.
peer = _Peer()
server.register_live_transport(peer)
try:
server._relay_compute_host_rpc(relayed[0]["message"])
finally:
server.unregister_live_transport(peer)
assert [f["params"]["type"] for f in peer.frames] == ["plugin.rss-reader.feed.updated"]
assert peer.frames[0]["params"]["payload"] == {"count": 3}
def test_broadcast_without_a_gateway_module_is_a_logged_no_op(monkeypatch, caplog):
"""A plugin-only process (``hermes plugins validate`` importing the backend) has no
``tui_gateway.server``; the documented "safe from any handler" promise must hold there too."""
import builtins
import logging
real_import = builtins.__import__
def _no_gateway(name, *args, **kwargs):
if name == "tui_gateway.server":
raise ImportError("No module named 'tui_gateway.server'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _no_gateway)
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugin_events"):
plugin_events.broadcast_plugin_event("rss-reader", "feed.updated")
assert "plugin.rss-reader.feed.updated" in caplog.text
@pytest.mark.parametrize(
("plugin_id", "event", "payload", "exc"),
[
("", "items", None, ValueError),
("Bad Id", "items", None, ValueError),
("has/slash", "items", None, ValueError),
("other.x", "items", None, ValueError), # a dotted id would spell plugin ``other``'s namespace
("ok", "", None, ValueError),
("ok", "bad name", None, ValueError),
("ok", "../x", None, ValueError),
("ok", ".leading", None, ValueError),
("ok", "a..b", None, ValueError),
("ok", "items", ["not", "a", "dict"], TypeError),
],
)
def test_names_that_cannot_form_a_namespaced_event_are_refused_before_emit(monkeypatch, plugin_id, event, payload, exc):
import tui_gateway.server as server
monkeypatch.setattr(server, "_broadcast_global_event", lambda *_a, **_k: pytest.fail("must not emit"))
with pytest.raises(exc):
plugin_events.broadcast_plugin_event(plugin_id, event, payload) # type: ignore[arg-type]