Skip to content

fix(gateway): keep quoted, forwarded and shared text out of the sender's message - #129176

Draft
iainlane wants to merge 304 commits into
NousResearch:mainfrom
iainlane:fix/gateway-quoted-text-out-of-body
Draft

iainlane wants to merge 304 commits into
NousResearch:mainfrom
iainlane:fix/gateway-quoted-text-out-of-body

Conversation

@iainlane

@iainlane iainlane commented Sep 30, 2026 •

Copy link
Copy Markdown

Stack

#128679 and #129160 must merge before this PR. Their order relative to each other is independent. #128655 follows this PR. The nesting records the logical dependencies, including both prerequisites of this PR.

Quoted and forwarded content must remain distinct from the sender's own words. Otherwise the gateway can expand a file or URL reference that another person wrote, or lose the quoted context when several messages are batched.

Represent quoted and forwarded text as attributed event context across Discord, Telegram, Slack, Feishu, WeCom, Weixin, QQ and Yuanbao. Preserve that context through adapter and gateway batching. Extract the existing receiver methods into topical modules before changing their behaviour.

The branch composes the current published contributions from speech and reference handling and gateway batching. Reference expansion receives the sender's actual caption and transcript before generated notes, quoted text or other people's context are added. The composition preserves the current Matrix context snapshot, media metadata, retention and rendering paths. Native Discord thread dispatch also retains the channel topic.

Review and history

The direct published batching prerequisite is 627fb2227c402f619e699eb2afd9214dd6e9180d. Above it are eight original source contributions from the current published #128679, followed by this PR's 24 original records. Those 24 include eight intentional contributor-credit records and 16 source or test contributions. All current original author and committer identities, dates, timezones, complete message prefixes, source references, order and empty credit records are preserved. Three bounded source joins preserve the existing extractions and apply the captured upstream annotation, UTC and timeout-alias cleanup inside their actual defining modules. The WeCom reply-context simplification retains the current UTC timestamp alias.

The own comparison is the composed context prerequisite through this PR. All 32 records are linear and signed. The published #128679 itself remains unchanged.

Validation

I ran all 48 affected complete test files with the installed SDKs, four workers, no retries and strict runtime, resource, unraisable-exception and thread warnings. The run recorded 955 passes and seven host skips. Two unchanged files retain two failures and three errors, so this is not a fully green scope.

  • The corrected sender-reference, caption and immediate-transcription paths pass their complete files. Reference expansion still precedes generated notes and other people's context. Transcription receives the actual event and populates its transcript cache before expansion.
  • The native Discord topic regression passes all three actual SDK dispatch cases, including a newly created thread.
  • The QQ and Slack failures were reproduced on the exact preceding published batching prerequisite. Both complete current test modules and the complete lifecycle and dispatch scopes that define those failures are unchanged from the published context branch. The same two failure IDs and three error IDs recur. Those files remain non-green.
  • Five other complete fixture modules are byte-identical to the preceding published context branch and emit the same 23 database shutdown warnings. Their 55 assertions pass, but those files are not resource-clean. The remaining 41 complete files have 631 passes and complete shutdown output without the four checked warning categories.
  • All 8,478 tracked Python source inputs are authenticated against the runtime Git revision before and after execution. The final published supplier update differs from that runtime source solely by one surplus EOF newline in an unused supplier test module. Every complete historical module inverse has an identical AST; selected runtime and static source inputs remain unchanged.
  • Ty reports 821 findings over all 43 complete Python paths in this layer, compared with 818 on the preceding public branch and 827 on the prepared current batching prerequisite. Of these, 813 match complete lexical spans, relative positions, whole defining statement ASTs and occurrence counts. Eight existing class findings require separate whole-source qualification: all three original base vectors are unchanged, the seven media method owners retain complete supplier bytes, and the Feishu original batch signature differs only by upstream removal of quoted annotation syntax. The enclosing class ASTs differ because this feature adds context methods. No new finding remains after those dispositions.
  • Ruff and changed-line whitespace checks pass. Forty inherited formatter paths remain, with no new path beyond the original or supplier scope. Local joins against the verified campaign base and the required captured upstream revision are conflict-free.

These checks exercise local dispatch, batching and real SDK imports with transport doubles. They do not establish live service exchange or hosted CI approval.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins platform/discord Discord bot adapter platform/feishu Feishu / Lark adapter platform/qqbot QQ Bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/wecom WeCom / WeChat Work adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 30, 2026

