hermes-agent/tests/gateway/relay/test_relay_passthrough.py
Ben Barclay 64a507da44
feat(relay): handle passthrough_forward over the WS (Phase 5 §5.1, gateway half) (#50702)
The connector half (gateway-gateway) moves the passthrough plane's post-ACK
forward off the HTTP gatewayEndpoint onto the gateway's outbound /relay WS via
a new passthrough_forward frame. This is the gateway side: the relay adapter
now RECEIVES and handles that frame, so a hosted gateway (no public IP) can
process forwarded Class-2/3 traffic (Discord interactions, Twilio) over the
socket it already holds — closing the "passthrough inbound doesn't work for
hosted gateways" gap.

- ws_transport.py: decode the passthrough_forward frame; PassthroughForward
  dataclass + _passthrough_from_wire (base64 body -> exact bytes, byte parity
  with the connector's toPassthroughForward); set_passthrough_handler mirrors
  set_interrupt_inbound_handler.
- transport.py: PassthroughHandler type + set_passthrough_handler on the
  RelayTransport protocol.
- adapter.py: connect() wires the passthrough handler; _on_passthrough decodes
  the (already-sanitized, token-free) forward and, for a Discord interaction,
  converts it to a MessageEvent routed through the normal agent path
  (handle_message) — the reply egresses over the outbound / token-less
  follow_up path, so the gateway never holds the interaction credential. Never
  raises (a bad forward can't kill the read loop). Non-discord forwards (Twilio)
  are logged + dropped for now.
- docs/relay-connector-contract.md: document the passthrough_forward frame +
  PassthroughForward shape + §3.1.

The interaction -> MessageEvent CONVERSION semantics (slash-command vs button
UX, option rendering) are the open sub-design flagged in the spec; the TRANSPORT
+ receive mechanism (this) is settled per Ben's Gate-2 decision: "the relay
adapter handles receiving these events over the WS."

Tests (tests/gateway/relay/test_relay_passthrough.py): byte-preservation
round-trip (+ malformed-body tolerance), connect() wiring, application-command
and message-component interactions route through handle_message with correct
session source + scope capture, malformed/non-discord forwards dropped cleanly.
100 relay tests green. Pairs with the connector PR (gateway-gateway).
2026-06-22 20:10:57 +10:00

199 lines
6.3 KiB
Python

"""Relay passthrough-over-WS forwarding (Phase 5 §5.1).
Proves the gateway side of §5.1: a connector-forwarded passthrough request
(Discord interaction, Twilio, …) arrives over the SAME outbound /relay WS as
inbound messages (a hosted gateway has no public inbound port), and the relay
adapter handles it — decoding the byte-preserved body and routing a Discord
interaction through the normal agent path (handle_message).
Mirrors test_relay_interrupt.py's wiring discipline (connect() registers the
connector->gateway handlers on the transport).
"""
from __future__ import annotations
import base64
import json
import pytest
from gateway.config import PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.relay.ws_transport import PassthroughForward, _passthrough_from_wire
from tests.gateway.relay.stub_connector import StubConnector
def _desc() -> CapabilityDescriptor:
return CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform="discord",
label="Discord",
max_message_length=2000,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="discord",
len_unit="chars",
)
@pytest.fixture
def adapter():
return RelayAdapter(PlatformConfig(), _desc(), transport=StubConnector(_desc()))
def _interaction_forward(payload: dict) -> PassthroughForward:
body = json.dumps(payload).encode("utf-8")
return PassthroughForward(
platform="discord",
bot_id="appShared",
method="POST",
path="/interactions/discord/appShared",
headers=[("content-type", "application/json")],
body=body,
)
def test_passthrough_from_wire_byte_preserves_body():
"""The wire frame's base64 body decodes back to the exact bytes (parity with
the connector's toPassthroughForward)."""
original = json.dumps({"type": 2, "data": {"name": "ping"}, "guild_id": "g1"}).encode("utf-8")
wire = {
"platform": "discord",
"botId": "appShared",
"method": "POST",
"path": "/interactions/discord/appShared",
"headers": [["content-type", "application/json"]],
"bodyB64": base64.b64encode(original).decode("ascii"),
}
fwd = _passthrough_from_wire(wire)
assert fwd.platform == "discord"
assert fwd.bot_id == "appShared"
assert fwd.body == original
assert fwd.headers == [("content-type", "application/json")]
def test_passthrough_from_wire_tolerates_malformed_body():
"""A non-base64 body must not raise (the reader must never crash)."""
fwd = _passthrough_from_wire({"platform": "x", "bodyB64": "!!!not base64!!!"})
assert fwd.body == b""
@pytest.mark.asyncio
async def test_connect_wires_passthrough_handler_over_ws(adapter):
"""connect() registers the passthrough handler on the transport so a
connector-delivered passthrough_forward frame reaches the adapter."""
await adapter.connect()
stub = adapter._transport
assert stub._passthrough is not None
@pytest.mark.asyncio
async def test_discord_interaction_routes_through_handle_message(adapter, monkeypatch):
"""A forwarded Discord application-command interaction is decoded and routed
through the normal agent path (handle_message) with a correct session source."""
await adapter.connect()
stub = adapter._transport
seen = []
async def fake_handle(event):
seen.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
fwd = _interaction_forward(
{
"id": "interaction-1",
"type": 2, # APPLICATION_COMMAND
"channel_id": "chan-9",
"guild_id": "guild-7",
"data": {"name": "summarize"},
"member": {"user": {"id": "user-3", "username": "ben"}},
}
)
await stub.push_passthrough(fwd, buffer_id=None)
assert len(seen) == 1
ev = seen[0]
assert ev.text == "summarize"
assert ev.source.chat_id == "chan-9"
assert ev.source.guild_id == "guild-7"
assert ev.source.user_id == "user-3"
assert ev.source.chat_type == "channel"
# Scope captured so the agent's reply re-asserts guild_id for egress.
assert adapter._scope_by_chat.get("chan-9") == "guild-7"
@pytest.mark.asyncio
async def test_message_component_interaction_uses_custom_id(adapter, monkeypatch):
"""A MESSAGE_COMPONENT (button) interaction surfaces its custom_id as text."""
await adapter.connect()
stub = adapter._transport
seen = []
async def fake_handle(event):
seen.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
fwd = _interaction_forward(
{
"id": "i2",
"type": 3, # MESSAGE_COMPONENT
"channel_id": "c2",
"guild_id": "g2",
"data": {"custom_id": "approve_btn"},
"member": {"user": {"id": "u2", "username": "x"}},
}
)
await stub.push_passthrough(fwd)
assert len(seen) == 1
assert seen[0].text == "approve_btn"
@pytest.mark.asyncio
async def test_malformed_interaction_body_does_not_raise(adapter, monkeypatch):
"""A non-JSON forward is logged and dropped — never crashes the read loop."""
await adapter.connect()
stub = adapter._transport
called = []
async def fake_handle(event):
called.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
bad = PassthroughForward(
platform="discord",
bot_id="appShared",
method="POST",
path="/x",
headers=[],
body=b"not json",
)
await stub.push_passthrough(bad) # must not raise
assert called == []
@pytest.mark.asyncio
async def test_non_discord_forward_dropped_cleanly(adapter, monkeypatch):
"""A platform with no gateway-side handler yet (e.g. twilio) is dropped, not raised."""
await adapter.connect()
stub = adapter._transport
called = []
async def fake_handle(event):
called.append(event)
monkeypatch.setattr(adapter, "handle_message", fake_handle)
fwd = PassthroughForward(
platform="twilio",
bot_id="bot1",
method="POST",
path="/webhooks/twilio/seg",
headers=[],
body=b"From=+1&Body=hi",
)
await stub.push_passthrough(fwd) # must not raise
assert called == []