Skip to content

fix(gateway): withdraw queued Discord and Slack deletions - #131044

Draft
iainlane wants to merge 394 commits into
NousResearch:mainfrom
iainlane:codex/native-deletion-auto-thread-followup
Draft

iainlane wants to merge 394 commits into
NousResearch:mainfrom
iainlane:codex/native-deletion-auto-thread-followup

Conversation

@iainlane

@iainlane iainlane commented Oct 2, 2026 •

Copy link
Copy Markdown

Stack

All PRs below target main. Nesting specifies merge order: prerequisites must merge before their dependants. This branch includes the published #131009 history. #135482 is a separate logical prerequisite for the shared worker-loop lifetime correction.

Discord and Slack deletion notifications remove matching input before its model turn starts. Discord consumes raw single and bulk notifications, including uncached messages. Slack consumes the registered message_deleted subtype before conversation filters. Other pending input retains its arrival order, and an already-started model turn remains unchanged.

Matching requires the platform, guild or workspace, original channel, message IDs, known thread ID and current receiving adapter. Automatically threaded Discord starters use their recorded parent channel; ordinary thread messages retain their thread channel. Slack requires Bolt's outer workspace envelope and rejects a conflicting inner workspace. The notification supplies no deleting actor, so the consumer does not infer author identity. The existing Matrix author-only consumer and cached Discord plugin event contract are preserved.

Withdrawal searches independent earlier debounce events and the current reservations, batches, FIFO and pending slots. Notification extraction preserves the complete current callbacks and existing Discord mixins.

Current source and validation

The history consists of the 386 published prerequisite records followed by this layer's eight original contributions. All eight original author and committer fields, timestamps, timezones and complete messages are preserved. All 46 meaningful empty records remain separate, including this layer's two empty credit contributions. Only the eight dependent records need renewed signatures.

Review this layer. Each complete cumulative snapshot is an automatic three-way composition of the original contribution with the delivered prerequisite. The compositions have no conflicts. The prerequisite includes the current source layout, the previously published fixture lifetimes, the checkpoint-order correction and the read-context EOF correction. This layer retains its deletion-notification behaviour and the existing pending input ordering.

I ran fifteen complete files through the canonical runner with the installed SDK and the actual three published #135482 records in a separate runtime-only composition. All 525 cases passed; eleven catalogue cases were skipped. The fifteen files cover deletion notifications, pending withdrawal, Slack callers, the catalogue and five inherited fixture modules. Complete child output has no resource, runtime, unraisable or thread-exception warnings through interpreter exit. Existing SDK deprecation warnings remain visible. The runner uses zero retries. All 9,319 captured tracked Python, configuration and test-runner inputs remain unchanged before and after execution, and their hashes match the actual Git blobs. The installed SDK is bound separately. The #135482 records are not duplicated in this PR.

The complete 26-path static comparison reports 342 inherited diagnostics: 339 exact complete lexical, relative statement-span and whole-statement matches, plus three existing Discord media override findings. The complete implementation and base methods for those three findings are byte-exact to the preceding published branch, and the complete Discord inheritance order is unchanged. No new finding is introduced. Ruff and changed-line whitespace pass. All 26 files retain inherited formatter differences without any new affected path. Both the frozen 99a45ecc17cc51c0488ec3b8ccf7eaf483f7203d and the captured 3637c512fc3211bdccb61b7140d09ac1018508ba local joins are clean.

The earlier 1744-base checks remain historical evidence for their recorded heads. The current checks above cover the bounded source update; they do not establish whole-suite, live-service or hosted CI acceptance.

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.
iainlane and others added 22 commits October 5, 2026 05:09
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 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.
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 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 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.
@iainlane
iainlane force-pushed the codex/native-deletion-auto-thread-followup branch 4 times, most recently from 36a20b3 to 34921a4 Compare October 9, 2026 04:42
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 codex/native-deletion-auto-thread-followup branch 2 times, most recently from c981179 to 2a4880a Compare October 9, 2026 22:22

This branch has not been deployed

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

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P2 Medium — degraded but workaround exists platform/discord Discord bot adapter platform/slack Slack app adapter 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