@arkheioncorp arkheioncorp left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE

Verdict: APPROVE

Summary: Moves quoted, forwarded, and shared text out of the sender's text field and into channel_context / reply_to_text, so the gateway treats only the sender's own words as the request and expands @file:/@url: references only in it.

Findings:

  • Security positive: This is a meaningful XSS/injection-surface reduction. Before this change, a forwarded message containing see @file:planted.txt would be expanded as if the sender wrote it. Now it stays literal in channel_context.
  • neutralize_untrusted_inline_text is correctly used for author names in attributed_context().
  • The channel_context / reply_to_text split is clean: replies go to reply_to_text (with reply_to_message_id), forwards/shared content goes to channel_context (with attributed_context() labeling).
  • All adapters updated: telegram, discord, wecom, qqbot, weixin, yuanbao, slack, feishu — each consistently puts other-people text in the right field.
  • base.py merge logic (absorb_channel_context) preserves context across batched/merged messages.
  • Tests cover forwarded documents, forwarded messages, replied-to attachments, merged forwards, shared links, QQ quotes, Weixin quotes, WeCom quotes — comprehensive.
  • One minor: the reply_to_message_id=f"quote:{msg_id}" synthetic ID for quotes without a real message ID is a reasonable convention; just ensure downstream code that matches on reply_to_message_id handles non-numeric prefixes (looks like it does, since it was already used in some form).

No blocking issues. Well-scoped, well-tested, and addresses a real security concern.

Matrix permalinks change with each triggering event. Rendering one in
the pinned session prompt made the prompt stale or changed its bytes
every turn, which breaks conversation prompt caching.

Put the link in the current model-facing user message and remove it from
the durable transcript, including queued turns. Respect effective PII
redaction when adding the note. Percent-encode room IDs, event IDs and
the via server so delimiters cannot change the matrix.to URL structure.

Preserve the Matrix and Discord source-note handling when integrating
the upstream prefix removal change. Use removeprefix after selecting
the platform-specific note, so each transport removes only its own
generated prefix.
The live Matrix exchange now checks the model request for a permalink to
the event sent by the independent client. It also verifies that the link
is absent from the system prompt, which must remain stable across turns.
Record the exchange duration separately from container setup time.
Room version 12 IDs have no server component. A permalink built only
from the room ID therefore omitted the server hint, which can prevent a
client outside the room from locating it.

Use the joined bot account's server when the room ID has no domain. The
builder still leaves via absent when neither server is known.
A room ID can identify a server that no longer participates in the
room. Its server may also include a port or IPv6 address, which the
last-colon parser truncated to an invalid via hint.

Use the joined bot account's server for inbound source links. When only
a room ID is available, parse its full server after the first colon.
The same parse also corrects the room's session scope.
In a thread, every turn's source link pointed at the thread root. The
root was chosen as a stable anchor while the link lived in the pinned
session prompt, which is per thread. The link now goes on each user
turn, so the reason no longer applies, and the model never received the
ID of the message that triggered the turn. A request to cite "this
message" produced a link to the root, which may be another user's
message.

Build the per-turn link from the triggering event in all cases. A
matrix.to link to an event inside a thread opens that thread in current
clients, and the session prompt already gives the thread root.
A room-ID permalink needs via servers so that a reader outside the room
can join through one of them. The link carried only the bot's server.
When that server was unreachable from the reader's server, the link
failed.

Choose up to three servers as the Matrix specification recommends for
room-ID permalinks. The first is the server of the highest-power joined
member at power level 50 or above; room version 12 creators count as
the highest power. The rest are the servers with the most joined
members, and IP literals are never chosen. The adapter reads members,
power levels and the create event from the mautrix state store, which
sync keeps current, so building a link makes no request. When the
store has no joined members for the room, use the bot's server and then
the room ID's server.

Server ACLs are not applied, because the state store does not keep
m.room.server_acl. Move the builder and the selection into a sibling
module, and emit one via parameter per server.
The session prompt stopped rendering the Matrix source link when the
link moved to the current user turn. Two prompt tests still asserted
that the prompt omitted the link when it was unset or redacted. No code
path produces that output any more, so neither test could fail because
of this feature. The prompt-stability test covers the remaining
contract, and the per-turn tests cover the note and its PII
suppression.

