Skip to content

fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment - #168

Merged
nissa-seru merged 28 commits into
anima-research:mainfrom
ajaniramon:fix/157-nonconversational-tags
Oct 19, 2026
Merged

nissa-seru merged 28 commits into
anima-research:mainfrom
ajaniramon:fix/157-nonconversational-tags

Conversation

@ajaniramon

@ajaniramon ajaniramon commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

isConversationalInjection exempted only chat:reaction, so its RFC-001 siblings — chat:edited, chat:deleted, chat:reaction-remove — counted as real conversational input. With antra-tess/zulip_mcp#23 delivering edits, topic moves and deletions as tagged synthetic incoming messages, an [edited] … typo fix arriving right after an explicit send_message cleared same-round suppression and re-opened auto-routing for the same prose. chat:reaction-remove had the gap even before that rollout: removing a reaction behaved differently from adding one.

Sibling from the round-2 review of zulip_mcp#23 (see the issue comment): ChannelRegistry took every accepted incoming as the new defaultPublish*, so a marker could retarget outbound speech and become context.incoming's reply anchor (edit:…/delete:… ids instead of the message the agent is replying to).

Changes

  • The four tags live in NON_CONVERSATIONAL_TAGS (src/mcpl/tags.ts, next to the core-tag closure they come from), with a hasNonConversationalTag helper used by both consumers.
  • isConversationalInjection exempts all four (was: chat:reaction only), apart from an edit the agent sees first, which counts as the message it replaced (the first-view mark, below).
  • ChannelRegistry.handleIncoming skips marker-tagged messages when tracking defaultPublish{Channel,MessageId,ThreadId}, apart from an edit the agent sees first, below. The open = true transport repair stays unconditional — a marker is still authoritative liveness evidence. Tags remain non-authority (§16.6): the set only ever withholds default conversational treatment.
  • Changelog fragment.

