Skip to content

fix(gateway): preserve quoted and speech context across pending merges - #131009

Draft
iainlane wants to merge 386 commits into
NousResearch:mainfrom
iainlane:codex/pending-quote-speech-prerequisite
Draft

iainlane wants to merge 386 commits into
NousResearch:mainfrom
iainlane:codex/pending-quote-speech-prerequisite

Conversation

@iainlane

@iainlane iainlane commented Oct 1, 2026 •

Copy link
Copy Markdown

Stack

All PRs below target main. Nesting records merge order. Each dependent follows every prerequisite shown. Each linked description lists its other dependencies.

Change

Expand references in the sender's original caption and successful speech before adding generated notes, reply text or channel history. Transcript echoes retain raw speech. Merged pending input preserves each contribution's quoted and forwarded context, withdrawal provenance and data-only shutdown capture.

Pending recovery remains provisional until the session store records the input owner. Session preparation binds the same owner after the recursive input helper computes the identity. The active-turn marker, withdrawal boundary, caption and speech expansion, profile routing, stored context and leftover steer admission remain intact.

Current source and history

I resolved the concrete current-layout conflicts against the captured upstream revision 3637c512fc3211bdccb61b7140d09ac1018508ba. The branch is rebased onto that revision. Every commit has one parent. All 385 original contributions remain in order, including this PR's 37 own contributions. The existing credited Matrix SDK fixture maintenance contribution remains record 386. The 44 meaningful empty contributions remain separate. All original full message prefixes, author and committer headers, dates and timezones are preserved.

The resolutions retain the authored implementations in their extracted owners and apply the current annotation, UTC and timeout spellings there. The source-note helper uses the published transport-preserving removeprefix change from #125738. The checkpoint fixture uses the ordering already published in #126281: finish its background tasks before awaiting the checkpoint completion. Its complete assertions remain unchanged. The existing fixture lifetime corrections and all later restoration, decoding and live-revalidation assertions remain intact.

Review this layer's 37 original contributions and the credited fixture maintenance. Compare the preceding published head with this update. The comparison against main includes the prerequisites.

Native fixture correction and validation

The native rich-content fixture now uses queue mode, atomically clears its expected-event marker, separates active and queued pause paths, and records actual redacted cache entries by event ID. These bounded fixture mechanisms are already published in #126375. The retained queued sticker expectation includes its exact Matrix source permalink. The complete retained caption, cached image path, exact PNG, five model requests and prior-history assertions remain unchanged.

The fixture support begins at record 163. Queued-only changes apply after that phase appears at record 164; the atomic reset applies after the existing hand-off helper appears at record 172. The strict retained-input expectation is corrected at its original record 341. The first 162 signed objects remain exact, while the necessary 224-record suffix is renewed. All 386 full message prefixes, author and committer headers, dates, timezones, order and 44 empty credits remain intact. Only three native-test files change.

I ran the complete plain and encrypted native rich-content module on the exact corrected source and its current PM-built image. Both cases pass, including model input, PNG pixels, active and pending withdrawal, explicit Matrix reads, and stable prior history. The complete child exits with zero strict resource warnings and no retries. Every captured tracked Python and canonical-runner input is authenticated against Git before and after execution.

The preceding accepted supplier validation remains qualified evidence: 1,267 cases across 37 complete SDK files, plus seven optional lark-oapi dependency skips, with zero strict resource warnings. Its execution composes the three actual published #135482 commits; those records remain outside this branch's pure history. Every selected complete SDK file, ancestor fixture, production input and runner remains byte-exact. The three changed native-test files are outside that SDK scope. No fresh 37-file execution is claimed.

The preceding complete 254-path static result is reused through exact whole-source bindings to the preceding published source for the 251 unchanged paths, including the two previously accepted formatter and EOF inverses, and a fresh check of the three native-test paths. The fresh scope has one existing FakeLLMServer argument finding. Its lexical span and relative occurrence remain exact; a complete fixture inverse restores its original enclosing statement, which contains the generated provider literals. There are zero introduced type findings. Ruff passes. The same three native-test formatter paths remain inherited. The prior 3,425 total diagnostic occurrences, nine method/base qualifications and 213 inherited formatter paths remain explicitly source-qualified; this is not a fresh global static or formatter pass.

