mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
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:
parent
760112adb6
commit
ebab890ae5
13 changed files with 540 additions and 12 deletions
|
|
@ -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",)
|
||||
|
|
|
|||
123
tests/gateway/relay/test_handoff_relay_aliasing.py
Normal file
123
tests/gateway/relay/test_handoff_relay_aliasing.py
Normal 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"}
|
||||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
68
tests/gateway/relay/test_wire_user_identity.py
Normal file
68
tests/gateway/relay/test_wire_user_identity.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue