Skip to content

fix(matrix): keep reaction feedback on a threaded card in its thread - #128710

Draft
iainlane wants to merge 524 commits into
NousResearch:mainfrom
iainlane:fix/matrix-reaction-feedback-thread
Draft

iainlane wants to merge 524 commits into
NousResearch:mainfrom
iainlane:fix/matrix-reaction-feedback-thread

Conversation

@iainlane

@iainlane iainlane commented Sep 30, 2026 •

Copy link
Copy Markdown

Stack

Merge each parent before its child. This PR targets main; its seven own commits are based on the published prerequisite below. The prerequisite's description lists the earlier dependencies.

Behaviour

Feedback on a Matrix approval or picker card stays in the card's thread and replies to the targeted card, including when another output message arrives before the reaction. Cards in the room timeline keep ordinary reply relations.

The shared reaction gate previously sent invalid-key, unauthorised and non-requester notices without thread metadata. Model-picker expiry, choice confirmation and failure notices had the same problem. Picker prompts now retain their send metadata. Feedback uses the thread root and the targeted card's fallback event ID, so continuation tracking does not select later output.

The first commit extracts picker state and shared reaction controls into reaction_controls.py. The extraction preserves the method bodies and logger binding, and updates direct imports and the area guide. The canonical approval lifecycle retains its expiry and terminal notices. Authorisation, approval/model/choice precedence and ordinary reaction follow-ups remain unchanged.

Historical validation

The previous complete six-file affected gate passed all 502 tests on its recorded source. The project runner uses four workers, zero retries and strict resource, runtime, unraisable and thread-exception warnings. Complete child output is resource-clean, and the runner source is restored exactly after output capture. Tests use the actual installed Matrix SDKs.

The seven-path static comparison reports 82 existing type findings. Every complete diagnostic, relative span, affected statement and multiplicity matches the original layer or the current prerequisite. No new finding is introduced. Ruff and introduced whitespace checks pass; the same seven files retain their existing formatting differences.

The current adapter retains all prerequisite mixins and adds the extracted reaction-control mixin after the reaction-prompt mixin. The runtime inheritance check uses the installed Mautrix 0.21.1 source. Local merges against both verified upstream revisions, 99a45ecc17cc51c0488ec3b8ccf7eaf483f7203d and 1744a19e0df568c647e4f3ff9c37f2a284a282fb, are conflict-free.

The earlier native receipt applies to source f0e03d83b9a9e0b16530d664574938f8fe064bed: all eight plain/encrypted approval cases passed in 153.8 seconds, within the 300-second budget, with zero retries or skips. The independent observer checked requester-only and invalid-key feedback after an intervening notice, including the card reply target, thread root and continuation flag. That is historical evidence; no fresh native run or hosted CI result is claimed for this update.

Review scope

The seven own commits preserve their original authors, author and committer dates, complete message prefixes and order. The extraction commit includes a short explanation of the current import and inheritance join. The original FU19 authorship and provenance trailers remain intact.

Published head: 0d7667d19ed1c86350a2e8514c70d3d4bea1bfc8. Review the seven own commits above prerequisite cda5ab71f0ae03c78b1ceb0e0c773e3b1af6e291. Once the prerequisites merge, rebase this layer when needed to remove the already merged changes from the upstream comparison.

Current captured-upstream compatibility

This update preserves all seven original contributions above the published #126288 prerequisite. Each complete source snapshot joins automatically against that prerequisite. The author and committer identities, dates, timezones, complete messages and contribution order remain unchanged. The current source merges without conflicts against captured upstream 3637c512fc3211bdccb61b7140d09ac1018508ba.

The fresh affected gate passes 502 cases across six complete files with the actual installed Matrix SDKs, four workers, zero retries and all four strict resource and runtime warning categories. Complete child output through interpreter exit contains no database, loop, socket, transport or client resource warnings. The gate authenticates all 9,355 tracked Python, configuration, lock and runner inputs before and after execution.

That runtime validation composes the three published #135482 worker and auxiliary-client lifetime corrections with this branch. Those three corrections are separate prerequisites and are excluded from this branch's seven own commits. The executed runtime source and the pure publication source are recorded separately. This update does not claim a fresh native homeserver run or hosted CI result.

The full seven-path static comparison contains 82 inherited diagnostics, with no new complete diagnostic occurrence compared with either the original layer or the current prerequisite. Ruff passes; the same seven formatter paths remain inherited. The original comparison uses the byte-identical complete source tree from the previously accepted publication. Earlier validation paragraphs retain their historical source qualifications.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins platform/matrix Matrix adapter (E2EE) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 30, 2026
Pending withdrawal changes three oversized modules: the shared adapter,
turn execution and Matrix adapter. Extract their existing pending merge,
post-turn selection and redaction handling into topical siblings first.

The Matrix redaction and runner drain methods keep their complete bodies.
The pending merge keeps its existing caption helper through a local
adapter import. In-tree callers and patch targets use the defining
module, with no compatibility re-export. The strict fifteen-file gate
passes 631 tests before any withdrawal behaviour changes.
Matrix gateway admission and text-batch receipts share the durable sync
boundary. Move their unchanged methods into an intake mixin before
extending the same boundary to native content. The adapter retains its
configuration, event parsing and transport lifecycle.

The four method ASTs are unchanged apart from a defining import for the
adapter's command normalisation helper. The existing sync, native-content
and reaction admission contracts pass after extraction.
A native emote can wait in the ordinary text batch while Matrix processes
later sync responses. Its handler returned no receipt, so the durable
sync cursor advanced before the batch entered the gateway. A restart in
that window could lose the emote. Ordinary text already deferred the
cursor until the same batch completed admission.

