hermes-agent/tests/gateway/test_discord_connect.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

624 lines
22 KiB
Python

import asyncio
import json
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import PlatformConfig
class _FakeAllowedMentions:
"""Stand-in for ``discord.AllowedMentions`` — exposes the same four
boolean flags as real attributes so tests can assert on safe defaults.
"""
def __init__(self, *, everyone=True, roles=True, users=True, replied_user=True):
self.everyone = everyone
self.roles = roles
self.users = users
self.replied_user = replied_user
def _ensure_discord_mock():
"""Install (or augment) a mock ``discord`` module.
Always force ``AllowedMentions`` onto whatever is in ``sys.modules`` —
other test files also stub the module via ``setdefault``, and we need
``_build_allowed_mentions()``'s return value to have real attribute
access regardless of which file loaded first.
"""
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
return
if sys.modules.get("discord") is None:
discord_mod = MagicMock()
discord_mod.Intents.default.return_value = MagicMock()
discord_mod.Client = MagicMock
discord_mod.File = MagicMock
discord_mod.DMChannel = type("DMChannel", (), {})
discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3, grey=4, secondary=5)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
discord_mod.Interaction = object
discord_mod.Embed = MagicMock
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 = SimpleNamespace(is_loaded=lambda: True)
ext_mod = MagicMock()
commands_mod = MagicMock()
commands_mod.Bot = MagicMock
ext_mod.commands = commands_mod
sys.modules["discord"] = discord_mod
sys.modules.setdefault("discord.ext", ext_mod)
sys.modules.setdefault("discord.ext.commands", commands_mod)
sys.modules["discord"].AllowedMentions = _FakeAllowedMentions
_ensure_discord_mock()
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
@pytest.fixture(autouse=True)
def _speed_up_command_sync_mutation_pacing(monkeypatch):
monkeypatch.setattr(
DiscordAdapter,
"_command_sync_mutation_interval_seconds",
lambda self: 0.0,
)
class FakeTree:
def __init__(self):
self.sync = AsyncMock(return_value=[])
self.fetch_commands = AsyncMock(return_value=[])
self._commands = []
def command(self, *args, **kwargs):
return lambda fn: fn
def get_commands(self, *args, **kwargs):
return list(self._commands)
class FakeBot:
def __init__(self, *, intents, proxy=None, allowed_mentions=None, **_):
self.intents = intents
self.allowed_mentions = allowed_mentions
self.application_id = 999
self.user = SimpleNamespace(id=999, name="Hermes")
self._events = {}
self.tree = FakeTree()
self.http = SimpleNamespace(
upsert_global_command=AsyncMock(),
edit_global_command=AsyncMock(),
delete_global_command=AsyncMock(),
)
def event(self, fn):
self._events[fn.__name__] = fn
return fn
async def start(self, token):
if "on_ready" in self._events:
await self._events["on_ready"]()
async def close(self):
return None
class SlowSyncTree(FakeTree):
def __init__(self):
super().__init__()
self.started = asyncio.Event()
self.allow_finish = asyncio.Event()
async def _slow_sync():
self.started.set()
await self.allow_finish.wait()
return []
self.sync = AsyncMock(side_effect=_slow_sync)
class SlowSyncBot(FakeBot):
def __init__(self, *, intents, proxy=None):
super().__init__(intents=intents, proxy=proxy)
self.tree = SlowSyncTree()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"initial_allowed",
[
{"*"},
{"769524422783664158", "*"},
],
)
async def test_resolve_allowed_usernames_preserves_wildcard(monkeypatch, initial_allowed):
"""Regression (#22334): ``_resolve_allowed_usernames`` must not strip ``"*"``.
The method splits entries into numeric-IDs (kept) vs non-numeric (treated
as usernames to resolve), then rewrites ``self._allowed_user_ids`` and the
``DISCORD_ALLOWED_USERS`` env var from the numeric set. Since ``"*"`` is
non-numeric, without the wildcard branch it ends up in the resolution
bucket, fails to match any guild member, and is silently dropped from both
the in-memory set and the env var on the first ``on_ready`` — quietly
undoing the wildcard fix in ``_is_allowed_user`` for every subsequent
message.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
adapter._allowed_user_ids = set(initial_allowed)
adapter._client = SimpleNamespace(guilds=[]) # no guilds → no resolution work
monkeypatch.setenv("DISCORD_ALLOWED_USERS", ",".join(sorted(initial_allowed)))
await adapter._resolve_allowed_usernames()
assert "*" in adapter._allowed_user_ids, (
"wildcard must survive _resolve_allowed_usernames; it is the open-mode "
"marker, not a username to resolve"
)
env_entries = {
e.strip()
for e in os.environ.get("DISCORD_ALLOWED_USERS", "").split(",")
if e.strip()
}
assert "*" in env_entries, (
"DISCORD_ALLOWED_USERS env must still contain '*' after resolution; "
"downstream readers (and any restart-time re-parse) rely on it"
)
@pytest.mark.asyncio
async def test_reconnect_closes_previous_client_to_prevent_zombie_websocket(monkeypatch):
"""Regression for #18187: calling connect() twice without disconnect() in
between (e.g. during an in-process reconnect attempt) must close the old
commands.Bot before creating a new one. Without this guard, two websockets
stay alive and both fire on_message, producing double responses with
different wording.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
intents = SimpleNamespace(
message_content=False, dm_messages=False, guild_messages=False,
members=False, voice_states=False,
)
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
class TrackedBot(FakeBot):
"""FakeBot that records close() calls and reports open/closed state."""
_closed = False
def is_closed(self):
return self._closed
async def close(self):
self._closed = True
created: list[TrackedBot] = []
def fake_bot_factory(*, command_prefix, intents, proxy=None, allowed_mentions=None, **_):
bot = TrackedBot(intents=intents, allowed_mentions=allowed_mentions)
created.append(bot)
return bot
monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory)
monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock())
# First connect — fresh adapter, no prior client.
assert await adapter.connect() is True
assert len(created) == 1
first_bot = created[0]
assert first_bot._closed is False, "first bot should still be open after connect()"
# Second connect WITHOUT disconnect — simulates an in-process reconnect.
# Without the fix, first_bot would remain open (zombie), and both would
# receive every Discord event, causing double responses.
assert await adapter.connect() is True
assert len(created) == 2
second_bot = created[1]
# The first bot must be closed before the second is assigned.
assert first_bot._closed is True, (
"First Discord client must be closed on re-entry of connect() to prevent "
"zombie websocket (#18187)"
)
assert second_bot._closed is False, "second bot should still be open"
assert adapter._client is second_bot
await adapter.disconnect()
@pytest.mark.asyncio
async def test_connect_timeout_cancels_bot_task(monkeypatch):
"""Regression: connect() timeout must cancel _bot_task so the zombie
Discord client cannot fire on_message after the adapter is discarded.
Without this fix, the orphaned task eventually completes its WebSocket
handshake and a subsequent successful reconnect leaves two live clients
that each process every message, producing duplicate threads.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
intents = SimpleNamespace(
message_content=False, dm_messages=False, guild_messages=False,
members=False, voice_states=False,
)
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
class NeverReadyBot(FakeBot):
"""Bot whose start() never fires on_ready — simulates a slow gateway handshake."""
async def start(self, token):
await asyncio.Event().wait() # hang forever
monkeypatch.setattr(
discord_platform.commands,
"Bot",
lambda **kwargs: NeverReadyBot(
intents=kwargs["intents"],
proxy=kwargs.get("proxy"),
allowed_mentions=kwargs.get("allowed_mentions"),
),
)
async def fake_wait_for_ready(ready_event, bot_task, timeout):
raise asyncio.TimeoutError()
monkeypatch.setattr(
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
)
ok = await adapter.connect()
assert ok is False
assert adapter._bot_task is None, (
"_bot_task must be cancelled and cleared on connect() timeout; "
"leaving it alive creates a zombie Discord client that produces duplicate threads"
)
@pytest.mark.asyncio
async def test_disconnect_cancels_running_bot_task(monkeypatch):
"""Regression: disconnect() must cancel _bot_task even when connect() timed out.
_dispose_unused_adapter calls disconnect() on adapters whose connect() returned
False. If _bot_task was still running (zombie), disconnect() must cancel it.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
# Simulate a zombie bot_task that never finishes (as if discord.py is mid-handshake)
async def _forever():
await asyncio.Event().wait() # hang forever
zombie_task = asyncio.create_task(_forever())
adapter._bot_task = zombie_task
adapter._client = AsyncMock()
adapter._post_connect_task = None
adapter._voice_clients = {}
adapter._running = True
adapter._ready_event = asyncio.Event()
await adapter.disconnect()
# The task must have been cancelled (done + cancelled) and cleared from the adapter.
assert adapter._bot_task is None, "disconnect() must clear _bot_task"
assert zombie_task.done(), "disconnect() must have awaited the bot task to completion"
assert zombie_task.cancelled(), "disconnect() must cancel the zombie bot task"
@pytest.mark.asyncio
async def test_safe_sync_slash_commands_only_mutates_diffs():
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
class _DesiredCommand:
def __init__(self, payload):
self._payload = payload
def to_dict(self, tree):
assert tree is not None
return dict(self._payload)
class _ExistingCommand:
def __init__(self, command_id, payload):
self.id = command_id
self.name = payload["name"]
self.type = SimpleNamespace(value=payload["type"])
self._payload = payload
def to_dict(self):
return {
"id": self.id,
"application_id": 999,
**self._payload,
"name_localizations": {},
"description_localizations": {},
}
desired_same = {
"name": "status",
"description": "Show Hermes session status",
"type": 1,
"options": [],
"nsfw": False,
"dm_permission": True,
"default_member_permissions": None,
}
desired_updated = {
"name": "help",
"description": "Show available commands",
"type": 1,
"options": [],
"nsfw": False,
"dm_permission": True,
"default_member_permissions": None,
}
desired_created = {
"name": "metricas",
"description": "Show Colmeio metrics dashboard",
"type": 1,
"options": [],
"nsfw": False,
"dm_permission": True,
"default_member_permissions": None,
}
existing_same = _ExistingCommand(11, desired_same)
existing_updated = _ExistingCommand(
12,
{
**desired_updated,
"description": "Old help text",
},
)
existing_deleted = _ExistingCommand(
13,
{
"name": "old-command",
"description": "To be deleted",
"type": 1,
"options": [],
"nsfw": False,
"dm_permission": True,
"default_member_permissions": None,
},
)
fake_tree = SimpleNamespace(
get_commands=lambda: [
_DesiredCommand(desired_same),
_DesiredCommand(desired_updated),
_DesiredCommand(desired_created),
],
fetch_commands=AsyncMock(return_value=[existing_same, existing_updated, existing_deleted]),
)
fake_http = SimpleNamespace(
upsert_global_command=AsyncMock(),
edit_global_command=AsyncMock(),
delete_global_command=AsyncMock(),
)
adapter._client = SimpleNamespace(
tree=fake_tree,
http=fake_http,
application_id=999,
user=SimpleNamespace(id=999),
)
summary = await adapter._safe_sync_slash_commands()
assert summary == {
"total": 3,
"unchanged": 1,
"updated": 1,
"recreated": 0,
"created": 1,
"deleted": 1,
}
fake_http.edit_global_command.assert_awaited_once_with(999, 12, desired_updated)
fake_http.upsert_global_command.assert_awaited_once_with(999, desired_created)
fake_http.delete_global_command.assert_awaited_once_with(999, 13)
@pytest.mark.asyncio
async def test_post_connect_initialization_retries_fingerprint_after_timeout(tmp_path, monkeypatch):
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
class _DesiredCommand:
def to_dict(self, tree):
return {
"name": "skill",
"description": "Run a skill",
"type": 1,
"options": [],
}
adapter._client = SimpleNamespace(
tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]),
application_id=999,
user=SimpleNamespace(id=999),
)
desired_fingerprint = adapter._desired_command_sync_fingerprint()
state_path = (
tmp_path
/ discord_platform._DISCORD_COMMAND_SYNC_STATE_SUBDIR
/ discord_platform._DISCORD_COMMAND_SYNC_STATE_FILENAME
)
state_path.parent.mkdir(parents=True)
state_path.write_text(
json.dumps(
{
"999": {
"fingerprint": desired_fingerprint,
"last_attempt_at": 102.0,
"last_success_at": 101.0,
"summary": {"total": 1},
}
}
),
encoding="utf-8",
)
summary = {
"total": 1,
"unchanged": 0,
"updated": 0,
"recreated": 0,
"created": 1,
"deleted": 0,
}
sync = AsyncMock(side_effect=[asyncio.TimeoutError(), summary])
monkeypatch.setattr(adapter, "_safe_sync_slash_commands", sync)
await adapter._run_post_connect_initialization()
timed_out_entry = json.loads(state_path.read_text(encoding="utf-8"))["999"]
assert timed_out_entry["fingerprint"] == desired_fingerprint
assert "last_success_at" not in timed_out_entry
assert "summary" not in timed_out_entry
await adapter._run_post_connect_initialization()
assert sync.await_count == 2
recovered_entry = json.loads(state_path.read_text(encoding="utf-8"))["999"]
assert recovered_entry["last_success_at"] >= recovered_entry["last_attempt_at"]
assert recovered_entry["summary"] == summary
@pytest.mark.asyncio
async def test_safe_sync_reads_permission_attrs_from_existing_command():
"""Regression: AppCommand.to_dict() in discord.py does NOT include
nsfw, dm_permission, or default_member_permissions — they live only
on the attributes. Without reading those attrs, any command with
non-default permissions false-diffs on every startup.
"""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
class _DesiredCommand:
def __init__(self, payload):
self._payload = payload
def to_dict(self, tree):
return dict(self._payload)
class _ExistingCommand:
"""Mirrors discord.py's AppCommand — to_dict() omits nsfw/dm/perms."""
def __init__(self, command_id, name, description, *, nsfw, guild_only, default_permissions):
self.id = command_id
self.name = name
self.description = description
self.type = SimpleNamespace(value=1)
self.nsfw = nsfw
self.guild_only = guild_only
self.default_member_permissions = (
SimpleNamespace(value=default_permissions)
if default_permissions is not None
else None
)
def to_dict(self):
# Match real AppCommand.to_dict() — no nsfw/dm_permission/default_member_permissions
return {
"id": self.id,
"type": 1,
"application_id": 999,
"name": self.name,
"description": self.description,
"name_localizations": {},
"description_localizations": {},
"options": [],
}
desired = {
"name": "admin",
"description": "Admin-only command",
"type": 1,
"options": [],
"nsfw": True,
"dm_permission": False,
"default_member_permissions": "8",
}
# Existing command has matching attrs — should report unchanged, NOT falsely diff.
existing = _ExistingCommand(
42,
"admin",
"Admin-only command",
nsfw=True,
guild_only=True,
default_permissions=8,
)
fake_tree = SimpleNamespace(
get_commands=lambda: [_DesiredCommand(desired)],
fetch_commands=AsyncMock(return_value=[existing]),
)
fake_http = SimpleNamespace(
upsert_global_command=AsyncMock(),
edit_global_command=AsyncMock(),
delete_global_command=AsyncMock(),
)
adapter._client = SimpleNamespace(
tree=fake_tree,
http=fake_http,
application_id=999,
user=SimpleNamespace(id=999),
)
summary = await adapter._safe_sync_slash_commands()
# Without the fix, this would be unchanged=0, recreated=1 (false diff).
assert summary == {
"total": 1,
"unchanged": 1,
"updated": 0,
"recreated": 0,
"created": 0,
"deleted": 0,
}
fake_http.edit_global_command.assert_not_awaited()
fake_http.delete_global_command.assert_not_awaited()
fake_http.upsert_global_command.assert_not_awaited()
# ============================================================================
# #31049: unconfigured platform skips reconnection (non-retryable fatal error)
# ============================================================================
class TestDiscordUnconfiguredNonRetryable:
"""Verify that missing dependency/token sets a non-retryable fatal error
so the gateway does not queue the platform for background reconnection."""
@pytest.mark.asyncio
async def test_no_discord_lib_sets_non_retryable_fatal(self, monkeypatch):
"""connect() with discord.py unavailable → non-retryable fatal error."""
_ensure_discord_mock()
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake"))
# Simulate discord.py not installed
monkeypatch.setattr(discord_platform, "DISCORD_AVAILABLE", False)
result = await adapter.connect()
assert result is False
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert adapter.fatal_error_code == "missing_dependency"