The branch joins both fixed upstream snapshots cleanly. Earlier verification against 1744a19e0df568c647e4f3ff9c37f2a284a282fb remains historical. Hosted CI and maintainer acceptance remain separate.

iainlane and others added 30 commits October 1, 2026 13:51
The adapter could post its own processing reactions, but a standard
Matrix agent could not request one. The existing send_message reaction
path was outside the Matrix session's model toolset.

Register matrix_reaction in the Matrix bundle and route react and
unreact through the session's receiving adapter on its gateway loop.
Use the inbound event ID from the turn as the default target so another
turn in the same room cannot change which message receives a reaction.

Track every annotation that the agent adds to a target. Unreact removes
those annotations without touching processing tapbacks, and a failed
redaction keeps its event ID available for a later retry.

A separate Synapse client verifies that the model discovers the tool
and that react and unreact create native annotation and redaction
events for the intended message.
Two unreact calls could read the same annotation list before either
redaction completed. Both sent a redaction, then the second call raised
ValueError when the first call removed the shared event ID.

Claim the tracked IDs before awaiting the server. Restore IDs that
fail to redact, including IDs still pending when the call is
cancelled. A concurrent call no longer redacts the same annotation.

The generic send_message reaction action also ran adapter coroutines
on a worker event loop. Matrix's live client belongs to the gateway
loop, so dispatch that action through the existing gateway-loop path
used for adapter sends.
`add_reaction` and `remove_reaction` fell back to the most recent event
that had started processing anywhere in the room when the caller gave no
`message_id`. Matrix threads run as separate concurrent sessions, so
that event could belong to another thread or another user, and the
agent would react to or unreact from the wrong message. The
`matrix_reaction` tool always supplies an event ID, and no in-tree
caller of `send_message(action="react")` relies on the fallback.

Remove the per-room record of the latest inbound event and return an
error when `message_id` is missing. `on_processing_start` goes back to
its original shape. The error strings with em dashes from the salvaged
commit go with the fallback.
`remove_reaction` decided success by reading `_agent_reactions` after
its redactions had finished. That map is shared, so when an
`add_reaction` on the same event recorded a new annotation while the
redaction was in flight, the unreact returned `success: False` even
though every annotation that it had claimed was redacted. When a
redaction did fail, the result had no `error`, so the model received no
reason.

Decide the result from the list of annotations that this call failed to
redact, and return an error message when that list is not empty.
`_agent_reactions` gained an entry for every event that the agent
reacted to and lost it only when the agent removed the reaction. A
long-running gateway therefore kept the ID of every reaction that it
had ever sent.

Store the records with `bounded_put`, as the adapters' other per-chat
maps do, capped at 1000 events. A new reaction or a failed redaction
moves the event to the newest position, so the eviction drops the
events that the agent has not touched for longest. Unreact already
works only for reactions recorded in this process, and an evicted event
falls under the same limit.

`bounded_put` was annotated for string keys only. Its implementation
accepts any hashable key, so type it generically for the tuple keys
used here.
`matrix_read` passes the session requester to the adapter, which checks
that the room is joined and admitted by `MATRIX_ALLOWED_ROOMS` and that
the requester is still authorised before it touches the client.
`matrix_reaction` did neither. Its only policy check was the admission
check that ran when the inbound message started the turn. If an
operator revoked a user's pairing during that turn, `matrix_read`
refused the user's later calls, but `matrix_reaction` still sent and
redacted annotations as the bot. A session without a user ID was also
refused by one tool and accepted by the other.

Move the room and requester checks from `read_matrix_context` into
`check_session_access`, and expose it on the adapter. The reaction tool
now requires `HERMES_SESSION_USER_ID` and runs that check on the
gateway loop immediately before it sends or redacts. Both Matrix
session tools therefore apply the same policy when they act.
After 30 seconds `matrix_reaction` cancelled the future for the
reaction, which cancelled `add_reaction` on the gateway loop. When the
homeserver had already accepted the `m.reaction` event, the
cancellation did not withdraw it, but `add_reaction` never reached the
line that records the reaction's event ID. The reaction stayed visible,
and a later unreact reported that no reaction was recorded.