Restore the comment on the per-turn notes. strip_inbound_source_note
removes a note only when it is the first thing in the message, so a
later prefix added outside the notes would leave the note in the
persisted transcript.
Choosing source-link via servers made every inbound message await
get_power_levels and get_create on the client's state store. The
project-context isolation and voice tests gave the adapter a MagicMock
store with only get_members awaitable, so all six inbound tests in
those files failed with "'MagicMock' object can't be awaited".

Production always uses mautrix's MemoryStateStore. Give both fakes a
real MemoryStateStore in which the bot and the sender have joined the
test rooms. The real store implements every state query, so these tests
keep working when the adapter starts reading other room state. The
stores are filled with MemoryStateStore.deserialize because the helpers
are synchronous and some are called inside a running event loop.
The effective-state parent added a description to the matrix_read limit
argument: "Maximum number of events to read". In this layer the same
argument also bounds the members kind, which returns members rather
than events, and the pins kind. Say that the limit applies to events or
members so the model can tell that it bounds member lists too.
The merged matrix_read description said that events are listed oldest
first and that `errors` lists events that could not be decrypted. Both
sentences came from the history reads. A pins read returns events in
the order of the room's m.room.pinned_events list, and its errors also
cover pins that could not be fetched, are not visible messages, came
back as another event, or whose edit was redacted. A model that read
the description could tell a user that a deleted pin was an encrypted
message.

Limit the ordering sentence to room and thread reads, say that pins
follow the room's pinned order, and say that `errors` lists events that
could not be read or decrypted, with the reason. History reads already
returned non-decryption errors such as "event content changed", so the
wider wording is also accurate for them.
Matrix batches quick text messages into one turn, and the merged event
keeps the first chunk's message_id. When thread backfill read the
thread's relations, it excluded only that ID. If a user sent two quick
messages into a thread without a session, the second message appeared
under "[Earlier messages in this thread]" and again in the current
turn.

Record the IDs of the events merged into a pending event, in the text
batch and in both busy-session merge branches, and exclude all of them
from the backfill.
Reply resolution and thread backfill decrypt m.room.encrypted events
when the client has the megolm session keys, but no test reached
either branch. Thread backfill deserialises the raw event with mautrix,
and the earlier test invocation could not import mautrix, so a test of
that path would have skipped the event without failing.

Drive both paths with real mautrix events and a crypto fake that
decrypts only when the keys are available. A decrypted event supplies
the quoted text or the thread entry, and a missing session leaves it
out. Replacing either decryption call with a no-op fails all four
cases.
The gateway core fetched earlier thread messages for a new Matrix thread
session itself. `_prepare_inbound_message_text` checked
`source.platform == Platform.MATRIX` and `hasattr(adapter,
"fetch_thread_context")`, which put Matrix-specific code in the runner
beside the generic `prepare_turn_context` hook that the room-context
layer added for room-state notes.

The two paths also disagreed about the `[New message]` marker. The
thread history went in front of the message text, and the room note
with its marker was prepended afterwards. When a room note was pending
and a message started a new thread session, the model received

    <room note>

    [New message]
    <reply pointer>

    [Earlier messages in this thread]
    ...

    <message>

so the marker labelled the thread history as part of the new message.

Move the thread fetch into `MatrixAdapter.prepare_turn_context`. The
hook gains a `first_turn` argument, which the core sets when the
session transcript is empty, so an adapter can add history that only a
new session needs. The Matrix adapter returns the room note followed by
the thread history as one note, and the core puts the single
`[New message]` marker after all of it, before the reply pointer and
the message. The marker also follows thread history when no room note
is pending, as it already follows the Discord history backfill.

`TurnContextUpdate.channel_state` becomes optional. The adapter reads
room state only for text messages, and a failed read gives no
snapshot, but thread history is still reported in both cases; a `None`
snapshot leaves the saved state unchanged.

The core no longer checks the platform here, and any adapter can
report first-turn context through the same hook.
After the merge of NousResearch#128621, the adapter had two rules for one question:
whether a leading "> " block is a legacy reply fallback or the user's
own quote. NousResearch#128621 added _has_reply_fallback, which requires an
<mx-reply> element at the start of the HTML body, or a plain quote block
that starts with the quoted sender's pill and ends with a blank line.
This branch's _strip_thread_reply_fallback used its own copy of the pill
regex and checked the pill alone. The explicit-reply path in
_extract_reply_context still treated any leading "> " as a fallback,
because this branch's signature passed only formatted_body, and
_has_reply_fallback needs the whole content.

