feat(relay): Phase 1 parity — supported_ops discovery, wire identity fields, /handoff aliasing, provision displayName (#71300)

- CapabilityDescriptor.supported_ops + supports_op() with legacy-op-set
  fallback (additive, contract doc §2 updated)
- get_chat_info gated on op discovery (skip round trip on legacy connectors)
- _event_from_wire consumes user_display_name/user_handle (§3 fields
  previously dropped); native display-name parity, session-key stable
- /handoff <fronted-platform> works on relay-fronted gateways: CLI pre-check
  reads the GATEWAY_RELAY_PLATFORMS fronted set; watcher resolves through
  resolve_delivery_transport and replies via send_for_platform
- self-provision forwards displayName (env > skin branding, stock brand
  suppressed) — the primary name source for gg#171 attribution
This commit is contained in:
Ben Barclay 2026-07-25 20:36:16 +10:00 committed by GitHub
parent 760112adb6
commit ebab890ae5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 540 additions and 12 deletions

View file

@ -96,3 +96,45 @@ 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_supported_ops_roundtrips_json():
d = _telegram_descriptor(supported_ops=("send", "edit", "typing"))
restored = CapabilityDescriptor.from_json(d.to_json())
assert restored.supported_ops == ("send", "edit", "typing")
assert restored == d
def test_supports_op_advertised_list_is_authoritative():
d = _telegram_descriptor(supported_ops=("send", "typing", "get_chat_info"))
assert d.supports_op("send") is True
assert d.supports_op("get_chat_info") is True
# An advertised list EXCLUDES what it omits — even a legacy op.
assert d.supports_op("edit") is False
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",)

View file

@ -0,0 +1,123 @@
"""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
def test_unfronted_platform_still_fails(self):
"""Aliasing must not let relay hijack a platform it does not front."""
relay = _RelayStub({"telegram"})
cfg = _config_with({Platform.RELAY: PlatformConfig(enabled=True)})
transport = resolve_delivery_transport(
Platform.DISCORD, cfg, {Platform.RELAY: relay}
)
assert transport is None
def test_native_adapter_wins_over_relay(self):
native = object()
relay = _RelayStub({"discord"})
cfg = _config_with(
{
Platform.DISCORD: PlatformConfig(enabled=True),
Platform.RELAY: PlatformConfig(enabled=True),
}
)
transport = resolve_delivery_transport(
Platform.DISCORD, cfg, {Platform.DISCORD: native, Platform.RELAY: relay}
)
assert transport is not None
assert transport.adapter is native
assert transport.is_relay is False
@pytest.mark.asyncio
async def test_transport_send_stamps_logical_platform(self):
"""The handoff reply leg must go through send_for_platform so the
outbound frame carries the logical platform tag."""
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
result = await transport.send(
Platform.DISCORD, "chan-1", "handed-off reply", {"thread_id": "t-9"}
)
assert result.success is True
assert relay.sent == [("discord", "chan-1", "handed-off reply", {"thread_id": "t-9"})]
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
def test_unconfigured_env_yields_generic_relay_only(self, monkeypatch):
monkeypatch.delenv("GATEWAY_RELAY_PLATFORMS", raising=False)
from gateway.relay import relay_platform_identities
fronted = {p for p, _ in relay_platform_identities()}
assert fronted == {"relay"}

View file

@ -508,3 +508,58 @@ async def test_no_revocation_no_fatal():
except asyncio.CancelledError:
pass
assert a.has_fatal_error is False
# ─────────────── 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_proxied_when_advertised():
t = _ChatInfoTransport()
a = RelayAdapter(
PlatformConfig(),
make_desc(supported_ops=("send", "edit", "typing", "get_chat_info")),
transport=t,
)
info = await a.get_chat_info("chan-1")
assert info == {"name": "general", "type": "channel"}
assert t.calls == ["chan-1"]
@pytest.mark.asyncio
async def test_get_chat_info_local_fallback_for_legacy_connector():
"""A legacy connector (no supported_ops) would only answer 'unsupported op'
the adapter must skip the round trip and answer locally."""
t = _ChatInfoTransport()
a = RelayAdapter(PlatformConfig(), make_desc(), transport=t)
info = await a.get_chat_info("chan-1")
assert info == {"name": "chan-1", "type": "dm"}
assert t.calls == []
@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 == []

View file

@ -20,6 +20,8 @@ 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
@ -112,6 +114,12 @@ async def test_outbound_send_round_trips(wired):
@pytest.mark.asyncio
async def test_get_chat_info_proxied_to_connector(wired):
adapter, stub = wired
# Phase 1: the proxy is gated on op discovery — the connector must
# advertise get_chat_info in supported_ops (a legacy descriptor falls
# back to the local echo; see test_relay_adapter.py).
adapter._apply_descriptor(
replace(_discord_descriptor(), supported_ops=("send", "edit", "typing", "get_chat_info"))
)
stub.chat_info["chan1"] = {"name": "general", "type": "group"}
info = await adapter.get_chat_info("chan1")
assert info == {"name": "general", "type": "group"}

View file

@ -375,3 +375,107 @@ def test_connector_failure_is_non_fatal(monkeypatch):
monkeypatch.setattr(relay, "_post_provision", _boom)
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_env_wins(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_DISPLAY_NAME", " Atlas ")
assert relay.relay_display_name() == "Atlas"
def test_relay_display_name_caps_at_64(monkeypatch):
monkeypatch.setenv("GATEWAY_RELAY_DISPLAY_NAME", "x" * 200)
assert relay.relay_display_name() == "x" * 64
def test_relay_display_name_falls_back_to_skin_branding(monkeypatch):
monkeypatch.delenv("GATEWAY_RELAY_DISPLAY_NAME", raising=False)
class _Skin:
def get_branding(self, key, fallback=""):
return "Chatterbox" if key == "agent_name" else fallback
monkeypatch.setattr("hermes_cli.skin_engine.get_active_skin", lambda: _Skin())
assert relay.relay_display_name() == "Chatterbox"
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
def test_relay_display_name_branding_failure_is_non_fatal(monkeypatch):
monkeypatch.delenv("GATEWAY_RELAY_DISPLAY_NAME", raising=False)
def _boom():
raise RuntimeError("no skin engine")
monkeypatch.setattr("hermes_cli.skin_engine.get_active_skin", _boom)
assert relay.relay_display_name() is None
def test_self_provision_forwards_display_name(monkeypatch):
_arm(monkeypatch)
monkeypatch.setenv("GATEWAY_RELAY_DISPLAY_NAME", "Atlas")
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
assert captured["display_name"] == "Atlas"
def test_post_provision_body_includes_displayName_only_when_set(monkeypatch):
"""`displayName` joins the body ONLY when a value is supplied — omitting it
lets the connector store null (attribution falls back to linked-owner)."""
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)
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=[],
display_name="Atlas",
)
assert sent["body"]["displayName"] == "Atlas"
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 "displayName" not in sent["body"]

View file

@ -0,0 +1,68 @@
"""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_display_name_preferred_over_user_name(self):
ev = _event_from_wire(_wire_event(user_display_name="Ben Display"))
assert ev.source.user_name == "Ben Display"
def test_user_name_when_no_display_name(self):
ev = _event_from_wire(_wire_event())
assert ev.source.user_name == "rawusername"
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_all_absent_yields_none(self):
ev = _event_from_wire(_wire_event(user_name=None))
assert ev.source.user_name is None
def test_empty_display_name_falls_through(self):
"""An empty-string enrichment must not shadow a real user_name."""
ev = _event_from_wire(_wire_event(user_display_name=""))
assert ev.source.user_name == "rawusername"
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)