Hermes-agent
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
"""Shared fixtures for gateway e2e tests (Telegram, Discord).
|
||||
|
||||
These tests exercise the full async message flow:
|
||||
adapter.handle_message(event)
|
||||
→ background task
|
||||
→ GatewayRunner._handle_message (command dispatch)
|
||||
→ adapter.send() (captured by mock)
|
||||
|
||||
No LLM, no real platform connections.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, SendResult
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
|
||||
E2E_MESSAGE_SETTLE_DELAY = 0.3
|
||||
|
||||
# Platform library mocks
|
||||
|
||||
# Ensure telegram module is available (mock it if not installed)
|
||||
def _ensure_telegram_mock():
|
||||
"""Install mock telegram modules so TelegramAdapter can be imported."""
|
||||
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
|
||||
return # Real library installed
|
||||
|
||||
telegram_mod = MagicMock()
|
||||
telegram_mod.Update = MagicMock()
|
||||
telegram_mod.Update.ALL_TYPES = []
|
||||
telegram_mod.Bot = MagicMock
|
||||
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
|
||||
telegram_mod.ext.Application = MagicMock()
|
||||
telegram_mod.ext.Application.builder = MagicMock
|
||||
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
|
||||
telegram_mod.ext.MessageHandler = MagicMock
|
||||
telegram_mod.ext.CommandHandler = MagicMock
|
||||
telegram_mod.ext.filters = MagicMock()
|
||||
telegram_mod.request.HTTPXRequest = MagicMock
|
||||
|
||||
for name in (
|
||||
"telegram",
|
||||
"telegram.constants",
|
||||
"telegram.ext",
|
||||
"telegram.ext.filters",
|
||||
"telegram.request",
|
||||
):
|
||||
sys.modules.setdefault(name, telegram_mod)
|
||||
|
||||
|
||||
# Ensure discord module is available (mock it if not installed)
|
||||
def _ensure_discord_mock():
|
||||
"""Install mock discord modules so DiscordAdapter can be imported."""
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return # Real library installed
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Forbidden = type("Forbidden", (Exception,), {})
|
||||
discord_mod.MessageType = SimpleNamespace(default=0, reply=19)
|
||||
discord_mod.Object = lambda *, id: SimpleNamespace(id=id)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
discord_mod.opus.is_loaded.return_value = True
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
sys.modules.setdefault("discord.opus", discord_mod.opus)
|
||||
|
||||
|
||||
def _ensure_slack_mock():
|
||||
"""Install mock slack modules so SlackAdapter can be imported."""
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return # Real library installed
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_ensure_telegram_mock()
|
||||
_ensure_discord_mock()
|
||||
_ensure_slack_mock()
|
||||
|
||||
import discord # noqa: E402 — mocked above
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
from gateway.platforms.slack import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
# Platform-generic factories
|
||||
|
||||
def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1", chat_type: str = "dm") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=platform,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
user_name="e2e_tester",
|
||||
chat_type=chat_type,
|
||||
)
|
||||
|
||||
|
||||
def make_session_entry(platform: Platform, source: SessionSource = None) -> SessionEntry:
|
||||
source = source or make_source(platform)
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=f"sess-{uuid.uuid4().hex[:8]}",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=platform,
|
||||
chat_type="dm",
|
||||
)
|
||||
|
||||
|
||||
def make_event(
|
||||
platform: Platform,
|
||||
text: str = "/help",
|
||||
chat_id: str = "e2e-chat-1",
|
||||
user_id: str = "e2e-user-1",
|
||||
chat_type: str = "dm",
|
||||
) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
source=make_source(platform, chat_id, user_id, chat_type),
|
||||
message_id=f"msg-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
|
||||
|
||||
def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "GatewayRunner":
|
||||
"""Create a GatewayRunner with mocked internals for e2e testing.
|
||||
|
||||
Skips __init__ to avoid filesystem/network side effects.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
if session_entry is None:
|
||||
session_entry = make_session_entry(platform)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, token="e2e-test-token")}
|
||||
)
|
||||
runner.adapters = {}
|
||||
runner._voice_mode = {}
|
||||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||||
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.has_any_sessions.return_value = True
|
||||
runner.session_store.append_to_transcript = MagicMock()
|
||||
runner.session_store.rewrite_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
runner.session_store.reset_session = MagicMock()
|
||||
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._shutdown_event = asyncio.Event()
|
||||
runner._exit_reason = None
|
||||
runner._exit_code = None
|
||||
runner._background_tasks = set()
|
||||
runner._draining = False
|
||||
runner._restart_requested = False
|
||||
runner._restart_task_started = False
|
||||
runner._restart_detached = False
|
||||
runner._restart_via_service = False
|
||||
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
|
||||
runner._stop_task = None
|
||||
runner._busy_input_mode = "interrupt"
|
||||
runner._running_agents_ts = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._update_prompt_pending = {}
|
||||
runner._voice_mode = {}
|
||||
runner._session_db = None
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._show_reasoning = False
|
||||
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._set_session_env = lambda _context: None
|
||||
runner._handle_message_with_agent = AsyncMock(return_value="agent-handled-default")
|
||||
runner._should_send_voice_reply = lambda *_a, **_kw: False
|
||||
runner._send_voice_reply = AsyncMock()
|
||||
runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None
|
||||
runner._emit_gateway_run_progress = AsyncMock()
|
||||
|
||||
# Disable destructive slash confirm gate so /new executes immediately
|
||||
runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}}
|
||||
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store._is_rate_limited = MagicMock(return_value=False)
|
||||
runner.pairing_store.generate_code = MagicMock(return_value="ABC123")
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
def make_adapter(platform: Platform, runner=None):
|
||||
"""Create a platform adapter wired to *runner*, with send methods mocked."""
|
||||
if runner is None:
|
||||
runner = make_runner(platform)
|
||||
|
||||
config = PlatformConfig(enabled=True, token="e2e-test-token")
|
||||
|
||||
if platform == Platform.DISCORD:
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
with patch.object(ThreadParticipationTracker, "_load", return_value=set()):
|
||||
adapter = DiscordAdapter(config)
|
||||
platform_key = Platform.DISCORD
|
||||
elif platform == Platform.SLACK:
|
||||
adapter = SlackAdapter(config)
|
||||
platform_key = Platform.SLACK
|
||||
else:
|
||||
adapter = TelegramAdapter(config)
|
||||
platform_key = Platform.TELEGRAM
|
||||
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
|
||||
adapter.send_typing = AsyncMock()
|
||||
|
||||
adapter.set_message_handler(runner._handle_message)
|
||||
runner.adapters[platform_key] = adapter
|
||||
|
||||
return adapter
|
||||
|
||||
|
||||
async def send_and_capture(adapter, text: str, platform: Platform, **event_kwargs) -> AsyncMock:
|
||||
"""Send a message through the full e2e flow and return the send mock."""
|
||||
event = make_event(platform, text, **event_kwargs)
|
||||
adapter.send.reset_mock()
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
return adapter.send
|
||||
|
||||
|
||||
# Parametrized fixtures for platform-generic tests
|
||||
@pytest.fixture(params=[Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK], ids=["telegram", "discord", "slack"])
|
||||
def platform(request):
|
||||
return request.param
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def source(platform):
|
||||
return make_source(platform)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session_entry(platform, source):
|
||||
return make_session_entry(platform, source)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def runner(platform, session_entry):
|
||||
return make_runner(platform, session_entry)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def adapter(platform, runner):
|
||||
return make_adapter(platform, runner)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Discord helpers and fixtures
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BOT_USER_ID = 99999
|
||||
BOT_USER_NAME = "HermesBot"
|
||||
CHANNEL_ID = 22222
|
||||
GUILD_ID = 44444
|
||||
THREAD_ID = 33333
|
||||
MESSAGE_ID_COUNTER = 0
|
||||
|
||||
|
||||
def _next_message_id() -> int:
|
||||
global MESSAGE_ID_COUNTER
|
||||
MESSAGE_ID_COUNTER += 1
|
||||
return 70000 + MESSAGE_ID_COUNTER
|
||||
|
||||
|
||||
def make_fake_bot_user():
|
||||
return SimpleNamespace(
|
||||
id=BOT_USER_ID, name=BOT_USER_NAME,
|
||||
display_name=BOT_USER_NAME, bot=True,
|
||||
)
|
||||
|
||||
|
||||
def make_fake_guild(guild_id: int = GUILD_ID, name: str = "Test Server"):
|
||||
return SimpleNamespace(id=guild_id, name=name)
|
||||
|
||||
|
||||
def make_fake_text_channel(channel_id: int = CHANNEL_ID, name: str = "general", guild=None):
|
||||
return SimpleNamespace(
|
||||
id=channel_id, name=name,
|
||||
guild=guild or make_fake_guild(),
|
||||
topic=None, type=0,
|
||||
)
|
||||
|
||||
|
||||
def make_fake_dm_channel(channel_id: int = 55555):
|
||||
ch = MagicMock(spec=[])
|
||||
ch.id = channel_id
|
||||
ch.name = "DM"
|
||||
ch.topic = None
|
||||
ch.__class__ = discord.DMChannel
|
||||
return ch
|
||||
|
||||
|
||||
def make_fake_thread(thread_id: int = THREAD_ID, name: str = "test-thread", parent=None):
|
||||
th = MagicMock(spec=[])
|
||||
th.id = thread_id
|
||||
th.name = name
|
||||
th.parent = parent or make_fake_text_channel()
|
||||
th.parent_id = th.parent.id
|
||||
th.guild = th.parent.guild
|
||||
th.topic = None
|
||||
th.type = 11
|
||||
th.__class__ = discord.Thread
|
||||
return th
|
||||
|
||||
|
||||
def make_discord_message(
|
||||
*, content: str = "hello", author=None, channel=None, mentions=None,
|
||||
attachments=None, message_id: int = None,
|
||||
):
|
||||
if message_id is None:
|
||||
message_id = _next_message_id()
|
||||
if author is None:
|
||||
author = SimpleNamespace(
|
||||
id=11111, name="testuser", display_name="testuser", bot=False,
|
||||
)
|
||||
if channel is None:
|
||||
channel = make_fake_text_channel()
|
||||
if mentions is None:
|
||||
mentions = []
|
||||
if attachments is None:
|
||||
attachments = []
|
||||
|
||||
return SimpleNamespace(
|
||||
id=message_id, content=content, author=author, channel=channel,
|
||||
guild=getattr(channel, "guild", None),
|
||||
mentions=mentions, attachments=attachments,
|
||||
type=getattr(discord, "MessageType", SimpleNamespace()).default,
|
||||
reference=None, created_at=datetime.now(timezone.utc),
|
||||
create_thread=AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
def get_response_text(adapter) -> str | None:
|
||||
"""Extract the response text from adapter.send() call args, or None if not called."""
|
||||
if not adapter.send.called:
|
||||
return None
|
||||
return adapter.send.call_args[1].get("content") or adapter.send.call_args[0][1]
|
||||
|
||||
|
||||
def _make_discord_adapter_wired(runner=None):
|
||||
"""Create a DiscordAdapter wired to a GatewayRunner for e2e tests."""
|
||||
if runner is None:
|
||||
runner = make_runner(Platform.DISCORD)
|
||||
|
||||
config = PlatformConfig(enabled=True, token="e2e-test-token")
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
with patch.object(ThreadParticipationTracker, "_load", return_value=set()):
|
||||
adapter = DiscordAdapter(config)
|
||||
|
||||
bot_user = make_fake_bot_user()
|
||||
adapter._client = SimpleNamespace(
|
||||
user=bot_user,
|
||||
get_channel=lambda _id: None,
|
||||
fetch_channel=AsyncMock(),
|
||||
)
|
||||
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1"))
|
||||
adapter.send_typing = AsyncMock()
|
||||
adapter.set_message_handler(runner._handle_message)
|
||||
runner.adapters[Platform.DISCORD] = adapter
|
||||
|
||||
return adapter, runner
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def discord_setup():
|
||||
return _make_discord_adapter_wired()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def discord_adapter(discord_setup):
|
||||
return discord_setup[0]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def discord_runner(discord_setup):
|
||||
return discord_setup[1]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bot_user():
|
||||
return make_fake_bot_user()
|
||||
@@ -0,0 +1,49 @@
|
||||
# Matrix cross-signing bootstrap — E2E test
|
||||
|
||||
Self-contained end-to-end test for the auto-bootstrap behavior added in
|
||||
`gateway/platforms/matrix.py`. Spins up a real Continuwuity homeserver
|
||||
in Docker, registers a fresh bot, runs the patched bootstrap path
|
||||
against it, and asserts:
|
||||
|
||||
1. Cross-signing keys get published with **unpadded** base64 keyids
|
||||
(the bug this PR fixes — padded keyids are silently rejected by
|
||||
matrix-rust-sdk in Element).
|
||||
2. On a second startup with the same crypto store, bootstrap is
|
||||
skipped.
|
||||
3. When `MATRIX_RECOVERY_KEY` is set, the existing recovery-key path
|
||||
takes precedence and no fresh bootstrap happens.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
|
||||
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
The `down -v` step removes the persistent volume so the next run gets
|
||||
a fresh homeserver — important because Continuwuity's one-time admin
|
||||
registration token is only valid before the first user is created.
|
||||
|
||||
## Port
|
||||
|
||||
The compose binds Continuwuity to `127.0.0.1:26167` by default. Override
|
||||
with `HOMESERVER_HOST_PORT=NNNNN docker compose up -d` if that port is
|
||||
busy locally.
|
||||
|
||||
## What the test exercises
|
||||
|
||||
The test mirrors the bootstrap snippet from
|
||||
`gateway/platforms/matrix.py` (the "if MATRIX_RECOVERY_KEY else
|
||||
get_own_cross_signing_public_keys / generate_recovery_key" branch)
|
||||
inline so it runs without importing the entire hermes gateway and its
|
||||
many dependencies. **If the source diverges from what's in
|
||||
`_connect_with_bootstrap`, this test must be updated to match.** A
|
||||
small price for not requiring the full hermes-agent runtime in CI.
|
||||
|
||||
## Skipped when
|
||||
|
||||
- `mautrix` Python package is not installed
|
||||
- The homeserver isn't reachable at `$E2E_MATRIX_HS` (default
|
||||
`http://127.0.0.1:26167`)
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
homeserver:
|
||||
image: ghcr.io/continuwuity/continuwuity:latest
|
||||
environment:
|
||||
CONTINUWUITY_SERVER_NAME: localhost
|
||||
CONTINUWUITY_DATABASE_PATH: /var/lib/conduwuit/conduwuit.db
|
||||
CONTINUWUITY_PORT: "6167"
|
||||
CONTINUWUITY_ADDRESS: "0.0.0.0"
|
||||
CONTINUWUITY_ALLOW_REGISTRATION: "true"
|
||||
CONTINUWUITY_REGISTRATION_TOKEN: testreg
|
||||
CONTINUWUITY_ALLOW_FEDERATION: "false"
|
||||
CONTINUWUITY_TRUSTED_SERVERS: "[]"
|
||||
CONTINUWUITY_LOG: "warn,conduwuit=info"
|
||||
CONTINUWUITY_ALLOW_CHECK_FOR_UPDATES: "false"
|
||||
ports:
|
||||
- "127.0.0.1:${HOMESERVER_HOST_PORT:-26167}:6167"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/6167 && echo -e 'GET /_matrix/client/versions HTTP/1.0\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK' || exit 1"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
@@ -0,0 +1,331 @@
|
||||
"""End-to-end test for Matrix cross-signing auto-bootstrap.
|
||||
|
||||
Spins a real Continuwuity homeserver in docker, registers a fresh bot,
|
||||
runs the patched ``MatrixAdapter.connect()`` against it, and asserts:
|
||||
|
||||
1. cross-signing keys get published with **unpadded** base64 keyids
|
||||
(the bug this PR fixes — padded keyids are silently rejected by
|
||||
matrix-rust-sdk in Element);
|
||||
2. on a second startup with the same crypto store, bootstrap is
|
||||
skipped (``get_own_cross_signing_public_keys`` finds the keys);
|
||||
3. the bot's current device is signed by the new SSK, so Element
|
||||
considers the device "verified by its owner".
|
||||
|
||||
Self-contained: ``docker compose up -d`` brings up Continuwuity on
|
||||
127.0.0.1:26167; this script registers a fresh bot using the
|
||||
homeserver's one-time admin registration token (printed once at first
|
||||
boot, parsed from the container logs); then drives the gateway code.
|
||||
|
||||
Run from repo root::
|
||||
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
|
||||
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
|
||||
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
|
||||
|
||||
Skipped automatically if mautrix isn't installed or the homeserver
|
||||
isn't reachable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
HS = os.environ.get("E2E_MATRIX_HS", "http://127.0.0.1:26167")
|
||||
COMPOSE_DIR = Path(__file__).parent
|
||||
CONTAINER_NAME = "matrix_xsign_bootstrap-homeserver-1"
|
||||
|
||||
|
||||
def _hs_reachable() -> bool:
|
||||
try:
|
||||
urllib.request.urlopen(f"{HS}/_matrix/client/versions", timeout=2).read()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _first_time_token() -> str | None:
|
||||
"""Continuwuity prints a one-time registration token on first boot.
|
||||
|
||||
The configured CONTINUWUITY_REGISTRATION_TOKEN does NOT activate
|
||||
until an account exists, so we have to pull this token out of the
|
||||
docker logs to bootstrap the very first user.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["docker", "logs", CONTAINER_NAME],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout + subprocess.run(
|
||||
["docker", "logs", CONTAINER_NAME],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stderr
|
||||
except Exception:
|
||||
return None
|
||||
cleaned = re.sub(r"\x1b\[[0-9;]*m", "", out)
|
||||
m = re.search(r"registration token ([A-Za-z0-9]+)", cleaned)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict, headers: dict | None = None) -> tuple[int, dict]:
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
r = urllib.request.urlopen(req)
|
||||
return r.status, json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read().decode())
|
||||
|
||||
|
||||
CONFIG_REG_TOKEN = "testreg" # matches docker-compose.yml
|
||||
|
||||
|
||||
def _register_bot(*, prefer_token: str = CONFIG_REG_TOKEN, fallback_token: str | None = None) -> dict:
|
||||
"""Register a fresh bot. Tries the configured token first; falls back to
|
||||
the homeserver's one-time admin token (only valid until the first user
|
||||
is created)."""
|
||||
user = "bot" + secrets.token_hex(3)
|
||||
password = secrets.token_urlsafe(20)
|
||||
last_err = None
|
||||
for tok in (prefer_token, fallback_token):
|
||||
if tok is None:
|
||||
continue
|
||||
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {})
|
||||
if st != 401 or "session" not in b:
|
||||
last_err = (st, b); continue
|
||||
session = b["session"]
|
||||
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {
|
||||
"auth": {"type": "m.login.registration_token", "token": tok, "session": session},
|
||||
"username": user, "password": password,
|
||||
"initial_device_display_name": "e2e-bootstrap-test",
|
||||
})
|
||||
if st == 200:
|
||||
return b
|
||||
last_err = (st, b)
|
||||
raise AssertionError(f"register failed for both tokens: {last_err}")
|
||||
|
||||
|
||||
def _query_keys(token: str, mxid: str) -> dict:
|
||||
return _post_json(
|
||||
f"{HS}/_matrix/client/v3/keys/query",
|
||||
{"device_keys": {mxid: []}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)[1]
|
||||
|
||||
|
||||
@unittest.skipUnless(_hs_reachable(), f"homeserver not reachable at {HS}")
|
||||
class XsignBootstrapE2E(unittest.IsolatedAsyncioTestCase):
|
||||
"""Drive the patched MatrixAdapter.connect() against real continuwuity."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
import mautrix # noqa: F401
|
||||
except ImportError:
|
||||
raise unittest.SkipTest("mautrix not installed")
|
||||
cls.first_tok = _first_time_token()
|
||||
# If no user has ever been created, the configured `testreg` token
|
||||
# won't activate yet — burn the one-time admin token first to
|
||||
# bootstrap the homeserver into a usable state.
|
||||
if cls.first_tok:
|
||||
try:
|
||||
_register_bot(prefer_token=cls.first_tok, fallback_token=None)
|
||||
except AssertionError:
|
||||
pass # Already burnt previously; testreg should now work.
|
||||
|
||||
async def _connect_with_bootstrap(self, creds: dict, store_dir: Path) -> tuple[list[str], str | None]:
|
||||
"""Drive matrix.py's bootstrap branch directly.
|
||||
|
||||
We import the gateway module and execute the same OlmMachine init +
|
||||
bootstrap sequence, capturing log lines so we can assert what fired.
|
||||
Returns (log_lines, recovery_key_or_None).
|
||||
"""
|
||||
from mautrix.api import HTTPAPI
|
||||
from mautrix.client import Client
|
||||
from mautrix.client.state_store.memory import MemoryStateStore
|
||||
from mautrix.crypto import OlmMachine, PgCryptoStore
|
||||
from mautrix.types import TrustState
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
# The actual bootstrap snippet from gateway/platforms/matrix.py
|
||||
# (copied so we can run it without importing the full hermes
|
||||
# gateway and its many deps). If the source code drifts from this,
|
||||
# the test should be updated to match.
|
||||
log_lines: list[str] = []
|
||||
captured_recovery_key: str | None = None
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, record):
|
||||
log_lines.append(self.format(record))
|
||||
|
||||
logger = logging.getLogger("e2e.bootstrap")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
handler = _Capture()
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
|
||||
client = Client(
|
||||
mxid=creds["user_id"], api=api,
|
||||
device_id=creds["device_id"], state_store=MemoryStateStore(),
|
||||
)
|
||||
client.api.token = creds["access_token"]
|
||||
|
||||
store_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = store_dir / "crypto.db"
|
||||
crypto_db = Database.create(f"sqlite:///{db_path}", upgrade_table=PgCryptoStore.upgrade_table)
|
||||
await crypto_db.start()
|
||||
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
|
||||
await crypto_store.open()
|
||||
|
||||
olm = OlmMachine(client, crypto_store, MemoryStateStore())
|
||||
olm.share_keys_min_trust = TrustState.UNVERIFIED
|
||||
olm.send_keys_min_trust = TrustState.UNVERIFIED
|
||||
await olm.load()
|
||||
|
||||
# --- The patched bootstrap block, mirrored from matrix.py ---
|
||||
recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
|
||||
if recovery_key:
|
||||
try:
|
||||
await olm.verify_with_recovery_key(recovery_key)
|
||||
logger.info("Matrix: cross-signing verified via recovery key")
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: recovery key verification failed: %s", exc)
|
||||
else:
|
||||
try:
|
||||
own_xsign = await olm.get_own_cross_signing_public_keys()
|
||||
except Exception as exc:
|
||||
own_xsign = None
|
||||
logger.warning("Matrix: cross-signing key lookup failed: %s", exc)
|
||||
if own_xsign is None:
|
||||
try:
|
||||
new_recovery_key = await olm.generate_recovery_key()
|
||||
captured_recovery_key = new_recovery_key
|
||||
logger.warning(
|
||||
"Matrix: bootstrapped cross-signing for %s. "
|
||||
"SAVE THIS RECOVERY KEY: %s",
|
||||
client.mxid, new_recovery_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: cross-signing bootstrap failed: %s", exc)
|
||||
|
||||
# --- /end patched block ---
|
||||
# Clean teardown — without this the asyncio loop never exits.
|
||||
await crypto_db.stop()
|
||||
await api.session.close()
|
||||
return log_lines, captured_recovery_key
|
||||
|
||||
async def asyncSetUp(self):
|
||||
self.creds = _register_bot(prefer_token=CONFIG_REG_TOKEN, fallback_token=self.first_tok)
|
||||
self.creds["homeserver"] = HS
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="e2e-xsign-"))
|
||||
# mautrix.generate_recovery_key requires account.shared, which means
|
||||
# we must share device keys (one-time keys) first. Do that via a
|
||||
# short bootstrap to publish device keys.
|
||||
await self._publish_device_keys(self.creds, self.tmp)
|
||||
|
||||
async def _publish_device_keys(self, creds, store_dir):
|
||||
"""Tiny helper: open OlmMachine, share device keys, close."""
|
||||
from mautrix.api import HTTPAPI
|
||||
from mautrix.client import Client
|
||||
from mautrix.client.state_store.memory import MemoryStateStore
|
||||
from mautrix.crypto import OlmMachine, PgCryptoStore
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
|
||||
client = Client(mxid=creds["user_id"], api=api, device_id=creds["device_id"],
|
||||
state_store=MemoryStateStore())
|
||||
store_dir.mkdir(parents=True, exist_ok=True)
|
||||
crypto_db = Database.create(f"sqlite:///{store_dir / 'crypto.db'}",
|
||||
upgrade_table=PgCryptoStore.upgrade_table)
|
||||
await crypto_db.start()
|
||||
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
|
||||
await crypto_store.open()
|
||||
olm = OlmMachine(client, crypto_store, MemoryStateStore())
|
||||
await olm.load()
|
||||
await olm.share_keys() # publishes device keys (precondition for generate_recovery_key)
|
||||
await crypto_db.stop()
|
||||
await api.session.close()
|
||||
|
||||
async def asyncTearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
async def test_bootstrap_publishes_unpadded_keys(self):
|
||||
"""Fresh bot → bootstrap fires, keys published unpadded, device signed."""
|
||||
log_lines, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
# 1. Bootstrap must have produced a recovery key
|
||||
self.assertIsNotNone(rec_key, "expected recovery key from bootstrap")
|
||||
self.assertTrue(any("bootstrapped cross-signing" in l for l in log_lines),
|
||||
f"expected bootstrap log line, got: {log_lines}")
|
||||
# 2. Homeserver should now serve a master + ssk for the bot
|
||||
d = _query_keys(self.creds["access_token"], self.creds["user_id"])
|
||||
self.assertIn(self.creds["user_id"], d.get("master_keys", {}),
|
||||
"no master_keys after bootstrap")
|
||||
self.assertIn(self.creds["user_id"], d.get("self_signing_keys", {}),
|
||||
"no self_signing_keys after bootstrap")
|
||||
# 3. The keyids must be UNPADDED (this is the bug this PR exists to fix)
|
||||
master_kid = next(iter(d["master_keys"][self.creds["user_id"]]["keys"]))
|
||||
ssk_kid = next(iter(d["self_signing_keys"][self.creds["user_id"]]["keys"]))
|
||||
self.assertFalse(master_kid.endswith("="),
|
||||
f"master keyid is padded: {master_kid!r}")
|
||||
self.assertFalse(ssk_kid.endswith("="),
|
||||
f"ssk keyid is padded: {ssk_kid!r}")
|
||||
# 4. The current device must be signed by the new SSK
|
||||
dev = d["device_keys"][self.creds["user_id"]][self.creds["device_id"]]
|
||||
sig_kids = list(dev["signatures"][self.creds["user_id"]].keys())
|
||||
self.assertIn(ssk_kid, sig_kids,
|
||||
f"device {self.creds['device_id']} not signed by new SSK; "
|
||||
f"signatures: {sig_kids}")
|
||||
|
||||
async def test_second_startup_skips_bootstrap(self):
|
||||
"""Second startup with same crypto store → no second recovery key."""
|
||||
# First connect bootstraps.
|
||||
_, rec1 = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
self.assertIsNotNone(rec1, "first connect should have bootstrapped")
|
||||
# Second connect on same crypto store should NOT re-bootstrap.
|
||||
log2, rec2 = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
self.assertIsNone(rec2, f"second connect re-bootstrapped! logs: {log2}")
|
||||
self.assertFalse(any("bootstrapped cross-signing" in l for l in log2),
|
||||
f"second connect re-bootstrapped! logs: {log2}")
|
||||
|
||||
async def test_recovery_key_path_takes_precedence(self):
|
||||
"""If MATRIX_RECOVERY_KEY is set, no fresh bootstrap happens."""
|
||||
# First, bootstrap to get a real recovery key.
|
||||
_, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
|
||||
self.assertIsNotNone(rec_key)
|
||||
# Fresh store directory + recovery key set in env: must take the
|
||||
# verify_with_recovery_key path, NOT bootstrap a new identity.
|
||||
fresh_store = Path(tempfile.mkdtemp(prefix="e2e-xsign-fresh-"))
|
||||
try:
|
||||
await self._publish_device_keys(self.creds, fresh_store)
|
||||
os.environ["MATRIX_RECOVERY_KEY"] = rec_key
|
||||
try:
|
||||
log, rec2 = await self._connect_with_bootstrap(self.creds, fresh_store)
|
||||
self.assertIsNone(rec2, "bootstrap fired despite MATRIX_RECOVERY_KEY being set")
|
||||
self.assertTrue(
|
||||
any("verified via recovery key" in l for l in log),
|
||||
f"expected recovery-key verify log, got: {log}",
|
||||
)
|
||||
finally:
|
||||
del os.environ["MATRIX_RECOVERY_KEY"]
|
||||
finally:
|
||||
shutil.rmtree(fresh_store, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Minimal e2e tests for Discord mention stripping + /command detection.
|
||||
|
||||
Covers the fix for slash commands not being recognized when sent via
|
||||
@mention in a channel, especially after auto-threading.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.conftest import (
|
||||
BOT_USER_ID,
|
||||
E2E_MESSAGE_SETTLE_DELAY,
|
||||
get_response_text,
|
||||
make_discord_message,
|
||||
make_fake_dm_channel,
|
||||
make_fake_thread,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def dispatch(adapter, msg):
|
||||
await adapter._handle_message(msg)
|
||||
await asyncio.sleep(E2E_MESSAGE_SETTLE_DELAY)
|
||||
|
||||
|
||||
class TestMentionStrippedCommandDispatch:
|
||||
async def test_mention_then_command(self, discord_adapter, bot_user):
|
||||
"""<@BOT> /help → mention stripped, /help dispatched."""
|
||||
msg = make_discord_message(
|
||||
content=f"<@{BOT_USER_ID}> /help",
|
||||
mentions=[bot_user],
|
||||
)
|
||||
await dispatch(discord_adapter, msg)
|
||||
response = get_response_text(discord_adapter)
|
||||
assert response is not None
|
||||
assert "/new" in response
|
||||
|
||||
async def test_nickname_mention_then_command(self, discord_adapter, bot_user):
|
||||
"""<@!BOT> /help → nickname mention also stripped, /help works."""
|
||||
msg = make_discord_message(
|
||||
content=f"<@!{BOT_USER_ID}> /help",
|
||||
mentions=[bot_user],
|
||||
)
|
||||
await dispatch(discord_adapter, msg)
|
||||
response = get_response_text(discord_adapter)
|
||||
assert response is not None
|
||||
assert "/new" in response
|
||||
|
||||
async def test_text_before_command_not_detected(self, discord_adapter, bot_user):
|
||||
"""'<@BOT> something else /help' → mention stripped, but 'something else /help'
|
||||
doesn't start with / so it's treated as text, not a command."""
|
||||
msg = make_discord_message(
|
||||
content=f"<@{BOT_USER_ID}> something else /help",
|
||||
mentions=[bot_user],
|
||||
)
|
||||
await dispatch(discord_adapter, msg)
|
||||
# Message is accepted (not dropped by mention gate), but since it doesn't
|
||||
# start with / it's routed as text — no command output, and no agent in this
|
||||
# mock setup means no send call either.
|
||||
response = get_response_text(discord_adapter)
|
||||
assert response is None or "/new" not in response
|
||||
|
||||
async def test_no_mention_in_channel_dropped(self, discord_adapter):
|
||||
"""Message without @mention in server channel → silently dropped."""
|
||||
msg = make_discord_message(content="/help", mentions=[])
|
||||
await dispatch(discord_adapter, msg)
|
||||
assert get_response_text(discord_adapter) is None
|
||||
|
||||
async def test_dm_no_mention_needed(self, discord_adapter):
|
||||
"""DMs don't require @mention — /help works directly."""
|
||||
dm = make_fake_dm_channel()
|
||||
msg = make_discord_message(content="/help", channel=dm, mentions=[])
|
||||
await dispatch(discord_adapter, msg)
|
||||
response = get_response_text(discord_adapter)
|
||||
assert response is not None
|
||||
assert "/new" in response
|
||||
|
||||
|
||||
class TestAutoThreadingPreservesCommand:
|
||||
async def test_command_detected_after_auto_thread(self, discord_adapter, bot_user, monkeypatch):
|
||||
"""@mention /help in channel with auto-thread → thread created AND command dispatched."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
fake_thread = make_fake_thread(thread_id=90001, name="help")
|
||||
msg = make_discord_message(
|
||||
content=f"<@{BOT_USER_ID}> /help",
|
||||
mentions=[bot_user],
|
||||
)
|
||||
|
||||
# Simulate discord.py restoring the original raw content (with mention)
|
||||
# after create_thread(), which undoes any prior mention stripping.
|
||||
original_content = msg.content
|
||||
|
||||
async def clobber_content(**kwargs):
|
||||
msg.content = original_content
|
||||
return fake_thread
|
||||
|
||||
msg.create_thread = AsyncMock(side_effect=clobber_content)
|
||||
await dispatch(discord_adapter, msg)
|
||||
|
||||
msg.create_thread.assert_awaited_once()
|
||||
response = get_response_text(discord_adapter)
|
||||
assert response is not None
|
||||
assert "/new" in response
|
||||
|
||||
|
||||
class TestRepliedToMediaDispatch:
|
||||
async def test_reply_to_image_message_caches_referenced_attachment(
|
||||
self, discord_adapter, bot_user, monkeypatch
|
||||
):
|
||||
"""A text reply to an image-bearing Discord message should give the agent that image."""
|
||||
cached_path = "/tmp/replied-discord-image.png"
|
||||
|
||||
async def fake_cache_image_from_url(url, *, ext=".jpg"):
|
||||
assert url == "https://cdn.discordapp.com/attachments/image.png"
|
||||
assert ext == ".png"
|
||||
return cached_path
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
fake_cache_image_from_url,
|
||||
)
|
||||
discord_adapter.handle_message = AsyncMock()
|
||||
|
||||
attachment = SimpleNamespace(
|
||||
content_type="image/png",
|
||||
filename="image.png",
|
||||
url="https://cdn.discordapp.com/attachments/image.png",
|
||||
size=1234,
|
||||
)
|
||||
referenced_message = SimpleNamespace(
|
||||
id=12345,
|
||||
content="",
|
||||
attachments=[attachment],
|
||||
)
|
||||
msg = make_discord_message(
|
||||
content=f"<@{BOT_USER_ID}> what's in this image?",
|
||||
mentions=[bot_user],
|
||||
)
|
||||
msg.type = 19
|
||||
msg.reference = SimpleNamespace(message_id=12345, resolved=referenced_message)
|
||||
|
||||
await discord_adapter._handle_message(msg)
|
||||
|
||||
discord_adapter.handle_message.assert_awaited_once()
|
||||
await_args = discord_adapter.handle_message.await_args
|
||||
assert await_args is not None
|
||||
event = await_args.args[0]
|
||||
assert event.reply_to_message_id == "12345"
|
||||
assert event.media_urls == [cached_path]
|
||||
assert event.media_types == ["image/png"]
|
||||
assert event.message_type.value == "photo"
|
||||
@@ -0,0 +1,237 @@
|
||||
"""E2E tests for gateway slash commands (Telegram, Discord).
|
||||
|
||||
Each test drives a message through the full async pipeline:
|
||||
adapter.handle_message(event)
|
||||
→ BasePlatformAdapter._process_message_background()
|
||||
→ GatewayRunner._handle_message() (command dispatch)
|
||||
→ adapter.send() (captured for assertions)
|
||||
|
||||
No LLM involved — only gateway-level commands are tested.
|
||||
Tests are parametrized over platforms via the ``platform`` fixture in conftest.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import SendResult
|
||||
from tests.e2e.conftest import make_event, send_and_capture
|
||||
|
||||
|
||||
class TestSlashCommands:
|
||||
"""Gateway slash commands dispatched through the full adapter pipeline."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_returns_command_list(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/help", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "/new" in response_text
|
||||
assert "/status" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_shows_session_info(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/status", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "session" in response_text.lower() or "Session" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_resets_session(self, adapter, runner, platform):
|
||||
send = await send_and_capture(adapter, "/new", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
runner.session_store.reset_session.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_when_no_agent_running(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/stop", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
response_lower = response_text.lower()
|
||||
assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_shows_listing(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/commands", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
# Should list at least some commands
|
||||
assert "/" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_commands_share_session(self, adapter, platform):
|
||||
"""Two commands from the same chat_id should both succeed."""
|
||||
send_help = await send_and_capture(adapter, "/help", platform)
|
||||
send_help.assert_called_once()
|
||||
|
||||
send_status = await send_and_capture(adapter, "/status", platform)
|
||||
send_status.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbose_responds(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/verbose", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
# Either shows the mode cycle or tells user to enable it in config
|
||||
assert "verbose" in response_text.lower() or "tool_progress" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plaintext_restart_gateway_routes_to_safe_restart_command(self, adapter, runner, platform, monkeypatch):
|
||||
if platform != Platform.TELEGRAM:
|
||||
pytest.skip("Plaintext restart shortcut is intentionally DM/Telegram-focused")
|
||||
|
||||
monkeypatch.setenv("INVOCATION_ID", "e2e-systemd")
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
|
||||
send = await send_and_capture(adapter, "restart gateway", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "restart" in response_text.lower() or "draining" in response_text.lower()
|
||||
runner.request_restart.assert_called_once_with(detached=False, via_service=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plaintext_restart_gateway_in_group_stays_plain_text(self, adapter, runner, platform, monkeypatch):
|
||||
if platform != Platform.TELEGRAM:
|
||||
pytest.skip("Shortcut scope is only verified for Telegram here")
|
||||
|
||||
monkeypatch.setenv("INVOCATION_ID", "e2e-systemd")
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
runner._handle_message_with_agent = AsyncMock(return_value="agent-handled")
|
||||
|
||||
send = await send_and_capture(adapter, "restart gateway", platform, chat_id="group-chat-1", user_id="u1", chat_type="group")
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert response_text == "agent-handled"
|
||||
runner.request_restart.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_lists_options(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/personality", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "personalit" in response_text.lower() # matches "personality" or "personalities"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yolo_toggles_mode(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/yolo", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "yolo" in response_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compress_command(self, adapter, platform):
|
||||
send = await send_and_capture(adapter, "/compress", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert "compress" in response_text.lower() or "context" in response_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_command_alias_targets_builtin_command_with_args(
|
||||
self, adapter, runner, platform
|
||||
):
|
||||
"""Alias targets with args must reach the built-in command handler."""
|
||||
runner.config.quick_commands = {
|
||||
"s": {"type": "alias", "target": "/status extra-arg"}
|
||||
}
|
||||
async def _handle_status(event):
|
||||
assert event.get_command_args() == "extra-arg"
|
||||
return "status via alias"
|
||||
|
||||
runner._handle_status_command = AsyncMock(side_effect=_handle_status)
|
||||
|
||||
send = await send_and_capture(adapter, "/s", platform)
|
||||
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
assert response_text == "status via alias"
|
||||
runner._handle_status_command.assert_awaited_once()
|
||||
runner._handle_message_with_agent.assert_not_awaited()
|
||||
|
||||
|
||||
|
||||
class TestSessionLifecycle:
|
||||
"""Verify session state changes across command sequences."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_then_status_reflects_reset(self, adapter, runner, session_entry, platform):
|
||||
"""After /new, /status should report the fresh session."""
|
||||
await send_and_capture(adapter, "/new", platform)
|
||||
runner.session_store.reset_session.assert_called_once()
|
||||
|
||||
send = await send_and_capture(adapter, "/status", platform)
|
||||
send.assert_called_once()
|
||||
response_text = send.call_args[1].get("content") or send.call_args[0][1]
|
||||
# Session ID from the entry should appear in the status output
|
||||
assert session_entry.session_id[:8] in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_is_idempotent(self, adapter, runner, platform):
|
||||
"""/new called twice should not crash."""
|
||||
await send_and_capture(adapter, "/new", platform)
|
||||
await send_and_capture(adapter, "/new", platform)
|
||||
assert runner.session_store.reset_session.call_count == 2
|
||||
|
||||
|
||||
class TestAuthorization:
|
||||
"""Verify the pipeline handles unauthorized users."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_user_gets_pairing_response(self, adapter, runner, platform):
|
||||
"""Unauthorized DM should trigger pairing code, not a command response."""
|
||||
runner._is_user_authorized = lambda _source: False
|
||||
|
||||
event = make_event(platform, "/help")
|
||||
adapter.send.reset_mock()
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# The adapter.send is called directly by the authorization path
|
||||
# (not via _send_with_retry), so check it was called with a pairing message
|
||||
adapter.send.assert_called()
|
||||
response_text = adapter.send.call_args[0][1] if len(adapter.send.call_args[0]) > 1 else ""
|
||||
assert "recognize" in response_text.lower() or "pair" in response_text.lower() or "ABC123" in response_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_user_does_not_get_help(self, adapter, runner, platform):
|
||||
"""Unauthorized user should NOT see the help command output."""
|
||||
runner._is_user_authorized = lambda _source: False
|
||||
|
||||
event = make_event(platform, "/help")
|
||||
adapter.send.reset_mock()
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# If send was called, it should NOT contain the help text
|
||||
if adapter.send.called:
|
||||
response_text = adapter.send.call_args[0][1] if len(adapter.send.call_args[0]) > 1 else ""
|
||||
assert "/new" not in response_text
|
||||
|
||||
|
||||
class TestSendFailureResilience:
|
||||
"""Verify the pipeline handles send failures gracefully."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_failure_does_not_crash_pipeline(self, adapter, platform):
|
||||
"""If send() returns failure, the pipeline should not raise."""
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=False, error="network timeout"))
|
||||
adapter.set_message_handler(adapter._message_handler) # re-wire with same handler
|
||||
|
||||
event = make_event(platform, "/help")
|
||||
# Should not raise — pipeline handles send failures internally
|
||||
await adapter.handle_message(event)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
adapter.send.assert_called()
|
||||
Reference in New Issue
Block a user