Files
hermes-agent/tests/gateway/test_discord_reactions.py
Teknium 227601c200 feat(discord): add message processing reactions (salvage #1980) (#3871)
Adds lifecycle hooks to the base platform adapter so Discord (and future
platforms) can react to message processing events:

  👀  when processing starts
    on successful completion (delivery confirmed)
    on failure, error, or cancellation

Implementation:
- base.py: on_processing_start/on_processing_complete hooks with
  _run_processing_hook error isolation wrapper; delivery tracking
  via _record_delivery closure for accurate success detection
- discord.py: _add_reaction/_remove_reaction helpers + hook overrides
- Tests for base hook lifecycle and Discord-specific reactions

Co-authored-by: alanwilhelm <alanwilhelm@users.noreply.github.com>
2026-03-29 21:55:23 -07:00

171 lines
5.1 KiB
Python

"""Tests for Discord message reactions tied to processing lifecycle hooks."""
import asyncio
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType, SendResult
from gateway.session import SessionSource, build_session_key
def _ensure_discord_mock():
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
return
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.Interaction = object
discord_mod.app_commands = SimpleNamespace(
describe=lambda **kwargs: (lambda fn: fn),
choices=lambda **kwargs: (lambda fn: fn),
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
)
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)
_ensure_discord_mock()
from gateway.platforms.discord import DiscordAdapter # noqa: E402
class FakeTree:
def __init__(self):
self.commands = {}
def command(self, *, name, description):
def decorator(fn):
self.commands[name] = fn
return fn
return decorator
@pytest.fixture
def adapter():
config = PlatformConfig(enabled=True, token="***")
adapter = DiscordAdapter(config)
adapter._client = SimpleNamespace(
tree=FakeTree(),
get_channel=lambda _id: None,
fetch_channel=AsyncMock(),
user=SimpleNamespace(id=99999, name="HermesBot"),
)
return adapter
def _make_event(message_id: str, raw_message) -> MessageEvent:
return MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.DISCORD,
chat_id="123",
chat_type="dm",
user_id="42",
user_name="Jezza",
),
raw_message=raw_message,
message_id=message_id,
)
@pytest.mark.asyncio
async def test_process_message_background_adds_and_swaps_reactions(adapter):
raw_message = SimpleNamespace(
add_reaction=AsyncMock(),
remove_reaction=AsyncMock(),
)
async def handler(_event):
await asyncio.sleep(0)
return "ack"
async def hold_typing(_chat_id, interval=2.0, metadata=None):
await asyncio.Event().wait()
adapter.set_message_handler(handler)
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999"))
adapter._keep_typing = hold_typing
event = _make_event("1", raw_message)
await adapter._process_message_background(event, build_session_key(event.source))
assert raw_message.add_reaction.await_args_list[0].args == ("👀",)
assert raw_message.remove_reaction.await_args_list[0].args == ("👀", adapter._client.user)
assert raw_message.add_reaction.await_args_list[1].args == ("",)
@pytest.mark.asyncio
async def test_interaction_backed_events_do_not_attempt_reactions(adapter):
interaction = SimpleNamespace(guild_id=123456789)
async def handler(_event):
await asyncio.sleep(0)
return None
async def hold_typing(_chat_id, interval=2.0, metadata=None):
await asyncio.Event().wait()
adapter.set_message_handler(handler)
adapter._add_reaction = AsyncMock()
adapter._remove_reaction = AsyncMock()
adapter._keep_typing = hold_typing
event = MessageEvent(
text="/status",
message_type=MessageType.COMMAND,
source=SessionSource(
platform=Platform.DISCORD,
chat_id="123",
chat_type="dm",
user_id="42",
user_name="Jezza",
),
raw_message=interaction,
message_id="2",
)
await adapter._process_message_background(event, build_session_key(event.source))
adapter._add_reaction.assert_not_awaited()
adapter._remove_reaction.assert_not_awaited()
@pytest.mark.asyncio
async def test_reaction_helper_failures_do_not_break_message_flow(adapter):
raw_message = SimpleNamespace(
add_reaction=AsyncMock(side_effect=[RuntimeError("no perms"), RuntimeError("no perms")]),
remove_reaction=AsyncMock(side_effect=RuntimeError("no perms")),
)
async def handler(_event):
await asyncio.sleep(0)
return "ack"
async def hold_typing(_chat_id, interval=2.0, metadata=None):
await asyncio.Event().wait()
adapter.set_message_handler(handler)
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999"))
adapter._keep_typing = hold_typing
event = _make_event("3", raw_message)
await adapter._process_message_background(event, build_session_key(event.source))
adapter.send.assert_awaited_once()