Use _has_reply_fallback for thread messages and for explicit replies,
and remove _strip_thread_reply_fallback and its duplicate regex.
_extract_reply_context now takes source_content as a positional
argument before relates_to, as in NousResearch#128621, and reads formatted_body from
it. The sender and chat_type keywords remain.

Two test expectations change:

- In NousResearch#128621's test_reply_strips_only_a_reply_fallback, the
  thread-message-own-quote case now expects no reply_to_message_id.
  This branch treats m.in_reply_to under is_falling_back as the thread's
  fallback target, not an explicit reply, so the event is not a reply.
  The message text, which is what that case checks, is unchanged.
- test_thread_message_strips_only_the_reply_fallback gains a case for a
  thread message whose quote starts with a pill but has no blank line
  after it. The pill-only rule stripped that quote; the shared rule
  keeps it as the user's text.

The shared rule also strips a thread message's plain quote when the HTML
body starts with <mx-reply>, even without a pill, as it already does for
explicit replies.
Two helpers check whether a reply's HTML body starts with an
<mx-reply> fallback. _has_reply_fallback decides whether the plain-text
quote is stripped from the message, and extract_mx_reply_quote returns
the quote as reply_to_text. Their checks disagreed.
_has_reply_fallback required format "org.matrix.custom.html" and a
bare "<mx-reply>" tag. extract_mx_reply_quote ignored format and
accepted any body that started with "<mx-reply". When the tag had an
attribute, as in <mx-reply data-x="1">, or the event had no format
field, and the plain-text quote had no pill, the message kept the
quote and reply_to_text repeated it. The model then saw the quote
twice.

Add starts_with_mx_reply(content) to reply_context.py and call it from
both helpers. It requires the HTML format and matches an <mx-reply>
start tag with or without attributes, because the spec defines the
fallback as a formatted_body that "begins with an <mx-reply> start
tag". The match ignores case because the quote extractor's HTML parser
lower-cases tag names, so the check accepts every tag that the
extractor reads as <mx-reply>. extract_mx_reply_quote now takes the
event content instead of the formatted body because it needs the
format field.
Busy-text debounce updates the reply anchor to the latest inbound message
but did not record the earlier message IDs. A new Matrix thread session
therefore fetched an earlier trigger as thread history and included the
same text again in the current user message.

Record each previous anchor and incoming batch's merged IDs before the
anchor changes. Thread-history preparation can then exclude every event
already represented in the current turn. Input without a new anchor
keeps its existing anchor and still contributes its merged IDs.

Extend the real model-preparation invariant to both ingress and busy
batching. The busy case repeated the first trigger before this change
and now excludes it. Independent full-event expectations also reproduce
six missing-ID cases and pass after the fix. The pending-merge contract
checks unknown, unauthorized and authorized quoted authors together with
media and merged IDs.
The room-context prerequisite adds channel_state as the final positional
MessageEvent constructor argument. Threading inserted two fields before
that argument, so an existing positional room-state dictionary became
the reply-author authorization value and the event lost its room state.

Append the new threading fields after channel_state. Existing positional
construction keeps its original meaning, and the new authorization
field defaults to None. A direct constructor probe reproduces the old
misassignment and passes after reordering; all 330 tests in the seven
adjacent event, thread and gateway files pass.
iainlane and others added 24 commits October 4, 2026 16:11
The reaction follow-up layer now includes the complete action and
effective-event prerequisites. Text intake and event construction pass
reply_parent explicitly, while reaction tools require joined room state
and an authorised requester. The old callbacks rejected the keyword,
and the queued identity test failed before exercising context restoration.

Accept the reply context in those callbacks and configure the queued
fixture with joined membership and both intended requesters. Remove the
duplicated identity argument introduced when replaying the metadata test.
The admission, cursor, FIFO and A/B/A identity assertions remain intact.

The adjacent background-task control also created an unawaited coroutine
because its AsyncMock treated toolsets_for_source as asynchronous. Give
that synchronous method an explicit MagicMock, like the existing media
extractors. The warning reproduces on the original published branch under
strict coroutine checks; the corrected control and the composed fixture
checks pass all 103 cases.
Move reaction watch registration, delivery tracking and admission into a
topical mixin before changing their interaction with effective reply
contexts. The adapter exceeds the project file-size threshold; the mixin
keeps the existing behaviour and gateway bindings together.