Wait on the dispatched future through `asyncio.shield`, as the generic
send path in `send_message_tool.py` does, and stop cancelling it. The
timeout now ends only the tool's wait. The reaction keeps running on
the gateway loop and records its event when the send completes. The
timeout message tells the model that the reaction may still complete.

The test sets the timeout to zero and blocks the send on an event, so it
exercises the timeout without waiting on a clock.
The reaction tool test used `AsyncMock` adapter methods, which complete
on whichever event loop awaits them. If the tool awaited the adapter on
the worker thread's loop instead of dispatching to the gateway loop,
the test still passed, although the real Matrix client is bound to the
gateway loop.

Replace the mocks with a fake adapter that records, for each call,
whether it ran on the loop bound to the session. The test compares the
whole call list, so a call on the wrong loop fails it. With the
dispatch removed from the tool, all four calls record `False`.
The Matrix guide said that the agent can remove its own reactions from
a message. The adapter records agent reactions only in memory and keeps
records for the 1,000 most recently used messages, so after a gateway
restart, or for an older message, unreact finds no record and reports
an error.

State that limit, and state that each reaction or read call checks the
room allowlist and the requester's authorisation again.
The read-tools parent changed set_session_vars so that it no longer
records the running event loop as the session's transport loop. The
gateway passes its own loop as transport_loop, and other callers bind
no loop.

The reaction tool tests bound only the adapter, so the tool found no
loop and returned "Matrix gateway loop is unavailable" before reaching
the adapter. Bind the test's running loop as transport_loop, as the
gateway does for a live turn.
When the matrix_reaction handler ran on the session's gateway loop
itself, it awaited the adapter call directly and never applied
_REACTION_TIMEOUT_SECONDS, so a stalled send blocked the tool call
indefinitely. The tool also scheduled its coroutine onto the gateway
loop with its own code for closing the coroutine when scheduling
failed.

The read-tools parent fixed the same two problems in matrix_read.
Schedule the reaction with safe_schedule_threadsafe, which closes the
coroutine on every failure path, and await every call through the
same shielded deadline. A reaction that outlives the deadline still
completes on the gateway loop and is recorded for a later unreact.

The timeout test now also runs the handler on the gateway loop. Before
this change that case waited for the stalled send until the test's own
2-second guard failed it.
The `matrix_reaction` toolset is in the `hermes-matrix` composite but
not in `CONFIGURABLE_TOOLSETS`. The Matrix checklist of `hermes tools`
did not list it, and `hermes tools disable matrix_reaction --platform
matrix` printed "Unknown toolset" and saved nothing. Tool resolution
treated the non-configurable toolset as native to Matrix and added it
to every Matrix toolset list, including a saved list of individual
toolsets.

Add `matrix_reaction` to `CONFIGURABLE_TOOLSETS` and restrict it to
Matrix in `_TOOLSET_PLATFORM_RESTRICTIONS`, as the read-tools parent
did for `matrix_read`. The checklist and the enable and disable
commands now offer it on Matrix, and other platforms reject it. An
unsaved Matrix configuration and a saved `[hermes-matrix]` list enable
it through the composite. A saved list of individual toolsets enables
it only when the list includes `matrix_reaction`.

