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.
182 lines
8.5 KiB
Python
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()
|