hermes-agent/tests/gateway/relay/stub_connector.py
Ben Barclay 6e4e5967f7
feat(relay): multi-platform-per-agent — list identity, provision-loop, N-hello, per-frame egress (Phase 1.5) (#52830)
Cut over the agent half of Shape A (D-Q1.5a/b.1/c) to front a SET of platforms on
one relay WS:

- relay_platform_identities() parses GATEWAY_RELAY_PLATFORMS (list) +
  GATEWAY_RELAY_BOT_IDS (JSON keyed map {platform:{botId,username?}}). Cut over
  from the scalar GATEWAY_RELAY_PLATFORM/_BOT_ID (no fallback, D-Q1.5c).
- self_provision_relay() loops one /relay/provision per platform under one
  gatewayId+secret, partial-failure-tolerant.
- WebSocketRelayTransport takes the identity SET, sends one hello per identity
  (connector accumulates the advertised set), and stamps the per-frame
  OutboundFrame.platform + its matching advertised botId on outbound.
- RelayAdapter remembers each chat's underlying source.platform (mirroring the
  existing guild/dm scope capture) and tags the reply's egress platform.
- send_relay_policy() declares one relevance policy per fronted platform (the
  connector keys policy by (tenant,platform,instanceId)).

Single-platform deploys are byte-identical on the wire (1-element list, no per-frame
tag -> connector session-default fallback). typecheck/ruff clean; relay unit 221 pass
(+10 new); all 15 cross-repo E2E drivers green vs connector origin/main.
2026-06-26 17:32:46 +10:00

115 lines
5.4 KiB
Python

"""Test-only in-memory stub connector implementing RelayTransport.
MUST stay under tests/ — never under plugins/ or gateway/ (a CI guard in
test_no_stub_leak.py asserts this). It lets Phase 1 prove the gateway side of
the relay end-to-end with zero dependency on the real (Node) connector.
The stub:
- hands back a fixed CapabilityDescriptor at handshake,
- lets a test push synthetic inbound MessageEvents (push_inbound),
- records every outbound action (sent/interrupts) for assertions,
- answers get_chat_info from a small fixture map.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from gateway.platforms.base import MessageEvent
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.relay.transport import InboundHandler
class StubConnector:
"""In-memory RelayTransport for tests."""
def __init__(self, descriptor: CapabilityDescriptor) -> None:
self._descriptor = descriptor
self._inbound: Optional[InboundHandler] = None
self._interrupt_inbound: Optional[Any] = None
self._passthrough: Optional[Any] = None
self.connected = False
self.sent: List[Dict[str, Any]] = []
# Per-frame egress platform recorded alongside each sent action (Phase 1.5).
self.sent_platforms: List[Optional[str]] = []
self.interrupts: List[Dict[str, Any]] = []
self.follow_ups: List[Dict[str, Any]] = []
self.follow_up_platforms: List[Optional[str]] = []
# The fronted (platform, bot_id) identity set (Phase 1.5). Mirrors the real
# transport's _identities so RelayAdapter._platform_is_fronted resolves; a
# single-identity default keeps existing tests' behaviour unchanged.
self._identities: List[tuple] = [(descriptor.platform, "")]
self.chat_info: Dict[str, Dict[str, Any]] = {}
# Canned result for the next send_outbound (override per-test).
self.next_send_result: Dict[str, Any] = {"success": True, "message_id": "m1"}
# Canned result for the next send_follow_up (override per-test). Default
# mimics a resolved capability egress; set success=False to simulate an
# absent/expired capability or a tenant mismatch on the connector side.
self.next_follow_up_result: Dict[str, Any] = {"success": True, "message_id": "f1"}
async def connect(self, *, is_reconnect: bool = False) -> bool:
self.connected = True
return True
async def disconnect(self) -> None:
self.connected = False
async def handshake(self) -> CapabilityDescriptor:
return self._descriptor
def set_inbound_handler(self, handler: InboundHandler) -> None:
self._inbound = handler
def set_interrupt_inbound_handler(self, handler: Any) -> None:
"""Mirror the real WS transport: the adapter registers its interrupt
bridge here so connector→gateway interrupt_inbound frames route to it."""
self._interrupt_inbound = handler
def set_passthrough_handler(self, handler: Any) -> None:
"""Mirror the real WS transport: the adapter registers its passthrough
bridge here so connector→gateway passthrough_forward frames route to it
(Phase 5 §5.1)."""
self._passthrough = handler
async def send_outbound(
self, action: Dict[str, Any], *, platform: Optional[str] = None
) -> Dict[str, Any]:
# Record the per-frame egress platform (Phase 1.5) alongside the action so
# tests can assert which platform a reply was tagged for.
self.sent.append(action)
self.sent_platforms.append(platform)
if action.get("op") == "send":
return dict(self.next_send_result)
return {"success": True}
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return self.chat_info.get(chat_id, {"name": chat_id, "type": "dm"})
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
self.interrupts.append({"session_key": session_key, "reason": reason})
async def send_follow_up(
self, action: Dict[str, Any], *, platform: Optional[str] = None
) -> Dict[str, Any]:
self.follow_ups.append(action)
self.follow_up_platforms.append(platform)
return dict(self.next_follow_up_result)
# ── test driver ──────────────────────────────────────────────────────
async def push_inbound(self, event: MessageEvent) -> None:
"""Simulate the connector delivering a normalized inbound event."""
if self._inbound is None:
raise RuntimeError("no inbound handler registered (call adapter.connect first)")
await self._inbound(event)
async def push_interrupt(self, session_key: str, chat_id: str) -> None:
"""Simulate the connector delivering an interrupt_inbound over the WS."""
if self._interrupt_inbound is None:
raise RuntimeError("no interrupt_inbound handler registered (call adapter.connect first)")
await self._interrupt_inbound(session_key, chat_id)
async def push_passthrough(self, forward: Any, buffer_id: Optional[str] = None) -> None:
"""Simulate the connector forwarding a passthrough request over the WS (§5.1)."""
if self._passthrough is None:
raise RuntimeError("no passthrough handler registered (call adapter.connect first)")
await self._passthrough(forward, buffer_id)