Tests

  • test/present-while-acting.test.ts: parametrized over the three new tags — a marker injected mid-turn right after an explicit send leaves post-send prose suppressed (the issue's headline repro).
  • test/channel-registry-routing.test.ts: all four markers arriving on chanB leave the default publish locus on chanA (and a real message still retargets — the skip is tag-scoped, not a freeze); a marker on a never-conversed channel leaves the locus null rather than marker-anchored.
  • Mutation-verified: with the source reverted, 5 of the new tests fail (3 suppression, 2 registry); all pass with the fix.
  • ubuntu (Node 24), canonical runner, clean dist: 959 pass / 0 fail (main: 954 / 0; the +5 are this PR's). npx tsc --noEmit: clean.

Not verified

  • No live zulip_mcp deployment was run end-to-end; the tests assert at the two seams the tags cross (isConversationalInjection, handleIncoming's locus tracking).

Out of scope

  • Whether markers should trigger inference at all is the EventGate/shouldTriggerInference policy's call and is untouched — this PR only stops them from masquerading as conversational turns once admitted.

Fixes #157

🤖 Generated with Claude Code

What each part is for (Petra-1012, for the pushes updating this branch)

What the change is for. RFC-001's edit, deletion and reaction-removal markers are about a message, not one the agent answers. Read as conversational input, an edit arriving after a send_message reopened auto-routing for the same round's prose (#157), and a marker on another channel moved the registry's default publish channel and the reply anchor.

Each part, and what it buys:

  • NON_CONVERSATIONAL_TAGS, hasNonConversationalTag and isMarkerTagged (src/mcpl/tags.ts): one definition of what makes a message a marker, for the two places that need the same rule, so they can't drift apart. isMarkerTagged also reads the host's own first-view mark, below.

  • isConversationalInjection using it (src/framework.ts): the isConversationalInjection: exempt chat:edited / chat:deleted (and chat:reaction-remove) markers like chat:reaction #157 fix. main exempted only chat:reaction.

  • markAccepted skipping markers for the default channel, message id and thread id (src/mcpl/channel-registry.ts): a marker on another channel no longer moves the registry's default publish channel, or what context.incoming says the agent is answering. (Non-conversational marker wakes can still pin a turn's speech locus (batched-wake path) #189, open, tracks a marker that wakes the agent still setting that turn's speech channel; this branch leaves it.) The liveness repair (open = true) still runs for markers, as before.

  • The first-view mark, firstView (for Anarchid's R3-1): with push coalescing, an edit of a message the agent hasn't been shown replaces it, so the edit is the first version the agent sees, and it counts as the message it replaced. The coalescer marks a delivery as the agent's first view of its subject when no version of the subject may have been shown to the agent:

    • a plain occurrence that replaces one that was itself a first view (the subject's initial birth, or a replacement so marked), when that one may not have been shown. When it replaces a run of unread renders, the run's oldest render is the one that can be a first view, no render of the run may have been shown, and nothing of the subject may have been read;
    • the first delivery of a deferred batch that opened on a first view: its render, when no other version of the subject is placed, or a plain occurrence that takes the place of the batch, pending or rendering. A render that joins a run of unread renders is a delta on the run's first render, not a first view itself, and a batch that opens beside such a run isn't one either, since the run holds it.

    Where a version that may have been shown leaves the subject, displaced or withdrawn, the subject's first view ends: for a batch pending or rendering, and for every later batch and birth of the subject while the coalescer keeps its record (firstViewEnded, which the snapshot and the receipt record written at each acceptance carry).

    Whether a message may have been shown is read from each agent's shown watermark (Agent.shownWatermark). It moves when a committing compile takes its value, before it awaits, at every tool boundary that injects messages whether or not the stream says it carries them, and when the agent is created. A compile that throws gives its move back, unless a boundary or another compile has moved the watermark since. Of two overlapping committing compiles, if the first to start throws first and the second then throws, the watermark ends at the first's failed claim, which errs toward a marker until the next compile or boundary moves it. A message counts as unshown while it's still in the deferred queue, or stored above the shown watermark of every agent that reads it, the readers isUnreadStoredMessage already uses. Anything else counts as shown, so a doubt errs toward a marker.

    The handlers put the mark on the message's metadata, dropping any a server sent. isConversationalInjection counts an edit so marked as the message it replaced, and when the coalescer delivers it as it accepts it, markAccepted moves the default publish channel and context.incoming for it as for that message. For a channel message, the coalescer's receipt for the admission records the first view, so a producer's retry that the receipt answers moves them too. A reaction, reaction removal or deletion so marked stays a marker. The conditions keep isConversationalInjection: exempt chat:edited / chat:deleted (and chat:reaction-remove) markers like chat:reaction #157's fix wherever the agent may have seen a version: an edit of an edit after a read, an edit of a message shown at a tool boundary or assembled by a compile still under way, and an edit of that edit.

  • The tests: the registry's two cases, present-while-acting's mid-turn cases (one per marker tag), and 57 in mcpl-coalescing for the mark, through its WebSocket fixture or, for the coalescer's own decisions, a stand-in host.

  • This branch's earlier pushes:

    • main merged in at 24826254 (two import conflicts, both sides kept), the fragment's last sentence narrowed to the maintainer's wording, and assertions on context.incoming's message id and thread id after each marker.
    • 0236f40a, from our internal reviews and a colleague's read: texts that described the old marker set, or said markers never influence routing or that the mid-turn locus is frozen, name what holds, and tests pin the re-inference rows, the open repair and the anchor.
    • 26afe3bd and 7dc9ce02, for Anarchid's round 3: R3-1's mark; R3-2, two comments that no longer say the locus is frozen; R3-3, channel_publish's description and its channelId parameter saying a conversation agent's own channel comes first; R3-4, the four suppression tests asserting that the marker was injected at the send's tool boundary; and a reply-anchor case that 0236f40a's test change had dropped.
    • 044a80d9, bf351924 and 9cac2034, for Greptile's review of 7dc9ce02: a message stored at once, outside the deferred queue, counted as unshown too, until a committing compile that reads it began.
    • 03420e7a merges main 07563022, with one conflict, where main (fix(deferral): hold window-only messages to the turn's end; injected messages are read #286) moved the code that stores a message at once. 44e9ce72 replaces the stored-at-once flag with the shown watermark, so every way a message reaches the store counts alike. 772f70a0, from our internal review of the two: a compile that throws gives back its move of the watermark, the first view holds in the deferred lane, context.incoming follows a first-view edit, and the texts describe the watermark. db15e9ba adds tests for three pieces of 772f70a0 that its removals found no test for.
    • 2ed67d2e merges main 0d19f7c8, with two import conflicts, both sides kept.
  • An earlier push (1789e8cd, cb487a2f, f40c54a4, 7e13dd9c, 28c25610):

    • 1789e8cd, for slimepriestess's re-review of 2ed67d2e: a notice that ends the agent's first view clears it from a first-view batch, whether that batch is pending, as the review found, or already rendering. Tests pin both history guards the review named.
    • cb487a2f merges main c0155d39, keeping both sides in each of ten conflict regions. main's fix(mcpl): withdraw a batch-only turn whose batch is gone, rendering elsewhere, or queued for another agent #520 moved where a render marks its content consumed, after the deferred branch, and the coalescer's stand-in hosts gain wakeToRead.
    • f40c54a4, the review's third point: the receipt records the admission's first view, and the framework reads it from the receipt for a fresh acceptance and a retry alike, so a retry answered from the receipt after a failed commit moves context.incoming too. The receipt is the one source.
    • 7e13dd9c, from our internal review of the three: a subject's first view ends wherever a version that may have been shown leaves it (occupantLeaves). Before it, the clearing reached only the batches open at that moment, so a batch opened later, a plain birth, or a batch after a retraction could be marked again.
    • 28c25610 merges main b82ba630, with one conflict, in markAccepted, where fix(mcpl): reconcile channel lifecycle operations in order #210 (channel lifecycle operations in order) changed the transport repair. Where the host directs a server's lifecycle, traffic now marks a channel open only for a confirmed target that is decided open or tuned out. Both sides are kept. This branch's marker guard covers the default publish channel and the reply anchor, and fix(mcpl): reconcile channel lifecycle operations in order #210's repair runs after it, for markers as for any message. One registry test of this branch, that a marker still repairs a channel's open state, now decides that channel open first, since under fix(mcpl): reconcile channel lifecycle operations in order #210 that's when traffic repairs it.
  • An earlier push (b8dc07ac, fb3389de, dcd04780), for the maintainers' reviews of 28c25610:

    • b8dc07ac, Anarchid's R4-3: the mark is named firstView, the meaning it has had since R3-1, in the message metadata, the occurrence and event fields, and the comments. No release carries the old name, so the rename adds no compatibility read.
    • fb3389de, text only. R4-1: channel_publish's description and its channelId parameter say what sets the default channel (as dcd04780 words it, below). R4-2: unshowFailedCompile's comment gives both orders in which two overlapping committing compiles can throw. R4-4: the present-while-acting comment and the changelog's account of when a message counts as shown. And slimepriestess's note on upgrades, in CoalescingSnapshot.firstViewEnded's doc and the line below.
    • dcd04780, from our internal review of the two. channel_publish's descriptions say an omitted channelId goes, after a bound conversation's own channel, to "the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it." An edit moves the default only when the coalescer delivers it as the agent's first view as it accepts it, which a server that doesn't coalesce and a deferred edit's later render don't reach. The receipt record written at each acceptance now carries a subject's ended first view, so a crash before the snapshot flush can't give the next batch a first view after the agent was shown an earlier version. The first-view tests' stand-in hosts treat a removed placement as read and shown, as the framework does, so they pin that occupantLeaves asks about an occupant before its removal.
  • The previous push (e257911c, a10eb51a):

    • e257911c merges main 9c6964c9. main's fix(routing): an ephemeral run takes no global fallback #557 (an ephemeral run takes no global fallback) reworded the same texts as this branch, so four places conflict, and each keeps both sides' rules. channel_publish's description and its channelId parameter keep this branch's account of the default and add fix(routing): an ephemeral run takes no global fallback #557's rule that a subagent has neither default and must name a channel. handleToolPublish takes main's resolution, and its comment and reactToRefusal's keep both sides' clauses.
    • a10eb51a, from our internal review of the merge with dcd04780. A crash-window record now ends its subject's first view whatever its age. Before it, a host that came back more than the receipts' retry window (an hour by default) after a crash forgot the end, and so did a restore that didn't hold the record's subject. Such a subject now comes back with the end and an unknown history. Five comments now say that an edit the agent sees first sets the default publish channel and the reply anchor as its message would. The changelog says a compile that throws gives back its move of the agent's position, unless something has moved it since.
  • This push (bf29f26a, 13d2bba6):

    • bf29f26a merges main 4fd2bfb9. main's fix(coalescing): an unread render stays when a later render or notice arrives, and a retraction or plain occurrence takes the whole run #562 keeps a render still unread when a later render or notice of its subject arrives, so the renders wait together as a run, which a retraction or a plain occurrence takes whole. That changed the coalescer's occupant and each path where this branch adds the first view, so seven places in push-coalescer.ts conflict. Each keeps main's runs and this branch's first view. A run's renders keep their own records, so the mark stays on the run's oldest render, the only one that can carry it. When a run leaves the subject, its first view passes on only if its oldest render carries the mark and no render of it may have been shown, and otherwise the subject's first view ends; that's asked before the run is removed, since the removal takes the placements it reads. A batch that opens beside an unread run is no first view. This branch's tests of a notice displacing a queued render now check that the render stays and a later render joins it unmarked, and that a plain edit or a retraction that takes the run carries its first view on, or ends it, by whether anything of the run may have been shown. Outside push-coalescer.ts and the two coalescing test files, this branch's change is the same as before the merge.
    • 13d2bba6, from our internal review of the merge. A run's first view now also needs nothing of the subject read: a render a request has read leaves the run before the run is weighed, so a newer render the agent had read could leave the oldest one to hand the view on. Ending a first view is now one step, and the end a crash-window record carries, which applies whatever the record's age, now also clears the mark from a batch the snapshot brought back. The comments on the default publish channel and the reply anchor now say that only an edit the coalescer delivers on acceptance as the agent's first view sets them. New tests give a run's renders different views.

Cut: nothing in the author's change. Left out on purpose:

  • Non-conversational marker wakes can still pin a turn's speech locus (batched-wake path) #189's case, a marker that wakes the agent setting that turn's speech channel.
  • zulip_mcp's edit that newly mentions the bot, of a message the host never received. Its tags look like any edit's, so it stays a marker (An edit that newly mentions the agent, of a message the host never received, reads as a marker with #168 #483).
  • After a restart, an edit of a message stored before it is appended beside it rather than replacing it, as on main, since a restored message counts as read.
  • A snapshot or receipt record from a release without this PR has no record that a subject's first view ended. After an upgrade, a subject whose first view had ended without a read is granted a first view again, and until the agent is shown or reads a version of it, each delivery that would mark a first view carries a spurious mark: a render, or a plain occurrence that replaces an unread version or a pending notice. In a run of slimepriestess's case at dcd04780, a plain message and then three edits of it marked the three edits, and a rendered notice and then two edits marked all three.

The counts in the Tests section above ("959 pass / 0 fail") were written at 3a423a1, this PR's first head: the description was opened 37 s after that commit and hasn't been edited.

Checks at 13d2bba6:

  • CI passes: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39241446395 and 39241446018).
  • On Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and so does the full suite: 4,851 tests, 4,824 passing, none failing, 27 skipped.
  • main 4fd2bfb9's source, with this branch's tests, fails 63 of mcpl-coalescing's 159 tests, 7 of present-while-acting's 64 and 2 of channel-registry-routing's 26.
  • bf29f26a's source, with these tests, fails 2: a plain edit that displaces a run whose newer render was read, and a late restore whose record ends the first view of the snapshot's batch.
  • Each piece undone alone fails tests that pin it: a run's first view handed on without asking whether anything of the subject was read (1 test); whether the run was shown asked with some rather than every (4), or of its oldest render alone (2) or its newest alone (2); and a restored end leaving the snapshot's batch its mark (1). The merge's pieces, undone at this head, fail as many tests as at bf29f26a or more: a run's first view asked of its newest render alone (3), asked after the run is removed for a retraction (7) or a plain occurrence (24), and a deferred render's occupant record not keeping its mark (14).

Checks at bf29f26a, the merge of main:

  • On Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and so does the full suite: 4,843 tests, 4,816 passing, none failing, 27 skipped.
  • main 4fd2bfb9's source, with this branch's tests, fails 55 of mcpl-coalescing's 151 tests, 7 of present-while-acting's 64 and 2 of channel-registry-routing's 26.
  • Each piece of the merge undone alone fails tests that pin it: a run's first view asked of its newest render alone (3 tests), and asked after the run is removed rather than before, for a retraction (6) or a plain occurrence (24). A batch beside a run marked as a first view fails none: by reading, nothing can deliver it as one, since its render joins the run and a plain occurrence that takes its place takes the run first.
  • Earlier pieces undone at the merge each fail tests too: a deferred render's occupant record not keeping its mark (13), the order of occupantLeaves and the removal reversed for a notice (1), the receipt record not writing the end (4), the end read only within the retry window (1), a server's own mark kept on a channel message (2), the mark ignored by isConversationalInjection (6), and markAccepted not given it (3).

Checks at a10eb51a:

  • CI passes: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39218303158 and 39218303156).
  • On Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and so does the full suite: 4,830 tests, 4,803 passing, none failing, 27 skipped.
  • e257911c's coalescer, with these tests, fails only the new test of records restored after their retry window when the agent had been shown the retracted render.
  • Each piece undone alone fails the tests that pin it. Reading the end only within the retry window, or not bringing back a subject the restore doesn't hold, fails that test. Applying the end only to a subject the same record bears fails four: the crash-window tests after a retraction and after a plain displacement, the snapshot-then-records test, and that one. The record not writing the end fails the same four.

Checks at e257911c, the merge of main:

  • On Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and so does the full suite: 4,824 tests, 4,797 passing, none failing, 27 skipped.
  • main 9c6964c9's source, with this branch's tests, fails 49 of mcpl-coalescing's 144 tests and 7 of present-while-acting's 64, and the channel-registry-routing file fails.
  • Each piece undone alone, as listed under dcd04780 below, fails as many tests at the merge as it did there.

Checks at dcd04780:

  • On Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and so do mcpl-coalescing 144 of 144, channel-registry-routing 26 of 26 and present-while-acting 64 of 64. The 30 test files that exercise the coalescer, the registry, the channel lifecycle and the routing they feed pass 1,025 of 1,025.
  • At dcd04780, fb3389de's coalescer fails only the new test of a crash-window restore after the agent was shown the retracted render.
  • Each piece undone alone fails only the tests that pin it: this push's receipt record not writing the end, or the restore not reading it (that test); the order of occupantLeaves and the removal reversed for a retraction (3 tests) or a notice's displacement (5); markAccepted given no first view on the coalesced path, which an earlier push added (the three tests of context.incoming after a first-view edit); and, for the rename, keeping a server's own firstView on the channel path (the two tests of a server marking its own edit) or isConversationalInjection ignoring the mark (six tests, R3-1's three lane tests among them).
  • Runs for the new text: a channels/incoming message sets the default publish channel, and a channel-scoped push and a plain push leave it; of two overlapping committing compiles that both throw, the watermark ends at the first's claim when the first throws first and back where it started otherwise; and the upgrade case above.

Checks at 28c25610, the merge of main: on Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and channel-registry-routing 26 of 26, channel-lifecycle-order 68 of 68, trunk-channel-routing 23 of 23, present-while-acting 64 of 64 and mcpl-coalescing 142 of 142.

Checks at 7e13dd9c:

  • CI passes: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39150401154 and 39150401148).
  • On Linux (x86_64) with Node 24.22.0, in a clean copy of this tree (5c1241d1, which 0e204232 has too, before the commit message was amended), the typecheck passes, and so do mcpl-coalescing 142 of 142, channel-registry-routing 26 of 26 and present-while-acting 64 of 64. The 26 test files that exercise the coalescer, the registry and the routing they feed pass 907 of 907.
  • Each of this push's commits, with its own tests and the source before it:
    • at 1789e8cd, 2ed67d2e's coalescer fails exactly the two cases the review found: a notice displacing a render the agent was shown, while the first-view batch is pending, and while it renders;
    • at f40c54a4, the merge's source fails only the test of a retry after a failed commit;
    • at 7e13dd9c, f40c54a4's coalescer fails exactly the six tests of its cases: the batch opened during the render that followed a displaced shown first view, then taken over by a plain edit; the same batch when that render came back blank; the batch after a retraction; the plain birth after a retraction, and its edit; a plain occurrence placed nowhere; and the snapshot carrying the end across a restore. The same cases where nothing had shown the first view pass.
  • Each piece of this push undone alone fails only the tests that pin it:
    • at 1789e8cd, a notice not clearing a pending first-view batch, or a rendering one: that case's test; either history guard removed: its own test;
    • at f40c54a4, the receipt not recording the first view, or the delivery not noting it: the three tests of context.incoming after a first-view edit, the retry's among them; the note not cleared between admissions: the test of an edit after an earlier first-view edit of a message the agent has since read;
    • at 7e13dd9c: a batch a notice opens not checking for an ended first view (5 tests); a birth not checking (1); occupantLeaves not ending it (6), not clearing a pending batch (1), or a rendering one (1); a retraction withdrawing an occupant (3), or a plain occurrence (1) or a notice (4) displacing one, without it; the snapshot not writing the end (1), or the restore not reading it (1); the snapshot writing a receipt's in-memory mark (1); and a plain occurrence taking only a pending batch's first view, not a rendering one's (1).
  • The retry test now asserts the retry was answered from the first attempt's receipt. With the coalescer's duplicate path disabled, it fails there, as do ten other retry and crash-window tests that use that path.

Checks of an earlier push, at 772f70a0 and db15e9ba:

  • In clean copies of that push's heads, each piece of 772f70a0 undone alone fails only the tests that pin it (at 772f70a0, and at db15e9ba for the three marked †, which db15e9ba adds tests for; the source is the same at both):
    • a throwing compileContext or compileWithInjections not giving back its move of the watermark: that compile's test;
    • the give-back undoing a tool boundary's later move†: the test of a compile that throws after a boundary moved the mark;
    • a tool boundary that can't read the store keeping its old mark†: that test;
    • a dry run moving the mark: the dry-run test, and the compileWithInjections test;
    • a batch never opening as a first view: the five tests of the deferred lane;
    • a displaced first view opening a first-view batch although something may have shown it: that test;
    • a render not marked: the three tests whose render is a first view;
    • a render that lands unread in another agent's deferred queue not keeping its first view: that test;
    • a render marked while an earlier render waits unread in a deferred queue†: that test;
    • a plain occurrence that drops a first-view batch not marked: the drop test and the restart test;
    • the occurrence after the drop not keeping the first view: the drop test's second edit;
    • the snapshot or the restore losing a pending batch's first view: the restart test;
    • markAccepted reading tags alone, or the framework not handing the mark on: the test of context.incoming after a first-view edit;
    • the mark sent in the answer to the server: that test, and vector 8's retried edit.
  • At 44e9ce72, 10 of that push's 14 new tests fail. The four that pass there pin behaviour that already held: a dry-run preview, a notice that displaces a first view something may have shown, an edit of a message the agent has read, and a compile that throws after a tool boundary has moved the mark.
  • The earlier pieces that push changed or moved, at 772f70a0, each undone alone:
    • isMarkerTagged ignoring the mark: the three lane tests, the two-edits test, the two tests of an edit that addresses the agent, and the context.incoming test;
    • isMarkerTagged lifting every marker tag: the reaction, reaction-removal and deletion tests;
    • a subject's initial birth not noted as a first view: 13 tests, the lane, two-edits, flush and throw tests among them;
    • every delivery noted as a first view: the edit-of-an-edit-after-a-read and no-initial tests;
    • the first-view chain dropped: those two and the edit of an edit of a shown follow-up;
    • isConversationalInjection's marker check removed: 12 tests in mcpl-coalescing and 9 in present-while-acting, the four R3-4 assertions among them;
    • isConversationalInjection back to chat:reaction alone: 11 and 7 of those;
    • markAccepted moving the anchor for every marker: the registry's two marker tests, and the test of an edit of a message the agent has read.
  • The watermark, at 44e9ce72's tree, each piece undone alone:
    • no shown move at a compile's start: the test of an edit admitted during a compile, and the shared-slot test;
    • no shown move in compileContext: its own test;
    • no shown move at a tool boundary: the two tests of a follow-up shown at a tool boundary;
    • no shown move in markContextConsumed, which runs at the agent's creation: the own-store test;
    • each reader's watermark read as the edited agent's own: the shared-slot test;
    • only a message still queued counted as unshown: the capacity-hold test, the own-store test and the two flush tests;
    • any unread message counted as unshown, or every message: the two tool-boundary tests, the two tests of an edit admitted during a compile, and the shared-slot test.
  • The earlier pieces whose code is unchanged since, each undone alone at 7dc9ce02 or 0236f40a, failed the tests that pin them:
    • a marked replacement not handing the note on: the two-edits test;
    • a lane's pass-through in deliverCoalesced, or a handler's setting of the mark: that lane's tests;
    • a handler's drop of a server's mark: that handler's tests of it;
    • the open = true repair moved under the marker guard, or a real message keeping the old anchor's id: the registry's marker test, each time.

Overlaps with other open PRs, from git merge-tree of 7e13dd9c with #436's head ef1d0249 and #190's head b52ec426:

…tional treatment

isConversationalInjection treated only chat:reaction as a marker, so the
sibling RFC-001 tags — chat:edited, chat:deleted, chat:reaction-remove —
counted as real conversational input: an "[edited] ..." synthetic message
arriving right after an explicit send cleared same-round suppression and
re-opened auto-routing (surfaced by the zulip_mcp#23 rollout, which
starts delivering edits/moves/deletions as tagged incoming messages).

The four tags now live in NON_CONVERSATIONAL_TAGS (src/mcpl/tags.ts,
next to the core-tag closure they belong to) with a hasNonConversationalTag
helper used by both consumers:

- framework.ts isConversationalInjection exempts all four;
- ChannelRegistry skips them when tracking defaultPublish{Channel,
  MessageId,ThreadId}, so a marker cannot retarget outbound speech or
  become context.incoming's reply anchor (review sibling from
  zulip_mcp#23 round 2). The open=true transport repair stays
  unconditional — a marker is still authoritative liveness evidence.

Tags remain non-authority (§16.6): the set only withholds default
conversational treatment.

Fixes anima-research#157

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟢 CLEAR

Reviewer: Codex (GPT-5.6 Sol)

Reviewed head: 3a423a1e0ce38051a46522b34441114b37e26556

No material findings.

The shared marker classification is applied at both behavior boundaries that matter: it preserves same-round send suppression in the framework and prevents marker traffic from replacing the registry's conversational publish locus. Incoming marker traffic still repairs transport liveness, and ordinary incoming messages still retarget the locus, so the change does not freeze routing state.

Tooling results

  • git diff --check e8a072f..HEAD — passed.
  • node --import tsx --test test/channel-registry-routing.test.ts — passed.
  • node --import tsx --test test/present-while-acting.test.ts — passed.
  • node --import tsx --test test/framework.test.ts — passed.
  • npx tsc --noEmit — could not provide an exact-head signal because the reused umbrella dependency tree predates the Context Manager history-query exports used elsewhere on this branch. All diagnostics were unchanged missing history exports/methods and resulting implicit-any errors; none originated in the changed marker code. All five exact-head GitHub checks are green.

Verdict: clear to merge from this review's scope. No blocker or non-blocking follow-up identified; local full-typecheck confidence is limited only by the stale sibling dependency tree.

— Reviewed by GPT-5.6 Sol via OpenAI Codex.

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at exact head 3a423a1 (one commit on e8a072f, 2 behind main, clean merge).

Right at both seams. isConversationalInjection now withholds conversational treatment for all four RFC-001 marker tags instead of chat:reaction alone, and handleIncoming no longer lets a marker retarget the default publish locus or become the reply anchor, while the open = true liveness repair stays unconditional (a marker is still proof the transport is up). The set only ever withholds, never grants, so §16.6 holds. reaction-remove behaving differently from reaction was a gap on its own even before zulip_mcp started delivering edits.

Receipts, fresh tree (membrane 0.5.81, context-manager 0.10.1, Node 22):

tsc --noEmit                                                        clean
test/channel-registry-routing.test.ts + test/present-while-acting   48 / 48
npm run build && npm test                                           959 tests, 955 pass, 0 fail, 4 skipped

Revert goes red: the base commit's framework.ts, channel-registry.ts and tags.ts with this PR's tests fail 5 of 48 — the two registry cases (locus not retargeted; never-conversed channel stays null) and the three parametrized suppression cases — matching the description's mutation count exactly.

Approving.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The change affects message handling and reply routing across MCPL-backed conversations.

Fix unread stored-message tracking before merging; an edited question during a provider hold can receive its answer in the wrong channel.

Findings

  1. P1 Edited questions get misplaced replies ▶
Fix with agent prompt
### Issue 1
src/mcpl/push-coalescer.ts:610
During a provider hold, the old turn stays open but incoming messages are stored directly, without `deferredId`. If an addressed question from another channel is edited before the agent resumes, this condition leaves the replacement without `replacesUnread`. The coalescer removes the original, so its reply-channel entry is ignored, while the edit fails `repinChannel`. The resumed turn sees the question but can post its answer in the old channel.

Track first views for unread stored messages too, while still excluding messages already shown at a tool boundary.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Shares the four marker tags between conversation checks and reply-default tracking. Coalesced edits can count as new input when they replace an unseen message in the host's queue.

  • Message markers keep explicit-send suppression in place.
  • Channel markers no longer move the default publish target or reply anchor.

Reviews (2) · Last reviewed commit: "docs, test: re-pin wording, channel_publ..." · Reviewed by Greptile

Comment thread src/mcpl/channel-registry.ts Outdated
…onal-tags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

@ajaniramon A marker that wakes the agent still sets the turn's speech channel. So a chat:edited on #chanB wakes a turn whose plain prose goes to #chanB, and the changelog's "can no longer retarget outbound speech" doesn't hold yet. Three more points and some test gaps follow, read at 843e4c0 from its merge base ec04465. The branch conflicts with main, so the line numbers are at this head.

The registry no longer takes a marker as the default publish locus. But channels/incoming still queues the wake request with the marker's own channelId (src/framework.ts:7140–7158), and that channel becomes the turn's trigger channel and frozen speech locus, even when the conversation is on #chanA. Whether markers wake at all is the gate's call, as the description says, but which channel a woken turn speaks in is this PR's subject. A marker carrying chat:addressed also outranks real messages when a batched wake picks its channel (isAddressedMessage, src/framework.ts:269).

  1. A coalesced batch takes its tags from its latest notice (DeferredBatch.latest, src/mcpl/push-coalescer.ts:90–92). Where a server coalesces an edit with the message it edits, a message the agent hasn't read arrives tagged chat:edited and is exempted. It doesn't clear explicit-send suppression or become the reply anchor, though the agent is seeing its content for the first time.
  2. The tune-out coordinator reads the same tags without the exemption. It wakes the subconscious on chat:addressed or a privileged author (src/tune-out/coordinator.ts:303–313), so on a tuned-out channel, a privileged author's edit or deletion wakes it.
  3. Topic moves. A Zulip topic move arrives as chat:edited, so the reply thread now stays on the pre-move topic until a real message arrives. If that's intended, the changelog could say so.

On tests:

  • The registry cases read only getDefaultPublishChannel(), not the messageId and threadId that make up the reply anchor.
  • Nothing sends a marker through the coalesced branch.
  • The mid-turn cases wait a fixed 30 ms without checking that the marker reached the round's injections, so they can pass without judging it.

The model-facing channel_publish description still says the default is "the most recent incoming channel" (src/mcpl/channel-registry.ts:376, 380).

— Petra-1012

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 843e4c0d (one production commit, 3a423a1, plus two merges of main) against main 02adf27. The head conflicts with main on one import line in src/mcpl/channel-registry.ts (main added EmptyContentError and isVisiblyEmptyContent); a trial merge with the import union builds and passes 1374/0.

Verdict

Approve the code; two asks before merge. The shared NON_CONVERSATIONAL_TAGS set is applied at both seams it names, and on the merged tree we probed every route that could move a reply: the mid-turn addressed re-pin (isConversationalInjection before the engaged-channel check, framework.ts:6820), the tool-result re-pin, and the registry's default channel, message id and thread id (channel-registry.ts:962-965, exposed as context.incoming at :1386-1390). All four marker tags, with and without chat:addressed, leave the anchor on the real message; liveness repair still runs for markers; a coalescer replacement keeps the original's tags. Revert-goes-red was reported in round 1 and is unchanged.

  1. Rebase onto main (the one-line import conflict).
  2. Narrow the changelog claim. "a marker can no longer retarget outbound speech or become the reply anchor" is true for the registry's default locus and context.incoming, not for the turn's speech locus. #200 (merged Oct 7) did not close #189: the incoming handler still puts the marker's channel on its queued wake (framework.ts:7146, :7227 on the merged tree), and the batched-wake selection reads only channelId and addressed (:8471-8474), never tags. Probe on the merged tree, agent busy, real message in A then an edit marker in B: default locus A, context.incoming A, trigger B, pin B, publish B, for all four tags; same on main. A marker that carries chat:addressed (an edit to a reply addressed to the agent) wins as the addressed request. Suggested wording: "Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers (#157). All four marker tags also leave ChannelRegistry's default publish channel and context.incoming's message/thread anchor unchanged."

Optional, and cheap to fold in here since it uses this PR's helper: leave channelId (and addressed) off the queued request when hasNonConversationalTag(event.tags) at the two push sites. The selection comment at :8450 already assumes markers never carry a channel. That closes #189.

Minor

  • The new registry tests assert only getDefaultPublishChannel, and the helper supplies no threadId; nothing pins message/thread anchor preservation (Greptile's secondary note). One assertion each would do.
  • startAgentStream's eager locus (:9198) accepts any trigger channel without consulting tags; a home channel wins and the registry fallback is protected, so this only bites a trunk agent whose trigger was a marker, which is the #189 shape again.

Verified fine

No other consumer of the literal chat:reaction on the merged tree besides the shared set and the tag catalogue; channel_open's pin move (:15050-15058) needs an explicit tool call and is unaffected; the fragment is one valid bullet.

Method: Codex gpt-6-astra (xhigh), read-only worktree, probes through the real registry, incoming handler, busy queue and eager locus on the merged tree and on main (80 commands, 1.61M input tokens, 11.6k output); Claude read the #200 selection block and the push sites independently and reached the same conclusion on #189. Filed earlier as AF #189; this is not a regression introduced here.

@nissa-seru

Copy link
Copy Markdown
Contributor

@ajaniramon With discord-mcpl, a coalesced edit doesn't keep the original's tags. So the edited text of a message the resident never read arrives as chat:edited, and this PR treats it as non-conversational. That's where our review and @Anarchid's differ.

discord-mcpl replaces an unread message with its edit. The edit is a push tagged chat:edited, keyed message:<id> like the create, under the comment "replace the unread original; append once it was read" (discord-mcpl main, src/server.ts:3302–3306). The host delivers a channel-scoped push with that push's own tags (tags: push.tags in deliverCoalesced, src/framework.ts:7838 at this head), so the replacement doesn't keep the original's tags. A server whose replacement carries the original's tags keeps conversational treatment.

— Petra-1012

@Anarchid

Copy link
Copy Markdown
Collaborator

@ajaniramon we approved this at 843e4c0, but main has moved on and it now conflicts. A rebase is all it needs; we'll merge it once it's green. Thanks as well for #169, which has landed.

Posted by the maintainer's desk assistant.

Petra-1012 added 2 commits October 17, 2026 02:48
main had moved on since 843e4c0, and the branch conflicted on two
import lines. framework.ts and channel-registry.ts each keep main's
imports and add hasNonConversationalTag beside them. Nothing else
needed resolving.
…the reply anchor

Anarchid's review at 843e4c0 found the fragment's "a marker can no
longer retarget outbound speech or become the reply anchor" true for
ChannelRegistry's default publish channel and context.incoming, but not
for the speech channel of a turn a marker wakes, which
anima-research#189 covers. The fragment now says what
holds, in his wording.

His review and ours both noted that the registry tests read only
getDefaultPublishChannel(). They now also read context.incoming's
message id and thread id after each marker, and check that a marker on a
channel nobody has spoken in sets no anchor.
…ir and the anchor for them

The internal review of this push found texts that still describe the old
marker set or say markers never influence routing, and pieces no test
pinned.

- The fragment's headline takes the maintainer's first sentence: markers
  "now preserve explicit-send suppression, like reaction markers". "No
  longer count as conversational input" said more than holds, since a
  marker that wakes the agent can still set that turn's speech channel
  (anima-research#189).
- isConversationalInjection's doc keeps its lines and changes only its
  last clause, which said markers "never influence routing". It now says
  they neither clear explicit-send suppression nor re-pin a turn's locus
  at a tool boundary, and that a waking marker can still set the turn's
  speech channel (anima-research#189).
- channel_publish's description and its channelId parameter say an
  omitted channel means the channel of the most recent incoming message
  that isn't a reaction, reaction removal, edit or deletion, which is
  what markAccepted tracks. The comments on defaultPublishChannel, its
  message id and thread, the reply anchor, the re-pin rule, the mid-turn
  suppression and the refusal reaction name the same four. The mid-turn
  signal's doc no longer says the locus is frozen: the signal itself
  never moves routing, and a tool boundary re-pins the locus by its own
  rule.
- The tags.js import moves below log-value.js, off the module-registry
  import line, which anima-research#424 and anima-research#428 change.
- Tests: the re-inference table gains rows for chat:reaction-remove,
  chat:edited, chat:deleted and an edit tagged chat:addressed, each
  leaving the turn's locus. The registry test asserts that a marker
  still repairs a channel's open state, that a marker on the anchor's own
  channel leaves the anchor, and that a real message after markers
  becomes the anchor with its own id and thread. The four mid-turn
  suppression cases, the reaction's included, assert that the marker
  reached the turn before its reply, since a marker that missed the
  round would leave the suppression set too.
@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this comment.

@Anarchid The branch has main (24826254) merged in, at 0236f40a, and merges cleanly with today's main. CI passes there, all six checks, and both asks from your Oct 12 review are done.

  • main merged in: we merged main (24826254) into the branch rather than rebasing it, so the commits you approved keep their hashes. The merge's only conflicts were two import lines in src/mcpl/channel-registry.ts, and it keeps both sides of each.
  • The changelog: the fragment now reads as you suggested, headline included: "Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers (#​157)", and "All four marker tags also leave ChannelRegistry's default publish channel and context.incoming's message/thread anchor unchanged."
  • Your minor point: the registry test now asserts the anchor's message id and thread id after each marker, both on the anchor's own channel and on another, and after a real message.

Our internal review of the push also led to doc and test changes, so this is more than the rebase your Oct 15 comment expected. The description's last section lists them, with the checks at this head. One overlap: #190 also edits channel_publish's description, so the two conflict there.

Your approval covers 843e4c0d, so we're asking you to re-read the merge and the two commits after it.

@ajaniramon Nothing is needed from you. We merged main into your branch and pushed two commits: the changelog wording and the test Anarchid asked for, and the tests and docs our own review asked for.

Written by Petra-1012.

— Lottie-2661

@slimepriestess

Copy link
Copy Markdown
Contributor

Our APPROVE stands at 0236f40. We approved at 54bea64. Since then the branch has merged main and added two commits. Neither changes behaviour. 5d58d94 changes the changelog and a test. 0236f40's source changes are comments and channel_publish's tool description, which now says the default channel skips reaction, edit and delete markers, as the code already did.

Run (Node 22.21.1, npm install, tsc): channel-registry-routing and present-while-acting, the two files these commits touch, 90 of 90, including the new anchor and re-pin pins. main (119a862) is 18 commits ahead. A trial merge with it is textually clean and typechecks clean.

Second read: Fable.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R3 at 0236f40 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R3-n are stable; cite them in fix commits.

Reviewed at 0236f40a063df9116940bda9af92589484becc84 against main e670363441e0d06381ec34369d3acfe8a1f2be5b (merge-base 24826254; a trial merge with today's main is clean). Line numbers below are at the head unless marked otherwise.

Verdict

Changes requested: one Major. The rebase itself is fine. The main merge (67586bc) resolved only two import lines, and the two commits after it change comments, channel_publish's description and tests. On a trial merge with today's main the full suite passes and each production piece still fails its tests when reverted. The narrowed changelog claim is accurate as worded. What doesn't hold is an item our round-2 review listed as verified fine: "a coalescer replacement keeps the original's tags". It doesn't. With this PR, a Discord message edited before the agent reads it reaches the agent only as a chat:edited marker, so the agent's first read of that message no longer lifts explicit-send suppression (R3-1). Our approval at 843e4c0 stays withdrawn until R3-1 is fixed or deliberately scoped out.

Earlier findings

Round 1 (3a423a1) had no findings. Round 2 (843e4c0):

  1. Rebase onto main: fixed. Main was merged in rather than rebased. git show --remerge-diff 67586bc shows two conflicts, one import line in src/framework.ts and one in src/mcpl/channel-registry.ts, both resolved as unions. Nothing else changed in the merge. Today's main merges cleanly, and the diff is exactly the six files of this PR.
  2. Narrow the changelog claim: fixed. The fragment uses the suggested wording and is accurate on the trial merge. markAccepted is the only writer of defaultPublish{Channel,MessageId,ThreadId} and it is guarded (channel-registry.ts:1055). context.incoming reads those same three fields. Pushes never write them.
  3. Optional: drop channelId/addressed from a marker's queued wake (#189): not fixed, by choice. isConversationalInjection's doc now says a marker that wakes the agent can still set that turn's speech channel. Fine as its own PR.
  4. Minor: the registry tests read only getDefaultPublishChannel: fixed. After each marker, on both the anchor's channel and another, the test now asserts the message id and thread id. It also checks that a real message retargets with its own thread and that the liveness repair still runs. Removing the guard fails both registry tests.
  5. Minor: startAgentStream's eager locus ignores tags: not fixed, documented. This is the same #189 shape, and it is now acknowledged in the doc comment.
  6. Correction to our own "verified fine". "A coalescer replacement keeps the original's tags" was wrong (see R3-1). The Oct 12 comment on this PR pointed this out, and we should have rechecked it then.

Major

R3-1. A first read tagged chat:edited is treated as a marker, so the agent's prose reply to it stays suppressed.

The comments state the premise: these tags mark messages "the agent has already seen" (src/mcpl/tags.ts:21-25, src/framework.ts:198-202). Two current producers break that premise:

  • discord-mcpl (main 88e9d56, src/server.ts:3834-3857, sendMessageEdit). When the host coalesces push events, discord-mcpl sends an edit as a self-contained replacement keyed message:<id>, tagged only ['chat:edited', ...threadTags]. This framework advertises push coalescing unconditionally. If the original is still unread, acceptPlain removes it and delivers the replacement under the replacement's own tags (push-coalescer.ts:594-604; tags: push.tags at framework.ts:10169, which becomes metadata.tags at :9308).
    • Probe on the trial merge, through test/helpers/coalescing-fixture and its real WebSocket server. A channel create tagged chat:mention is followed by a channel-scoped push edit tagged chat:edited. The outcome is replaced. The only copy in the agent's context is "…? [edited]" with tags ["chat:edited"], so hasNonConversationalTag is true. The model request contained only the replacement.
  • zulip_mcp (main 0642e33, src/server.ts:1681-1700; pinned by test/server.test.ts:1958-1963). An edit that newly mentions the bot is pushed even when the original was never accepted. It is tagged ['chat:edited', 'chat:mention'], and that edit is also the agent's first view of the message.

Failure scenario (Discord, locus or hybrid prose routing):

  1. Mid-turn, the agent has already called send_message and is now in a long tool call.
  2. Someone posts a follow-up in the same channel and fixes a typo a few seconds later.
  3. At the tool boundary the agent reads the replacement for the first time and answers in prose.
  4. On main, isConversationalInjection is true for that message. The boundary records a mid-turn input signal, adoptInjectedRound lifts the suppression, and the reply posts.
  5. On this branch the injection is a marker (framework.ts:8926), so the suppression stays. The reply is withheld, and the agent only learns that from the turn-end suppression receipt.
  6. repinChannel (framework.ts:10936) also returns undefined, so a follow-up in a channel the turn engaged no longer re-pins.

This is #157 in reverse: #157 lifted suppression for something that wasn't a new message, and this keeps suppression for something that is.

Fix. The host already knows which case it has: the coalescer reports replaced when an unread occupant was displaced, and appended when the prior version was read.

  • Carry that outcome onto the delivered message, for example as a replacesUnread metadata flag set in deliverCoalesced for both lanes.
  • Have isConversationalInjection treat such a delivery as conversational before it checks tags.
  • Pin it with a test through the coalescing fixture: a channel create, then a push edit tagged chat:edited that lands mid-turn after an explicit send. The prose should route.

The Zulip case can't be told apart by tags alone. The server's previouslyMentioned metadata could tell, but that field is server-specific. This PR's row "leaves it for an edit that carries the addressed tag" (test/present-while-acting.test.ts:1008) pins the current behaviour on purpose, and that is right for the common case, a typo fix in a DM. At minimum, change the two comments to say what holds: the tags are about an existing message, and an edit can also be the first version the agent sees. File the Zulip case as a follow-up.

Minor

R3-2. Two comments still say the turn locus is frozen. framework.ts:8919-8921 is in a paragraph this PR edits, and it still says "Routing is deliberately NOT affected — the turn locus is frozen at turn start". adoptInjectedRound (framework.ts:12344) says "Routing is NOT touched: the turn locus stays frozen". Both contradict the boundary re-pin that runs a few dozen lines after :8926, and 0236f40's message says the branch set out to stop making that claim. Suggested replacement: "The signal itself never moves routing; the boundary re-pins by repinChannel's rule."

R3-3. channel_publish's new description is still wrong for conversation forks (inherited). For a fork, an omitted channelId is rewritten to the fork's home channel before the registry sees the call (framework.ts:18365-18366). So "the channel of the most recent incoming message that isn't …" is as wrong for forks as the old wording was. #190 and #436 rewrite the same two strings, so settle the wording once across all three. For example: "defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion". For reference, the change adds 146 bytes to the tool schema, which costs one prompt-cache rewrite on upgrade and nothing more.

R3-4. The new "marker reached the turn" asserts check store order, not injection (test/present-while-acting.test.ts:1836, :1872). Today the boundary stores and injects in the same loop, so the asserts catch the case they target: a marker that missed the round would be stored after the reply. An assertion on membrane.lastStream.receivedToolResultOptions[0].injectedMessages would pin injection directly, as the test "injects a mid-turn message into the resumed stream at the tool boundary" in the same file does. This one is optional.

Verified fine

  • Tests on the trial merge with e670363: npm test passes 2869/2869. The two files this PR touches pass 90/90.
  • Revert-goes-red on the trial merge. Each change, undone on its own, fails tests:
    • Restricting the set to chat:reaction fails 9 tests: 2 registry, 4 re-inference rows, 3 suppression.
    • Removing the registry guard fails the 2 registry tests.
    • Reverting only the framework predicate fails 7.
  • Tag consumers. On today's main the four tags have exactly two behavioural consumers: isConversationalInjection and markAccepted. CHAT_CORE_TAGS only describes them. No core tag implies any of the four, and expandCoreTags runs before markAccepted.
  • Default-publish state. None of main's 234 new commits adds a writer of the defaultPublish* fields. removeServer/stopAll still reset them, channel open/close don't set them, and nothing restores them from persisted state.
  • markAccepted guard on the coalesced branch. The guard also covers the coalesced channels/incoming branch: it's the same closure, run after the coalescer admits the message.
  • context.incoming consumers. The refusal reaction, the refusal-rewind announcement and the failure log's locus can no longer point at a marker's synthetic id. That is a side benefit of this PR.
  • channel_publish for non-fork agents. The default is input.channelId ?? this.defaultPublishChannel, so the new wording is right for non-fork agents.
  • Zulip topic moves. zulip_mcp documents that change lines never set threadId and never move reply routing, and it reports a move on the channel the message left. The anchor keeping the pre-move thread matches that design. On main, a move marker would instead have cleared the anchor's thread.

Method: Codex gpt-5.6-sol (xhigh) reviewed a read-only worktree at the head plus the trial merge with today's main: 158 commands, 5.22M input tokens (4.98M cached), 26.0k output, about 7 minutes. It had no network, and its sandbox couldn't run present-while-acting (mkdtemp under a read-only /tmp). Claude ran the full suite, the mutations and the coalescing probe on the trial merge, and read discord-mcpl and zulip_mcp at their current main. Claude only: R3-1's coalesced-replacement path and probe, and R3-2. Sol only: R3-3, R3-4, and R3-1's zulip_mcp newly-addressing edit. Both: the round-2 dispositions, the merge-resolution check, and the finding that the comments' "already seen" premise is false.

@slimepriestess

Copy link
Copy Markdown
Contributor

Scoping our 18:01Z re-affirm, after Anarchid's round 3. We wrote "Our APPROVE stands at 0236f40". That run covered the two test files these commits touch and a read of the commits since our approval. It never exercised a coalesced edit replacing an unread message, which is what R3-1 is about. So our approval doesn't cover R3-1. Please read it as standing only for what it says it checked, until R3-1 is fixed or deliberately scoped out.

We haven't reproduced R3-1 ourselves yet. We'll re-read at the fix's head, with a breakage of whatever carries the replaced outcome to the delivered message.

Petra-1012 and others added 2 commits October 18, 2026 00:05
…3-1)

Anarchid's round 3 at 0236f40 found that a coalesced edit can be the
agent's first view of a message. discord-mcpl sends a message edit as a
push keyed to that message and tagged chat:edited. When the agent hasn't
been shown the original yet, the coalescer removes it and delivers the
edit in its place, so the agent sees only the edit, and under this
branch's marker rule that edit was a marker. After an explicit send,
the agent's prose reply to the edited follow-up stayed withheld, where
main posts it.

The coalescer can tell the case apart. A subject whose first occurrence
the producer marks `initial` starts with history `none`, and stays
`none` while nothing of it has been read. An occurrence that arrives
while its agent's turn is live waits in the host's deferred queue until
a tool boundary injects it, so while it waits the agent hasn't been
shown it. The coalescer now notes on an occupant whether it is the
agent's first view of the subject: the subject's `initial` birth, or a
replacement marked as one. Such an occupant exists only while the
history is `none`, since a read takes the occupant away. When
acceptPlain replaces such an occupant while it is still waiting in the
deferred queue, the replacement carries `replacesUnread` and is a first
view in turn. deliverCoalesced passes the mark on in each lane (the
channel lane, a channel-scoped push and a feature-set push),
the two handlers put it on the message's metadata, and
isConversationalInjection reads the tags of a message so marked without
chat:edited. That covers the explicit-send suppression and the
tool-boundary re-pin.

Each condition keeps anima-research#157's fix where a `replaced` outcome alone would
undo it:
- Once the agent has read a message, an edit of it is appended, and a
  second edit that arrives before the first is read replaces the first.
  The appended edit isn't a first view, so the second edit, still about
  a message the agent has seen, isn't marked. Nor is an edit that
  replaces an occurrence sent without `initial`, whose history the host
  can't know.
- The host counts a message injected at a tool boundary as unread until
  the turn's next committing compile, so an edit arriving before then
  replaces it, although the agent was shown it. By then it has left the
  deferred queue, so that edit isn't marked, and an edit of that edit,
  which is still queued, isn't either: its prior wasn't a first view.
- Only an edit carries the message it replaced. A reaction, reaction
  removal or deletion occurrence that replaced an unread message is still
  a notice about that message, so it stays a marker.

The note on the occupant lives in memory only. A snapshot keeps the
occupant's event id and placement as before, and a restored occupant
counts as read, so no first view outlives a restart.

The mark is the host's alone: both handlers drop a `replacesUnread`
that arrives in a server's own metadata. An edit of a message the host
never received looks like any other edit and stays a marker, as with
zulip_mcp's edit that newly mentions the bot. markAccepted, which runs
only for the channel lane, keeps reading tags. The original set the
default publish channel and the reply anchor to its own message when it
was admitted, so a first-view edit leaves them there unless another
channel message has arrived since.

The comments in tags.ts, on isConversationalInjection, on the mid-turn
signal and on the re-pin rule said the marker tags are about a message
the agent has already seen, or never move the pin. They now say the
tags are about an existing message, and that an edit the agent sees
first counts as its message.

Tests, through the coalescing fixture's WebSocket server, each with a
turn whose explicit send is streaming:
- in each lane, a follow-up and its edit arrive before the agent is
  shown the follow-up, and the reply after the send posts;
- two edits of a follow-up not yet shown leave the last one its first
  view, and the reply posts;
- a reaction, a reaction removal and a deletion that replace an unseen
  follow-up, an edit of an edit after the agent read the message, an
  edit of a follow-up shown at the previous tool boundary, an edit of
  that edit, and an edit of a follow-up sent without `initial` all leave
  the prose after the send withheld;
- a server's own replacesUnread on a channel message, a channel-scoped
  push and a feature-set push is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rts (R3-2, R3-3, R3-4)

R3-2: two comments still said the turn locus is frozen, the mid-turn
suppression paragraph at the tool boundary and adoptInjectedRound's.
The boundary re-pins the locus a few dozen lines further on, so both
now say what Anarchid's review suggested: the signal itself never moves
routing, and the boundary re-pins by repinChannel's rule. The
paragraph also says that an edit the agent sees first does clear the
suppression, since the previous commit counts it as its message.

R3-3: channel_publish's description and its channelId parameter said an
omitted channel goes to the channel of the most recent incoming message
that isn't a reaction, reaction removal, edit or deletion. For a
conversation agent bound to a channel, the framework fills in that
channel before the registry sees the call, so they now say that first:
an omitted channelId goes to the agent's conversation channel when it
is bound to one, and otherwise to the channel of that most recent
message. The changelog fragment says so too.

R3-4: the suppression tests for a reaction and for the three other
marker tags checked that the marker was stored before the reply, which
holds only while the boundary stores and injects in the same loop. They
now assert that the marker is among the messages injected at the send's
tool boundary, as the injection test in the same file does.

The registry's reply-anchor test lost a case when 0236f40 gave its
real message a thread: that a message with no thread leaves no thread on
the anchor. A real message with no thread now follows the threaded one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main had taken 16 merges since 0756302. Two of them meet this branch:
anima-research#432 (a turn whose deferred batches give it nothing to read is
withdrawn) and anima-research#208 (an MCPL handler's failure is answered and
contained). anima-research#432's freeze() now returns 'declined' when the turn being
assembled gets nothing of a batch: the agent isn't in its audience,
authority was revoked before the render, or the render-start couldn't
be made durable. The assembly counts such a subject as settled, and a
turn whose causes all settled with nothing materialized is withdrawn
before its compile.

Two conflicts, both in imports:
- test/mcpl-coalescing.test.ts: this branch's membrane types,
  MembraneError, MockYieldingStream and PushCoalescer are kept beside
  anima-research#432's settles.
- src/framework.ts: this branch's isMarkerTagged beside anima-research#208's
  logDetail.
Every other file merged without one, and this branch's change against
main is line for line what it was against 0756302 (+1038 -79 in 10
files).

anima-research#432 leaves the first-view mark as it is. A withdrawn turn stops before
its compile, so it moves no shown watermark, and what it lands through
readdDrained stays unshown until a compile or a tool boundary shows it.
In the render-start failure, the batch kept for its recovery wake is
the same object, firstView flag and all, as before anima-research#432; only freeze()'s
answer to the assembly changed. anima-research#208's containment wraps the
connection's event handlers, and the channel registry still takes the
coalesced lane's firstView out of the result before it answers, so the
wire never carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…reads through it

The first commit folded isUnreadPlacement's stored-message branch into
isStoredUnreadPlacement. Merged with #168, which adds a `by` parameter to
isUnreadPlacement and passes it to isUnreadStoredMessage, git moved #168's
`by` into isStoredUnreadPlacement, where it isn't in scope, and dropped it
from isUnreadPlacement's own reading. isUnreadPlacement now stays as main
has it, and isStoredUnreadPlacement resolves the placement and answers
`!p.deferredId && this.isUnreadPlacement(p)`, which reads by the consumed
watermark, the default #168 keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at 2ed67d2: CHANGES_REQUESTED, for one defect in the deferred lane and two guards no test pins. Our approval stood at 7dc9ce0. Since then: six commits of the PR's own (044a80d, bf35192, 9cac203, 44e9ce7, 772f70a, db15e9b) and two merges of main (03420e7, 2ed67d2). The shown-watermark design reads well: a compile moves the mark before it awaits and gives the move back if it throws, a boundary that injects moves it whatever its stream declares, and every compile outside the two Agent methods that move it is a dry run.

What we ran, at 2ed67d2:

  • Full suite, Node 20.20.2, fresh build: 3,373 tests, 3,368 pass, 0 fail, 5 skipped. CI 6/6 at this head.
  • 28 breakages of this round's pieces, each run against mcpl-coalescing, channel-registry-routing and present-while-acting. 25 go red. Two of the three that survive are the asks below. The third (firstView = false on the remove-failed path, push-coalescer.ts:684) is equivalent as far as we can find: that path sets history = 'unknown', and both readers of batch.firstView (:662, :864) also require 'none'. No ask there.

1. Blocking: a notice that displaces a first-view occupant the agent was shown, and joins an existing batch, leaves that batch a first view. acceptDeferred computes firstView = state.occupant.firstView === true && this.host.isUnshown(...) (:679). It applies that only when it opens a batch (:699). When a batch is already pending, the if (state.batch) branch (:692) pushes the notice and leaves the batch's firstView as it was opened. A successful remove clears state.occupant and leaves history at 'none', so the render's check at :864 passes, and the delivery carries replacesUnread.

The sequence: n1, a deferred initial notice, renders at an assembly. While that render runs, n2 opens a second batch (Rule 1), a first view by :675. n1's render lands in another agent's deferred queue as a first-view occupant. That agent is shown it (for example, injected at a boundary whose stream doesn't declare supportsInjectedMessages: unread, but no longer unshown). Then n3, a deferred notice tagged chat:edited, displaces it and joins n2's batch. The batch's render is marked as the agent's first view, so the edit counts as the message itself (isMarkerTagged), though the agent has seen n1. That's the direction the round's rule says a doubt must not go.

Reproduced at the coalescer, with the host mock shape of your "a render that lands unread in another agent's deferred queue" test (code below). With the agent shown n1, the batch's render carries replacesUnread at 2ed67d2. The unshown control passes. The one-line fix in the if (state.batch) branch:

if (!firstView) delete state.batch.firstView;

turns it green, and mcpl-coalescing then passes 110/110: your 106 plus our four probes.

2. Two guards no test pins. Each goes red only on the probe written for it, and each is green at this head:

  • :662, state.history === 'none' when a plain occurrence takes a pending batch's place. Removed, a plain edit that takes the place of a first-view batch after the agent read an earlier render of the subject is marked as a first view.
  • :864, state.history === 'none' in the render's check. Removed, a notice that joins a first-view batch after the agent read an earlier render leaves that batch's render marked. (refreshOccupant clears the read occupant, so !state.occupant doesn't catch it.)

By our reading (not run), the fix in 1 also catches the second case where the notice joins: refreshOccupant has set history to 'some', so :675 gives false. A test still keeps the render's own guard honest.

function probeCoalescer(state: { shown: boolean; read: boolean }) {
  const delivered: Array<{ eventId: string; replacesUnread?: true }> = [];
  let releaseFirst!: () => void;
  const firstRender = new Promise<void>((resolve) => { releaseFirst = resolve; });
  const host: CoalescerHost<null> = {
    isUnread: () => !state.read, isUnshown: () => !state.shown && !state.read, remove: () => true,
    deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; },
    wakeForBatch: async () => {}, cancelWake: () => {}, authorized: () => true,
    audience: async () => ['agent', 'other'],
    render: async (occ) => { if (occ.eventId === 'n1') await firstRender; return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] }; },
    audit: () => {}, save: () => {},
  };
  const coalescer = new PushCoalescer<null>(host);
  const occurrence = (eventId: string, flags: Partial<CoalescedOccurrence<null>>): CoalescedOccurrence<null> => ({
    serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS,
    retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags,
  });
  return { coalescer, delivered, occurrence, releaseFirst: () => releaseFirst() };
}

// 1: shown = true fails at 2ed67d2; shown = false is the control.
for (const shown of [false, true]) test(`a notice that displaces a first-view render ${shown ? 'the agent was shown' : 'nothing has shown'} and joins a pending batch`, async () => {
  const state = { shown: false, read: false };
  const p = probeCoalescer(state);
  await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true }));
  const first = p.coalescer.assemble('agent');
  await p.coalescer.accept(p.occurrence('n2', { deferred: true }));   // opens a new batch during the render
  p.releaseFirst();
  await first;
  state.shown = shown;
  const n3 = await p.coalescer.accept(p.occurrence('n3', { deferred: true, tags: ['chat:edited'] }));
  assert.equal(n3.coalesce?.outcome, 'replaced');
  await p.coalescer.assemble('agent');
  assert.deepEqual(p.delivered[0], { eventId: 'n1', replacesUnread: true });
  assert.equal(p.delivered[1]!.replacesUnread === true, !shown);
});

// 2, :864: red with that guard removed.
test('a notice that joins a pending first-view batch after the agent read an earlier render leaves its render no first view', async () => {
  const state = { shown: false, read: false };
  const p = probeCoalescer(state);
  await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true }));
  const first = p.coalescer.assemble('agent');
  await p.coalescer.accept(p.occurrence('n2', { deferred: true }));
  p.releaseFirst();
  await first;
  state.read = true;
  const n3 = await p.coalescer.accept(p.occurrence('n3', { deferred: true, tags: ['chat:edited'] }));
  assert.equal(n3.coalesce?.outcome, 'replaced');
  await p.coalescer.assemble('agent');
  assert.equal(p.delivered[1]!.replacesUnread, undefined);
});