Use that existing admission method for both text and native emotes. Each
buffered event gets the batch's deferred receipt, and immediate emotes
report the same consumption result as immediate text.

The regression uses typed mautrix sync events and the actual durable
checkpoint store. Both text and emotes keep the previous cursor while
buffered and advance only after gateway admission.
The restart fixture waited for the model task after admitting its prime
input. Durable batch admission commits the sync cursor in a separate
checkpoint task, so the fixture sometimes read the old cursor and failed
although the input had been admitted correctly.

Wait for the existing checkpoint receipt before checking the admitted
input and cursor. The assertions still require the same exact message
and completed checkpoint.
The extracted intake mixin calls the gateway's cooperative message
handler and uses Matrix caches, parked voices and admission receipts.
Those dependencies were implicit in the adapter, so the standalone
mixin could not type-check its calls or its super dispatch.

Make the existing BasePlatformAdapter relationship explicit and declare
the Matrix-specific attributes and callback signatures. The adapter's
runtime method resolution order is unchanged, including the gateway
handler that accepts each durable intake.
@iainlane
iainlane force-pushed the fix/matrix-reaction-feedback-thread branch from 1e9af79 to 016267f Compare October 1, 2026 19:46
iainlane and others added 19 commits October 1, 2026 20:57
The native rich-content contract expected a redaction placeholder after
withdrawing one queued sticker. Pending input is removed before its turn
starts, so the shared withdrawal path rebuilds the merged input from the
remaining contribution. Placeholders apply to content revalidation after
a turn has already started.

Assert the complete retained caption, attachment echo and exact PNG for
the queued phase. The existing model-request count requires one remaining
turn, and the whole input excludes the withdrawn caption and image. Keep
the earlier active-turn placeholder contract unchanged.

A separate native admission observer confirmed that both stickers were
coalesced before the sender redacted the second contribution. The final
model input contained only the retained contribution; the obsolete
placeholder expectation failed in both plain and encrypted rooms.
The queue composition records the identity of each merged contribution
so a pending withdrawal can remove the correct input. The older busy
photo, startup and grace controls still expected the pre-withdrawal
MessageEvent shape, whose merged message identity list was empty.

Keep those whole-event contracts and include the incoming contribution
identity in the expected merged event. All sender, reply, permission,
attachment and FIFO boundaries remain asserted. The three compatible
controls fail on the published composition before this correction.
Input that arrives during startup restoration waits in a separate list.
The shutdown writer only captured adapter and overflow queues, so stopping
before the startup gate opened lost that deferred input.

Include the startup list in the existing profile-scoped snapshot writer.
Each session keeps its FIFO tail followed by its deferred inputs. Remove
only the inputs whose complete snapshot was written; a failed profile
write leaves its inputs available for retry.

The invariant uses two owning homes in A/B/A order through the real
startup deferral gate. Both homes get their complete ordered records, and
a controlled write failure preserves A's inputs while B succeeds.
Pending input can await the busy handler or recursive turn preparation
before the next model turn starts. Those paths are inside the oversized
adapter and turn execution modules, which makes their shared dispatch
ownership boundary difficult to change in a focused patch.

Move the unchanged methods into topical mixins before extending pending
withdrawal through those waits. Keep the existing facade bindings,
logger identities and lazy imports. The method ASTs are unchanged apart
from the defining import for the adapter's thread metadata helper.
Pending preparation can overlap cancellation, shutdown and a later input
while provider or handler work awaits. The processing and task ownership
methods remain inside the oversized base adapter, and the voice interrupt
helper remains inside the oversized turn module.

Move the unchanged adapter processing lifecycle into a topical mixin and
move the unchanged interrupt helper beside pending selection. Existing
callers still use the adapter and runner methods through their MRO. Keep
logger identities and defining helper imports, and declare the actual
host attributes and callback types before extending the ownership model.

The eleven method ASTs are unchanged except for the defining adapter
helper import. Existing ownership, shutdown, voice and follow-up contracts
remain green. The normalized type comparison adds no diagnostic.

The current prerequisite reconciles pending input after a cancellation
wait times out. Preserve that completion path when extracting processing
methods, including the original local imports for their facade helpers.

Resolve the captured upstream changes in the original defining scopes
when updating this contribution. Keep the extracted implementations and
authored behaviour; apply the same current annotation, timeout or UTC
spelling in their actual owners where those scopes moved.
The Matrix adapter cached every inbound audio, video and file
attachment under a name taken from the event's raw `body`. It never
read the `filename` field. Since Matrix 1.10, a client that sends a
caption puts the filename in `filename` and the caption in `body`, and
some clients send a caption in `body` without any `filename`. A reply
can also prefix the body with a quoted reply fallback. The cached
document was therefore named after the caption, for example
`doc_<id>_please review`, or after several lines of quoted text. Audio
took its extension from whatever followed the last dot in the body: a
reply whose fallback quoted `@bob:example.org` produced a file named
`audio_<id>.org> what did you think?` with the rest of the caption
after it. That name has no audio extension when the audio container
is not recognized.

