mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
The gateway HALF of the D-Q2.5c cleanup (connector half: gateway-gateway #92). Scope is STRICTLY the relay adapter (gateway/relay/) — session.py and every native platform adapter are untouched (SessionSource.guild_id remains for their use; it is NOT relay-only). Within gateway/relay/, drop the D-Q2.5 wire dual-write/dual-read alias AND genericize all platform-specific (Discord "guild") scope terminology: - ws_transport._event_from_wire: read scope_id only (drop the ?? guild_id fallback). - adapter._with_scope: emit scope_id only on outbound metadata (drop the guild_id dual-write); genericize the "GUILD reply" docstring to "SCOPED reply". - adapter._capture_scope: read source.scope_id only; rename the local `guild` var to `scope`; genericize the docstring + the _scope_by_chat/_dm_user_by_chat field comments ("guild_id (Discord)" -> "scope_id (server/workspace scope)"). - __init__.relay_route_keys docstring: "guild_ids" -> "scope_ids". - The ONE real Discord `guild_id` kept: the raw inbound interaction payload field (payload.get("guild_id")), which is Discord's own wire field, mapped straight into the generic scope_id slot — unchanged. Contract doc (docs/relay-connector-contract.md): reframe the `guild_id` row as a legacy alias the connector no longer reads (session.py's agent-wide to_dict() still emits it for non-relay persistence, so it stays documented + wire-present but ignored) — accurate, and keeps the to_dict()-vs-doc conformance test green. Tests (relay only): migrate the wire-key writes + assertions guild_id -> scope_id across test_relay_adapter / _ws_transport / _passthrough / _roundtrip / _roundtrip_telegram / _multiplatform; keep raw Discord `type:2` interaction payloads' guild_id (real Discord field) and the conformance test's guild_id parametrize (validates the kept legacy field stays wire-reachable). Gate: 156 relay tests pass, ruff clean. Cross-repo E2E — all 14 drivers pass BOTH ways: connector#92 (scope_id-only) x agent-main (still dual-reads) AND connector#92 x this worktree (scope_id-only). Deploy-order-safe either way.
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""WebSocketRelayTransport against a real in-process WebSocket server.
|
|
|
|
Exercises the production transport over an actual ``websockets`` server (no
|
|
mock socket): handshake (hello -> descriptor), inbound frame -> handler,
|
|
outbound request/response correlation, and follow_up routing. Proves the wire
|
|
framing (newline-delimited JSON) and the request/response future plumbing work
|
|
end to end on a live socket.
|
|
|
|
Skipped cleanly if the optional ``websockets`` dependency is absent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from gateway.relay.ws_transport import WebSocketRelayTransport, WEBSOCKETS_AVAILABLE
|
|
|
|
pytestmark = pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")
|
|
|
|
if WEBSOCKETS_AVAILABLE:
|
|
import websockets
|
|
|
|
|
|
DESCRIPTOR = {
|
|
"contract_version": 1,
|
|
"platform": "discord",
|
|
"label": "Discord",
|
|
"max_message_length": 2000,
|
|
"supports_draft_streaming": False,
|
|
"supports_edit": True,
|
|
"supports_threads": True,
|
|
"markdown_dialect": "discord",
|
|
"len_unit": "chars",
|
|
}
|
|
|
|
|
|
class _StubConnectorServer:
|
|
"""Minimal connector: answers hello with a descriptor, echoes outbound."""
|
|
|
|
def __init__(self):
|
|
self.received: list[dict] = []
|
|
self._server = None
|
|
self.url = ""
|
|
# Push channel: tests set this to a frame dict to deliver inbound.
|
|
self._to_push: list[dict] = []
|
|
|
|
async def start(self):
|
|
self._server = await websockets.serve(self._handle, "127.0.0.1", 0)
|
|
sock = next(iter(self._server.sockets))
|
|
port = sock.getsockname()[1]
|
|
self.url = f"ws://127.0.0.1:{port}"
|
|
|
|
async def stop(self):
|
|
if self._server is not None:
|
|
self._server.close()
|
|
await self._server.wait_closed()
|
|
|
|
async def _handle(self, ws):
|
|
async for raw in ws:
|
|
for line in str(raw).split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
frame = json.loads(line)
|
|
self.received.append(frame)
|
|
await self._on_frame(ws, frame)
|
|
|
|
async def _on_frame(self, ws, frame):
|
|
ftype = frame.get("type")
|
|
if ftype == "hello":
|
|
await ws.send(json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n")
|
|
# Deliver any queued inbound frames right after handshake.
|
|
for f in self._to_push:
|
|
await ws.send(json.dumps(f) + "\n")
|
|
elif ftype == "outbound":
|
|
action = frame.get("action", {})
|
|
# Echo a successful result correlated by requestId.
|
|
result = {"success": True, "message_id": f"srv-{action.get('op')}"}
|
|
await ws.send(
|
|
json.dumps({"type": "outbound_result", "requestId": frame["requestId"], "result": result})
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def server():
|
|
srv = _StubConnectorServer()
|
|
await srv.start()
|
|
yield srv
|
|
await srv.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handshake_negotiates_descriptor(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
await t.connect()
|
|
try:
|
|
desc = await t.handshake()
|
|
assert desc.platform == "discord"
|
|
assert desc.max_message_length == 2000
|
|
# The hello carried the platform + botId.
|
|
hello = next(f for f in server.received if f["type"] == "hello")
|
|
assert hello["platform"] == "discord"
|
|
assert hello["botId"] == "appShared"
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_frame_reaches_handler(server):
|
|
server._to_push = [
|
|
{
|
|
"type": "inbound",
|
|
"event": {
|
|
"text": "hello from connector",
|
|
"message_type": "text",
|
|
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "scope_id": "guildA"},
|
|
},
|
|
"bufferId": "buf-1",
|
|
}
|
|
]
|
|
received = []
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
t.set_inbound_handler(lambda ev: received.append(ev) or asyncio.sleep(0))
|
|
await t.connect()
|
|
try:
|
|
await t.handshake()
|
|
# Give the reader a tick to deliver the pushed inbound frame.
|
|
await asyncio.sleep(0.05)
|
|
assert len(received) == 1
|
|
assert received[0].text == "hello from connector"
|
|
assert received[0].source.scope_id == "guildA"
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_round_trips_with_correlation(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
await t.connect()
|
|
try:
|
|
await t.handshake()
|
|
result = await t.send_outbound({"op": "send", "chat_id": "chan1", "content": "hi"})
|
|
assert result["success"] is True
|
|
assert result["message_id"] == "srv-send"
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_follow_up_round_trips(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared")
|
|
await t.connect()
|
|
try:
|
|
await t.handshake()
|
|
result = await t.send_follow_up(
|
|
{"op": "follow_up", "session_key": "s1", "kind": "discord.interaction_token", "content": "fu"}
|
|
)
|
|
assert result["success"] is True
|
|
assert result["message_id"] == "srv-follow_up"
|
|
# The follow_up rode an outbound frame the connector saw.
|
|
outbound = [f for f in server.received if f["type"] == "outbound"]
|
|
assert any(f["action"]["op"] == "follow_up" for f in outbound)
|
|
finally:
|
|
await t.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_fails_pending_waiters_cleanly(server):
|
|
t = WebSocketRelayTransport(server.url, "discord", "appShared", outbound_timeout_s=5)
|
|
await t.connect()
|
|
await t.handshake()
|
|
await t.disconnect()
|
|
# After disconnect, an outbound returns a structured failure rather than hanging.
|
|
result = await t.send_outbound({"op": "send", "chat_id": "c", "content": "x"})
|
|
assert result["success"] is False
|
|
|
|
|
|
def test_https_url_normalized_to_wss():
|
|
"""The relay URL is configured once as the http(s):// BASE (for the provision
|
|
POST), but websockets.connect needs ws(s):// and the connector mounts its WS
|
|
server at /relay. The transport must convert scheme AND ensure the /relay
|
|
path. Regression for the live staging failures 'scheme isn't ws or wss' then
|
|
'server rejected WebSocket connection: HTTP 400' (wrong path)."""
|
|
t = WebSocketRelayTransport("https://connector.example", "discord", "b")
|
|
assert t._url == "wss://connector.example/relay"
|
|
t2 = WebSocketRelayTransport("http://connector.local:8080", "discord", "b")
|
|
assert t2._url == "ws://connector.local:8080/relay"
|
|
|
|
|
|
def test_ws_dial_url_idempotent_with_scheme_and_path():
|
|
# Already ws(s):// and/or already ending in /relay -> unchanged (no double append).
|
|
t = WebSocketRelayTransport("wss://connector.example/relay", "discord", "b")
|
|
assert t._url == "wss://connector.example/relay"
|
|
t2 = WebSocketRelayTransport("https://connector.example/relay/", "discord", "b")
|
|
assert t2._url == "wss://connector.example/relay"
|
|
t3 = WebSocketRelayTransport("ws://127.0.0.1:9", "discord", "b")
|
|
assert t3._url == "ws://127.0.0.1:9/relay"
|
|
|
|
|
|
# ── Phase 7 Unit 7d-B: terminal 4401 (opt-out revocation) ────────────────────
|
|
|
|
|
|
class _Revoking4401Server:
|
|
"""Connector stub that, on hello, optionally sends a descriptor and then
|
|
closes the socket with application code 4401 (unauthorized) — the shape of a
|
|
connector that has revoked this gateway's per-gateway secret (opt-out)."""
|
|
|
|
def __init__(self, *, send_descriptor_first: bool):
|
|
self._server = None
|
|
self.url = ""
|
|
self._send_descriptor_first = send_descriptor_first
|
|
|
|
async def start(self):
|
|
self._server = await websockets.serve(self._handle, "127.0.0.1", 0)
|
|
port = next(iter(self._server.sockets)).getsockname()[1]
|
|
self.url = f"ws://127.0.0.1:{port}"
|
|
|
|
async def stop(self):
|
|
if self._server is not None:
|
|
self._server.close()
|
|
await self._server.wait_closed()
|
|
|
|
async def _handle(self, ws):
|
|
async for raw in ws:
|
|
for line in str(raw).split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
frame = json.loads(line)
|
|
if frame.get("type") == "hello":
|
|
if self._send_descriptor_first:
|
|
await ws.send(
|
|
json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n"
|
|
)
|
|
# Let the descriptor flush + be processed before the close.
|
|
await asyncio.sleep(0.05)
|
|
# Close with 4401 (the connector's "unauthorized" close).
|
|
await ws.close(code=4401, reason="unauthorized")
|
|
return
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_4401_after_handshake_is_terminal_no_reconnect():
|
|
"""A 4401 close AFTER a successful handshake = a revoked credential (opt-out):
|
|
the transport latches auth_revoked and does NOT spin the reconnect supervisor."""
|
|
srv = _Revoking4401Server(send_descriptor_first=True)
|
|
await srv.start()
|
|
try:
|
|
t = WebSocketRelayTransport(
|
|
srv.url, "discord", "appShared",
|
|
gateway_id="gw-x", upgrade_secret="secret-x",
|
|
reconnect=True, reconnect_backoff_s=0.05,
|
|
)
|
|
await t.connect()
|
|
await t.handshake() # records _handshake_succeeded
|
|
# Wait for the server's 4401 close to propagate through the read loop.
|
|
for _ in range(100):
|
|
if t.auth_revoked:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
assert t.auth_revoked is True
|
|
# Terminal: no reconnect supervisor was spawned.
|
|
assert t._supervisor is None
|
|
# Give a reconnect (if it were going to happen) time to NOT happen.
|
|
await asyncio.sleep(0.2)
|
|
assert t._supervisor is None
|
|
finally:
|
|
await t.disconnect()
|
|
await srv.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_4401_before_handshake_stays_retryable():
|
|
"""A 4401 close BEFORE any successful handshake is a cold-start / not-yet-
|
|
provisioned race, NOT a revocation: it stays retryable (reconnect runs)."""
|
|
srv = _Revoking4401Server(send_descriptor_first=False)
|
|
await srv.start()
|
|
try:
|
|
t = WebSocketRelayTransport(
|
|
srv.url, "discord", "appShared",
|
|
gateway_id="gw-x", upgrade_secret="secret-x",
|
|
reconnect=True, reconnect_backoff_s=0.05,
|
|
)
|
|
await t.connect()
|
|
# No handshake ever succeeded; the 4401 must NOT latch auth_revoked.
|
|
for _ in range(50):
|
|
if t._supervisor is not None:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
assert t.auth_revoked is False
|
|
# The reconnect supervisor IS running (retrying), since this is not terminal.
|
|
assert t._supervisor is not None
|
|
finally:
|
|
await t.disconnect()
|
|
await srv.stop()
|