The plugin loader imports each adapter under a profile-owned namespace.
A class imported through the source path has a different identity, so
check the shared Matrix mixin when registering a delivery. A real plugin
loader invariant verifies watch registration across A/B/A profile homes.
A reaction to the last chunk of a split final reply loses the beginning
of the logical answer when gateway preparation refreshes the concrete
reply event. After an encrypted restart, the follow-up therefore quotes
the reacted chunk instead of the saved final excerpt.

Record the delivered prefix in delivery order, with digests of the final
visible bodies. Persist those bounded dependencies with the watch and
propagate their retained effective states through the existing turn
snapshot. Reuse the exact saved excerpt while the visible prefix remains
unchanged. Current edits replace the quotation, and redactions or content
errors prevent stale text from entering the model input. The reacted
target is checked separately even when it follows the quoted prefix.

The regressions reproduce the restart loss and stale prefix quotation
on the previous head. The strict seven-file gate passes 172 tests with
the actual Matrix SDK, four workers and retries disabled.
A cancelled reaction handler deletes its single-use watch before the
gateway admits the follow-up. Cancellation during the busy-session lookup
leaves no queued input and no acknowledged sync event, so replaying the
same reaction cannot resume the conversation.

Reserve the watch for that reaction instead of deleting it immediately.
Release the reservation when admission fails before the gateway consumes
the event. Delete it after admission, including cancellation while a
queued event's durable acknowledgement finishes. Persist the reaction ID
so a restart can retry the same event without allowing a different
reaction to consume the watch. Claim tokens make completion conditional
on the reservation's current owner.

The regression cancels the real busy admission path after its session
lookup, then retries through the native sync dispatcher. It also checks
reservation replacement after reopening the store and cancellation after
FIFO admission while the durable write is pending. The strict eight-file
gate passes 175 tests with the actual Matrix SDK and no retries.

The reaction-admission tests created SessionStores but finished with
their shared SQLite handles still open. All three cancellation cases
passed, followed by six unclosed database warnings at interpreter exit.

Close each test-owned store after cancellation and task gathering in the
existing finally block. The cancellation and exactly-once assertions stay
unchanged. The three complete child cases now exit without unclosed
database warnings.
The reaction follow-up contract registered the gateway authorisation
callback, then expected the adapter's direct user check to deny every
caller. Matrix now delegates that check to the live callback so pairing
revocations take effect immediately. Global, platform and pairing grants
therefore fail the old assertion before the reaction path runs.

Expect the direct check to match the configured grant. Keep the actual
follow-up admission assertion and the denied case, which retains its
reaction watch without dispatching a turn.

Historical attribution for original source a4d3478
(audit record 314; historical approval position 315). Public PR NousResearch#126281
already includes this assertion correction at its captured head
1369cea. This empty commit records the
missing original contribution credit. The source tree is unchanged.
Reaction sends retain every delivered event ID, and final edits update
effective content after successful delivery. Those outcomes can still
report success when the adapter exceeds Matrix's byte budget.

Check the complete receipt result for multibyte chunks. Check that a
final edit fits the content budget without changing its complete
effective content, finalization flag or successful cache update.

On the unchanged public source, these contracts reproduce a 16-byte
chunk under a 12-byte limit and a 59,494-byte formatted edit under a
45,000-byte content limit. Both pass in the combined upstream and
reaction implementation.
Restoring every accepted follow-up separately preserves FIFO input, but
it also separates a parked photo from the compatible goal continuation
that should consume it. The photo then completes with a separate turn
while the continuation remains pending.

At the recursion limit, merge compatible photo and text input through
the existing recorded merger and attach its processing lifecycle to the
consuming event. Conflicting replies, security contexts and execution
owners still use accepted FIFO restoration. That path retains every
previously admitted input without repeating capacity admission.

All 93 original lifecycle routes now pass, including merged photos and
orphan cancellation. The queued profile and reply contracts use the
actual TurnContext model, so the fixture includes the receipt state
without replacing its identity or profile assertions.

Source-commit: a51649262e4290a32bdd2f64e29d704b7425747d
Integration-note: A fresh processing task previously promoted its reservation
to accepted queued input before the runner decided whether to merge it.
Preserve the existing accepted bit when a queued reservation is reused,
but create fresh processing reservations with accepted=False. This keeps
fresh photos with compatible pending input and retains accepted FIFO
restoration for previously queued records. The actual processing contract
reproduces the fresh-input failure and retains the accepted-input control.
The qualified 111-case gate excludes only the two independently diagnosed
shutdown observer cases; the complete gate follows their owner correction.
The shared batching implementation is in the platform facade, which is
already about 4,700 lines long. A new reply-boundary hook would add more
behaviour to that facade.