Add `inbound_media_filename`, which returns the declared `filename`,
or the body with any reply fallback removed when the body is itself a
filename. A body counts as a filename when it is one line with no
directory part and ends in an extension, and either the extension
maps to a known MIME type or the body contains no spaces. A client
that follows the spec without captions, such as one that sends
`Q3 Report.pdf` as the body, keeps its name. A multi-line caption, or
one containing a URL, does not become a filename. The helper also
reduces a declared filename to its last component for both `/` and
`\`, so the name contains no path separator on any host.

When neither source gives a filename, the adapter caches the document
as `document` or `video` with the extension for the event's MIME type,
and audio takes its extension from the MIME type as well. The voice
flag from `_classify_inbound_media` was used only to choose between
two defaults that both ended in `.ogg`, so it is removed.

`cache_document_from_bytes` already strips directory components and
checks that the result stays inside the cache directory, so a sender
could not write outside it before this change either.

Integration: apply the cache-name change in the extracted media sibling.
Keep the canonical three-field media classification contract and remove
only the obsolete download filename default. Adapt the tests to streamed
SDK downloads and the current room identity model.

(cherry picked from commit fa87c83)
Root AGENTS.md asks for a facade's siblings to use the name
<stem>_<topic>.py. The filename helper belongs to adapter.py, so the
module becomes adapter_media.py, as in the Discord adapter.

Integration: the media sibling already contains the extracted handler.
Move the helper into that module with its function bodies unchanged.

(cherry picked from commit 2e0bbf2)
The first version of `inbound_media_filename` rejected any name that
`str.isprintable()` refused. That excludes every Unicode space other
than U+0020 and every format character. macOS names screen recordings
with U+202F before "AM" or "PM", and emoji sequences contain U+200D,
so such an attachment was cached as `video.mov` even when the client
declared the name in `filename`. A declared name that reduced to
nothing, such as `..`, also stopped the helper from using a clean
body, and a non-string `filename` was cached under its `str()` form.

A body without `filename` needed a MIME type known to `mimetypes`
whenever it contained a space. That table differs between Python
versions: `config file.yaml` was kept on 3.14 and dropped on 3.11 to
3.13, and `Contract Final.pages` was dropped on all of them. Python
3.14 also treats a trailing `.` as a suffix, so `Done.` became a
filename there only.

Remove only control characters (category Cc) and the bidirectional
embedding, override and isolate controls, which can make a name
display with a different extension. Use `filename` only when it is a
string and fall back to the body when it reduces to nothing. Accept a
body as a filename unless it has more than one line, contains a path
separator (which excludes a URL), or lacks an extension of one to ten
letters or digits. The rule uses no lookup table, so every supported
Python version gives the same answer.

Take the generic extension from `media_cache.ext_for_mime`, which
the other adapters share. It removes MIME parameters, so
`video/mp4; codecs=avc1` now gives `video.mp4` where
`mimetypes.guess_extension` returned nothing.

Integration: keep the corrected helper in the existing media sibling.
Use the shared MIME-extension resolver in the streamed cache path. The
original twenty-one filename cases pass against the real cache; the
four affected files pass 317 tests with zero retries.

(cherry picked from commit 69e68c3)
A queued message leaves its adapter slot before voice transcription and
recursive turn preparation finish. Sender deletion could therefore miss
that input and allow the deleted text or audio to start the next turn.
Overlapping busy handlers also replaced the lane's single reservation.

Keep each preparing input in the existing dispatch reservations until
steering, redirecting or turn dispatch claims it. Withdrawal rebuilds the
remaining contributions in place, including admission copies, so waiting
preparers cannot continue with a detached event. Revision checks repeat
transcription and inbound preparation after partial withdrawal and check
again after transcript echoes and processing hooks.

Cancellation restores unclaimed input in FIFO order and releases the
captured reservation. A previous task's completion cannot release a new
owner. Shutdown snapshots and capacity checks enumerate all accepted
reservations, while new intake preparations do not bypass queue limits.

The two lifecycle contracts reproduce deletion during STT, transcript
echoes, admission rewrites and recursive preparation. They also verify
partial voice retention, overlapping busy input and the irreversible
claim boundary under cancellation. Automatic durable restoration remains
a separate consumer of the pending snapshots.

The pending-input protocol resolves the session key before the pause
gate. The existing paused-turn test used a bare runner and a source
without thread identity, so it failed before checking the paused reply.
Construct a real GatewayRunner, MessageEvent and SessionSource while
preserving its three original paused-reply assertions.

This owner integrates only the pause-fixture hunk from original campaign
correction 61048ea. The native
locale-warming hunk has separate NousResearch#126281 and NousResearch#128952 union destinations.
This unit-fixture correction provides no new native validation.

Cancellation before transcript ownership keeps the provisional input for
shutdown recovery. Exact durable input ownership excludes active input
from pending capacity. Withdrawal closes at the original synchronous
dispatch boundary, separately from recovery ownership. Plugin consumption,
lease refusal and completed handlers still finish their dispatch claims.

Retain linked pending reservations until cancelled processing completes.
The completion callback and waiter share reconciliation for each original
input, so non-durable input returns to the queue once and durable input
is excluded. Preserve the existing synchronous withdrawal boundary and
current ownership fixtures when composing the updated prerequisite.

Close the withdrawal fixtures and sweep their isolated test-home registry after the original task cleanup. The lease fixture also replaces a constructor store, so closing its replacement alone leaves a registry reference. Preserve all original phase and partial-input assertions.
When the Matrix adapter could not download or cache an inbound
attachment for a reason other than its size, such as a network error,
an empty download or bytes that the image cache rejects, it gave the
agent the attachment's `/_matrix/client/v1/media/download/...` URL
instead of a cached file. That endpoint requires the bot's access
token, so the agent's attempts to fetch the URL failed, and the model
could repeat the URL in its reply. Encrypted media had no URL to fall
back to: the event kept its media message type with no attachment, and
nothing told the agent that the user had sent one.

When the download or cache step produces no file, the adapter now
sends a text event with the user's caption followed by a marker such
as `[matrix image attachment could not be downloaded: photo.png]`. The
marker has the same shape as the `[matrix <kind> attachment too large:
<filename>]` marker proposed in NousResearch#125637, and `_inbound_media_marker`
builds it so that the oversized-media path can use the same helper.
The filename is the declared `filename`, or a body that contains only
the transport filename once any reply fallback is removed. The event
has no media URL or media type, so downstream tools do not treat the
attachment as downloaded.

`_mxc_to_http` has no other caller and is removed.

Integration: use the shared filename rule and preserve the canonical
sticker kind, retained reply context, mention claim and admission result.
Oversize failures still take precedence. The current SDK never returns
None, so the file failure fixture uses the subsequent source timeout
correction at the streamed download boundary. The affected gate passes
319 tests; four original failure cases were reproduced before the fix.

(cherry picked from commit cdc5ce6)
The previous commit said that an empty download gets the new marker.
That is true only for images. mautrix's `download_media` returns the
response body or raises; it never returns `None`. An empty body is
`b""`, which the image cache rejects because it has no image signature,
so an empty `m.image` gets the marker. The audio and document caches
write an empty body as an empty file, so an empty `m.file` or `m.audio`
still reaches the agent as an empty cached file with no marker. The
failures that do produce the marker are a download that raises, bytes
that the image cache rejects, and missing decryption metadata for
encrypted media.

The file case of the test mocked `download_media` to return `None`,
which only a mock does. It now raises `TimeoutError`, as a stalled
download does.

The test also covers `m.file` and `m.audio`, so it moves out of
`TestMatrixImageOnlyMediaNormalization` into a class of its own.

Integration: the preceding adapted streamed fixture already uses the
real TimeoutError case. Preserve this contribution as the separate test
class move. All three original cases pass in the new class, and the
previous four-file gate has 319 passing tests.

(cherry picked from commit cb2c0db)
A media download failure must remain visible to the model without an
HTTP download URL which requires the bot token. The filename and failure
repairs also need to preserve the adapter's retained reply context,
mention claim and consumed-event result when the input becomes text.

Exercise a real homeserver 404, image-cache rejection and encrypted
attachment without its required hash through the independent Matrix
client and gateway. Compare the complete user-message record before the
separate first-message onboarding note, and confirm that no attachment
was cached. Keep the existing caption and both oversize cases unchanged.

Add one parameterised adapter contract for the retained parent, both
admission outcomes, sticker classification and size-limit precedence.
The same contract checks that empty audio and documents remain cached,
and that missing-URL inputs keep their existing behaviour.

The unfixed parent fails the five changed admission cases while six
controls pass. The fixed adapter passes all eleven cases. The live
parent reproduces the missing failure markers before fixed-image
validation.
Intake checks the adapter's pending dispatch reservations before using an
input. Unconstrained mock adapters fabricate another mock for that store,
so traversing its records follows an endless mock predecessor chain.
Initialise the same empty dictionary as a real adapter in those fixtures.

The voice fixtures also fabricate an asynchronous channel-prompt resolver,
although the adapter API is synchronous. Those three fixtures already
emit unawaited-coroutine warnings on the preserved baseline. Provide a
synchronous resolver that returns no prompt and keep the existing input
and routing assertions.
The canonical context test treated a failed encrypted download and an
input without a URL as the same caption-only event. Download failures
now include a marker, so the encrypted row rejected the intended new
behaviour even though its cache and native-image state were correct.

Specify each row's complete expected message. Keep the missing-URL row
unchanged because no download was attempted, and retain the assertions
that neither path creates an attachment or pending native image.

The encrypted row fails on the composed failure repair before this
expectation change while the missing-URL control passes. Both rows pass
with their distinct expected messages.
The Matrix callback converts the server timestamp to seconds for the
startup grace check, but text and media discarded that value when they
created a MessageEvent. Downstream consumers therefore saw the adapter's
processing time instead of the message's server time.

Pass the event time through the shared inbound event builder and convert
it to an aware UTC datetime. Events without a server time use the current
UTC time. A voice message can also wait for a separate bare mention:
keep the voice's server time while it is gated and parked, then use that
time when the mention claims it. Otherwise the claimed voice receives
the later mention's timestamp.

Callback tests exercise text and media with and without server times.
The parked-voice test covers both a later claim and concurrent voices;
its three claim cases failed before the parked time was preserved.

Adapted from a202104 for the
shared inbound event builder.

Source-commit: a5c20f1

Integration-note: The canonical builder is topical; timestamp is
keyword-only after the existing context arguments. Normal text, emote
and both successful and marker media paths propagate it. Parked voices
use their original server time.


Upstream compatibility: keep the current builtin tuple annotation and
include the server timestamp in the parked voice record and gate slots.
Adapter tests can confirm timestamp conversion but cannot prove the
server time reaches persisted turns through the running gateway.

Send text and image events through a separate Matrix client, fetch
their origin_server_ts values from Synapse, and compare them with user
rows indexed by the corresponding Matrix event IDs. This exercises the
live transport, media handling, turn, and transcript paths.

Source-commit: edabc0a
A missing gateway write made the live timestamp test poll the
database for a full minute before reporting a failure. Persisted
rows normally appear as soon as the gateway handles each event.

Stop polling after 15 seconds while keeping the 250 ms checks for
real text and media events. The separate-client Synapse case passed
with a 2.766-second test body.

Source-commit: b96eacb
The adapter converted every non-zero origin_server_ts with
datetime.fromtimestamp. The sender's homeserver sets that value, and
canonical JSON allows integers far beyond year 9999, so a value such as
10**15 ms raised ValueError while the event was being built. By then
the event was marked as seen, the read receipt was sent and any media
was downloaded, so the message was lost. On the voice-mention path the
parked voice had already been claimed, so both the voice and the
mention were lost.

A representable time far in the future caused a quieter problem. A
remote server with a clock a year ahead produced a user turn dated a
year ahead. The session's latest message then made it look active until
that date, so stale-session archiving skipped it and session lists
sorted it first.

Convert the server time in one helper. It returns the current UTC time
when the value is missing, when datetime cannot convert it, or when it
is more than 60 seconds ahead of the current time. The adapter reads
the current time through an instance clock so the tests can fix it.

The four timestamp cases are now one parameterised callback test in
their own class, with rows for a skew within the tolerance, a time a
year ahead and an unrepresentable time. The last two rows failed
before this change, one with the future date and one with ValueError.

Source-commit: 4c262d5

Integration-note: The unchanged source helper is defined in the
extracted inbound-events sibling. The UTC clock is an overridable static
method there, preserving the injected clock without growing the facade
constructor.
The adapter kept a server time up to 60 seconds ahead of its own clock.
The sender's homeserver sets origin_server_ts from its own clock, while
session persistence stamps the assistant reply with the gateway host's
time. When a remote server's clock ran 30 seconds ahead and the agent
replied within 10 seconds, the user row was stored about 20 seconds
after the reply. Views that order messages by timestamp, such as search
results and the neighbouring rows around a search hit, then showed the
reply before the question, and with message timestamps enabled the
model saw the question stamped after its answer. Any skew inside the
tolerance that exceeded the reply latency had this effect.

Return the earlier of the server time and the current time instead. A
correctly synchronised server stamps an event before the adapter
receives it, so the clamp changes that time by at most clock jitter and
every past time is kept exactly. A server that is ahead gets the
receipt time, which sorts before the reply. Missing and unconvertible
values still fall back to the current time, and the tolerance constant
is gone.

The 30-seconds-ahead test row now expects the current time. It failed
before this change with the server time 15:00:30 against a clock of
15:00:00.

Source-commit: 1e4c467

Integration-note: The original complete clamp helper is retained in the
defining inbound-events sibling.
A notification retry test must include the subscription cursor and the
turn admission result. Once collection advances the cursor, checking
only a submitted string can miss a batch that was discarded before a
turn started.

Use real subscriptions and admission for lease refusal, startup failure
and Stop. Verify that the buffered batch remains available and starts
once on a later poll, without replaying the database event. Also verify
that accepted diagnostic entries are removed without deleting an
appended message or a regular message with equal text.

These invariants fail against the prior integration base: all three
refusals discard the batch, and diagnostic startup prematurely removes
the selected entries.

Canonical-source: 971bc03
The Matrix invite lifecycle and Slack direct-message resolver remain in
large adapter facades. Move these topics into typed platform siblings
before adapting their pending fixes. Runtime SDK bindings remain deferred
through the Matrix facade.

Move the configured inbound-media limit reader into its defining sibling
and update its consumers and test binding. The existing fallback reader,
positive limits and negative-limit behaviour are unchanged by this move.


Upstream compatibility: extract the complete current invite and Slack DM
target methods with their builtin mapping annotations. Keep the current
platform test import while patching the extracted media-limit supplier.

Current-layout integration:

Keep the extracted Matrix invite implementation and use the current
built-in TimeoutError spelling at its defining owner.
Shutdown records preserved public message fields but omitted the original
contributions and merge operations. Reconstructing only the final body
would therefore prevent a deleted constituent from being removed before
replay. Native Matrix snapshots also need fresh dependencies on the
current transport cache, rather than objects from the previous process.

Record contributions with a closed set of the four existing merge
operations. Decode public fields with their actual dataclass types and
rebuild Matrix reply, logical excerpt and rich-content snapshots against
the current adapter. The consumer must still validate routing, access,
remote availability and local files before admission.

Keep the aggregate pending UID when withdrawal rebuilds its remaining
parts. This identifies the same stored input after re-preparation. Cache
voice text only when every original attachment still has the same bytes;
a successful clip alone cannot validate a changed untranscribed clip.
Already echoed clips retain their own matching attachment receipts.

The four merger contracts reproduce UID loss on withdrawal. Changed and
missing untranscribed attachments reproduce stale cached voice text.
The repaired reconstruction, native snapshot and transit contracts pass
39 tests through the project runner. This does not enable live replay or
consume a shutdown record.

Repair note:

Pending voice can cache formatted text without clip receipts or typed
parts. Capture that text when it is not None, including an empty string,
so reconstruction reuses the cache when every attachment still matches.
Changed and missing attachment controls still discard cached text. The
new opaque and empty cache invariant fails on the published contribution
for unchanged attachments and passes with this capture case.
Turn preparation resolves the session, lease, transcript and inbound
content before the agent executes. Extract that existing phase and its
prepared value into a topical sibling so pending input ownership can be
updated without adding more logic to the large turn execution module.

The two extracted definitions retain their ASTs apart from the actual
GatewayRunner receiver annotation. Current gateway failure-owner and
inbound preparation contracts pass before the ownership change.

Preserve the current input-owner binding when moving turn preparation
into its defining mixin. Pending recovery remains provisional until the
session store records that owner, while the existing active-turn marker
continues to describe the turn lifecycle.

Extract the complete current preparation record and method, including
the defining YOLO restoration call, prompt pins, input ownership, title
and persistence fields. Keep the original receiver annotation and the
original recursive input-owner helper.
The initial gateway turn writes its input owner beside the actual user
row. Recursive queued turns omitted that metadata, so a durable pending
record could not distinguish an executed follow-up from RAM admission.
An input without a delivery ID also received a different owner on every
attempt, even when its shutdown record already had a stable identity.

Resolve input ownership through one helper for both execution paths.
Use the stored pending UID when present; ordinary platform inputs keep
their existing delivery namespace and keyless live turns retain distinct
owners. Partial withdrawal and re-preparation therefore query the same
stored input on the canonical transcript continuation.

Extend the existing recursive persistence contract to require that owner
at the actual agent call. The contract fails without metadata on the
base and passes with the repair, alongside the failure-writer and transit
controls. This marker proves persistent user input, not RAM admission or
exactly-once external effects before the transcript flush succeeds.

Bind the pending dispatch to the owner computed by the recursive input
helper. The durable-owner query must use that same identity after the
helper replaces the inline owner calculation.
Three adapter fixtures left conversations_replies unconstrained. Awaiting
that AsyncMock returned another AsyncMock, so the synchronous response
.get call created a coroutine that was never awaited. Collection of that
coroutine reported warnings in later tests, obscuring the original calls.

Return the actual empty Slack response shape in the mentioned command,
existing thread and assistant DM fixtures. Their assertions and all other
fixture behaviour are unchanged. The full file also reproduces the fault
on the published transit baseline.
The text batching extraction moved annotations into a module without the
MessageEvent import. Its dynamic chunk-length assignment also retained a
type suppression even though the adapter already reads that metadata with
getattr.

Import the defining event type and write the private chunk metadata with
setattr. The module now passes type checking without a suppression; the
text batching contracts retain their original behaviour.
Combining forwarded contexts preserves each contribution, but a thread
batch also fetched the same shared history for every chunk. A later fetch
included the first chunk as history, so the merged model input repeated
both the background and the sender's earlier words.

Fetch shared history for the first compatible text contribution only. A
successful fetch is recorded on that concrete pending event, and the
producer checks the pending batch again after the network await. Forwarded
content still belongs to its original contribution and is merged normally.
An empty first fetch does not prevent a later chunk from fetching history.

The existing batch invariant now covers plain and forwarded input. Both
cases fail with duplicated history on the composition base and pass with
one backfill. Withdrawing the first contribution leaves only the second
sender's text and its own forwarded context.
Authored reference expansion now precedes sender and quote formatting.
The composed Matrix path also moved snapshot capture after that awaited
expansion. An edit during expansion could then be overwritten by the
initial remote context fetch, and failed edit recovery could reuse stale
quoted text.

Capture the typed current context before reference expansion, then expand
only authored text before enrichment and rendering. The existing final
snapshot refresh still applies edits, redactions and unavailable states.
The sixteen reference-boundary controls pass without changing their
expected effective-state protection or the cached conversation prefix.
Queued user rows now persist their execution owner beside room state. Add
that owner to the existing whole-object room-state assertion so the
contract checks both metadata fields.

The WeCom eager-reseed invariant counted frames after a 50ms sleep before
its existing state wait. A slow transport could therefore fail frame
counting before opening the requested stream. Move that existing wait
before counting, preserving every assertion and the missing-seed failure.
A 200ms transport delay reproduces the original failure and succeeds with
this synchronisation; disabling eager seeding still fails the wait.
Two concurrent thread inputs can both finish history fetches before either
publishes its text batch. Thread participation awaits after the first
pending-history check, so both contributions could still insert growing
shared history despite the earlier producer guard.

Recheck the compatible batch immediately before the synchronous enqueue.
Remove only the redundant shared backfill; the contribution's forwarded
context remains. When the first fetch is empty, a later fetch stops before
the earliest batched message and records its successful history insertion
on the aggregate after merging. Withdrawal therefore preserves the marker
only when the contribution with that history remains.

A deterministic two-fetch/two-participation barrier reproduces duplicate
history on the prior head. The existing invariant also covers an empty
first fetch and withdrawal of either contribution. Three cases fail on the
prior source; all five focused cases pass with the repair.
Voice references depended on when transcription ran. Fresh preparation
expanded the caption before speech existed, while pending preparation
expanded an aggregate that also contained generated failure and path
notes. The latter could therefore interpret a generated filename as an
authored reference.

Keep successful clip text separate from generated notes. Both preparation
paths expand the caption and raw speech together under the routed profile
before sender attribution, backfill, image analysis and reply context.
One preprocessing pass preserves the aggregate reference and token limits.
Format transcript quotes and failure notes after expansion, and echo the
original speech without any attached file content.

Invalidate the typed transcript cache when pending media changes. Preserve
the existing tuple API and update its concrete event-aware callers so the
pending path caches the same typed input as fresh preparation.

Two parameterised behaviour contracts reproduce six failures and two
controls on the published parent. The repaired contracts cover both paths,
profile A/B/A routing, generated and quoted references, root restrictions
and the combined caption/speech budget. Adjacent voice and attachment
contracts also pass with strict warnings and no retries.

Review-follow-up: NousResearch#128679 (comment)

Source-commit: 72caa3d

Integration-note: Use the existing canonical voice sibling and typed clip API. Preserve Matrix context capture before reference awaits, current-context refresh, pending revision/withdrawal checks and clip-path echo receipts. Persist typed generated/speech parts alongside existing fingerprinted voice receipts, and invalidate them at the existing bind/merge/withdrawal seams.

Preserve the current builtin list annotations when adding transcript
parts. The complete speech binding, authored text and rendering scopes
remain unchanged.
An older pending voice cache may contain only a formatted aggregate. The
speech preparation fallback kept that aggregate literal but discarded the
reference expansion of the actual event caption.

Append the authored caption's context suffix to the opaque aggregate.
The cached aggregate and legacy transcript list remain unscanned because
they do not distinguish sender speech from generated notes.

An opaque-cache row in the existing profile contract fails on the parent
and passes after the fix. The full Weixin file and adjacent gateway gate
pass 92 tests across 12 files with strict warnings and no retries.

Source-commit: 214ba38

Integration-note: Retain actual-caption expansion beside a literal opaque cache. Canonical successful clip records have separate typed provenance; legacy transcript strings remain unscanned.
Reference preprocessing preserves the authored input and appends context,
then strips outer whitespace from the final message. The opaque voice
fallback sliced that result using the untrimmed caption length, which
could truncate an attached-context header for a padded caption.

Strip only the opaque fallback's authored preprocessing input before
expansion. Keep the cached aggregate literal and leave ordinary non-voice
text unchanged when it has no references. Typed speech already uses a
normalised joined authored input.

A padded-caption variant in the existing opaque-cache contract fails on
the parent and passes after the correction. The strict 12-file gate,
including the full Weixin file, passes 93 tests without retries.

Source-commit: d6806e7

Integration-note: The canonical authored-text combiner already strips its preprocessing input, so the additional opaque-only production guard is redundant here. Preserve the complete padded-caption regression instead.
…very

Pending voice recovery reconstructs fingerprinted successful clips and
literal generated notes. The reference pipeline must use that provenance
after reconstruction instead of rescanning a formatted aggregate.

Add a durable-pending variant to the existing profile contract. It writes
the actual voice provenance as JSON, restores a fresh event through the
canonical codec and prepares it under profiles A, B and A. Successful
speech expands, generated notes remain literal, and transcription runs
once per clip without a repeated provider call during preparation.
Compaction preserves channel state on the summary or merge row that
replaces removed transcript rows. The preparation hook reads the newest
row with channel state without filtering by role, so its baseline can
come from a summary rather than a user row.

Remove the user-only qualification from the adapter hook documentation.
Speech reference tests used empty history, so they did not exercise
channel-state acknowledgement while references appeared in stored
transcript rows and adapter-generated context.

Extend the existing A/B/A contract with empty and stored history for all
five speech variants. Check the complete prepared message, unchanged
transcript, lookup and adapter calls, returned channel state and single
raw authored preprocessing input. Stored references and generated channel
notes remain literal while caption and successful speech references
expand under the owning profile.

The complete file passes 21 cases with strict asynchronous warnings and
zero retries. This adds coverage to the existing preparation behaviour;
it does not repair a reproduced production defect.
The startup coalescing fixtures compared complete events but omitted the
reaction parent's merged-message identities. The direct processor test
also cancelled a same-chat waiter before its callback coroutine began,
then discarded that caller-owned coroutine without closing it. Strict
coroutine warnings exposed the incomplete fixture lifetime.

Include the incoming identity in the complete merged-event expectation.
Keep references to the direct test callbacks and close them after all
processor tasks finish. The predecessor, cancellation and FIFO ordering
assertions remain unchanged. Production coroutine ownership is unchanged.

The original combined source run reproduced three complete-event
mismatches and one unawaited-coroutine failure. All 455 cases in the 22
adjacent source files pass with strict warnings and no retries.
A queued Matrix reaction includes a refreshable context object with its
live adapter and retained event cache. Shutdown snapshots copied every
constructor field, so serialising this object raised TypeError and left
the complete pending input unwritten.

Capture public normalised event fields separately from private context
objects. Quoted-media dependencies become plain identity records, and
the existing inbound snapshot interface supplies its pending state.
Matrix records the bounded logical excerpt dependencies and original
excerpt without serialising its adapter or cached authority.

This data is inspection evidence, not authorisation to replay a turn.
Restoration must reconstruct context through the current owning adapter
and revalidate effective state before model use. Capturing a pending
record leaves the original live snapshot unchanged, so later redactions
continue to replace stale quotations.

The regression writes a real split-reply reaction through the production
shutdown writer. It reproduced the TypeError before the repair, then
checks the complete data record and a later redaction on the live input.

Close the logical snapshot fixture runner after its original restore and revalidation assertions. Apply the published public fixture correction without replacing the later snapshot contract.
An accepted leftover /steer can arrive while provisional pending input
has already filled the queue. The drain ignored FIFO admission failure,
so it dropped the correction while keeping the provisional input. The
branch that prioritised a steer over a queued event also restored that
event directly without releasing its provisional dispatch reservation.

Retain an unadmitted steer for the next turn. Check each pending input
through the recognised-command guard before combining the remaining
text, so a residual command is discarded without losing the correction.
Restore deferred FIFO events through the existing dispatch helper, which
releases reservations while preserving order.

Three assertion failures reproduce the lost correction with ordinary
provisional text, the residual /help safety case, and the restored FIFO
state on the parent. The FIFO contract releases each returned dispatch
before measuring queue depth, as the caller does in normal operation.
All eight contracts in the existing file pass after the fix.
The concurrent-update fixture creates three handler coroutines before
passing each to the processor. Cancelling the middle update while it
waits for its predecessor leaves that handler coroutine unawaited, so
the fixture fails under strict RuntimeWarning checks.

Keep references to the fixture-created coroutines and close the
cancelled handler after every processor task has finished. The existing
assertions still require the first and third handlers to complete in
order without executing the cancelled handler.
Direct FIFO callers such as /queue, /steer fallback and Telegram grace
could bypass the 32-entry bound. Normal busy admission only logged a
refusal, while /queue acknowledged input even when no event was queued.
Debounced text was also outside the pending count, so a later photo or
command could overtake an older accepted burst.

Check admission at the shared FIFO boundary and return its result to
command and busy handlers. Count buffered bursts as pending input and
submit older bursts before accepting a later FIFO event. Refused
external input receives a thread-aware queue-full reply; internal wakes
remain unaccepted so their existing producers can retry. Earlier
accepted events are never replaced to admit the new input.

Copy command events without reconstructing their fields. This preserves
reply, media and control context together with the existing bot-loop
admission flag, so replay cannot acquire permissions or spend admission
twice after stripping the command prefix.

Parameterised contracts reproduce bypassed capacity, false
acknowledgements, missing refusal notices, context loss and overtaking
of buffered input.

fix(i18n): translate the pending queue refusal

The bounded pending queue reports an English-only warning when admission
is refused. The other sixteen bundled language catalogues omit that key,
so users receive the English fallback even after selecting their language.

Add the refusal message to every bundled catalogue. Verify that runtime
lookup selects the requested catalogue, that non-English results differ
from the English fallback, and that all catalogue keys and placeholders
remain in parity.

The runtime contract failed for all sixteen translated languages before
the entries were added. The complete four-file locale gate now passes
all 92 cases.

Source-commit: cd37cd3a25ab9c157495df61ed3a8049765e90ab
Protected-source-alias: b01a5942e1b077ee7dcd7ad73f3cc0002f926ae1
Maintenance-adaptation: Apply the debounce admission method change in
 base_text_debounce.py, preserving its typed receiver. Add the original
 refusal-control branch to the current platform handler without changing
 the upstream selected-owner loop. Keep the predicate name and defining
 merge import from the earlier sources in the busy-session module; its
 other incoming method and declaration bytes remain unchanged.
Maintenance-adaptation: Declare the existing pending-refusal
 collaborator interface and use pytest monkeypatch for the admission
 fixture overrides and dynamic marker; keep the original scenario values
 and assertions.

Resolve the captured upstream changes in the original defining scopes
when updating this contribution. Keep the extracted implementations and
authored behaviour; apply the same current annotation, timeout or UTC
spelling in their actual owners where those scopes moved.
The notification contracts can use the installed discord.py module when
it is already loaded. Their thread helpers still called Thread without
arguments, which only worked with the fallback shim. Five existing
thread notification cases failed before reaching the normaliser.

Allocate the fixture instance without calling its SDK constructor, then
populate the fields which the notification normaliser reads. The real
Thread class still supplies the adapter's isinstance contract. Keep the
existing test bodies and assertions unchanged.

All 17 notification cases pass with the installed SDK. The separate raw
deletion contracts construct real SDK guild and thread payloads.
Busy interrupts and post-turn drains share the media placeholder builder.
The builder is in the oversized runner facade, while attachment transfer
already belongs to the inbound media sibling.

Move the builder without changing its behaviour and update internal
consumers to import its defining module. Classification still uses the
existing facade definitions. This isolates the renderer before changing
its profile handling.
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.

Apply these complete published fake-API and reconnect fixture scopes to the preserved supplier source. The original fixture definitions predate the campaign, and all original assertions remain unchanged.
Discord event normalization and Slack's inbound guard still occupy their
large adapter facades. Move those methods into their platform siblings,
with deferred facade bindings for runtime SDK and helper resolution.

Extract the pending withdrawal traversal behind its existing sender
predicate so native deletion notifications can reuse every pending store.
The traversal and existing deletion policy remain unchanged.

Compose the platform-event and inbound-context extraction with the current prerequisite. Remove the extracted facade implementations and retain their complete runtime contracts in the defining mixins, including current builtin container annotations, explicit receiver types and the Slack facade helper binding.
@iainlane
iainlane force-pushed the fix/matrix-reaction-feedback-thread branch 2 times, most recently from cf58b30 to 0d7667d Compare October 10, 2026 00:37
iainlane added a commit to iainlane/hermes-agent that referenced this pull request Oct 11, 2026
An expiry notice for a reaction menu appeared in the room timeline even
when the menu belonged to a thread. The pending picker kept the request
and expiry time but discarded its send metadata, so the feedback sender
received no thread root or card fallback target.

Keep a copy of the picker metadata and pass the card's notice metadata
through the expiry path. The metadata projection uses the existing
threaded-control schema from NousResearch#128710. The sender already distinguishes
notices from continuation output, so the notice replies to the expired
card even after another message appears in the thread. With rich replies
disabled, the thread fallback still targets that card.

The real adapter regression covers threaded and timeline menus with
rich replies enabled and disabled. Both threaded cases fail before the
fix; the complete menu test file passes after it. Expiry still removes
the control and never starts a selected turn.

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/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/matrix Matrix adapter (E2EE) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.