From 68efd58c04e18f69853ddc82e470baa2c61cacc6 Mon Sep 17 00:00:00 2001 From: Jonny Kovacs Date: Mon, 3 Aug 2026 15:55:28 -0500 Subject: [PATCH] feat(matrix): agent-facing add_reaction/remove_reaction (photon parity) send_message(action="react"/"unreact") resolves reactions by duck-typing public add_reaction/remove_reaction coroutines on the live adapter (tools/send_message_tool._handle_react). Photon ships the pair; the Matrix adapter only had the private _send_reaction/_redact_reaction machinery it uses for lifecycle tapbacks, so reacting on Matrix errored "Platform 'matrix' does not support message reactions" even though the adapter can already post native m.reaction annotations. Add the public pair to MatrixAdapter with photon's semantics: - Default target is the room's most recent inbound message, recorded in on_processing_start before the MATRIX_REACTIONS gate (that flag mutes the automatic tapbacks, not deliberate agent requests). Explicit message_id overrides it. - remove_reaction redacts only annotations this process placed via add_reaction (tracked per (room, target)), so the lifecycle tapbacks keep managing their own redaction. Reactions land as standard m.annotation relations and render natively in Element and other Matrix clients. Tests: tests/gateway/test_matrix_agent_reactions.py (8 passed) + tests/gateway/test_matrix*.py + test_suppression_contract_matrix.py + tests/tools/test_send_message_react.py + tests/plugins/platforms/photon/test_reactions.py (228 passed, 1 skipped; 12 failures in test_matrix.py are pre-existing on main: venv lacks aiohttp) --- plugins/platforms/matrix/adapter.py | 89 +++++++++- tests/gateway/test_matrix_agent_reactions.py | 178 +++++++++++++++++++ 2 files changed, 261 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_matrix_agent_reactions.py diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 6a2eb362ff5d4..5ae64c3b5d698 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -1338,6 +1338,11 @@ def __init__(self, config: PlatformConfig): # if that changes, add a config.yaml entry rather than an env var. self._reaction_redaction_delay_seconds = 5.0 self._reaction_redaction_tasks: Set[asyncio.Task] = set() + # Agent-facing reactions (send_message action="react"): the most + # recent inbound event per room (the default reaction target), and + # the annotation events we posted so unreact can redact them. + self._last_inbound_by_room: dict[str, str] = {} + self._agent_reactions: dict[tuple[str, str], str] = {} # Proxy support — resolve once at init, reuse for all HTTP traffic. self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY") @@ -3960,6 +3965,72 @@ async def _redact_reaction( """Remove a reaction by redacting its event.""" return await self.redact_message(room_id, reaction_event_id, reason) + # -- Agent-facing reactions (send_message action="react") --------------- + # + # Unlike the lifecycle hooks below, these are deliberate agent intents, + # so they are NOT gated by MATRIX_REACTIONS (that env var exists to mute + # the automatic per-message tapback noise, not explicit requests). + # Mirrors the photon adapter's pair; ``send_message`` discovers them via + # ``getattr(adapter, "add_reaction")``. + + async def add_reaction( + self, + chat_id: str, + emoji: str, + message_id: Optional[str] = None, + ) -> Dict[str, Any]: + """React ``emoji`` onto a message in room ``chat_id``. + + Without ``message_id``, targets the room's most recent inbound + message (typically the one the agent is responding to). Posts a + native ``m.reaction`` annotation, so any Matrix client renders it. + """ + target = message_id or self._last_inbound_by_room.get(str(chat_id)) + if not target: + return { + "success": False, + "error": "no message to react to — pass message_id (no " + "inbound message seen in this room since the gateway " + "started)", + } + reaction_event_id = await self._send_reaction( + str(chat_id), str(target), emoji + ) + if not reaction_event_id: + return { + "success": False, + "error": "reaction send failed (see gateway debug log)", + } + self._agent_reactions[(str(chat_id), str(target))] = reaction_event_id + return {"success": True, "message_id": str(target)} + + async def remove_reaction( + self, chat_id: str, message_id: Optional[str] = None + ) -> Dict[str, Any]: + """Retract our reaction from a message (best-effort). + + Only reactions placed through :meth:`add_reaction` in this process + are tracked; the lifecycle tapbacks manage their own redaction. + """ + target = message_id or self._last_inbound_by_room.get(str(chat_id)) + if not target: + return { + "success": False, + "error": "no message to unreact — pass message_id", + } + reaction_event_id = self._agent_reactions.pop( + (str(chat_id), str(target)), None + ) + if not reaction_event_id: + return { + "success": False, + "error": "no reaction of ours recorded on that message", + } + ok = await self._redact_reaction( + str(chat_id), reaction_event_id, "reaction retracted" + ) + return {"success": bool(ok), "message_id": str(target)} + def _schedule_reaction_redaction( self, room_id: str, @@ -3991,14 +4062,20 @@ async def _redact_later() -> None: async def on_processing_start(self, event: MessageEvent) -> None: """Add eyes reaction when the agent starts processing a message.""" - if not self._reactions_enabled: - return msg_id = event.message_id room_id = event.source.chat_id - if msg_id and room_id: - reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440") - if reaction_event_id: - self._pending_reactions[(room_id, msg_id)] = reaction_event_id + if not msg_id or not room_id: + return + # Remember the triggering message so add_reaction() can default to + # "the message being answered" (photon's _last_inbound_by_chat + # precedent). Recorded before the MATRIX_REACTIONS gate — muting the + # lifecycle tapbacks must not blind deliberate agent reactions. + self._last_inbound_by_room[str(room_id)] = str(msg_id) + if not self._reactions_enabled: + return + reaction_event_id = await self._send_reaction(room_id, msg_id, "\U0001f440") + if reaction_event_id: + self._pending_reactions[(room_id, msg_id)] = reaction_event_id async def on_processing_complete( self, diff --git a/tests/gateway/test_matrix_agent_reactions.py b/tests/gateway/test_matrix_agent_reactions.py new file mode 100644 index 0000000000000..9270d6a5d92df --- /dev/null +++ b/tests/gateway/test_matrix_agent_reactions.py @@ -0,0 +1,178 @@ +"""Tests for the Matrix adapter's agent-facing add_reaction/remove_reaction. + +``send_message(action="react")`` dispatches by duck-typing public +``add_reaction``/``remove_reaction`` coroutines on the live adapter +(tools/send_message_tool.py). Photon ships the pair; Matrix had only the +private ``_send_reaction`` machinery, so reacting on Matrix errored +"Platform 'matrix' does not support message reactions" despite the adapter +being able to post native ``m.reaction`` annotations. These tests pin the +public pair: default-target resolution, explicit ``message_id``, unreact +redaction of our own annotation, and the no-target error path. +""" + +import asyncio +import sys +import types +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +# --------------------------------------------------------------------------- +# Stub mautrix so plugins.platforms.matrix.adapter can be imported without the SDK. +# --------------------------------------------------------------------------- + +def _stub_mautrix(): + stub = types.ModuleType("mautrix") + for sub in ("mautrix.types", "mautrix.client", "mautrix.client.api", + "mautrix.errors", "mautrix.crypto", "mautrix.util", + "mautrix.util.config"): + sys.modules.setdefault(sub, types.ModuleType(sub)) + sys.modules.setdefault("mautrix", stub) + m = sys.modules["mautrix.types"] + + class EventType: + ROOM_MESSAGE = "m.room.message" + REACTION = "m.reaction" + ROOM_ENCRYPTED = "m.room.encrypted" + ROOM_NAME = "m.room.name" + + class PaginationDirection: + BACKWARD = "b" + FORWARD = "f" + + class PresenceState: + ONLINE = "online" + OFFLINE = "offline" + UNAVAILABLE = "unavailable" + + class RoomCreatePreset: + PRIVATE = "private_chat" + PUBLIC = "public_chat" + TRUSTED_PRIVATE = "trusted_private_chat" + + class TrustState: + UNVERIFIED = 0 + VERIFIED = 1 + + for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"): + setattr(m, attr, str) + m.EventType = EventType + m.PaginationDirection = PaginationDirection + m.PresenceState = PresenceState + m.RoomCreatePreset = RoomCreatePreset + m.TrustState = TrustState + + +_stub_mautrix() + +from plugins.platforms.matrix.adapter import MatrixAdapter # noqa: E402 + + +ROOM = "!testroom:matrix.org" + + +def _make_adapter(): + """Construct a MatrixAdapter with only the reaction state under test.""" + adapter = object.__new__(MatrixAdapter) + adapter._reactions_enabled = True + adapter._pending_reactions = {} + adapter._last_inbound_by_room = {} + adapter._agent_reactions = {} + adapter._send_reaction = AsyncMock(return_value="$reaction-1") + adapter._redact_reaction = AsyncMock(return_value=True) + return adapter + + +def _run(coro): + return asyncio.run(coro) + + +def test_add_reaction_defaults_to_last_inbound(): + adapter = _make_adapter() + adapter._last_inbound_by_room[ROOM] = "$inbound-42" + result = _run(adapter.add_reaction(chat_id=ROOM, emoji="❤️")) + assert result == {"success": True, "message_id": "$inbound-42"} + adapter._send_reaction.assert_awaited_once_with(ROOM, "$inbound-42", "❤️") + assert adapter._agent_reactions[(ROOM, "$inbound-42")] == "$reaction-1" + + +def test_add_reaction_explicit_message_id_wins(): + adapter = _make_adapter() + adapter._last_inbound_by_room[ROOM] = "$inbound-42" + result = _run(adapter.add_reaction(chat_id=ROOM, emoji="🔥", message_id="$older-7")) + assert result["success"] is True + adapter._send_reaction.assert_awaited_once_with(ROOM, "$older-7", "🔥") + + +def test_add_reaction_without_target_errors(): + adapter = _make_adapter() + result = _run(adapter.add_reaction(chat_id=ROOM, emoji="👍")) + assert result["success"] is False + assert "message_id" in result["error"] + adapter._send_reaction.assert_not_awaited() + + +def test_add_reaction_send_failure_is_reported(): + adapter = _make_adapter() + adapter._last_inbound_by_room[ROOM] = "$inbound-42" + adapter._send_reaction = AsyncMock(return_value=None) + result = _run(adapter.add_reaction(chat_id=ROOM, emoji="❤️")) + assert result["success"] is False + assert (ROOM, "$inbound-42") not in adapter._agent_reactions + + +def test_remove_reaction_redacts_own_annotation(): + adapter = _make_adapter() + adapter._last_inbound_by_room[ROOM] = "$inbound-42" + _run(adapter.add_reaction(chat_id=ROOM, emoji="❤️")) + result = _run(adapter.remove_reaction(chat_id=ROOM)) + assert result == {"success": True, "message_id": "$inbound-42"} + adapter._redact_reaction.assert_awaited_once() + args = adapter._redact_reaction.await_args.args + assert args[0] == ROOM + assert args[1] == "$reaction-1" + assert adapter._agent_reactions == {} + + +def test_remove_reaction_without_own_reaction_errors(): + adapter = _make_adapter() + adapter._last_inbound_by_room[ROOM] = "$inbound-42" + result = _run(adapter.remove_reaction(chat_id=ROOM)) + assert result["success"] is False + adapter._redact_reaction.assert_not_awaited() + + +def test_processing_start_records_target_even_when_reactions_muted(): + """MATRIX_REACTIONS=false mutes the lifecycle tapbacks, not agent intent.""" + adapter = _make_adapter() + adapter._reactions_enabled = False + event = SimpleNamespace( + message_id="$inbound-99", + source=SimpleNamespace(chat_id=ROOM), + ) + _run(adapter.on_processing_start(event)) + assert adapter._last_inbound_by_room[ROOM] == "$inbound-99" + adapter._send_reaction.assert_not_awaited() + + +def test_send_message_react_dispatches_to_matrix_adapter(): + """The generic dispatch resolves matrix's new public pair end to end.""" + import json + from unittest.mock import patch + + import tools.send_message_tool as smt + from gateway.config import Platform + + adapter = _make_adapter() + adapter._last_inbound_by_room[ROOM] = "$inbound-42" + runner = SimpleNamespace(adapters={Platform("matrix"): adapter}) + with patch("gateway.run._gateway_runner_ref", lambda: runner): + result = json.loads( + smt.send_message_tool( + {"action": "react", "target": f"matrix:{ROOM}", "emoji": "❤️"} + ) + ) + assert result["success"] is True + assert result["message_id"] == "$inbound-42"