Files
hermes-agent/tests/gateway/test_identity_canonical_ingress.py
teknium1 1654575c6c fix(gateway): canonicalize identity first at every adapter ingress path
Every adapter-side lane (text/photo/album batch dicts, _active_sessions, the
busy guard, /stop /new /reset and clarify replies) derived its session key
before the receiving bot's identity was known, so two bots seeing the same
Telegram chat.id == user.id collided on one agent:main lane and a route to an
unserved profile still reached the default lane's running task.

BasePlatformAdapter._canonicalize pins the RoutingIdentity (via the new
session_identity.canonical_identity seam) as the FIRST statement of
handle_message, _enqueue_text_event, _handle_message_while_active, Telegram
_route_photo_event and every _source_session_key; _drop_unresolved drops a
rejected route at the first seam with one WARNING. Topic recovery copies the
source through replace_source so the identity travels; Slack thread sources go
through build_source so the thread key carries the same provenance.
2026-09-19 00:30:29 -07:00

182 lines
8.5 KiB
Python

"""Identity is canonicalized FIRST on every ingress path (#88715 phase 3).
Two Telegram bots on one multiplexed gateway see the same ``chat.id == user.id`` for a DM with the
same human; every adapter-side lane (batch dict, ``_active_sessions``, the busy guard, control
commands, clarify replies) must be keyed by the receiving bot's identity before anything else
derives a key. Real ``GatewayRunner`` resolvers, real ``BasePlatformAdapter`` ingress, temp home.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.pairing import PairingStore
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
from gateway.profile_routing import parse_profile_routes
from gateway.session_identity import identity_of
UID = "72719239" # Telegram DM: chat.id == user.id, identical for every bot
class _Stub(BasePlatformAdapter):
pass
_Stub.__abstractmethods__ = frozenset()
def _adapter(runner, owner=None):
adapter = _Stub.__new__(_Stub)
BasePlatformAdapter.__init__(adapter, PlatformConfig(enabled=True, extra={}), Platform.TELEGRAM)
adapter.gateway_runner = runner
adapter._session_store = SimpleNamespace(_resolve_profile_for_key=lambda s: "default")
if owner:
adapter.set_owner_profile(owner)
return adapter
@pytest.fixture
def rig(tmp_path, monkeypatch):
"""Default home owns bot A; ``team_b`` owns bot B; satellite ``ops`` is routed through bot A
for chat 5150; a route to unserved ``ghost`` for chat 4040."""
from gateway.run import GatewayRunner
home = tmp_path / "hh"
for name in ("ops", "team_b"):
(home / "profiles" / name).mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner.config.platforms = {Platform.TELEGRAM: PlatformConfig(enabled=True, extra={})}
runner.config.profile_routes = parse_profile_routes([
{"name": "ops-dm", "platform": "telegram", "profile": "ops", "chat_id": "5150"},
{"name": "ghost", "platform": "telegram", "profile": "ghost", "chat_id": "4040"},
])
runner.pairing_store = PairingStore(profile="default")
runner.pairing_stores = {}
runner._primary_profile_name = "default"
bot_a, bot_b = _adapter(runner), _adapter(runner, owner="team_b")
runner.adapters = {Platform.TELEGRAM: bot_a}
runner._profile_adapters = {"team_b": {Platform.TELEGRAM: bot_b}, "ops": {}}
served = [("default", home), ("ops", home / "profiles" / "ops"), ("team_b", home / "profiles" / "team_b")]
with patch("hermes_cli.profiles.profiles_to_serve", return_value=served), \
patch("hermes_cli.profiles.get_profile_dir", side_effect=lambda n: home if n == "default" else home / "profiles" / n), \
patch("hermes_cli.profiles.profile_exists", return_value=True):
yield SimpleNamespace(runner=runner, home=home, bot_a=bot_a, bot_b=bot_b)
def _event(adapter, chat_id, text, **kw):
source = adapter.build_source(chat_id=chat_id, chat_type="dm", user_id=chat_id)
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source, message_id="m", **kw)
async def _hold(adapter, key):
guard = asyncio.Event()
adapter._active_sessions[key] = guard
adapter._session_tasks[key] = asyncio.create_task(asyncio.sleep(30))
return guard
@pytest.mark.asyncio
async def test_two_bots_same_chat_have_distinct_lanes_and_stop_cannot_cross(rig):
"""Acceptance row A: the same human DMs both bots. Batching, the active-session guard and a
``/stop`` all resolve on the receiving bot's lane; ``/stop`` on A never touches B's run, and the
clarify reply on A resolves A's pending prompt, not B's."""
seen = []
async def handler(event):
seen.append((identity_of(event.source).transport_profile, event.text))
return None
for bot in (rig.bot_a, rig.bot_b):
bot.set_message_handler(handler)
ev_a, ev_b = _event(rig.bot_a, UID, "hello A"), _event(rig.bot_b, UID, "hello B")
rig.bot_a._enqueue_text_event(ev_a)
rig.bot_b._enqueue_text_event(ev_b)
key_a, key_b = rig.bot_a._event_session_key(ev_a), rig.bot_b._event_session_key(ev_b)
assert key_a == f"agent:main:telegram:dm:{UID}" and key_b == f"agent:team_b:telegram:dm:{UID}"
assert list(rig.bot_a._pending_text_batches) == [key_a] and list(rig.bot_b._pending_text_batches) == [key_b]
# The identity was pinned BEFORE the key was derived, and the two lanes never share a source.
assert identity_of(ev_a.source).transport_profile == "default"
assert identity_of(ev_b.source).transport_profile == "team_b"
for bot in (rig.bot_a, rig.bot_b):
for task in list(bot._pending_text_batch_tasks.values()):
task.cancel()
bot._pending_text_batches.clear()
# Both bots busy on "their" chat; /stop on A interrupts A's guard only and B's task survives.
guard_a, guard_b = await _hold(rig.bot_a, key_a), await _hold(rig.bot_b, key_b)
task_b = rig.bot_b._session_tasks[key_b]
await rig.bot_a.handle_message(_event(rig.bot_a, UID, "/stop", allow_gateway_control=True))
await asyncio.sleep(0)
assert seen == [("default", "/stop")]
assert key_a not in rig.bot_a._session_tasks
assert rig.bot_b._session_tasks[key_b] is task_b and not task_b.cancelled()
assert rig.bot_b._active_sessions[key_b] is guard_b and not guard_b.is_set()
assert not guard_a.is_set() # the command path replaced A's guard; the run was cancelled, not flagged
# Clarify: pending prompts on both lanes; the plain-text answer on A resolves A only.
from tools import clarify_gateway
clarify_gateway.register("c-a", key_a, "Which?", ["x", "y"])
clarify_gateway.register("c-b", key_b, "Which?", ["x", "y"])
clarify_gateway.mark_awaiting_text("c-a")
clarify_gateway.mark_awaiting_text("c-b")
try:
await _hold(rig.bot_a, key_a)
await rig.bot_a.handle_message(_event(rig.bot_a, UID, "x", allow_gateway_control=True))
assert seen[-1] == ("default", "x") # routed through A's inline clarify intercept
assert clarify_gateway.get_pending_for_session(key_b, include_choice_prompts=True) is not None
assert key_b not in rig.bot_a._pending_messages and key_a not in rig.bot_b._pending_messages
finally:
clarify_gateway.clear_session(key_a)
clarify_gateway.clear_session(key_b)
for bot in (rig.bot_a, rig.bot_b):
for task in bot._session_tasks.values():
task.cancel()
@pytest.mark.asyncio
async def test_shared_bot_routed_chat_runs_as_satellite_and_unserved_route_is_dropped_everywhere(rig):
"""Acceptance row B: a chat routed through the shared bot to satellite ``ops`` keys into
``agent:ops`` on every ingress path (fresh, batch, busy) while the transport stays the receiving
bot. A route to an unserved profile is dropped at every path — never ``agent:main``."""
calls = []
async def handler(event):
calls.append(event.text)
return None
async def busy(event, session_key):
calls.append(("busy", session_key, identity_of(event.source).runtime_profile))
return True
rig.bot_a.set_message_handler(handler)
rig.bot_a.set_busy_session_handler(busy)
routed = _event(rig.bot_a, "5150", "hi ops")
key = rig.bot_a._event_session_key(routed)
identity = identity_of(routed.source)
assert key == "agent:ops:telegram:dm:5150"
assert (identity.transport_profile, identity.runtime_profile) == ("default", "ops")
assert identity.adapter() is rig.bot_a and identity.runtime_home == rig.home / "profiles" / "ops"
await _hold(rig.bot_a, key)
await rig.bot_a.handle_message(_event(rig.bot_a, "5150", "follow-up"))
assert calls[-1] == ("busy", key, "ops")
# Unserved route: fresh, batched, busy and control paths all drop; no lane is created or touched.
main_key = "agent:main:telegram:dm:4040"
guard = await _hold(rig.bot_a, main_key)
for text, kw in (("hello", {}), ("/stop", {"allow_gateway_control": True})):
await rig.bot_a.handle_message(_event(rig.bot_a, "4040", text, **kw))
rig.bot_a._enqueue_text_event(_event(rig.bot_a, "4040", "chunk"))
assert calls == [("busy", key, "ops")]
assert not rig.bot_a._pending_text_batches and main_key not in rig.bot_a._pending_messages
assert rig.bot_a._active_sessions[main_key] is guard and main_key in rig.bot_a._session_tasks
for task in rig.bot_a._session_tasks.values():
task.cancel()