"""Real-path desktop connection-operation lifecycle through the TUI gateway.""" import json import os import threading import time from contextlib import ExitStack, suppress import pytest from gateway.session_context import clear_session_vars, reset_session_vars, set_session_vars from tools.connectors import live from tools.connectors.contract import Actor, TargetState from tools.connectors.operation import ConnectionOperation, Target from tools.connectors.tool import manage_connections from tui_gateway import server from tui_gateway.transport import StdioTransport SID = "connector-operation-e2e" class PipeClient: """A real JSON-RPC pipe transport, following the gateway RPC test fixture idiom.""" def __init__(self, stack): read_fd, write_fd = os.pipe() self.reader = stack.enter_context(os.fdopen(read_fd, "r", encoding="utf-8")) self.writer = os.fdopen(write_fd, "w", encoding="utf-8") stack.callback(self._cleanup) self.transport = StdioTransport(lambda: self.writer, threading.Lock()) self.frames = [] self._frames_lock = threading.Lock() self._closed = False self._reader = threading.Thread(target=self._read, daemon=True) self._reader.start() def _read(self): for line in self.reader: with self._frames_lock: self.frames.append(json.loads(line)) def _cleanup(self): if self._closed: return self._closed = True with suppress(BrokenPipeError): self.writer.close() self._reader.join(timeout=5) assert not self._reader.is_alive() def events(self, event_type, predicate=lambda _payload: True): deadline = time.monotonic() + 5 while time.monotonic() < deadline: with self._frames_lock: events = [ frame["params"]["payload"] for frame in self.frames if frame.get("method") == "event" and frame.get("params", {}).get("type") == event_type and predicate(frame["params"].get("payload") or {}) ] if events: return events time.sleep(0.01) with self._frames_lock: return [ frame["params"]["payload"] for frame in self.frames if frame.get("method") == "event" and frame.get("params", {}).get("type") == event_type and predicate(frame["params"].get("payload") or {}) ] class FakeConnectorClient: """The only fake: the tool-gateway HTTP client boundary.""" def __init__(self): self._connected = set() self._lock = threading.Lock() def list_connectors(self): with self._lock: connected = set(self._connected) return [ {"connector": name, "enabled": True, "connected": name in connected} for name in ("gmail", "notion") ] def connections(self, connectors, *, reinitiate=False): return { "results": [ { "connector": name, "status": "initiated", "connect_url": f"https://connect.example/{name}", "reinitiated": reinitiate, } for name in connectors ] } def set_connected(self, name): with self._lock: self._connected.add(name) @pytest.fixture def owned_session(monkeypatch, tmp_path): home = tmp_path / "hermes-home" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) reset_session_vars() live.reset_for_tests() with ExitStack() as stack: owner, stranger = PipeClient(stack), PipeClient(stack) session = { "transport": owner.transport, "agent": None, "session_key": SID, "history": [], "history_lock": threading.Lock(), "history_version": 0, "running": False, "attached_images": [], "source": "desktop", } monkeypatch.setitem(server._sessions, SID, session) yield owner, stranger live.reset_for_tests() reset_session_vars() def _rpc(client, method, **params): response = server.dispatch( { "jsonrpc": "2.0", "id": 7, "method": method, "params": {"session_id": SID, **params}, }, client.transport, ) assert response is not None return response def test_desktop_connect_settles_through_callback_response(owned_session, monkeypatch): """The actual tool thread waits for the gateway card outcome, not a fake callback.""" owner, _ = owned_session client = FakeConnectorClient() monkeypatch.setattr("tools.connectors.run.WATCH_INTERVAL_SECONDS", 0.05) result = {} finished = threading.Event() def run_tool(): # Bind the way the server binds a turn: the desktop surface arrives as the session's # `source`, not as a messaging platform. tokens = server._set_session_context(SID) try: # The agent's durable session_id is not the gateway session key (compaction rotates it # mid-turn); the operation must register under the key every RPC looks it up by. result["raw"] = manage_connections( {"action": "connect", "connectors": ["gmail", "notion"]}, client_factory=lambda: client, session_id="20260914_rotated_agent_id", connection_callback=server._agent_cbs(SID)["connection_callback"], ) except BaseException as exc: # assertion below reports any real-path failure result["error"] = exc finally: clear_session_vars(tokens) finished.set() tool_thread = threading.Thread(target=run_tool, daemon=True) tool_thread.start() (request,) = owner.events("connection.request") assert request["op_id"] assert len(request["targets"]) == 2 assert request["deadline_at"] > time.time() initiated = _rpc(owner, "connectors.operation.status", op_id=request["op_id"])["result"] by_name = {target["name"]: target for target in initiated["targets"]} assert {name: target["state"] for name, target in by_name.items()} == { "gmail": "initiated", "notion": "initiated", } assert all(target["connect_url"] == f"https://connect.example/{name}" for name, target in by_name.items()) client.set_connected("gmail") assert owner.events( "connection.update", lambda payload: payload.get("target") == "gmail" and payload.get("to") == "connected", ) response = _rpc( owner, "connection.respond", op_id=request["op_id"], result={"targets": [{"name": "notion", "status": "skipped"}]}, ) assert response["result"] == {"status": "ok", "settled": True} assert finished.wait(5), "connection.respond must release the callback bridge" tool_thread.join(timeout=5) assert not tool_thread.is_alive() assert "error" not in result settled = json.loads(result["raw"]) assert settled["status"] == "settled" assert settled["settled_by"] == "all_resolved" assert {target["name"]: target["state"] for target in settled["targets"]} == { "gmail": "connected", "notion": "skipped", } assert "connect_url" not in json.dumps(settled) assert live.current(SID) is None # A settled operation leaves the registry: the frozen result travelled in the tool result and # in the last connection.update; the status RPC has nothing left to serve. closed = _rpc(owner, "connectors.operation.status", op_id=request["op_id"]) assert closed["error"]["code"] == 4004 final_update = owner.events("connection.update", lambda payload: payload.get("settled") is True)[-1] assert {target["name"]: target["state"] for target in final_update["targets"]} == { "gmail": "connected", "notion": "skipped", } def test_cli_connect_returns_urls_without_emitting_a_card(owned_session): owner, _ = owned_session client = FakeConnectorClient() tokens = set_session_vars(platform="cli", session_key=SID, session_id=SID) try: result = json.loads( manage_connections( {"action": "connect", "connectors": ["gmail", "notion"]}, client_factory=lambda: client, session_id=SID, connection_callback=server._agent_cbs(SID)["connection_callback"], ) ) finally: clear_session_vars(tokens) assert result["status"] == "initiated" assert all(target["connect_url"] for target in result["targets"]) assert owner.events("connection.request") == [] assert live.current(SID) is None def test_connection_respond_rejects_managed_connected_claims_and_strangers(owned_session): owner, stranger = owned_session operation = ConnectionOperation([Target("gmail", "connector", "connect")], session_key=SID) live.open(operation) operation.transition("gmail", TargetState.initiated, Actor.backend_watcher) rejected = _rpc( owner, "connection.respond", op_id=operation.op_id, result={"targets": [{"name": "gmail", "status": "connected"}]}, ) assert rejected["error"]["code"] == 4002 assert operation.target("gmail").state == TargetState.initiated foreign = _rpc( stranger, "connection.respond", op_id=operation.op_id, result={"targets": [{"name": "gmail", "status": "skipped"}]}, ) assert foreign["error"]["code"] == 4001