Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
"""Tests for GatewayRunner._resolve_profile_home_for_source — profile resolution logic."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.profile_routing import ProfileRoute, ProfileRouteRejected
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runner():
|
||||
"""Create a minimal mock GatewayRunner with the methods we need."""
|
||||
runner = MagicMock(spec=GatewayRunner)
|
||||
runner.config = MagicMock(profile_routes=[])
|
||||
# Bind the actual methods to the mock
|
||||
runner._profile_name_for_source = GatewayRunner._profile_name_for_source.__get__(runner)
|
||||
runner._resolve_profile_home_for_source = GatewayRunner._resolve_profile_home_for_source.__get__(runner)
|
||||
return runner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def discord_source():
|
||||
"""Create a basic Discord SessionSource for testing."""
|
||||
return SessionSource(
|
||||
platform=MagicMock(value="discord"),
|
||||
chat_id="123456",
|
||||
guild_id="789",
|
||||
thread_id=None,
|
||||
parent_chat_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telegram_source():
|
||||
"""Create a basic Telegram SessionSource for testing.
|
||||
|
||||
Telegram (like Slack/Feishu/etc.) has no ``guild_id`` — only ``chat_id``.
|
||||
Used to prove profile routing is platform-generic, not Discord-only.
|
||||
"""
|
||||
return SessionSource(
|
||||
platform=MagicMock(value="telegram"),
|
||||
chat_id="-1001234567890",
|
||||
guild_id=None,
|
||||
thread_id=None,
|
||||
parent_chat_id=None,
|
||||
)
|
||||
|
||||
|
||||
class TestResolutionOrder:
|
||||
"""Tests that profile resolution follows the correct priority order."""
|
||||
|
||||
def test_source_profile_wins_over_routing(self, mock_runner, discord_source):
|
||||
"""source.profile should be used even if routing would match."""
|
||||
discord_source.profile = "from-source"
|
||||
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
|
||||
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
|
||||
with patch("hermes_cli.profiles.profile_exists", return_value=True):
|
||||
mock_get_dir.return_value = Path("/hermes/profiles/from-source")
|
||||
result = mock_runner._resolve_profile_home_for_source(discord_source)
|
||||
|
||||
assert result == Path("/hermes/profiles/from-source")
|
||||
mock_get_dir.assert_called_once_with("from-source")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestMissingProfileWarning:
|
||||
"""Tests for warning when a profile doesn't exist on disk."""
|
||||
|
||||
def test_nonexistent_profile_warning(self, mock_runner, discord_source, caplog):
|
||||
"""When source.profile points to a nonexistent profile, log a WARNING."""
|
||||
discord_source.profile = "nonexistent"
|
||||
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
|
||||
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
|
||||
mock_get_dir.return_value = Path("/hermes/profiles/nonexistent")
|
||||
with patch("hermes_cli.profiles.profile_exists", return_value=False):
|
||||
with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = mock_runner._resolve_profile_home_for_source(discord_source)
|
||||
|
||||
# Should fall back to global HERMES_HOME
|
||||
assert result == Path("/hermes")
|
||||
|
||||
# Should have logged a warning
|
||||
assert len(caplog.records) == 1
|
||||
assert caplog.records[0].levelname == "WARNING"
|
||||
assert "nonexistent" in caplog.records[0].message
|
||||
assert "does not exist" in caplog.records[0].message
|
||||
assert "discord" in caplog.records[0].message
|
||||
assert "123456" in caplog.records[0].message
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TestExceptionHandling:
|
||||
"""Tests for exception handling in profile resolution."""
|
||||
|
||||
def test_get_profile_dir_exception_logs_warning(self, mock_runner, discord_source, caplog):
|
||||
"""When get_profile_dir raises an exception, log a WARNING with context."""
|
||||
discord_source.profile = "bad-profile"
|
||||
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
|
||||
with patch("hermes_cli.profiles.get_profile_dir", side_effect=ValueError("Invalid profile name")):
|
||||
with patch("hermes_constants.get_hermes_home", return_value=Path("/hermes")):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = mock_runner._resolve_profile_home_for_source(discord_source)
|
||||
|
||||
# Should fall back to global HERMES_HOME
|
||||
assert result == Path("/hermes")
|
||||
|
||||
# Should have logged a warning with exception info
|
||||
assert len(caplog.records) == 1
|
||||
assert caplog.records[0].levelname == "WARNING"
|
||||
assert "bad-profile" in caplog.records[0].message
|
||||
assert "Failed to resolve profile directory" in caplog.records[0].message
|
||||
|
||||
|
||||
|
||||
class TestRoutingConsultation:
|
||||
"""Tests that _profile_name_for_source is consulted when source.profile is empty."""
|
||||
|
||||
def test_routing_consulted_when_source_profile_empty(self, mock_runner, discord_source):
|
||||
"""_profile_name_for_source should be called when source.profile is empty."""
|
||||
discord_source.profile = None
|
||||
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="active"):
|
||||
with patch("hermes_cli.profiles.get_profile_dir") as mock_get_dir:
|
||||
mock_get_dir.return_value = Path("/hermes/profiles/routed")
|
||||
|
||||
mock_runner._profile_name_for_source = MagicMock(return_value="routed")
|
||||
|
||||
mock_runner._resolve_profile_home_for_source(discord_source)
|
||||
|
||||
# Should have called routing
|
||||
mock_runner._profile_name_for_source.assert_called_once_with(discord_source)
|
||||
|
||||
|
||||
|
||||
class TestNonDiscordProfileRouting:
|
||||
"""Profile routing must be platform-generic, not Discord-only.
|
||||
|
||||
Regression coverage for the ``gateway_runner`` injection gap: previously
|
||||
only Discord's adapter pre-declared ``gateway_runner``, so only Discord
|
||||
ever had ``build_source`` call ``_profile_name_for_source``. Telegram /
|
||||
Feishu / Slack / etc. silently fell through to the default profile. These
|
||||
tests pin the resolution half for a non-Discord platform (Telegram).
|
||||
"""
|
||||
|
||||
def test_telegram_route_resolves(self, mock_runner, telegram_source):
|
||||
"""A configured Telegram route resolves to its profile via the real
|
||||
``_profile_name_for_source`` (bound onto the mock runner)."""
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(name="tg", platform="telegram", profile="tg-profile",
|
||||
chat_id="-1001234567890"),
|
||||
]
|
||||
telegram_source.profile = None
|
||||
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default")),
|
||||
("tg-profile", Path("/profiles/tg-profile"))],
|
||||
):
|
||||
assert mock_runner._profile_name_for_source(telegram_source) == "tg-profile"
|
||||
|
||||
def test_route_inside_allowlist_resolves(self, mock_runner, telegram_source):
|
||||
mock_runner.config.multiplex_profile_allowlist = ["worker"]
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(
|
||||
name="worker-route",
|
||||
platform="telegram",
|
||||
profile="worker",
|
||||
chat_id="route-chat",
|
||||
)
|
||||
]
|
||||
telegram_source.chat_id = "route-chat"
|
||||
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default")),
|
||||
("worker", Path("/profiles/worker"))],
|
||||
) as enumerate_profiles:
|
||||
assert mock_runner._profile_name_for_source(telegram_source) == "worker"
|
||||
|
||||
enumerate_profiles.assert_called_once_with(
|
||||
multiplex=True, profile_allowlist=["worker"]
|
||||
)
|
||||
|
||||
def test_route_outside_allowlist_rejects(self, mock_runner, telegram_source, caplog):
|
||||
mock_runner.config.multiplex_profile_allowlist = ["worker"]
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(
|
||||
name="restricted-route",
|
||||
platform="telegram",
|
||||
profile="restricted",
|
||||
chat_id="route-chat",
|
||||
)
|
||||
]
|
||||
telegram_source.chat_id = "route-chat"
|
||||
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default")),
|
||||
("worker", Path("/profiles/worker"))],
|
||||
), caplog.at_level(logging.WARNING, logger="gateway.run"):
|
||||
with pytest.raises(ProfileRouteRejected):
|
||||
mock_runner._profile_name_for_source(telegram_source)
|
||||
|
||||
assert "target profile 'restricted' is not served" in caplog.text
|
||||
|
||||
def test_no_route_match_preserves_default_sentinel(self, mock_runner, telegram_source):
|
||||
mock_runner.config.multiplex_profile_allowlist = ["worker"]
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(
|
||||
name="other-chat",
|
||||
platform="telegram",
|
||||
profile="worker",
|
||||
chat_id="different-chat",
|
||||
)
|
||||
]
|
||||
telegram_source.chat_id = "route-chat"
|
||||
|
||||
assert mock_runner._profile_name_for_source(telegram_source) is None
|
||||
adapter = _stub_adapter(Platform.TELEGRAM, mock_runner)
|
||||
source = adapter.build_source(chat_id="route-chat", chat_type="group")
|
||||
assert source.profile is None
|
||||
|
||||
|
||||
class TestGatewayRunnerInjection:
|
||||
"""``BasePlatformAdapter`` declares ``gateway_runner`` so the gateway's
|
||||
unconditional injection reaches every platform adapter — the foundation
|
||||
that makes the routing in TestNonDiscordProfileRouting reachable at runtime.
|
||||
"""
|
||||
|
||||
def test_base_adapter_declares_gateway_runner(self):
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
# Class-level attribute exists and defaults to None.
|
||||
assert hasattr(BasePlatformAdapter, "gateway_runner")
|
||||
assert BasePlatformAdapter.gateway_runner is None
|
||||
|
||||
def test_factory_binds_every_adapter_to_runner(self, monkeypatch):
|
||||
"""``_create_adapter`` binds the runner regardless of which branch
|
||||
built the adapter (plugin registry OR built-in if/elif) — every
|
||||
lifecycle path (startup, reconnect, secondary profiles) goes through
|
||||
it, so this is the single seam that makes profile_routes reachable
|
||||
for built-ins like Signal (#68332 / #70831)."""
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
adapter = MagicMock(spec=BasePlatformAdapter)
|
||||
monkeypatch.setattr(runner, "_instantiate_adapter", lambda platform, config: adapter)
|
||||
assert runner._create_adapter(Platform.SIGNAL, PlatformConfig(enabled=True)) is adapter
|
||||
assert adapter.gateway_runner is runner
|
||||
monkeypatch.setattr(runner, "_instantiate_adapter", lambda platform, config: None)
|
||||
assert runner._create_adapter(Platform.SIGNAL, PlatformConfig(enabled=True)) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_signal_factory_routes_inbound_group_event(self, monkeypatch):
|
||||
"""A factory-built (built-in) Signal adapter resolves profile_routes
|
||||
for a real inbound envelope — fails on main where the Signal branch
|
||||
returned a bare ``SignalAdapter(config)`` with no runner."""
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
group_id = "test-signal-route"
|
||||
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", group_id)
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
multiplex_profiles=True,
|
||||
profile_routes=[
|
||||
ProfileRoute(name="signal", platform="signal", profile="ops", chat_id=f"group:{group_id}"),
|
||||
],
|
||||
)
|
||||
adapter = runner._create_adapter(
|
||||
Platform.SIGNAL,
|
||||
PlatformConfig(enabled=True, extra={"http_url": "http://127.0.0.1:18080", "account": "+15555550123"}),
|
||||
)
|
||||
assert adapter is not None and adapter.gateway_runner is runner
|
||||
|
||||
captured = {}
|
||||
|
||||
async def capture_event(event):
|
||||
captured["event"] = event
|
||||
|
||||
adapter.handle_message = capture_event
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default")), ("ops", Path("/profiles/ops"))],
|
||||
):
|
||||
await adapter._handle_envelope({
|
||||
"envelope": {
|
||||
"sourceNumber": "+15555550124",
|
||||
"sourceName": "Test Operator",
|
||||
"timestamp": 1700000000000,
|
||||
"dataMessage": {
|
||||
"message": "diagnose the cluster",
|
||||
"groupInfo": {"groupId": group_id, "groupName": "US East 7"},
|
||||
},
|
||||
},
|
||||
})
|
||||
source = captured["event"].source
|
||||
assert source.profile == "ops"
|
||||
assert build_session_key(source, profile=source.profile).startswith("agent:ops:")
|
||||
|
||||
|
||||
# A concrete adapter we can instantiate without the full platform stack.
|
||||
# ``build_source`` only reads ``self.platform`` and ``self.gateway_runner``, so a
|
||||
# bare instance with those two attrs exercises the real BasePlatformAdapter
|
||||
# method end-to-end. Clearing ``__abstractmethods__`` lets ``__new__`` bypass
|
||||
# the ABC instantiation guard without stubbing connect/send/get_chat_info/…
|
||||
class _StubAdapter(BasePlatformAdapter):
|
||||
pass
|
||||
|
||||
|
||||
_StubAdapter.__abstractmethods__ = frozenset() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _stub_adapter(platform: Platform, runner) -> "_StubAdapter":
|
||||
a = _StubAdapter.__new__(_StubAdapter)
|
||||
a.platform = platform
|
||||
a.gateway_runner = runner
|
||||
return a
|
||||
|
||||
|
||||
class TestAdapterToSessionKeyIntegration:
|
||||
"""Adapter -> ``source.profile`` -> session-key integration coverage.
|
||||
|
||||
The review asked for integration coverage for Discord AND a non-Discord
|
||||
platform. These drive a concrete adapter's real ``build_source``
|
||||
(BasePlatformAdapter) with an injected ``gateway_runner``, assert the
|
||||
matched route's profile is stamped on the source, and that the resulting
|
||||
session key is profile-scoped (``agent:<profile>:...`` rather than the
|
||||
shared ``agent:main:...``). The Telegram case is the bug-#2 regression:
|
||||
pre-fix it never received ``gateway_runner`` and fell through to default.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _routes():
|
||||
return [
|
||||
ProfileRoute(name="dc", platform="discord", profile="coder",
|
||||
guild_id="111", chat_id="222"),
|
||||
ProfileRoute(name="tg", platform="telegram", profile="ops",
|
||||
chat_id="-1001234567890"),
|
||||
]
|
||||
|
||||
def test_discord_adapter_stamps_profile_and_scopes_key(self, mock_runner):
|
||||
mock_runner.config.profile_routes = self._routes()
|
||||
adapter = _stub_adapter(Platform.DISCORD, mock_runner)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default")),
|
||||
("coder", Path("/profiles/coder"))],
|
||||
):
|
||||
source = adapter.build_source(
|
||||
chat_id="222", chat_type="group", guild_id="111", user_id="u1",
|
||||
)
|
||||
assert source.profile == "coder"
|
||||
|
||||
key = build_session_key(source, profile=source.profile)
|
||||
assert key.startswith("agent:coder:"), key
|
||||
# A default-profile key would land in agent:main — must differ.
|
||||
assert key != build_session_key(source, profile=None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_drops_rejected_route_before_dispatch(self, mock_runner):
|
||||
mock_runner.config.multiplex_profile_allowlist = []
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(
|
||||
name="restricted-route",
|
||||
platform="telegram",
|
||||
profile="restricted",
|
||||
chat_id="route-chat",
|
||||
)
|
||||
]
|
||||
adapter = _stub_adapter(Platform.TELEGRAM, mock_runner)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default"))],
|
||||
):
|
||||
source = adapter.build_source(chat_id="route-chat", chat_type="group")
|
||||
|
||||
assert source.profile is None
|
||||
assert source.profile_route_rejected is True
|
||||
roundtrip = SessionSource.from_dict(source.to_dict())
|
||||
assert roundtrip.profile_route_rejected is False
|
||||
assert roundtrip == source
|
||||
result = await GatewayRunner._handle_message(
|
||||
mock_runner,
|
||||
MessageEvent(text="discard me", source=source),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_source_is_rejected_at_shared_ingress(self, mock_runner):
|
||||
mock_runner.config.multiplex_profiles = True
|
||||
mock_runner.config.multiplex_profile_allowlist = []
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(
|
||||
name="restricted-route",
|
||||
platform="telegram",
|
||||
profile="restricted",
|
||||
chat_id="route-chat",
|
||||
)
|
||||
]
|
||||
source = SessionSource(platform=Platform.TELEGRAM, chat_id="route-chat")
|
||||
|
||||
with patch(
|
||||
"hermes_cli.profiles.profiles_to_serve",
|
||||
return_value=[("default", Path("/profiles/default"))],
|
||||
):
|
||||
result = await GatewayRunner._handle_message(
|
||||
mock_runner,
|
||||
MessageEvent(text="discard me", source=source),
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert source.profile is None
|
||||
assert source.profile_route_rejected is True
|
||||
|
||||
|
||||
class TestMultiplexGate:
|
||||
"""``profile_routes`` only activates under ``gateway.multiplex_profiles``.
|
||||
|
||||
Routing stamps ``source.profile``, which namespaces session/batch keys —
|
||||
but the profile-scoped agent run (``_profile_runtime_scope``) only engages
|
||||
when multiplexing is on. Without the gate, a configured route with
|
||||
multiplexing off would split batch/session keys into ``agent:<profile>``
|
||||
while the agent still served the turn from ``agent:main``'s home.
|
||||
"""
|
||||
|
||||
def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source):
|
||||
mock_runner.config.multiplex_profiles = False
|
||||
mock_runner.config.profile_routes = [
|
||||
ProfileRoute(name="dc", platform="discord", profile="coder",
|
||||
guild_id="789", chat_id="123456"),
|
||||
]
|
||||
discord_source.profile = None
|
||||
|
||||
assert mock_runner._profile_name_for_source(discord_source) is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user