Files
aiturk-hermes-ide/tests/gateway/test_handoff_secondary_profile_adapter.py

217 lines
7.8 KiB
Python

"""A secondary profile's handoff must deliver through ITS OWN adapter/config.
Regression guard for the third `/handoff` multi-profile bug. Even after the
watcher polls the right ``state.db`` and the session key carries the profile
namespace, ``_process_handoff`` still resolved delivery from ``self.adapters``
and ``self.config`` — which on a multiplexed gateway hold ONLY the primary
profile's adapters and home channel. A medicina handoff was therefore sent by
the default profile's bot, to the default profile's chat, while persisting a
``agent:medicina:...`` key and reporting ``handoff_state='completed'``: a
false positive that looks fine in the database and is wrong on the wire.
This was caught by an adversarial review reading gateway.log, not by the
end-to-end test — the log line showed ``hermes_plugins.telegram_platform``
(primary) instead of the secondary's ``..._home_<hash>`` adapter module.
"""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig
from gateway.run import GatewayRunner
from gateway.session import SessionEntry
def _adapter(tag):
"""A platform adapter stand-in that records which one was used."""
a = MagicMock()
a.tag = tag
a.send = AsyncMock(return_value=SimpleNamespace(success=True))
a.create_handoff_thread = AsyncMock(return_value=None)
a._bot = None
return a
def _config(chat_id):
cfg = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
cfg.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
platform=Platform.TELEGRAM, chat_id=chat_id, name=f"home-{chat_id}",
)
return cfg
def _make_multiplex_runner():
runner = object.__new__(GatewayRunner)
runner.config = _config("1111") # primary/default home
runner.config.multiplex_profiles = True
runner.adapters = {Platform.TELEGRAM: _adapter("primary")}
runner._profile_adapters = {
"medicina": {Platform.TELEGRAM: _adapter("medicina")},
}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(
emit=AsyncMock(), emit_collect=AsyncMock(return_value=[]), loaded_hooks=False,
)
captured = {}
store = MagicMock()
store.get_or_create_session = AsyncMock(return_value=SessionEntry(
session_key="k", session_id="s",
created_at=datetime.now(), updated_at=datetime.now(),
platform=Platform.TELEGRAM, chat_type="dm",
))
async def _switch(key, sid):
captured["session_key"] = key
return SessionEntry(
session_key=key, session_id=sid,
created_at=datetime.now(), updated_at=datetime.now(),
platform=Platform.TELEGRAM, chat_type="dm",
)
store.switch_session = AsyncMock(side_effect=_switch)
# ``async_session_store`` is a derived property with no setter: it rebuilds
# the facade whenever ``facade._store is not self.session_store``. Wiring
# the mock as that ``_store`` is what makes the primed cache survive.
runner.session_store = store
runner._async_session_store = SimpleNamespace(
_store=store,
get_or_create_session=store.get_or_create_session,
switch_session=store.switch_session,
)
runner._evict_cached_agent = MagicMock()
runner._release_running_agent_state = MagicMock()
runner._session_db = None
async def _handle_message(event):
captured["source"] = event.source
return "ok"
runner._handle_message = AsyncMock(side_effect=_handle_message)
return runner, captured
def _spy_transport_factory(used):
"""Build a resolve_delivery_transport stand-in that records what it got.
The real transport exposes ``.adapter`` AND an awaitable ``.send``; the
handoff uses both, so the stand-in must too.
"""
def _spy(platform, config, adapters):
adapter = adapters[platform]
used["adapter_tag"] = adapter.tag
used["home_chat_id"] = config.get_home_channel(platform).chat_id
async def _send(_platform, _chat_id, _text, _metadata=None):
used["sent_via"] = adapter.tag
return SimpleNamespace(success=True)
return SimpleNamespace(adapter=adapter, send=_send)
return _spy
@pytest.mark.asyncio
async def test_secondary_profile_handoff_uses_its_own_adapter(monkeypatch):
"""medicina's handoff must NOT be delivered by the primary's adapter."""
runner, captured = _make_multiplex_runner()
used = {}
monkeypatch.setattr(
"gateway.run.resolve_delivery_transport", _spy_transport_factory(used),
)
# The watcher would already be inside _profile_runtime_scope here, so a
# fresh load resolves the secondary's config.
monkeypatch.setattr("gateway.run.load_gateway_config", lambda: _config("2222"))
await runner._process_handoff(
{"id": "cli-session", "title": "work", "handoff_platform": "telegram"},
profile_name="medicina",
)
assert used["adapter_tag"] == "medicina", (
"delivery must use the secondary profile's own adapter, not the primary's"
)
assert used["sent_via"] == "medicina", "the message went out on the wrong bot"
assert used["home_chat_id"] == "2222", (
"delivery must use the secondary profile's own home channel"
)
assert captured["session_key"].startswith("agent:medicina:"), (
f"session key must carry the profile namespace, got {captured['session_key']}"
)
assert captured["source"].profile == "medicina"
@pytest.mark.asyncio
async def test_default_profile_handoff_keeps_primary_adapter(monkeypatch):
"""The default/root path must behave exactly as before the fix."""
runner, captured = _make_multiplex_runner()
used = {}
monkeypatch.setattr(
"gateway.run.resolve_delivery_transport", _spy_transport_factory(used),
)
await runner._process_handoff(
{"id": "cli-session", "title": "work", "handoff_platform": "telegram"},
profile_name=None,
)
assert used["adapter_tag"] == "primary"
assert used["home_chat_id"] == "1111"
@pytest.mark.asyncio
async def test_secondary_profile_config_load_failure_fails_closed(monkeypatch):
"""A secondary profile whose config cannot load must fail the handoff.
Falling back to the primary's config delivers through the right bot to
the WRONG chat (the primary's home channel) and reports completed.
"""
runner, _ = _make_multiplex_runner()
used = {}
def _boom():
raise RuntimeError("config.yaml exploded")
monkeypatch.setattr(
"gateway.run.resolve_delivery_transport", _spy_transport_factory(used),
)
monkeypatch.setattr("gateway.run.load_gateway_config", _boom)
with pytest.raises(RuntimeError, match="could not load config"):
await runner._process_handoff(
{"id": "cli-session", "title": "work", "handoff_platform": "telegram"},
profile_name="medicina",
)
assert used == {}, (
"nothing may be delivered when the profile config fails to load"
)
@pytest.mark.asyncio
async def test_secondary_profile_without_live_adapters_fails_loudly(monkeypatch):
"""Never silently fall back to the primary's bot — that ships to the wrong chat.
Raising marks the row ``failed`` and the CLI reports it; delivering through
another profile's bot would look like success.
"""
runner, _ = _make_multiplex_runner()
runner._profile_adapters = {}
monkeypatch.setattr(
"gateway.run.resolve_delivery_transport", _spy_transport_factory({}),
)
with pytest.raises(RuntimeError, match="no live adapters"):
await runner._process_handoff(
{"id": "cli-session", "title": "work", "handoff_platform": "telegram"},
profile_name="medicina",
)