fix(matrix): propagate sender MXID + reply context to MessageEvent

The Matrix adapter built MessageEvent from inbound room events but dropped
the sender's MXID and display name on the event itself -- only 'source'
carried them. Other adapters (signal/slack/telegram/discord/mattermost/irc)
have the same gap; this PR fixes matrix and adds the supporting
top-level MessageEvent fields so the rest can follow.

Downstream effects for matrix specifically:
  - gateway prompt assembly can now read event.user_name (or source)
    without having to dig into source per platform
  - reply context (reply_to_text / reply_to_author_id /
    reply_to_author_name) is parsed from the inline > <@user:server> ...
    Matrix fallback format before stripping, instead of discarded
  - the gateway's existing [Replying to: "..."] renderer can now show
    who the user was replying to (was always anonymous for matrix)

MessageEvent gains two optional top-level fields (user_id, user_name,
both default None) so non-IM producers (cron/webhook/autonomous) remain
unaffected. Source still carries the same values for callers that
already read from there.

Tests cover:
  - non-reply message carries sender user_id/user_name on MessageEvent
  - different senders (alice, bob) both propagate
  - reply message carries reply_to_message_id + reply_to_text +
    reply_to_author_id + reply_to_author_name, parsed from the
    > <@carol:example.org> original question\n\nactual reply shape
  - non-reply message does NOT spuriously set reply_to_* fields

Sibling matrix tests (148 across test_matrix*.py) remain green.

Authored by WintleChoung <cwt@users.noreply.github.com>
Salvaged from PR #80293.
This commit is contained in:
WintleChoung 2026-08-07 14:14:26 +05:30 committed by kshitij
parent 87086bc5d7
commit e245a98781
3 changed files with 278 additions and 2 deletions

View File

@ -2060,7 +2060,16 @@ class MessageEvent:
# Message content
text: str
message_type: MessageType = MessageType.TEXT
# Author of this inbound message. Carried on the event itself (not
# only on ``source``) so prompt builders that build per-message text
# can resolve "who said this" without having to dig into ``source``.
# ``source`` still carries the same values for callers that already
# read from there. Adapters that produce events from non-IM sources
# (cron, webhook, autonomous) may leave these as ``None``.
user_id: Optional[str] = None
user_name: Optional[str] = None
# Source information
source: SessionSource = None

View File

@ -345,6 +345,49 @@ def _normalize_matrix_bang_command(text: str) -> str:
return f"/{resolved}{match.group(2) or ''}"
# Matrix reply fallback prefix looks like:
# > <@alice:example.org> quoted text
# > continuation of quoted text
# <empty line>
# actual reply text
# Capture the original quoted text and the quoted author's MXID so the
# gateway prompt layer can render "[Replying to: \"...\"]" with the author
# attached. Returns (text, author_id) — text is the joined quoted body,
# author_id is the MXID parsed from the leading "<@user:server>" pill or
# ``None`` if the fallback uses an unsupported shape.
_MATRIX_REPLY_FALLBACK_PILL_RE = re.compile(r"^>\s*<(@[^>]+)>\s*(.*)$")
def _extract_reply_fallback(body: str) -> tuple[Optional[str], Optional[str]]:
"""Return (reply_to_text, reply_to_author_id) parsed from a Matrix reply body.
Matrix stores reply text inline as ``> <@user:server> <line>\\n> <line>...``
followed by a blank line and the actual reply. The first line carries an
optional ``<@user:server>`` mention pill naming the original author.
"""
if not body or not body.startswith("> "):
return None, None
quoted_lines: list[str] = []
author_id: Optional[str] = None
for line in body.split("\n"):
if not line.startswith("> "):
# Blank line or the start of the actual reply — stop accumulating.
break
content = line[2:]
if author_id is None:
pill_match = _MATRIX_REPLY_FALLBACK_PILL_RE.match(line)
if pill_match:
author_id = pill_match.group(1)
# Drop the pill from the visible quoted text so "[Replying
# to: ...]" in the LLM prompt reads naturally.
content = pill_match.group(2)
quoted_lines.append(content)
quoted_text = "\n".join(quoted_lines).strip() or None
return quoted_text, author_id
class _MatrixHtmlSanitizer(HTMLParser):
"""Allowlist sanitizer for Matrix-compatible formatted HTML."""
@ -3394,8 +3437,17 @@ class MatrixAdapter(BasePlatformAdapter):
if in_reply_to:
reply_to = in_reply_to.get("event_id")
# Strip reply fallback from body.
# Capture the reply fallback BEFORE stripping it from body, so the
# gateway prompt layer can render "[Replying to: \"<original>\"]".
# Other adapters (Signal, Slack, Telegram) populate reply_to_text
# from their quote payload; Matrix stores it inline as `> <@user:srv>
# <text>\n\n<actual reply>` and discards it after stripping.
reply_to_text: Optional[str] = None
reply_to_author_id: Optional[str] = None
reply_to_author_name: Optional[str] = None
if reply_to and body.startswith("> "):
reply_to_text, reply_to_author_id = _extract_reply_fallback(body)
lines = body.split("\n")
stripped = []
past_fallback = False
@ -3410,6 +3462,13 @@ class MatrixAdapter(BasePlatformAdapter):
stripped.append(line)
body = "\n".join(stripped) if stripped else body
# Resolve the replied-to author's display name when we have the
# state_store available — falls back to the localpart otherwise.
if reply_to_author_id:
reply_to_author_name = await self._get_display_name(
room_id, reply_to_author_id
)
# Re-run bang normalization after reply-fallback stripping so a quoted
# reply whose actual content is a bang command (e.g. ``> quoted\n\n!model``)
# is treated as a command, matching how ``/command`` is recognized below.
@ -3426,6 +3485,15 @@ class MatrixAdapter(BasePlatformAdapter):
raw_message=source_content,
message_id=event_id,
reply_to_message_id=reply_to,
reply_to_text=reply_to_text,
reply_to_author_id=reply_to_author_id,
reply_to_author_name=reply_to_author_name,
# Sender metadata at MessageEvent level — `source.user_name`
# already carries this, but downstream code (e.g. the prompt
# layer, ghost-context rendering) historically reads the
# top-level fields. Mirror them so matrix matches signal/slack.
user_id=sender,
user_name=display_name,
)
if msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:

View File

@ -0,0 +1,199 @@
"""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