diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 9ec40732b6..a4038f8d41 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -119,8 +119,13 @@ Both absent ⇒ byte-identical to today. A connector that never sends them, or a `PassthroughForward` is the wire form of a forwarded passthrough-plane request (Class-2/3 webhooks — Discord interactions, Twilio): `{platform, botId, method, -path, headers: [[k,v],…], bodyB64}`. The body is base64-encoded so arbitrary -bytes survive the newline-delimited-JSON transport; the gateway base64-decodes +path, headers: [[k,v],…], bodyB64, profile?}`. `profile` is optional — the +connector stamps it when NAS resolves the target profile for a Team-Gateway +interaction; omitting it (single-profile gateways) preserves legacy routing to +the default `agent:main` session namespace, mirroring the `profile` field the +`inbound` frame's `SessionSource` already carries (#60586). The body is +base64-encoded so arbitrary bytes survive the newline-delimited-JSON transport; +the gateway base64-decodes back to the exact bytes the connector forwarded (the connector already verified the provider signature and stripped any shared-identity credential at the edge — §6 — so the gateway re-processes a sanitized, token-free body and acts on it via diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 1fdeea8d93..a42bef5d83 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -1620,6 +1620,13 @@ class RelayAdapter(BasePlatformAdapter): # how platform=RELAY home channels slipped through in the first # place. Set locally, never read off the wire. delivered_via_upstream_relay=True, + # The HERMES profile this interaction is routed to (multiplex + # mode) — mirrors _event_from_wire's profile stamping for plain + # relayed messages (#60586). Without this, a Team-Gateway's + # Discord slash-command/button/modal always fell back to the + # legacy agent:main namespace even when the connector resolved + # a specific profile for it. + profile=getattr(forward, "profile", None), ) event = MessageEvent(text=text, message_type=message_type, source=source) if itype == 3: diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index 5e90887c57..bba21cd171 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -402,6 +402,16 @@ class PassthroughForward: path: str headers: list[tuple[str, str]] body: bytes + # The HERMES profile this interaction is routed to (multiplex mode). + # Mirrors the ``profile`` field _event_from_wire already carries on the + # ``inbound`` frame's SessionSource (#60586) — the connector stamps it + # when NAS resolves the target profile for a Team-Gateway interaction; + # absent for a single-profile gateway, where it stays None and session + # keys keep the legacy ``agent:main`` namespace. Without this, a Discord + # slash-command/button/modal relayed through the passthrough plane always + # fell back to agent:main even when the equivalent plain message would + # have been routed to the correct profile. + profile: Optional[str] = None def _passthrough_from_wire(raw: Dict[str, Any]) -> PassthroughForward: @@ -431,6 +441,7 @@ def _passthrough_from_wire(raw: Dict[str, Any]) -> PassthroughForward: path=str(raw.get("path", "")), headers=headers, body=body, + profile=raw.get("profile"), ) diff --git a/tests/gateway/relay/test_relay_passthrough.py b/tests/gateway/relay/test_relay_passthrough.py index 2150e9bf0b..a8e27e4335 100644 --- a/tests/gateway/relay/test_relay_passthrough.py +++ b/tests/gateway/relay/test_relay_passthrough.py @@ -44,7 +44,7 @@ def adapter(): return RelayAdapter(PlatformConfig(), _desc(), transport=StubConnector(_desc())) -def _interaction_forward(payload: dict) -> PassthroughForward: +def _interaction_forward(payload: dict, *, profile: str | None = None) -> PassthroughForward: body = json.dumps(payload).encode("utf-8") return PassthroughForward( platform="discord", @@ -53,6 +53,7 @@ def _interaction_forward(payload: dict) -> PassthroughForward: path="/interactions/discord/appShared", headers=[("content-type", "application/json")], body=body, + profile=profile, ) @@ -75,6 +76,27 @@ def test_passthrough_from_wire_byte_preserves_body(): 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 @@ -137,6 +159,40 @@ async def test_discord_interaction_routes_through_handle_message(adapter, monkey 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