Move the existing batching methods to BaseTextBatchingMixin before
changing their behaviour. Keep every method body unchanged and type the
mixin receiver. Update the gateway instructions to specify the new
implementation path.

The fixed upstream annotation cleanup overlaps the extracted text-batch
methods. Keep the complete methods in their topical module and apply
the same unquoted event annotations there. Their bodies are unchanged.
Gate finding BR8 r1 2c [Medium]: the new reply-context conflict rule made
the debounce's pending-slot merge reachable for one sender. one->X,
two->Y, three->X left pending='one\nthree' and buffer='two', so the agent
saw "three" before "two". Same-sender text now stays in the debounce
buffer; only a different sender may merge into the pending slot.
The shared text batcher appends text and media without absorbing reply
context. A plain message followed by a quoted message loses the quote,
and two different reply targets become one turn with the first target.

Use the existing boundary hook to dispatch incompatible reply contexts
as separate batches. Absorb the complete reply context when one event
has no quote, and pad attachment flags before extending media URLs.
Keep the shared dispatch cancellation policy for completed batches.

WeCom now promotes the current batch after enqueueing. Looking up the
previous batch would change the attachment event that a reply boundary
has already dispatched.

Keep the upstream unquoted event annotations and format the extracted
module consistently. Method bodies and assertions are unchanged.
The Telegram adapter contains the teardown of text, photo and album
buffers in a large transport facade. Reply-boundary deliveries need the
same teardown policy.

Move the existing cancellation method into TelegramDelayedDeliveryMixin
before extending it. Keep the method body and adapter method resolution
unchanged, and type the mixin receiver.
A reply boundary starts a flush outside the ordinary delivery maps.
Delivery teardown therefore misses a boundary that has already popped
its event and is awaiting dispatch. Cancelling a boundary before its
coroutine starts can also lose the event in its private temporary map.

Keep boundary events and tasks in a dedicated buffer. Include that
buffer in delivery cancellation, salvage and clearing. Cancellation
requeues an in-flight event through the existing flush body; buffered
boundaries are salvaged before the later ordinary text batch.

The event-synchronised regression reproduces the missing earlier batch
and unfinished task before the fix. It checks both complete events and
that the boundary task has exited when teardown returns.
Inbound preparation combines profile scope, context references, media
notes and reply pointers in a large intake module. Move that existing
pipeline into a topical sibling before changing its expansion and
quoting behaviour. GatewayInboundMixin inherits the methods, and the
lazy intake-helper imports preserve the existing runtime patch seams.

Compose the inbound extraction with the required upstream revision.
Move all fifteen complete current context methods, including their current
type annotations, while retaining the remaining upstream intake methods
and the plugin injection base. Keep the existing local imports for the
Discord routing note and media rehoming at their new defining scopes.

Keep the extracted inbound methods in their topical module and apply
the upstream unquoted annotations there. Method bodies are unchanged.
Image descriptions are generated context. A reference in a description
must not trigger local file access. Document why image analysis follows
authored caption and speech expansion so a later ordering change does
not treat generated references as sender input.
Pending-event merges and busy-text debounce share reply and attachment
semantics inside the large base adapter. Telegram's ingress overrides
also modify the same batching boundary. Extract those existing topics
before integrating the reviewed reply-context fixes.

Repoint in-tree imports and patch targets to the defining merge module.
The adapter classes inherit their existing methods, so timers, tracked
redispatch and delayed-delivery ownership keep their current behaviour.
Four routing tests pass a recording transport to RelayAdapter, whose
transport parameter requires the RelayTransport protocol. The fixture
only implements its inbound setter and outbound send, so the four
constructor calls fail static type checking even though their exercised
routing paths work.

Implement the complete structural protocol in the recording fixture.
Unused operations raise an assertion error so an unexpected call fails
the test. Keep the recorded action, platform and send result, and narrow
the captured dictionaries before the existing routing assertions.

The correction changes the inherited test fixture. It preserves the
existing contributor records and all original routing assertions.
The encrypted-file fixture returned a constant serialized payload, so
assigning its uploaded URL did not appear in later serialization. The
Matrix SDK serializes the URL stored on the encrypted-file object. The
adapter currently overwrites the outgoing URL, which concealed this
fixture difference in the existing upload test.

Read the fixture URL when serialization is called. Add an invariant
that compares the complete payload before and after URL assignment,
with only the URL overwritten. Keep the existing upload and routing
assertions.
Matrix receives ordinary m.reaction events, but reads and catch-up only
showed message events. The bundled m.relations summary cannot provide
annotation reactions.

