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).
134 lines
5.2 KiB
Python
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]
|