Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
View File
+131
View File
@@ -0,0 +1,131 @@
"""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_media op (Phase 2; override per-test).
self.next_media_result: Dict[str, Any] = {"success": True, "message_id": "md1"}
# Canned results for the Phase 3 interactive ops (override per-test).
self.next_prompt_result: Dict[str, Any] = {"success": True, "message_id": "pm1"}
self.next_react_result: Dict[str, Any] = {"success": True}
# 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"}
# Canned result for the next draft frame (NS-658 live cards). The
# sealing frame (final=true) echoes message_id = the stream ts.
self.next_draft_result: Dict[str, Any] = {"success": True}
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)
if action.get("op") == "draft":
return dict(self.next_draft_result)
if action.get("op") == "send_media":
return dict(self.next_media_result)
if action.get("op") == "prompt":
return dict(self.next_prompt_result)
if action.get("op") == "react":
return dict(self.next_react_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)
+105
View File
@@ -0,0 +1,105 @@
"""Unit tests for gateway/relay/auth.py — the gateway-side relay auth primitives.
Two layers:
1. **Self-consistency** — make_token/verify_token round-trip, delivery-signature
verify, rotation verify list, tamper + skew + expiry rejection.
2. **Cross-implementation conformance** — frozen vectors generated by the
connector's TypeScript (``src/core/relayAuthToken.ts`` ``makeToken``/``sign``)
are reproduced byte-for-byte by the Python port. If the connector ever
changes its wire scheme, these vectors must be regenerated in lockstep
(and that is the point — the test fails loudly on drift). Regenerate with:
node -e 'import("./dist/core/relayAuthToken.js").then(m=>{ \
const s="00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; \
console.log(m.makeToken("gw-instance-1", s, 0)); \
console.log(m.sign("1750000000."+JSON.stringify({a:1}), s)); })'
"""
from __future__ import annotations
import json
from gateway.relay.auth import (
DELIVERY_SIG_HEADER,
DELIVERY_TS_HEADER,
make_token,
make_upgrade_token,
sign,
verify_delivery_signature,
verify_signature,
verify_token,
)
# A fixed 256-bit hex secret used for the frozen connector vectors below.
_SECRET = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
# ── Frozen vectors produced by the connector's TypeScript (relayAuthToken.ts).
# Generated via dist/core/relayAuthToken.js makeToken/sign; see module docstring.
_CONN_TOKEN = "Z3ctaW5zdGFuY2UtMTowOjM3YWE3YjE0NWU4NzY0ZDQwM2JhOWM2MzlmMjMwZGQ2M2RlOGVkOTliODhmZWQzNmFhMDI2MjVhOGE3ZTM1NjQ"
# The EXACT bytes the connector signed: JS JSON.stringify emits compact JSON
# (no spaces). The gateway verifies over the literal received body, so the
# vector is the compact form — NOT Python's spaced json.dumps default. This is
# the raw-byte-preservation discipline (a single differing byte breaks the HMAC).
_CONN_BODY = '{"type":"message","event":{"text":"hi","source":{"chat_id":"c1"}}}'
_CONN_TS = 1750000000
_CONN_SIG = "ac9509c8dae52b5590f06378260877334ff1adc4b1c96bafa4b514165fae6dc6"
# ── Self-consistency ──────────────────────────────────────────────────────
def test_upgrade_token_is_make_token_of_gateway_id():
assert make_upgrade_token("gw-1", _SECRET, 0) == make_token("gw-1", _SECRET, 0)
def test_token_wrong_secret_rejected():
tok = make_token("p", _SECRET, 0)
assert verify_token(tok, ["deadbeef" * 8]) is None
def test_token_expired_rejected():
# ttl in the past -> exp < now -> rejected.
tok = make_token("p", _SECRET, ttl_seconds=1)
# Force expiry by signing with a manual past exp via the low-level helper.
# Simpler: a 1s ttl token is still valid now; instead assert a clearly-old one.
# Build an already-expired token by hand using the same scheme.
import base64
signed = "p:1" # exp=1 (1970) -> long past
sig = sign(signed, _SECRET)
raw = f"{signed}:{sig}".encode()
expired = base64.urlsafe_b64encode(raw).decode().rstrip("=")
assert verify_token(expired, [_SECRET]) is None
# And the fresh one is accepted.
assert verify_token(tok, [_SECRET]) == "p"
def test_verify_signature_constant_time_multi_secret():
payload = "1700000000.body"
s = sign(payload, _SECRET)
assert verify_signature(payload, s, ["wrong", _SECRET]) is True
assert verify_signature(payload, s, ["wrong"]) is False
assert verify_signature(payload, "zz", [_SECRET]) is False # bad hex
# ── Delivery signature (connector -> gateway inbound) ──────────────────────
def test_delivery_signature_skew_rejected():
body = "{}"
ts = 1700000000
s = sign(f"{ts}.{body}", _SECRET)
# Beyond the 300s replay window in either direction.
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts + 301) is False
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts - 301) is False
assert verify_delivery_signature(body, str(ts), s, [_SECRET], now=ts + 299) is True
# ── Cross-implementation conformance (frozen connector vectors) ────────────
def test_python_make_token_matches_connector_byte_for_byte():
assert make_token("gw-instance-1", _SECRET, 0) == _CONN_TOKEN
@@ -0,0 +1,86 @@
"""Unit tests for relay channel-context consumption (design relay-channel-context).
Covers:
- CapabilityDescriptor.supports_context: default False + JSON round-trip +
forward-compat (older gateway ignores it / newer connector sends it).
- _event_from_wire mapping the connector's read-only `context` array into the
existing MessageEvent.channel_context injection field, and leaving it unset
(byte-identical to today) when absent/empty/malformed.
- The trigger text is never affected by context (read-only invariant).
Pure unit tests: no socket, no websockets dependency.
"""
from __future__ import annotations
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.relay.ws_transport import _event_from_wire, _render_relay_context
def _descriptor_kwargs(**overrides):
base = dict(
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",
)
base.update(overrides)
return base
class TestDescriptorSupportsContext:
def test_from_json_ignores_unknown_keys(self):
# Forward-compat: a newer connector sending extra keys must not break.
payload = (
'{"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",'
'"supports_context":true,"some_future_field":123}'
)
d = CapabilityDescriptor.from_json(payload)
assert d.supports_context is True
class TestRenderRelayContext:
def test_none_and_empty_return_none(self):
assert _render_relay_context(None) is None
assert _render_relay_context([]) is None
assert _render_relay_context("not a list") is None
class TestEventFromWireContext:
def _wire(self, **overrides):
base = {
"text": "@bot repeat what they said above",
"message_type": "text",
"source": {
"platform": "discord",
"chat_id": "chan-1",
"chat_type": "channel",
"user_id": "author-1",
},
"message_id": "m-100",
}
base.update(overrides)
return base
def test_context_maps_into_channel_context(self):
ev = _event_from_wire(
self._wire(
context=[
{"text": "earlier", "source": {"user_name": "alice"}},
]
)
)
assert ev.channel_context is not None
assert "alice: earlier" in ev.channel_context
@@ -0,0 +1,126 @@
"""Cross-repo contract conformance: docs/relay-connector-contract.md ⟷ Python.
The contract doc is the formal interface the connector repo
(NousResearch/gateway-gateway) implements against. The connector's TypeScript
structs are hand-mirrored from the doc, so if the Python source of truth drifts
from the doc, the two repos silently diverge and the handshake / session-keying
breaks only at integration time.
These tests make the doc ⟷ code relationship an enforced invariant:
* Every ``CapabilityDescriptor`` field (§2 table) is documented with the
correct required/optional flag, and the doc lists no fields the dataclass
lacks.
* Every ``SessionSource`` wire key (what ``to_dict()`` actually serializes)
is named in the contract doc's §3 discriminator section, and every
discriminator the doc calls out as a column header exists on the dataclass.
They are invariants, NOT change-detector snapshots: they assert the *relation*
between two artifacts that must move together, not a frozen list of names. Add
a field to the descriptor and the doc, and the test stays green; add it to only
one, and CI fails — which is exactly the lockstep guarantee the plan's
Cross-Repo Coordination Checklist calls for.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.session import SessionSource
# Repo root: tests/gateway/relay/ -> repo root is parents[3]
_CONTRACT_DOC = (
Path(__file__).resolve().parents[3] / "docs" / "relay-connector-contract.md"
)
def _doc_text() -> str:
assert _CONTRACT_DOC.exists(), (
f"Contract doc missing at {_CONTRACT_DOC}. It is the formal cross-repo "
f"interface (Phase 1, Task 1.5) and must ship with the relay adapter."
)
return _CONTRACT_DOC.read_text(encoding="utf-8")
def _parse_descriptor_table(text: str) -> dict[str, bool]:
"""Parse §2's markdown table → {field_name: required}.
Rows look like: ``| `field` | type | yes|no | meaning |``. Returns a map of
field name to whether the Required column says "yes".
"""
fields: dict[str, bool] = {}
# Restrict to the §2 section so §3/§4 tables don't bleed in.
section = text.split("## 2. CapabilityDescriptor", 1)[-1].split("## 3.", 1)[0]
row_re = re.compile(r"^\|\s*`([a-z_]+)`\s*\|[^|]*\|\s*(yes|no)\s*\|", re.M)
for name, required in row_re.findall(section):
fields[name] = required.strip() == "yes"
return fields
def test_descriptor_fields_match_contract_doc():
"""§2 table ⟷ CapabilityDescriptor dataclass, names + required/optional."""
documented = _parse_descriptor_table(_doc_text())
assert documented, "Failed to parse any descriptor fields from the §2 table."
dc_fields = CapabilityDescriptor.__dataclass_fields__ # type: ignore[attr-defined]
# A dataclass field is "required" iff it has no default and no default_factory.
import dataclasses
code_required = {
name
for name, f in dc_fields.items()
if f.default is dataclasses.MISSING
and f.default_factory is dataclasses.MISSING # type: ignore[misc]
}
code_names = set(dc_fields.keys())
doc_names = set(documented.keys())
missing_from_doc = code_names - doc_names
assert not missing_from_doc, (
f"CapabilityDescriptor fields missing from the §2 contract-doc table: "
f"{sorted(missing_from_doc)}. Document them so the connector mirrors them."
)
extra_in_doc = doc_names - code_names
assert not extra_in_doc, (
f"Contract-doc §2 table documents fields the dataclass does not have: "
f"{sorted(extra_in_doc)}. Remove them or add them to descriptor.py."
)
# Required/optional must agree, so the connector knows which fields it may omit.
for name, doc_required in documented.items():
assert doc_required == (name in code_required), (
f"Field '{name}': contract doc says required={doc_required}, but the "
f"dataclass says required={name in code_required}. Reconcile them."
)
def _session_source_wire_keys() -> set[str]:
"""Keys ``SessionSource.to_dict()`` can emit (the actual wire surface).
Build a maximally-populated source so conditionally-included keys (the
``if self.x:`` branches in ``to_dict``) all appear.
"""
from gateway.config import Platform
src = SessionSource(
platform=Platform.DISCORD,
chat_id="c",
chat_name="n",
chat_type="channel",
user_id="u",
user_name="un",
thread_id="t",
chat_topic="topic",
user_id_alt="ua",
chat_id_alt="ca",
guild_id="g",
parent_chat_id="p",
message_id="m",
)
return set(src.to_dict().keys())
+66
View File
@@ -0,0 +1,66 @@
"""Tests for the experimental CapabilityDescriptor (relay Phase 0, Task 0.2)."""
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
def _telegram_descriptor(**overrides) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="telegram",
label="Telegram",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="markdown_v2",
len_unit="utf16",
emoji="\u2708\ufe0f",
platform_hint="You are on Telegram.",
pii_safe=False,
)
base.update(overrides)
return CapabilityDescriptor(**base)
def test_descriptor_is_frozen():
d = _telegram_descriptor()
try:
d.max_message_length = 1 # type: ignore[misc]
except Exception as exc: # FrozenInstanceError
assert "cannot assign" in str(exc) or "frozen" in str(exc).lower()
else: # pragma: no cover
raise AssertionError("descriptor should be immutable (frozen)")
def test_module_is_marked_experimental():
import gateway.relay.descriptor as m
assert "EXPERIMENTAL" in (m.__doc__ or "")
# ─────────────── supported_ops (op-level capability discovery, Phase 1) ───────────────
def test_supports_op_legacy_connector_assumes_legacy_set():
"""An empty supported_ops means the connector predates op discovery: the
legacy four ops are assumed supported (old connectors keep working), while
NEW ops are not (discovery semantics — never probe by trying)."""
d = _telegram_descriptor() # no supported_ops
for op in ("send", "edit", "typing", "follow_up"):
assert d.supports_op(op) is True, op
assert d.supports_op("get_chat_info") is False
def test_from_json_normalizes_malformed_supported_ops():
"""Non-list shapes and non-string members degrade to the legacy fallback
(empty tuple), never raise — malformed input can't break the handshake."""
base = (
'{"contract_version": 1, "platform": "x", "label": "X", '
'"max_message_length": 2000, "supports_draft_streaming": false, '
'"supports_edit": true, "supports_threads": false, '
'"markdown_dialect": "plain", "len_unit": "chars", '
)
d = CapabilityDescriptor.from_json(base + '"supported_ops": "send"}')
assert d.supported_ops == ()
d = CapabilityDescriptor.from_json(base + '"supported_ops": ["send", 7, null, ""]}')
assert d.supported_ops == ("send",)
@@ -0,0 +1,38 @@
"""Descriptor <- PlatformEntry projection (relay Phase 0, Task 0.3).
Proves the CapabilityDescriptor is a projection of the existing PlatformEntry,
not a parallel concept: the entry's label/limit/emoji/hint/pii fields carry
straight through.
"""
from gateway.platform_registry import PlatformEntry
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
def _entry(**overrides) -> PlatformEntry:
base = dict(
name="telegram",
label="Telegram",
adapter_factory=lambda cfg: None,
check_fn=lambda: True,
max_message_length=4096,
pii_safe=False,
emoji="\u2708\ufe0f",
platform_hint="You are on Telegram.",
)
base.update(overrides)
return PlatformEntry(**base)
def test_projection_carries_platform_entry_fields():
d = CapabilityDescriptor.from_platform_entry(_entry(), len_unit="utf16")
assert d.contract_version == CONTRACT_VERSION
assert d.platform == "telegram"
assert d.label == "Telegram"
assert d.max_message_length == 4096
assert d.emoji == "\u2708\ufe0f"
assert d.platform_hint == "You are on Telegram."
assert d.pii_safe is False
assert d.len_unit == "utf16"
@@ -0,0 +1,76 @@
"""Unit tests for /handoff platform aliasing over relay (Phase 1 parity).
Symptom fixed: on a relay-fronted gateway, ``/handoff discord`` was rejected
in TWO places even though "discord" is deliverable through the relay adapter:
1. The CLI pre-check (``cli_commands_mixin._handle_handoff_command``) looked
up ``gw_config.platforms.get(DISCORD)`` — only a RELAY entry exists.
2. The gateway watcher (``run.py _process_handoff``) did a literal
``adapters.get(discord)`` — the adapter is registered under RELAY.
Both now resolve fronted platforms through the same alias-aware machinery the
delivery router uses (``resolve_delivery_transport`` — native adapter wins;
relay eligible only when its authenticated transport advertises the logical
platform). These tests cover the resolver semantics the watcher depends on,
plus the CLI pre-check's env-derived fronted set.
"""
from __future__ import annotations
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.delivery import resolve_delivery_transport
class _RelayStub:
"""Minimal relay adapter double advertising a fronted-platform set."""
def __init__(self, fronted):
self._fronted = set(fronted)
self.sent = []
def fronts_platform(self, platform):
value = getattr(platform, "value", platform)
return str(value) in self._fronted
async def send_for_platform(self, logical_platform, chat_id, content, reply_to=None, metadata=None):
self.sent.append((getattr(logical_platform, "value", logical_platform), chat_id, content, metadata))
return type("R", (), {"success": True, "message_id": "m-1", "error": None})()
async def send(self, chat_id, content, reply_to=None, metadata=None): # pragma: no cover
raise AssertionError("relay transport must use send_for_platform, not send")
def _config_with(platforms):
cfg = GatewayConfig()
cfg.platforms = platforms
return cfg
class TestHandoffRelayAliasing:
def test_fronted_platform_resolves_to_relay_adapter(self):
"""The watcher's exact miss: adapters holds only RELAY, target is discord."""
relay = _RelayStub({"discord"})
cfg = _config_with({Platform.RELAY: PlatformConfig(enabled=True)})
transport = resolve_delivery_transport(
Platform.DISCORD, cfg, {Platform.RELAY: relay}
)
assert transport is not None
assert transport.adapter is relay
assert transport.is_relay is True
class TestCliHandoffFrontedSet:
"""The CLI pre-check derives the fronted set from deploy env (no live adapter)."""
def test_fronted_set_from_env(self, monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord,telegram")
monkeypatch.delenv("GATEWAY_RELAY_BOT_IDS", raising=False)
from gateway.relay import relay_platform_identities
fronted = {p for p, _ in relay_platform_identities()}
assert "discord" in fronted
assert "telegram" in fronted
assert "slack" not in fronted
@@ -0,0 +1,257 @@
"""Unit tests for the generic-OIDC / Nous-Portal caller-identity token resolver.
Covers gateway.relay._resolve_relay_identity_token() — the canonical resolver
shared by the runtime self-provision path and the `hermes gateway enroll` CLI.
Three modes:
1. Generic OAuth2 client_credentials when gateway.idp.token_url (or
GATEWAY_RELAY_IDP_TOKEN_URL) is configured WITH client credentials
(air-gapped / self-hosted-IdP).
1b. Ambient token endpoint when token_url is configured WITHOUT client
credentials: plain GET, body is the token (raw JWT or JSON envelope).
The metadata-server pattern (e.g. Domino's $DOMINO_API_PROXY/access-token).
2. Nous Portal (resolve_nous_access_token) otherwise — the default.
The HTTP calls and the Nous resolver are monkeypatched; these prove the mode
SELECTION, the request shapes, and the fail-closed paths.
"""
from __future__ import annotations
import io
import json
import pytest
import gateway.relay as relay
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for k in (
"GATEWAY_RELAY_IDP_TOKEN_URL",
"GATEWAY_RELAY_IDP_CLIENT_ID",
"GATEWAY_RELAY_IDP_CLIENT_SECRET",
"GATEWAY_RELAY_IDP_SCOPE",
):
monkeypatch.delenv(k, raising=False)
# Never read config.yaml off disk by default.
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False)
def test_client_credentials_via_env(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "agent-client")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "shh")
monkeypatch.setenv("GATEWAY_RELAY_IDP_SCOPE", "connector.provision")
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["body"] = req.data.decode()
captured["headers"] = {k.lower(): v for k, v in req.headers.items()}
return io.BytesIO(json.dumps({"access_token": "idp-workload-token"}).encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
token = relay._resolve_relay_identity_token()
assert token == "idp-workload-token"
assert captured["url"] == "https://idp.test/token"
assert captured["method"] == "POST"
# client_credentials grant, form-encoded, with all fields.
assert "grant_type=client_credentials" in captured["body"]
assert "client_id=agent-client" in captured["body"]
assert "client_secret=shh" in captured["body"]
assert "scope=connector.provision" in captured["body"]
assert captured["headers"]["content-type"] == "application/x-www-form-urlencoded"
def test_raises_when_no_access_token_in_response(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "c")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "s")
def fake_urlopen(req, timeout=None):
return io.BytesIO(json.dumps({"token_type": "Bearer"}).encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="no access_token"):
relay._resolve_relay_identity_token()
# ---------------------------------------------------------------------------
# Mode 1b — ambient token endpoint (token_url set, NO client credentials).
# The metadata-server pattern: plain GET, response body IS the token, either
# a raw JWT string (Domino's $DOMINO_API_PROXY/access-token) or a JSON
# envelope ({"access_token": ...}).
# ---------------------------------------------------------------------------
_FAKE_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2ln"
def test_ambient_get_when_no_client_credentials(monkeypatch):
"""token_url without client_id/secret selects a plain GET, not a raise."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["body"] = req.data
return io.BytesIO((_FAKE_JWT + "\n").encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
token = relay._resolve_relay_identity_token()
assert token == _FAKE_JWT # raw body, whitespace-trimmed
assert captured["url"] == "https://proxy.local/access-token"
assert captured["method"] == "GET"
assert captured["body"] is None # no form payload on the ambient path
def test_ambient_accepts_json_envelope(monkeypatch):
"""Ambient endpoints that return {"access_token": ...} JSON also work."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
def fake_urlopen(req, timeout=None):
return io.BytesIO(json.dumps({"access_token": _FAKE_JWT}).encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
assert relay._resolve_relay_identity_token() == _FAKE_JWT
@pytest.mark.parametrize(
"envelope",
[
{"access_token": 12345678901234567890123456789012}, # number, not string
{"access_token": True}, # boolean: str() would coerce to 'True'
{"access_token": {"nested": "x"}}, # object
{"access_token": ""}, # empty string
{"access_token": None},
{"token": "wrong-field-name"},
],
)
def test_ambient_rejects_non_string_envelope_values(monkeypatch, envelope):
"""access_token in a JSON envelope must be a non-empty STRING — the same
contract as the client_credentials path. No str() coercion of numbers,
booleans, or objects into 'tokens'."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
def fake_urlopen(req, timeout=None):
return io.BytesIO(json.dumps(envelope).encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="ambient"):
relay._resolve_relay_identity_token()
def test_ambient_rejects_non_token_body(monkeypatch):
"""A body that is neither a JWT-ish string nor a token envelope fails loudly."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
def fake_urlopen(req, timeout=None):
return io.BytesIO(b"<html>404 not found</html>")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="ambient"):
relay._resolve_relay_identity_token()
def test_ambient_rejects_empty_body(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
def fake_urlopen(req, timeout=None):
return io.BytesIO(b" \n")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="ambient"):
relay._resolve_relay_identity_token()
def test_ambient_rejects_short_plaintext_error_words(monkeypatch):
"""A terse plain-text error body (e.g. 'unauthorized') must not be
returned as a credential — it matches the base64url alphabet but is
neither a JWT nor plausibly an opaque token."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
for body in ("unauthorized", "error", "access_denied", "null", "forbidden"):
def fake_urlopen(req, timeout=None, _b=body):
return io.BytesIO(_b.encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="ambient"):
relay._resolve_relay_identity_token()
def test_ambient_accepts_long_opaque_token(monkeypatch):
"""Non-JWT opaque bearer tokens (long random strings) still work."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://proxy.local/access-token")
opaque = "v2_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b"
def fake_urlopen(req, timeout=None):
return io.BytesIO(opaque.encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
assert relay._resolve_relay_identity_token() == opaque
def test_client_credentials_still_selected_when_creds_present(monkeypatch):
"""Presence of client creds keeps the POST grant — ambient never hijacks it."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "agent-client")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "shh")
captured = {}
def fake_urlopen(req, timeout=None):
captured["method"] = req.get_method()
return io.BytesIO(json.dumps({"access_token": "cc-token"}).encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
assert relay._resolve_relay_identity_token() == "cc-token"
assert captured["method"] == "POST"
def test_partial_credentials_client_id_only_raises_without_get(monkeypatch):
"""client_id without client_secret is a misconfig: loud error, no ambient GET."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_ID", "agent-client")
def fake_urlopen(req, timeout=None):
raise AssertionError("no HTTP request may be issued on partial credentials")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="client_secret missing"):
relay._resolve_relay_identity_token()
def test_partial_credentials_client_secret_only_raises_without_get(monkeypatch):
"""client_secret without client_id is a misconfig: loud error, no ambient GET."""
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://idp.test/token")
monkeypatch.setenv("GATEWAY_RELAY_IDP_CLIENT_SECRET", "shh")
def fake_urlopen(req, timeout=None):
raise AssertionError("no HTTP request may be issued on partial credentials")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(RuntimeError, match="client_id missing"):
relay._resolve_relay_identity_token()
def test_ambient_via_config_yaml(monkeypatch):
"""Ambient mode also engages when token_url comes from config.yaml, not env."""
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"gateway": {"idp": {"token_url": "https://proxy.local/access-token"}}},
raising=False,
)
def fake_urlopen(req, timeout=None):
assert req.get_method() == "GET"
return io.BytesIO(_FAKE_JWT.encode())
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
assert relay._resolve_relay_identity_token() == _FAKE_JWT
@@ -0,0 +1,133 @@
"""Local integration trace: REAL StreamConsumer + REAL RelayAdapter + stub
transport. Reproduces the multi-segment live-cards turn to reveal the exact
op sequence the connector sees (finding #4/#5 forensics — Alice canary).
Run: python -m pytest tests/gateway/relay/test_live_cards_flow_trace.py -q -s
"""
import asyncio
import sys
import types
import pytest
from gateway.relay.adapter import RelayAdapter
class TraceTransport:
"""Stub connector transport recording every outbound op."""
def __init__(self, fail_ops=None):
self.ops = []
self.fail_ops = set(fail_ops or ())
self._ts = 1000
async def send_outbound(self, payload, platform=None):
op = payload.get("op")
self.ops.append(dict(payload))
if op in self.fail_ops:
return {"success": False, "error": f"stub-forced {op} failure"}
self._ts += 1
return {"success": True, "message_id": f"{self._ts}.000"}
def _mk_adapter(supported_ops=("send", "edit", "typing", "draft", "task_card", "task_card_stop")):
# Reuse the live-cards test helper wiring (descriptor + config stubs).
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
adapter, _ = _connected_adapter(supported_ops=supported_ops)
t = TraceTransport()
adapter._transport = t
return adapter, t
def test_trace_multisegment_draft_flow():
"""Simulate the consumer's actual call pattern for a 3-segment turn:
seg1 drafts -> segment-break finalize (send) -> seg2 drafts ->
segment-break finalize (send) -> seg3 drafts -> turn-final send.
Prints the op timeline; asserts the invariant we EXPECT enterprise-wise:
at most ONE final message identity visible to the user.
"""
adapter, t = _mk_adapter()
loop = asyncio.new_event_loop()
md = {"thread_ts": "1700.100"}
async def turn():
# segment 1 streaming
await adapter.send_draft("C1", 7, "seg1 partial", metadata=md)
await adapter.send_draft("C1", 7, "seg1 complete.", metadata=md)
# tool boundary (fix #5): consumer now emits a cumulative draft
# frame instead of a finalize send for stream-is-the-message
# adapters — simulate that call shape.
await adapter.send_draft("C1", 7, "seg1 complete.", metadata=md)
# segment 2 streaming (fix #4: same draft_id)
await adapter.send_draft("C1", 7, "seg1 complete.\nseg2 partial", metadata=md)
await adapter.send_draft("C1", 7, "seg1 complete.\nseg2 complete.", metadata=md)
# segment 3 + the ONE turn-final send (seal-intercepted)
await adapter.send_draft(
"C1", 7, "seg1 complete.\nseg2 complete.\nfinal answer partial", metadata=md
)
r3 = await adapter.send(
"C1", "seg1 complete.\nseg2 complete.\nfinal answer complete.", metadata=md
)
return r3
r3 = loop.run_until_complete(turn())
print("\n--- OP TIMELINE ---")
for i, op in enumerate(t.ops):
print(f"{i:2d} {op['op']:<16} final={op.get('final')} draft_id={op.get('draft_id')} "
f"content={str(op.get('content'))[:40]!r}")
finals = [o for o in t.ops if o["op"] == "draft" and o.get("final")]
plain_sends = [o for o in t.ops if o["op"] == "send"]
print(f"seal frames: {len(finals)}, plain sends: {len(plain_sends)}")
# The user-visible message count = seals + plain sends (each seal ends a
# visible stream message; each plain send posts a message).
visible = len(finals) + len(plain_sends)
print(f"user-visible messages this turn: {visible}")
assert visible == 1, (
f"turn produced {visible} user-visible messages (expected 1): "
f"each segment-break send() gets converted to draft(final=true) by the "
f"adapter's seal-interception, sealing a stream PER SEGMENT"
)
def test_trace_parallel_turns_do_not_collide():
"""Finding #10 (live): three concurrent turns in ONE flat DM must keep
fully independent stream + card identities. Per-chat keying merged
turn B's card into turn A's and clobbered seal state (3x duplicates)."""
adapter, t = _mk_adapter()
loop = asyncio.new_event_loop()
# Each turn's metadata carries its own thread anchor (inbound stamps
# thread_ts = event.thread_ts or ts on every top-level message).
md_a = {"thread_ts": "100.1"}
md_b = {"thread_ts": "200.2"}
async def interleaved():
# A and B stream interleaved on the SAME chat with different anchors
await adapter.send_draft("C1", 11, "A partial", metadata=md_a)
await adapter.send_draft("C1", 12, "B partial", metadata=md_b)
# A's card and B's card must be distinct card_ids
await adapter.send_native_task_card_progress(
"C1", [{"id": "t1", "title": "x", "status": "in_progress"}],
reply_to=None, metadata=md_a)
await adapter.send_native_task_card_progress(
"C1", [{"id": "t2", "title": "y", "status": "in_progress"}],
reply_to=None, metadata=md_b)
# A seals; B keeps streaming — B's state must survive A's seal
ra = await adapter.send("C1", "A final.", metadata=md_a)
await adapter.send_draft("C1", 12, "B partial more", metadata=md_b)
rb = await adapter.send("C1", "B final.", metadata=md_b)
return ra, rb
ra, rb = loop.run_until_complete(interleaved())
drafts = [o for o in t.ops if o["op"] == "draft"]
seals = [o for o in drafts if o.get("final")]
cards = [o for o in t.ops if o["op"] == "task_card"]
plain = [o for o in t.ops if o["op"] == "send"]
# Distinct card identities per turn:
assert len({c["card_id"] for c in cards}) == 2, cards
# Each turn sealed its OWN stream (2 seals, matching draft_ids 11/12):
assert sorted(s["draft_id"] for s in seals) == [11, 12], seals
# No leaked plain send: both finals absorbed by their own seals:
assert not plain, plain
# B's post-A-seal frame was NOT dropped by A's tombstone:
b_frames = [d for d in drafts if d["draft_id"] == 12 and not d.get("final")]
assert len(b_frames) == 2, b_frames
+44
View File
@@ -0,0 +1,44 @@
"""CI guard: the test-only StubConnector must never leak into production paths.
The relay stub connector lives under tests/ and exists only to prove the
gateway side of the relay without the real (Node) connector. If it ever appears
under gateway/ or plugins/, that's a production leak — fail loudly.
"""
from __future__ import annotations
import pathlib
import re
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
_FORBIDDEN_DIRS = ("gateway", "plugins")
# Match actual code leaks (imports / class definitions), not prose mentions in
# docstrings/comments. A production file that *imports* the stub or *defines*
# StubConnector is a real leak; a docstring that references the stub's path as
# documentation is not.
_LEAK_PATTERNS = (
re.compile(r"^\s*(?:from|import)\s+.*stub_connector", re.MULTILINE),
re.compile(r"^\s*(?:from|import)\s+.*\bStubConnector\b", re.MULTILINE),
re.compile(r"^\s*class\s+StubConnector\b", re.MULTILINE),
)
def test_stub_connector_does_not_leak_into_production_paths():
offenders: list[str] = []
for top in _FORBIDDEN_DIRS:
base = _REPO_ROOT / top
if not base.is_dir():
continue
for path in base.rglob("*.py"):
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError: # pragma: no cover
continue
for pat in _LEAK_PATTERNS:
if pat.search(text):
offenders.append(
f"{path.relative_to(_REPO_ROOT)} matches {pat.pattern!r}"
)
assert not offenders, (
"relay test stub leaked into production paths:\n " + "\n ".join(offenders)
)
@@ -0,0 +1,167 @@
"""Regression: the transport's timeout-shaped RESULT is ambiguous, not a
rejection (PR 85796 review round 2, finding 1).
The production ws transport does not raise on ack timeout — it returns
{"success": False, "error": "relay outbound timed out", "ambiguous": True}.
The round-1 fixes keyed ambiguity handling entirely on the exception
channel, so the shape production actually produces was misclassified:
- a lost SEAL ack skipped the idempotent retry and fell straight to a
plain send (duplicate final whenever the seal had actually applied);
- a lost FRAME ack was treated as a definite connector rejection and
DISARMED interception — frozen native stream beside a plain final
(the original G-D1 ambiguous-ack defect, moved to the result channel).
Contract now: transport tags ack-timeouts ambiguous=True (fail-fast
closing/not-connected results stay unmarked — nothing was sent); the
adapter retries the idempotent seal frame on ambiguous results exactly as
for exceptions, and disarms interception only on definite rejections.
"""
import asyncio
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
class AckLossTransport:
"""Returns the production timeout shape for selected ops."""
def __init__(self, lose_acks_for=(), lose_times=None):
self.ops = []
self.lose_acks_for = set(lose_acks_for)
self.lose_times = dict(lose_times or {}) # key -> remaining losses
self._n = 0
def _lost(self, key):
if key not in self.lose_acks_for:
return False
remaining = self.lose_times.get(key)
if remaining is None:
return True
if remaining <= 0:
return False
self.lose_times[key] = remaining - 1
return True
async def send_outbound(self, payload, platform=None):
op = payload.get("op")
key = "seal" if (op == "draft" and payload.get("final")) else op
self.ops.append((op, bool(payload.get("final")), str(payload.get("content"))[:30]))
if self._lost(key):
return {
"success": False,
"error": "relay outbound timed out",
"ambiguous": True,
}
self._n += 1
return {"success": True, "message_id": f"ts.{self._n}"}
class TestSealAckLoss:
@pytest.mark.asyncio
async def test_lost_seal_ack_retries_idempotent_frame(self):
"""One lost seal ack → the SAME final frame is retried; its ack
carries the stream ts; NO plain send fires."""
t = AckLossTransport(lose_acks_for=("seal",), lose_times={"seal": 1})
adapter, _ = _connected_adapter()
adapter._transport = t
md = {"message_id": "m.1"}
await adapter.send_draft("C1", 7, "partial", metadata=md)
r = await adapter.send("C1", "complete", metadata=dict(md))
assert r.success
seal_attempts = [o for o in t.ops if o[0] == "draft" and o[1]]
assert len(seal_attempts) == 2, "ambiguous seal result must retry"
assert not [o for o in t.ops if o[0] == "send"], (
"a retried-and-acked seal must not be followed by a plain send"
)
@pytest.mark.asyncio
async def test_seal_ack_lost_twice_reports_ambiguous_failure(self):
t = AckLossTransport(lose_acks_for=("seal",))
adapter, _ = _connected_adapter()
adapter._transport = t
md = {"message_id": "m.2"}
await adapter.send_draft("C1", 8, "partial", metadata=md)
r = await adapter.send("C1", "complete", metadata=dict(md))
# Fail-open: the plain send goes out (send acks fine here) — a
# possible duplicate beats a silent loss after double ack loss.
assert r.success
assert [o for o in t.ops if o[0] == "draft" and o[1]], "seal attempted"
assert [o for o in t.ops if o[0] == "send"], "fail-open plain send ran"
class TestFrameAckLoss:
@pytest.mark.asyncio
async def test_lost_frame_ack_keeps_interception_armed(self):
"""THE round-2 regression: a timeout-shaped frame result must not
disarm — the connector may have applied the frame, and the
turn-final must still seal the stream."""
t = AckLossTransport(lose_acks_for=("draft",), lose_times={"draft": 1})
adapter, _ = _connected_adapter()
adapter._transport = t
md = {"message_id": "m.3"}
r1 = await adapter.send_draft("C1", 9, "partial", metadata=md)
assert not r1.success
key = adapter._draft_key("C1", md)
assert adapter._open_draft_by_chat.get(key) == 9, (
"ambiguous frame result disarmed interception (round-2 finding 1b)"
)
final = await adapter.send("C1", "complete", metadata=dict(md))
assert final.success
seals = [o for o in t.ops if o[0] == "draft" and o[1]]
assert len(seals) == 1 and seals[0][2] == "complete"
@pytest.mark.asyncio
async def test_definite_rejection_still_disarms(self):
"""Sibling guard: an explicit non-ambiguous rejection keeps the
round-1 disarm semantics."""
class RejectTransport:
def __init__(self):
self.ops = []
async def send_outbound(self, payload, platform=None):
self.ops.append((payload.get("op"), bool(payload.get("final"))))
if payload.get("op") == "draft":
return {"success": False, "error": "stream_gone"}
return {"success": True, "message_id": "m"}
adapter, _ = _connected_adapter()
t = RejectTransport()
adapter._transport = t
md = {"message_id": "m.4"}
r = await adapter.send_draft("C1", 10, "partial", metadata=md)
assert not r.success
assert not adapter._open_draft_by_chat, "definite rejection must disarm"
await adapter.send("C1", "final", metadata=dict(md))
assert t.ops[-1] == ("send", False)
class TestTransportTagsAmbiguity:
@pytest.mark.asyncio
async def test_ws_transport_timeout_result_is_tagged(self):
"""Source-of-truth check on the production transport: the ack
timeout branch carries ambiguous=True; fail-fast branches do not."""
from gateway.relay.ws_transport import WebSocketRelayTransport
transport = WebSocketRelayTransport.__new__(WebSocketRelayTransport)
transport._closing = False
transport._ws = object() # non-None so we reach the pending path
transport._pending = {}
transport._outbound_timeout_s = 0.01
transport._bot_id_for = lambda p: None
async def _send(frame):
return None # sent fine; ack never arrives
transport._send = _send
result = await transport.send_outbound({"op": "send"})
assert result["success"] is False
assert result.get("ambiguous") is True
transport._closing = True
result2 = await transport.send_outbound({"op": "send"})
assert result2["success"] is False
assert "ambiguous" not in result2, "fail-fast paths are definite"
+334
View File
@@ -0,0 +1,334 @@
"""RelayAdapter capability-advertisement tests (relay Phase 1, Task 1.1)."""
import asyncio
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="telegram",
label="Telegram",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="markdown_v2",
len_unit="utf16",
emoji="\u2708\ufe0f",
platform_hint="",
pii_safe=False,
)
base.update(kw)
return CapabilityDescriptor(**base)
def _adapter(**desc_kw) -> RelayAdapter:
return RelayAdapter(PlatformConfig(), make_desc(**desc_kw))
def test_relay_platform_member_exists():
assert Platform("relay") is Platform.RELAY
def test_advertises_descriptor_max_length():
a = _adapter(max_message_length=2000)
assert a.MAX_MESSAGE_LENGTH == 2000
def test_supports_draft_streaming_follows_descriptor():
# NS-658: the flag alone no longer advertises drafts — before the
# "draft" op existed, flag=True was a latent lie (send_draft inherited
# NotImplementedError). Advertisement now requires flag AND op.
assert _adapter(supports_draft_streaming=False).supports_draft_streaming() is False
assert (
_adapter(supports_draft_streaming=False, supported_ops=("send", "draft"))
.supports_draft_streaming()
is False
), "op without flag must not advertise"
assert (
_adapter(supports_draft_streaming=True, supported_ops=("send", "draft"))
.supports_draft_streaming()
is True
)
def test_len_fn_utf16_counts_code_units():
a = _adapter(len_unit="utf16")
# An astral-plane emoji is two UTF-16 code units.
assert a.message_len_fn("\U0001f600") == 2
def test_is_a_base_platform_adapter():
# stream_consumer's isinstance(adapter, BasePlatformAdapter) guard must pass.
from gateway.platforms.base import BasePlatformAdapter
assert isinstance(_adapter(), BasePlatformAdapter)
def test_connect_signature_matches_base_contract():
"""The is_reconnect parameter must be keyword-accepting and default False,
matching BasePlatformAdapter.connect, so the reconnect watcher's
``connect(is_reconnect=...)`` call is valid for relay as for every other
adapter."""
import inspect
from gateway.platforms.base import BasePlatformAdapter
sig = inspect.signature(RelayAdapter.connect)
base_sig = inspect.signature(BasePlatformAdapter.connect)
assert "is_reconnect" in sig.parameters
param = sig.parameters["is_reconnect"]
base_param = base_sig.parameters["is_reconnect"]
# Keyword-acceptable (KEYWORD_ONLY here, matching the base) with a False default.
assert param.kind is base_param.kind
assert param.default is False
class _CaptureTransport:
"""Minimal RelayTransport stand-in that records the outbound action."""
def __init__(self):
self.sent = None
self.sent_platform = None
# No concrete fronted identities ⇒ _platform_is_fronted is a no-op here.
self._identities = []
def set_inbound_handler(self, h): # noqa: D401
self._h = h
async def send_outbound(self, action, *, platform=None):
self.sent = action
self.sent_platform = platform
return {"success": True, "message_id": "m1"}
def _make_event(chat_id="chan-1", scope_id="scope-9"):
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
src = SessionSource(
platform=Platform.RELAY,
chat_id=chat_id,
chat_type="channel",
scope_id=scope_id,
)
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
def _make_dm_event(chat_id="dm-1", user_id="user-42"):
"""An inbound DM: no scope_id, carries the authentic author user_id."""
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
src = SessionSource(
platform=Platform.RELAY,
chat_id=chat_id,
chat_type="dm",
scope_id=None,
user_id=user_id,
)
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
def _make_scoped_event_with_author(
chat_id="chan-1", scope_id="scope-9", user_id="user-42"
):
"""An inbound scoped (guild/channel) message that ALSO carries the authentic
author user_id — the real shape of a Discord guild message (it has both a
guild scope_id and an author). Used to prove the adapter re-attaches BOTH
discriminators so the connector can fall back author-first when the guild
has no route row (managed agents join guilds dynamically)."""
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
src = SessionSource(
platform=Platform.RELAY,
chat_id=chat_id,
chat_type="channel",
scope_id=scope_id,
user_id=user_id,
)
return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
@pytest.mark.asyncio
async def test_send_reattaches_dm_user_id_from_inbound_scope():
"""A DM reply has no scope_id, so the connector resolves the tenant from the
recipient's author binding — it needs metadata.user_id. The adapter must
re-attach the authentic author id learned from the inbound DM. Regression for
live 'discord egress declined: target not routed to an onboarded tenant' on
DM replies (the connector-side fix is gateway-gateway #67)."""
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
a._capture_scope(_make_dm_event(chat_id="dm-1", user_id="user-42"))
await a.send("dm-1", "the reply")
assert t.sent["metadata"].get("user_id") == "user-42"
# A DM carries no scope_id — only the author discriminator.
assert "scope_id" not in t.sent["metadata"]
@pytest.mark.asyncio
async def test_scoped_reply_reattaches_both_scope_id_and_user_id():
"""A scoped (guild) reply now re-attaches BOTH scope_id AND the authentic
author user_id. scope_id is the connector's primary discriminator; user_id
is the author-first FALLBACK the connector uses when the guild has no route
row (a managed agent joins guilds dynamically, so a provision-time guild
route is not guaranteed). Regression for live 'discord egress declined:
target not routed to an onboarded tenant' on GUILD replies (paired with
gateway-gateway makeDiscordTenantOf guild-route-miss fallback)."""
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
a._capture_scope(
_make_scoped_event_with_author(
chat_id="chan-1", scope_id="scope-9", user_id="user-42"
)
)
await a.send("chan-1", "hi")
assert t.sent["metadata"].get("scope_id") == "scope-9"
assert t.sent["metadata"].get("user_id") == "user-42"
@pytest.mark.asyncio
async def test_stop_typing_forwards_explicit_clear_with_routing_context():
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="slack"), transport=t)
event = _make_event(chat_id="channel-1", scope_id="workspace-1")
event.source.platform = Platform.SLACK
a._capture_scope(event)
await a.stop_typing("channel-1", metadata={"thread_id": "thread-1"})
assert t.sent == {
"op": "typing",
"chat_id": "channel-1",
"content": "",
"metadata": {
"thread_id": "thread-1",
"scope_id": "workspace-1",
},
}
assert t.sent_platform == "slack"
# ── typing indicator over the relay (op="typing") ────────────────────────────
@pytest.mark.asyncio
async def test_send_typing_tags_egress_platform():
"""Phase 1.5: a multi-platform gateway must egress typing through the
platform the chat lives on, exactly like send() — the underlying platform
learned from the inbound event tags the frame."""
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
t = _CaptureTransport()
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
src = SessionSource(
platform=Platform.DISCORD,
chat_id="chan-2",
chat_type="channel",
scope_id="scope-1",
)
a._capture_scope(MessageEvent(text="hi", source=src, message_type=MessageType.TEXT))
await a.send_typing("chan-2")
assert t.sent_platform == "discord"
# ── Phase 7 Unit 7d-B: terminal auth revocation → clean "relay disabled" ─────
class _RevokedTransport:
"""Transport stand-in that reports a terminal auth revocation (the
production WebSocketRelayTransport latches this after a 4401 close that
follows a successful handshake)."""
def __init__(self):
self.auth_revoked = True
def set_inbound_handler(self, h): # noqa: D401
self._h = h
# ─────────────── get_chat_info gated on supported_ops (Phase 1) ───────────────
class _ChatInfoTransport:
"""Transport stub that records whether get_chat_info was proxied."""
def __init__(self):
self.calls = []
def set_inbound_handler(self, h): # noqa: D401
self._h = h
async def get_chat_info(self, chat_id):
self.calls.append(chat_id)
return {"name": "general", "type": "channel"}
@pytest.mark.asyncio
async def test_get_chat_info_local_fallback_when_not_advertised():
"""A connector that advertises ops but OMITS get_chat_info is authoritative."""
t = _ChatInfoTransport()
a = RelayAdapter(
PlatformConfig(),
make_desc(supported_ops=("send", "edit", "typing")),
transport=t,
)
info = await a.get_chat_info("chan-1")
assert info == {"name": "chan-1", "type": "dm"}
assert t.calls == []
class _HangOnIdleTransport:
"""Transport that hangs in go_idle so outer disconnect cancellation can race it."""
def __init__(self):
self.go_idle_started = asyncio.Event()
self.go_idle_timeouts: list[float] = []
self.disconnect_calls = 0
def set_inbound_handler(self, h): # noqa: D401
self._h = h
async def go_idle(self, timeout_s: float = 10.0):
self.go_idle_timeouts.append(timeout_s)
self.go_idle_started.set()
await asyncio.sleep(3600)
return False
async def disconnect(self):
self.disconnect_calls += 1
@pytest.mark.asyncio
async def test_disconnect_tears_down_transport_when_go_idle_is_cancelled():
"""Runner disconnect budgets can cancel adapter.disconnect mid go_idle.
The gateway runner's default adapter disconnect budget is 5s, while
transport.go_idle defaults to 10s. If cancellation lands during the idle
handshake, transport.disconnect must still run so the websocket/supervisor
cannot outlive the adapter.
"""
transport = _HangOnIdleTransport()
adapter = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=transport)
task = asyncio.create_task(adapter.disconnect())
await asyncio.wait_for(transport.go_idle_started.wait(), timeout=1.0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert transport.disconnect_calls == 1
assert transport.go_idle_timeouts
assert transport.go_idle_timeouts[0] < 5.0
@@ -0,0 +1,73 @@
"""A2 outbound capability action: the token-less ``follow_up`` op.
Proves the gateway can act on a shared-identity capability (e.g. a Discord
interaction follow-up token) WITHOUT ever holding the credential: it names the
session it is in plus the capability ``kind``, and the connector resolves the
real value from its vault and egresses. See gateway/relay/transport.py
(send_follow_up) and docs/relay-connector-contract.md §4.
The gateway side is what's exercised here (against the stub connector); the
connector's resolve + tenant-match enforcement lives in the connector repo
(resolveOutboundCapability). The key gateway-side guarantees:
- the wire action carries NO token (only session_key + kind + content),
- success/failure surfaces from the connector's resolve result,
- a failed resolve (absent/expired/tenant mismatch) returns success=False
with nothing for the gateway to retry with.
"""
from __future__ import annotations
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 _discord_descriptor() -> 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 wired():
stub = StubConnector(_discord_descriptor())
adapter = RelayAdapter(PlatformConfig(), _discord_descriptor(), transport=stub)
return adapter, stub
@pytest.mark.asyncio
async def test_follow_up_wire_action_carries_no_credential(wired):
"""The action dict must carry only session refs — no credential VALUE.
Note the capability ``kind`` legitimately names the credential type
(e.g. ``"discord.interaction_token"``) — that's a reference, not the secret.
The guarantee is structural: the action has exactly the token-less semantic
fields, and no field holds an actual credential value.
"""
adapter, stub = wired
await adapter.connect()
await adapter.send_follow_up(
session_key="sess-1", kind="discord.interaction_token", content="x", metadata={"a": 1}
)
action = stub.follow_ups[0]
# Exactly the token-less semantic fields (+ metadata); no value/secret field.
assert set(action.keys()) == {"op", "session_key", "kind", "content", "metadata"}
# No field NAMES a credential carrier (the kind string is a type ref, allowed).
assert "value" not in action
assert "token" not in action
assert "secret" not in action
assert "credential" not in action
@@ -0,0 +1,264 @@
"""Phase 5 §5.3 — going-idle / buffered-flip primitive (gateway side).
Exercises the WebSocketRelayTransport's going_idle/ack handshake, the
buffered-inbound ack (a bufferId-carrying inbound is acked after the handler
runs), the NET-NEW reconnect loop (re-dial + re-handshake after an unexpected
close), and the RelayAdapter emitting going_idle from its existing drain
(disconnect) transition. All against a real in-process websockets server.
"""
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 _IdleAwareServer:
"""Connector stub: descriptor on hello, acks going_idle, records inbound_acks,
and can push buffered inbound frames (with bufferId) after handshake."""
def __init__(self):
self.received: list[dict] = []
self.inbound_acks: list[str] = []
self.going_idle_count = 0
self._server = None
self.url = ""
# Frames to push right after each handshake (e.g. buffered backlog replay).
self._to_push: list[dict] = []
self.connections = 0
async def start(self):
self._server = await websockets.serve(self._handle, "127.0.0.1", 0)
sock = next(iter(self._server.sockets))
self.url = f"ws://127.0.0.1:{sock.getsockname()[1]}"
async def stop(self):
if self._server is not None:
self._server.close()
await self._server.wait_closed()
async def _handle(self, ws):
self.connections += 1
try:
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)
except Exception:
pass
async def _on_frame(self, ws, frame):
ftype = frame.get("type")
if ftype == "hello":
await ws.send(json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n")
for f in self._to_push:
await ws.send(json.dumps(f) + "\n")
elif ftype == "going_idle":
self.going_idle_count += 1
await ws.send(json.dumps({"type": "going_idle_ack"}) + "\n")
elif ftype == "inbound_ack":
self.inbound_acks.append(frame.get("bufferId"))
@pytest_asyncio.fixture
async def server():
srv = _IdleAwareServer()
await srv.start()
yield srv
await srv.stop()
@pytest.mark.asyncio
async def test_buffered_inbound_is_acked_after_handler(server):
# A buffered delivery (bufferId present) is acked AFTER the handler runs; a
# live delivery (no bufferId) is not acked.
server._to_push = [
{
"type": "inbound",
"event": {
"text": "buffered",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "c1", "chat_type": "dm"},
},
"bufferId": "buf-42",
},
{
"type": "inbound",
"event": {
"text": "live",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "c1", "chat_type": "dm"},
},
},
]
seen = []
async def handler(ev):
seen.append(ev.text)
t = WebSocketRelayTransport(server.url, "discord", "appShared")
t.set_inbound_handler(handler)
await t.connect()
try:
await t.handshake()
await asyncio.sleep(0.1)
assert "buffered" in seen and "live" in seen
# Only the buffered (bufferId) delivery was acked.
assert server.inbound_acks == ["buf-42"]
finally:
await t.disconnect()
@pytest.mark.asyncio
async def test_reconnect_redials_after_unexpected_close():
# A server that drops the FIRST connection right after handshake; the
# transport with reconnect=True re-dials and handshakes again.
drops = {"n": 0}
srv = _IdleAwareServer()
async def handle(ws):
srv.connections += 1
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":
await ws.send(json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n")
if drops["n"] == 0:
drops["n"] += 1
await ws.close() # force an unexpected close on the first connection
return
srv._server = await websockets.serve(handle, "127.0.0.1", 0)
sock = next(iter(srv._server.sockets))
srv.url = f"ws://127.0.0.1:{sock.getsockname()[1]}"
t = WebSocketRelayTransport(srv.url, "discord", "appShared", reconnect=True, reconnect_backoff_s=0.05)
try:
await t.connect()
await t.handshake()
# First connection is dropped server-side; the reconnect loop re-dials.
await asyncio.sleep(0.2)
assert srv.connections >= 2
finally:
await t.disconnect()
srv._server.close()
await srv._server.wait_closed()
# ── scale-to-zero go_dormant() (D12 / F14) ───────────────────────────────────
@pytest.mark.asyncio
async def test_go_dormant_redials_on_wake_and_drains(server):
"""After go_dormant() the reconnect supervisor stays armed, so the gateway
re-dials (simulating a wake) and the connector replays its buffered backlog
on the new handshake. This is the wake->reconnect->drain contract (§3.4)."""
# Queue a buffered inbound to be replayed on the NEXT (wake) handshake.
server._to_push = [
{
"type": "inbound",
"event": {
"text": "while-asleep",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "c1", "chat_type": "dm"},
},
"bufferId": "buf-wake-1",
}
]
seen: list[str] = []
async def handler(ev):
seen.append(ev.text)
t = WebSocketRelayTransport(
server.url, "discord", "appShared", reconnect=True, reconnect_backoff_s=5.0
)
# Dormant re-dial cadence is short so the test wakes promptly even though the
# ordinary reconnect backoff is long (proves the dormant path uses its own).
t._dormant_redial_s = 0.05
t.set_inbound_handler(handler)
await t.connect()
await t.handshake()
before = server.connections
try:
await t.go_dormant(timeout_s=2)
# The supervisor was armed by the dormant close; it re-dials on the
# dormant cadence (~0.05s), NOT the 5s reconnect backoff.
for _ in range(50):
if server.connections > before and "while-asleep" in seen:
break
await asyncio.sleep(0.05)
assert server.connections > before # re-dialed (woke)
assert "while-asleep" in seen # drained the buffered backlog on reconnect
# The successful re-dial cleared the dormant flag.
assert t._dormant is False
# The buffered entry was acked (this stub re-pushes on every handshake, so
# a long-lived dormant poll may ack it more than once; the invariant is
# that it was drained at least once — a real connector stops replaying an
# acked entry).
assert "buf-wake-1" in server.inbound_acks
finally:
await t.disconnect()
@pytest.mark.asyncio
async def test_adapter_go_dormant_delegates_to_transport(server):
"""RelayAdapter.go_dormant() drives the transport's go_dormant (going_idle +
dormant close) without the terminal teardown disconnect() does."""
from gateway.config import PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
placeholder = CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform="discord",
label="Relay",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=False,
markdown_dialect="plain",
len_unit="chars",
)
transport = WebSocketRelayTransport(
server.url, "discord", "appShared", reconnect=True, reconnect_backoff_s=0.05
)
adapter = RelayAdapter(PlatformConfig(), placeholder, transport=transport)
await adapter.connect()
try:
ok = await adapter.go_dormant()
assert ok is True
assert server.going_idle_count == 1
assert transport._closing is False # NOT the terminal teardown
assert transport._dormant is True
finally:
await adapter.disconnect()
@@ -0,0 +1,231 @@
"""Inbound replay dedupe on the relay adapter (transplanted from the
live-cards branch for the rc.4 relay-fixes train).
Live-canary finding #3 (Alice, staging): the relay inbound leg is
at-least-once. On WS re-handshake the connector replays its durable
per-instance buffer; a long multi-tool turn straddling a quiet socket drop
got its ORIGINAL inbound replayed after the turn finished, re-running the
entire turn — the user saw the final answer posted 2-5x. Platform message
identity (chat_id + message_id/ts) is stable across replays, so a bounded
seen-set drops them. Fail-open: events without a message_id never dedupe
(dropping a real message is strictly worse than rerunning one).
"""
from __future__ import annotations
import asyncio
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, SessionSource
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from tests.gateway.relay.stub_connector import StubConnector
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="slack",
label="Slack",
max_message_length=39000,
supports_draft_streaming=True,
supports_edit=True,
supports_threads=True,
markdown_dialect="slack",
len_unit="chars",
emoji="\U0001f4ac",
platform_hint="",
pii_safe=False,
supported_ops=("send", "edit", "typing"),
)
base.update(kw)
return CapabilityDescriptor(**base)
def _connected_adapter(**desc_kw):
desc = make_desc(**desc_kw)
stub = StubConnector(desc)
adapter = RelayAdapter(PlatformConfig(), desc, transport=stub)
return adapter, stub
@pytest.fixture()
def loop():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()
def _record(bucket, event):
async def _coro():
bucket.append(event)
return _coro()
async def _false_coro():
return False
async def _none_coro():
return None
class TestInboundReplayDedupe:
"""Finding #3 (live canary): connector replay of the original inbound
after a WS re-handshake must not re-run the turn."""
def _event(self, message_id="1700.100", chat_id="C1", text="hi"):
# A REAL MessageEvent, shaped exactly as _event_from_wire produces it:
# chat identity lives on event.source, NOT as a top-level attribute.
# (The first version of these tests used a SimpleNamespace with a
# top-level chat_id — a shape no production code path produces — and
# green-lit a dedupe key that read the wrong field.)
source = SessionSource(
platform=Platform.SLACK,
chat_id=chat_id,
chat_type="channel",
user_id="U1",
message_id=message_id,
)
return MessageEvent(text=text, source=source, message_id=message_id)
def _tap(self, adapter, handled):
adapter.handle_message = lambda e: _record(handled, e)
adapter._consume_prompt_response = lambda e: _false_coro()
adapter._localize_inbound_media = lambda e: _none_coro()
def test_replayed_inbound_dropped(self, loop):
adapter, _ = _connected_adapter()
handled = []
self._tap(adapter, handled)
e = self._event()
loop.run_until_complete(adapter._on_inbound(e))
loop.run_until_complete(adapter._on_inbound(e)) # replay
assert len(handled) == 1
def test_distinct_messages_both_handled(self, loop):
adapter, _ = _connected_adapter()
handled = []
self._tap(adapter, handled)
loop.run_until_complete(adapter._on_inbound(self._event("1700.100")))
loop.run_until_complete(adapter._on_inbound(self._event("1700.200")))
assert len(handled) == 2
def test_missing_message_id_fails_open(self, loop):
adapter, _ = _connected_adapter()
handled = []
self._tap(adapter, handled)
e = self._event(message_id=None)
loop.run_until_complete(adapter._on_inbound(e))
loop.run_until_complete(adapter._on_inbound(e))
assert len(handled) == 2 # never dedupe without identity
def test_seen_set_bounded(self, loop):
adapter, _ = _connected_adapter()
adapter.handle_message = lambda e: _none_coro()
adapter._consume_prompt_response = lambda e: _false_coro()
adapter._localize_inbound_media = lambda e: _none_coro()
for i in range(600):
loop.run_until_complete(adapter._on_inbound(self._event(f"ts.{i}")))
assert len(adapter._seen_inbound) <= adapter._SEEN_INBOUND_MAX
class TestWireLevelReplayDedupe:
"""The full production inbound path: a connector wire frame decoded by
_event_from_wire, then dispatched through RelayAdapter._on_inbound.
This is the layer the hand-built-event tests above cannot vouch for: the
dedupe key must work on the exact object shape the wire decoder emits.
The original dedupe commit shipped green on hand-built events while being
a no-op on decoded ones — this class exists so that can't recur.
"""
WIRE = {
"text": "hi",
"message_type": "text",
"message_id": "1700.100",
"source": {
"platform": "slack",
"chat_id": "C1",
"chat_type": "channel",
"user_id": "U1",
"message_id": "1700.100",
},
}
def _tap(self, adapter, handled):
adapter.handle_message = lambda e: _record(handled, e)
adapter._consume_prompt_response = lambda e: _false_coro()
adapter._localize_inbound_media = lambda e: _none_coro()
def _decode(self, **overrides):
from gateway.relay.ws_transport import _event_from_wire
raw = {**self.WIRE, **overrides}
if "source" in overrides:
raw["source"] = {**self.WIRE["source"], **overrides["source"]}
return _event_from_wire(raw)
def test_decoded_event_yields_a_dedupe_key(self):
adapter, _ = _connected_adapter()
key = adapter._inbound_dedupe_key(self._decode())
assert key is not None, (
"the wire decoder's event shape must produce a dedupe key — "
"None here means the dedupe is fail-open for ALL production "
"traffic (the original ship-broken state)"
)
def test_replayed_wire_frame_dropped(self, loop):
adapter, _ = _connected_adapter()
handled = []
self._tap(adapter, handled)
loop.run_until_complete(adapter._on_inbound(self._decode()))
# The connector re-delivers the SAME frame on re-handshake; the
# decoder builds a fresh object each time, so identity must come
# from the key, not object identity.
loop.run_until_complete(adapter._on_inbound(self._decode()))
assert len(handled) == 1
def test_same_ids_on_different_platforms_not_conflated(self, loop):
# Phase 1.5 multiplex: one adapter fronts several platforms. Numeric
# chat/message ids can collide across platforms; both must dispatch.
adapter, _ = _connected_adapter()
handled = []
self._tap(adapter, handled)
loop.run_until_complete(adapter._on_inbound(self._decode()))
loop.run_until_complete(
adapter._on_inbound(self._decode(source={"platform": "discord"}))
)
assert len(handled) == 2
class TestDedupeKeyPlatformNormalization:
"""The platform component of the key must be spelling-invariant: a
Platform enum and its plain-string form are ONE platform (one key), and
two different string platforms must never collapse into a shared empty
component. Production wire decoding always yields the enum; alternate
event constructors may carry the string."""
def _key(self, platform):
adapter, _ = _connected_adapter()
source = SessionSource(
platform=platform, chat_id="C1", chat_type="channel", message_id="m1"
)
event = MessageEvent(text="hi", source=source, message_id="m1")
return adapter._inbound_dedupe_key(event)
def test_enum_and_string_spellings_produce_one_key(self):
assert self._key(Platform.SLACK) == self._key("slack")
def test_distinct_string_platforms_stay_distinct(self):
assert self._key("slack") != self._key("discord")
def test_missing_platform_still_yields_a_key(self):
# Fail-open on identity is reserved for missing message/chat ids;
# a missing platform alone must not disable dedupe.
key = self._key(None)
assert key is not None
assert key.startswith(":")
@@ -0,0 +1,362 @@
"""Relay Phase 3 interactive tests — prompt op egress, prompt_response
consumption, and the react ack lifecycle.
Covers:
- send_exec_approval / send_slash_confirm / send_clarify render through ONE
`prompt` op with the right option sets, honoring op gating (legacy
connectors get the base/text behaviour or a structured failure that
triggers run.py's text fallback);
- the pending-prompt registry: mint → consume-once → expiry;
- _consume_prompt_response routes answers to the approval / slash-confirm /
clarify resolvers and CONSUMES the event; unknown/expired ids fall
through to normal dispatch;
- the Discord type-3 hp1 decode (structured prompt_response replacing the
bare-custom_id stub; foreign custom_ids keep the legacy text shape);
- on_processing_start/complete drive react ops (👀 → ✅/❌), op-gated and
best-effort.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any, Dict, Optional
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.session import SessionSource
from tests.gateway.relay.stub_connector import StubConnector
FULL_OPS = (
"send",
"edit",
"typing",
"get_chat_info",
"send_media",
"prompt",
"react",
)
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="telegram",
label="Telegram",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="markdown_v2",
len_unit="utf16",
supported_ops=FULL_OPS,
)
base.update(kw)
return CapabilityDescriptor(**base)
def _adapter(**desc_kw) -> tuple[RelayAdapter, StubConnector]:
stub = StubConnector(make_desc(**desc_kw))
adapter = RelayAdapter(PlatformConfig(), make_desc(**desc_kw), transport=stub)
return adapter, stub
def _event(
prompt_response: Optional[Dict[str, Any]] = None,
text: str = "/once",
chat_id: str = "c1",
) -> MessageEvent:
return MessageEvent(
text=text,
message_type=MessageType.COMMAND,
source=SessionSource(
platform="telegram", chat_id=chat_id, chat_type="dm", user_id="u1"
),
prompt_response=prompt_response,
)
# ── egress: the three prompt surfaces ────────────────────────────────────
@pytest.mark.asyncio
async def test_exec_approval_renders_full_option_set():
adapter, stub = _adapter()
result = await adapter.send_exec_approval(
"c1", "rm -rf /tmp/x", "sess:1", description="deletes files"
)
assert result.success is True
assert result.message_id == "pm1"
action = stub.sent[-1]
assert action["op"] == "prompt"
assert action["prompt_kind"] == "approval"
ids = [o["id"] for o in action["options"]]
assert ids == ["once", "session", "always", "deny"]
assert "rm -rf /tmp/x" in action["content"]
assert "deletes files" in action["content"]
# The registry holds the pending prompt keyed by the wire's prompt_id.
assert action["prompt_id"] in adapter._pending_prompts
state = adapter._pending_prompts[action["prompt_id"]]
assert state["kind"] == "exec_approval"
assert state["session_key"] == "sess:1"
@pytest.mark.asyncio
async def test_exec_approval_smart_denied_and_flag_gating():
adapter, stub = _adapter()
await adapter.send_exec_approval(
"c1", "cmd", "s", smart_denied=True, allow_permanent=True, allow_session=True
)
ids = [o["id"] for o in stub.sent[-1]["options"]]
assert ids == ["once", "deny"] # smart-deny: no session/always
await adapter.send_exec_approval(
"c1", "cmd", "s", allow_session=True, allow_permanent=False
)
ids = [o["id"] for o in stub.sent[-1]["options"]]
assert ids == ["once", "session", "deny"]
@pytest.mark.asyncio
async def test_slash_confirm_renders_three_options():
adapter, stub = _adapter()
result = await adapter.send_slash_confirm(
"c1", "Reload MCP", "This invalidates the prompt cache.", "sess:1", "cf-9"
)
assert result.success is True
action = stub.sent[-1]
ids = [o["id"] for o in action["options"]]
assert ids == ["once", "always", "cancel"]
assert "Reload MCP" in action["content"]
state = adapter._pending_prompts[action["prompt_id"]]
assert state == {
**state,
"kind": "slash_confirm",
"confirm_id": "cf-9",
"session_key": "sess:1",
}
@pytest.mark.asyncio
async def test_clarify_renders_choices_plus_other_with_positional_ids():
adapter, stub = _adapter()
result = await adapter.send_clarify(
"c1",
"Which environment?",
["staging — the safe one", "production"],
"cl-1",
"sess:1",
)
assert result.success is True
action = stub.sent[-1]
assert action["prompt_kind"] == "clarify"
ids = [o["id"] for o in action["options"]]
# Positional ids (choice text is arbitrary UTF-8; ids must be callback-safe).
assert ids == ["c0", "c1", "other"]
labels = [o["label"] for o in action["options"]]
assert labels[0].startswith("staging")
state = adapter._pending_prompts[action["prompt_id"]]
assert state["choices"] == ["staging — the safe one", "production"]
# ── the pending-prompt registry ──────────────────────────────────────────
# ── inbound consumption ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_prompt_response_resolves_clarify_choice_and_other(monkeypatch):
adapter, stub = _adapter()
await adapter.send_clarify("c1", "Which?", ["alpha", "beta"], "cl-9", "s")
prompt_id = stub.sent[-1]["prompt_id"]
resolved: list[tuple] = []
marked: list[str] = []
monkeypatch.setattr(
"tools.clarify_gateway.resolve_gateway_clarify",
lambda cid, resp: resolved.append((cid, resp)) or True,
)
monkeypatch.setattr(
"tools.clarify_gateway.mark_awaiting_text", lambda cid: marked.append(cid)
)
# Positional id maps back to the REAL choice text.
event = _event({"prompt_id": prompt_id, "option_id": "c1"})
assert await adapter._consume_prompt_response(event) is True
assert resolved == [("cl-9", "beta")]
# "Other" flips to text capture.
await adapter.send_clarify("c1", "Which?", ["a"], "cl-10", "s")
prompt_id2 = stub.sent[-1]["prompt_id"]
event2 = _event({"prompt_id": prompt_id2, "option_id": "other"})
assert await adapter._consume_prompt_response(event2) is True
assert marked == ["cl-10"]
# ── Discord type-3 hp1 decode ────────────────────────────────────────────
def test_discord_component_interaction_decodes_prompt_token():
adapter, _stub = _adapter()
class Forward:
platform = "discord"
method = "POST"
path = "/interactions/bot1"
body = (
b'{"type": 3, "id": "i1", "channel_id": "ch1", "guild_id": "g1",'
b' "message": {"id": "pm55"},'
b' "member": {"user": {"id": "u1", "username": "ben"}},'
b' "data": {"custom_id": "hp1:a1b2c3d4:deny"}}'
)
event = adapter._discord_interaction_to_event(Forward())
assert event is not None
assert event.prompt_response == {
"prompt_id": "a1b2c3d4",
"option_id": "deny",
"prompt_message_id": "pm55",
}
assert event.text == "/deny"
assert event.message_type == MessageType.COMMAND
# ── react ack lifecycle ──────────────────────────────────────────────────
def _reactable_event() -> MessageEvent:
return MessageEvent(
text="do something",
message_type=MessageType.TEXT,
source=SessionSource(
platform="discord",
chat_id="ch1",
chat_type="channel",
user_id="u1",
message_id="m42",
),
message_id="m42",
)
@pytest.mark.asyncio
async def test_processing_lifecycle_reacts_eyes_then_check():
adapter, stub = _adapter()
event = _reactable_event()
await adapter.on_processing_start(event)
await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
reacts = [a for a in stub.sent if a["op"] == "react"]
assert [(r["emoji"], r.get("remove", False)) for r in reacts] == [
("👀", False),
("👀", True),
("", False),
]
assert all(r["message_id"] == "m42" and r["chat_id"] == "ch1" for r in reacts)
# ── fanned-out prompt answers (one press, many gateways) ─────────────────
#
# The connector delivers a passthrough forward (a Discord button press) to
# EVERY live gateway session of the tenant, unlike a message, which it narrows
# to the admitted instance set. So one press reaches every sibling gateway
# while only the minting one can resolve it. These pin that a non-owner stays
# silent and that the owner still answers exactly once.
@pytest.mark.asyncio
async def test_sibling_gateway_ignores_another_instances_prompt_answer(monkeypatch):
"""A press for a prompt this process didn't mint is consumed silently."""
owner, owner_stub = _adapter()
sibling, sibling_stub = _adapter()
await owner.send_clarify("c1", "Which?", ["alpha", "beta"], "cl-1", "s")
prompt_id = owner_stub.sent[-1]["prompt_id"]
resolved: list[tuple] = []
monkeypatch.setattr(
"tools.clarify_gateway.resolve_gateway_clarify",
lambda cid, resp: resolved.append((cid, resp)) or True,
)
monkeypatch.setattr("tools.clarify_gateway.mark_awaiting_text", lambda cid: None)
event = _event({"prompt_id": prompt_id, "option_id": "c1"})
# Consumed (True) so the "/c1"-shaped text is never dispatched as chat --
# that fall-through is what produced one "Unknown command `/c1`" per
# sibling gateway. And the sibling neither resolves nor says anything.
assert await sibling._consume_prompt_response(event) is True
assert resolved == []
assert sibling_stub.sent == []
# The owner still resolves the same press normally.
assert await owner._consume_prompt_response(event) is True
assert resolved == [("cl-1", "beta")]
@pytest.mark.asyncio
async def test_repeat_answer_for_resolved_prompt_is_ignored(monkeypatch):
"""A double tap / redelivered forward must not resolve twice."""
adapter, stub = _adapter()
await adapter.send_clarify("c1", "Which?", ["alpha", "beta"], "cl-2", "s")
prompt_id = stub.sent[-1]["prompt_id"]
resolved: list[tuple] = []
monkeypatch.setattr(
"tools.clarify_gateway.resolve_gateway_clarify",
lambda cid, resp: resolved.append((cid, resp)) or True,
)
event = _event({"prompt_id": prompt_id, "option_id": "c0"})
assert await adapter._consume_prompt_response(event) is True
assert resolved == [("cl-2", "alpha")]
sent_after_first = len(stub.sent)
assert await adapter._consume_prompt_response(event) is True
assert resolved == [("cl-2", "alpha")] # not resolved a second time
assert len(stub.sent) == sent_after_first # and no second ack / notice
@pytest.mark.asyncio
async def test_expired_own_prompt_notifies_instead_of_unknown_command():
"""An expired prompt of OURS gets an expiry notice, not chat dispatch.
Falling through would hand run.py a command-shaped "/c1", which is not a
real command, so the user got "Unknown command `/c1`".
"""
adapter, stub = _adapter()
prompt_id = adapter._mint_prompt("clarify", {"chat_id": "c1"}, timeout_s=-1.0)
event = _event({"prompt_id": prompt_id, "option_id": "c1"})
assert await adapter._consume_prompt_response(event) is True
# The notice is fire-and-forget now (read-loop self-deadlock fix:
# awaiting a send from _consume_prompt_response blocks the very read
# loop that resolves the send's result future). Yield so the
# background ack task runs before asserting egress.
await asyncio.sleep(0.05)
notices = [a for a in stub.sent if a["op"] == "send"]
assert len(notices) == 1
assert "no longer waiting" in notices[0]["content"]
def test_minted_prompt_ids_are_instance_scoped_and_callback_safe():
"""Ids carry the minting process's nonce and stay codec-legal.
The connector's promptCodec validates each id as [A-Za-z0-9_.-]{1,32} and
caps "hp1:<prompt_id>:<option_id>" at Telegram's 64-byte callback budget.
"""
import re
a, _ = _adapter()
b, _ = _adapter()
id_a = a._mint_prompt("clarify", {"chat_id": "c1"})
id_b = b._mint_prompt("clarify", {"chat_id": "c1"})
assert re.fullmatch(r"[A-Za-z0-9_.\-]{1,32}", id_a)
assert len(f"hp1:{id_a}:option_id_up_to_32_chars_here") <= 64
assert a._minted_here(id_a) is True
assert b._minted_here(id_a) is False
assert a._minted_here(id_b) is False
# A legacy id minted before the nonce existed (no "." segment) is still
# treated as ours, so a prompt in flight across an upgrade resolves.
assert a._minted_here("a1b2c3d4") is True
@@ -0,0 +1,54 @@
"""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"
@@ -0,0 +1,277 @@
"""Relay Slack live cards — gateway half (NS-658).
The relay adapter emits three new additive ops when the connector's
negotiated descriptor advertises them:
{op: "draft", chat_id, draft_id, content, final, metadata}
{op: "task_card", chat_id, card_id, chunks, metadata}
{op: "task_card_stop", chat_id, card_id, metadata}
Gateway side is deliberately dumb: no Slack API knowledge, no new config
keys. Capability is descriptor-driven — an old connector that never
advertises "draft"/"task_card" gets byte-identical behavior to today.
Semantic bridge: the base send_draft contract is Telegram-shaped (draft
clears; final answer is a separate send). Slack native streaming makes the
stream THE message. The adapter tracks the open draft per chat; the
turn-final regular send() for that chat converts to draft(final=true) so
the connector seals the stream instead of posting a duplicate.
"""
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 make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="slack",
label="Slack",
max_message_length=39000,
supports_draft_streaming=True,
supports_edit=True,
supports_threads=True,
markdown_dialect="slack",
len_unit="chars",
emoji="\U0001f4ac",
platform_hint="",
pii_safe=False,
supported_ops=("send", "edit", "typing", "draft", "task_card"),
)
base.update(kw)
return CapabilityDescriptor(**base)
def _connected_adapter(**desc_kw):
desc = make_desc(**desc_kw)
stub = StubConnector(desc)
adapter = RelayAdapter(PlatformConfig(), desc, transport=stub)
return adapter, stub
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@pytest.fixture()
def loop():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
loop.close()
# ---------------------------------------------------------------------------
# Draft streaming op
# ---------------------------------------------------------------------------
class TestDraftOp:
def test_send_draft_emits_draft_op(self, loop):
adapter, stub = _connected_adapter()
result = loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=7, content="hel")
)
assert result.success
frames = [s for s in stub.sent if s.get("op") == "draft"]
assert len(frames) == 1
frame = frames[0]
assert frame["chat_id"] == "C1"
assert frame["draft_id"] == 7
assert frame["content"] == "hel"
assert frame["final"] is False
def test_send_draft_requires_descriptor_op(self, loop):
# supports_draft_streaming True but op NOT advertised: old connector.
adapter, stub = _connected_adapter(
supported_ops=("send", "edit", "typing")
)
with pytest.raises(NotImplementedError):
loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=1, content="x")
)
assert not [s for s in stub.sent if s.get("op") == "draft"]
def test_supports_draft_streaming_requires_both_flag_and_op(self):
both, _ = _connected_adapter()
assert both.supports_draft_streaming() is True
flag_only, _ = _connected_adapter(
supported_ops=("send", "edit", "typing")
)
assert flag_only.supports_draft_streaming() is False
# Legacy connector with EMPTY supported_ops: fail-open elsewhere, but
# draft must NOT fail open — the op did not exist pre-contract.
legacy, _ = _connected_adapter(supported_ops=())
assert legacy.supports_draft_streaming() is False
def test_final_send_seals_open_draft(self, loop):
adapter, stub = _connected_adapter()
loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=7, content="hel")
)
loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=7, content="hello wor")
)
stub.next_draft_result = {"success": True, "message_id": "1723600000.1"}
result = loop.run_until_complete(
adapter.send("C1", "hello world", reply_to=None)
)
assert result.success
# No regular send op — the final frame rides the draft stream.
ops = [s["op"] for s in stub.sent]
assert "send" not in ops, "final must seal the stream, not post anew"
finals = [
s for s in stub.sent if s.get("op") == "draft" and s.get("final")
]
assert len(finals) == 1
assert finals[0]["content"] == "hello world"
# Stream ts comes back as the message identity.
assert result.message_id == "1723600000.1"
def test_send_without_open_draft_is_normal(self, loop):
adapter, stub = _connected_adapter()
loop.run_until_complete(adapter.send("C1", "plain", reply_to=None))
assert [s["op"] for s in stub.sent] == ["send"]
def test_draft_state_clears_after_seal(self, loop):
adapter, stub = _connected_adapter()
loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=7, content="a")
)
loop.run_until_complete(adapter.send("C1", "a!", reply_to=None))
# Second send on the same chat is a NORMAL send again.
loop.run_until_complete(adapter.send("C1", "followup", reply_to=None))
ops = [s["op"] for s in stub.sent]
assert ops == ["draft", "draft", "send"]
def test_draft_failure_result_propagates(self, loop):
adapter, stub = _connected_adapter()
stub.next_draft_result = {"success": False, "error": "stream_gone"}
result = loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=1, content="x")
)
assert not result.success
# DEFINITE connector rejection disarms seal-interception: the
# stream consumer falls back to edit-based streaming on this
# failure, and its turn-final must go out as a REAL send — never
# a seal on a stream the connector just rejected.
stub.next_send_result = {"success": True, "message_id": "m2"}
loop.run_until_complete(adapter.send("C1", "final", reply_to=None))
assert [s["op"] for s in stub.sent][-1] == "send"
def test_draft_transport_exception_keeps_interception_armed(self, loop):
"""Ambiguity contract (G-D1): a transport EXCEPTION — as opposed
to an explicit rejection — may mean the frame was delivered, so
interception stays armed and the turn-final still seals."""
adapter, _ = _connected_adapter()
class _FlakyOnce:
def __init__(self):
self.calls = 0
async def send_outbound(self, payload, platform=None):
self.calls += 1
if self.calls == 1:
raise ConnectionError("mid-write drop")
return {"success": True, "message_id": "ts.9"}
adapter._transport = _FlakyOnce()
result = loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=2, content="x")
)
assert not result.success
# Still armed: the turn-final converts to the sealing frame.
final = loop.run_until_complete(adapter.send("C1", "final", reply_to=None))
assert final.success
assert final.message_id == "ts.9"
def test_drafts_are_per_chat(self, loop):
adapter, stub = _connected_adapter()
loop.run_until_complete(
adapter.send_draft(chat_id="C1", draft_id=1, content="a")
)
# A send to a DIFFERENT chat is untouched.
loop.run_until_complete(adapter.send("D9", "other", reply_to=None))
by_op = [(s["op"], s["chat_id"]) for s in stub.sent]
assert ("send", "D9") in by_op
# ---------------------------------------------------------------------------
# Task-card ops (#85476 TurnRunner seam, relay leg)
# ---------------------------------------------------------------------------
def _tasks():
return [
{
"id": "call_1",
"title": "terminal",
"status": "in_progress",
"details": "ls -la",
},
{
"id": "call_2",
"title": "web_search",
"status": "complete",
"details": "slack startStream",
},
]
class TestTaskCardOps:
def test_progress_emits_task_card_op(self, loop):
adapter, stub = _connected_adapter()
result = loop.run_until_complete(
adapter.send_native_task_card_progress(
chat_id="C1", tasks=_tasks(), reply_to="1700.100"
)
)
assert result.success
frames = [s for s in stub.sent if s.get("op") == "task_card"]
assert len(frames) == 1
assert frames[0]["card_id"] == "turn:1700.100"
chunk_ids = [c["id"] for c in frames[0]["chunks"]]
assert chunk_ids == ["call_1", "call_2"]
statuses = {c["id"]: c["status"] for c in frames[0]["chunks"]}
assert statuses["call_2"] == "complete"
def test_stop_emits_task_card_stop(self, loop):
adapter, stub = _connected_adapter()
loop.run_until_complete(
adapter.send_native_task_card_progress(
chat_id="C1", tasks=_tasks(), reply_to="1700.100"
)
)
loop.run_until_complete(
adapter.stop_native_task_card_progress(chat_id="C1", reply_to="1700.100")
)
assert [s["op"] for s in stub.sent] == ["task_card", "task_card_stop"]
def test_task_card_gated_on_descriptor(self, loop):
adapter, stub = _connected_adapter(
supported_ops=("send", "edit", "typing", "draft")
)
# No "task_card" in supported_ops: the TurnRunner's hasattr gate must
# see NO capability — expose it via a probe method returning False,
# and the send must be a clean no-op failure (never an exception on
# the turn path).
assert adapter.supports_native_task_cards() is False
result = loop.run_until_complete(
adapter.send_native_task_card_progress(
chat_id="C1", tasks=_tasks(), reply_to="t"
)
)
assert not result.success
assert not stub.sent
def test_task_card_legacy_empty_ops_not_fail_open(self):
legacy, _ = _connected_adapter(supported_ops=())
assert legacy.supports_native_task_cards() is False
+231
View File
@@ -0,0 +1,231 @@
"""Relay Phase 2 media tests — send_media egress lanes + inbound media localization.
Covers:
- the five ``send_*`` overrides route through ONE ``send_media`` op with the
right ``media_kind`` and honor op-level capability gating (a connector not
advertising ``send_media`` falls back to the base-class behaviour);
- local-path sources upload through the RelayMediaClient first (the
connector cannot reach our filesystem) and public URLs pass through;
- a connector decline / failed upload degrades to the pre-media fallback;
- inbound ``media_urls`` are localized to temp paths (re-hosts downloaded
with the per-gateway bearer; dead re-host refs dropped; public URLs kept
when no client is available);
- the RelayMediaClient URL derivation + auth header shape.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
import pytest
from gateway.config import PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.relay.media import RelayMediaClient, media_base_url
from tests.gateway.relay.stub_connector import StubConnector
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="telegram",
label="Telegram",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="markdown_v2",
len_unit="utf16",
supported_ops=(
"send",
"edit",
"typing",
"get_chat_info",
"send_media",
),
)
base.update(kw)
return CapabilityDescriptor(**base)
class FakeMediaClient:
"""In-memory stand-in for RelayMediaClient (no HTTP)."""
def __init__(self) -> None:
self.enabled = True
self.uploads: list[tuple[str, Optional[str]]] = []
self.downloads: list[str] = []
self.upload_result: Optional[str] = "https://conn.example/relay/media/aa11"
self.download_result: Optional[str] = "/tmp/relay_media_fake.png"
async def upload(self, file_path, *, mime=None, filename=None):
self.uploads.append((str(file_path), filename))
return self.upload_result
async def download(self, url, *, suggested_name=None):
self.downloads.append(url)
return self.download_result
def is_relay_media_url(self, url: str) -> bool:
return "/relay/media/" in (url or "")
def _adapter(**desc_kw) -> tuple[RelayAdapter, StubConnector, FakeMediaClient]:
stub = StubConnector(make_desc(**desc_kw))
adapter = RelayAdapter(PlatformConfig(), make_desc(**desc_kw), transport=stub)
fake = FakeMediaClient()
adapter._media_client = fake # bypass env-derived construction
return adapter, stub, fake
# ── egress: the five overrides ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_send_image_url_passes_through_without_upload():
adapter, stub, fake = _adapter()
result = await adapter.send_image(
"chat1", "https://fal.media/x.png", caption="a pic", reply_to="m9"
)
assert result.success is True
assert result.message_id == "md1"
assert fake.uploads == [] # public URL → no upload leg
action = stub.sent[-1]
assert action["op"] == "send_media"
assert action["media_kind"] == "image"
assert action["source_url"] == "https://fal.media/x.png"
assert action["content"] == "a pic"
assert action["reply_to"] == "m9"
@pytest.mark.asyncio
async def test_local_path_lanes_upload_first(tmp_path: Path):
adapter, stub, fake = _adapter()
f = tmp_path / "clip.ogg"
f.write_bytes(b"oggbytes")
result = await adapter.send_voice("chat1", str(f), caption="listen")
assert result.success is True
assert fake.uploads == [(str(f), None)]
action = stub.sent[-1]
assert action["op"] == "send_media"
assert action["media_kind"] == "voice"
# The wire carries the RE-HOST reference, never the local path.
assert action["source_url"] == fake.upload_result
assert str(f) not in str(action)
@pytest.mark.asyncio
async def test_op_gating_falls_back_when_not_advertised(tmp_path: Path):
# Connector advertises only the legacy ops — send_media must never hit the wire.
adapter, stub, fake = _adapter(
supported_ops=("send", "edit", "typing", "get_chat_info")
)
result = await adapter.send_image("chat1", "https://x.io/a.png", caption="hi")
# Base-class fallback: caption + URL as a text send.
assert result.success is True
ops = [a["op"] for a in stub.sent]
assert "send_media" not in ops
assert ops[-1] == "send"
assert "https://x.io/a.png" in stub.sent[-1]["content"]
# ── inbound localization ─────────────────────────────────────────────────
def _make_event(media_urls):
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
return MessageEvent(
text="look",
message_type=MessageType.TEXT,
source=SessionSource(
platform="telegram", chat_id="c1", chat_type="dm", user_id="u1"
),
media_urls=list(media_urls),
)
@pytest.mark.asyncio
async def test_inbound_without_client_keeps_public_drops_rehost():
adapter, _stub, _fake = _adapter()
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
event = _make_event(
[
"https://conn.example/relay/media/deadbeef",
"https://cdn.discordapp.com/attachments/a/b.png",
]
)
await adapter._localize_inbound_media(event)
assert event.media_urls == ["https://cdn.discordapp.com/attachments/a/b.png"]
# ── RelayMediaClient unit surface ────────────────────────────────────────
@pytest.mark.asyncio
async def test_client_upload_rejects_oversize_and_missing(tmp_path: Path):
c = RelayMediaClient("https://c.example", "gw1", "sec")
# Missing file → None (no network attempted).
assert await c.upload(str(tmp_path / "nope.bin")) is None
# Empty file → None.
empty = tmp_path / "empty.bin"
empty.write_bytes(b"")
assert await c.upload(str(empty)) is None
@pytest.mark.asyncio
async def test_download_sends_a_user_agent_on_every_request():
"""Discord's CDN 403s the default ``Python-urllib/x.y`` User-Agent.
Live-verified on staging 2026-08-26: every Discord CDN pass-through
download failed with ``HTTP Error 403: Forbidden``, the localizer then kept
the raw URL, and the transcriber tried to open a URL as a file path
("Audio file not found") — so voice notes, images and documents were ALL
silently dead on the Discord relay lane. Reproduced from a clean shell:
urllib with no UA → 403; the same URL with any descriptive UA → 200.
Assert the header on BOTH url classes (public pass-through and
bearer-authenticated re-host), because they take different header paths.
"""
seen: list[dict] = []
class _Resp:
headers = {"Content-Type": "audio/ogg", "Content-Length": "4"}
def read(self, *_a):
return b"OggS"
def __enter__(self):
return self
def __exit__(self, *_a):
return False
def _fake_urlopen(req, timeout=None): # noqa: ARG001
seen.append(dict(req.headers))
return _Resp()
import urllib.request as _ur
orig = _ur.urlopen
_ur.urlopen = _fake_urlopen # type: ignore[assignment]
try:
c = RelayMediaClient("https://conn.example", "gw1", "sec")
assert await c.download("https://cdn.discordapp.com/attachments/1/2/v.ogg")
assert await c.download("https://conn.example/relay/media/deadbeef")
finally:
_ur.urlopen = orig # type: ignore[assignment]
assert len(seen) == 2
for headers in seen:
# urllib title-cases header keys on Request.
ua = headers.get("User-agent") or headers.get("User-Agent")
assert ua, f"no User-Agent sent; urllib would default to Python-urllib (403s on Discord CDN): {headers}"
assert "python-urllib" not in ua.lower()
# The re-host request must still carry its bearer (no regression).
rehost_headers = seen[1]
assert (rehost_headers.get("Authorization") or "").startswith("Bearer ")
@@ -0,0 +1,154 @@
"""Unit tests for Phase 1.5 multi-platform-per-agent (relay).
Covers the agent half of Shape A (gateway-gateway D-Q1.5b.1 / D-Q1.5c):
- relay_platform_identities() parsing the GATEWAY_RELAY_PLATFORMS list +
GATEWAY_RELAY_BOT_IDS keyed map (the cut-over shape — no scalar fallback),
- relay_bot_username() reading the per-platform username,
- self_provision_relay() looping one /relay/provision POST per platform under
one gatewayId + one secret, partial-failure-tolerant,
- the RelayAdapter stamping the per-frame egress platform on outbound from the
chat's inbound source.platform.
The connector HTTP is monkeypatched; the cross-repo E2E exercises the real path.
"""
from __future__ import annotations
import json
import pytest
import gateway.relay as relay
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for k in (
"GATEWAY_RELAY_URL",
"GATEWAY_RELAY_ID",
"GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_DELIVERY_KEY",
"GATEWAY_RELAY_PLATFORM",
"GATEWAY_RELAY_BOT_ID",
"GATEWAY_RELAY_PLATFORMS",
"GATEWAY_RELAY_BOT_IDS",
):
monkeypatch.delenv(k, raising=False)
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False)
# ─────────────────────────── identity parsing ───────────────────────────
def test_identities_multi_platform_keyed_map(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord, telegram")
monkeypatch.setenv(
"GATEWAY_RELAY_BOT_IDS",
json.dumps(
{
"discord": {"botId": "app-1"},
"telegram": {"botId": "bot-9", "username": "@my_bot"},
}
),
)
# Order preserved; whitespace in the list trimmed.
assert relay.relay_platform_identities() == [("discord", "app-1"), ("telegram", "bot-9")]
# The PRIMARY is the first listed platform.
assert relay.relay_platform_identity() == ("discord", "app-1")
# Username folded into the per-platform entry; the leading @ is stripped.
assert relay.relay_bot_username("telegram") == "my_bot"
assert relay.relay_bot_username("discord") is None
def test_bot_ids_malformed_json_degrades_to_empty(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord")
monkeypatch.setenv("GATEWAY_RELAY_BOT_IDS", "{not valid json")
# A bad map must not crash boot — degrades to empty bot ids.
assert relay.relay_platform_identities() == [("discord", "")]
# ─────────────────────────── provision loop ───────────────────────────
def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"):
monkeypatch.setattr(relay, "relay_url", lambda: url)
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token)
def test_self_provision_loops_per_platform(monkeypatch):
_arm(monkeypatch)
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord,telegram")
monkeypatch.setenv(
"GATEWAY_RELAY_BOT_IDS",
json.dumps({"discord": {"botId": "app-1"}, "telegram": {"botId": "bot-9"}}),
)
calls = []
def _fake(**kwargs):
calls.append((kwargs["platform"], kwargs["bot_id"], kwargs["gateway_id"]))
return {"secret": "s" * 64, "deliveryKey": "d" * 64, "tenant": "t", "gatewayId": kwargs["gateway_id"]}
monkeypatch.setattr(relay, "_post_provision", _fake)
assert relay.self_provision_relay() is True
# One POST per fronted platform, all under the SAME gatewayId.
assert [(p, b) for p, b, _ in calls] == [("discord", "app-1"), ("telegram", "bot-9")]
assert len({gw for _, _, gw in calls}) == 1
# The in-process secret is set once (from the first success).
import os
assert os.environ["GATEWAY_RELAY_SECRET"] == "s" * 64
# ─────────────────────────── per-frame egress (adapter) ───────────────────────────
@pytest.mark.asyncio
async def test_adapter_stamps_per_frame_platform_from_inbound(monkeypatch):
"""An inbound from a concrete platform makes the reply egress tagged for it."""
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.session import SessionSource
from tests.gateway.relay.stub_connector import StubConnector
descriptor = CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform="relay",
label="Relay",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=False,
markdown_dialect="plain",
len_unit="chars",
)
stub = StubConnector(descriptor)
# This gateway fronts both discord and telegram.
stub._identities = [("discord", "app-1"), ("telegram", "bot-9")]
adapter = RelayAdapter(PlatformConfig(), descriptor, transport=stub)
await adapter.connect()
# A telegram inbound for chat "tg-1".
await stub.push_inbound(
MessageEvent(
text="hi",
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.TELEGRAM, chat_id="tg-1", chat_type="dm", user_id="u-1"),
)
)
await adapter.send("tg-1", "a telegram reply")
# The reply was tagged for telegram (per-frame egress).
assert stub.sent_platforms[-1] == "telegram"
# A discord inbound for chat "dc-1".
await stub.push_inbound(
MessageEvent(
text="yo",
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.DISCORD, chat_id="dc-1", chat_type="channel", scope_id="g-1"),
)
)
await adapter.send("dc-1", "a discord reply")
assert stub.sent_platforms[-1] == "discord"
@@ -0,0 +1,114 @@
"""Regression: stream semantics and draft capability resolve PER CHAT on
a multi-platform relay (PR 85796 review round 2, finding 2).
One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate
per platform on the transport and outbound frames are tagged per chat.
The round-1 gate keyed draft_stream_is_message and
supports_draft_streaming() off the PRIMARY scalar descriptor only:
- Slack primary + Telegram chat: the Telegram chat's turn-final was
intercepted into draft(final=true) — no real Telegram history
message (probed on the head);
- Telegram primary + Slack chat: the Slack chat was denied native
streaming entirely.
Both now resolve through _descriptor_for_chat (the same per-chat
machinery max_message_length already uses); the scalar remains the
fallback for unknown chats and single-platform gateways.
"""
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter, make_desc
def _desc_for(platform, **kw):
return make_desc(
platform=platform,
label=platform.title(),
markdown_dialect="markdown_v2" if platform == "telegram" else "slack",
max_message_length=4096,
**kw,
)
class RecordingTransport:
def __init__(self, descriptors_by_platform=None):
self.ops = []
self._descs = dict(descriptors_by_platform or {})
def descriptor_for_platform(self, platform):
return self._descs.get(platform)
async def send_outbound(self, payload, platform=None):
self.ops.append(dict(payload))
return {"success": True, "message_id": "ts.1"}
def _multi_adapter(primary="slack", others=("telegram",)):
"""Adapter with a primary descriptor + per-platform secondaries."""
adapter, _ = _connected_adapter() if primary == "slack" else _connected_adapter(
platform=primary, markdown_dialect="markdown_v2",
supported_ops=("send", "edit", "typing", "draft"),
)
descs = {p: _desc_for(p) for p in (primary, *others)}
t = RecordingTransport(descs)
adapter._transport = t
return adapter, t
class TestPerChatStreamSemantics:
@pytest.mark.asyncio
async def test_slack_primary_telegram_chat_gets_real_final(self):
"""The round-2 probe: telegram chat on a Slack-primary adapter →
frames stream, the final is a REAL send, never a seal."""
adapter, t = _multi_adapter(primary="slack", others=("telegram",))
adapter._platform_by_chat["TG1"] = "telegram"
md = {"message_id": "m.1"}
await adapter.send_draft("TG1", 3, "partial", metadata=md)
r = await adapter.send("TG1", "complete", metadata=dict(md))
assert r.success
assert not [o for o in t.ops if o["op"] == "draft" and o.get("final")], (
"telegram chat sealed by the Slack primary's semantic"
)
assert [o for o in t.ops if o["op"] == "send"]
@pytest.mark.asyncio
async def test_slack_primary_slack_chat_still_seals(self):
adapter, t = _multi_adapter(primary="slack", others=("telegram",))
adapter._platform_by_chat["C1"] = "slack"
md = {"message_id": "m.2"}
await adapter.send_draft("C1", 4, "partial", metadata=md)
r = await adapter.send("C1", "complete", metadata=dict(md))
assert r.success
assert [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert not [o for o in t.ops if o["op"] == "send"]
def test_telegram_primary_slack_chat_gets_native_streaming(self):
"""Inverse starvation: a Telegram primary must not deny a
secondary Slack chat its draft capability or seal semantics."""
adapter, _t = _multi_adapter(primary="telegram", others=("slack",))
adapter._platform_by_chat["C9"] = "slack"
assert adapter.supports_draft_streaming(chat_id="C9") is True
assert adapter.stream_is_message_for_chat("C9") is True
# And the primary's own chats keep telegram semantics:
adapter._platform_by_chat["TG9"] = "telegram"
assert adapter.stream_is_message_for_chat("TG9") is False
def test_unknown_chat_falls_back_to_scalar(self):
adapter, _t = _multi_adapter(primary="slack", others=("telegram",))
# Never seen inbound — scalar (slack primary) governs.
assert adapter.stream_is_message_for_chat("UNSEEN") is True
@pytest.mark.asyncio
async def test_capability_gate_respects_chat_descriptor(self):
"""A chat whose platform descriptor lacks the draft op must raise
NotImplementedError even when the primary advertises it."""
adapter, t = _multi_adapter(primary="slack", others=())
# Telegram descriptor WITHOUT the draft op:
t._descs["telegram"] = _desc_for(
"telegram", supported_ops=("send", "edit", "typing")
)
adapter._platform_by_chat["TG2"] = "telegram"
with pytest.raises(NotImplementedError):
await adapter.send_draft("TG2", 5, "x", metadata={})
@@ -0,0 +1,268 @@
"""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 Platform, 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, *, profile: str | None = None) -> 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,
profile=profile,
)
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_stamps_routed_profile():
"""A connector-routed profile on the wire frame lands on PassthroughForward.
Mirrors _event_from_wire's profile stamping for the ``inbound`` frame
(#60586) — the passthrough plane needs the same carry-through so a
Team-Gateway's Discord interactions route to the same profile a plain
message would.
"""
wire = {
"platform": "discord",
"botId": "appShared",
"method": "POST",
"path": "/interactions/discord/appShared",
"headers": [],
"bodyB64": "",
"profile": "reviewer",
}
fwd = _passthrough_from_wire(wire)
assert fwd.profile == "reviewer"
@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]
# APPLICATION_COMMAND interactions are normalized to a leading-slash
# command (the dispatcher's contract), not the bare registered name.
assert ev.text == "/summarize"
assert ev.is_command() is True
assert ev.get_command() == "summarize"
assert ev.source.chat_id == "chan-9"
assert ev.source.scope_id == "guild-7"
assert ev.source.user_id == "user-3"
# LOGICAL platform + native-parity chat_type: the session key must match
# the connector's capability-vault binding (interactionSessionSource →
# buildSessionKey: platform "discord", chat_type "group") and the relay
# text lane. Platform.RELAY / "channel" here forked the session and made
# /sethome file the home channel under platforms.relay (invisible to cron).
assert ev.source.platform == Platform.DISCORD
assert ev.source.chat_type == "group"
# Authenticated upstream-trust marker, parity with the relay text lane
# (ws_transport._event_from_wire) — /sethome's via_relay guard keys on it.
assert ev.source.delivered_via_upstream_relay is True
# Scope captured so the agent's reply re-asserts scope_id for egress.
assert adapter._scope_by_chat.get("chan-9") == "guild-7"
# The logical platform is now recorded for egress sender selection too
# (_capture_scope skips only the generic "relay").
assert adapter._platform_by_chat.get("chan-9") == "discord"
@pytest.mark.asyncio
async def test_discord_interaction_stamps_routed_profile(adapter, monkeypatch):
"""A connector-routed profile on the passthrough forward lands on the
resulting event's SessionSource, the same way it does for a plain relayed
message (#60586) — so a Team-Gateway's Discord slash-command/button/modal
routes to the same profile a plain message would, instead of always
falling back to agent:main."""
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-2",
"type": 2, # APPLICATION_COMMAND
"channel_id": "chan-9",
"guild_id": "guild-7",
"data": {"name": "summarize"},
"member": {"user": {"id": "user-3", "username": "ben"}},
},
profile="reviewer",
)
await stub.push_passthrough(fwd, buffer_id=None)
assert len(seen) == 1
assert seen[0].source.profile == "reviewer"
@pytest.mark.asyncio
async def test_application_command_subcommand_nesting_renders_names_then_values(
adapter, monkeypatch
):
"""SUB_COMMAND (type 1) appends its name, then recurses into its options."""
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": "i-sub",
"type": 2,
"channel_id": "c5",
"guild_id": "g5",
"data": {
"name": "skill",
"options": [
{
"name": "run",
"type": 1, # SUB_COMMAND
"options": [{"name": "target", "type": 3, "value": "deploy"}],
}
],
},
"member": {"user": {"id": "u5", "username": "ben"}},
}
)
await stub.push_passthrough(fwd)
assert len(seen) == 1
ev = seen[0]
assert ev.text == "/skill run deploy"
assert ev.is_command() is True
assert ev.get_command() == "skill"
assert ev.get_command_args() == "run deploy"
@pytest.mark.asyncio
async def test_dm_interaction_keys_as_discord_dm(adapter, monkeypatch):
"""A guild-less (DM) interaction keys as a Discord DM: logical platform,
chat_type 'dm', and the authenticated relay marker — the /sethome-in-DM
shape must file under platforms.discord, never platforms.relay."""
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": "i-dm",
"type": 2,
"channel_id": "dm-chan-1",
"data": {"name": "sethome"},
"user": {"id": "u9", "username": "ben"},
}
)
await stub.push_passthrough(fwd)
assert len(seen) == 1
ev = seen[0]
assert ev.source.platform == Platform.DISCORD
assert ev.source.chat_type == "dm"
assert ev.source.scope_id is None
assert ev.source.user_id == "u9"
assert ev.source.delivered_via_upstream_relay is True
@@ -0,0 +1,157 @@
"""Per-platform capability descriptors on the relay (multi-platform Phase 1.5).
The bug class: one relay adapter fronts N platforms on one WS, but the
capability surface (``MAX_MESSAGE_LENGTH`` / ``message_len_fn``) was a SCALAR
from whichever descriptor resolved the handshake — so a Discord chat on a
gateway whose primary identity was Telegram inherited Telegram's 4,096-char
cap and over-sent into Discord's 2,000-char API 400 (observed live: 2,543 and
2,641-char sends rejected).
Covers:
- the transport accumulating one descriptor per platform (first = session
default, later frames must NOT overwrite it),
- the map resetting on a re-dial,
- RelayAdapter.max_message_length_for_chat / message_len_fn_for_chat
resolving from the chat's inbound platform,
- fallback to the scalar descriptor for unknown chats / transports without
the map,
- the stream consumer's _raw_message_limit honoring the per-chat cap.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any, Dict, List, Optional
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.session import SessionSource
from tests.gateway.relay.stub_connector import StubConnector
def _descriptor(platform: str, max_len: int, len_unit: str = "chars") -> CapabilityDescriptor:
return CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform=platform,
label=platform.title(),
max_message_length=max_len,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=False,
markdown_dialect="plain",
len_unit=len_unit,
)
DISCORD = _descriptor("discord", 2000)
TELEGRAM = _descriptor("telegram", 4096, len_unit="utf16")
class MultiDescriptorStub(StubConnector):
"""StubConnector extended with the per-platform descriptor map."""
def __init__(self, primary: CapabilityDescriptor, *others: CapabilityDescriptor) -> None:
super().__init__(primary)
self._by_platform = {d.platform: d for d in (primary, *others)}
def descriptor_for_platform(self, platform: str) -> Optional[CapabilityDescriptor]:
return self._by_platform.get(platform)
async def _push(stub: StubConnector, platform: Platform, chat_id: str) -> None:
await stub.push_inbound(
MessageEvent(
text="hi",
message_type=MessageType.TEXT,
source=SessionSource(
platform=platform, chat_id=chat_id, chat_type="dm", user_id="u-1"
),
)
)
# ───────────────────── transport descriptor accumulation ─────────────────────
def _make_transport():
from gateway.relay.ws_transport import WebSocketRelayTransport
return WebSocketRelayTransport(
"wss://connector.example/relay",
"telegram",
"bot-9",
identities=[("telegram", "bot-9"), ("discord", "app-1")],
)
@pytest.mark.asyncio
async def test_transport_descriptor_map_resets_on_redial(monkeypatch):
"""A re-dial starts a fresh handshake generation: stale per-platform
descriptors must not survive into the new connection."""
t = _make_transport()
loop = asyncio.get_running_loop()
t._descriptor_ready = loop.create_future()
await t._handle_frame(json.dumps({"type": "descriptor", "descriptor": DISCORD.__dict__}))
assert t.descriptor_for_platform("discord") is not None
# Simulate _dial_and_start's reset preamble without a real socket.
class _FakeWs:
async def close(self): # pragma: no cover - not called
pass
sent: List[str] = []
async def _fake_connect(url, **kwargs):
return _FakeWs()
async def _fake_send(payload):
sent.append(payload)
import gateway.relay.ws_transport as wst
monkeypatch.setattr(wst, "websockets", type("M", (), {"connect": staticmethod(_fake_connect)}))
monkeypatch.setattr(t, "_send", _fake_send)
monkeypatch.setattr(
t, "_read_loop", lambda: asyncio.sleep(0)
) # substitute a no-op coroutine factory
await t._dial_and_start()
assert t.descriptor_for_platform("discord") is None
assert t._descriptor is None
# ───────────────────── adapter per-chat capability surface ─────────────────────
@pytest.mark.asyncio
async def test_adapter_resolves_per_chat_limits_from_inbound_platform():
"""The live bug shape: primary identity Telegram (4096), a Discord chat on
the same adapter must get Discord's 2000 — not Telegram's scalar."""
stub = MultiDescriptorStub(TELEGRAM, DISCORD)
stub._identities = [("telegram", "bot-9"), ("discord", "app-1")]
adapter = RelayAdapter(PlatformConfig(), TELEGRAM, transport=stub)
await adapter.connect()
await _push(stub, Platform.DISCORD, "dc-1")
await _push(stub, Platform.TELEGRAM, "tg-1")
# Scalar surface still the primary's (back-compat).
assert adapter.MAX_MESSAGE_LENGTH == 4096
# Per-chat: each chat resolves its own platform's cap.
assert adapter.max_message_length_for_chat("dc-1") == 2000
assert adapter.max_message_length_for_chat("tg-1") == 4096
# Length unit follows the chat too: Telegram utf16, Discord codepoints.
surrogate = "\U0001f600" # 2 UTF-16 units, 1 codepoint
assert adapter.message_len_fn_for_chat("tg-1")(surrogate) == 2
assert adapter.message_len_fn_for_chat("dc-1")(surrogate) == 1
# ───────────────────── stream consumer integration ─────────────────────
@@ -0,0 +1,138 @@
"""Unit tests for the gateway-side relay relevance-policy declaration (Phase 6 ζ).
Covers gateway.relay.relay_relevance_policy() (the projection of the agent's
mention-gating / free-response / allow-bots config into the connector's generic
vocabulary) and send_relay_policy() (the boot-time POST to /relay/policy). The
connector HTTP POST is monkeypatched; the cross-repo E2E (connector repo,
gateway_policy_driver.py) exercises the real route. These prove the PROJECTION
mapping, the auth/skip logic, and the fail-soft boot behaviour.
"""
from __future__ import annotations
import pytest
import gateway.relay as relay
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for k in (
"GATEWAY_RELAY_URL",
"GATEWAY_RELAY_ID",
"GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_PLATFORM",
"GATEWAY_RELAY_BOT_ID",
"GATEWAY_RELAY_PLATFORMS",
"GATEWAY_RELAY_BOT_IDS",
"DISCORD_ALLOW_BOTS",
):
monkeypatch.delenv(k, raising=False)
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False)
# --------------------------------------------------------------------------
# relay_relevance_policy() — the projection
# --------------------------------------------------------------------------
def test_projection_maps_require_mention_and_free_response(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True, "free_response_channels": ["c-support", "c-help"]}},
raising=False,
)
pol = relay.relay_relevance_policy()
assert pol == {
"platform": "discord",
"requireAddress": True,
"freeResponseScopes": ["c-support", "c-help"],
"allowOtherBots": False,
}
def test_projection_declares_explicit_require_mention_false(monkeypatch):
# An EXPLICIT `require_mention: false` is a configured (non-default) choice
# and MUST be declared: the connector's absent-row default is now
# requireAddress=true, so staying silent would mention-gate an agent the
# operator configured to free-respond.
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": False}},
raising=False,
)
pol = relay.relay_relevance_policy()
assert pol == {
"platform": "discord",
"requireAddress": False,
"freeResponseScopes": [],
"allowOtherBots": False,
}
# --------------------------------------------------------------------------
# send_relay_policy() — the boot-time declaration
# --------------------------------------------------------------------------
def _arm(monkeypatch, *, url="wss://connector.example/relay"):
monkeypatch.setenv("GATEWAY_RELAY_URL", url)
monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-x")
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "s" * 48)
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord")
def test_send_posts_projected_policy_with_token(monkeypatch):
_arm(monkeypatch)
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True, "free_response_channels": ["c-support"]}},
raising=False,
)
captured = {}
def _fake_post(*, policy_url, token, policy, timeout=15.0):
captured["policy_url"] = policy_url
captured["token"] = token
captured["policy"] = policy
return 200
monkeypatch.setattr(relay, "_post_policy", _fake_post)
assert relay.send_relay_policy() is True
assert captured["policy_url"] == "https://connector.example/relay/policy"
assert captured["token"] # a real upgrade token was minted
assert captured["policy"]["requireAddress"] is True
assert captured["policy"]["freeResponseScopes"] == ["c-support"]
def test_send_skips_when_no_secret(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_URL", "wss://connector.example/relay")
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "discord")
# no GATEWAY_RELAY_ID / SECRET
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
called = {"n": 0}
monkeypatch.setattr(relay, "_post_policy", lambda **k: called.__setitem__("n", called["n"] + 1) or 200)
assert relay.send_relay_policy() is False
assert called["n"] == 0 # never attempted without a secret to auth with
def test_send_fail_soft_on_transport_error(monkeypatch):
_arm(monkeypatch)
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"discord": {"require_mention": True}},
raising=False,
)
def _boom(**kwargs):
raise RuntimeError("connector unreachable")
monkeypatch.setattr(relay, "_post_policy", _boom)
# Never raises; returns False so boot proceeds.
assert relay.send_relay_policy() is False
@@ -0,0 +1,140 @@
"""RelayAdapter registration via the platform registry.
The relay platform is registered when a connector relay URL is configured
(``GATEWAY_RELAY_URL`` env or ``gateway.relay_url`` in config.yaml) — the same
config-driven shape as ``gateway.proxy_url``, not a separate feature flag. With
no URL configured, registration is a no-op so direct/single-tenant deployments
are unaffected. ``force=True`` registers a transport-less adapter for tests.
"""
from __future__ import annotations
import pytest
from gateway.config import PlatformConfig
from gateway.platform_registry import platform_registry
from gateway.relay import register_relay_adapter, relay_url
from gateway.relay.adapter import RelayAdapter
@pytest.fixture(autouse=True)
def _clean_registry(monkeypatch):
"""Each test starts/ends with no 'relay' entry and a clean relay env."""
monkeypatch.delenv("GATEWAY_RELAY_URL", raising=False)
monkeypatch.delenv("GATEWAY_RELAY_PLATFORM", raising=False)
monkeypatch.delenv("GATEWAY_RELAY_BOT_ID", raising=False)
platform_registry.unregister("relay")
yield
platform_registry.unregister("relay")
def test_off_when_no_url_configured(monkeypatch):
# No GATEWAY_RELAY_URL and (assuming) no gateway.relay_url in config.
monkeypatch.setattr("gateway.relay.relay_url", lambda: None)
assert register_relay_adapter() is False
assert platform_registry.is_registered("relay") is False
def test_registers_when_url_configured(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_URL", "wss://connector.example/relay")
assert relay_url() == "wss://connector.example/relay"
assert register_relay_adapter() is True
assert platform_registry.is_registered("relay") is True
class TestConfigRegistrationAgreementUnderMultiplexScope:
"""The config loader and the relay registration path must resolve the SAME
GATEWAY_RELAY_URL under an active multiplex profile scope — the routing
stamp is process-global (agent/secret_scope.py), so neither side may see a
value the other doesn't. Regression for the split-brain states: adapter
registered with Platform.RELAY absent from config (process stamp dropped
by the scoped reload), and enabled RELAY config with no adapter
(profile-only stamp invisible to relay_url())."""
def test_process_stamp_agrees_on_both_sides_under_scope(
self, tmp_path, monkeypatch
):
from agent import secret_scope as ss
from gateway.config import Platform, load_gateway_config
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n"
" platforms:\n"
" telegram:\n"
" enabled: true\n"
" bot_token: '123:abc'\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("GATEWAY_RELAY_URL", "wss://deploy.example/relay")
profile_dir = tmp_path / "profile-a"
profile_dir.mkdir()
(profile_dir / ".env").write_text("", encoding="utf-8")
ss.set_multiplex_active(True)
tok = ss.set_secret_scope(ss.build_profile_secret_scope(profile_dir))
try:
config = load_gateway_config()
# Registration-side reader agrees with the config side.
assert relay_url() == "wss://deploy.example/relay"
assert register_relay_adapter() is True
finally:
ss.reset_secret_scope(tok)
ss.set_multiplex_active(False)
assert config.platforms[Platform.RELAY].enabled is True
assert config.platforms[Platform.TELEGRAM].enabled is False
# The registered factory actually constructs an adapter with a live
# transport dialing the same URL — the connect loop's contract.
adapter = platform_registry.create_adapter(
"relay", config.platforms[Platform.RELAY]
)
assert isinstance(adapter, RelayAdapter)
# A URL-configured registration builds a live transport (force=True's
# transport-less posture is the test-only path) — private attr, no
# public accessor on the adapter.
assert adapter._transport is not None
def test_profile_only_stamp_is_inert_on_both_sides_under_scope(
self, tmp_path, monkeypatch
):
from agent import secret_scope as ss
from gateway.config import Platform, load_gateway_config
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n"
" platforms:\n"
" telegram:\n"
" enabled: true\n"
" bot_token: '123:abc'\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
profile_dir = tmp_path / "profile-a"
profile_dir.mkdir()
(profile_dir / ".env").write_text(
"GATEWAY_RELAY_URL=wss://profile.example/relay\n", encoding="utf-8"
)
ss.set_multiplex_active(True)
tok = ss.set_secret_scope(ss.build_profile_secret_scope(profile_dir))
try:
config = load_gateway_config()
# Both sides agree the stamp is absent: no half-enabled state.
assert relay_url() is None
assert register_relay_adapter() is False
finally:
ss.reset_secret_scope(tok)
ss.set_multiplex_active(False)
relay_cfg = config.platforms.get(Platform.RELAY)
assert relay_cfg is None or relay_cfg.enabled is False
assert config.platforms[Platform.TELEGRAM].enabled is True
@@ -0,0 +1,89 @@
"""End-to-end relay round-trip against the in-memory stub connector.
Proves the gateway side of the relay works with no real connector:
- connect() registers the inbound handler,
- a connector-delivered MessageEvent reaches the adapter's message path,
- SessionSource discriminators (scope_id) drive build_session_key isolation,
- an outbound send round-trips through the transport.
These target the transport contract + session-key derivation (Task 1.2's gate),
not the full agent turn — handle_message is patched to capture the event.
"""
from __future__ import annotations
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource, build_session_key
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from dataclasses import replace
from tests.gateway.relay.stub_connector import StubConnector
def _discord_descriptor() -> 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",
emoji="\U0001f47e",
platform_hint="You are on Discord.",
pii_safe=False,
)
def _discord_event(scope_id: str, channel_id: str, user_id: str, text: str) -> MessageEvent:
"""Synthetic inbound the connector would build from a discord.js message."""
source = SessionSource(
platform=Platform.DISCORD,
chat_id=channel_id,
chat_type="group",
user_id=user_id,
scope_id=scope_id,
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
@pytest.fixture
def wired():
stub = StubConnector(_discord_descriptor())
adapter = RelayAdapter(PlatformConfig(), _discord_descriptor(), transport=stub)
return adapter, stub
@pytest.mark.asyncio
async def test_connect_registers_inbound_handler(wired):
adapter, stub = wired
assert stub._inbound is None
ok = await adapter.connect()
assert ok is True
assert stub.connected is True
assert stub._inbound is not None
@pytest.mark.asyncio
async def test_inbound_event_reaches_adapter(wired, monkeypatch):
adapter, stub = wired
captured = []
monkeypatch.setattr(adapter, "handle_message", lambda ev: _async_capture(captured, ev))
await adapter.connect()
ev = _discord_event("guildA", "chan1", "userX", "hello")
await stub.push_inbound(ev)
assert len(captured) == 1
assert captured[0].text == "hello"
assert captured[0].source.scope_id == "guildA"
async def _async_capture(sink, event):
sink.append(event)
return None
@@ -0,0 +1,114 @@
"""End-to-end relay round-trip for Telegram against the in-memory stub.
Companion to ``test_relay_roundtrip.py`` (Discord). Proves the relay generalizes
beyond Discord — the Phase 1 exit gate requires *both* Telegram and Discord
descriptors to round-trip and their inbound ``MessageEvent``s to drive
``build_session_key()`` correctly.
Telegram's discriminator profile differs from Discord's, which is the point:
- No ``scope_id``; isolation between chats comes from ``chat_id`` alone.
- Forum topics live inside ONE ``chat_id`` and isolate by ``thread_id`` (the
Telegram analog of Discord's per-scope isolation).
- Forum/thread sessions are shared across participants by default
(``thread_sessions_per_user=False``) — user_id is NOT appended in a thread.
- ``len_unit="utf16"`` (Telegram counts UTF-16 code units) and
``markdown_dialect="markdown_v2"`` — distinct from Discord's chars/discord.
If the descriptor or session-keying only worked for Discord, these fail.
"""
from __future__ import annotations
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource, build_session_key
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from tests.gateway.relay.stub_connector import StubConnector
def _telegram_descriptor() -> CapabilityDescriptor:
return CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform="telegram",
label="Telegram",
max_message_length=4096,
supports_draft_streaming=True, # Telegram DMs support sendMessageDraft
supported_ops=("send", "edit", "typing", "follow_up", "draft"),
supports_edit=True,
supports_threads=True, # forum topics
markdown_dialect="markdown_v2",
len_unit="utf16",
emoji="\u2708\ufe0f",
platform_hint="You are on Telegram.",
pii_safe=False,
)
def _tg_group_event(chat_id: str, user_id: str, text: str, thread_id: str | None = None) -> MessageEvent:
"""Synthetic inbound the connector would build from a Telegram update.
A plain group message has no thread_id; a forum-topic message carries the
topic id as thread_id (no scope_id — Telegram has no scope concept).
"""
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
chat_type="forum" if thread_id else "group",
user_id=user_id,
thread_id=thread_id,
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
def _tg_dm_event(chat_id: str, user_id: str, text: str) -> MessageEvent:
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id,
chat_type="dm",
user_id=user_id,
)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
@pytest.fixture
def wired():
desc = _telegram_descriptor()
stub = StubConnector(desc)
adapter = RelayAdapter(PlatformConfig(), desc, transport=stub)
return adapter, stub
@pytest.mark.asyncio
async def test_telegram_descriptor_round_trips_through_stub(wired):
"""The connector's handshake descriptor for Telegram survives JSON + the
adapter configures itself from it (utf16 length unit, 4096 limit)."""
adapter, stub = wired
desc = _telegram_descriptor()
assert CapabilityDescriptor.from_json(desc.to_json()) == desc
# Adapter reflects the descriptor's capability profile.
assert adapter.MAX_MESSAGE_LENGTH == 4096
assert adapter.supports_draft_streaming() is True
# utf16 length unit selects a non-default len fn (Telegram counts UTF-16).
assert adapter.message_len_fn is not len
@pytest.mark.asyncio
async def test_inbound_telegram_event_reaches_adapter(wired, monkeypatch):
adapter, stub = wired
captured: list[MessageEvent] = []
monkeypatch.setattr(adapter, "handle_message", lambda ev: _async_capture(captured, ev))
await adapter.connect()
await stub.push_inbound(_tg_group_event("chat-100", "userX", "hello"))
assert len(captured) == 1
assert captured[0].text == "hello"
assert captured[0].source.platform == Platform.TELEGRAM
assert captured[0].source.scope_id is None # Telegram has no scope
async def _async_capture(sink, event):
sink.append(event)
return None
@@ -0,0 +1,84 @@
"""Regression: cancellation during the final seal must not orphan the
remote stream (PR 85796 review round 2, finding 4).
_seal_open_draft pops the open entry and writes the local tombstone
BEFORE awaiting transport I/O. CancelledError bypasses the failure
handling (it is not an Exception), so a cancel mid-seal left:
remote stream: OPEN (live indicator until connector eviction)
adapter._open_draft_by_chat: {} <- abandon finds nothing
adapter._sealed_draft_by_chat: {key: id} <- premature tombstone
The seal now restores the open entry and drops its premature tombstone
on CancelledError before re-raising, so the consumer's abandon pass can
seal the stream in place.
"""
import asyncio
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
class HangOnSealTransport:
def __init__(self):
self.ops = []
self.hang_seal = True
async def send_outbound(self, payload, platform=None):
final = bool(payload.get("final"))
self.ops.append((payload.get("op"), final, str(payload.get("content"))[:25]))
if payload.get("op") == "draft" and final and self.hang_seal:
await asyncio.sleep(30)
return {"success": True, "message_id": "ts.1"}
class TestCancelDuringSeal:
@pytest.mark.asyncio
async def test_cancel_mid_seal_restores_open_state(self):
adapter, _ = _connected_adapter()
t = HangOnSealTransport()
adapter._transport = t
md = {"message_id": "m.1"}
await adapter.send_draft("C1", 11, "partial", metadata=md)
key = adapter._draft_key("C1", md)
task = asyncio.create_task(adapter.send("C1", "complete", metadata=dict(md)))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert adapter._open_draft_by_chat.get(key) == 11, (
"cancelled seal must restore the open entry so abandon can close it"
)
assert key not in adapter._sealed_draft_by_chat, (
"premature tombstone must not survive a cancelled seal"
)
@pytest.mark.asyncio
async def test_abandon_after_cancelled_seal_closes_remote_stream(self):
"""End-to-end: cancel mid-seal, then the abandon pass (what the
consumer's CancelledError handler runs) seals the stream."""
adapter, _ = _connected_adapter()
t = HangOnSealTransport()
adapter._transport = t
md = {"message_id": "m.2"}
await adapter.send_draft("C1", 12, "partial on screen", metadata=md)
task = asyncio.create_task(adapter.send("C1", "complete", metadata=dict(md)))
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
t.hang_seal = False # transport recovers for the abandon pass
r = await adapter.abandon_open_draft(
"C1", "partial on screen", metadata=dict(md)
)
assert r.success
seals = [o for o in t.ops if o[0] == "draft" and o[1]]
# First seal attempt hung (cancelled); the abandon's seal landed.
assert seals[-1][2] == "partial on screen"
assert not adapter._open_draft_by_chat
@@ -0,0 +1,90 @@
"""Invariant: the relay path sheds platform crypto — it re-validates nothing.
Under the A2 trust model (see docs/relay-connector-contract.md §6), the
*connector* is the sole crypto/identity boundary: it verifies/decrypts every
inbound platform payload at the edge (it holds the tenant secrets), normalizes
it to a tenant-scoped ``MessageEvent``, and forwards only the sanitized event.
The gateway re-validates nothing — it cannot, without being handed the shared
signing secret, which would itself be the leak on a shared bot.
The relay package therefore MUST NOT import or call platform signature/crypto
verification (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt, generic webhook
signature checks). Those live in the *direct* platform adapters
(``gateway/platforms/*``) which serve non-relay deployments; the relay receives
already-trusted events. This test fails if someone bolts re-validation onto the
relay path, re-coupling the gateway to platform secrets it must never hold.
It is an invariant (asserts the *relation* "relay imports no crypto"), not a
change-detector snapshot of a frozen import list.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
# gateway/relay package directory: tests/gateway/relay/ -> repo root parents[3].
_REPO_ROOT = Path(__file__).resolve().parents[3]
_RELAY_PKG = _REPO_ROOT / "gateway" / "relay"
# Modules / symbols that mean "platform crypto re-validation". If the relay path
# imports any of these it has re-coupled the gateway to a platform secret.
_FORBIDDEN_MODULE_TOKENS = (
"wecom_crypto",
"wecom_callback",
"webhook", # gateway.platforms.webhook holds signature verification
)
_FORBIDDEN_SYMBOL_RE = re.compile(
r"(ed25519|verify_key|verifykey|verify_signature|verify_ed25519|"
r"verify_webhook|bizmsg|hmac|x[-_]signature)",
re.IGNORECASE,
)
def _relay_py_files() -> list[Path]:
assert _RELAY_PKG.is_dir(), f"relay package missing at {_RELAY_PKG}"
return sorted(_RELAY_PKG.glob("*.py"))
# ``auth.py`` is the connector⇄gateway CHANNEL authenticator (the gateway's WS
# upgrade bearer). It is net-new, intended, and the whole point of
# authenticating an untrusted/disposable gateway — it is NOT platform crypto.
# It uses HMAC over the connector's per-gateway secret (NOT any platform's
# signing secret), so it is exempt from the platform-crypto symbol scan below.
# The module-import ban (platform-crypto modules) still applies to every file
# including this one — it imports only stdlib hmac/hashlib, never a
# platform-crypto module, so it stays clean there.
_CHANNEL_AUTH_FILES = {"auth.py"}
def test_relay_package_calls_no_signature_verification():
"""No relay module references a PLATFORM signature/crypto-verification symbol.
Scoped to platform crypto (Discord ed25519, Twilio/WeCom HMAC, webhook
signature checks). The connector⇄gateway channel authenticator (``auth.py``)
is exempt: its HMAC is over the connector's own per-gateway/per-tenant
secrets to authenticate the relay channel itself — the gateway holds NO
platform secret and re-validates NO platform payload. See ``auth.py`` and
docs/connector-gateway-auth-design.md.
"""
offenders: list[str] = []
for path in _relay_py_files():
if path.name in _CHANNEL_AUTH_FILES:
continue
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
# Skip comments / docstrings-as-prose: only flag code-like usage.
stripped = line.strip()
if stripped.startswith("#"):
continue
m = _FORBIDDEN_SYMBOL_RE.search(line)
if m:
offenders.append(f"{path.name}:{lineno}: '{m.group(0)}' in: {stripped[:80]}")
assert not offenders, (
"The relay path must not perform platform signature/crypto verification "
"(A2). Found verification-symbol references:\n "
+ "\n ".join(offenders)
+ "\nThe connector verifies at the edge; the gateway re-validates nothing."
)
@@ -0,0 +1,403 @@
"""Slack relay: edit-based streaming of the reply must fire in a DM.
Reported symptom (live): agent responses stream progressively (edit-based) in a
Slack THREAD but arrive FLAT (single message, no progressive edits) in a Slack
DM/home over the relay.
Root cause: a DM turn's streaming reply is sent with
``reply_to = <triggering message ts>`` (the stream consumer's
``initial_reply_to_id`` — its edit anchor). The connector's slackRestSender maps
a raw ``reply_to`` to a Slack ``thread_ts``, so a plain DM reply gets threaded
UNDER the user's message instead of posting flat at the DM root, and a threaded
first send loses the progressive edit streaming the user sees in a real thread.
Native ``SlackAdapter._resolve_thread_ts`` already suppresses this synthetic DM
thread anchor; the relay lane had no such disambiguation.
These are behaviour-contract tests: they assert how the outbound frame relates to
the chat type + thread metadata (the invariant the connector depends on), not a
snapshot. They drive the REAL ``RelayAdapter`` + ``GatewayStreamConsumer`` +
``StubConnector`` end to end.
"""
from __future__ import annotations
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.session import SessionSource
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
from tests.gateway.relay.stub_connector import StubConnector
def _slack_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="slack",
label="Slack",
max_message_length=4000,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="mrkdwn",
len_unit="chars",
emoji="\U0001f4ac",
platform_hint="",
pii_safe=False,
)
base.update(kw)
return CapabilityDescriptor(**base)
def _wire(chat_id: str, chat_type: str, *, user_id="U1", scope_id=None):
"""A RelayAdapter fronting Slack, with inbound scope captured for chat_id."""
stub = StubConnector(_slack_desc())
adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub)
src = SessionSource(
platform=Platform.SLACK,
chat_id=chat_id,
chat_type=chat_type,
user_id=user_id,
scope_id=scope_id,
)
adapter._capture_scope(
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
)
return adapter, stub
# ---------------------------------------------------------------------------
# The pure disambiguation contract (RelayAdapter.send)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_slack_dm_reply_keeps_anchor_in_thread_per_message_mode():
"""Default mode (reply_in_thread=True, thread-per-message): the triggering
ts reply_to is the final reply's ONLY threading signal (base.py builds
metadata from source.thread_id, which is None for a top-level DM) — it
must be KEPT so the final message lands in the per-message thread with
the progress bubbles (2026-07-27 mixed-placement report)."""
adapter, stub = _wire("D1", "dm")
await adapter.send("D1", "the answer", reply_to="1700.0001")
assert len(stub.sent) == 1
frame = stub.sent[0]
assert frame["op"] == "send"
assert frame["reply_to"] == "1700.0001", (
"thread-per-message: the triggering ts anchors the final reply"
)
# The connector's Slack sender threads on metadata.thread_id ONLY
# (threadTs() never reads the frame's reply_to), so the surviving anchor
# must be promoted into metadata for the send to actually thread.
assert (frame["metadata"] or {}).get("thread_id") == "1700.0001"
@pytest.mark.asyncio
async def test_slack_dm_reply_drops_synthetic_anchor_in_flat_mode():
"""Flat mode (reply_in_thread=False): the synthetic self-anchor is dropped
so the reply posts flat at the DM root (native _resolve_thread_ts parity)
and no synthetic thread is invented (#18859)."""
adapter, stub = _wire("D1", "dm")
adapter.config.extra = {"reply_in_thread": False}
await adapter.send("D1", "the answer", reply_to="1700.0001")
frame = stub.sent[0]
assert frame["reply_to"] is None
assert "thread_id" not in (frame["metadata"] or {})
assert "thread_ts" not in (frame["metadata"] or {})
@pytest.mark.asyncio
async def test_slack_dm_reply_with_real_thread_keeps_anchor():
"""A DM turn that IS inside a real thread (metadata carries a distinct
thread_id) must keep threading — the guard only drops the synthetic anchor."""
adapter, stub = _wire("D1", "dm")
await adapter.send(
"D1", "in thread", reply_to="1700.0002", metadata={"thread_id": "1699.9000"}
)
frame = stub.sent[0]
assert frame["reply_to"] == "1700.0002"
assert frame["metadata"]["thread_id"] == "1699.9000"
@pytest.mark.asyncio
async def test_slack_channel_top_level_reply_keeps_autothread_anchor():
"""A channel top-level reply carries thread_id (its own ts) in metadata when
autoThread is on; the DM-only guard must not touch it."""
adapter, stub = _wire("C1", "channel", scope_id="T1")
await adapter.send(
"C1", "channel reply", reply_to="1700.0003", metadata={"thread_id": "1700.0003"}
)
frame = stub.sent[0]
assert frame["reply_to"] == "1700.0003"
assert frame["metadata"]["thread_id"] == "1700.0003"
@pytest.mark.asyncio
async def test_non_slack_dm_reply_unchanged():
"""The disambiguation is Slack-scoped: a non-Slack relay chat keeps reply_to
(its connector owns its own threading semantics)."""
stub = StubConnector(_slack_desc(platform="discord"))
adapter = RelayAdapter(
PlatformConfig(), _slack_desc(platform="discord"), transport=stub
)
src = SessionSource(
platform=Platform.DISCORD, chat_id="dc1", chat_type="dm", user_id="U1"
)
adapter._capture_scope(
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
)
await adapter.send("dc1", "hi", reply_to="msg-9")
assert stub.sent[0]["reply_to"] == "msg-9"
# ---------------------------------------------------------------------------
# End-to-end: the stream consumer keeps edit-streaming in a DM
# ---------------------------------------------------------------------------
async def _drive_stream(adapter, chat_id, *, metadata, initial_reply_to_id, chat_type):
cfg = StreamConsumerConfig(
edit_interval=0.0,
buffer_threshold=1,
transport="edit",
chat_type=chat_type,
)
consumer = GatewayStreamConsumer(
adapter=adapter,
chat_id=chat_id,
config=cfg,
metadata=metadata,
initial_reply_to_id=initial_reply_to_id,
)
# Feed progressive deltas, then finalize — mirrors the live delta callback.
for chunk in ("Hel", "lo ", "world", ". Done."):
consumer.on_delta(chunk)
consumer.finish()
await consumer.run()
return consumer
@pytest.mark.asyncio
async def test_slack_dm_stream_consumer_edits_own_ts_not_flat():
"""A Slack DM turn (chat_type='dm', no thread, metadata None) still builds a
stream consumer that keeps edit support and emits progressive EDITs of the
reply message — the flat-DM regression contract.
The connector returns a real message_id for the flat first send, so edit
support must stay on and at least one edit op must be emitted (progressive
streaming), identical to a thread. No synthetic thread is created.
Runs in EXPLICIT flat mode (reply_in_thread=False) — that is the mode this
contract belongs to; the default thread-per-message path is covered by
test_slack_dm_stream_consumer_threads_in_thread_per_message_mode."""
adapter, stub = _wire("D1", "dm")
adapter.config.extra = {"reply_in_thread": False}
consumer = await _drive_stream(
adapter,
"D1",
metadata=None, # DM: _status_thread_metadata is None in run.py
initial_reply_to_id="1700.0001", # the triggering message ts
chat_type="dm",
)
ops = [f["op"] for f in stub.sent]
# First a flat send; edit support stays on so progressive edits CAN flow
# (exact intermediate-frame timing is covered by the stream_consumer unit
# suite — here we assert the DM regression contract: streaming is not
# self-disabled and every edit targets the reply's own ts).
assert ops[0] == "send"
# Edit support survived: message_id set, not the __no_edit__ sentinel.
assert consumer.message_id and consumer.message_id != "__no_edit__"
assert consumer._edit_supported is True
first_send = stub.sent[0]
# The reply posts FLAT at the DM root — no synthetic thread anchor.
assert first_send["reply_to"] is None
assert "thread_id" not in (first_send["metadata"] or {})
assert "thread_ts" not in (first_send["metadata"] or {})
# reply_to_message_id (the mirrored self-anchor) is stripped too.
assert "reply_to_message_id" not in (first_send["metadata"] or {})
# Any edits that flowed target the same first-send ts (editing its own
# message), never a synthetic thread.
edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"}
assert edit_ids <= {stub.next_send_result["message_id"]}
@pytest.mark.asyncio
async def test_slack_thread_stream_consumer_still_threads_and_streams():
"""Regression guard: a Slack THREAD turn keeps its real thread_id AND streams
(the DM fix must not change the thread path)."""
adapter, stub = _wire("C1", "channel", scope_id="T1")
consumer = await _drive_stream(
adapter,
"C1",
metadata={"thread_id": "1699.9000"},
initial_reply_to_id="1700.0002",
chat_type="channel",
)
ops = [f["op"] for f in stub.sent]
assert ops[0] == "send"
assert consumer._edit_supported is True
first_send = stub.sent[0]
# Thread preserved: the real thread_id rides along and reply_to is kept.
assert first_send["metadata"]["thread_id"] == "1699.9000"
assert first_send["reply_to"] == "1700.0002"
@pytest.mark.asyncio
async def test_slack_dm_stream_consumer_threads_in_thread_per_message_mode():
"""Default mode: the DM stream's first send keeps the triggering-ts anchor
so the streamed final reply lands in the per-message thread; edits still
target the reply's own ts."""
adapter, stub = _wire("D1", "dm")
consumer = await _drive_stream(
adapter,
"D1",
metadata=None,
initial_reply_to_id="1700.0001",
chat_type="dm",
)
first_send = stub.sent[0]
assert first_send["op"] == "send"
assert first_send["reply_to"] == "1700.0001"
assert consumer.message_id and consumer.message_id != "__no_edit__"
edit_ids = {f["message_id"] for f in stub.sent if f["op"] == "edit"}
assert edit_ids <= {stub.next_send_result["message_id"]}
# ---------------------------------------------------------------------------
# The media lane obeys the SAME thread-anchor contract as the text lane.
#
# send() and _send_media() both egress through the connector's Slack sender,
# so an anchor resolved on only one of them threads an image under the user's
# DM message in flat mode, and loses the per-message thread in thread mode
# (threadTs() reads metadata only). Both lanes route through
# _apply_slack_thread_anchor; these pin that they stay in agreement.
# ---------------------------------------------------------------------------
def _media_wire(chat_id: str, chat_type: str):
"""Like _wire, but the descriptor advertises the send_media op."""
desc = _slack_desc(supported_ops=("send", "edit", "typing", "send_media"))
stub = StubConnector(desc)
adapter = RelayAdapter(PlatformConfig(), desc, transport=stub)
src = SessionSource(
platform=Platform.SLACK,
chat_id=chat_id,
chat_type=chat_type,
user_id="U1",
)
adapter._capture_scope(
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
)
return adapter, stub
@pytest.mark.asyncio
async def test_slack_dm_media_keeps_and_promotes_anchor_in_thread_mode():
"""Thread-per-message: an image must land in the per-message thread. The
connector threads on metadata.thread_id only, so the surviving anchor has
to be promoted there — a bare reply_to would post to the home channel."""
adapter, stub = _media_wire("D1", "dm")
await adapter.send_image("D1", "https://example.com/x.png", reply_to="1700.0001")
frame = [f for f in stub.sent if f["op"] == "send_media"][-1]
assert frame["reply_to"] == "1700.0001"
assert (frame["metadata"] or {}).get("thread_id") == "1700.0001", (
"media frame must carry the anchor where the connector reads it"
)
@pytest.mark.asyncio
async def test_slack_dm_media_drops_synthetic_anchor_in_flat_mode():
"""Flat mode: the media frame drops the synthetic self-anchor exactly as
the text lane does, so the image posts flat at the DM root instead of
threading under the user's message, and invents no thread (#18859)."""
adapter, stub = _media_wire("D1", "dm")
adapter.config.extra = {"reply_in_thread": False}
await adapter.send_image("D1", "https://example.com/x.png", reply_to="1700.0001")
frame = [f for f in stub.sent if f["op"] == "send_media"][-1]
assert frame["reply_to"] is None
assert "thread_id" not in (frame["metadata"] or {})
assert "thread_ts" not in (frame["metadata"] or {})
@pytest.mark.asyncio
async def test_slack_channel_media_anchor_untouched():
"""Non-DM chats are outside the synthetic-anchor rule: a channel media
send keeps its reply_to unchanged."""
adapter, stub = _media_wire("C1", "channel")
await adapter.send_image("C1", "https://example.com/x.png", reply_to="1700.0009")
frame = [f for f in stub.sent if f["op"] == "send_media"][-1]
assert frame["reply_to"] == "1700.0009"
@pytest.mark.asyncio
async def test_media_caller_metadata_not_mutated():
"""The anchor promotion must not leak into the caller's dict — media
helpers are called in loops with a shared metadata mapping."""
adapter, stub = _media_wire("D1", "dm")
caller_md = {"user_id": "U1"}
await adapter.send_image(
"D1", "https://example.com/x.png", reply_to="1700.0001", metadata=caller_md
)
assert caller_md == {"user_id": "U1"}, "caller metadata was mutated in place"
# ---------------------------------------------------------------------------
# Operator flags coerce exactly as the native Slack adapter's do.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
(False, False),
("false", False),
("False", False),
(" no ", False),
("off", False),
("0", False),
(True, True),
("true", True),
("yes", True),
("on", True),
("1", True),
],
)
def test_relay_slack_flags_coerce_like_native(raw, expected):
"""A YAML-quoted "false" must turn these knobs OFF, matching native's
str().strip().lower() predicate. A bare bool() would read any non-empty
string as True and silently ignore the operator's off switch."""
adapter, _stub = _wire("D1", "dm")
adapter.config.extra = {
"slack": {
"reply_in_thread": raw,
"dm_top_level_threads_as_sessions": raw,
}
}
assert adapter._effective_reply_in_thread() is expected
assert adapter._dm_top_level_threads_as_sessions() is expected
def test_relay_slack_flags_default_true_when_absent():
"""Both knobs default ON when the operator sets nothing."""
adapter, _stub = _wire("D1", "dm")
adapter.config.extra = {}
assert adapter._effective_reply_in_thread() is True
assert adapter._dm_top_level_threads_as_sessions() is True
# ---------------------------------------------------------------------------
# The status clear targets the same thread the heartbeat set.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_typing_and_clear_share_one_status_anchor():
"""send_typing and stop_typing resolve the anchor through one helper: a
clear that no-ops threadless leaves the status line stuck until Slack's
own timeout."""
adapter, stub = _wire("D1", "dm")
adapter._last_inbound_ts_by_chat["D1"] = "1700.0001"
await adapter.send_typing("D1")
await adapter.stop_typing("D1")
typing_frames = [f for f in stub.sent if f["op"] == "typing"]
assert len(typing_frames) == 2
anchors = [(f["metadata"] or {}).get("thread_id") for f in typing_frames]
assert anchors == ["1700.0001", "1700.0001"], (
"the clear must target the thread the heartbeat set"
)
@@ -0,0 +1,486 @@
"""Slack relay: interactive prompts follow the turn's thread stamp.
The threading MODE (flat DM vs thread-per-message) is decided in exactly ONE
place: run.py's ``_resolve_progress_thread_id``, which reads
``platforms.slack.extra.reply_in_thread`` and encodes the verdict into the
outbound ``metadata`` stamp:
* flat mode -> the synthetic self-anchor is suppressed in run.py, so prompt
metadata arrives with NO ``thread_id`` and the card posts at the DM root;
* thread-per-message (default) -> ``metadata.thread_id`` is stamped for the
whole turn; on the FIRST turn it legitimately equals the triggering
message's ts (the synthetic root IS the thread).
The prompt lane must TRUST that stamp, like ``_resolve_reply_to_for_send``
does. Re-deriving the mode here (the old unconditional
``thread_id == message_id`` strip) exiled the approval card and its
resolved-state swap to the DM root while progress bubbles honoured the thread
(the 2026-07-27 mixed-placement report).
These are behaviour-contract tests: they assert how the outbound ``prompt``
frame relates to the inherited thread metadata (the invariant the connector
depends on), not a snapshot. They drive the REAL ``RelayAdapter`` +
``StubConnector`` end to end.
"""
from __future__ import annotations
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.session import SessionSource
from tests.gateway.relay.stub_connector import StubConnector
FULL_OPS = ("send", "edit", "typing", "get_chat_info", "send_media", "prompt", "react")
def _slack_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="slack",
label="Slack",
max_message_length=4000,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="mrkdwn",
len_unit="chars",
supported_ops=FULL_OPS,
)
base.update(kw)
return CapabilityDescriptor(**base)
def _wire(
chat_id: str,
chat_type: str,
*,
user_id="U1",
scope_id=None,
platform=Platform.SLACK,
):
"""A RelayAdapter fronting Slack, with inbound scope + chat_type captured."""
stub = StubConnector(_slack_desc())
adapter = RelayAdapter(PlatformConfig(), _slack_desc(), transport=stub)
src = SessionSource(
platform=platform,
chat_id=chat_id,
chat_type=chat_type,
user_id=user_id,
scope_id=scope_id,
)
adapter._capture_scope(
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
)
return adapter, stub
def _last_prompt(stub) -> dict:
prompts = [f for f in stub.sent if f["op"] == "prompt"]
assert prompts, "expected a prompt op on the wire"
return prompts[-1]
# ---------------------------------------------------------------------------
# Flat mode: run.py stamps NO thread_id -> the card posts at the DM root.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_exec_approval_flat_mode_posts_at_dm_root():
"""Flat-DM turn (reply_in_thread=false): run.py suppressed the synthetic
anchor upstream, so prompt metadata has no thread_id and none appears on
the wire — the card posts at the DM root."""
adapter, stub = _wire("D1", "dm", scope_id="T1")
md = {"message_id": "1700000000.000100", "scope_id": "T1"}
result = await adapter.send_exec_approval(
"D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md
)
assert result.success is True
frame = _last_prompt(stub)
meta = frame["metadata"] or {}
assert "thread_id" not in meta
assert "thread_ts" not in meta
# reply_to on the outbound action stays unset — a root-level post.
assert frame["reply_to"] is None
# Tenant scope is preserved untouched (egress routing must not break).
assert meta.get("scope_id") == "T1"
# ---------------------------------------------------------------------------
# Thread-per-message mode, end-to-end placement contract: run.py stamps the
# turn's thread (first turn: the triggering message's own ts) and the adapter
# forwards prompt metadata UNTOUCHED — no re-derivation, no strip. Mixed
# placement (progress threaded, card at root) was the 2026-07-27 regression.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_exec_approval_forwards_run_py_thread_stamp_untouched():
"""The adapter must forward run.py's thread stamp verbatim: the approval
card posts INTO the stamped thread. Any adapter-side re-derivation or
strip exiled the card to the home channel (2026-07-27 report)."""
adapter, stub = _wire("D1", "dm", scope_id="T1")
md = {
"thread_id": "1700000000.000100",
"message_id": "1700000000.000100",
"scope_id": "T1",
}
result = await adapter.send_exec_approval(
"D1", "rm -rf /tmp/x", "sess:1", description="deletes files", metadata=md
)
assert result.success is True
frame = _last_prompt(stub)
meta = frame["metadata"] or {}
assert meta.get("thread_id") == "1700000000.000100", (
"first-turn self-anchor is the thread root; the prompt must honour it"
)
assert meta.get("scope_id") == "T1"
@pytest.mark.asyncio
async def test_clarify_forwards_run_py_thread_stamp_untouched():
adapter, stub = _wire("D1", "dm", scope_id="T1")
md = {
"thread_id": "1700000000.000200",
"message_id": "1700000000.000200",
"scope_id": "T1",
}
result = await adapter.send_clarify(
"D1", "Which env?", ["prod", "staging"], "cl-1", "sess:1", metadata=md
)
assert result.success is True
frame = _last_prompt(stub)
meta = frame["metadata"] or {}
assert meta.get("thread_id") == "1700000000.000200"
assert meta.get("scope_id") == "T1"
@pytest.mark.asyncio
async def test_slash_confirm_forwards_run_py_thread_stamp_untouched():
"""The forward-untouched rule covers every prompt surface (single
_send_prompt choke point)."""
adapter, stub = _wire("D1", "dm")
md = {"thread_id": "1700000000.000300", "message_id": "1700000000.000300"}
await adapter.send_slash_confirm(
"D1", "Reload MCP", "invalidates cache", "s", "cf-1", metadata=md
)
frame = _last_prompt(stub)
assert (frame["metadata"] or {}).get("thread_id") == "1700000000.000300"
# ---------------------------------------------------------------------------
# Regression guards: a REAL thread and non-DM / non-Slack chats are untouched
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_exec_approval_in_real_thread_keeps_thread_id():
"""A DM prompt raised inside a REAL thread (thread_id distinct from the
triggering message ts) stays in that thread."""
adapter, stub = _wire("D1", "dm", scope_id="T1")
md = {
"thread_id": "1699000000.999000",
"message_id": "1700000000.000100",
"scope_id": "T1",
}
await adapter.send_exec_approval("D1", "cmd", "s", metadata=md)
frame = _last_prompt(stub)
assert frame["metadata"]["thread_id"] == "1699000000.999000"
@pytest.mark.asyncio
async def test_channel_approval_keeps_thread_id():
"""A Slack CHANNEL prompt keeps its thread_id (autoThread / real thread)."""
adapter, stub = _wire("C1", "channel", scope_id="T1")
md = {
"thread_id": "1700000000.000400",
"message_id": "1700000000.000400",
"scope_id": "T1",
}
await adapter.send_exec_approval("C1", "cmd", "s", metadata=md)
frame = _last_prompt(stub)
assert frame["metadata"]["thread_id"] == "1700000000.000400"
@pytest.mark.asyncio
async def test_non_slack_dm_approval_keeps_thread_id():
"""A non-Slack relay DM keeps thread_id (its connector owns its own
threading semantics)."""
adapter, stub = _wire("dc1", "dm", platform=Platform.DISCORD)
md = {"thread_id": "9000", "message_id": "9000"}
await adapter.send_exec_approval("dc1", "cmd", "s", metadata=md)
frame = _last_prompt(stub)
assert frame["metadata"]["thread_id"] == "9000"
# ---------------------------------------------------------------------------
# Rich status: the relay advertises Slack's text status line and carries
# the live per-tool phrase on the typing frame (native set_status_text parity).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_slack_relay_advertises_status_text():
adapter, _stub = _wire("D1", "dm")
assert adapter.supports_status_text is True
@pytest.mark.asyncio
async def test_non_slack_relay_does_not_advertise_status_text():
stub = StubConnector(_slack_desc(platform="discord"))
adapter = RelayAdapter(
PlatformConfig(), _slack_desc(platform="discord"), transport=stub
)
assert adapter.supports_status_text is False
@pytest.mark.asyncio
async def test_typing_carries_live_status_phrase():
"""set_status_text() -> the next typing frame carries the phrase as
content; clearing it (None) reverts to a content-less heartbeat frame
(never an empty string, which is Slack's explicit clear)."""
adapter, stub = _wire("D1", "dm", scope_id="T1")
adapter.set_status_text("D1", "is running pytest…")
await adapter.send_typing("D1", metadata={"scope_id": "T1"})
typing = [f for f in stub.sent if f["op"] == "typing"]
assert typing and typing[-1].get("content") == "is running pytest…"
adapter.set_status_text("D1", None)
await adapter.send_typing("D1", metadata={"scope_id": "T1"})
typing = [f for f in stub.sent if f["op"] == "typing"]
assert "content" not in typing[-1], (
"cleared phrase must omit content (empty string means CLEAR on Slack)"
)
# ---------------------------------------------------------------------------
# Status thread anchor: typing frames synthesize the per-message thread
# root in thread-per-message mode (the status line is thread-only on Slack).
# ---------------------------------------------------------------------------
def _wire_with_ts(chat_id, chat_type, message_id, **kw):
adapter, stub = _wire(chat_id, chat_type, **kw)
src = SessionSource(
platform=Platform.SLACK, chat_id=chat_id, chat_type=chat_type,
user_id="U1", scope_id=kw.get("scope_id"),
)
ev = MessageEvent(
text="hi", source=src, message_type=MessageType.TEXT, message_id=message_id
)
adapter._capture_scope(ev)
return adapter, stub
@pytest.mark.asyncio
async def test_typing_synthesizes_thread_anchor_in_thread_mode():
"""Top-level DM turn, thread-per-message mode: the typing frame gains the
triggering ts as thread_id so the connector's setStatus targets the
per-message thread instead of no-oping threadless."""
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
await adapter.send_typing("D1", metadata=None)
typing = [f for f in stub.sent if f["op"] == "typing"]
assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"
@pytest.mark.asyncio
async def test_typing_flat_mode_status_anchors_to_trigger_ts_by_default():
"""Flat-DM liveliness: the STATUS still anchors to the triggering ts
(renders in the footer space, no message artifact) while replies stay
flat — the send lane strips its anchors, so placement cannot inherit this."""
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
adapter.config.extra = {"reply_in_thread": False}
await adapter.send_typing("D1", metadata=None)
typing = [f for f in stub.sent if f["op"] == "typing"]
assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"
@pytest.mark.asyncio
async def test_typing_anchors_unconditionally_in_both_modes():
"""Liveliness is not a preference: the status anchors whenever an inbound
ts exists, regardless of reply_in_thread. Placement safety comes from the
send-side anchor strip, not from suppressing the status."""
for extra in ({}, {"slack": {"reply_in_thread": False}}):
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
adapter.config.extra = extra
await adapter.send_typing("D1", metadata=None)
typing = [f for f in stub.sent if f["op"] == "typing"]
assert typing and typing[-1]["metadata"].get("thread_id") == "1700.0042"
@pytest.mark.asyncio
async def test_flat_mode_sends_stay_flat_with_status_anchor_active():
"""The liveliness anchor must NOT leak into reply placement: sends in
flat mode still strip the synthetic anchor (send-lane contract)."""
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
adapter.config.extra = {"reply_in_thread": False}
await adapter.send_typing("D1", metadata=None)
await adapter.send("D1", "the answer", reply_to="1700.0042")
frame = [f for f in stub.sent if f["op"] == "send"][-1]
assert frame["reply_to"] is None
assert "thread_id" not in (frame["metadata"] or {})
@pytest.mark.asyncio
async def test_typing_honours_real_thread_anchor():
"""Metadata that already names a thread wins over the synthetic cache."""
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
await adapter.send_typing("D1", metadata={"thread_id": "1699.9000"})
typing = [f for f in stub.sent if f["op"] == "typing"]
assert typing[-1]["metadata"]["thread_id"] == "1699.9000"
@pytest.mark.asyncio
async def test_stop_typing_clear_targets_same_synthesized_thread():
"""The clear frame targets the same synthesized thread as the heartbeat
(else the status line sticks)."""
adapter, stub = _wire_with_ts("D1", "dm", "1700.0042")
await adapter.send_typing("D1", metadata=None)
await adapter.stop_typing("D1", metadata=None)
clears = [
f for f in stub.sent if f["op"] == "typing" and f.get("content") == ""
]
assert clears and clears[-1]["metadata"].get("thread_id") == "1700.0042"
# ---------------------------------------------------------------------------
# Session keying: a top-level Slack DM message gets its own ts stamped as
# source.thread_id (native inbound parity) so each message keys a FRESH
# session in thread-per-message mode; flat mode and real threads untouched.
# ---------------------------------------------------------------------------
def _inbound_event(chat_id="D1", message_id="1700.0100", thread_id=None):
src = SessionSource(
platform=Platform.SLACK, chat_id=chat_id, chat_type="dm",
user_id="U1", scope_id="T1", thread_id=thread_id,
)
return MessageEvent(
text="hi", source=src, message_type=MessageType.TEXT,
message_id=message_id,
)
def test_top_level_dm_gets_session_thread_stamp():
adapter, _ = _wire("D1", "dm")
ev = _inbound_event(message_id="1700.0100")
adapter._stamp_slack_session_thread(ev)
assert ev.source.thread_id == "1700.0100"
def test_two_top_level_messages_key_distinct_sessions():
from gateway.session import build_session_key
adapter, _ = _wire("D1", "dm")
e1 = _inbound_event(message_id="1700.0100")
e2 = _inbound_event(message_id="1700.0200")
adapter._stamp_slack_session_thread(e1)
adapter._stamp_slack_session_thread(e2)
k1 = build_session_key(e1.source)
k2 = build_session_key(e2.source)
assert k1 != k2, "each top-level message must be its own session"
def test_real_thread_reply_keeps_its_thread_session():
adapter, _ = _wire("D1", "dm")
ev = _inbound_event(message_id="1700.0300", thread_id="1700.0100")
adapter._stamp_slack_session_thread(ev)
assert ev.source.thread_id == "1700.0100", (
"an in-thread reply must keep resolving to its thread's session"
)
def test_flat_mode_keeps_shared_dm_session():
adapter, _ = _wire("D1", "dm")
adapter.config.extra = {"reply_in_thread": False}
ev = _inbound_event(message_id="1700.0400")
adapter._stamp_slack_session_thread(ev)
assert ev.source.thread_id is None, (
"flat mode: shared rolling DM session (steer/queue) is intended UX"
)
def test_nested_relay_slack_config_subset_wins():
"""Enterprise knob shape: platforms.relay.extra.slack.reply_in_thread."""
adapter, _ = _wire("D1", "dm")
adapter.config.extra = {"slack": {"reply_in_thread": False}}
assert adapter._effective_reply_in_thread() is False
adapter.config.extra = {"slack": {"reply_in_thread": True}}
assert adapter._effective_reply_in_thread() is True
# Legacy flat key still honoured when no nested object exists.
adapter.config.extra = {"reply_in_thread": False}
assert adapter._effective_reply_in_thread() is False
# Default: thread-per-message.
adapter.config.extra = {}
assert adapter._effective_reply_in_thread() is True
# ---------------------------------------------------------------------------
# Cross-module boundary pin (review 2026-07-28): the adapter deliberately has
# NO prompt-side strip — flat-mode placement depends entirely on run.py's
# _resolve_progress_thread_id suppressing the synthetic self-anchor upstream.
# If that suppression regresses, prompt cards silently thread again. These
# tests pin the boundary in BOTH modes so the coupling is load-bearing.
# ---------------------------------------------------------------------------
def test_run_py_suppresses_self_anchor_in_flat_mode():
from gateway.run import _resolve_progress_thread_id
# Flat mode + synthetic self-anchor (thread_id == own message id) => None:
# prompt/progress metadata arrives at the adapter with NO thread anchor.
assert (
_resolve_progress_thread_id(
"slack", "1700.001", "1700.001", reply_in_thread=False
)
is None
)
# Flat mode + REAL thread (ids differ) => the real thread survives.
assert (
_resolve_progress_thread_id(
"slack", "1699.000", "1700.001", reply_in_thread=False
)
== "1699.000"
)
def test_run_py_keeps_self_anchor_in_thread_mode():
from gateway.run import _resolve_progress_thread_id
# Thread-per-message mode: the first-turn self-anchor IS the thread root
# and must flow through to the adapter unchanged.
assert (
_resolve_progress_thread_id(
"slack", "1700.001", "1700.001", reply_in_thread=True
)
== "1700.001"
)
# No source thread at all: Slack synthesizes the root from the message id.
assert (
_resolve_progress_thread_id("slack", None, "1700.001", reply_in_thread=True)
== "1700.001"
)
# ---------------------------------------------------------------------------
# Native parity escape hatch: platforms.relay.extra.slack.
# dm_top_level_threads_as_sessions=false keeps threaded replies but ONE
# rolling DM session (mirrors native SlackAdapter._dm_top_level_threads_as_sessions).
# Without the knob, reply_in_thread alone couples placement AND session
# keying — a posture native operators can express and relay ones could not.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_stamp_opt_out_keeps_rolling_dm_session():
adapter, stub = _wire("D1", "dm")
adapter.config.extra = {
"slack": {
"reply_in_thread": True,
"dm_top_level_threads_as_sessions": False,
}
}
event = _inbound_event("D1", message_id="1700.0001", thread_id=None)
adapter._stamp_slack_session_thread(event)
assert getattr(event.source, "thread_id", None) is None, (
"opt-out: top-level DM must NOT be stamped — one rolling session"
)
@pytest.mark.asyncio
async def test_session_stamp_default_remains_per_message():
adapter, stub = _wire("D1", "dm")
adapter.config.extra = {"slack": {"reply_in_thread": True}}
event = _inbound_event("D1", message_id="1700.0002", thread_id=None)
adapter._stamp_slack_session_thread(event)
assert getattr(event.source, "thread_id", None) == "1700.0002", (
"default (native parity): per-message sessions stay on"
)
@@ -0,0 +1,563 @@
"""Relay-side Slack unfurl suppression: gateway-directed metadata stamping.
The gateway resolves ``platforms.relay.extra.slack.unfurl_links`` /
``unfurl_media`` and stamps them onto the outbound frame metadata; the
connector just forwards whatever the gateway resolved (no connector config).
Contract under test:
- Slack chats: explicit booleans are stamped; omitted keys are absent.
- Non-Slack chats: never stamped (metadata not polluted cross-platform).
- Non-boolean values (hostile/hand-edited config) are dropped.
- The scheduled/cron lane (send_for_platform) stamps too.
"""
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
contract_version=CONTRACT_VERSION,
platform="slack",
label="Slack",
max_message_length=39000,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=True,
markdown_dialect="slack",
len_unit="char",
emoji="\U0001f4bc",
platform_hint="",
pii_safe=False,
)
base.update(kw)
return CapabilityDescriptor(**base)
class _CaptureTransport:
def __init__(self):
self.sent = None
self.sent_platform = None
# Advertise a slack identity so fronts_platform() passes for the
# send_for_platform (cron) lane.
self._identities = [("slack", None)]
def set_inbound_handler(self, h): # noqa: D401
self._h = h
async def send_outbound(self, action, *, platform=None):
self.sent = action
self.sent_platform = platform
return {"success": True, "message_id": "m1"}
def _slack_adapter(extra):
a = RelayAdapter(
PlatformConfig(extra=extra), make_desc(platform="slack"), transport=_CaptureTransport()
)
return a
def _mark_slack_chat(a, chat_id="chan-1"):
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
src = SessionSource(
platform=Platform.SLACK, chat_id=chat_id, chat_type="channel", scope_id="w-1"
)
ev = MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
a._capture_scope(ev)
class TestUnfurlHints:
def test_non_slack_returns_none(self):
a = _slack_adapter({"slack": {"unfurl_links": False}})
assert a._slack_unfurl_hints("discord") is None
assert a._slack_unfurl_hints(None) is None
def test_slack_explicit_bools_returned(self):
a = _slack_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}})
assert a._slack_unfurl_hints("slack") == {
"unfurl_links": False,
"unfurl_media": False,
}
def test_omitted_keys_return_none(self):
a = _slack_adapter({"slack": {}})
assert a._slack_unfurl_hints("slack") is None
def test_string_bools_from_config_set_are_coerced(self):
# Railway knobs / `hermes config set` persist YAML strings.
a = _slack_adapter({"slack": {"unfurl_links": "true", "unfurl_media": "false"}})
assert a._slack_unfurl_hints("slack") == {
"unfurl_links": True,
"unfurl_media": False,
}
def test_junk_values_dropped(self):
a = _slack_adapter({"slack": {"unfurl_links": "maybe", "unfurl_media": 0}})
assert a._slack_unfurl_hints("slack") is None
def test_flat_legacy_key_fallback(self):
# _relay_slack_extra falls back to the flat extra when no "slack"
# object exists (legacy staging configs).
a = _slack_adapter({"unfurl_links": False})
assert a._slack_unfurl_hints("slack") == {"unfurl_links": False}
class TestSendStampsUnfurl:
@pytest.mark.asyncio
async def test_send_stamps_explicit_bools(self):
a = _slack_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}})
_mark_slack_chat(a)
await a.send("chan-1", "see https://example.com")
assert a._transport.sent["metadata"]["unfurl_links"] is False
assert a._transport.sent["metadata"]["unfurl_media"] is False
@pytest.mark.asyncio
async def test_send_omits_when_unconfigured(self):
a = _slack_adapter({"slack": {}})
_mark_slack_chat(a)
await a.send("chan-1", "plain text")
assert "unfurl_links" not in a._transport.sent["metadata"]
assert "unfurl_media" not in a._transport.sent["metadata"]
@pytest.mark.asyncio
async def test_non_slack_chat_never_stamped(self):
a = _slack_adapter({"slack": {"unfurl_links": False}})
# A chat mapped to discord, not slack, must not carry the hint.
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
src = SessionSource(
platform=Platform.DISCORD,
chat_id="chan-1",
chat_type="channel",
scope_id="w-1",
)
a._capture_scope(
MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
)
await a.send("chan-1", "see https://example.com")
assert "unfurl_links" not in a._transport.sent["metadata"]
@pytest.mark.asyncio
async def test_send_falls_back_to_descriptor_platform(self):
"""No inbound frame yet (e.g. gateway restart): _platform_by_chat is
empty, so the platform must resolve from the negotiated descriptor —
the same fallback the streaming gate and delivery resolver use."""
a = _slack_adapter({"slack": {"unfurl_links": False}})
assert not a._platform_by_chat
await a.send("chan-1", "see https://example.com")
assert a._transport.sent["metadata"]["unfurl_links"] is False
class TestMediaLaneStampsUnfurl:
"""The send_media lane egresses through the connector's Slack sender too,
so it must stamp the same unfurl hints as the text lane."""
def _media_adapter(self, extra):
a = RelayAdapter(
PlatformConfig(extra=extra),
make_desc(platform="slack", supported_ops=("send", "send_media")),
transport=_CaptureTransport(),
)
return a
@pytest.mark.asyncio
async def test_media_lane_stamps_explicit_bools(self):
a = self._media_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}})
_mark_slack_chat(a)
res = await a.send_image("chan-1", "https://img.example/x.png", caption="cap")
assert res.success is True
assert a._transport.sent["op"] == "send_media"
assert a._transport.sent["metadata"]["unfurl_links"] is False
assert a._transport.sent["metadata"]["unfurl_media"] is False
@pytest.mark.asyncio
async def test_media_lane_falls_back_to_descriptor_platform(self):
"""Regression: _send_media resolved platform only from
_platform_by_chat; after a gateway restart a proactive media send to a
Slack chat missed the stamp. Must fall back to descriptor.platform."""
a = self._media_adapter({"slack": {"unfurl_links": False}})
assert not a._platform_by_chat
res = await a.send_image("chan-1", "https://img.example/x.png")
assert res.success is True
assert a._transport.sent["op"] == "send_media"
assert a._transport.sent["metadata"]["unfurl_links"] is False
@pytest.mark.asyncio
async def test_media_lane_omits_when_unconfigured(self):
a = self._media_adapter({"slack": {}})
_mark_slack_chat(a)
await a.send_image("chan-1", "https://img.example/x.png")
assert a._transport.sent["op"] == "send_media"
assert "unfurl_links" not in a._transport.sent["metadata"]
assert "unfurl_media" not in a._transport.sent["metadata"]
class TestSendForPlatformStampsUnfurl:
@pytest.mark.asyncio
async def test_cron_lane_stamps_explicit_bools(self):
a = _slack_adapter({"slack": {"unfurl_links": False, "unfurl_media": False}})
from gateway.config import Platform as P
res = await a.send_for_platform(P.SLACK, "C123", "brief https://x.dev")
assert res.success is True
assert a._transport.sent["metadata"]["unfurl_links"] is False
assert a._transport.sent["metadata"]["unfurl_media"] is False
@pytest.mark.asyncio
async def test_cron_lane_omits_when_unconfigured(self):
a = _slack_adapter({"slack": {}})
from gateway.config import Platform as P
await a.send_for_platform(P.SLACK, "C123", "brief")
assert "unfurl_links" not in a._transport.sent["metadata"]
assert "unfurl_media" not in a._transport.sent["metadata"]
class TestUnfurlDisablesDraftStreaming:
def test_explicit_unfurl_disables_slack_draft_stream(self):
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True}}),
make_desc(
platform="slack",
supports_draft_streaming=True,
supported_ops=("send", "draft"),
),
transport=_CaptureTransport(),
)
assert a.supports_draft_streaming() is False
def test_omitted_unfurl_keeps_slack_draft_stream(self):
a = RelayAdapter(
PlatformConfig(extra={"slack": {}}),
make_desc(
platform="slack",
supports_draft_streaming=True,
supported_ops=("send", "draft"),
),
transport=_CaptureTransport(),
)
assert a.supports_draft_streaming() is True
def test_string_true_also_disables_stream(self):
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": "true"}}),
make_desc(
platform="slack",
supports_draft_streaming=True,
supported_ops=("send", "draft"),
),
transport=_CaptureTransport(),
)
assert a.supports_draft_streaming() is False
class TestFreshFinalForForceOnUnfurl:
"""Slack evaluates unfurls ONLY at chat.postMessage (live-probed
2026-08-28: an edit that introduces a URL never previews, stamped or
not). Force-on hints must therefore route streamed finals through the
consumer's fresh-final path; false-only hints must NOT (suppression
rides the placeholder post and inherits through edits)."""
def _adapter(self, slack_extra):
return RelayAdapter(
PlatformConfig(extra={"slack": slack_extra}),
make_desc(
platform="slack",
supports_draft_streaming=True,
supported_ops=("send", "draft"),
),
transport=_CaptureTransport(),
)
def test_force_on_with_link_prefers_fresh_final(self):
a = self._adapter({"unfurl_links": True, "unfurl_media": True})
assert (
a.prefers_fresh_final_streaming("see https://studiotwin.ai") is True
)
def test_force_on_string_true_prefers_fresh_final(self):
# hermes config set / Railway knobs persist YAML strings.
a = self._adapter({"unfurl_links": "true"})
assert a.prefers_fresh_final_streaming("see https://x.dev") is True
def test_force_on_mrkdwn_link_prefers_fresh_final(self):
a = self._adapter({"unfurl_links": True})
assert (
a.prefers_fresh_final_streaming("see <https://x.dev|x.dev>") is True
)
def test_force_on_without_link_keeps_edit_lane(self):
# No URL => nothing to unfurl; a fresh final would only duplicate
# the preview (relay contract v1 has no delete op).
a = self._adapter({"unfurl_links": True})
assert a.prefers_fresh_final_streaming("plain text answer") is False
def test_false_only_hints_keep_edit_lane(self):
# Enterprise fail-closed posture: suppression is decided at the
# placeholder post; the edit lane preserves it. No UX change.
a = self._adapter({"unfurl_links": False, "unfurl_media": False})
assert (
a.prefers_fresh_final_streaming("see https://studiotwin.ai") is False
)
def test_unconfigured_keeps_edit_lane(self):
a = self._adapter({})
assert (
a.prefers_fresh_final_streaming("see https://studiotwin.ai") is False
)
def test_non_slack_platform_keeps_edit_lane(self):
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True}}),
make_desc(
platform="telegram",
supports_draft_streaming=True,
supported_ops=("send", "draft"),
),
transport=_CaptureTransport(),
)
assert (
a.prefers_fresh_final_streaming("see https://studiotwin.ai") is False
)
class _RecordingTransport(_CaptureTransport):
"""Capture EVERY outbound action in order, not just the last."""
def __init__(self):
super().__init__()
self.actions = []
async def send_outbound(self, action, *, platform=None):
self.actions.append(action)
self.sent = action
self.sent_platform = platform
return {"success": True, "message_id": f"m{len(self.actions)}"}
class TestConsumerRoutesForceOnFinalAsFreshSend:
"""End-to-end consumer contract (the lane the live regression hid in):
an edit-streamed turn whose URL only exists in the FINAL text must
finalize via a fresh `send` op carrying the unfurl stamps — not via an
`edit` op, which Slack never re-evaluates for previews."""
@pytest.mark.asyncio
async def test_url_arriving_late_finalizes_as_stamped_fresh_send(self):
from gateway.stream_consumer import (
GatewayStreamConsumer,
StreamConsumerConfig,
)
transport = _RecordingTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True, "unfurl_media": True}}),
make_desc(platform="slack", supports_edit=True),
transport=transport,
)
consumer = GatewayStreamConsumer(
adapter=a,
chat_id="D1",
config=StreamConsumerConfig(),
)
# Frame 1: placeholder posts WITHOUT any URL (the task-card /
# early-frame shape from the live regression).
await consumer._send_or_edit("Working on it…")
# Final: the URL exists only now.
await consumer._send_or_edit(
"see https://studiotwin.ai", finalize=True
)
ops = [x.get("op") for x in transport.actions]
# Placeholder went out as a send; the FINAL must be a fresh send
# too (not an edit) so Slack evaluates the preview with the URL
# present.
assert ops[0] == "send"
assert ops[-1] == "send", f"final left as {ops[-1]!r}; ops={ops}"
final = transport.actions[-1]
assert final["content"] == "see https://studiotwin.ai"
assert final["metadata"]["unfurl_links"] is True
assert final["metadata"]["unfurl_media"] is True
@pytest.mark.asyncio
async def test_false_only_hints_finalize_via_edit_unchanged(self):
from gateway.stream_consumer import (
GatewayStreamConsumer,
StreamConsumerConfig,
)
transport = _RecordingTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": False, "unfurl_media": False}}),
make_desc(platform="slack", supports_edit=True),
transport=transport,
)
consumer = GatewayStreamConsumer(
adapter=a,
chat_id="D1",
config=StreamConsumerConfig(),
)
await consumer._send_or_edit("Working on it…")
await consumer._send_or_edit("see https://studiotwin.ai", finalize=True)
ops = [x.get("op") for x in transport.actions]
# Fail-closed posture keeps the edit lane: placeholder send (which
# carries the false stamps at post time) + finalize edit.
assert ops[0] == "send"
assert transport.actions[0]["metadata"]["unfurl_links"] is False
assert ops[-1] == "edit", f"ops={ops}"
class TestMultiplexPerChatFreshFinalSeam:
"""The consumer must resolve the fresh-final decision through the CHAT's
negotiated platform, not the relay's primary identity (the scalar-vs-
per-chat descriptor seam). Two failure directions:
- Slack PRIMARY + force-on unfurl: a fronted TELEGRAM chat must keep the
edit lane — its descriptor advertises no ``delete`` op, so a fresh
final would deliver the answer twice (orphaned preview).
- Non-Slack primary: a fronted SLACK chat must still get the fresh
stamped final, or the shipped feature is dark on exactly the chats it
was built for.
"""
class _MultiplexTransport(_RecordingTransport):
def __init__(self):
super().__init__()
self.descriptors = {
"telegram": make_desc(
platform="telegram",
supports_edit=True,
supported_ops=("send", "edit", "typing"),
),
"slack": make_desc(
platform="slack",
supports_edit=True,
supported_ops=("send", "edit", "typing", "delete"),
),
}
def descriptor_for_platform(self, platform):
return self.descriptors.get(platform)
def _consumer(self, adapter, chat_id):
from gateway.stream_consumer import (
GatewayStreamConsumer,
StreamConsumerConfig,
)
return GatewayStreamConsumer(
adapter=adapter, chat_id=chat_id, config=StreamConsumerConfig(),
)
@pytest.mark.asyncio
async def test_telegram_chat_on_slack_primary_keeps_edit_lane(self):
transport = self._MultiplexTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True}}),
make_desc(platform="slack", supports_edit=True), # Slack PRIMARY
transport=transport,
)
# Relay learned this chat is Telegram from inbound traffic.
a._platform_by_chat["TG1"] = "telegram"
consumer = self._consumer(a, "TG1")
await consumer._send_or_edit("Working on it…")
await consumer._send_or_edit("see https://studiotwin.ai", finalize=True)
ops = [x.get("op") for x in transport.actions]
assert ops[-1] == "edit", (
f"telegram chat misrouted through fresh-final: ops={ops}"
)
@pytest.mark.asyncio
async def test_slack_chat_on_telegram_primary_gets_fresh_final(self):
transport = self._MultiplexTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True}}),
make_desc(platform="telegram", supports_edit=True), # non-Slack primary
transport=transport,
)
a._platform_by_chat["D1"] = "slack"
consumer = self._consumer(a, "D1")
await consumer._send_or_edit("Working on it…")
await consumer._send_or_edit("see https://studiotwin.ai", finalize=True)
ops = [x.get("op") for x in transport.actions]
# Fresh stamped final followed by cleanup of the sealed preview
# (Slack's descriptor advertises delete) — no duplicate.
assert "delete" in ops, f"preview not cleaned up: ops={ops}"
sends = [x for x in transport.actions if x.get("op") == "send"]
assert len(sends) == 2, f"slack chat on non-slack primary left dark: ops={ops}"
final = sends[-1]
assert final["content"] == "see https://studiotwin.ai"
assert final["metadata"]["unfurl_links"] is True
class TestDeleteOpForFreshFinalCleanup:
"""Relay delete_message: emitted only when the negotiated descriptor
advertises the additive `delete` op; older connectors degrade to the
leave-the-preview-behind behavior (return False, no wire traffic)."""
@pytest.mark.asyncio
async def test_delete_emitted_when_advertised(self):
transport = _RecordingTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True}}),
make_desc(
platform="slack",
supported_ops=("send", "edit", "delete"),
),
transport=transport,
)
ok = await a.delete_message("D1", "1700000000.000200")
assert ok is True
assert transport.actions[-1]["op"] == "delete"
assert transport.actions[-1]["message_id"] == "1700000000.000200"
@pytest.mark.asyncio
async def test_delete_refused_when_not_advertised(self):
transport = _RecordingTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True}}),
make_desc(platform="slack", supported_ops=("send", "edit")),
transport=transport,
)
ok = await a.delete_message("D1", "1700000000.000200")
assert ok is False
assert transport.actions == [] # no wire traffic for old connectors
@pytest.mark.asyncio
async def test_consumer_fresh_final_deletes_preview_when_supported(self):
from gateway.stream_consumer import (
GatewayStreamConsumer,
StreamConsumerConfig,
)
transport = _RecordingTransport()
a = RelayAdapter(
PlatformConfig(extra={"slack": {"unfurl_links": True, "unfurl_media": True}}),
make_desc(
platform="slack",
supports_edit=True,
supported_ops=("send", "edit", "delete"),
),
transport=transport,
)
consumer = GatewayStreamConsumer(
adapter=a, chat_id="D1", config=StreamConsumerConfig()
)
await consumer._send_or_edit("Working on it…")
await consumer._send_or_edit("see https://studiotwin.ai", finalize=True)
ops = [x.get("op") for x in transport.actions]
# send (placeholder) ... send (fresh final) ... delete (preview)
assert ops[-1] == "delete", f"ops={ops}"
deleted = transport.actions[-1]["message_id"]
# The deleted message must be the FIRST send's id (m1), not the final.
assert deleted == "m1"
@@ -0,0 +1,47 @@
"""Regression: draft/seal coordination dicts are bounded (PR 85796
review, M1).
_sealed_draft_by_chat's key embeds a per-turn identity, so every
completed turn added a permanent entry — unbounded growth for the life
of a long-running gateway process. _open_draft_by_chat could grow the
same way via abandoned entries. Both now FIFO-evict at the same cap the
sibling bounded caches use (and the connector's own tombstone store).
"""
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
class OkTransport:
async def send_outbound(self, payload, platform=None):
return {"success": True, "message_id": "ts.1"}
@pytest.mark.asyncio
async def test_sealed_tombstones_are_bounded():
adapter, _ = _connected_adapter()
adapter._transport = OkTransport()
cap = adapter._DRAFT_STATE_CAP
for n in range(cap + 300):
md = {"message_id": f"evt.{n}"}
await adapter.send_draft("C1", 1000 + n, "x", metadata=md)
await adapter.send("C1", "final", metadata=dict(md))
assert len(adapter._sealed_draft_by_chat) <= cap
assert len(adapter._open_draft_by_chat) <= cap
# Newest tombstone survives (FIFO evicts oldest first).
newest_key = adapter._draft_key("C1", {"message_id": f"evt.{cap + 299}"})
assert newest_key in adapter._sealed_draft_by_chat
@pytest.mark.asyncio
async def test_open_drafts_are_bounded():
adapter, _ = _connected_adapter()
adapter._transport = OkTransport()
cap = adapter._DRAFT_STATE_CAP
# Arm many streams without ever sealing (abandoned-turn shape).
for n in range(cap + 300):
await adapter.send_draft(
"C1", 2000 + n, "x", metadata={"message_id": f"evt.{n}"}
)
assert len(adapter._open_draft_by_chat) <= cap
@@ -0,0 +1,84 @@
"""Regression: stream-is-the-message is a SLACK semantic, not a relay
semantic (PR 85796 review, B4).
The base send_draft contract is Telegram-shaped: the draft animates and
clears client-side, and the final answer arrives as a separate REAL send
that becomes the history message. Slack native streaming inverts this —
the stream IS the message and the turn-final seals it in place.
The relay adapter hardcoded draft_stream_is_message = True for every
descriptor, so a Telegram (or any non-Slack) connector advertising the
draft op had its turn-final intercepted into draft(final=true): no real
message was ever posted to the chat history. Live-probed on the review
branch (`platform telegram … ops [draft(final=False), draft(final=True)]`,
no send op).
Now the flag is gated on the negotiated descriptor platform. A future
platform with genuine stream-is-the-message semantics should advertise it
via the descriptor rather than widening the gate by guesswork.
"""
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
class RecordingTransport:
def __init__(self):
self.ops = []
async def send_outbound(self, payload, platform=None):
self.ops.append(dict(payload))
return {"success": True, "message_id": "m.1"}
class TestStreamIsMessageGating:
def test_slack_descriptor_gets_stream_is_message(self):
adapter, _ = _connected_adapter() # platform="slack" default
assert adapter.draft_stream_is_message is True
def test_telegram_descriptor_does_not(self):
adapter, _ = _connected_adapter(
platform="telegram",
markdown_dialect="markdown_v2",
supported_ops=("send", "edit", "typing", "draft"),
)
assert adapter.draft_stream_is_message is False
@pytest.mark.asyncio
async def test_telegram_final_is_a_real_send_not_a_seal(self):
"""The B4 probe: telegram + draft op → frames go out as drafts,
the final goes out as a REAL send (history message), never as
draft(final=true)."""
adapter, _ = _connected_adapter(
platform="telegram",
markdown_dialect="markdown_v2",
supported_ops=("send", "edit", "typing", "draft"),
)
t = RecordingTransport()
adapter._transport = t
md = {"reply_to_message_id": "evt.1"}
await adapter.send_draft("T1", 3, "partial", metadata=md)
r = await adapter.send("T1", "complete answer", metadata=dict(md))
assert r.success
ops = [(o["op"], o.get("final")) for o in t.ops]
assert ("draft", False) in ops
assert ("send", None) in ops, ops
assert not [o for o in t.ops if o["op"] == "draft" and o.get("final")], (
"telegram-shaped connector must never receive draft(final=true) "
"as the turn-final — the draft clears client-side and the real "
"send is the history message"
)
@pytest.mark.asyncio
async def test_slack_final_still_seals(self):
"""Sibling guard: the gate must not have broken the Slack lane."""
adapter, _ = _connected_adapter()
t = RecordingTransport()
adapter._transport = t
md = {"reply_to_message_id": "evt.2"}
await adapter.send_draft("C1", 4, "partial", metadata=md)
r = await adapter.send("C1", "complete answer", metadata=dict(md))
assert r.success
assert [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert not [o for o in t.ops if o["op"] == "send"]
@@ -0,0 +1,47 @@
"""Regression: task-card transport failures degrade, never raise
(PR 85796 review, B7).
send_native_task_card_progress / stop_native_task_card_progress let
transport exceptions escape. The stop runs in the progress loop's
finally block on the turn-cleanup path, and the cleanup awaits caught
only CancelledError — a socket drop during card publish/stop therefore
aborted cleanup BEFORE the final-delivery bookkeeping ran.
"""
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
class ExplodingTransport:
async def send_outbound(self, payload, platform=None):
raise ConnectionError("socket dropped")
def _adapter():
adapter, _ = _connected_adapter()
adapter._transport = ExplodingTransport()
return adapter
TASKS = [{"id": "t1", "title": "terminal", "status": "in_progress"}]
class TestTaskCardTransportFailure:
@pytest.mark.asyncio
async def test_progress_returns_failed_result(self):
adapter = _adapter()
result = await adapter.send_native_task_card_progress(
"C1", TASKS, reply_to="1700.1"
)
assert result.success is False
assert "transport error" in (result.error or "")
@pytest.mark.asyncio
async def test_stop_returns_failed_result(self):
adapter = _adapter()
result = await adapter.stop_native_task_card_progress(
"C1", reply_to="1700.1"
)
assert result.success is False
assert "transport error" in (result.error or "")
+560
View File
@@ -0,0 +1,560 @@
"""Relay Phase 4 tests — thread lifecycle ops, reply_to enrichment parse,
auto-thread markers, and the hello command manifest.
Covers:
- create_handoff_thread routes through `thread_create` (op-gated; None
fallback contract preserved for the handoff watcher);
- rename_thread routes through `thread_rename` with the
only_if_current_name guard on the wire (op-gated; False on decline);
- the relay semantic-rename lane parity: a relay source carrying the
connector-stamped auto-thread markers satisfies the same field contract
the native _is_discord_auto_thread_lane reads;
- _event_from_wire maps reply_to {text,author,is_own} onto the native
MessageEvent reply-context fields and the auto-thread markers onto
SessionSource;
- the ws transport sends command_manifest on the DISCORD hello only;
- the manifest builder satisfies Discord CHAT_INPUT naming rules.
"""
from __future__ import annotations
import re
from typing import Any, Dict
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.relay.adapter import RelayAdapter
from gateway.relay.command_manifest import build_relay_command_manifest
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
from gateway.relay.ws_transport import _event_from_wire
from tests.gateway.relay.stub_connector import StubConnector
FULL_OPS = (
"send",
"edit",
"typing",
"get_chat_info",
"thread_create",
"thread_rename",
)
def make_desc(**kw) -> CapabilityDescriptor:
base = dict(
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",
supported_ops=FULL_OPS,
)
base.update(kw)
return CapabilityDescriptor(**base)
def _adapter(**desc_kw) -> tuple[RelayAdapter, StubConnector]:
stub = StubConnector(make_desc(**desc_kw))
adapter = RelayAdapter(PlatformConfig(), make_desc(**desc_kw), transport=stub)
return adapter, stub
# ── thread_create (handoff) ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_handoff_thread_routes_thread_create():
adapter, stub = _adapter()
stub.next_send_result = {"success": True} # unused; thread op has own arm
async def send_outbound(action, *, platform=None):
stub.sent.append(action)
stub.sent_platforms.append(platform)
return {"success": True, "thread_id": "th77"}
stub.send_outbound = send_outbound # type: ignore[method-assign]
thread_id = await adapter.create_handoff_thread("chan1", "fix the build")
assert thread_id == "th77"
action = stub.sent[-1]
assert action["op"] == "thread_create"
assert action["chat_id"] == "chan1"
assert action["thread_name"] == "fix the build"
# ── rename_thread (semantic rename) ──────────────────────────────────────
@pytest.mark.asyncio
async def test_rename_thread_carries_the_no_clobber_guard():
adapter, stub = _adapter()
ok = await adapter.rename_thread(
"th1", "Fix the build", only_if_current_name="Hermes"
)
assert ok is True
action = stub.sent[-1]
assert action["op"] == "thread_rename"
assert action["message_id"] == "th1"
assert action["thread_name"] == "Fix the build"
assert action["only_if_current_name"] == "Hermes"
# chat_id defaults to the thread id (Discord ignores it; Telegram callers
# pass parent_chat_id explicitly).
assert action["chat_id"] == "th1"
@pytest.mark.asyncio
async def test_rename_thread_parent_chat_and_gating():
adapter, stub = _adapter()
await adapter.rename_thread("42", "topic", parent_chat_id="-100999")
assert stub.sent[-1]["chat_id"] == "-100999"
assert "only_if_current_name" not in stub.sent[-1]
gated, gated_stub = _adapter(supported_ops=("send",))
assert await gated.rename_thread("42", "x") is False
assert gated_stub.sent == []
@pytest.mark.asyncio
async def test_rename_thread_prefers_connector_owned_guard():
"""The relay lane sends only_if_connector_created (connector resolves the
no-clobber guard from its own created-name memory) instead of the fragile
cross-repo only_if_current_name string."""
adapter, stub = _adapter()
ok = await adapter.rename_thread(
"th9", "Real Session Title", prefer_connector_created=True
)
assert ok is True
action = stub.sent[-1]
assert action["op"] == "thread_rename"
assert action["only_if_connector_created"] is True
# The fragile string guard is NOT sent when the connector owns the check.
assert "only_if_current_name" not in action
@pytest.mark.asyncio
async def test_rename_thread_connector_guard_takes_precedence_over_string():
"""prefer_connector_created wins even if a legacy string is also passed."""
adapter, stub = _adapter()
await adapter.rename_thread(
"th9",
"Title",
prefer_connector_created=True,
only_if_current_name="ignored initial words",
)
action = stub.sent[-1]
assert action["only_if_connector_created"] is True
assert "only_if_current_name" not in action
@pytest.mark.asyncio
async def test_rename_thread_resolves_scope_from_parent_chat_not_thread():
"""The connector's egress guard resolves the owning tenant from the
outbound metadata's scope_id / user_id, and the adapter's discriminator
caches are keyed by the PARENT channel chat_id (learned at inbound), never
the thread id. A rename that passes parent_chat_id must carry that
discriminator; a rename keyed only on the thread id must not — reproducing
the live decline ("target not routed to an onboarded tenant") and its fix.
"""
adapter, stub = _adapter()
# Simulate the inbound-learned scope for the PARENT channel only.
adapter._scope_by_chat["chan-parent"] = "guild-123"
# Fix: pass the parent chat id -> scope_id resolves.
await adapter.rename_thread(
"th-9",
"Real Title",
prefer_connector_created=True,
parent_chat_id="chan-parent",
)
fixed = stub.sent[-1]
assert fixed["metadata"].get("scope_id") == "guild-123"
# Regression shape: keyed on the thread id alone (no parent) -> no scope_id,
# which is exactly what made the connector decline the op.
await adapter.rename_thread(
"th-9",
"Real Title",
prefer_connector_created=True,
)
unscoped = stub.sent[-1]
assert "scope_id" not in unscoped["metadata"]
# ── the relay semantic-rename lane (marker parity) ───────────────────────
# ── reply_to wire parse ──────────────────────────────────────────────────
def test_event_from_wire_reply_to_absent_and_partial():
plain = _event_from_wire(
{
"text": "hi",
"message_type": "text",
"source": {"platform": "telegram", "chat_id": "5", "chat_type": "dm"},
}
)
assert plain.reply_to_text is None
assert plain.reply_to_is_own_message is False
partial = _event_from_wire(
{
"text": "re",
"message_type": "text",
"source": {"platform": "whatsapp", "chat_id": "1", "chat_type": "dm"},
"reply_to_message_id": "wamid.x",
"reply_to": {"author": "Alice"}, # text leg missed the cache
}
)
assert partial.reply_to_author_name == "Alice"
assert partial.reply_to_text is None
# ── hello command manifest ───────────────────────────────────────────────
# ── auto-thread routing feedback (send-result thread_id) ─────────────────
@pytest.mark.asyncio
async def test_send_captures_auto_thread_feedback():
"""A send result carrying thread_id + auto_thread_name (the connector's
auto-thread egress policy routed the reply into a thread it created)
populates auto_thread_info_for_chat for the semantic-rename lane."""
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
stub.sent.append(action)
return {
"success": True,
"message_id": "m1",
"thread_id": "th-auto-1",
"auto_thread_name": "What is a duck",
}
stub.send_outbound = send_outbound # type: ignore[method-assign]
result = await adapter.send("chan1", "quack")
assert result.success
assert adapter.auto_thread_info_for_chat("chan1") == (
"th-auto-1",
"What is a duck",
)
# Plain results (no auto-thread) leave no feedback for other chats.
assert adapter.auto_thread_info_for_chat("chan-other") is None
@pytest.mark.asyncio
async def test_send_without_thread_feedback_leaves_no_info():
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {"success": True, "message_id": "m2"}
stub.send_outbound = send_outbound # type: ignore[method-assign]
await adapter.send("chan2", "hello")
assert adapter.auto_thread_info_for_chat("chan2") is None
@pytest.mark.asyncio
async def test_waiting_for_auto_thread_feedback_outlasts_the_turn():
"""The rename lane asks where the reply landed before the reply exists.
Titling reads the user's opening message, so the question arrives one whole
turn early — and a turn is however long the agent takes. Waiting on the send
rather than on a fixed nap is what makes the answer arrive at all.
"""
import asyncio
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {
"success": True,
"message_id": "m1",
"thread_id": "th-auto-1",
"auto_thread_name": "What is a duck",
}
stub.send_outbound = send_outbound # type: ignore[method-assign]
waiting = asyncio.ensure_future(adapter.wait_for_auto_thread_info("chan1", 10.0))
await asyncio.sleep(0)
assert not waiting.done()
await adapter.send("chan1", "quack")
assert await waiting == ("th-auto-1", "What is a duck")
@pytest.mark.asyncio
async def test_a_reply_that_was_not_auto_threaded_reports_its_miss_on_send():
"""A miss is an answer, and the send is when we have it.
Only a turn that never sends at all should reach the timeout, so a policy
that doesn't auto-thread costs a wait as long as the turn, not as long as
the backstop.
"""
import asyncio
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {"success": True, "message_id": "m1"}
stub.send_outbound = send_outbound # type: ignore[method-assign]
waiting = asyncio.ensure_future(adapter.wait_for_auto_thread_info("chan1", 600.0))
await asyncio.sleep(0)
await adapter.send("chan1", "quack")
assert await waiting is None
@pytest.mark.asyncio
async def test_waiting_for_auto_thread_feedback_gives_up():
"""A turn that never sends anything still has to end."""
adapter, _stub = _adapter()
assert await adapter.wait_for_auto_thread_info("chan-quiet", 0.05) is None
@pytest.mark.asyncio
async def test_auto_thread_feedback_is_bounded():
adapter, stub = _adapter()
async def send_outbound(action, *, platform=None):
return {
"success": True,
"message_id": "m",
"thread_id": f"th-{action['chat_id']}",
"auto_thread_name": "n",
}
stub.send_outbound = send_outbound # type: ignore[method-assign]
for i in range(300):
await adapter.send(f"c{i}", "x")
assert len(adapter._auto_thread_by_chat) <= 256
# Newest entries survive the bound.
assert adapter.auto_thread_info_for_chat("c299") == ("th-c299", "n")
# ── title-turn rename: registration shape-gate + fire-time cache poll ────
def _mk_runner_stub():
"""Minimal object carrying the three GatewayRunner methods under test."""
import asyncio as _asyncio
from gateway.run import GatewayRunner
class _Stub:
_is_relay_discord_channel_lane = GatewayRunner._is_relay_discord_channel_lane
_relay_auto_thread_info = GatewayRunner._relay_auto_thread_info
_await_relay_auto_thread_info = GatewayRunner._await_relay_auto_thread_info
_is_discord_auto_thread_lane = GatewayRunner._is_discord_auto_thread_lane
_sanitize_discord_thread_title = GatewayRunner._sanitize_discord_thread_title
_rename_discord_auto_thread_for_session_title = (
GatewayRunner._rename_discord_auto_thread_for_session_title
)
def __init__(self, adapter):
self.adapters = {Platform.RELAY: adapter}
def _adapter_for_source(self, source):
return self.adapters.get(Platform.RELAY)
return _Stub
def _relay_channel_source():
from types import SimpleNamespace
return SimpleNamespace(
platform=Platform.DISCORD,
chat_id="chan-parent",
chat_type="group",
thread_id=None,
delivered_via_upstream_relay=True,
auto_thread_created=False,
auto_thread_initial_name=None,
)
def test_relay_channel_lane_shape_gate():
from types import SimpleNamespace
from gateway.config import Platform as P
stub = _mk_runner_stub()(adapter=None)
src = _relay_channel_source()
assert stub._is_relay_discord_channel_lane(src) is True
# thread events, DMs, and native (non-relay) events do not match
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "thread_id": "t1"})
)
is False
)
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "chat_type": "dm"})
)
is False
)
assert (
stub._is_relay_discord_channel_lane(
SimpleNamespace(**{**src.__dict__, "delivered_via_upstream_relay": False})
)
is False
)
@pytest.mark.asyncio
async def test_relay_auto_thread_info_prefers_prospective_thread_id():
"""When the connector stamps prospective_thread_id, the rename lane uses it
directly (deterministic, per-thread) and does NOT consult the per-chat
send-result cache — the empty initial-name marker defers no-clobber to the
connector's own created-name guard."""
from types import SimpleNamespace
adapter, _ = _adapter()
# Poison the per-chat cache with a DIFFERENT (stale sibling) thread to prove
# the prospective id wins and the cache is not read.
adapter._auto_thread_by_chat["chan-parent"] = ("th-STALE", "old words")
runner = _mk_runner_stub()(adapter)
src = SimpleNamespace(
**{**_relay_channel_source().__dict__, "prospective_thread_id": "th-B"}
)
assert runner._relay_auto_thread_info(src) == ("th-B", "")
@pytest.mark.asyncio
async def test_sibling_threads_in_one_channel_each_rename_to_own_thread():
"""Two auto-threads spawned from the SAME parent channel must each rename
to their OWN thread id. Before the prospective_thread_id fix the per-chat
cache held one slot, so only the first thread renamed (staging repro
2026-08-02: thread A renamed, sibling thread B stuck at raw text)."""
from types import SimpleNamespace
adapter, _ = _adapter()
renames: list = []
async def rename_thread(
thread_id,
name,
*,
only_if_current_name=None,
prefer_connector_created=False,
parent_chat_id=None,
):
renames.append((thread_id, name, prefer_connector_created, parent_chat_id))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
base = _relay_channel_source().__dict__
# A and B share the parent channel but carry distinct prospective thread ids.
src_a = SimpleNamespace(**{**base, "prospective_thread_id": "th-A"})
src_b = SimpleNamespace(**{**base, "prospective_thread_id": "th-B"})
await runner._rename_discord_auto_thread_for_session_title(
src_a, "sessA", "Sea Shanty Draft"
)
await runner._rename_discord_auto_thread_for_session_title(
src_b, "sessB", "Exotic Short Story"
)
# Each renamed ITS OWN thread, via the connector-owned guard, passing the
# parent channel id for tenant discriminator resolution.
assert renames == [
("th-A", "Sea Shanty Draft", True, "chan-parent"),
("th-B", "Exotic Short Story", True, "chan-parent"),
]
@pytest.mark.asyncio
async def test_title_rename_waits_for_feedback_that_arrives_late():
"""The title beats delivery by a whole turn, and the lane still renames.
Feedback only exists once the reply is sent, so the lane waits on the send
rather than on a nap long enough to cover a turn it can't measure.
"""
import asyncio
adapter, stub_conn = _adapter()
renames: list = []
async def rename_thread(
thread_id,
name,
*,
only_if_current_name=None,
prefer_connector_created=False,
parent_chat_id=None,
):
renames.append((thread_id, name, prefer_connector_created, parent_chat_id))
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
src = _relay_channel_source()
async def send_outbound(action, *, platform=None):
return {
"success": True,
"message_id": "m1",
"thread_id": "th-9",
"auto_thread_name": "Initial words",
}
stub_conn.send_outbound = send_outbound # type: ignore[method-assign]
async def deliver_late():
await asyncio.sleep(0.05)
await adapter.send("chan-parent", "the reply")
task = asyncio.create_task(deliver_late())
await runner._rename_discord_auto_thread_for_session_title(
src, "sess1", "Debugging the flux capacitor"
)
await task
# Relay lane uses the connector-owned guard (prefer_connector_created=True),
# not the fragile cross-repo initial-name string. It MUST pass the PARENT
# channel chat_id so the connector's egress guard can resolve the tenant
# (the discriminator caches are keyed by the parent channel, not the thread;
# omitting it made the connector decline "target not routed to an onboarded
# tenant" — the live failure on staging 2026-08-01).
assert renames == [
("th-9", "Debugging the flux capacitor", True, "chan-parent")
]
@pytest.mark.asyncio
async def test_title_rename_true_miss_noops():
"""The connector didn't auto-thread this reply, so there is nothing to rename."""
import asyncio
adapter, stub_conn = _adapter()
renames: list = []
async def rename_thread(thread_id, name, **kw):
renames.append(thread_id)
return True
adapter.rename_thread = rename_thread # type: ignore[method-assign]
runner = _mk_runner_stub()(adapter)
src = _relay_channel_source()
async def send_outbound(action, *, platform=None):
return {"success": True, "message_id": "m1"}
stub_conn.send_outbound = send_outbound # type: ignore[method-assign]
async def deliver():
await asyncio.sleep(0.05)
await adapter.send("chan-parent", "the reply")
task = asyncio.create_task(deliver())
await runner._rename_discord_auto_thread_for_session_title(
src, "sess1", "A title"
)
await task
assert renames == []
@@ -0,0 +1,194 @@
"""Regression: per-TURN stream/card identity (PR 85796 review, B2).
Keying coordination state on the thread anchor alone is simultaneously:
- too coarse: two parallel turns replying INSIDE ONE Slack thread share
thread_ts — turn A's final sealed turn B's stream with A's content
while A's own stream stayed open (live probe on the review branch);
- too fragile: a flat DM with no thread metadata degraded to the bare
chat id, re-creating the original finding-#10 collision.
The key now prefers the triggering inbound message id (message_id /
reply_to_message_id — per-turn by construction), falling back to the
thread anchor, then the bare chat. Legacy callers with placement-only
metadata still seal via _match_open_draft's single-open-stream fallback,
but NEVER when multiple streams are open (a duplicate message is
recoverable; sealing someone else's stream is not).
"""
import asyncio
import pytest
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
class RecordingTransport:
def __init__(self):
self.ops = []
self._n = 0
async def send_outbound(self, payload, platform=None):
self.ops.append(dict(payload))
self._n += 1
return {"success": True, "message_id": f"ts.{self._n}"}
def _adapter():
adapter, _ = _connected_adapter()
t = RecordingTransport()
adapter._transport = t
return adapter, t
class TestSameThreadParallelTurns:
@pytest.mark.asyncio
async def test_two_turns_in_one_thread_do_not_collide(self):
"""The exact B2 probe: same thread_ts, distinct triggering message
ids -> each turn seals its OWN stream with its OWN content."""
adapter, t = _adapter()
md_a = {"thread_ts": "1700.100", "message_id": "1700.111"}
md_b = {"thread_ts": "1700.100", "message_id": "1700.222"}
await adapter.send_draft("C1", 11, "A partial", metadata=md_a)
await adapter.send_draft("C1", 12, "B partial", metadata=md_b)
ra = await adapter.send("C1", "A final", metadata=dict(md_a))
rb = await adapter.send("C1", "B final", metadata=dict(md_b))
assert ra.success and rb.success
seals = [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert {(s["draft_id"], s["content"]) for s in seals} == {
(11, "A final"),
(12, "B final"),
}, seals
assert not [o for o in t.ops if o["op"] == "send"]
@pytest.mark.asyncio
async def test_card_ids_distinct_per_turn_in_one_thread(self):
adapter, t = _adapter()
md_a = {"thread_ts": "1700.100", "message_id": "1700.111"}
md_b = {"thread_ts": "1700.100", "message_id": "1700.222"}
tasks = [{"id": "t1", "title": "x", "status": "in_progress"}]
await adapter.send_native_task_card_progress(
"C1", tasks, reply_to=None, metadata=md_a
)
await adapter.send_native_task_card_progress(
"C1", tasks, reply_to=None, metadata=md_b
)
cards = [o for o in t.ops if o["op"] == "task_card"]
assert len({c["card_id"] for c in cards}) == 2, cards
@pytest.mark.asyncio
async def test_card_stop_uses_same_key_as_send(self):
adapter, t = _adapter()
md = {"thread_ts": "1700.100", "message_id": "1700.111"}
tasks = [{"id": "t1", "title": "x", "status": "in_progress"}]
await adapter.send_native_task_card_progress(
"C1", tasks, reply_to=None, metadata=md
)
await adapter.stop_native_task_card_progress("C1", metadata=md)
card_ids = {
o["card_id"]
for o in t.ops
if o["op"] in ("task_card", "task_card_stop")
}
assert len(card_ids) == 1, t.ops
class TestFlatDmKeying:
@pytest.mark.asyncio
async def test_flat_dm_without_thread_metadata_still_seals(self):
"""Flat DM: no thread anchor anywhere, but the consumer stamps
reply_to_message_id on frames and the final alike."""
adapter, t = _adapter()
md = {"reply_to_message_id": "evt.1"}
await adapter.send_draft("D1", 5, "partial", metadata=md)
r = await adapter.send("D1", "final", metadata=dict(md))
assert r.success
seals = [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert len(seals) == 1 and seals[0]["content"] == "final"
@pytest.mark.asyncio
async def test_parallel_flat_dm_turns_keyed_by_message_id(self):
adapter, t = _adapter()
md_a = {"reply_to_message_id": "evt.a"}
md_b = {"reply_to_message_id": "evt.b"}
await adapter.send_draft("D1", 21, "A partial", metadata=md_a)
await adapter.send_draft("D1", 22, "B partial", metadata=md_b)
await adapter.send("D1", "A final", metadata=dict(md_a))
await adapter.send("D1", "B final", metadata=dict(md_b))
seals = [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert {(s["draft_id"], s["content"]) for s in seals} == {
(21, "A final"),
(22, "B final"),
}
class TestLegacyPlacementOnlyCallers:
@pytest.mark.asyncio
async def test_single_open_stream_absorbs_identityless_final(self):
"""A resolver-lane send with NO turn identity still seals when the
chat has exactly one open stream (legacy compatibility)."""
adapter, t = _adapter()
md = {"thread_ts": "1700.9", "message_id": "1700.910"}
await adapter.send_draft("C1", 31, "partial", metadata=md)
r = await adapter.send("C1", "final", metadata=None)
assert r.success
seals = [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert len(seals) == 1 and seals[0]["draft_id"] == 31
@pytest.mark.asyncio
async def test_thread_anchored_placement_only_final_seals(self):
"""Review r2, finding 5: metadata carrying ONLY a thread anchor is
placement info, not turn identity — with one open turn-keyed
stream in the chat, the final must absorb into it, not post a
plain duplicate beside the live stream."""
adapter, t = _adapter()
md_frames = {"thread_ts": "1700.9", "message_id": "1700.910"}
await adapter.send_draft("C1", 32, "partial", metadata=md_frames)
r = await adapter.send("C1", "final", metadata={"thread_ts": "1700.9"})
assert r.success
seals = [o for o in t.ops if o["op"] == "draft" and o.get("final")]
assert len(seals) == 1 and seals[0]["draft_id"] == 32, (
"placement-only final must seal the single open stream"
)
assert not [o for o in t.ops if o["op"] == "send"]
assert not adapter._open_draft_by_chat
@pytest.mark.asyncio
async def test_multiple_open_streams_identityless_send_stays_plain(self):
"""With several open streams, an identity-less send must NOT guess:
plain send (recoverable) over sealing the wrong stream (not)."""
adapter, t = _adapter()
await adapter.send_draft(
"C1", 41, "A", metadata={"message_id": "m.1"}
)
await adapter.send_draft(
"C1", 42, "B", metadata={"message_id": "m.2"}
)
r = await adapter.send("C1", "ambiguous final", metadata=None)
assert r.success
assert [o["op"] for o in t.ops][-1] == "send"
assert not [o for o in t.ops if o["op"] == "draft" and o.get("final")]
@pytest.mark.asyncio
async def test_placement_only_with_multiple_open_streams_stays_plain(self):
adapter, t = _adapter()
await adapter.send_draft(
"C1", 43, "A", metadata={"thread_ts": "1700.9", "message_id": "m.3"}
)
await adapter.send_draft(
"C1", 44, "B", metadata={"thread_ts": "1700.9", "message_id": "m.4"}
)
r = await adapter.send("C1", "ambiguous", metadata={"thread_ts": "1700.9"})
assert r.success
assert [o["op"] for o in t.ops][-1] == "send"
@pytest.mark.asyncio
async def test_message_id_mismatch_never_falls_back(self):
"""A caller WITH a message id whose key misses must not steal the
one open stream — its identity is authoritative (different turn)."""
adapter, t = _adapter()
await adapter.send_draft("C1", 45, "A", metadata={"message_id": "m.5"})
r = await adapter.send("C1", "other turn", metadata={"message_id": "m.OTHER"})
assert r.success
assert [o["op"] for o in t.ops][-1] == "send"
assert adapter._open_draft_by_chat, "stream must survive the mismatch"
+259
View File
@@ -0,0 +1,259 @@
"""Unit tests for boot-time relay self-provisioning.
Covers gateway.relay.self_provision_relay() + the relay_endpoint() /
relay_route_keys() config readers. The connector HTTP POST is monkeypatched
(the cross-repo E2E exercises the real /relay/provision); these prove the
TRIGGER logic, in-process env wiring, and fail-soft boot behaviour.
The trigger is deliberately NOT is_managed() (that means NixOS/package-manager-
managed, which is False on a NAS-hosted Fly agent). The real gate is
"relay_url set + no pinned secret + a resolvable NAS token".
"""
from __future__ import annotations
import os
import pytest
import gateway.relay as relay
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for k in (
"GATEWAY_RELAY_URL",
"GATEWAY_RELAY_ID",
"GATEWAY_RELAY_SECRET",
"GATEWAY_RELAY_DELIVERY_KEY",
"GATEWAY_RELAY_ENDPOINT",
"GATEWAY_RELAY_ROUTE_KEYS",
"GATEWAY_RELAY_PLATFORM",
"GATEWAY_RELAY_BOT_ID",
"GATEWAY_RELAY_INSTANCE_ID",
"GATEWAY_RELAY_WAKE_URL",
):
monkeypatch.delenv(k, raising=False)
# Never read config.yaml off disk in these tests.
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False)
def _stub_post(captured: dict):
"""A fake _post_provision that records its kwargs and returns creds."""
def _fake(**kwargs):
captured.update(kwargs)
return {
"secret": "a" * 64,
"deliveryKey": "b" * 64,
"tenant": "org-tenant-x",
"gatewayId": kwargs["gateway_id"],
"routeKeys": kwargs["route_keys"],
}
return _fake
def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"):
"""Arm the real trigger: a relay URL + a resolvable NAS token.
Note there is intentionally no `managed` knob — self-provision no longer
consults is_managed(). A test that wants the "no NAS identity" branch
monkeypatches resolve_nous_access_token to raise instead.
"""
monkeypatch.setattr(relay, "relay_url", lambda: url)
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token)
# ─────────────────────────── config readers ───────────────────────────
def test_provision_url_maps_ws_to_http():
assert relay._provision_url("wss://c.example/relay") == "https://c.example/relay/provision"
assert relay._provision_url("ws://c.example/relay") == "http://c.example/relay/provision"
assert relay._provision_url("https://c.example") == "https://c.example/relay/provision"
# ─────────────────────────── trigger logic ───────────────────────────
def test_skips_when_relay_not_configured(monkeypatch):
_arm(monkeypatch, url=None)
called = {"n": 0}
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
assert relay.self_provision_relay() is False
assert called["n"] == 0
def test_skips_when_secret_already_pinned(monkeypatch):
"""A self-hosted, enrolled gateway has a pinned secret -> never self-provisions."""
_arm(monkeypatch)
monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-pinned")
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "deadbeef")
called = {"n": 0}
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
assert relay.self_provision_relay() is False
assert called["n"] == 0
# The pinned secret is untouched.
assert relay.relay_connection_auth() == ("gw-pinned", "deadbeef")
# ─────────────────────────── happy path ───────────────────────────
def test_provisions_and_sets_env_in_process(monkeypatch):
_arm(monkeypatch)
monkeypatch.setenv("GATEWAY_RELAY_ENDPOINT", "https://gw.example.com/inbound")
monkeypatch.setenv("GATEWAY_RELAY_ROUTE_KEYS", "guild-1,guild-2")
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
# The connector POST carried the gateway-asserted endpoint + route keys.
assert captured["provision_url"] == "https://connector.example/relay/provision"
assert captured["access_token"] == "nas-token"
assert captured["gateway_endpoint"] == "https://gw.example.com/inbound"
assert captured["route_keys"] == ["guild-1", "guild-2"]
# Creds landed in os.environ (in-process), so register_relay_adapter() reads them.
gid, secret = relay.relay_connection_auth()
assert gid and secret == "a" * 64
# The delivery key is persisted in-process too (issued by the connector,
# kept for forward-compat; inbound rides the WS so it isn't consumed).
assert os.environ["GATEWAY_RELAY_DELIVERY_KEY"] == "b" * 64
def test_outbound_only_when_no_endpoint(monkeypatch):
_arm(monkeypatch)
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
assert captured["gateway_endpoint"] is None
assert captured["route_keys"] == []
assert relay.relay_connection_auth()[1] == "a" * 64
# ─────────────────── instance-id forwarding (Phase 6 Unit α) ───────────────────
def test_instance_id_absent_forwards_none(monkeypatch):
"""No stamp (self-hosted / pre-Phase-6) -> instance_id None; the connector
stores null and per-instance routing simply has no binding yet."""
_arm(monkeypatch)
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
assert captured["instance_id"] is None
def test_post_provision_body_includes_instanceId_only_when_set(monkeypatch):
"""The real _post_provision adds `instanceId` to the JSON body ONLY when a
value is supplied — omitting it lets the connector store null (back-compat),
rather than binding an empty string."""
import json
sent: dict = {}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return json.dumps({"secret": "a" * 64, "deliveryKey": "b" * 64, "tenant": "t", "gatewayId": "gw-1"}).encode()
def _fake_urlopen(req, timeout=None): # noqa: ANN001
sent["body"] = json.loads(req.data.decode())
return _Resp()
monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen)
# With an instance id -> present in the body.
relay._post_provision(
provision_url="https://c.example/relay/provision",
access_token="tok",
gateway_id="gw-1",
platform="discord",
bot_id="app",
gateway_endpoint=None,
route_keys=[],
instance_id="inst-abc",
)
assert sent["body"]["instanceId"] == "inst-abc"
# Without one -> the key is absent entirely (not "" ).
relay._post_provision(
provision_url="https://c.example/relay/provision",
access_token="tok",
gateway_id="gw-1",
platform="discord",
bot_id="app",
gateway_endpoint=None,
route_keys=[],
)
assert "instanceId" not in sent["body"]
# ─────────────────── wake-url forwarding (Phase 5 Unit C) ───────────────────
def test_forwards_wake_url_to_provision(monkeypatch):
"""A suspendable agent stamped with GATEWAY_RELAY_WAKE_URL forwards it to the
connector so the connector can poke it awake when the first buffered event
lands on a flipped destination (Unit C wake primitive)."""
_arm(monkeypatch)
monkeypatch.setenv("GATEWAY_RELAY_WAKE_URL", "https://wake.example/poke")
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
assert captured["wake_url"] == "https://wake.example/poke"
def test_wake_url_absent_forwards_none(monkeypatch):
"""No stamp (self-hosted / non-suspendable) -> wake_url None; the connector
stores null and simply never pokes (it can't wake what it can't reach)."""
_arm(monkeypatch)
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
assert captured["wake_url"] is None
# ─────────────────────────── fail-soft ───────────────────────────
def test_no_nas_token_is_non_fatal(monkeypatch):
"""A self-hosted box with a relay URL but no resolvable NAS identity skips
quietly (this is the branch that replaces the old is_managed() gate for the
non-NAS case)."""
monkeypatch.setattr(relay, "relay_url", lambda: "wss://connector.example/relay")
def _boom():
raise RuntimeError("no token")
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", _boom)
# Must not raise; returns False; no creds set.
assert relay.self_provision_relay() is False
assert relay.relay_connection_auth() == (None, None)
# ─────────────────────────── displayName (Phase 1 parity, gg#171) ───────────────────────────
def test_relay_display_name_suppresses_stock_brand(monkeypatch):
"""The default 'Hermes Agent' brand is identical on every install — forwarding
it would shadow the connector's linked-owner fallback (which actually
disambiguates) with a uniform label. Only customized names are forwarded."""
monkeypatch.delenv("GATEWAY_RELAY_DISPLAY_NAME", raising=False)
class _Skin:
def get_branding(self, key, fallback=""):
return "Hermes Agent" if key == "agent_name" else fallback
monkeypatch.setattr("hermes_cli.skin_engine.get_active_skin", lambda: _Skin())
assert relay.relay_display_name() is None
@@ -0,0 +1,54 @@
"""Unit tests for relay wire-field hygiene (Phase 1 parity).
Covers _event_from_wire's consumption of the contract §3 user-identity
enrichment fields the connector has always sent but the gateway used to drop:
- ``user_display_name`` — the human-facing display name (native parity: the
native Discord adapter surfaces ``message.author.display_name`` as
``user_name``).
- ``user_handle`` — the raw platform handle, used only as a last resort.
Precedence: user_display_name > user_name > user_handle. Session keys derive
from user_id (never user_name), so this mapping is presentation-only and must
remain key-stable — asserted here via build_session_key.
Pure unit tests: no socket, no websockets dependency.
"""
from __future__ import annotations
from gateway.relay.ws_transport import _event_from_wire
from gateway.session import build_session_key
def _wire_event(**src_overrides):
src = {
"platform": "discord",
"chat_id": "chan-1",
"chat_type": "group",
"user_id": "u-1",
"user_name": "rawusername",
"scope_id": "guild-1",
}
src.update(src_overrides)
return {"text": "hello", "message_type": "text", "source": src}
class TestUserIdentityEnrichment:
def test_handle_is_last_resort(self):
ev = _event_from_wire(
_wire_event(user_name=None, user_handle="ben#1234")
)
assert ev.source.user_name == "ben#1234"
def test_session_key_is_stable_across_name_shapes(self):
"""user_name is presentation-only: the same user_id keys the same
session whether or not the connector sent the enrichment fields."""
plain = _event_from_wire(_wire_event())
enriched = _event_from_wire(
_wire_event(user_display_name="Ben Display", user_handle="ben#1234")
)
assert build_session_key(plain.source) == build_session_key(enriched.source)
@@ -0,0 +1,348 @@
"""Unit tests for relay voice-note + per-attachment media-type mapping.
The relay contract gained ``message_type: "voice"`` (connector PR: voice
notes on Discord/Telegram/WhatsApp classify distinctly from audio-file
uploads). The gateway side has two obligations:
1. ``MessageType("voice")`` must parse — it already does, since the enum
predates the wire value — and
2. the connector's rich ``media[]`` array (each entry carries the
per-attachment ``mime``) must land on ``event.media_types`` so run.py's
per-attachment classifiers (``_event_media_is_stt_input``, image vs
document routing) work for relayed events, not just native adapters.
Live-verified failure (staging 2026-08-26): a voice note arrived as
``MessageType.AUDIO`` with ``media_types == []`` — STT never fired and the
agent got a "the user sent an audio file attachment" note instead.
Pure unit tests: no socket, no websockets dependency.
"""
from __future__ import annotations
from gateway.platforms.base import MessageType
from gateway.relay.ws_transport import _event_from_wire
def _wire_event(message_type: str, **extra):
src = {
"platform": "discord",
"chat_id": "chan-1",
"chat_type": "dm",
"user_id": "u-1",
"user_name": "ben",
}
return {
"text": "",
"message_type": message_type,
"source": src,
**extra,
}
class TestVoiceMessageType:
def test_wire_voice_parses_to_message_type_voice(self):
ev = _event_from_wire(_wire_event("voice"))
assert ev.message_type == MessageType.VOICE
def test_wire_audio_still_parses_to_message_type_audio(self):
"""Music files keep the non-STT type — the connector must not have
collapsed them (it doesn't), and the gateway must not either."""
ev = _event_from_wire(_wire_event("audio"))
assert ev.message_type == MessageType.AUDIO
class TestMediaTypesMapping:
def test_media_mimes_land_on_event_media_types(self):
media = [
{
"url": "https://cdn.discordapp.com/attachments/1/2/voice-message.ogg",
"kind": "audio",
"mime": "audio/ogg",
"size": 19197,
"filename": "voice-message.ogg",
},
]
ev = _event_from_wire(
_wire_event("voice", media=media, media_urls=[m["url"] for m in media])
)
assert ev.media_types == ["audio/ogg"]
# media_urls must remain the parallel legacy field (unchanged).
assert ev.media_urls == [media[0]["url"]]
def test_media_types_parallel_to_media_urls_for_multiple_attachments(self):
media = [
{"url": "https://x/photo.png", "kind": "image", "mime": "image/png"},
{"url": "https://x/doc.pdf", "kind": "document", "mime": "application/pdf"},
]
ev = _event_from_wire(
_wire_event("image", media=media, media_urls=[m["url"] for m in media])
)
assert ev.media_types == ["image/png", "application/pdf"]
assert len(ev.media_types) == len(ev.media_urls)
def test_missing_mime_gives_empty_string_at_that_index(self):
"""A media entry without a mime must not shift the alignment between
media_urls[i] and media_types[i] — run.py indexes both by position."""
media = [
{"url": "https://x/photo.png", "kind": "image", "mime": "image/png"},
{"url": "https://x/blob", "kind": "document"}, # no mime
]
ev = _event_from_wire(
_wire_event("image", media=media, media_urls=[m["url"] for m in media])
)
assert ev.media_types == ["image/png", ""]
def test_no_media_field_means_empty_media_types(self):
"""Older connectors (no media[]) — byte-identical to pre-fix."""
ev = _event_from_wire(_wire_event("text"))
assert ev.media_types == []
def test_length_mismatch_resolves_by_url_without_misaligning(self):
"""media_urls and media[] are independent wire fields; consumers index
BOTH by the same i. Resolution is BY URL, so a length disagreement can
no longer misalign anything: each surviving URL keeps its own mime and
an unmatched URL degrades to "" (message-level classification)."""
media = [
{"url": "https://x/a.png", "kind": "image", "mime": "image/png"},
{"url": "https://x/b.pdf", "kind": "document", "mime": "application/pdf"},
]
ev = _event_from_wire(
_wire_event("image", media=media, media_urls=["https://x/a.png"])
)
assert ev.media_urls == ["https://x/a.png"]
assert ev.media_types == ["image/png"]
class TestSttGate:
"""Direct assertions on run.py's STT gate against REAL wire-parsed
events. The PR's acceptance criterion is STT routing, not object
construction — pin the user-visible invariant here.
Note the deployment-ordering consequence: MessageType.VOICE parses on
ANY gateway (the enum predates this PR) and the gate accepts VOICE with
empty media_types, so a NEW connector + OLD gateway already fires STT
for voice notes. Desirable — but it means the gate's behaviour is part
of the wire contract, and it must not silently change."""
def test_new_connector_voice_event_is_stt_eligible(self):
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event(
"voice",
media=[{"url": "https://gw/relay/media/x", "kind": "voice", "mime": "audio/ogg"}],
media_urls=["https://gw/relay/media/x"],
)
)
assert _event_media_is_stt_input(ev, 0) is True
def test_voice_event_stt_eligible_even_without_media_types(self):
"""A new-connector/old-gateway-shaped event — voice type, no
media[] — still fires STT (the gate's VOICE branch doesn't consult
media_types). Pins the rollout behaviour, not just the new one."""
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event("voice", media_urls=["https://gw/relay/media/x"])
)
# No media[] ⇒ no per-attachment mime, but the slot still exists so the
# parallel-array invariant holds (see TestParallelArrayLengthInvariant).
assert ev.media_types == [""]
assert _event_media_is_stt_input(ev, 0) is True
def test_legacy_audio_typed_voice_note_stays_out_of_stt(self):
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event(
"audio",
media=[{"url": "https://gw/relay/media/x", "kind": "voice", "mime": "audio/ogg"}],
media_urls=["https://gw/relay/media/x"],
)
)
assert _event_media_is_stt_input(ev, 0) is False
def test_music_upload_is_never_stt_eligible(self):
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event(
"audio",
media=[{"url": "https://cdn/x/song.mp3", "kind": "audio", "mime": "audio/mpeg"}],
media_urls=["https://cdn/x/song.mp3"],
)
)
assert _event_media_is_stt_input(ev, 0) is False
class TestUrlMimePairingThroughLocalization:
"""The end-to-end invariant my unit tests originally missed.
media_urls and media_types are indexed BY POSITION by every downstream
classifier. _localize_inbound_media drops entries (a dead connector
re-host) as a NORMAL best-effort path — so if it filters URLs without
filtering MIMEs in lockstep, every surviving attachment inherits a
neighbour's type. Drive the REAL path: wire parse -> localization ->
run.py classifier."""
def _adapter(self):
from gateway.relay.adapter import RelayAdapter
return RelayAdapter.__new__(RelayAdapter)
def test_dropped_first_attachment_does_not_shift_the_second_mime(self):
import asyncio
from gateway.run import _event_media_is_image, _event_media_type_at
rehost = "https://conn.example/relay/media/dead"
kept = "https://cdn.discordapp.com/attachments/1/2/kept.png"
ev = _event_from_wire(
_wire_event(
"document",
media=[
{"url": rehost, "kind": "document", "mime": "application/pdf"},
{"url": kept, "kind": "image", "mime": "image/png"},
],
media_urls=[rehost, kept],
)
)
assert ev.media_types == ["application/pdf", "image/png"]
adapter = self._adapter()
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
asyncio.run(adapter._localize_inbound_media(ev))
# The dead re-host is dropped; the PNG must keep ITS OWN mime.
assert ev.media_urls == [kept]
assert ev.media_types == ["image/png"]
assert _event_media_type_at(ev, 0) == "image/png"
assert _event_media_is_image(ev, 0) is True
def test_reordered_media_vs_media_urls_resolves_by_url_not_position(self):
"""Equal-length but differently-ordered wire fields must not pair up
positionally — resolve each URL's mime by lookup."""
png = "https://x/a.png"
pdf = "https://x/b.pdf"
ev = _event_from_wire(
_wire_event(
"image",
media=[
{"url": pdf, "mime": "application/pdf"},
{"url": png, "mime": "image/png"},
],
media_urls=[png, pdf],
)
)
assert ev.media_types == ["image/png", "application/pdf"]
def test_url_with_no_matching_media_entry_gets_empty_mime(self):
known = "https://x/a.png"
orphan = "https://x/unknown.bin"
ev = _event_from_wire(
_wire_event(
"image",
media=[{"url": known, "mime": "image/png"}],
media_urls=[known, orphan],
)
)
assert ev.media_types == ["image/png", ""]
def test_media_without_media_urls_yields_no_types(self):
"""No URL list to align to ⇒ the indices are meaningless."""
ev = _event_from_wire(
_wire_event("image", media=[{"url": "https://x/a.png", "mime": "image/png"}])
)
assert ev.media_urls == []
assert ev.media_types == []
class TestParallelArrayLengthInvariant:
"""media_types must ALWAYS have one slot per media_url.
Not merely an indexing nicety: ``merge_pending_message_event``
(gateway/platforms/base.py) EXTENDS both lists when a second media message
is merged into a pending one. If a media_types-less event merges with a
typed one, extend() concatenates lists of different lengths and shifts
every later mime onto the wrong url. Found by self-review after two
rounds of external review flagged this same bug class in adjacent seams."""
def test_media_urls_without_media_still_get_one_empty_slot_each(self):
"""An older connector sends media_urls with no media[]. Padding keeps
the invariant instead of emitting a length-0 media_types."""
ev = _event_from_wire(
_wire_event("image", media_urls=["https://x/a.png", "https://x/b.png"])
)
assert ev.media_types == ["", ""]
assert len(ev.media_types) == len(ev.media_urls)
def test_merging_an_untyped_event_with_a_typed_one_keeps_mimes_on_their_urls(self):
from gateway.platforms.base import merge_pending_message_event
from gateway.run import _event_media_type_at
untyped = _event_from_wire(
_wire_event("image", media_urls=["https://x/old1.png", "https://x/old2.png"])
)
typed = _event_from_wire(
_wire_event(
"document",
media=[{"url": "https://x/new.pdf", "mime": "application/pdf"}],
media_urls=["https://x/new.pdf"],
)
)
pending = {"k": untyped}
merge_pending_message_event(pending, "k", typed)
merged = pending["k"]
assert len(merged.media_types) == len(merged.media_urls)
# The PDF's mime must stay on the PDF, not slide onto old1.png.
assert _event_media_type_at(merged, 0) == ""
assert _event_media_type_at(merged, 1) == ""
assert _event_media_type_at(merged, 2) == "application/pdf"
def test_localization_preserves_the_invariant_when_it_drops_entries(self):
import asyncio
from gateway.relay.adapter import RelayAdapter
rehost = "https://conn.example/relay/media/dead"
kept = "https://x/kept.png"
ev = _event_from_wire(
_wire_event("image", media_urls=[rehost, kept]) # no media[] at all
)
assert ev.media_types == ["", ""]
adapter = RelayAdapter.__new__(RelayAdapter)
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
asyncio.run(adapter._localize_inbound_media(ev))
assert ev.media_urls == [kept]
assert len(ev.media_types) == len(ev.media_urls)
def test_localization_normalizes_a_short_media_types_from_any_source(self):
"""Defence in depth: an event that reaches the localizer with a SHORT
media_types (not produced by _event_from_wire — e.g. a synthetic or
replayed event) must come out with one slot per surviving url, not
with the short list passed through."""
import asyncio
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
ev = MessageEvent(text="", message_type=MessageType.PHOTO)
ev.media_urls = ["https://x/a.png", "https://x/b.png"]
ev.media_types = [] # empty, while urls exist — the shape that must
# NOT be passed through: consumers would index/merge against a
# zero-length mime list and shift every later entry.
adapter = RelayAdapter.__new__(RelayAdapter)
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
asyncio.run(adapter._localize_inbound_media(ev))
assert ev.media_urls == ["https://x/a.png", "https://x/b.png"]
assert ev.media_types == ["", ""]
assert len(ev.media_types) == len(ev.media_urls)
+210
View File
@@ -0,0 +1,210 @@
"""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()
# ── 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()
@@ -0,0 +1,373 @@
"""Regression tests for the relay WS transport hardening fix.
Coatue incident 2026-08-18: WAN latency / event-loop stalls tripped the
websockets library's default 20s pong deadline, closing customer-gateway
sockets with `1011 keepalive ping timeout`. On top of the spurious close,
every in-flight outbound then hung for the full _outbound_timeout_s (~30s)
because only disconnect() failed pending futures — an unexpected socket drop
left them stranded — and sends issued while the reconnect supervisor was
backing off registered futures no reader could ever resolve.
Three hardening changes under test:
1. _read_loop fails all in-flight _pending futures on ANY exit path with
the dict shape callers expect ({"success": False, ...}).
2. _request_response fails fast while the reconnect supervisor is
mid-redial (live supervisor task = the redial window).
3. connect() passes explicit WAN-friendly keepalive tuning
(ping_interval=30, ping_timeout=60) to websockets.connect().
"""
from __future__ import annotations
import asyncio
import pytest
import gateway.relay.ws_transport as ws_transport_mod
from gateway.relay.ws_transport import WebSocketRelayTransport, WEBSOCKETS_AVAILABLE
pytestmark = pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")
if WEBSOCKETS_AVAILABLE:
from websockets.exceptions import ConnectionClosedError
class _DroppingWS:
"""Fake socket: accepts sends, then the read loop dies mid-iteration —
the shape of an unexpected close (e.g. 1011 keepalive ping timeout)."""
def __init__(self, close_code: int | None = None):
self.sent: list[str] = []
# Reader blocks here until the test releases it, so the outbound
# future is registered BEFORE the "socket" drops.
self.drop = asyncio.Event()
self._close_code = close_code
async def send(self, data):
self.sent.append(data)
def __aiter__(self):
return self
async def __anext__(self):
await self.drop.wait()
if self._close_code is not None:
from websockets.frames import Close
raise ConnectionClosedError(Close(self._close_code, ""), None)
raise ConnectionClosedError(None, None)
async def close(self):
pass
@pytest.mark.asyncio
async def test_read_loop_exit_fails_pending_futures_promptly():
"""When the socket drops unexpectedly, in-flight _request_response callers
must get {"success": False, ...} promptly — not block ~30s on a future
only the (now dead) reader could have resolved."""
t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=30.0)
fake = _DroppingWS()
t._ws = fake
t._reader = asyncio.create_task(t._read_loop())
send_task = asyncio.create_task(t.send_outbound({"op": "send_message", "text": "hi"}))
# Let the outbound frame go out and its future register in _pending.
for _ in range(50):
if t._pending:
break
await asyncio.sleep(0.01)
assert t._pending, "outbound future never registered"
# Drop the socket: the read loop exits on ConnectionClosedError.
fake.drop.set()
result = await asyncio.wait_for(send_task, timeout=2.0)
assert result == {"success": False, "error": "relay transport connection lost"}
assert t._pending == {}
await t._reader
@pytest.mark.asyncio
async def test_send_during_redial_window_fails_fast():
"""While the reconnect supervisor is backing off after a drop, a send must
return an error dict immediately (no RuntimeError, no 30s timeout on an
unresolvable future). Drives the REAL sequence — reader exit arms the
supervisor and clears _ws — rather than hand-crafting a stale-_ws state
the transport can no longer reach."""
t = WebSocketRelayTransport(
"ws://unused",
"discord",
"bot1",
reconnect=True,
reconnect_backoff_s=60.0, # park the supervisor in backoff
outbound_timeout_s=30.0,
)
fake = _DroppingWS()
t._ws = fake
await _run_reader_to_exit(t, fake)
supervisor = t._supervisor
try:
assert supervisor is not None and not supervisor.done(), (
"reader exit must arm the reconnect supervisor"
)
result = await asyncio.wait_for(
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=1.0
)
assert result["success"] is False
assert t._pending == {}
finally:
if supervisor is not None:
supervisor.cancel()
try:
await supervisor
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_send_allowed_once_redial_installs_fresh_socket(monkeypatch):
"""The moment _dial_and_start() installs a fresh socket and its reader,
the transport is genuinely usable — even though the supervisor task has
not finished unwinding (it is still awaiting the hello sends). A send in
that window must be ACCEPTED, not rejected as 'reconnecting': gating
sends on supervisor state rejected real traffic on a live socket."""
class _LiveWS:
def __init__(self):
self.sent: list[str] = []
self.hello_seen = asyncio.Event()
self.release = asyncio.Event()
async def send(self, data):
self.sent.append(data)
if '"hello"' in data:
# Inside _dial_and_start, AFTER _ws and the reader are
# installed. Park here to hold the window open.
self.hello_seen.set()
await self.release.wait()
def __aiter__(self):
return self
async def __anext__(self):
await asyncio.sleep(3600)
async def close(self):
pass
live = _LiveWS()
async def _fake_connect(url, **kwargs):
return live
monkeypatch.setattr(ws_transport_mod.websockets, "connect", _fake_connect)
t = WebSocketRelayTransport(
"ws://unused",
"discord",
"bot1",
reconnect=True,
reconnect_backoff_s=0.01,
outbound_timeout_s=5.0,
)
# Arm the supervisor exactly as the reader's fall-through does.
t._supervisor = asyncio.create_task(t._reconnect_loop())
await asyncio.wait_for(live.hello_seen.wait(), timeout=2.0)
try:
assert t._ws is live and not t._supervisor.done()
send_task = asyncio.create_task(
t.send_outbound({"op": "send_message", "text": "hi"})
)
# The send must reach the live socket (registered + frame written),
# not fail fast: wait for the outbound frame to land.
for _ in range(100):
if any('"outbound"' in s for s in live.sent):
break
await asyncio.sleep(0.01)
assert any('"outbound"' in s for s in live.sent), (
"send was rejected during the post-dial window despite a live "
"socket and running reader"
)
# Resolve it via the reader path shape: answer directly.
rid = next(iter(t._pending))
t._pending[rid].set_result({"success": True})
assert (await asyncio.wait_for(send_task, timeout=2.0)) == {"success": True}
finally:
live.release.set()
await asyncio.wait_for(t._supervisor, timeout=2.0)
if t._reader is not None:
t._reader.cancel()
try:
await t._reader
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_connect_passes_wan_keepalive_tuning(monkeypatch):
"""connect() must pass ping_interval=30 / ping_timeout=60 explicitly —
the library defaults (20/20) caused spurious 1011 keepalive closes over
WAN paths (Coatue 2026-08-18). Both call sites (with/without auth
headers) are exercised."""
captured: list[dict] = []
class _IdleWS:
async def send(self, data):
pass
def __aiter__(self):
return self
async def __anext__(self):
await asyncio.sleep(3600)
async def close(self):
pass
async def _fake_connect(url, **kwargs):
captured.append(kwargs)
return _IdleWS()
monkeypatch.setattr(ws_transport_mod.websockets, "connect", _fake_connect)
# Site 1: no upgrade secret -> the headerless connect() call.
t = WebSocketRelayTransport("ws://unused", "discord", "bot1")
await t.connect()
await t.disconnect(budget_s=0)
# Site 2: secret + gateway_id -> the additional_headers connect() call.
t2 = WebSocketRelayTransport(
"ws://unused", "discord", "bot1", gateway_id="gw-1", upgrade_secret="s3cret"
)
await t2.connect()
await t2.disconnect(budget_s=0)
assert len(captured) == 2
no_header_kwargs, header_kwargs = captured
assert "additional_headers" not in no_header_kwargs
assert "additional_headers" in header_kwargs
for kwargs in captured:
assert kwargs.get("ping_interval") == 30
assert kwargs.get("ping_timeout") == 60
async def _run_reader_to_exit(t: WebSocketRelayTransport, fake: _DroppingWS) -> None:
"""Start the reader on ``fake``, drop the socket, and wait for the reader
to fully unwind — the state every post-drop assertion depends on."""
t._reader = asyncio.create_task(t._read_loop())
await asyncio.sleep(0)
fake.drop.set()
await t._reader
@pytest.mark.asyncio
async def test_read_loop_without_socket_still_fails_pending():
"""If the reader is ever scheduled with no socket (lifecycle bug), it must
still settle in-flight waiters on its way out — the old `assert` escaped
before the fail-pending cleanup and left them to the full 30s timeout."""
t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=30.0)
loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future()
t._pending["rid"] = fut
t._ws = None
await t._read_loop() # must not raise
assert fut.done()
assert fut.result() == {"success": False, "error": "relay transport connection lost"}
assert t._pending == {}
@pytest.mark.asyncio
async def test_send_after_terminal_4401_revocation_fails_fast():
"""A terminal 4401 revocation deliberately arms NO reconnect supervisor,
so the reader's exit is the LAST liveness transition this transport will
ever make. If _ws still points at the dead socket afterwards, the
revocation path's own fatal-error notification send wedges for the full
_outbound_timeout_s. The reader must leave _ws cleared so the
not-connected guard answers instantly."""
t = WebSocketRelayTransport(
"ws://unused", "discord", "bot1", reconnect=True, outbound_timeout_s=30.0
)
fake = _DroppingWS(close_code=4401)
t._ws = fake
t._handshake_succeeded = True # prior handshake -> 4401 is a revocation
await _run_reader_to_exit(t, fake)
assert t._auth_revoked is True
assert t._supervisor is None # revocation must not re-dial
assert t._ws is None, "dead socket handle must not survive the reader"
result = await asyncio.wait_for(
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
)
assert result["success"] is False
assert t._pending == {}
@pytest.mark.asyncio
async def test_send_after_drop_with_reconnect_disabled_fails_fast():
"""reconnect=False transports never arm a supervisor either — the same
stranded-_ws wedge as the revocation path, reachable by configuration."""
t = WebSocketRelayTransport(
"ws://unused", "discord", "bot1", reconnect=False, outbound_timeout_s=30.0
)
fake = _DroppingWS()
t._ws = fake
await _run_reader_to_exit(t, fake)
assert t._ws is None, "dead socket handle must not survive the reader"
result = await asyncio.wait_for(
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
)
assert result["success"] is False
assert t._pending == {}
@pytest.mark.asyncio
async def test_send_raising_socket_returns_error_dict():
"""The socket can die BETWEEN the `_ws is None` liveness guard and the
actual write (the reader's finally hasn't cleared the handle yet). The
write then raises ConnectionClosed — but send_outbound's contract is a
result dict, and RelayAdapter.send consumes it with no try. The raise
must be converted to {"success": False, ...}, with no future left in
_pending."""
class _RaisingWS:
"""Send raises (already dead); the reader hasn't noticed yet."""
def __init__(self):
self.reader_release = asyncio.Event()
async def send(self, data):
raise ConnectionClosedError(None, None)
def __aiter__(self):
return self
async def __anext__(self):
await self.reader_release.wait()
raise ConnectionClosedError(None, None)
async def close(self):
pass
t = WebSocketRelayTransport("ws://unused", "discord", "bot1", outbound_timeout_s=5.0)
fake = _RaisingWS()
t._ws = fake
t._reader = asyncio.create_task(t._read_loop())
await asyncio.sleep(0)
result = await asyncio.wait_for(
t.send_outbound({"op": "send_message", "text": "hi"}), timeout=2.0
)
assert result["success"] is False
assert "relay send failed" in result["error"]
assert t._pending == {}
fake.reader_release.set()
await t._reader