Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 "")
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user