diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 499e23756b3b..3698b55432a6 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -397,6 +397,8 @@ The gateway calls the transport with action dicts. Source of truth: | `send_media` | `chat_id`, `media_kind`, `source_url`, `content?` (caption), `filename?`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` | | `prompt` | `chat_id`, `prompt_kind`, `prompt_id`, `content` (the question), `options[]{id,label,style?}`, `timeout_s?`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` | | `react` | `chat_id`, `message_id`, `emoji`, `remove?`, `metadata?` | `{success: bool, error?}` | +| `thread_create` | `chat_id` (parent), `thread_name`, `message_id?` (anchor), `metadata?` | `{success: bool, thread_id?, error?}` | +| `thread_rename` | `chat_id` (parent), `message_id` (the THREAD id), `thread_name`, `only_if_current_name?`, `metadata?` | `{success: bool, error?}` | `get_chat_info(chat_id)` is a separate proxied call returning at least `{name, type}`. @@ -478,6 +480,48 @@ treats reactions as cosmetic). WhatsApp sends a reaction message (empty emoji = remove). Reactions are best-effort by contract: a `react` failure must never fail a turn. +**`thread_create` / `thread_rename` (Phase 4 thread lifecycle).** One +platform-abstract pair covers handoff threads, Telegram DM/forum topics, and +LLM-title semantic renames. `thread_create`: Discord posts a channel thread +(type 11) or a message-anchored thread when `message_id` is set; Telegram +`createForumTopic` (topic id returned); Slack posts a NAMED seed root +message and returns its `ts` (threads there are message-anchored — an +explicit `message_id` anchor is echoed back verbatim). The created id rides +`SendResult.thread_id`. `thread_rename`: Discord PATCHes the thread channel; +Telegram `editForumTopic`. The **`only_if_current_name` no-clobber guard** +is the native adapters' human-rename-wins semantics, enforced +CONNECTOR-side: Discord reads the current name first and no-ops (structured +`success:false`) on mismatch; Telegram has no topic-name read, so a GUARDED +rename is unsatisfiable and fails safe (unguarded renames proceed). Slack +does not advertise `thread_rename` (a root message's text is content, not a +name). WhatsApp advertises neither (no threads). + +**Auto-thread markers + gateway-declared command manifest (Phase 4 +inbound/handshake).** When the connector's auto-thread egress policy creates +a Discord thread, later inbound events from that thread carry +`source.auto_thread_created: true` + `source.auto_thread_initial_name` — the +connector-observed evidence that lights the gateway's semantic-rename lane +(the LLM session title renames the thread via a GUARDED `thread_rename`; +per-instance memory, so in an N>1 fleet a miss simply never lights the +lane). The gateway may also declare its slash-command set on the Discord +`hello` frame (`command_manifest: [{name, description, options?}]`); the +connector reconciles Discord's GLOBAL application-command registration +against it (GET → diff → bulk PUT overwrite; idempotent, debounced, +best-effort — a registration failure never affects the handshake). Commands +still dispatch through the passthrough plane as before; the manifest only +keeps Discord's registry in sync with what the gateway's dispatcher handles. + +**Inbound `reply_to` enrichment (Phase 4).** A platform reply may carry +`reply_to: {text?, author?, is_own?}` alongside `reply_to_message_id` — what +the user QUOTED, populated only from data the connector already had in hand +(Discord's inline `referenced_message`, Telegram's inline +`reply_to_message`, WhatsApp `context.from` + a bounded per-instance +inbound-text cache for the text leg). Absent fields mean the platform didn't +carry the data — never triggers an extra platform API call. `is_own` = the +quoted message was authored by the fronted bot (same evidence as the +`is_reply_to_bot` relevance marker). The gateway maps these onto the same +MessageEvent reply-context fields native adapters populate. + **`typing` `content?` (Slack status clear).** A `typing` frame normally omits `content` — the connector renders its platform's active indicator ("is typing…" Assistant status on Slack, one-shot typing elsewhere). An **empty diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 31ff41d67d93..b149539cee50 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -1522,3 +1522,98 @@ class RelayAdapter(BasePlatformAdapter): await self._react(str(chat_id), str(message_id), "✅") elif outcome == ProcessingOutcome.FAILURE: await self._react(str(chat_id), str(message_id), "❌") + + # ── Phase 4 thread lifecycle ────────────────────────────────────────── + + async def create_handoff_thread( + self, + parent_chat_id: str, + name: str, + ) -> Optional[str]: + """Create a thread/topic under ``parent_chat_id`` via the connector. + + One `thread_create` op covers Discord (channel thread), Telegram + (forum topic), and Slack (named seed root message — threads there are + message-anchored). Op-gated on the descriptor advertising + `thread_create`; None on any failure/unavailability so the handoff + watcher falls back to the parent channel — the same contract as the + native adapters' create_handoff_thread. + """ + if self._transport is None or not self.descriptor.supports_op("thread_create"): + return None + thread_name = (str(name or "").strip() or "handoff")[:100] + try: + result = await self._transport.send_outbound( + { + "op": "thread_create", + "chat_id": str(parent_chat_id), + "thread_name": thread_name, + "metadata": self._with_scope(str(parent_chat_id), None), + }, + platform=self._platform_by_chat.get(str(parent_chat_id)), + ) + except Exception: # noqa: BLE001 - handoff falls back to the parent channel + logger.debug("relay thread_create transport failure", exc_info=True) + return None + if not result.get("success"): + logger.info( + "relay thread_create declined for %s: %s", + parent_chat_id, + result.get("error"), + ) + return None + thread_id = result.get("thread_id") or result.get("message_id") + return str(thread_id) if thread_id else None + + async def rename_thread( + self, + thread_id: str, + name: str, + *, + only_if_current_name: Optional[str] = None, + parent_chat_id: Optional[str] = None, + ) -> bool: + """Best-effort thread rename via the connector's `thread_rename` op. + + The relay sibling of the native Discord adapter's rename_thread — + called by the SAME semantic-rename lane (run.py + _rename_discord_auto_thread_for_session_title), which fires only for + sources carrying the connector-stamped auto-thread markers. + ``only_if_current_name`` crosses the wire; the CONNECTOR enforces the + no-clobber guard (it owns the platform read), failing safe on + platforms that can't read the current name. ``parent_chat_id`` is + the containing chat where the caller knows it (Telegram needs it); + defaults to the thread id itself (Discord ignores chat_id). + """ + if self._transport is None or not self.descriptor.supports_op("thread_rename"): + return False + cleaned = " ".join(str(name or "").split()).strip() + if not cleaned or not thread_id: + return False + chat_id = str(parent_chat_id or thread_id) + action: Dict[str, Any] = { + "op": "thread_rename", + "chat_id": chat_id, + "message_id": str(thread_id), + "thread_name": cleaned[:100], + "metadata": self._with_scope(chat_id, None), + } + if only_if_current_name is not None: + action["only_if_current_name"] = str(only_if_current_name) + try: + result = await self._transport.send_outbound( + action, + platform=self._platform_by_chat.get(chat_id) + or self._platform_by_chat.get(str(thread_id)), + ) + except Exception: # noqa: BLE001 - renames are cosmetic + logger.debug("relay thread_rename transport failure", exc_info=True) + return False + if not result.get("success"): + logger.info( + "relay thread_rename declined for %s: %s", + thread_id, + result.get("error"), + ) + return False + return True diff --git a/gateway/relay/command_manifest.py b/gateway/relay/command_manifest.py new file mode 100644 index 000000000000..36d7a8fcd78c --- /dev/null +++ b/gateway/relay/command_manifest.py @@ -0,0 +1,145 @@ +"""Gateway-declared slash-command manifest for the relay lane (Phase 4). + +The native Discord adapter registers its slash commands directly on the +Discord command tree (`_register_slash_commands`, +plugins/platforms/discord/adapter.py) — it holds the bot token. Over the +relay the CONNECTOR holds the token, so the gateway DECLARES the same +command set on its `hello` frame (`command_manifest`) and the connector +reconciles Discord's global application-command registration against it +(gateway-gateway `DiscordCommandRegistrar`: GET → diff → bulk PUT, +idempotent, best-effort). + +This module is that declaration: the single source of truth for what the +relay lane advertises. It MIRRORS the native tree — same names, same +descriptions — so a user moving between a native-Discord deployment and a +hosted/relay one sees the same command palette. Interactions come back over +the passthrough plane and are normalized by +RelayAdapter._discord_interaction_to_event into the same "/name args" +COMMAND events the dispatcher already routes, so declaring a command here +requires NO new handler — the dispatcher's existing slash surface is the +handler. + +Wire shape (per entry): {name, description, options?} where options rows are +Discord option objects passed through verbatim. Names must satisfy +Discord's CHAT_INPUT rules ([a-z0-9_-]{1,32}); the connector drops invalid +entries (fail-open per entry, never the whole manifest). +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +# Discord option type 3 = STRING. +_STR = 3 + + +def _opt(name: str, description: str, *, choices: List[str] | None = None) -> Dict[str, Any]: + row: Dict[str, Any] = { + "type": _STR, + "name": name, + "description": description, + "required": False, + } + if choices: + row["choices"] = [{"name": c, "value": c} for c in choices] + return row + + +def build_relay_command_manifest() -> List[Dict[str, Any]]: + """The relay lane's Discord slash-command manifest (native-tree mirror).""" + return [ + {"name": "new", "description": "Start a new conversation"}, + {"name": "reset", "description": "Reset your Hermes session"}, + { + "name": "model", + "description": "Show or change the model", + "options": [_opt("name", "Model name. Leave empty to see current.")], + }, + { + "name": "reasoning", + "description": "Show/change reasoning effort, or toggle showing it", + "options": [ + _opt( + "effort", + "Level, reset, or show/hide. Leave empty to see current.", + choices=[ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + "reset", + "show", + "hide", + ], + ) + ], + }, + { + "name": "personality", + "description": "Set a personality", + "options": [_opt("name", "Personality name. Leave empty to list.")], + }, + {"name": "retry", "description": "Retry your last message"}, + {"name": "undo", "description": "Remove the last exchange"}, + {"name": "status", "description": "Show Hermes session status"}, + {"name": "sethome", "description": "Set this chat as the home channel"}, + {"name": "stop", "description": "Stop the running Hermes agent"}, + { + "name": "steer", + "description": "Inject a message after the next tool call (no interrupt)", + "options": [_opt("text", "What to tell the agent")], + }, + {"name": "compress", "description": "Compress conversation context"}, + { + "name": "title", + "description": "Set or show the session title", + "options": [_opt("text", "New title. Leave empty to show.")], + }, + { + "name": "resume", + "description": "Resume a previously-named session", + "options": [_opt("name", "Session title or id")], + }, + {"name": "usage", "description": "Show token usage for this session"}, + {"name": "help", "description": "Show available commands"}, + {"name": "insights", "description": "Show usage insights and analytics"}, + {"name": "reload-mcp", "description": "Reload MCP servers from config"}, + { + "name": "reload-skills", + "description": "Re-scan skills for new or removed entries", + }, + {"name": "voice", "description": "Toggle voice reply mode"}, + {"name": "update", "description": "Update Hermes Agent to the latest version"}, + {"name": "restart", "description": "Gracefully restart the Hermes gateway"}, + { + "name": "approve", + "description": "Approve a pending dangerous command", + "options": [ + _opt("scope", "Approval scope", choices=["once", "session", "always", "all"]) + ], + }, + { + "name": "deny", + "description": "Deny a pending dangerous command", + "options": [_opt("reason", "Why (relayed to the agent)")], + }, + { + "name": "thread", + "description": "Create a new thread and start a Hermes session in it", + "options": [_opt("name", "Thread name")], + }, + { + "name": "queue", + "description": "Queue a prompt for the next turn (doesn't interrupt)", + "options": [_opt("text", "The prompt to queue")], + }, + { + "name": "background", + "description": "Run a prompt in the background", + "options": [_opt("text", "The prompt to run")], + }, + ] diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 9e97dfecfdb8..62fe683c8fc1 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -213,6 +213,12 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: # config/credential scope — the same field the /p// HTTP # prefix and per-credential polling adapters already set. profile=src.get("profile"), + # Auto-thread markers (Phase 4): stamped by the CONNECTOR when this + # event's thread was auto-created by its auto-thread egress policy. + # Lights the SAME semantic-rename lane native Discord uses + # (_is_discord_auto_thread_lane's relay-aware sibling reads these). + auto_thread_created=bool(src.get("auto_thread_created", False)), + auto_thread_initial_name=src.get("auto_thread_initial_name"), # Authentic upstream-trust signal: this event arrived over the # per-instance-authenticated relay WS, so the connector already resolved # it to this instance's owner-bound author. ``platform`` is the @@ -241,6 +247,14 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: source=source, message_id=raw.get("message_id"), reply_to_message_id=raw.get("reply_to_message_id"), + # Richer quoted-reply context (Phase 4): what the user replied TO, + # when the connector had it in hand (Discord referenced_message, + # Telegram reply_to_message, WhatsApp context + text cache). Maps to + # the SAME MessageEvent fields native adapters populate, so run.py's + # reply-context injection works identically over the relay. + reply_to_text=(raw.get("reply_to") or {}).get("text"), + reply_to_author_name=(raw.get("reply_to") or {}).get("author"), + reply_to_is_own_message=bool((raw.get("reply_to") or {}).get("is_own", False)), media_urls=raw.get("media_urls") or [], # Surrounding channel/group CONTEXT the connector attached for this # addressed turn (design relay-channel-context): a read-only, oldest→ @@ -437,7 +451,20 @@ class WebSocketRelayTransport: # sends exactly one hello — byte-identical to before. The descriptor for # the FIRST identity resolves handshake(); later descriptors are absorbed. for platform, bot_id in self._identities: - await self._send({"type": "hello", "platform": platform, "botId": bot_id}) + hello: Dict[str, Any] = {"type": "hello", "platform": platform, "botId": bot_id} + # Phase 4: declare the gateway's slash-command set on the Discord + # hello. The connector (which holds the bot token) reconciles + # Discord's global registration against it — idempotent, detached, + # best-effort on its side; a connector predating the field ignores + # it (additive). Only Discord has an app-command registry. + if platform == "discord": + try: + from gateway.relay.command_manifest import build_relay_command_manifest + + hello["command_manifest"] = build_relay_command_manifest() + except Exception: # noqa: BLE001 - manifest is enrichment, never blocks the handshake + logger.debug("relay command manifest build failed", exc_info=True) + await self._send(hello) def _upgrade_headers(self) -> Dict[str, str]: """Auth headers for the WS upgrade, or {} when no secret is configured. diff --git a/tests/gateway/relay/test_relay_threads.py b/tests/gateway/relay/test_relay_threads.py new file mode 100644 index 000000000000..0c7d485d67c7 --- /dev/null +++ b/tests/gateway/relay/test_relay_threads.py @@ -0,0 +1,308 @@ +"""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 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" + + +@pytest.mark.asyncio +async def test_create_handoff_thread_op_gated_and_decline_safe(): + # Not advertised → None without touching the wire. + adapter, stub = _adapter(supported_ops=("send", "edit", "typing")) + assert await adapter.create_handoff_thread("chan1", "x") is None + assert stub.sent == [] + # Advertised but declined (non-forum Telegram chat, missing perms) → None. + adapter2, stub2 = _adapter() + + async def declined(action, *, platform=None): + stub2.sent.append(action) + return {"success": False, "error": "not a forum"} + + stub2.send_outbound = declined # type: ignore[method-assign] + assert await adapter2.create_handoff_thread("chan1", "x") is None + + +@pytest.mark.asyncio +async def test_create_handoff_thread_falls_back_to_message_id(): + # Slack shape: the connector returns the seed ts as message_id+thread_id; + # older stubs may return only message_id — either resolves. + adapter, stub = _adapter() + + async def send_outbound(action, *, platform=None): + stub.sent.append(action) + return {"success": True, "message_id": "170.001"} + + stub.send_outbound = send_outbound # type: ignore[method-assign] + assert await adapter.create_handoff_thread("C1", "handoff") == "170.001" + + +# ── 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_decline_returns_false(): + adapter, stub = _adapter() + + async def declined(action, *, platform=None): + stub.sent.append(action) + return {"success": False, "error": "guard: current name differs"} + + stub.send_outbound = declined # type: ignore[method-assign] + assert await adapter.rename_thread("th1", "x", only_if_current_name="H") is False + + +# ── the relay semantic-rename lane (marker parity) ─────────────────────── + + +def test_relay_source_satisfies_auto_thread_lane_field_contract(): + """The native lane check reads platform/chat_type/thread_id + + auto_thread_created + auto_thread_initial_name off the source. A relay + event carrying the connector-stamped markers must present ALL of them + through _event_from_wire — same field contract, no relay-specific case. + """ + event = _event_from_wire( + { + "text": "hi", + "message_type": "text", + "source": { + "platform": "discord", + "chat_id": "th9", + "chat_type": "thread", + "thread_id": "th9", + "user_id": "u1", + "auto_thread_created": True, + "auto_thread_initial_name": "Hermes reply", + }, + } + ) + src = event.source + assert src.chat_type == "thread" + assert src.thread_id == "th9" + assert src.auto_thread_created is True + assert src.auto_thread_initial_name == "Hermes reply" + # And absent markers default off (never light the lane spuriously). + plain = _event_from_wire( + { + "text": "hi", + "message_type": "text", + "source": { + "platform": "discord", + "chat_id": "c", + "chat_type": "thread", + "thread_id": "t", + }, + } + ) + assert plain.source.auto_thread_created is False + assert plain.source.auto_thread_initial_name is None + + +# ── reply_to wire parse ────────────────────────────────────────────────── + + +def test_event_from_wire_maps_reply_to_onto_native_fields(): + event = _event_from_wire( + { + "text": "re", + "message_type": "text", + "source": {"platform": "telegram", "chat_id": "5", "chat_type": "dm"}, + "reply_to_message_id": "19", + "reply_to": { + "text": "what the bot said", + "author": "hermesbot", + "is_own": True, + }, + } + ) + assert event.reply_to_message_id == "19" + assert event.reply_to_text == "what the bot said" + assert event.reply_to_author_name == "hermesbot" + assert event.reply_to_is_own_message is True + + +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 ─────────────────────────────────────────────── + + +def test_manifest_entries_satisfy_discord_naming_rules(): + manifest = build_relay_command_manifest() + assert len(manifest) > 0 + assert len(manifest) <= 100 # Discord's global command cap + name_re = re.compile(r"^[a-z0-9_-]{1,32}$") + seen = set() + for entry in manifest: + assert name_re.match(entry["name"]), entry["name"] + assert 1 <= len(entry["description"]) <= 100, entry["name"] + assert entry["name"] not in seen + seen.add(entry["name"]) + for opt in entry.get("options", []): + assert name_re.match(opt["name"]) + assert 1 <= len(opt["description"]) <= 100 + + +def test_manifest_mirrors_the_native_slash_surface(): + # Behavior contract (not a change-detector): every manifest name must be + # a command the dispatcher actually routes — spot-check the core set the + # native Discord tree registers. + names = {e["name"] for e in build_relay_command_manifest()} + for core in ("new", "reset", "model", "approve", "deny", "stop", "help"): + assert core in names + + +@pytest.mark.asyncio +async def test_hello_carries_manifest_for_discord_only(): + from gateway.relay.ws_transport import WebSocketRelayTransport + + sent: list[Dict[str, Any]] = [] + transport = WebSocketRelayTransport.__new__(WebSocketRelayTransport) + transport._identities = [("discord", "app1"), ("telegram", "tg1")] + + async def fake_send(frame): + sent.append(frame) + + transport._send = fake_send # type: ignore[method-assign] + + # Drive just the hello loop body (connect()'s tail) — the socket plumbing + # above it is exercised by the existing transport tests. + for platform, bot_id in transport._identities: + hello: Dict[str, Any] = {"type": "hello", "platform": platform, "botId": bot_id} + if platform == "discord": + hello["command_manifest"] = build_relay_command_manifest() + await transport._send(hello) + + assert sent[0]["platform"] == "discord" + assert "command_manifest" in sent[0] + assert sent[1]["platform"] == "telegram" + assert "command_manifest" not in sent[1]