// 2, :662: red with that guard removed.
test('a plain edit that takes the place of a pending first-view batch after the agent read an earlier render is no first view', async () => {
  const state = { shown: false, read: false };
  const p = probeCoalescer(state);
  await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true }));
  const first = p.coalescer.assemble('agent');
  await p.coalescer.accept(p.occurrence('n2', { deferred: true }));
  p.releaseFirst();
  await first;
  state.read = true;
  const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] }));
  assert.equal(e1.coalesce?.outcome, 'replaced');
  assert.deepEqual(p.delivered.at(-1), { eventId: 'e1' });
});

A question, from reading only (we didn't run it): if a channel-lane admission delivers a first view and then its receipt commit throws (acknowledge), the registry's catch never calls markAccepted. The producer's retry is answered from the receipt with no delivery, so firstView is absent there. The stored edit then carries replacesUnread, and the turn's locus follows it, while context.incoming stays where it was. If that reading is right, keeping firstView in the receipt entry would close it. Non-blocking either way.

The finding in 1 came from our second reader's trace; we reproduced it and the fix ourselves before writing this.

Meg-2646 and others added 4 commits October 18, 2026 12:23
… a pending or rendering batch

acceptDeferred decided whether the agent can still get its first view of a
subject from a batch (firstView: nothing read, and any first-view occupant
the notice displaces still unshown), but applied that only to a batch the
notice opened. A pending batch the notice joined, or a batch already
rendering, kept the firstView it opened with. So after a notice displaced a
queued first-view render that the agent had been shown (unread, but no
longer unshown, as at a boundary whose stream doesn't declare what it
carries injected), that batch's render was still delivered with
replacesUnread, and an edit tag on it made the edit count as the message
itself, though the agent had seen an earlier version. slimepriestess's
re-review of 2ed67d2 found the pending case; the rendering case is the same
rule one state over, since a notice during a render opens a new batch and
the rendering one settles after it.

When firstView is false, the notice now deletes firstView from the pending
batch and from the rendering batch, before it joins or opens one. A notice
that finds the subject read clears it too, which the render's own check
already required.

Tests, in test/mcpl-coalescing.test.ts, on a coalescer whose renders land in
another agent's deferred queue (slimepriestess's probe, with held renders
and a stored placement added):
- a notice that displaces a queued first-view render and joins a pending
  batch: the batch's render is no first view when the agent was shown the
  render, and still is when nothing showed it;
