Files
hermes-agent/tests/tui_gateway/test_subagent_child_mirror.py
teknium1 9f7f2f28c0 feat(gateway): server→client JSON-RPC requests replace the *.request/*.respond event pairs (#110521)
The gateway asked the user questions (approval, clarify, sudo, secret,
vault, MCP setup, the desktop read/act bridges) by emitting a
`<x>.request` EVENT carrying a hand-minted request_id, blocking the
agent thread on a module dict keyed by that id, and exposing a paired
`<x>.respond` METHOD per kind — thirteen pairs, four registries
(`_pending`, `_answers`, `_batch_clarify`, `_EXPIRING_REQUESTS`) and a
per-kind reconnect snapshot (`pending_clarify` / `pending_approval`)
that only two of the thirteen kinds ever got. JSON-RPC already has the
primitive: the server sends a request frame with an id and the client
answers with a response frame bearing the same id.

`tui_gateway/server_requests.py` owns the one mechanism:

  send()          block the agent thread until the response frame
                  (`srq-<n>` ids; ints belong to the client)
  send_async()    fire-and-callback variant (bot relay)
  cancel*()       withdraw with ONE `request.cancel {id, method, reason}`
                  event (timeout / interrupt / process exit /
                  answered elsewhere) instead of per-kind *.expire
  open_requests() the still-open frames, replayed by session.resume,
                  session.activate and session.events.since so a
                  reconnecting client re-renders every kind, not two
  clarify.lock    stays a real client→server RPC (locks one batch
                  answer early); locked answers merge into the final
                  set even when the closing response carries only the
                  tail the user answered last

A client that does not implement a method answers -32601 and the agent
fails fast (the old fixed-timeout "unavailable" probes for tour/preview
still work — a wire error IS an answer). Approval: the queue entry's
settle hook withdraws the request when `/approve` from another surface,
a timeout or an interrupt resolves it first, so no window keeps a dead
card. Compute-host children own their waits; the parent mirrors their
open frames for replay and relays `clarify.lock` + response frames.

Clients: `JsonRpcRequestChannel` gains `onRequest` (unhandled → -32601,
dedup by id) and `JsonRpcGatewayClient` re-delivers `open_requests`
from the replay result. Desktop gets `gateway-event/server-requests.ts`
(one handler per method, replacing the request branches of
`input-requests.ts` / `desktop-bridge.ts`) and a `store/server-requests`
registry so every answer site calls `respondToServerRequest(id, result)`
synchronously; the TUI gets `createServerRequestHandler.ts` +
`serverRequestStore.ts`. `gateway-events.json` now pins both halves
(events + server request methods); the two contract tests check both.

Live (real stdio gateway, real `clarify_callback` on the agent thread):
before, `clarify.request` event + `clarify.respond` RPC, batch final
answers lost ('' returned); after, `{"id":"srq-…","method":"clarify"}`
frame, `session.events.since.open_requests` replays it, response frame
`{"answer":"yes"}` reaches the agent, batch lock + final response
merge to `{"q0":"1","q1":"free text"}`.
2026-09-14 06:02:05 -07:00

240 lines
9.2 KiB
Python

"""Tests for the gateway's child-session live mirror.
A delegated child runs synchronously inside the parent's turn; its activity
reaches the gateway only as relayed ``subagent.*`` events on the PARENT sid
(tagged with ``child_session_id``). When a UI resumes the child's own session
(desktop open-in-new-window), ``_mirror_subagent_to_child`` translates those
relayed events into native stream events on the CHILD's live sid so the window
shows a real midstream turn instead of sitting silent until persistence.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def server():
# Mocks are scoped to the initial import only (see
# tests/tui_gateway/test_protocol.py for the rationale).
with patch.dict(
"sys.modules",
{
"hermes_constants": MagicMock(
get_hermes_home=MagicMock(return_value="/tmp/hermes_test_child_mirror")
),
"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()
__import__("tui_gateway.server_requests", fromlist=["x"]).reset_for_tests()
mod._child_mirrors.clear()
mod._active_child_runs.clear()
@pytest.fixture()
def emits(server, monkeypatch):
captured: list = []
monkeypatch.setattr(
server,
"_emit",
lambda event, sid, payload=None: captured.append((event, sid, payload)),
)
monkeypatch.setattr(server, "_tool_progress_enabled", lambda sid: True)
return captured
def _relay(server, event_type, **payload):
"""Drive _on_tool_progress the way the delegate relay does."""
server._on_tool_progress(
"parent-sid",
event_type,
payload.pop("tool_name", None),
payload.pop("preview", None),
None,
goal="research X",
task_count=1,
task_index=0,
**payload,
)
def test_no_live_child_session_no_mirror(server, emits):
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
# Only the parent-sid relay event — nothing mirrored, no state retained.
assert [(e, s) for e, s, _ in emits] == [("subagent.tool", "parent-sid")]
assert server._child_mirrors == {}
def test_live_child_session_gets_native_stream(server, emits):
# A window resumed the child session: live sid differs from the stored key.
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.tool", tool_name="terminal", preview="ls", child_session_id="child-1")
_relay(server, "subagent.thinking", preview="hmm", child_session_id="child-1")
_relay(server, "subagent.tool", tool_name="read_file", child_session_id="child-1")
_relay(
server,
"subagent.complete",
child_session_id="child-1",
status="completed",
summary="done deal",
)
child = [(e, p) for e, s, p in emits if s == "live-1"]
# Synthetic turn: start → tool → reasoning → tool rotation → close + summary.
assert [e for e, _ in child] == [
"message.start",
"tool.start",
"reasoning.delta",
"tool.complete",
"tool.start",
"tool.complete",
"message.complete",
]
first_tool = child[1][1]
assert first_tool["name"] == "terminal"
assert first_tool["tool_id"].startswith("submirror:child-1:")
assert child[2][1] == {"text": "hmm"}
# The rotated-out tool closes with the same id it opened with.
assert child[3][1]["tool_id"] == first_tool["tool_id"]
assert child[6][1] == {"text": "done deal"}
# Parent relay is untouched alongside the mirror.
assert [e for e, s, _ in emits if s == "parent-sid"] == [
"subagent.tool",
"subagent.thinking",
"subagent.tool",
"subagent.complete",
]
# Completion clears mirror state.
assert server._child_mirrors == {}
def test_window_closed_midrun_drops_state_then_fresh_turn_on_reopen(server, emits):
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert "child-1" in server._child_mirrors
# Window closes → live session gone → state dropped on the next event.
server._sessions.clear()
_relay(server, "subagent.tool", tool_name="read_file", child_session_id="child-1")
assert server._child_mirrors == {}
# Reopen under a new live sid → a fresh synthetic turn starts.
emits.clear()
server._sessions["live-2"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.tool", tool_name="web_search", child_session_id="child-1")
assert [(e, s) for e, s, _ in emits if s == "live-2"] == [
("message.start", "live-2"),
("tool.start", "live-2"),
]
def test_upgraded_child_session_not_mirrored(server, emits):
"""A watch window upgraded to a full session (agent built) owns a real
native stream — mirroring on top would interleave two turns on one sid."""
server._sessions["live-1"] = {"session_key": "child-1", "agent": object()}
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert [(e, s) for e, s, _ in emits] == [("subagent.tool", "parent-sid")]
assert server._child_mirrors == {}
# Liveness registry still updates — it serves resume, not the mirror.
assert "child-1" in server._active_child_runs
def test_stale_child_run_not_reported_active(server, emits):
"""A leaked registry entry (lost completion event) must age out instead of
pinning running=true on every future lazy resume of that child."""
server._active_child_runs["child-1"] = 0.0 # epoch — ancient
assert server._child_run_active("child-1") is False
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert server._child_run_active("child-1") is True
def test_prompt_submit_rejected_while_child_run_active(server, emits):
"""Typing into a watch window mid-run must not build a second agent racing
the in-flight child on the same stored session — busy error instead."""
import threading
server._sessions["live-1"] = {
"agent": None,
"history_lock": threading.Lock(),
"lazy": True,
"running": False,
"session_key": "child-1",
}
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
result = server._methods["prompt.submit"]("rid-1", {"session_id": "live-1", "text": "hi"})
assert result["error"]["code"] == 4009
# Run completes → the same submit upgrades into a real conversation
# (passes the guard; fails later only because this test stubs no agent).
_relay(server, "subagent.complete", child_session_id="child-1", status="completed", summary="ok")
assert server._child_run_active("child-1") is False
def test_active_child_runs_registry_tracks_liveness(server, emits):
"""Every relayed event marks the child as in flight (even with no window
open), and completion clears it — lazy watch resumes read this registry to
report running=true while the child is silent inside a long tool call."""
_relay(server, "subagent.start", preview="go", child_session_id="child-1")
assert "child-1" in server._active_child_runs
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert "child-1" in server._active_child_runs
_relay(server, "subagent.complete", child_session_id="child-1", status="completed", summary="ok")
assert "child-1" not in server._active_child_runs
def test_start_mirrors_as_immediate_header_line(server, emits):
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
# subagent.start emits a one-time header (the goal) so a freshly opened
# window shows context immediately. subagent.progress (batched tool-name
# rollups) no longer pollutes the message body — tools mirror natively via
# tool.start and the reply streams via subagent.text.
_relay(server, "subagent.start", preview="starting child branch", child_session_id="child-1")
_relay(server, "subagent.progress", preview="step 1/3", child_session_id="child-1")
child = [(e, p) for e, s, p in emits if s == "live-1"]
assert child == [
("message.start", None),
("message.delta", {"text": "starting child branch\n"}),
]
def test_text_mirrors_as_message_delta(server, emits):
"""The child's streamed reply (subagent.text) becomes a native
message.delta on the live child sid — the watch window streams it as the
agent 'talking', the piece that was previously missing entirely."""
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.text", preview="Here is ", child_session_id="child-1")
_relay(server, "subagent.text", preview="the answer.", child_session_id="child-1")
child = [(e, p) for e, s, p in emits if s == "live-1"]
assert child == [
("message.start", None),
("message.delta", {"text": "Here is "}),
("message.delta", {"text": "the answer."}),
]