Read bounded annotation children through the live Matrix client.
Decrypt encrypted reactions to verify their type, but use the outer
cleartext m.relates_to for the target and key even if the decrypted
payload specifies another relation. Discard redacted events and
deduplicate by sender and key. Present the actor and target in read and
catch-up context without starting a turn. Mark clipped keys and report
incomplete reads when keys, API failures or bounded pages prevent a
complete snapshot.

A five-second batch deadline keeps a slow homeserver from delaying a
turn for every earlier message. Completed lookups remain visible when
others time out. A separate Matrix client confirms that a reaction
appears in the next mentioned turn. Focused tests cover duplicate and
redacted reactions, encrypted events, long keys, lookup errors, and
the deadline.

Bind the real room-policy predicate in the read fixtures. Read admission
rechecks room policy after awaiting room identity, so the fixture must
provide the same adapter protocol as the production path.
Source-permalink and reaction-context branches each test their own prompt
contract. Their combined adapter adds the trigger permalink to catch-up
messages, so the reaction fixtures need the SDK's asynchronous state-store
methods and complete expected source notes.

Merge both feature histories without changing their existing commits.
Preserve reaction author trust, room and thread context, transcript roles,
cache privacy and the source-permalink encoding and routing rules. Use the
real MemoryStateStore in the affected fixtures and retrieve SessionSource
from the final context tuple item. Check each complete catch-up prompt
against its explicit room, trigger event and via server, including the
first event of a batched turn.

The four composed gateway files cover 343 cases. The affected two files
pass all 264 cases after the fixture adaptation; the unchanged session and
project-isolation files passed their 79 cases. Scoped Ruff reports no lint
diagnostics. Existing formatter differences remain qualified separately.
Live Docker room and thread backfill validation is still outstanding.

This dependent layer preserves both parent histories. Its two live
backfill fixture adaptations do not claim complete original 858 delivery.

Historical composition qualification

The preceding message records the original two-parent composition.
This successor reapplies its fixture integration after the individually
preserved prerequisite, SDK and reaction contributions on captured main
648a309. Its history has one parent.
The original composition message remains complete for provenance; this
commit does not create a merge or establish live Matrix acceptance.
The original feature tests predate the canonical approval lifecycle,
permalink source fields, room policy owner argument and SDK send error
classification. Their doubles omitted asynchronous state-store reads,
exact approval request IDs and terminal card edits. Several complete
expected events also omitted the newly composed source and mention
metadata, so the full feature gate failed before checking its intended
behaviour.

Update those fixture bindings and complete expected values for the
current defining owners. Keep every original parameter row, delivery
attempt, payload, cancellation and authorization assertion. Thread
reconnect expectations retain confirmed delivery and cache identity.
Catch-up expectations include the permalink note before the new message.

The complete nine-file feature boundary passes all 808 cases with the
actual installed Matrix SDK, four workers and no file retries. The full
Matrix file finishes in 30.7 seconds, including the event-loop lookup
contract that timed out on the earlier incomplete source composition.

Original858 public source qualification

The preceding 808-case result records the original composition. It does
not describe a new run against this public tree. The published backfill
composition has 343 scoped cases, using 264 final affected cases and 79
unchanged earlier successes. Its inherited formatter and type findings
remain qualified, and live Docker validation remains pending.

The other feature contracts are represented by their existing public
owners. Their fixture expectations follow the actual approval, notice,
location, send-classification, read-policy and delivered-event APIs.
Expectations from the original later composition do not establish bugs
in standalone owners that use an earlier API. No combined six-module
endpoint or repeated native gate is required for this source credit.

This source-neutral contribution preserves the complete original
message and both original contributor dates. It records the distributed
source qualification without changing the published source tree.

Current linear successor qualification

The 343-case result above describes the earlier a76019d composition.
This contribution follows the verified single-parent successor a83881c
and keeps its source tree unchanged. The accepted seven-file gateway
gate for that successor passes 362 cases. Live Matrix validation,
private integration and whole-campaign completion remain separate.
The Matrix SDK retries rate-limit responses and connection timeouts
indefinitely by default. A failed request during account or room setup
can therefore stop the whole native module until the outer runner kills
it, without reporting the setup error.

