fix(relay): carry routed profile through the passthrough-plane forward
The relay text lane stamps `SessionSource.profile` from the wire frame (#60586), but `PassthroughForward` had no profile field, so a relayed Discord slash-command/button/modal always landed in the default profile's agent:main namespace even when the connector resolved a specific profile. Add an optional `profile` to `PassthroughForward` (read off the wire in `_passthrough_from_wire`) and stamp it on the interaction's SessionSource. Absent on the wire → None → legacy routing, byte-identical for single-profile gateways. Contract doc updated. Salvaged from #61012 (pierrenode); two context conflicts resolved (delivered_via_upstream_relay / _platform_by_chat landed on main).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user