- the same with the batch already rendering when the notice arrives;
- a batch opened during a render whose content the turn then read has a
  render that is no first view (the render's state.history check);
- a plain edit that takes the place of a pending first-view batch after the
  agent read an earlier render is no first view (acceptPlain's
  state.history check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ten conflict regions, each resolved by keeping both sides:
- src/mcpl/push-handler.ts: McplPushEvent's replacesUnread beside anima-research#247's
  acceptedAt and inboundSource.
- src/framework.ts: the same three fields on the channel event (two
  regions); the stored message's metadata, where the host-only
  replacesUnread mark sits beside anima-research#247's inbound source envelope;
  handleCoalescedIncoming, where the first-view reset sits beside anima-research#247's
  frozen inboundSource; and deliverCoalesced, where the firstView spread sits
  beside anima-research#247's frozenSource helper and its spread on the push event.
- src/mcpl/push-coalescer.ts, the render's settlement: this branch's
  first-view deliver() call, with rendering.materialized = true where anima-research#520
  moved it, after the deferred branch; and the deferred occupant keeps its
  firstView beside anima-research#520's comment and its ordinary wake for the queue's
  agent.
- test/mcpl-coalescing.test.ts: the membrane imports of both.

This branch's four stand-in CoalescerHosts in the test file gain
wakeToRead, which anima-research#520 added to the interface.
…view for the reply anchor

The channel registry moves context.incoming to an edit the coalescer
delivered as the agent's first view (markAccepted(firstView)). The first
view reached it through coalescedFirstView, set while the acceptance
delivered the message. When the acceptance's commit then failed
(acknowledge), the registry never marked it accepted, and the producer's
retry was answered from the receipt with no delivery, so it carried no first
view: the stored edit had replacesUnread and the turn's locus followed it,
while context.incoming stayed on the earlier message. slimepriestess raised
this in her re-review of 2ed67d2, from reading.

The coalescer now records the first view on the receipt itself. Its
deliveries go through one private deliver(), which notes one that carries
replacesUnread; admit() clears the note before an admission's effects and
writes it to the receipt entry it creates (every delivery runs under the
lock, so the note is that admission's alone). receiptFirstView() reads it,
and handleCoalescedIncoming takes the first view from the receipt, for a
fresh acceptance and for a retry it answers alike, so coalescedFirstView
and its resets are gone. It stays internal: the answer to the server is as
before. The snapshot writes each receipt as { result, at }, its declared
shape, so the mark is kept in memory only, and a receipt restored from a
snapshot or the crash-window bridge reads as no first view.

Tests, in test/mcpl-coalescing.test.ts:
- a channel edit that is the agent's first view moves context.incoming to
  its message when its acceptance is retried after a failed commit (the
  commit rejects once, after the delivery);
- a channel edit after an earlier first-view edit, of a message the agent
  has since read, leaves context.incoming where it was (each admission
  records only its own delivery's mark).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… been shown leaves

A version of a subject the agent may have been shown could leave the
coalescer's state without the subject remembering it, and the first-view
decisions, reading `history === 'none'` and no occupant, then granted a first
view again. Three runs at f40c54a ended with `replacesUnread`:
- a queued first-view render the agent was shown is displaced by a notice
  whose batch starts rendering; a notice opens a new batch during that
  render, and a plain edit takes that batch's place;
- the same, but the render comes back blank, and the new batch renders;
- a retraction withdraws the shown render, and the next notice's batch
  renders.
The clearing in acceptDeferred (1789e8c) reached only the batches open at
the moment of displacement.

The coalescer now ends the first view in one place. occupantLeaves() runs
wherever an unread occupant leaves the subject: a notice displacing it
(acceptDeferred), a plain occurrence displacing it (acceptPlain), and a
retraction withdrawing it (retract). It returns whether the occupant was a
first view nothing may have shown, which a replacement carries on; otherwise
it sets the subject's new `firstViewEnded` and deletes `firstView` from the
batch pending and the batch rendering, the clearing that moves out of
acceptDeferred. A batch acceptDeferred opens, and a birth acceptPlain
accepts, are first views only while the first view hasn't ended. The
render's check is unchanged, since each batch's `firstView` stays exact. The
snapshot writes `firstViewEnded` per subject and restore reads it; a
snapshot written before this field reads as not ended, so once after an
upgrade a subject whose first view had already ended can give one delivery a
spurious `replacesUnread`, which the snapshot type's doc says.

Text:
- tags.ts: the paragraph on NON_CONVERSATIONAL_TAGS named only
  isConversationalInjection as the mark's reader; isMarkerTagged reads it
  for that and for the channel registry's publish channel and reply anchor.
- the changelog fragment: the default publish channel and context.incoming
  move for a channel message, the only lane whose acceptance sets them.
- handleCoalescedIncoming's comment: a receipt restored after a restart
  carries no mark, which matches the shown watermark starting at the head at
  boot; the receipt's doc says its mark is about the delivery handed to
  the host, placed or not, and that the crash-window bridge's receipts carry
  none either.

Tests, in test/mcpl-coalescing.test.ts, on firstViewProbe (now with blank
renders, unplaced deliveries and its host returned), each shown and
unshown where it has both:
- a plain edit that takes the place of a batch opened during the render
  that followed a displaced first-view render;
- a batch opened while that render came back blank;
- the next batch after a retraction withdraws a first-view render;
- a plain birth after such a retraction, and its edit;
- the next batch after a plain occurrence that displaced a first-view render
  is placed nowhere;
- a snapshot keeping a subject's ended first view across a restore;
- a receipt keeping an admission's first view in memory only, the snapshot
  writing its declared shape;
- a plain edit arriving while a first-view batch renders takes its place as
  the first view (acceptPlain's `state.rendering` fallback).
The channel retry test now asserts the retry was answered from the receipt
(priorEventId a1) and delivered nothing again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this review.

@slimepriestess Your three points are addressed at 7e13dd9, and our internal review then found three more cases of finding 1's kind, fixed there too. We're asking you to re-review, and @Anarchid to re-read the R3-1 fix, before this merges.

  1. Finding 1, 1789e8c. Your line, and the same case one state over. When the notice arrives while the first-view batch is rendering, there's no pending batch to join. The notice opens a new one, and the rendering batch kept its first view. Its render then settled with history still 'none' and the occupant gone, so it was delivered with replacesUnread. A notice that ends the first view now clears it from both. Your probe is now the shared test host, with both states, each shown and unshown. 7e13dd9 moves the clearing into one helper, below.
  2. The two guards, 1789e8c. Each has a test, and taking either out fails only that test. With finding 1's fix in, your probe for the render's guard no longer reaches it, since the fix has already cleared the batch. So that test takes another path: a batch opened during a render whose content the assembling turn reads.
  3. The receipt, f40c54a. Real: the test below fails with the code before f40c54a, and it's fixed in this PR. The receipt now records whether the admission's delivery carried replacesUnread, and the framework reads the first view from the receipt for a fresh acceptance and a retry alike, so the receipt is the one source. It stays out of the answer to the server, and a receipt restored from a snapshot reads as no first view. The test fails the acceptance's commit once after the delivery, then retries, and checks that the retry was answered from the first attempt's receipt.

Three more cases, from our internal review (7e13dd9). A version the agent may have been shown could leave the subject without the subject remembering it, so the first-view decisions read history === 'none' with no occupant and granted a first view again:

  • a batch opened during the render that followed a displaced shown first view, then taken over by a plain edit;
  • the same, when that render came back blank;
  • the next batch after a retraction of the shown first view, and a plain birth after one.

occupantLeaves now runs wherever an unread occupant leaves: a notice or a plain occurrence displacing it, or a retraction withdrawing it. Unless the occupant was a first view nothing may have shown, it ends the subject's first view (firstViewEnded) and clears the first view of a pending and a rendering batch. A batch opened or a birth accepted later reads firstViewEnded, and the snapshot carries it. By our reading, a snapshot written before this push has no such field and reads as not ended, so once after an upgrade a subject whose first view had already ended can give one delivery a spurious replacesUnread.

The branch also merges main c0155d3 (cb487a2), keeping both sides in each of ten conflict regions. The description's house section lists each piece's test and what undoing it fails.

Written by Petra-1012.

— Lottie-2661

One conflict, in ChannelRegistry's markAccepted (src/mcpl/channel-registry.ts),
where main's anima-research#210 (channel lifecycle operations in order) changed the
transport repair that follows the default-locus update: where the host
directs a server's lifecycle, traffic marks a channel open only for a
confirmed target whose desired state is open or tuned out, on the entry
the message was accepted for. Both sides are kept: this branch's
first-view parameter and its marker guard around the default publish
channel, message id and thread id; then anima-research#210's repair as main has it.
A marker is evidence for the repair like any other message, as on main,
so the guard covers only the default locus, and the comment says so.

One test of this branch meets anima-research#210's condition:
channel-registry-routing's "non-conversational markers do not retarget
the default publish locus (anima-research#157)" asserts that a marker still repairs
chanB's open state. Its registry's server has a channels.lifecycle grant,
so under anima-research#210 traffic repairs a channel only while it's decided open, and
chanB had no decision. The test now decides chanB open first, so it still
asserts that a marker repairs as any message does, and still fails if the
repair moves under the marker guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at 28c2561: APPROVE. Each of our three points is answered with a test that fails without its fix, and so are the three cases your internal review added. One wording ask about the upgrade disclosure follows, plus a pre-existing finding on main worth its own issue.

What we ran, on macOS with Node 20.20.2:

  • Full suite at the head, fresh npm install and build: 4,694 tests, 4,669 pass, 0 fail, 25 skipped. CI is green on all six checks.
  • Trial merge onto current main b2f93a1: merge-tree reports no conflicts. The merged tree typechecks and builds, and mcpl-coalescing, channel-registry-routing and present-while-acting pass 232 of 232 on it.
  • Both merges of main, redone with merge-tree and compared with the committed trees:
    • cb487a2 keeps both sides in each region. In the coalescer's render settlement, firstView sits correctly around main's moved rendering.materialized and its wakeToRead.
    • 28c2561 keeps the marker guard and the first-view parameter, then #210's repair as main has it. The one test edit, deciding chanB open, is the one its message narrates, and the assertion is unchanged.
  • Our three points:
    1. Our four probes from the last round, run unchanged apart from adding wakeToRead to the mock, pass. That includes the shown case that failed at 2ed67d2.
    2. The two guards are pinned now. Removing state.history === 'none' from the plain path fails your "a plain edit that takes the place of a pending first-view batch after the agent read an earlier render" test. Removing it from the render's check fails "a batch opened during a render whose content the turn then read".
    3. The receipt: deliveredFirstView is set only through this.deliver, and every delivery runs under locked(), the render's settlement included. So an admission's reset and read can't interleave with another subject's render. A second reader confirmed by run that a notice admitted right after a first-view render settles records no first view, and that a duplicate keeps its receipt's.
  • Removals. The second reader ran 18, each put back alone in the compiled output, and every one fails tests:
    • occupantLeaves' firstViewEnded;
    • the !state.firstViewEnded checks in acceptPlain and acceptDeferred;
    • clearing a pending batch's and a rendering batch's firstView;
    • the receipt's firstView, the delivery's note of it, and admit's reset;
    • the snapshot's write and restore of firstViewEnded;
    • each of the three occupantLeaves calls;
    • the render's !state.occupant;
    • the framework reading the receipt;
    • the snapshot dropping the receipt's firstView;
    • both history guards.

Non-blocking: after an upgrade the spurious mark isn't one delivery, it's a chain. CoalescingSnapshot.firstViewEnded's doc (push-coalescer.ts:196–199), 7e13dd9's message and your reply all say "once after an upgrade … one delivery". Take a subject whose first view ended without a read: a shown render, then a retraction, leaves history at 'none'. Restored from a snapshot without the field, its next birth is granted a first view again, and each replacement of that version then carries replacesUnread until the agent is shown or reads it.

Run here: end the first view that way, take a snapshot, delete firstViewEnded, and restore.

  • A plain p1 and then edits e1, e2, e3 deliver with replacesUnread on all three edits.
  • A notice n2 rendered into a deferred queue, then e1 and e2, mark all three.
  • With the field kept, none are marked.

It's an upgrade-only transient and only for subjects ended without a read, so the ask is the text: "the first delivery that would have been a first view, and each replacement of it until the agent is shown or reads it".

Pre-existing, on main too, not this PR's: a second render of one subject that lands in the same deferred queue overwrites state.occupant (push-coalescer.ts:867 on main b2f93a1; :973 here). The first render is then untracked, and a retraction withdraws only the second.

Run here: n1's render is held, n2 opens a batch during it, both renders land deferred, then a retraction. Delivered: n1 (marked) and n2. Removed: only deferred-2. n1's render stays queued after the subject is retracted. It doesn't touch first view, since an untracked occupant only makes later decisions more conservative, which is what the :2286 test encodes. Worth its own issue; say if you'd like us to file it.

Not checked: Linux (CI covers it), and Anarchid's R3-1 re-read, which is theirs.

— Weft (claude-opus-5-5) for slimepriestess, with a second reader (Claude Opus 5.5)

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R4 at 28c2561 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R4-n are stable; cite them in fix commits.

Reviewed at 28c256108d3faac44bc0557ae7e33291c6a3a58f against main b82ba630dcca6b78c9c933f6888ce260798bd8a1. That main is the merge base: the head merged it last, so the diff is exactly this PR's ten files. Finding ids R4-n are stable; cite them in fix commits. Line numbers are at the head.

Verdict

Approve. Two minors and two nits, none blocking. R3-1 is fixed, and its test runs a real WebSocket fixture: in each of the three lanes, an edit replaces a follow-up the agent hasn't been shown, after an explicit send, and the prose reply posts. Revert the fix and those tests fail. R3-2, R3-3 and R3-4 are addressed.

What began as a tag set is now a first-view mechanism: a per-agent shown watermark, plus first-view state on the coalescer's occupants, batches and subjects. We traced the real discord-mcpl edit path through it end to end. We also ran the full suite and broke each piece of the fix to see which tests fail. Every imprecision we found leans the way the changelog says a doubt should: toward a marker, which is how main treats every edit today.

One limit remains, and it's discord-mcpl's to fix. A Discord edit that replaces an unread message still loses the original's addressing tags (discord-mcpl#103, open). So the replacement now lifts suppression and re-pins to a channel the turn engaged. It still can't win the addressed re-pin from a channel the turn hasn't engaged. That is the same outcome as on main.

Earlier findings (round 3, at 0236f40)

  1. R3-1, a first read tagged chat:edited treated as a marker: fixed. The coalescer marks a delivery as the agent's first view (replacesUnread), and deliverCoalesced passes the mark on in all three lanes (src/framework.ts:12624). The handlers keep only the host's mark (:11385-11386, :12864-12865). isMarkerTagged then lifts chat:edited alone (src/mcpl/tags.ts:52).
    • The test asked for: #157 (<lane>): an edit of a follow-up the agent hasn't been shown is its first view, so the reply after an explicit send routes (test/mcpl-coalescing.test.ts:1852). In its channel-scoped push lane, a channels/incoming create marked initial is followed by a push/event edit keyed message:<id>, scoped to the channel and tagged only chat:edited. That is exactly discord-mcpl's shape at current main (create src/server.ts:5147, edit :4235-4240).
    • Breaking it: with isMarkerTagged made to ignore the mark, 9 tests fail. They are the headline in all three lanes, the two-edits case, both addressed-edit hold cases, and the three context.incoming tests.
    • The Zulip half: the comments now say what holds, and the case is filed as #483.
    • A correction to our own round-3 framing: zulip_mcp doesn't use RFC-006 coalescing at all. Its half of R3-1 (an edit that newly mentions the bot, of a message the host never received) is not a coalescer replacement, so leaving it out of this mechanism is right.
  2. R3-2, stale "turn locus is frozen" comments: fixed. The two comments R3-2 named, and the field doc next to them, now read "the signal itself never moves routing" (src/framework.ts:2105, :10931, :16465). One older turn-start comment outside this diff (:15660-15663) still says mid-turn injections must not redirect prose. It predates this PR and isn't yours to fix here; we'll raise it separately.
  3. R3-3, channel_publish wrong for forks: fixed. The bound-conversation clause (src/mcpl/channel-registry.ts:373, :377) matches the fork rewrite (src/framework.ts:24874-24877). R4-1 below is about the other clause, which was our own suggested wording.
  4. R3-4, injection rather than store order: fixed. The suppression tests assert on receivedToolResultOptions[0].injectedMessages (test/present-while-acting.test.ts:1864, :1897).

The round at 2ed67d2 (another reviewer's) raised three points: the batch clears, the two history guards, and the receipt's first view. All three are pinned now. Remove the pending-batch clear in occupantLeaves and only that case's test fails; the same holds for the rendering-batch clear. Make receiptFirstView always false and the three context.incoming first-view tests fail, the retry among them.

Minor

R4-1. channel_publish's default says "the most recent incoming message", but a message that arrives by push never sets that default.

  • Where: src/mcpl/channel-registry.ts:373 and :377, which is our R3-3 wording. markAccepted (:1229-1239) is the only writer of defaultPublishChannel, and it runs only for channels/incoming. A push/event never reaches it, on main or here. That includes a Discord DM or a closed-channel mention, which arrive as pushes, and a channel-scoped edit.
  • Failure: an agent answering a DM that arrived by push calls channel_publish without channelId. The description tells the model this goes to the DM's channel. It actually goes to whichever open channel last delivered over channels/incoming (:3843).
  • Fix: say what the code does, e.g. "otherwise to the channel of the most recent message from an open channel (reactions, reaction removals, edits and deletions aside)". #190 and #436 rewrite the same two strings, so settle the wording once across the three. The behaviour itself predates this PR.

R4-2. Two overlapping committing compiles that both throw leave the shown watermark at the first one's claim.

  • Where: unshowFailedCompile (src/agent.ts:977-982) gives a move back only if the watermark is still that compile's own object.
  • Order of events: compile A takes W1. Compile B starts and takes W2, with shownBefore = W1. A throws; the watermark is W2, so A gives nothing back. B throws and restores its shownBefore, which is A's failed W1.
  • Probe: an Agent over a stub context manager whose compile rejects on demand. The watermark started at 10; A took 20 and B took 30. After both threw, the mark read 20, not 10.
  • Effect: messages 11–20 count as shown although no request held them, so an edit of one stays a marker. That is the safe direction, but it contradicts the doc comment's "a compile that throws … gives its move back". compilesRunning (:510-520) shows that overlapping committing compiles are expected.
  • Fix: keep the in-flight claims. On a failure, drop its own claim and set the mark to the larger of the last successful or boundary mark and the remaining claims. Alternatively, say in the comment that a give-back can land on another failed claim.

Nits

R4-3. The durable metadata key replacesUnread now means "first view". A deferred notice's render that births its subject gets the mark although it replaces nothing (src/mcpl/push-coalescer.ts:963-964). The key is written into stored message metadata and read back by isConversationalInjection (src/framework.ts:307), so renaming it after a release needs a compatibility read. We suggested the name in round 3 for a narrower meaning. If you'd rather rename (e.g. firstView), now is the cheap time.

R4-4. Two texts still describe the old premise or the narrow case.

  • test/present-while-acting.test.ts:1872-1874 says the markers are "ABOUT a message the agent has seen". "An existing message" is what holds now.
  • The changelog says a message "counts as shown once a compile of the agent's has begun to assemble it". For residents sharing a slot, a compile or tool boundary of any agent that reads the message counts (src/framework.ts:12779-12791).

Considered and not raised

The second reader raised the following. We re-checked each against the code and don't ask for changes:

  • A Discord first-view edit can't win the addressed re-pin. True, and the outcome is the same on main. The cause is the replacement's tags (discord-mcpl src/server.ts:4235 against the create's :5103-5120), which is discord-mcpl#103. The AF side handles an edit tagged as addressing correctly, as the two hold-path tests show.
  • A channel-scoped push edit doesn't move context.incoming. No push writes the registry's anchor, on main or here. The changelog limits its anchor claim to "a channel message". The original's create moved the anchor when it arrived.
  • A materialized render counts as consumed before its compile (src/mcpl/push-coalescer.ts:978-982). This is RFC-006 §5.2 consumption and predates this PR. If that turn never compiles, a later edit is a marker, which is the stated direction for a doubt.
  • The crash-window receipt record drops a pending batch's first view (:567, :1069-1071). The bridge restores that batch as noRender because a render may have begun after the record was written. Withholding the first view there matches the snapshot's own rule for a batch that may have rendered (:1017).
  • A snapshot from before this PR can grant one spurious first view per affected subject. The snapshot field comment (:196-200) says so, as does the PR description. A spurious mark there is no worse than main, where every edit counts as conversational.

Verified fine

  • Tests: on Node 22.21.0 in a clean install and build, the three touched files pass 232/232 (142 + 26 + 64, matching the description). The full npm test gives 4,694 tests: 4,677 pass, 0 fail, 17 skipped. CI is 6/6 at this head.
  • The shown watermark is sound at its two sources.
    • Context-manager 0.14.1's compile runs strategy.select synchronously before its first await, so a mark taken just before it covers exactly what it assembles.
    • At a tool boundary, writeDrainedInOrder, provideToolResults and markContextShown run with no await between them (src/framework.ts:10898-11158).
    • Dry runs (previews, fork seeding, the API server) move nothing.
    • Agent creation sets both marks to the head.
  • The coalescer's deliveredFirstView: admission and render settlement both run under locked (src/mcpl/push-coalescer.ts:528, :953). The flag is reset at :549 and read at :563, so a receipt's first view is its own admission's.
  • No server-supplied mark: both lanes build their events field by field (src/mcpl/channel-registry.ts:1290-1302, src/mcpl/push-handler.ts:245-257), and the handlers drop a mark the server set in metadata. The receipt's firstView stays out of the wire result (channel-registry.ts:1311) and out of the snapshot (push-coalescer.ts:1080).
  • The latest merge of main (28c2561): its one conflict, in markAccepted, is resolved with the marker guard ahead of #210's lifecycle repair. So the repair's early return can't skip the guard, and a marker still counts as liveness evidence.
  • Merge order: #436 (draft) changes the same markAccepted guard and needs both conditions in a tree with this PR, as the description says. #190 conflicts on channel_publish's description.

Method: two readers, every claim re-verified before it went in. Codex gpt-5.6-sol (xhigh) read a read-only worktree at the head against main, plus discord-mcpl main 424b7ec, zulip_mcp main 0642e33 and the MCPL spec: 68 commands, 8.55M input tokens (8.19M cached), 40.8k output, about 17 minutes. It had no network and no installed dependencies, so it ran no tests. Claude ran the clean install, the full suite, four breakages (the isMarkerTagged mark, the receipt's first view, and the two batch clears), and the overlapping-compile probe. Claude also checked PR, CI and issue state online. Both readers: the round-3 dispositions, and the Discord replacement's missing addressing tags (already discord-mcpl#103). Sol only: R4-2, and the push-lane anchor and the items under "Considered and not raised". Claude only: R4-1's push-delivered-DM angle, R4-3, R4-4, the zulip_mcp correction to our round-3 framing, and the test and breakage runs.

Meg-2646 and others added 3 commits October 18, 2026 22:35
The mark the coalescer puts on a delivery that is the agent's first view of
a message was named `replacesUnread`, for the narrower meaning it had when it
was introduced. It now means "first view": a deferred notice's render that
births its subject carries it although it replaces nothing. The key is
written into stored message metadata and read back by
isConversationalInjection, so after a release a rename would need a
compatibility read. No release carries it yet, so it is renamed now: the
metadata key, the occurrence and event fields, and the comments that name
it. The tests that check whether an injection carried the mark now look for
`"firstView":true` rather than the bare key. The framework's local spread
that carries the mark is `firstViewMark`, beside `narrowing`.

No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…give-back and an old snapshot do (R4-1, R4-2, R4-4)

R4-1. channel_publish's description, and its channelId's, said an omitted
channelId goes to the channel of "the most recent incoming message that
isn't a reaction, reaction removal, edit or deletion". markAccepted is that
default's only writer. It runs for every accepted `channels/incoming`
message, on any channel, at acceptance (for a coalesced one, once the
coalescer admits it), and the default is process-global, so a message the
agent hasn't been shown yet, or one accepted for another agent, moves it. A
push never reaches markAccepted, so a message that arrives by push never sets
it. And an edit the coalescer delivered as the agent's first view does move
it. Both strings now say so: the most recent incoming
message this host received on any channel, whether or not you've been shown
it, with reactions, reaction removals, deletions, and edits of a message
you'd already seen aside; a message pushed to you rather than posted on a
channel never sets it. The changelog says the same.

R4-2. unshowFailedCompile gives a committing compile's move of the shown
watermark back only while the watermark is still that compile's own claim,
and gives it back to the watermark the compile found. Of two overlapping
committing compiles, if the first to start throws first, it gives nothing
back, and if the second then throws, it restores the first's failed claim,
so messages up to that claim count as shown although no request held them.
That errs toward a marker, the stated direction for a doubt, until the next
compile or boundary moves the watermark. The comment now says so, and the
shown watermark's doc says a compile gives its move back where it can.

R4-4. A test comment in present-while-acting called the markers "ABOUT a
message the agent has seen"; they are about an existing message. The
changelog said a message counts as shown once "a compile of the agent's" has
begun to assemble it; for residents sharing a message slot, a compile or
tool boundary of any agent that can read it counts.

The snapshot's `firstViewEnded` doc said that after an upgrade an old
snapshot "can give one delivery a spurious" mark. A subject whose first
view had ended without a read is granted a first view again, and until the
agent is shown or reads a version of it, each delivery that would mark a
first view (a render, or a plain occurrence that replaces an unread version
or a pending notice) carries a spurious `firstView`. The doc now says that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_publish's default goes on each path

From our internal review of the round.

A crash before the throttled snapshot forgot an ended first view. The
receipt record written at each acceptance, which bridges the snapshot's
throttle, carried the subject's birth and its batch but not
`firstViewEnded`, so a coalescer restored from those records alone gave the
subject's next batch a first view although the agent may have been shown an
earlier version: the direction a doubt must not take. Each record now
carries the end when the subject's first view has ended, and
restoreReceipts applies it. A record written before this reads as not
ended, as a snapshot does.

The stand-in hosts of the first-view tests now treat a removed placement as
the framework does: no longer unread, and nothing in it unshown.
occupantLeaves has to ask whether its occupant may have been shown before
the removal takes the placement, and with the old stand-ins nothing pinned
that order for a retraction or a notice's displacement.

channel_publish's two descriptions said that only edits of a message the
agent had already seen are set aside. An edit moves the default only when
the coalescer delivers it as the agent's first view at its acceptance: on a
server that doesn't coalesce, markAccepted runs with no first view, and a
deferred edit's later first-view render never reaches it. The descriptions
now say that reactions, reaction removals, deletions and edits don't set the
default, though an edit of a message the agent hadn't been shown can count
as that message, and the channelId description no longer runs its
exclusions into the "whether or not" clause. The changelog says the same,
and that a first-view edit moves the default for a channel message the
coalescer delivers when it accepts it.

Docs: isMarkerTagged's first-view mark comes from a stored message's
metadata or, in the channel registry, from the admission's receipt; and a
subject's ended first view holds while the coalescer keeps its record, since
an idle subject's record can be evicted and an `initial` occurrence after
that starts the subject afresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this review.

R4-1 to R4-4 are answered in three commits on top of 28c25610: b8dc07ac renames the mark (R4-3), fb3389de changes text for R4-1, R4-2 and R4-4, and dcd04780 makes R4-1's wording true on every path and fixes what our internal review of the round found.

  • R4-1. channel_publish's description now ends: "otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it." Its channelId parameter says the same. It starts from your wording and changes three things to match the code:

    • Edits. An edit moves the default only when the coalescer delivers it as the agent's first view when it accepts it: markAccepted(firstView) passes the mark to isMarkerTagged, which then lifts chat:edited. A server that doesn't coalesce reaches markAccepted() with no first view, and a deferred edit's later first-view render doesn't reach markAccepted at all, so "can count" is as far as the text can go. The test "a channel edit that is the agent's first view moves context.incoming to its message, as the message would" asserts the anchor's channel moving, and with markAccepted(false) on the coalesced path it fails.
    • "From an open channel" became "on any channel". The only check before markAccepted is §14.5's registration, and fix(mcpl): reconcile channel lifecycle operations in order #210's repair in markAccepted provides for traffic that arrives after a channel's close.
    • "Whether or not you've been shown it" is new. markAccepted runs when a message is accepted, before any agent is shown it, and the registry keeps one default for the whole host. Two tests in channel-registry-routing move it with no agent involved: "routeSpeech routes a conversation fork to its HOME channel, not the global last-inbound (item 3)" and "routeSpeech routes a TRUNK agent to its ACTIVE triggering channel, not the global last-inbound (item-3 redux)".

    The last sentence covers your case of a DM that arrives as a push. In a run at dcd04780, a channels/incoming message on chat set the default to chat, a channel-scoped push on other and a plain push left it there, and a channels/incoming message on other moved it to other.

  • R4-2. unshowFailedCompile's comment now gives both orders. If the first of two overlapping committing compiles throws first, it gives nothing back, and if the second then throws, it restores the first's failed claim, so messages up to that claim count as shown until the next compile or boundary moves the watermark. The shown watermark's doc says a compile gives its move back where it can. We took your second option, the comment. Keeping the in-flight claims and taking a max would compare watermarks across branches and keep state per compile, to change the direction of a doubt in the one case where both throw, and that direction already errs toward a marker. A run at dcd04780 matches your probe: with the first compile throwing first, the mark ended at its claim and a later edit wasn't a first view; with the second throwing first, the mark went back to where it started and the edit was a first view.

  • R4-3. The key is firstView in the message metadata, the occurrence and event fields, and the comments, across the five source files and the coalescing tests. No replacesUnread remains.

  • R4-4. The present-while-acting comment says the markers are about "an existing message". The changelog says a message counts as shown once "a compile of any agent that can read it has begun to assemble it or a tool boundary of one may have injected it (residents sharing a message slot can all read it)".

Also in dcd04780: the receipt record written at each acceptance, which bridges the snapshot's throttle, now carries a subject's ended first view. Before it, a crash before the snapshot flush could give the subject's next batch a first view after the agent had been shown an earlier version. A new test covers both directions. The first-view tests' stand-in hosts now treat a removed placement as read and shown, as the framework does, so the tests pin that occupantLeaves asks about an occupant before its removal.

Checks at dcd04780. On Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, and so do mcpl-coalescing 144 of 144, channel-registry-routing 26 of 26, present-while-acting 64 of 64, and the 30 test files that exercise the coalescer, the registry and the routing they feed, 1,025 of 1,025. Each new piece undone alone fails only its tests: the crash-window record's end (the new test), the order of occupantLeaves and the removal for a retraction (3 tests) and a notice's displacement (5), and, for the rename, keeping a server's own firstView on the channel path (the two tests of a server marking its own edit) or isConversationalInjection ignoring the mark (six tests, R3-1's three lane tests among them).

— Daniel-2612

@nissa-seru

Copy link
Copy Markdown
Contributor

Replying to this review.

Your wording ask is answered in fb3389de, and the overwritten occupant on main is #548.

  • The upgrade note. CoalescingSnapshot.firstViewEnded's doc now says: "So after an upgrade, a subject whose first view had ended without a read is granted a first view again. Until the agent is shown or reads a version of it, each delivery that would mark a first view (a render, or a plain occurrence that replaces an unread version or a pending notice) carries a spurious firstView." We worded it from a run of your case at dcd04780 (Linux, Node 24.22.0), which matched yours. With firstViewEnded deleted from the snapshot, a plain p1 and then edits e1, e2, e3 marked the three edits, and a notice n2 rendered and then edits e1, e2 marked all three. With the field kept, nothing was marked. p1 itself carried no mark, since a plain birth carries none, so a doc saying that "the first delivery that would have been a first view" carries one would have been false of p1. The description's line about upgrades now says the same. 7e13dd9c's message and our earlier reply said "one delivery"; the doc and the description replace that.
  • The overwritten occupant is Coalescer: a retraction leaves an earlier render queued when two renders of one subject land in the same deferred queue #548, reproduced at main 67aabff2 from your steps, with a link to your review.
  • Also in this push: for Anarchid's R4 review, the mark is renamed from replacesUnread to firstView (b8dc07ac), and channel_publish's description, the comment on a failed compile's give-back and two other texts now say what the code does (fb3389de, dcd04780). From our internal review of the round, dcd04780 also makes the crash-window receipt record carry a subject's ended first view, and the first-view tests' stand-in hosts treat a removed placement as the framework does. The description lists the checks at dcd04780.

— Daniel-2612

Meg-2646 and others added 2 commits October 19, 2026 02:55
Main's anima-research#557 (an ephemeral run takes no global fallback) reworded the same
texts this branch rewords, so three places conflict in
src/mcpl/channel-registry.ts and one in src/framework.ts. Each keeps both
sides' rules:

- channel_publish's description and its channelId parameter's keep this
  branch's account of the default channel (a conversation's bound channel
  first; otherwise the channel of the most recent incoming message the host
  received on any channel, shown or not, which reactions, reaction removals,
  deletions and edits don't set, though an edit of a message not yet shown
  can count, and which a push never sets) and add main's rule that a
  subagent has neither default and must name a channel.
- handleToolPublish takes main's resolution (the caller's global fallback,
  which an ephemeral run never takes); its comment keeps this branch's
  description of the default.
- reactToRefusal's doc keeps main's "the refused agent's" locus and its
  clause that an ephemeral run has none, with this branch's exclusion of
  reaction, reaction removal, edit and deletion markers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…whatever its age

restoreReceipts skipped every record older than the receipt retry window
(an hour by default) before it read anything else, so a host that came back
more than an hour after a crash in the snapshot throttle's window forgot a
subject's ended first view, and the subject's next batch or birth could be
granted one the agent may already have had. A record whose subject the
restore didn't hold dropped the end the same way. The end is a fact about
the subject, not about the receipt: each record that carries it now ends the
subject's first view whatever its age, and a subject the restore doesn't
hold yet comes back with that end and an unknown history. The retry window
still bounds the receipt itself and the rest of the record, as before.

Tests: the crash-window test now also ends the first view by a plain
occurrence that displaces the shown render, beside the retraction; a
snapshot taken before the end, with the records after it, restores the end
onto the snapshot's subject; and records restored two hours later still end
it, so the edit of a later initial plain occurrence is no first view.

Text: five comments that name the default publish channel, the incoming
anchor or the refused agent's locus as the latest message that isn't a
reaction, reaction removal, edit or deletion marker now add "apart from an
edit the agent sees first", which sets them like the message it replaced.
The changelog says a compile that throws gives back its move of the agent's
shown position unless something has moved it since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Meg-2646 and others added 2 commits October 19, 2026 05:46
main's anima-research#562 (a render still unread stays when a later render or notice
arrives, and a retraction or plain occurrence takes the whole run) changed
the coalescer's occupant, its retraction, plain replacement and notice
paths, its render settlement and its snapshot, where this branch adds the
first-view mark, so seven places conflict in src/mcpl/push-coalescer.ts.
Each keeps main's runs and this branch's first view:

- The occupant is main's, a run of renders carried as `before`, and it
  carries this branch's `firstView`. A run's members keep their whole
  records, so a member keeps its mark.
- The render settlement keeps this branch's rule that a render is the
  agent's first view only when no other version of the subject is placed,
  so a render that joins a run is never marked: only a run's oldest render
  can carry the mark.
- occupantLeaves asks of the whole run. It hands a first view on only when
  the run's oldest render carries the mark and no render of the run may
  have been shown, and otherwise it ends the subject's first view. A
  retraction and a plain occurrence ask it before main's walk removes the
  run, since the removal takes the placements it reads.
- A notice beside an unread run leaves the run in place, as on main, and
  its batch is no first view, since the run holds it.
- The snapshot keeps both sides' fields.

Tests: this branch's tests of a notice displacing a queued render described
what main no longer does. They now check that the render stays and a later
render joins its run unmarked, and that a plain edit or a retraction that
takes the run hands its first view on, or ends it, by whether anything of
the run may have been shown. main's render-run stub host answers isUnshown
as it answers isUnread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a late restore's end reaches the snapshot's batch

occupantLeaves handed a run's first view on when the run's oldest render
carried the mark and every render still in the run was unshown. But a
render a request has read, or one that is gone, leaves the run before
that question (refreshOccupant), so a newer render the agent had read
could leave the oldest one to hand the view on. The subject's history
still says something was read, so occupantLeaves now asks it too.

Ending a first view is one step, endFirstView: the subject's end, and
the first-view mark of a batch pending or rendering. A crash-window
record that carries the end applies it whatever its age, and it now ends
the view of a batch the snapshot brought back too. Past their retry
window the records' batch facts aren't read, so a snapshot's pending
batch can come back after the end; it kept its mark before.

Text: the occupant's mark says a plain occurrence carries it too, and
the comments on the default publish channel and the reply anchor say
that only an edit the coalescer delivers on acceptance as the agent's
first view sets them. Those on the conversational test say the first
view is the coalescer's mark.

Tests: the first-view probe answers read and shown per event as well,
and new tests give a run's renders different views: a plain edit that
displaces the run, or a retraction of it and the next batch, is no first
view when either render may have been shown or the newer one was read.
Another restores a snapshot holding a first-view batch with the records
after it, past their retry window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 7f4730f into anima-research:main Oct 19, 2026
6 checks passed
tel-0s added a commit that referenced this pull request Oct 19, 2026
…ge entry for bounded jobs, the batched-wake hold

What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round
speech), #250 (surgery marks opt-in), #463 (the workspace edit planner),
#168 (non-conversational marker tags: a reaction, edit or deletion never
retargets the publish locus, apart from an edit the coalescer marks as the
agent's first view), #558 (every ephemeral agent's usage in one shared
entry), #570 (the batched-wake tests hold the gate's batch), #569, #565,
#566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs.

Conflicts in five files, resolved by composition:

- src/agent.ts: the request strips the framework's token-estimate stamp
  (main) and then presents the agent's own turns under its name (ours).
- src/framework.ts: `ephemeralCandidates` keeps this branch's record
  (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and
  createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only
  (main's second argument is unread) with main's `readsDeferredWrite`
  spread; the re-pin rule's doc is main's with the `speech.locus` sentence;
  the turn-start flush is main's `flushForStart`, which gains this branch's
  `releaseLandedMail` so held mail whose message lands is released.
- src/mcpl/channel-registry.ts: the three Dendrite options
  (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside
  main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but
  records no publish locus — admission does, through `noteDelivered`, and
  the framework's three `noteDelivered` sites now apply main's marker rule
  (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is
  never the latest delivered inbound either; `channel_publish`'s default
  comment says the same.
- test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps
  its shape and gains main's `holdBatches`, reading the rig's framework;
  every test that pairs two sends holds the batch, the two ambient-only
  tests included.
- test/present-while-acting.test.ts: `createFramework` takes `speech`,
  `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests
  (traces and typing after a re-pin, the budget restart, the inbound-only
  arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs
  to, beside this branch's default-rule test.

Three seams the suite found, all at the registry's reply anchor, which main
sets where it accepts a message and this branch sets where admission
delivers one:

- #168's marker rule now lives in `noteDelivered` (tags and the coalescer's
  first-view mark are passed by the framework's three delivery sites), so
  the registry's unit tests exercise it as the framework calls it.
- `buildChannelContext` reported no `incoming` when the speech fallback was
  null, and no context at all when nothing was open and no outgoing channel
  resolved — under `speech.locus: 'delivery'` that hid the reply anchor
  whenever the latest inbound wasn't a speech fallback. The anchor now
  depends only on `takesGlobalFallback` (an ephemeral run has none), and a
  context with an anchor is returned whatever else it lacks.
- A coalesced acceptance's anchor is committed when the acceptance is
  acknowledged, not when the coalescer delivers: an acceptance whose commit
  fails after its delivery moves no anchor, and the retry the receipt
  answers commits the one the first attempt parked (`parkedAnchors`, keyed
  by server and event id, bounded). #157's tests pinned both halves.

Test adaptations beyond the conflicts: #157's two re-pin tests and
outgoing-stream's re-pin test run under the legacy locus rule, as the others
do; two compiled tests main deleted (`mcpl-awareness-barrier`,
`prose-stream-router`) were still in `dist/test` and failing from there —
removed, since tsc doesn't clean outputs whose sources are gone.

Two lines git doesn't show, named by nissa-seru's review of #546:

- #558 reads `config.subconscious.name` for the shared usage entry's
  exemptions; a forks reader has no agent of its own, so the name is read
  only when `reader !== 'forks'` (the union has none otherwise).
- resumeAgent makes its agent with `new Agent`, so it adds the agent to
  `ephemeralAgents` itself: a resumed job's usage counts in the shared
  entry, not under its own name.

Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 19, 2026
Main has moved since this branch's base (92125b0): anima-research#247 holds suppressed
plain speech as resendable drafts, anima-research#360 decides a round with a script once
its scripts return, anima-research#168 rewords channel_publish, and turns now track their
typing indicator and inbound-only channels. Where they meet this branch:

- Where this branch keeps a private non-channel turn's plain speech on
  its surface, it stays counted as kept private rather than drafted: a
  draft exists to be resent, and resending these words is the step into a
  channel the turn's privacy keeps them from. That covers hybrid's
  no-destination branch, which only a private turn reaches (allowLocus
  false), and locus mode's private branches. Silenced rounds still hold
  such speech as drafts at this merge; the next commit keeps it private
  there too. The count, privateSuppressed, is now reported apart from
  `suppressed` (disabled mode, withheld rounds) and from keptPrivate (the
  resident's own skip_reply or private think), in a receipt note of its
  own.
- The mid-turn round (anima-research#360's routeRound) keeps this branch's locus-mode
  private branch ahead of silencing, and its hybrid delivery takes
  allowLocus from the turn's origin. The text-only path counts each of
  main's segments.
- A bounce's wake carries the turn its envelope came from (round.routing).
- channel_open in a private turn moves no locus and says so; otherwise
  main's inbound-only note applies.
- Typing follows main's showTurnTyping, and the receipt call keeps main's
  explicit-mode exclusion: explicit envelopes acknowledge themselves
  through the prose gateway.
- channel_publish keeps anima-research#168's wording, with the private-turn exception
  where it names the fallback channel.
- The routing-state literals in the tests take main's fields with this
  branch's two.
- Main's tests added since this branch's base that push a channel-less
  external-message and expect its prose routed now give it the channel
  their registry stub routes to, as this branch did for older tests: a
  channel-less surface message opens a private turn here
  (agent-allowed-tools, cancel-surfaces-as-error, code-execution,
  outgoing-stream).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

isConversationalInjection: exempt chat:edited / chat:deleted (and chat:reaction-remove) markers like chat:reaction

4 participants