hermes-agent/tests/gateway/relay/test_relay_interrupt.py
Ben Barclay d2c53ff558
feat(relay): WS-only inbound on the gateway adapter (Phase 3) (#48294)
The connector now delivers inbound (messages + interrupts) over the gateway's
OUTBOUND /relay WebSocket, not a signed HTTP POST to an inbound endpoint. The
gateway needs no inbound HTTP port — which is what makes hosted gateways (no
public IP) able to receive inbound at all.

- gateway/relay/adapter.py: connect() wires set_interrupt_inbound_handler(
  self.on_interrupt) so connector->gateway interrupt_inbound frames bridge into
  the existing per-session interrupt path (the inbound message handler was
  already wired). Removed _maybe_start_inbound_receiver() + the _inbound_runner
  lifecycle — there is no HTTP receiver anymore.
- gateway/relay/inbound_receiver.py: deleted (the signed-HTTP InboundDelivery
  receiver).
- gateway/relay/__init__.py: removed relay_inbound_config() (dead with the
  receiver gone). The delivery key is still set in-process by self-provision for
  forward-compat but is no longer consumed for inbound.
- docs/relay-connector-contract.md: §3 rewritten — inbound is the WS back-channel
  routed cross-instance via the connector's relay bus; §5 interrupt + §6 auth
  table updated; the old signed-HTTP-POST + per-tenant-delivery-key-signing path
  is documented as superseded. gatewayEndpoint noted as passthrough-plane only.

Tests: stub_connector grows set_interrupt_inbound_handler + push_interrupt;
new test_relay_interrupt case proves connect() wires BOTH inbound handlers and an
interrupt_inbound frame over the WS cancels the right session. Removed the
HTTP-receiver test; updated the crypto-shedding scan + self-provision delivery-key
assertion. 88 relay tests pass.

EXPERIMENTAL. Pairs with gateway-gateway (relay bus + WsGatewayDelivery) and the
NAS GATEWAY_RELAY_URL stamp. The cross-repo E2E (connector repo) proves the full
multi-instance path against this production adapter code.
2026-06-19 09:33:15 +10:00

89 lines
3.1 KiB
Python

"""Relay /stop interrupt routing (relay Phase 1, Task 1.4).
Proves a connector-delivered mid-turn interrupt reaches the existing per-session
interrupt mechanism and cancels exactly the targeted session_key's turn — never
a sibling's. Mirrors the isolation discipline of test_stop_thread_sibling.py.
"""
from __future__ import annotations
import asyncio
import pytest
from gateway.config import PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
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()))
@pytest.mark.asyncio
async def test_interrupt_sets_only_target_session_event(adapter):
key_a = "agent:main:discord:group:chanA:userX"
key_b = "agent:main:discord:group:chanB:userY"
ev_a = asyncio.Event()
ev_b = asyncio.Event()
adapter._active_sessions[key_a] = ev_a
adapter._active_sessions[key_b] = ev_b
await adapter.on_interrupt(key_a, chat_id="chanA")
assert ev_a.is_set() is True, "target session's interrupt Event must be set"
assert ev_b.is_set() is False, "sibling session must be untouched"
@pytest.mark.asyncio
async def test_interrupt_unknown_session_is_noop(adapter):
# No active session for this key — must not raise.
await adapter.on_interrupt("agent:main:discord:group:nope:userZ", chat_id="nope")
@pytest.mark.asyncio
async def test_outbound_interrupt_reaches_connector(adapter):
"""The gateway-side /stop egress: send_interrupt is carried to the connector
so it can forward down the socket owning the session_key."""
stub = adapter._transport
await stub.send_interrupt("agent:main:discord:group:chanA:userX", reason="stop")
assert stub.interrupts == [
{"session_key": "agent:main:discord:group:chanA:userX", "reason": "stop"}
]
@pytest.mark.asyncio
async def test_connect_wires_inbound_interrupt_over_ws(adapter):
"""WS-only inbound: connect() registers BOTH the inbound message handler AND
the interrupt_inbound handler on the transport, so a connector-delivered
interrupt_inbound frame (no HTTP receiver) reaches the right session."""
await adapter.connect()
stub = adapter._transport
# Both connector->gateway handlers are wired post-connect.
assert stub._inbound is not None
assert stub._interrupt_inbound is not None
key = "agent:main:discord:group:chanA:userX"
ev = asyncio.Event()
adapter._active_sessions[key] = ev
# Simulate the connector pushing an interrupt_inbound frame down the WS.
await stub.push_interrupt(key, chat_id="chanA")
assert ev.is_set() is True, "interrupt delivered over the WS must cancel the target turn"