e8511efe75
_handle_media_message had the same gap as _handle_text_message: it didn't set user_id/user_name or any reply_to_* fields on MessageEvent. A user replying to a photo/video/audio in Matrix got null sender metadata and null reply context — the exact bug PR #80293 fixes for text messages. Mirrors the text handler's reply fallback parsing + sender propagation into the media handler, and adds a test covering a media reply event. Also fixes test env mutation: _make_adapter now accepts monkeypatch and uses monkeypatch.setenv() instead of bare os.environ assignment, preventing env var leakage across tests in the same xdist worker.
249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
"""Tests for Matrix MessageEvent metadata (sender, reply context).
|
|
|
|
The matrix adapter builds MessageEvent from inbound room events. Other adapters
|
|
(Signal, Slack, Telegram, Discord, Mattermost, IRC) populate the sender /
|
|
reply_to_* fields on MessageEvent so the gateway can:
|
|
- prepend "[Name] message" to user prompt text in shared-multi-user sessions
|
|
- render "[Replying to: ...]" with the replied-to author's name
|
|
- render "[Replying to your previous message: ...]" when the reply target
|
|
is the bot itself
|
|
|
|
Matrix historically dropped these fields, leaving the LLM unable to tell
|
|
who said what in a shared room. Agents saw only bare text strings, so a
|
|
self-emitted phantom interruption (e.g. "[This response was interrupted by
|
|
a user correction.]") looked identical to a real user message and triggered
|
|
endless reply loops.
|
|
|
|
These tests assert the invariant: every inbound Matrix text/media message
|
|
that the adapter dispatches via handle_message() must carry the sender's
|
|
MXID and display name on the MessageEvent (not buried in `source`), and
|
|
reply-targeted messages must carry the replied-to message's text and author.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
def _make_adapter(require_mention=False, auto_thread=False, monkeypatch=None):
|
|
"""Create a MatrixAdapter with mocked config and bypassed display-name lookup."""
|
|
# MATRIX_REQUIRE_MENTION and MATRIX_AUTO_THREAD are read once at __init__,
|
|
# so they must be set in the environment before constructing the adapter.
|
|
if monkeypatch is not None:
|
|
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true" if require_mention else "false")
|
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "true" if auto_thread else "false")
|
|
else:
|
|
import os
|
|
os.environ["MATRIX_REQUIRE_MENTION"] = "true" if require_mention else "false"
|
|
os.environ["MATRIX_AUTO_THREAD"] = "true" if auto_thread else "false"
|
|
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
from gateway.config import PlatformConfig
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@hermes:example.org",
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
adapter._text_batch_delay_seconds = 0
|
|
adapter.handle_message = AsyncMock()
|
|
# Bypass mautrix state_store lookup — fall back to localpart.
|
|
adapter._client = None
|
|
# Stub the DM/identity lookup chain so we don't need a real mautrix
|
|
# client. _is_allowed_matrix_room_event -> _is_dm_room ->
|
|
# _resolve_room_identity, and _resolve_message_context ->
|
|
# _resolve_room_identity (used to fetch chat_type).
|
|
identity = SimpleNamespace(
|
|
display_name="Test Room",
|
|
room_topic=None,
|
|
server_name="example.org",
|
|
chat_type="dm", # DM shortcut so we bypass MATRIX_ALLOWED_ROOMS
|
|
)
|
|
adapter._resolve_room_identity = AsyncMock(return_value=identity)
|
|
return adapter
|
|
|
|
|
|
def _make_event(
|
|
body,
|
|
sender="@alice:example.org",
|
|
event_id="$evt1",
|
|
room_id="!room1:example.org",
|
|
thread_id=None,
|
|
in_reply_to_event_id=None,
|
|
):
|
|
"""Build a fake Matrix room message event with optional reply context."""
|
|
content = {"body": body, "msgtype": "m.text"}
|
|
|
|
relates_to = {}
|
|
if thread_id:
|
|
relates_to["rel_type"] = "m.thread"
|
|
relates_to["event_id"] = thread_id
|
|
if in_reply_to_event_id:
|
|
relates_to["m.in_reply_to"] = {"event_id": in_reply_to_event_id}
|
|
if relates_to:
|
|
content["m.relates_to"] = relates_to
|
|
|
|
return SimpleNamespace(
|
|
sender=sender,
|
|
event_id=event_id,
|
|
room_id=room_id,
|
|
# Use *recent* timestamp so we don't fall into the startup-grace
|
|
# filter (which drops events older than `_startup_ts - 5s`). The
|
|
# production adapter ignores real events that pre-date gateway
|
|
# start; tests must use "now-ish" timestamps.
|
|
timestamp=int(time.time() * 1000),
|
|
content=content,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sender metadata on MessageEvent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_message_carries_sender_mxid(monkeypatch):
|
|
"""The MXID of the message author must reach MessageEvent, not just source.
|
|
|
|
Without this, downstream consumers cannot tell who said what.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch=monkeypatch)
|
|
adapter._startup_ts = time.time() - 10
|
|
|
|
event = _make_event("hello world", sender="@alice:example.org")
|
|
await adapter._on_room_message(event)
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
msg = adapter.handle_message.await_args.args[0]
|
|
assert msg.user_id == "@alice:example.org"
|
|
# Display name fallback to localpart when no state_store is available.
|
|
assert msg.user_name == "alice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_message_carries_sender_for_different_user(monkeypatch):
|
|
"""MXID propagation must work for arbitrary senders, not just alice."""
|
|
adapter = _make_adapter(monkeypatch=monkeypatch)
|
|
adapter._startup_ts = time.time() - 10
|
|
|
|
event = _make_event("hi from bob", sender="@bob:chat.example.org")
|
|
await adapter._on_room_message(event)
|
|
|
|
msg = adapter.handle_message.await_args.args[0]
|
|
assert msg.user_id == "@bob:chat.example.org"
|
|
assert msg.user_name == "bob"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reply context on MessageEvent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_carries_target_text_and_author(monkeypatch):
|
|
"""Reply messages must carry the replied-to message's text + author MXID/name.
|
|
|
|
Other adapters (Signal, Slack, Telegram) already populate these fields.
|
|
Matrix historically did not, so "[Replying to: ...]" rendered only the
|
|
quoted text without any indicator of *who* the user was replying to.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch=monkeypatch)
|
|
adapter._startup_ts = time.time() - 10
|
|
|
|
# Reply body in Matrix is: "> <@user:server> quoted body\n\nactual reply"
|
|
reply_body = "> <@carol:example.org> original question\n\nbecause reasons"
|
|
event = _make_event(
|
|
reply_body,
|
|
sender="@dave:example.org",
|
|
in_reply_to_event_id="$target1",
|
|
)
|
|
await adapter._on_room_message(event)
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
msg = adapter.handle_message.await_args.args[0]
|
|
|
|
# The reply target pointer must reach MessageEvent.
|
|
assert msg.reply_to_message_id == "$target1"
|
|
# The replied-to message's body must reach MessageEvent (stripped of
|
|
# the "> " quote prefix). Used by gateway/run.py:16015 to render
|
|
# [Replying to: "..."] in the LLM prompt.
|
|
assert msg.reply_to_text is not None
|
|
assert "original question" in msg.reply_to_text
|
|
# The replied-to author's MXID must reach MessageEvent. Used to detect
|
|
# "Replying to your previous message" vs "Replying to another user's message".
|
|
assert msg.reply_to_author_id == "@carol:example.org"
|
|
assert msg.reply_to_author_name == "carol"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_reply_message_has_no_reply_context(monkeypatch):
|
|
"""A non-reply message must not spuriously set reply_to_* fields."""
|
|
adapter = _make_adapter(monkeypatch=monkeypatch)
|
|
adapter._startup_ts = time.time() - 10
|
|
|
|
event = _make_event("plain message, no reply")
|
|
await adapter._on_room_message(event)
|
|
|
|
msg = adapter.handle_message.await_args.args[0]
|
|
assert msg.reply_to_message_id is None
|
|
assert msg.reply_to_text is None
|
|
assert msg.reply_to_author_id is None
|
|
assert msg.reply_to_author_name is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Media message sender + reply context (sibling gap fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_message_carries_sender_and_reply_context(monkeypatch):
|
|
"""Media messages must carry sender + reply context, same as text messages.
|
|
|
|
A user replying to a photo in Matrix produces a media event with the
|
|
same inline ``> <@user:server> ...`` reply fallback. The media handler
|
|
must parse it and populate reply_to_* fields, not just the text handler.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch=monkeypatch)
|
|
adapter._startup_ts = time.time() - 10
|
|
|
|
# Simulate a reply to a media message — the body carries the reply
|
|
# fallback prefix. We don't need a real mxc:// URL since the adapter
|
|
# validates URL format and rejects non-MXC; use a valid mxc:// URL.
|
|
reply_body = "> <@erin:example.org> nice photo\n\n"
|
|
event = SimpleNamespace(
|
|
sender="@frank:example.org",
|
|
event_id="$media_evt1",
|
|
room_id="!room1:example.org",
|
|
timestamp=int(time.time() * 1000),
|
|
content={
|
|
"body": reply_body,
|
|
"msgtype": "m.image",
|
|
"url": "mxc://example.org/abc123",
|
|
"info": {"mimetype": "image/png", "size": 1024},
|
|
"m.relates_to": {"m.in_reply_to": {"event_id": "$target_media1"}},
|
|
},
|
|
)
|
|
await adapter._on_room_message(event)
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
msg = adapter.handle_message.await_args.args[0]
|
|
|
|
# Sender metadata must be present on media messages too.
|
|
assert msg.user_id == "@frank:example.org"
|
|
assert msg.user_name == "frank"
|
|
# Reply context must be parsed from the fallback.
|
|
assert msg.reply_to_message_id == "$target_media1"
|
|
assert msg.reply_to_text is not None
|
|
assert "nice photo" in msg.reply_to_text
|
|
assert msg.reply_to_author_id == "@erin:example.org"
|
|
assert msg.reply_to_author_name == "erin"
|