275 lines
9.6 KiB
Python
275 lines
9.6 KiB
Python
"""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
|