"""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(): 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() mod._pending.clear() mod._answers.clear() 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."}), ] def test_text_routes_to_watch_transport_without_contextvar(server, monkeypatch): """Async/background path: the child runs on a detached daemon thread that carries NO contextvar transport binding. Routing must still reach the watch window because write_json keys event frames off the session's STORED transport, not the current context. Exercises the real _emit/write_json.""" monkeypatch.setattr(server, "_tool_progress_enabled", lambda sid: True) frames: list = [] class RecTransport: def write(self, obj): frames.append(obj) return True watch_t = RecTransport() # A lazy watch resume stored its transport on the live child session. server._sessions["live-1"] = { "session_key": "child-1", "agent": None, "transport": watch_t, } # Relay with NO transport bound on the current context (the daemon worker # thread never inherits the parent's contextvar) — mirrors the async case. assert server.current_transport() is None _relay(server, "subagent.text", preview="streamed reply", child_session_id="child-1") routed = [ (f["params"]["type"], f["params"]["session_id"], f["params"].get("payload")) for f in frames if f.get("method") == "event" and f["params"]["session_id"] == "live-1" ] assert ("message.start", "live-1", None) in routed assert ("message.delta", "live-1", {"text": "streamed reply"}) in routed