Disable those retries and bound ordinary fixture requests to fifteen
seconds. Apply the same configuration to registration,
restored accounts and the encrypted observer. The fixtures now expose
the SDK error so the failing operation can be diagnosed directly.

A real loopback HTTP server reproduces the repeated rate-limit requests
through registration and restored accounts. Both paths now make one
request and return the expected error.
Matrix turns could read message history, but the agent could not
inspect the current room state, joined members, power levels or pinned
messages. Those details are available from the receiving bot and can
affect what the agent should say or attempt.

Extend the Matrix-only read tool with bounded inspection kinds. Fetch
state and members through the owning adapter, apply its existing room
and requester policy, and resolve pinned messages with the same
decryption path as event reads. Report missing pin content per event
so the other pins remain visible. Include the event type and creator
override when presenting permission thresholds.

A separate Matrix client verifies that the model receives the room
details and pinned message from a disposable Synapse instance.

Use the effective-state parent's client-events decoder and gateway
scheduling path throughout inspection and its tests. Recheck admission
after identity resolution, derive permission defaults from raw state,
and preserve cancellation while refreshing pinned content. The feature
and these interface migrations form one change so the initial commit
uses the current prerequisite APIs.
The dependency-rebinding fixtures installed a fake importer but left the
package-availability check unchanged. With a complete SDK installed,
ensure_matrix_deps skips the importer, so these fixtures failed without
exercising their intended success and refusal paths.

Report a missing Matrix package in the fixture alongside the existing
installation seam. The real rebind and failed-import assertions then
exercise the intended fresh-install boundary.
The Matrix adapter ignores emotes and does not register native sticker
events. Emotes describe the sender's action, while stickers use a
separate event type with a description and an image. The existing text
handler would interpret a slash-like action body as a gateway command,
so native actions need attributed text before command classification.

Accept both through the existing room, sender and mention gates. Prefix
emotes with their actor, preserve sticker descriptions, and download
sticker pixels through the bounded media path. Use the same labels in
effective reads, replies and thread history.

Queued stickers can be edited or redacted while media preparation is
pending. Add an optional media-event snapshot capability and retain the
source paths of image descriptions, so final model preparation removes
both withdrawn pixels and their derived text. Existing platform
snapshots remain compatible and previous conversation context remains
unchanged.

The two parameterised invariants reproduce six failures on the committed
parents and now cover native SDK events, gates, unavailable media and
withdrawal during asynchronous preparation. Matrix and adjacent gateway
checks pass against the pinned SDK source.
@iainlane
iainlane force-pushed the fix/gateway-quoted-text-out-of-body branch from f9b241b to 0560602 Compare October 8, 2026 16:05
Discord ingress can create a thread from a text-channel message. The
source then identifies the thread, so its topic must come from that same
route. Looking up the original text channel instead adds its instructions
to a thread that does not inherit text-channel topics.

Exercise the real Discord SDK objects through inbound dispatch for direct
channels, existing threads and newly created threads. The new-thread row
fails when the topic lookup uses the original message channel; the
corrected effective-channel lookup passes all three rows.
The fake HTTP API discarded the client_session supplied by the Matrix
adapter, so disconnect closed a mock while the real aiohttp session was
left open. A reconnect test also replaced disconnect with a stub and
left its new connection open after checking the guard. Strict resource
warnings expose both leaks.

Preserve the supplied session in the fake API. Use a wrapping disconnect
spy in the reconnect test and disconnect the replacement client after
the existing call assertion. The fake API and reconnect fixture predate
this branch, so this focused fixture maintenance is separate from the
corrections folded into their campaign contributors. Production code
and all original assertions are unchanged.
Pending-event merges and busy-text debounce share reply and attachment
semantics inside the large base adapter. Telegram's ingress overrides
also modify the same batching boundary. Extract those existing topics
before integrating the reviewed reply-context fixes.

Repoint in-tree imports and patch targets to the defining merge module.
The adapter classes inherit their existing methods, so timers, tracked
redispatch and delayed-delivery ownership keep their current behaviour.

The fixed upstream annotation cleanup overlaps the extracted debounce
state. Keep the state in its existing topical module and apply the same
unquoted task annotation there. The complete state and timer behaviour
remain unchanged.
@iainlane
iainlane force-pushed the fix/gateway-quoted-text-out-of-body branch 2 times, most recently from 5923b54 to 5185097 Compare October 9, 2026 21:29

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/discord Discord bot adapter platform/feishu Feishu / Lark adapter platform/qqbot QQ Bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/wecom WeCom / WeChat Work adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.