The parent's two toolset tests now run for both Matrix toolsets. The
toolsets reference lists `matrix_reaction` and the `hermes-matrix`
composite, and the Matrix guide describes turning either toolset off.
An earlier commit on this branch changed send_message's react and
unreact actions to run the adapter call on the gateway loop, so that
the Matrix adapter's add_reaction and remove_reaction would use the
live client from its own loop. That path is not reachable by the
model. send_message has not been a registered model tool since
c6c8abb (NousResearch#47856), and the only in-tree callers of send_message_tool,
`hermes send` and the MCP server's messages_send, always pass
action="send". A process outside the gateway also has no live adapter,
so it gets the "Reactions require a live adapter" error.

The matrix_reaction tool is the model's route to Matrix reactions, and
it applies the room and requester checks on the gateway loop. Restore
send_message_tool.py and its react test to the versions on main
instead of maintaining a second Matrix dispatch without those checks.
A relay-fronted conversation can retain Matrix as its session platform,
but its delivery adapter has no native Matrix session-access method.
The reaction tool accepted that adapter and failed with AttributeError
when it tried to check room policy. The read tool already checks its
native adapter capability before scheduling work.

Require the reaction access-check capability at the same boundary.
Relay-fronted input now receives the live-Matrix-session error without
scheduling an unsupported operation. Native Matrix actions keep their
room and requester policy checks on the owning gateway loop.

The relay contract fails on the preceding head. The missing-adapter
control and repaired relay case pass with the affected six-file gate:
299 tests passed, six skipped and no retries.
The declared filename tests do not show whether media from a separate
Matrix client reaches the model after the gateway downloads and caches
it. A filename-like caption also needs to remain distinct from the
actual document filename across that path.

Upload a document to disposable Synapse with different caption and
filename values. Check the model-visible context, the gateway reply,
and the exact bytes cached under the declared filename.
The gateway removes an addressed bot mention before it builds the
inbound message. If a caption then matches the declared filename, the
media normaliser mistakes it for a client-generated filename and drops
the user's text.

Compare the original Matrix body with the filename to decide whether
the body is a transport fallback. Return the cleaned body for model
context when the original body contains a caption. A parameterised
regression covers a mentioned caption that matches the filename after
mention removal.
The live caption test requested a one-second Synapse poll and used
an outer loop deadline of one minute. Matrix-nio can retry a sync
request beyond that deadline, so a stalled exchange could run for
much longer than the diagnostic claimed.

Request a 250 ms server wait and bound each sync by the remaining
15-second reply deadline. The test still observes the reply through
the independent Matrix client and checks the model request and cache.
A Matrix client that replies with media and no caption can put the
inline reply fallback before the filename in body, for example
"> <@erin:example.org> nice photo\n\nphoto.png". The caption decision
compares the original wire body with the declared filename, or applies
the filename heuristic when no filename is declared. The wire body
still contains the fallback, so neither check recognised the bare
filename, and the model received "photo.png" as the user's text.

Strip the reply fallback from the wire body before the comparison when
the event replies to another event. Mentions stay in the wire body, so
a caption that only matches the filename after mention removal is still
kept. A parameterised regression covers the reply with and without a
declared filename.
test_other_client_media_event_keeps_declared_filename_and_caption sent
an m.file event with the caption "notes.pdf" and the filename
"original.pdf" through _on_room_message. The legacy heuristic never
drops a PDF name on m.file, the fake download returns no bytes so no
cache file is named, and raw_message equals the event content by
construction. The test therefore passed on the adapter before MSC2530
support.

The m.file case of
test_declared_filename_preserves_filename_looking_caption_and_names_cache
already checks that the document cache receives the declared filename
while the caption is kept, and it fails on that adapter.
The live media test sent an m.file with the caption "screenshot.png".
For m.file, the legacy heuristic only treats audio and video names as
bare filenames, so the adapter before MSC2530 support also forwarded
that caption. The test failed on that adapter only because of the cache
filename, and its caption check could not detect a lost caption.

Use "clip.mp4" as the caption, which the legacy heuristic drops for
m.file. Check that the paragraph after the document note is exactly the
caption, before the cache checks, so a dropped caption fails on its own.
The document note must also give the declared filename and the cached
path. The model context previously went through json.dumps and a
substring search, which the filename in the note could satisfy.
The Matrix adapter includes both transport filename classification and
message dispatch. Caption changes need to distinguish those decisions,
but adding more parsing to the adapter would expand an already large
module.

Move the existing filename classifiers into media_content.py. Keep the
classification rules unchanged and update the existing tests to import
the defining module. The adapter still uses the same private classifier
when preparing reply media and captions.
A media reply can contain an ordinary quotation followed by a filename.
The reply parser preserved that authored text, but caption classification
stripped any leading quoted block and compared only the trailing filename.
The media event could therefore lose the user's entire quotation.

Classify captions with the existing Matrix reply-fallback detector and
splitter. Strip only a recognised legacy fallback before comparing the
transport filename. Keep ordinary quotations as authored caption text,
with or without an explicit filename.

The existing captionless-reply contract now covers both ordinary quotes
and legacy fallbacks. Both ordinary-quote rows fail before the repair;
legacy fallback controls still remove the transport filename.
Media events above the Matrix size limit returned before the adapter
resolved room context or built an inbound message. The agent never saw
the user's caption or knew that an attachment had been rejected.

Resolve the usual message context, then deliver a text event with the
caption and an attachment-too-large marker. Include the declared
filename, or the transport filename when the body contains only that
filename. Skip the download and leave media URLs empty so the rejected
attachment cannot enter the model request through the media path.
The Matrix adapter compared MATRIX_MAX_MEDIA_BYTES only with the
optional info.size in the event. When a client omitted the size or
understated it, the adapter downloaded, decrypted and cached the whole
attachment and passed it to the agent as media. mautrix's
download_media reads the entire body into memory before it returns, so
the configured limit did not bound memory either.

When gateway.max_inbound_media_bytes was lower than the Matrix limit,
the shared cache helper rejected the bytes after the download. The
adapter logged a warning and delivered the event anyway: encrypted
media arrived with no attachment and no marker, and plain media arrived
with the homeserver download URL. The agent was not told that the
attachment had been rejected.

Take the smaller of the two limits and apply it to the declared size
and to the download. The download now uses the mautrix client's HTTP
session directly, with the same authenticated media endpoint that
download_media chooses. It rejects a Content-Length above the limit
and stops reading once the streamed body exceeds it. Decryption and
caching happen only after the body fits. An attachment rejected at
either point produces the same text event as a declared oversize: the
caption and a "[matrix <kind> attachment too large: <filename>]"
marker, with no media fields. The room and mention gates still run
before any download.

A separate client sent a document without info.size through
disposable Synapse; the model request contained the caption and marker
and the gateway cached no file.
The media-caption parent stopped serialising the model content in its
own live test and removed the module's `json` import. The oversized
test in the same module still serialises the user content with
`json.dumps` before it looks for the caption and the size marker, so
after the merge both of its cases failed with a NameError once the
gateway replied. The project's Ruff rules do not include the pyflakes
undefined-name check, so linting did not catch it.

Import `json` again so the oversized test can inspect the model
content.
A client can send a media reply with an inline reply fallback in the
body, such as "> <@bob:example.org> hi\n\nvoice.ogg", and no
filename field. The caption rule strips the fallback when the event
has m.in_reply_to, sees the bare transport filename and removes it
from the caption. The too-large marker still tested the raw body,
which contains the fallback and so is not a bare filename. For an
oversized reply of this kind the agent received only
"[matrix audio attachment too large]", with neither a caption nor a
filename.

Strip the fallback in one helper and use it for both the caption and
the marker filename. The marker now reads
"[matrix audio attachment too large: voice.ogg]" for that reply.
The adapter now chooses the media download endpoint itself: the
authenticated /_matrix/client/v1/media endpoint with a Bearer token
when the homeserver supports Matrix v1.11, and the legacy
/_matrix/media/v3 endpoint without a token otherwise. mautrix's
download_media made that choice before. The unit tests' fake media
download always reported v1.11 support and discarded the request
headers, so dropping the Authorization header or forcing the legacy
endpoint left every unit test passing.

Let the fake report either level of support, and record for each
request whether the adapter asked for the authenticated URL and which
headers it sent. One parametrised case per endpoint asserts both, so
each of those changes now fails a test.
The bounded inbound download now requires its effective size limit. The
quoted-image path still omitted that argument, so the reply cache caught
the resulting TypeError and the model received no image. That path also
checked only the Matrix cap, despite the gateway cap sometimes being
smaller.

Use the same effective limit for quoted images, both for declared sizes
and while streaming. Extend the existing real-cache reply contract with
Matrix and gateway cap controls. Update adjacent media fixtures at the
HTTP streaming boundary and retain their intake barriers.

The quoted-image contract reproduces four failures and two declared-size
controls before this repair. All seven adjacent files pass, 713 tests,
including routed-profile image ownership and queued dependency retention.
The oversized-media cases used an integer gateway fixture parameter. The
current shared fixture expects typed GatewaySettings, so those cases
failed during setup before either attachment was sent.

Configure the existing gateway media cap through gateway_extra_config.
The production download uses the smaller gateway and Matrix limits, so
both native cases still exercise the same ten-byte bound. Remove the
unused integer parameter bridge without changing other fixture modes.
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.
@iainlane
iainlane force-pushed the codex/pending-quote-speech-prerequisite branch from da27edd to 9c5bfd3 Compare October 5, 2026 05:15
iainlane and others added 9 commits October 5, 2026 07:54
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.

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.
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.

Format the unquoted batch-pop signature on one line, as required by the
current formatter; the complete module AST and behaviour 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.
When busy_text_mode=queue and a debounce state for sender B is stuck in
the store (timer fired but the pending slot belongs to sender A, a
different sender), any message from a third sender C hit an early
`return` without being saved to the debounce store, pending_messages, or
anywhere else — permanently and silently dropped.

Remove the `_can_merge(existing_pending, event)` guard from the inner
conflict branch in `_queue_text_debounce` and unconditionally call
`merge_pending_message_event` instead. C's text is appended to the
pending slot (or placed there directly if empty), matching the
pre-debounce behaviour that always queued follow-ups regardless of
sender. Sender attribution is best-effort when 3+ senders collide
simultaneously; losing the message entirely is worse.

Adds a regression test that pins the 3-sender scenario: A in pending,
B stuck in the debounce store with task=None, C from a third sender —
C must survive in _pending_messages and B must remain in the store for
_drain_pending_after_session_command to recover.

(cherry picked from commit 2a7e3c5)

Relocation onto pinned upstream 343500:

The current copy inserts the pending-event merge call before the
third-sender branch returns. The preceding extraction moved
_queue_text_debounce to BaseTextDebounceMixin. Apply the same insertion
in gateway/platforms/base_text_debounce.py and retain its typed
receiver. BasePlatformAdapter inherits this method. Keep the existing
Base adapter and the rest of the extracted module unchanged.
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.
@iainlane
iainlane force-pushed the codex/pending-quote-speech-prerequisite branch from 9c5bfd3 to c21cc3b Compare October 5, 2026 17:43
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.
@iainlane
iainlane force-pushed the codex/pending-quote-speech-prerequisite branch from c21cc3b to 496d686 Compare October 6, 2026 08:26
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.

The live test removes a shared expected-event marker between phases,
uses the default interrupt policy for queued input, and reuses the first
phase release path. The separate gateway can observe stale mounted
markers or fail between a marker existence check and its read. Reset the
expected value atomically, configure queue mode through the existing
fixture, and give queued preparation separate start and release paths.
Record actual redacted cache entries by event ID so the second
withdrawal barrier acknowledges its exact target. Preserve every model,
pixel and prior-history assertion.

Apply the independently published native fixture corrections from PR
NousResearch#126375 at 6f027ce.
@iainlane
iainlane force-pushed the codex/pending-quote-speech-prerequisite branch 4 times, most recently from 29dba1f to 9c36c9e Compare October 9, 2026 03:23
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.

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

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins needs-decision Awaiting maintainer decision before any implementation P2 Medium — degraded but workaround exists platform/discord Discord bot adapter platform/feishu Feishu / Lark adapter platform/matrix Matrix adapter (E2EE) platform/qqbot QQ Bot adapter platform/slack Slack app adapter platform/telegram Telegram bot adapter platform/wecom WeCom / WeChat Work adapter sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants