Repository navigation
Conversation
The DM tests marked m.direct account data but did not supply joined members. The two-person classifier therefore treated those fixtures as rooms, so their threading assertions tested the wrong policy. Give the fixture a full joined member list with the bot and one other user. Keep the three-member variant for room behaviour. The mention and threading tests now exercise the intended DM policy.
A room is a private bot chat only when the bot's user ID is one of its two joined members. With an access token, the adapter replaces the configured MATRIX_USER_ID with the ID that whoami returns. After a password login it kept the configured string. Synapse accepts a login identifier whose localpart differs in case from the registered user, so a configured @Hermes:example.org logs in as @Hermes:example.org. The membership check then never matched, and every one-to-one chat followed room rules: with MATRIX_ALLOWED_ROOMS set its messages were dropped, and otherwise they needed a mention. mautrix's login stores the homeserver's user ID in client.mxid. Copy it into the adapter after a successful login, as the whoami path does, so the membership check uses the ID that appears in the room's member list.
The Matrix handoff session-key test marked its DM case only through the m.direct cache. Room classification no longer reads m.direct, so the DM case was keyed as a group and the test failed. Give the fake client a joined member list: the bot and one other user for the DM case, and a third user for the room case.
The environment-variable reference still said that any room with two or fewer joined members is a DM, and told operators to add a third member to get room behaviour. A bot-only room, a room without the bot and a room whose membership cannot be read now follow room rules. Describe the current rule in the MATRIX_ALLOWED_ROOMS, MATRIX_REQUIRE_MENTION, MATRIX_FREE_RESPONSE_ROOMS and MATRIX_AUTO_THREAD rows. The comment in _schedule_pending_invite_joins said that a direct invite missing from m.direct is misclassified as a group. m.direct no longer affects classification, so drop that clause.
The E2EE crypto store keys every row (the Olm account, Olm sessions and inbound Megolm sessions) by an account ID taken from the adapter's user ID. After a password login the adapter now replaces the configured MATRIX_USER_ID with the homeserver's spelling of it, so the DM check matches the room's member list. That replacement also changed the store key. A deployment with a password login, MATRIX_USER_ID differing in case from the registered user, and a stable MATRIX_DEVICE_ID would open an empty store after upgrading. It would create a new Olm account for its existing device and could no longer decrypt history. The same happened on an in-process reconnect, because the second login read the replaced ID. Keep the configured ID in its own attribute and use it for the login identifier and, after a password login, for the store key, as before. The access-token path keys the store by the whoami answer, as it always has. The adapter's user ID still becomes the homeserver's spelling on both paths, and the DM, mention, invite and self-sender checks use it.
Matrix rooms often have no m.room.name state event. Identity resolution used the opaque room ID as the name shown to the agent even when other occupants had readable display names. Read member profiles from the state store, with the joined members API as a fallback when the store is empty. Use the other occupants to title an unnamed room. Explicit-name detection and DM classification remain unchanged.
Matrix room identity was cached without reacting to live state changes. Topic edits, renames and membership changes could leave stale room context. The agent also missed important privacy or replacement changes. Register sync-awaited state handlers that invalidate the identity cache. Queue one note per change kind for the next ordinary message. Keep notes through mention drops, commands and text batching. Quote and bound room-state values before they enter channel context.
Room notes were consumed while the adapter built an event. The gateway could reject that event for sender policy or intercept it for a paused session, leaving no note for the next model turn. A queued follow-up also bypasses normal admission and missed the note entirely. Consume notes in inbound model-message preparation, which both cold and queued turns use after their dispatch gates. Rejected and intercepted messages therefore leave pending notes for the next turn. Busy-session debounce and pending-message merges also discarded channel context from later events. Append it during both merges so accepted context reaches the model. Request joined member profiles explicitly so invited users do not appear in unnamed room titles. Runner admission, paused and queued turns, busy merges, room bounds, coalescing and invited membership have focused regressions.
A Matrix state update can change a member-derived room title, an explicit room name or the room topic between turns. Those changes altered the session-context prompt and agent cache signature, which rebuilt the agent and invalidated the provider's cached prefix. Render the Matrix room name, topic and same-sender display name from the session's persisted origin. Keep live state for routing and room identity. Member changes join the existing per-turn room notes, which also report explicit name and topic edits. A regression reproduced prompt and signature drift on the previous head. It now verifies stable bytes and signatures after a member rename, a join, an explicit room rename and a topic edit.
Matrix changes lacked a live exchange test, so mocked adapter tests could miss failures between Synapse, gateway setup, and model-visible context. Start disposable Synapse and a checkout-built Linux gateway through Testcontainers. Drive events with a separate matrix-nio account and a fake model under an isolated HERMES_HOME. Provision a Linux nio client with its own encrypted device store so later encrypted cases can also run from macOS hosts. Run the suite in a dedicated CI job through scripts/run_tests.sh. The plain-room test confirms both the reply and the inbound model context.
Synapse changes the owner of /data to UID 991 when it generates its configuration. On a Linux runner, the host test process cannot append to the resulting homeserver.yaml. Store Synapse data in a Docker volume and edit the configuration from a one-shot container. The test runner can kill a timed-out file worker before pytest tears down its fixtures. Enable Testcontainers' Ryuk reaper, pin its image by digest, and start it before creating the volume. Label the Synapse volume with the session ID; Testcontainers labels its network and containers. Ryuk can then remove these resources when the worker exits.
Parallel Matrix live tests built different checkouts under the same hermes-matrix-live:checkout tag. A second build could replace the tag between the first build and its container start, so a green test might exercise the wrong code. Give each fixture invocation a UUID image tag and remove that exact tag after its containers stop. Concurrent branches can now run the live suite without changing which checkout an existing fixture starts.
A file timeout kills the pytest worker and its build process without running fixture teardown. The unique image tag from the live test can then remain on the Docker host indefinitely. Start Ryuk before building and label the checkout image with the same Testcontainers session ID as its containers, network and volume. Ryuk can remove the image after the worker dies. A SIGKILL probe confirmed that the pinned Ryuk image removes a labelled Docker image.
Matrix live tests built the production image for each checkout. That build prepares SQLite, Chromium, Node and the UI even though the tests start only a gateway. The separate Linux observer also built a second Python environment at test setup time to obtain matrix-nio crypto. Build a small Debian image with the PM-pinned Python and the locked Matrix and test dependencies before copying the checkout source. The gateway and observer use the same prepared interpreter in separate containers, while the gateway still runs against an isolated home and the host fake model. Keep gateway logs in timeout failures so startup problems remain diagnosable. The plain-room test passed with a 1.545-second test body and 26.8 seconds for the complete test file. On this host, an uncached image build took 128.1 seconds and a cached rebuild took 4.67 seconds.
The live plain-room test requested a one-second Synapse poll and allowed a minute before reporting a missing reply. Matrix-nio can retry a sync request beyond that loop deadline, so a stalled exchange could take even longer to fail. Request a 250 ms server wait and bound each sync by the remaining 15-second deadline. Successful replies still arrive through the real Matrix event stream. The separate-client Synapse test passed.
The approval client sends cards, seed reactions and requester controls in quick succession. Synapse's default account event limit exhausts its burst and rejects the controls before the short client deadline. Configure the disposable server for 100 events per second with a burst of 100. The limit remains finite, and tests can exercise protocol events without waiting for the production rate limiter to refill. Client waits, retry rules and assertions are unchanged.
The Matrix live fixtures skipped with "require a running Docker daemon" on macOS installations whose daemon is reachable only through a Docker CLI context, such as Docker Desktop or Colima without the /var/run/docker.sock symlink. The fixture replaced DOCKER_CONFIG with an empty configuration to keep registry credentials away from the tests. That also discarded currentContext and the context metadata, so the Docker SDK, Testcontainers and the docker CLI all fell back to /var/run/docker.sock. scripts/run_tests.sh removed DOCKER_HOST, DOCKER_CONTEXT and DOCKER_CONFIG from the test environment, so a user could not select the socket explicitly either. The fixture now resolves the endpoint the same way the docker CLI does (DOCKER_HOST, then DOCKER_CONTEXT, then currentContext) before it isolates the configuration. It exports the result as DOCKER_HOST and removes DOCKER_CONTEXT, so the SDK probe, Testcontainers and the buildx build all connect to that endpoint. The isolated configuration keeps the CLI plugin directories, because Docker Desktop and Homebrew install buildx outside the default search path. It still contains no registry credentials or credential helpers. On macOS the daemon runs in a Linux VM and resolves bind-mount sources there. Testcontainers mounts the client-side socket path into Ryuk by default, which fails when that path is a host socket such as a context's. The fixture therefore mounts /var/run/docker.sock, the socket inside the VM, into Ryuk on macOS. The runner forwards DOCKER_HOST, DOCKER_CONTEXT and DOCKER_CONFIG. It still drops DOCKER_AUTH_CONFIG, DOCKER_CERT_PATH and DOCKER_TLS_VERIFY. With a scratch context whose socket forwards to OrbStack and counts connections, the plain exchange passed with 70 API connections through that socket. The previous fixture passed the same invocation with no connections through it, because it used /var/run/docker.sock instead. A context pointing at a missing socket now skips with a message that includes the socket path.
The last dependency step of the test Dockerfile imports olm, mautrix.crypto and nio, so an image without Matrix crypto support already fails to build, and the fixture reports the build log. The fixture then started a container from the finished image only to run the same imports again. That check could not fail, and it added a container start to every test module.
The gateway_image fixture built the Linux gateway image inside the pytest worker. run_tests_parallel.py kills a worker when the per-file timeout expires, so the cold build, the Synapse start-up and the exchange all had to fit in the 300-second per-file timeout that the workflow sets. The 45-minute job limit and the build's own 1800-second timeout could never take effect. A cold build of test_plain_exchange.py on OrbStack took 242 seconds of that budget. Move the build command to tests/integration/matrix_live/image_build.py. The fixture still uses it, so a local run builds a fresh image and removes it afterwards as before. The workflow runs the same module in a step before the tests and passes the tag in HERMES_TEST_MATRIX_IMAGE. When that variable is set, the fixture uses the image, checks that the daemon has it, and leaves it in place for the next test file. run_tests.sh forwards the variable, in the same way that it forwards HERMES_TEST_IMAGE for tests/docker. The build no longer has its own timeout: in the fixture the per-file timeout limits it, and in CI the job limit does. Drop the Buildx setup step. The fixture replaces DOCKER_CONFIG, where Buildx records the builder that the step creates, so every build used the daemon's default builder anyway. The pull_request path filter listed the production Dockerfile, which the live tests no longer use, and omitted most of the code that runs in the gateway. Filter on the code that the image copies and runs (the root modules, agent, cron, gateway, hermes_cli, hermes_platform, pm and tools), .dockerignore, the test runner and its setup action. With a prebuilt image, test_plain_exchange.py passed in 70 seconds.
The gateway container ran as UID 10000 and wrote into a bind-mounted
home that the fixture made world-writable. With rootful Docker on
Linux, the directories that the gateway creates, such as logs/, belong
to UID 10000 with mode 0755, so the runner's user cannot delete the
files in them. run_tests_parallel.py ignores errors when it removes its
temporary root, so each test left a tree under
/var/tmp/hermes-pytest-<uid>/ that only root could remove. macOS was
not affected, because OrbStack and Docker Desktop map bind-mount
ownership to the host user.
Run the gateway as the host UID and GID and keep the home's default
mode. The image has no passwd entry for that UID, so set HOME to the
home that the hermes user had. At teardown the fixture removes the home
itself, so a file that the runner cannot delete fails the test instead
of accumulating on the host. Windows has no host UID; Docker Desktop
does not enforce ownership on bind mounts there, so the gateway keeps
running as UID 10000.
On OrbStack's Linux kernel, a home written by UID 10000 could not be
removed by UID 501 ("Permission denied" on logs/gateway.log), and a
home written as 501:20 could. The plain exchange passes as the host
user on macOS.
The fake model server for the live gateway listened on 0.0.0.0, so any machine on the developer's network could send it requests while a test ran. A stray request would consume the scripted reply or appear in main_requests(), and the macOS firewall can ask whether to accept incoming connections. The gateway container reaches the fake as host.docker.internal. With a daemon on this host, as on Linux, the test network's bridge gateway is an address of this host, so the fake now listens on that address and host.docker.internal resolves to it. A daemon in a VM, such as Docker Desktop or OrbStack, does not expose that address on the host, and its host-gateway forwards to the host's loopback interface, so the fake listens on 127.0.0.1 there. The fixture chooses by trying to bind the gateway address. In OrbStack's Linux VM, a server bound to a network's gateway address answered a container on that network through the host.docker.internal mapping. On macOS the bind fails with EADDRNOTAVAIL, and the plain exchange passes over 127.0.0.1.
The gateway fixture yielded as soon as the gateway logged "Matrix: joined <room>". The Matrix adapter joins the invited room shortly after it connects, while the gateway is still in startup restore. During startup restore the gateway queues inbound messages and replays them when restore finishes, so a test's first message could reach the gateway through that replay. A scratch live test that sent its message as soon as the fixture yielded was queued in two of three runs. The replay can reach the adapter while the adapter's original dispatch of the same event still holds the session guard: for Matrix, the guard stays held during the typing-stop request after the handler has queued the event. The adapter then handles the replay as a second message while the session is busy, and the bot's first reply is the "Interrupting current task" notice. One native run of the oversized-media test failed this way. The gateway logs "Press Ctrl+C to stop" after startup restore has finished. The fixture now waits for that line as well as for the join, so a test's first message is dispatched directly. With this check the same scratch test was dispatched directly in three of three runs. When a test that uses the fixture fails, a report hook adds the last 200 lines of the gateway log to the failure report. The fixture deletes the gateway home at teardown, so the log of a failed run was lost before. LiveGateway now includes the home so that the hook can find the log.
_host_user() compared hermes_platform's os_family() with "windows", but os_family() returns sys.platform, which is "win32" on Windows. The Windows branch therefore never ran, and on a Windows host with Docker the gateway fixture called os.getuid(), which Windows does not have. Compare with "win32", as the conftest already does with "darwin". The matrix-live workflow's path filter omitted code that the gateway image copies and loads at run time: providers/, which agent/ imports; plugins/ outside the Matrix adapter (the plugin loader, model providers, memory and context engines); locales/, which agent/i18n.py reads; tui_gateway/, which gateway/ imports; and skills/, which the gateway syncs into its home at start-up. A change to any of them could break the live tests without running the workflow. Add all five. Rename HERMES_TEST_MATRIX_IMAGE to HERMES_TEST_MATRIX_GATEWAY_IMAGE. The suite uses Synapse, Ryuk and gateway images, and "Matrix image" reads most naturally as the homeserver.
Room classification controls the Matrix room allowlist and mention gates. The previous member-count shortcut classified a bot-only room as a DM, and an m.direct entry classified larger or unreadable rooms as DMs. Those rooms could bypass both gates. Classify a room as a private bot chat only when the bot and exactly one other user are joined. Request JOIN membership explicitly: mautrix's state store includes invited users by default. The state store may also contain only a subset of a room's members, so use its joined IDs only when has_full_member_list confirms completeness. Otherwise query the homeserver with get_joined_members and apply room rules if that fails. Keep m.direct as room identity metadata, without using it for authorisation. Document the resulting allowlist and mention policy. Tests reproduced the bot-only, bot-absent, multi-user, invited-user, partial-cache and unknown-membership cases before their fixes.
A reply to an m.emote message has a fallback that starts with "> * <@user:server>". The regex that parses the fallback's pill expected "<" straight after "> ", so it did not match this form. The reply lost its author (reply_to_author_id and reply_to_author_name were None), and the quoted text kept "* <@user:server>" at its start. Accept the optional "* " before the pill. The fallback detector already recognised the emote form with a second regex that described the same line, so both now use the one pattern.
Rapid Telegram messages can be combined before dispatch, but one event has only one set of reply fields. When two messages quote different messages, batching their text keeps only the first quote. The busy-text debounce also overwrote the quote ID with a later inbound message ID. Split ingress batches when their reply contexts differ. Dispatch the earlier event through Telegram's tracked inbound redispatch queue so a concurrent disconnect preserves the event for reconnect. A metadata-free continuation after a near-limit chunk inherits the first chunk's quote, which Telegram clients can omit from later parts of a split message. Compare reply context during busy-text debounce as well, and keep the quote ID distinct from the inbound message that an answer should target. (cherry picked from commit 348bef0)
Messages that arrive while a turn is running can be merged into one pending event. merge_pending_message_event joins a follow-up into the pending slot, and with busy_text_mode queue, _queue_text_debounce first joins a burst of text into one buffered event. A merged event has one set of reply_to_* fields, and neither path copied them from the incoming message. If a user sent "one" and then replied to an earlier message with "two", the next turn had no reply pointer, so the agent could not tell which message "two" referred to. The debounce also dropped a quoted image from the incoming media_urls. The previous commit, from NousResearch#65526, stops the debounce from merging messages whose reply contexts differ in any way. That keeps two reply targets apart, but it also splits a plain message from a reply that follows it, and merging those two would lose nothing. The debounce has two places where a queued turn can wait: the pending slot and its own buffer. Every split uses one of them, and a third message that fits neither was dropped. Copy the reply context instead where that loses nothing. MessageEvent.absorb_reply_context gives an event without a reply context the incoming one. Both branches of merge_pending_message_event and the debounce buffer call it, and the buffer now also takes the incoming media, as _enqueue_text_event does. MessageEvent.reply_context_conflicts is true only when both events reply to a message and their reply contexts differ. The debounce keeps those messages as separate turns, as it already does for messages from different senders. If the pending slot and the buffer both reply to other messages, a third message from the same sender joins the buffer. The buffer keeps its own reply context, so the third message's text reaches the agent but its reply context does not. merge_pending_message_event has to store every event in the single pending slot. When two reply contexts conflict there, the event keeps the first one, which is the rule that NousResearch#62088 applies to Matrix merges. NousResearch#66027 made the newest message's reply context replace the older one. That drops the older reply target, and it clears a quote when the newest message has none, so this change does not adopt it. Its coverage of both merge_pending_message_event branches and of the debounce is kept. Co-authored-by: Hilo-Hilo <57138830+Hilo-Hilo@users.noreply.github.com>
Busy-text debounce copied incoming attachment paths and MIME types but omitted media_text_inlined. A false flag means the attachment's text is not in the message. Losing that flag changes it to the legacy inlined state when the gateway builds document context. Pad legacy flag lists to the existing attachment count before appending new media. Append incoming flags and pad any legacy incoming entries as well, so each merged attachment keeps its own text-inlining contract. Consolidate the reply and media checks into two parameterized behavior contracts that compare complete events. Telegram dispatch checks await tracked tasks with zero batching delays instead of assuming that a short wall-clock sleep is enough under runner load. Three attachment cases reproduced missing flags before the repair and pass with the aligned lists. The contracts also retain the documented single-slot fallback for adapters without separate-turn storage.
Matrix thread continuations include an m.in_reply_to pointer for clients that do not render threads. The adapter treated that fallback pointer as a user reply, so it attributed the continuation to the thread root. Parse relations into explicit reply, thread fallback and edit fields. Use the parsed reply target for inbound context and the thread root for session routing. Malformed relation content now yields an empty relation instead of failing message handling.
A Matrix reply may omit the legacy inline quote. The gateway then knew only the target event ID and could not tell the agent what message the user referenced or who wrote it. Resolve reply targets from a bounded cache or the room event API. Apply edits to cached text and keep redactions as empty cache entries so a later reply cannot revive deleted content. Decrypt fetched events when the room uses E2EE, and label media bodies so filenames are not treated as spoken text. Attach the target author's name and authorisation verdict to the inbound event. The gateway marks fetched content from an unauthorised author as unverified background and renders Matrix quotes on one line. The adapter never fetches a remote target for a sender it rejects. Co-authored-by: Wessel <2385844+weebl2000@users.noreply.github.com> Co-authored-by: uuedxz <249543386+uuedxz@users.noreply.github.com> Co-authored-by: Simon Sharp <234941529+simonamsharp@users.noreply.github.com>
An earlier commit in this series appended every incoming event's channel_context when the base adapter merged a text batch, a pending message or a busy-session debounce. Matrix room notes were the reason, but Matrix now attaches its notes when the turn is prepared, so the change only affected Discord, Slack and relay. Discord backfills a thread message by scanning back to the bot's last reply. For two batched messages, the second backfill already contains the first backfill and the first message, so the merged turn showed the first backfill twice and the first message three times. Restore the previous merge, which keeps the first event's context, and add a Discord test for two batched thread messages.
Room context reached the model through Matrix-specific code in the gateway core. run_inbound imported the Matrix plugin behind a Platform.MATRIX check, run_turn read an ad hoc _matrix_room_state attribute, and session.py pinned prompt metadata, validated resets and kept a member digest on SessionSource for Matrix only. Plugins must use a generic surface instead. The Matrix adapter also kept a second mechanism: notes stashed from live state events with per-session cursors. On the production path the runner discarded those notes in favour of the snapshot comparison, so only tests reached them. Add BasePlatformAdapter.prepare_turn_context. The gateway calls it while it prepares a turn, passing the session origin and the channel_state saved with the latest user row. The adapter returns a TurnContextUpdate with a note for the user message and a new snapshot. The snapshot travels on MessageEvent.channel_state and is saved in the user row's display_metadata, so a change is acknowledged only when the turn that reported it is saved. An adapter that sets reports_chat_changes_in_turn keeps the session origin's chat name, topic and user name in the session-context prompt, which stays byte-stable while the notes report later changes. Matrix implements the hook with its existing snapshot comparison. The live-note stash, its take methods and their tests are gone; the remaining tests run through a real SessionStore. The state handlers now only invalidate the cached room identity. build_session_context no longer pins anything, so tools read the current chat name through HERMES_SESSION_CHAT_NAME, and the prompt copy uses session_identity.replace_source. reset_session refreshes the origin's name, topic and user name from the /reset source for every platform and keeps the origin's routing fields, which removes the Matrix guard that could raise after /reset had already evicted the agent. format_untrusted_prompt_value becomes public because the plugin uses it. Assert that the queued channel state and reset result exist before the original assertions access their fields. Keep every original assertion and parameter row. The consolidated runner fixture also drops the two unused attributes.
The room identity read m.room.name, m.room.topic and m.room.canonical_alias one after another and treated any exception as "the room has no such state". A timeout, a 5xx or a rate-limit response therefore looked like a cleared name and topic. The next turn told the model that the room name was now its room ID and that the topic was cleared, and saved that snapshot as the acknowledged baseline. The following successful read then produced a second pair of notes that restored the real values. The turn hook also forced a fresh read on every text message, so each reply waited for three sequential, unbounded homeserver requests. Only M_NOT_FOUND now means absent state. The identity reads the three state events and the member list concurrently, and each request stops after _ROOM_STATE_READ_TIMEOUT_SECONDS. When any read fails the identity has no room_state snapshot, and prepare_turn_context returns None: the turn gets no note and saves no channel_state, so the last saved snapshot stays the baseline. The hook reuses the cached identity that the adapter resolved for the message's source moments earlier. The state-event handlers already invalidate that cache when the room changes, and a restart starts with an empty cache. Room identity reads use gather with return_exceptions enabled. A cancelled read therefore returns CancelledError as a result. The state read previously treated cancellation as usable data, while the member read attempted to unpack the cancellation object. Propagate non-Exception control-flow failures before the ordinary read-error fallback. The two cancellation rows fail on the original public source. They pass with the correction, and the existing ordinary-exception controls pass on both sources.
Tombstone, encryption, join-rule and history-visibility notes came only from live state events held in adapter memory. They were marked delivered when the message was prepared, so a turn that failed before its transcript row was saved lost them, and a restart dropped any that had not been delivered yet. Name, topic and member changes did not have this problem because they were derived from the saved snapshot. Add the join rule, history visibility, encryption and tombstone to the room snapshot. The identity reads their state events in the same concurrent, time-limited batch as the name and topic, and the turn hook compares them with the snapshot saved on the latest user row. Every note kind is now acknowledged only when the row that reported it is saved, and a restarted gateway also reports changes that happened while it was stopped. A baseline taken from the session origin leaves these fields unknown, so the first turn of a session records them without a note. A saved snapshot without the newer fields still counts as a baseline for the name and topic.
The room-context layer changed both /new call sites to call `reset_session(session_key, source=source)`, so a reset refreshes the origin's chat name, topic and user name on every platform. The Telegram topic-mode test for /new in a group still expected a call with the session key alone, and failed with "expected call not found" from 8b5e52e onwards, although it passes on main. The test checks that /new in a group resets the group's own session key when DM topic mode is on. Expect the group source as well, which is the contract that the reset call now has.
…sion The compression-exhaustion auto-reset passes the turn's source to reset_session, which copies that source's chat name, topic and user name into the new session's origin. Internal wakes, such as kanban and watch notifications, build their source from routing fields only, so all three are None. When such a wake exhausted compression, the new origin lost its names on every platform. On Matrix the next system prompt showed the room ID, and the first human turn reported a rename against the empty baseline. Pass the source only when the turn came from a person. An internal wake now keeps the old origin, as it did before reset_session accepted a source, and a human turn still refreshes the origin with the current names.
prepend_turn_context_note called SessionStore.lookup_by_session_key directly on the event loop for every text turn on an adapter that implements prepare_turn_context. The lookup acquires SessionStore._lock, and other threads keep that lock while they save the routing index, for example during a reset that AsyncSessionStore runs in a worker thread. While a save was in progress, a Matrix text turn stopped the whole gateway loop until the save finished. Await the lookup through runner.async_session_store, which runs it in a worker thread, as the other inbound callers of this method already do.
The session-context prompt kept the origin's chat name, topic and user name only for adapters that set reports_chat_changes_in_turn, while the gateway called prepare_turn_context for any adapter that overrode it. The two declarations were independent, so two inconsistent adapters were possible. One with the flag and no hook froze the prompt and never reported a rename. One with the hook and no flag reported a rename in the user turn and also rewrote the system prompt. Remove the flag. reports_turn_context decides both behaviours from whether the adapter overrides prepare_turn_context, and the hook's docstring states that an override must report name and topic changes. Matrix, the only adapter that used either, keeps its behaviour. The reset test inserts its adapter into the runner adapter mapping, which requires BasePlatformAdapter. Implement that contract in the inert fixture and construct the fixture with its real configuration. Keep the existing turn-context hook and all original assertions.
When a room-identity refresh hit a timeout or a server error on m.room.name, m.room.topic or m.room.canonical_alias, the identity was built as though the room had no such state. The source then showed the alias, the member-derived name or the room ID, and no topic, and the adapter cached that identity for the identity TTL. A failed member read had the same effect on an unnamed room, whose name fell back to the room ID. New sessions and resets in that window took the degraded names into their origin, and tools saw them, until a later refresh restored the real names. Keep the last value read for each room's name, topic, canonical alias and member-derived name, and use it when the corresponding read fails. A successful read replaces the value, so a state event that empties or removes the name or topic still applies. Only M_NOT_FOUND means that the state is absent. The check also accepts an error that only has an M_NOT_FOUND errcode, which is how the room-instructions branch (NousResearch#125676) recognises it, and the values use the same _room_state_values map as that branch, so the two branches agree when they meet. The values live apart from the identity cache because _absorb_sync clears that cache whenever a sync response includes joined rooms, so the cached identity is usually gone when a later refresh fails.
Matrix compares the room with the snapshot saved on the newest user row that has one, under display_metadata["channel_state"]. Compaction replaced the summarised rows with a summary row that had no snapshot. When the summarised rows included every snapshot newer than the protected head, the baseline became an older snapshot in the head (the first turn's, with the default protect_first_n) or the session origin once the head no longer protects any user row. The next turn then repeated a name or topic note that the model had already seen, and it compared member and security state against stale or unknown values. Compaction removes the rows that contain the baseline, so compaction now keeps it. keep_newest_channel_state in agent/message_metadata.py records the newest snapshot of the original rows on the summary row (or on the tail row that absorbs the summary) when the compacted rows no longer have it as their newest. Tail rows come after the summary row, so a newer snapshot in the tail still wins. record_absorbed_message, which every fold of one row into another calls, applies the same rule, so the survivor of a merge of adjacent user rows (micro-compaction, alternation repair) keeps the later row's snapshot. The summary row can be an assistant row, so the gateway now reads the newest snapshot on a row of any role. CHANNEL_STATE_METADATA_KEY moves to agent/message_metadata.py because the compressor needs it, and acknowledged_channel_state is replaced by newest_channel_state.
When the compressor merges its summary into a tail row instead of adding a standalone row, that tail row must take the newest channel state from the rows it replaces. No test reached this path, so dropping the carrier assignment in _assemble_compressed passed the suite. Call _assemble_compressed directly for a summary merged into tail[0], for one merged into the first visible tail row when the head is empty, and for a tail that already has a newer snapshot, which must win.
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.
When an explicit reply (m.in_reply_to) had a body starting with "> ",
the adapter treated every leading "> " line as the legacy reply
fallback. It removed those lines from the message text and reported
them as the text of the replied-to message. Matrix 1.13 (MSC2781)
removed reply fallbacks, and current clients such as Element X send a
reply exactly as the user typed it. A user who began a reply with a
quotation therefore lost the quotation, and the agent saw it as the
message being replied to. Element also sets m.in_reply_to, with
is_falling_back, on ordinary thread messages, so a quote at the start
of a thread message was lost in the same way.
Treat the leading quote as a fallback only when the event has one: the
HTML body starts with an <mx-reply> element, or the plain quote block
starts with the quoted sender's pill ("> <@user:server>", or
"> * <@user:server>" for an emote) and ends with a blank line. Older
clients generate both forms. The reply-context parse and the mention
strip under require_mention use the same check, so a quote without a
fallback stays in the text with any bot mention removed, as it does in
a message that is not a reply.
The plain-body check is deliberately narrower than the spec. The
spec's stripping note still says to remove every leading "> " line of
a reply without condition, and that rule cannot tell a fallback from a
quote now that clients no longer send fallbacks. Ruma's
remove_plain_reply_fallback, which matrix-rust-sdk and Element X use,
narrows it in the same way: it strips only a body that starts with
"> <" or "> * <". The HTML check follows the spec's rule for
formatted_body, which applies only when it starts with <mx-reply>.
A hand-typed quote that starts with a pill and ends with a blank line
has the same shape as a plain fallback and is still stripped.
Threaded outbound sends always point their fallback reply at the thread root, even after the adapter receives or sends a newer event. Explicit replies are also labelled as fallback replies. The resulting relations lose the natural message chain in clients that do not render threads. Exercise an accepted inbound event, a split text response, an explicit reply, and encrypted and plain media sends. Both tests fail on the current adapter because every fallback target remains the root.
Thread relations previously labelled every outbound message as a fallback, including messages with an explicit reply target. That misrepresented genuine replies and could alter how clients render them. Set is_falling_back to false for an explicit reply and keep its target, even when the target is outside the current thread. For a plain thread send, use the supplied fallback target or the thread root and mark it as a fallback.
Matrix sends previously pointed every thread fallback at the root. A client without thread rendering therefore lost the order of later messages, especially when a response was split or media followed text. Track the latest event per room and thread after an inbound message or a successful outbound send. Use that event for the next fallback, with the root as the first anchor. Explicit reply targets still take precedence and remain genuine replies. The tracker is bounded and belongs to the Matrix plugin, so the gateway core gains no platform-specific state. The focused tests cover split text, explicit replies, encrypted media, plain media and multiple images. The Matrix client-server specification recommends the latest known message for this fallback target.
A separate Matrix client now sends two messages in the same thread and checks the relation on each gateway reply. Each reply targets the current inbound event and uses genuine reply semantics, including after an earlier bot reply in that thread. Live Matrix request/reply waits cover real HTTP sends, text batching, and the first gateway turn. A cold-service exchange produced a valid reply after 21.981 seconds, beyond the previous 15-second bound. Use the fixture's existing 60-second wait bound and keep the complete thread, reply and model-context assertions.
The gateway anchors every Matrix final response on the triggering event, so send() passed that event as reply_to for each chunk of a split response. Every chunk became a genuine thread reply with is_falling_back set to false, and Element rendered a quote of the user's message above each chunk. Streamed responses had the same problem: the stream consumer anchors each new chunk on the previous chunk, and later messages in the turn on the triggering event again. In auto-thread rooms the synthetic thread root is the triggering event itself, so the first reply in every new thread quoted its own root. The tracker now records, for each room and thread, whether Hermes sent the latest event and which event Hermes last replied to. A send anchored on the thread root, on Hermes's own latest event, or on an event that already has a reply in the thread is a continuation. It sets is_falling_back to true and points at the latest known event, so only the first message of a response remains a genuine reply. The decision stays in the Matrix plugin, and the platform-neutral chunk anchoring in the stream consumer is unchanged. Unit tests cover a split final response with reply_to, the buffered and live streamed paths, and a reply anchored on the root. A live Synapse test limits the gateway to 500-character messages and checks that only the first chunk of a threaded answer quotes the question. Before this change the second chunk also quoted it. Live Matrix request/reply waits cover real HTTP sends, text batching, and the first gateway turn. A cold-service exchange produced a valid reply after 21.981 seconds, beyond the previous 15-second bound. Use the fixture's existing 60-second wait bound and keep the complete thread, reply and model-context assertions.
Outbound thread relations accepted an explicit fallback anchor under two metadata keys, matrix_thread_fallback_event_id and thread_fallback_event_id. Nothing sets the unprefixed key, so remove it. The prefixed key remains because Matrix thread creation sets it to anchor the first reply of a new thread on the new root.
A threaded response quotes its triggering event once, and the later messages of that response are thread fallbacks. The tracker recognised a continuation only when reply_to was the event that Hermes had most recently replied to, or Hermes's own event while it was still the latest event in the thread. Any other genuine reply or inbound post replaced those values, so the rest of the response quoted its trigger again. That happens on ordinary gateway paths. A busy acknowledgement for a follow-up is a genuine reply to the follow-up, and the running turn then sends its next segment anchored on the original question. With thread_sessions_per_user, two sessions can answer in one thread at the same time. The stream consumer anchors each head chunk on the previous chunk, and another user can post between the two sends. Keep, for each thread, a bounded list of the recent events that Hermes has given a genuine reply to and of the events that Hermes has sent. A send whose reply_to is in either list is a continuation. The fallback target is still the latest known event in the thread.
The agent could only see Matrix messages included in the current turn or initial thread backfill. It could not ask for a recent room message, a thread, or an event when it needed more context. A generic network tool would also lack the receiving bot's crypto store and room policy. Bind the receiving adapter and its event loop to the gateway turn. Add a Matrix-only toolset that reads through that adapter, checks the current room and requester, and bounds event counts and bodies. Use the adapter's sync token for room history and its crypto object for encrypted events. Report missing decryption keys per event so readable neighbours remain available. The focused tests cover the owning loop, room and user gates, thread filtering, encrypted reads, and toolset selection. Co-authored-by: Chris <16943149+nepenth@users.noreply.github.com> Maintenance-adaptation: Room membership and room policy can change while the direct-room lookup awaits. Factor the existing room-policy condition into a synchronous predicate for admission and the post-await membership and room-policy recheck, before requester validation. The original inline adapter doubles bind the real predicate. Event-controlled membership, room-policy and requester cases reproduce stale-policy acceptance and verify refusal before client access.
iainlane
force-pushed
the
codex/original377-context-extraction
branch
2 times, most recently
from
October 8, 2026 13:43
4292baa to
e1c8aa0
Compare
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. Current published supplier fixture compatibility The current read-tools supplier has the same fake HTTP API session and reconnect lifetime defects. Preserve the complete existing test module and every assertion. Reuse the already published client factory from the final credited maintenance scope so each fake Client exposes the actual API session. Only the fourteen existing fake Client assignments are adapted, and the canonical-ID reconnect fixture disconnects its first connection before opening the second; production and the retained eighty-five contributor records remain unchanged.
Matrix room discovery and command presentation need changes to room identity reads and formatted HTML. Both topics were defined in the 3,536-line adapter facade, which also manages transport and delivery. Move the existing identity and room policy methods into the existing context mixin and move formatted HTML helpers into a rendering sibling. Keep the adapter MRO and method bodies unchanged, and update the timeout fixture and identity imports to their defining modules. The original adapter contracts pass with the actual SDK. Executable AST comparison is unchanged, and all 15 existing type diagnostics have the same defining function, rule and complete message after the move.
iainlane
force-pushed
the
codex/original377-context-extraction
branch
from
October 10, 2026 07:07
e1c8aa0 to
7ec84d5
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
This PR targets
main. Merge #125660 first. The branch is based on its independently accepted signed head5863966508580908a6058fade62aae9f66dd324f; the upstream diff includes that prerequisite.Change
The Matrix adapter defines room identity, room policy and HTML rendering beside its transport and delivery code. Move twelve existing context methods into
context_mixin.pyand the existing rendering declarations intorendering.py. Move the room identity dataclass into the existingroom_context.pymodule. The adapter inherits the extracted context mixin, and its existing method implementations and binding seams remain unchanged.Update the gateway routing guidance and tests to use the defining modules. All existing test assertions and parameter rows remain unchanged. This is a mechanical extraction of the current implementation. It preserves the current history readers, cache behaviour, room policy, media handling and rendering.
Current frozen-main runner compatibility repair
The branch now reuses the exact published #125660 prerequisite at
5863966508580908a6058fade62aae9f66dd324f, which includes the accepted runner repair from #125438 and frozen upstream99a45ecc17cc51c0488ec3b8ccf7eaf483f7203d. All 85 signed prerequisite contributions are reused without signing them again. This layer's single original extraction contribution is replayed above that supplier with its complete author and committer headers, dates, time zones and full message unchanged, then signed with the configured key.Current signed head:
e1c8aa0749fb5953356aab5528bd29edd599a8ac. Source tree:2b911f4fb8ad877b7fb65a9c429afca305a7edcb. Against the saved automatic merge of the old branch with frozen upstream, onlyscripts/run_tests.shand its existing shell regression differ. Both match the published prerequisite byte for byte. Every other product, SDK, privacy, fixture and extraction path matches that merge result.The strengthened shell regression fails on the old public head because strict acceptance is absent from the forwarded environment. The corrected prerequisite preserves
HERMES_E2E_STRICT_ACCEPTANCE, Docker endpoint settings and the gateway image while excluding Docker authentication and TLS credential variables. On this exact candidate, three complete files pass through the canonical runner with two workers and no retries: the shell contract and both strict helper files, 28 cases in total. Whole-tree Ruff lint, the changed shell test's type check, shell syntax and repair whitespace checks pass.The earlier 237-case extraction and static results below retain their recorded source revisions. This runner gate does not repeat those results or establish native-service acceptance. Hosted CI approval and merge acceptance remain separate; the PR remains a draft.
Review the extraction layer against the exact published prerequisite.
Recorded extraction verification
The complete Matrix adapter and media-filename unit files passed the same 237 cases before and after extraction, with actual mautrix models, one worker, strict runtime warnings and no retries. The extracted modules were loaded from the candidate checkout. Scoped Ruff passes before and after. Scoped Ty reports the same 54 findings, with none introduced.
Final corrections add two module-header blank lines and wrap nine typed method signatures. Both complete module ASTs remain identical to the tested candidate; the 237-case result applies to that behaviour. Inherited formatter differences remain in existing and moved bodies.
The complete original contribution message, author and committer metadata are preserved. Its historical validation statements describe the original contribution; the recorded extraction validation is described above. These unit results do not establish native client or homeserver acceptance.
The prerequisite update changes only the inherited import in
gateway/run_inbound.py. This branch preserves every file and assertion in its own extraction contribution byte for byte. The 237-case extraction results above are reused for that unchanged contribution; the repaired parent separately passed its scoped 292-case gate on its recorded pinned composition. These results do not establish current-main or native integration acceptance.Recorded earlier signed head:
4292baababb88e598a8e9cce465e195cbf570fe0.Current captured-upstream compatibility and verification
This mechanical extraction now uses the current #125660 supplier with its separately credited client-fixture maintenance. The complete original contribution preserves its literal author and committer fields, dates, timezones, full message and all source and test contracts. The branch retains the supplier's 86 signed contributions and reconstructs only this layer's one contribution. Current signed head:
7ec84d5a26ca397000c742e5c71783c77690ecb8.The extracted context and rendering modules retain every original implementation. Twelve complete Matrix methods move to their defining mixins, with the original five necessary late imports and explicit adapter self annotations. The complete class inheritance, callers and extracted HTML functions remain unchanged. Local joins with fixed upstream
3637c512fc3211bdccb61b7140d09ac1018508baand frozen99a45ecc17cc51c0488ec3b8ccf7eaf483f7203dare clean.The initial complete six-file run reproduced the supplier's session and worker-loop failures. Both failed whole files are excluded from that run's pass total. Four unchanged complete files provide 52 resource-clean cases. After the published supplier's fixture repair, the complete Matrix adapter file passes 210 cases on this pure source. Its 39 existing database-shutdown observations remain explicit. Every assertion is preserved and all 9,180 actual tracked inputs are source-bound before and after that complete child.
The complete read-tool file separately passes 16 resource-clean cases on a runtime composition of this source with the three published worker-lifecycle contributions from #135482. Those contributions remain a logical validation prerequisite and are not included in this branch's source history. The executed runtime source differs from the final runtime composition only in the unused Matrix fixture module; the complete read-tool file, production paths, relevant fixtures, runner and configuration inputs are exact. In total the qualified six-file scope contains 278 passing cases: 68 cases in five resource-clean files and 210 cases in the Matrix file with the recorded database observations. The results are a source-qualified union of complete executions, not one fresh six-file execution on the final head.
The full six-path static comparison reports 52 inherited complete diagnostic occurrences and no introduced lexical, relative-span or statement-AST occurrence. The affected complete Matrix file was checked again after the final client-factory repair; the other five modules are byte-identical to their earlier inputs. Scoped Ruff and changed-line whitespace checks pass. The same five inherited formatter paths remain. Earlier native service results retain their recorded sources and images. Live Matrix services were not repeated for this mechanical extraction. Hosted execution and merge acceptance remain separate.