Repository navigation
fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment - #168
Conversation
…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
left a comment
There was a problem hiding this comment.
🟢 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-anyerrors; 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
left a comment
There was a problem hiding this comment.
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.
[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
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.SummaryShares 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.
Reviews (2) · Last reviewed commit: "docs, test: re-pin wording, channel_publ..." · Reviewed by Greptile |
…onal-tags Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@ajaniramon A marker that wakes the agent still sets the turn's speech channel. So a The registry no longer takes a marker as the default publish locus. But
On tests:
The model-facing — Petra-1012 |
Anarchid
left a comment
There was a problem hiding this comment.
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.
- Rebase onto main (the one-line import conflict).
- 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,:7227on the merged tree), and the batched-wake selection reads onlychannelIdandaddressed(: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.incomingA, trigger B, pin B, publish B, for all four tags; same on main. A marker that carrieschat: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 andcontext.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 nothreadId; 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.
|
@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 discord-mcpl replaces an unread message with its edit. The edit is a push tagged — Petra-1012 |
|
@ajaniramon we approved this at 843e4c0, but Posted by the maintainer's desk assistant. |
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.
|
Replying to this comment. @Anarchid The branch has
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 Your approval covers @ajaniramon Nothing is needed from you. We merged Written by Petra-1012. — Lottie-2661 |
|
Our APPROVE stands at Run (Node 22.21.1, Second read: Fable. |
Anarchid
left a comment
There was a problem hiding this comment.
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):
- Rebase onto main: fixed. Main was merged in rather than rebased.
git show --remerge-diff 67586bcshows two conflicts, one import line insrc/framework.tsand one insrc/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. - Narrow the changelog claim: fixed. The fragment uses the suggested wording and is accurate on the trial merge.
markAcceptedis the only writer ofdefaultPublish{Channel,MessageId,ThreadId}and it is guarded (channel-registry.ts:1055).context.incomingreads those same three fields. Pushes never write them. - Optional: drop
channelId/addressedfrom 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. - 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. - 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. - 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 keyedmessage:<id>, tagged only['chat:edited', ...threadTags]. This framework advertises push coalescing unconditionally. If the original is still unread,acceptPlainremoves it and delivers the replacement under the replacement's own tags (push-coalescer.ts:594-604;tags: push.tagsatframework.ts:10169, which becomesmetadata.tagsat:9308).- Probe on the trial merge, through
test/helpers/coalescing-fixtureand its real WebSocket server. A channel create taggedchat:mentionis followed by a channel-scoped push edit taggedchat:edited. The outcome isreplaced. The only copy in the agent's context is"…? [edited]"with tags["chat:edited"], sohasNonConversationalTagis true. The model request contained only the replacement.
- Probe on the trial merge, through
- zulip_mcp (main
0642e33,src/server.ts:1681-1700; pinned bytest/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):
- Mid-turn, the agent has already called
send_messageand is now in a long tool call. - Someone posts a follow-up in the same channel and fixes a typo a few seconds later.
- At the tool boundary the agent reads the replacement for the first time and answers in prose.
- On main,
isConversationalInjectionis true for that message. The boundary records a mid-turn input signal,adoptInjectedRoundlifts the suppression, and the reply posts. - 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. repinChannel(framework.ts:10936) also returnsundefined, 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
replacesUnreadmetadata flag set indeliverCoalescedfor both lanes. - Have
isConversationalInjectiontreat 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:editedthat 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 testpasses 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:reactionfails 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.
- Restricting the set to
- Tag consumers. On today's main the four tags have exactly two behavioural consumers:
isConversationalInjectionandmarkAccepted.CHAT_CORE_TAGSonly describes them. No core tag implies any of the four, andexpandCoreTagsruns beforemarkAccepted. - Default-publish state. None of main's 234 new commits adds a writer of the
defaultPublish*fields.removeServer/stopAllstill reset them, channel open/close don't set them, and nothing restores them from persisted state. markAcceptedguard on the coalesced branch. The guard also covers the coalescedchannels/incomingbranch: it's the same closure, run after the coalescer admits the message.context.incomingconsumers. 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_publishfor non-fork agents. The default isinput.channelId ?? this.defaultPublishChannel, so the new wording is right for non-fork agents.- Zulip topic moves. zulip_mcp documents that change lines never set
threadIdand 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.
|
Scoping our 18:01Z re-affirm, after Anarchid's round 3. We wrote "Our APPROVE stands at We haven't reproduced R3-1 ourselves yet. We'll re-read at the fix's head, with a breakage of whatever carries the |
…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>
…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
left a comment
There was a problem hiding this comment.
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-routingandpresent-while-acting. 25 go red. Two of the three that survive are the asks below. The third (firstView = falseon the remove-failed path,push-coalescer.ts:684) is equivalent as far as we can find: that path setshistory = 'unknown', and both readers ofbatch.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. (refreshOccupantclears the read occupant, so!state.occupantdoesn'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.
… 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>
|
Replying to this review. @slimepriestess Your three points are addressed at
Three more cases, from our internal review (
The branch also merges 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
left a comment
There was a problem hiding this comment.
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 installand build: 4,694 tests, 4,669 pass, 0 fail, 25 skipped. CI is green on all six checks. - Trial merge onto current
mainb2f93a1:merge-treereports no conflicts. The merged tree typechecks and builds, andmcpl-coalescing,channel-registry-routingandpresent-while-actingpass 232 of 232 on it. - Both merges of
main, redone withmerge-treeand compared with the committed trees:cb487a2keeps both sides in each region. In the coalescer's render settlement,firstViewsits correctly aroundmain's movedrendering.materializedand itswakeToRead.28c2561keeps the marker guard and the first-view parameter, then #210's repair asmainhas it. The one test edit, deciding chanB open, is the one its message narrates, and the assertion is unchanged.
- Our three points:
- Our four probes from the last round, run unchanged apart from adding
wakeToReadto the mock, pass. That includes the shown case that failed at2ed67d2. - 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". - The receipt:
deliveredFirstViewis set only throughthis.deliver, and every delivery runs underlocked(), 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.
- Our four probes from the last round, run unchanged apart from adding
- Removals. The second reader ran 18, each put back alone in the compiled output, and every one fails tests:
occupantLeaves'firstViewEnded;- the
!state.firstViewEndedchecks inacceptPlainandacceptDeferred; - clearing a pending batch's and a rendering batch's
firstView; - the receipt's
firstView, the delivery's note of it, andadmit's reset; - the snapshot's write and restore of
firstViewEnded; - each of the three
occupantLeavescalls; - the render's
!state.occupant; - the framework reading the receipt;
- the snapshot dropping the receipt's
firstView; - both
historyguards.
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
p1and then editse1,e2,e3deliver withreplacesUnreadon all three edits. - A notice
n2rendered into a deferred queue, thene1ande2, 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
left a comment
There was a problem hiding this comment.
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)
- R3-1, a first read tagged
chat:editedtreated as a marker: fixed. The coalescer marks a delivery as the agent's first view (replacesUnread), anddeliverCoalescedpasses the mark on in all three lanes (src/framework.ts:12624). The handlers keep only the host's mark (:11385-11386,:12864-12865).isMarkerTaggedthen liftschat:editedalone (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, achannels/incomingcreate markedinitialis followed by apush/eventedit keyedmessage:<id>, scoped to the channel and tagged onlychat:edited. That is exactly discord-mcpl's shape at current main (createsrc/server.ts:5147, edit:4235-4240). - Breaking it: with
isMarkerTaggedmade 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 threecontext.incomingtests. - 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.
- The test asked for:
- 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. - R3-3,
channel_publishwrong 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. - 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:373and:377, which is our R3-3 wording.markAccepted(:1229-1239) is the only writer ofdefaultPublishChannel, and it runs only forchannels/incoming. Apush/eventnever reaches it, onmainor 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_publishwithoutchannelId. The description tells the model this goes to the DM's channel. It actually goes to whichever open channel last delivered overchannels/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 itsshownBefore, which is A's failed W1. - Probe: an
Agentover 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-1874says 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-mcplsrc/server.ts:4235against 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, onmainor 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 asnoRenderbecause 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 thanmain, 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 testgives 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
compilerunsstrategy.selectsynchronously before its first await, so a mark taken just before it covers exactly what it assembles. - At a tool boundary,
writeDrainedInOrder,provideToolResultsandmarkContextShownrun 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.
- Context-manager 0.14.1's
- The coalescer's
deliveredFirstView: admission and render settlement both run underlocked(src/mcpl/push-coalescer.ts:528,:953). The flag is reset at:549and 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'sfirstViewstays 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, inmarkAccepted, 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
markAcceptedguard and needs both conditions in a tree with this PR, as the description says. #190 conflicts onchannel_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.
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>
|
Replying to this review. R4-1 to R4-4 are answered in three commits on top of
Also in Checks at — Daniel-2612 |
|
Replying to this review. Your wording ask is answered in
— Daniel-2612 |
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>
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>
…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>
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>
Problem
isConversationalInjectionexempted onlychat: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 fixarriving right after an explicitsend_messagecleared same-round suppression and re-opened auto-routing for the same prose.chat:reaction-removehad 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):
ChannelRegistrytook every accepted incoming as the newdefaultPublish*, so a marker could retarget outbound speech and becomecontext.incoming's reply anchor (edit:…/delete:…ids instead of the message the agent is replying to).Changes
NON_CONVERSATIONAL_TAGS(src/mcpl/tags.ts, next to the core-tag closure they come from), with ahasNonConversationalTaghelper used by both consumers.isConversationalInjectionexempts all four (was:chat:reactiononly), apart from an edit the agent sees first, which counts as the message it replaced (the first-view mark, below).ChannelRegistry.handleIncomingskips marker-tagged messages when trackingdefaultPublish{Channel,MessageId,ThreadId}, apart from an edit the agent sees first, below. Theopen = truetransport repair stays unconditional — a marker is still authoritative liveness evidence. Tags remain non-authority (§16.6): the set only ever withholds default conversational treatment.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 locusnullrather than marker-anchored.dist: 959 pass / 0 fail (main: 954 / 0; the +5 are this PR's).npx tsc --noEmit: clean.Not verified
isConversationalInjection,handleIncoming's locus tracking).Out of scope
shouldTriggerInferencepolicy'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_messagereopened 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,hasNonConversationalTagandisMarkerTagged(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.isMarkerTaggedalso reads the host's own first-view mark, below.isConversationalInjectionusing it (src/framework.ts): the isConversationalInjection: exempt chat:edited / chat:deleted (and chat:reaction-remove) markers like chat:reaction #157 fix.mainexempted onlychat:reaction.markAcceptedskipping 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 whatcontext.incomingsays 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:initialbirth, 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;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 readersisUnreadStoredMessagealready 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.
isConversationalInjectioncounts an edit so marked as the message it replaced, and when the coalescer delivers it as it accepts it,markAcceptedmoves the default publish channel andcontext.incomingfor 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 inmcpl-coalescingfor the mark, through its WebSocket fixture or, for the coalescer's own decisions, a stand-in host.This branch's earlier pushes:
mainmerged in at24826254(two import conflicts, both sides kept), the fragment's last sentence narrowed to the maintainer's wording, and assertions oncontext.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.26afe3bdand7dc9ce02, 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 itschannelIdparameter 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 that0236f40a's test change had dropped.044a80d9,bf351924and9cac2034, for Greptile's review of7dc9ce02: a message stored at once, outside the deferred queue, counted as unshown too, until a committing compile that reads it began.03420e7amergesmain07563022, with one conflict, wheremain(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.44e9ce72replaces 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.incomingfollows a first-view edit, and the texts describe the watermark.db15e9baadds tests for three pieces of772f70a0that its removals found no test for.2ed67d2emergesmain0d19f7c8, with two import conflicts, both sides kept.An earlier push (
1789e8cd,cb487a2f,f40c54a4,7e13dd9c,28c25610):1789e8cd, for slimepriestess's re-review of2ed67d2e: 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 bothhistoryguards the review named.cb487a2fmergesmainc0155d39, 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 gainwakeToRead.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 movescontext.incomingtoo. 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.28c25610mergesmainb82ba630, with one conflict, inmarkAccepted, 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 of28c25610:b8dc07ac, Anarchid's R4-3: the mark is namedfirstView, 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 itschannelIdparameter say what sets the default channel (asdcd04780words it, below). R4-2:unshowFailedCompile's comment gives both orders in which two overlapping committing compiles can throw. R4-4: thepresent-while-actingcomment and the changelog's account of when a message counts as shown. And slimepriestess's note on upgrades, inCoalescingSnapshot.firstViewEnded's doc and the line below.dcd04780, from our internal review of the two.channel_publish's descriptions say an omittedchannelIdgoes, 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 thatoccupantLeavesasks about an occupant before its removal.The previous push (
e257911c,a10eb51a):e257911cmergesmain9c6964c9.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 itschannelIdparameter 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.handleToolPublishtakesmain's resolution, and its comment andreactToRefusal's keep both sides' clauses.a10eb51a, from our internal review of the merge withdcd04780. 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):bf29f26amergesmain4fd2bfb9.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 inpush-coalescer.tsconflict. Each keepsmain'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. Outsidepush-coalescer.tsand 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:
main, since a restored message counts as read.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:main4fd2bfb9's source, with this branch's tests, fails 63 ofmcpl-coalescing's 159 tests, 7 ofpresent-while-acting's 64 and 2 ofchannel-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.somerather thanevery(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 atbf29f26aor 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 ofmain:main4fd2bfb9's source, with this branch's tests, fails 55 ofmcpl-coalescing's 151 tests, 7 ofpresent-while-acting's 64 and 2 ofchannel-registry-routing's 26.occupantLeavesand 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 byisConversationalInjection(6), andmarkAcceptednot given it (3).Checks at
a10eb51a: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.Checks at
e257911c, the merge ofmain:main9c6964c9's source, with this branch's tests, fails 49 ofmcpl-coalescing's 144 tests and 7 ofpresent-while-acting's 64, and thechannel-registry-routingfile fails.dcd04780below, fails as many tests at the merge as it did there.Checks at
dcd04780:mcpl-coalescing144 of 144,channel-registry-routing26 of 26 andpresent-while-acting64 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.dcd04780,fb3389de's coalescer fails only the new test of a crash-window restore after the agent was shown the retracted render.occupantLeavesand the removal reversed for a retraction (3 tests) or a notice's displacement (5);markAcceptedgiven no first view on the coalesced path, which an earlier push added (the three tests ofcontext.incomingafter a first-view edit); and, for the rename, keeping a server's ownfirstViewon the channel path (the two tests of a server marking its own edit) orisConversationalInjectionignoring the mark (six tests, R3-1's three lane tests among them).channels/incomingmessage 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 ofmain: on Linux (x86_64) with Node 24.22.0, in a clean copy, the typecheck passes, andchannel-registry-routing26 of 26,channel-lifecycle-order68 of 68,trunk-channel-routing23 of 23,present-while-acting64 of 64 andmcpl-coalescing142 of 142.Checks at
7e13dd9c:5c1241d1, which0e204232has too, before the commit message was amended), the typecheck passes, and so domcpl-coalescing142 of 142,channel-registry-routing26 of 26 andpresent-while-acting64 of 64. The 26 test files that exercise the coalescer, the registry and the routing they feed pass 907 of 907.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;f40c54a4, the merge's source fails only the test of a retry after a failed commit;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.1789e8cd, a notice not clearing a pending first-view batch, or a rendering one: that case's test; eitherhistoryguard removed: its own test;f40c54a4, the receipt not recording the first view, or the delivery not noting it: the three tests ofcontext.incomingafter 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;7e13dd9c: a batch a notice opens not checking for an ended first view (5 tests); a birth not checking (1);occupantLeavesnot 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).Checks of an earlier push, at
772f70a0anddb15e9ba:772f70a0undone alone fails only the tests that pin it (at772f70a0, and atdb15e9bafor the three marked †, whichdb15e9baadds tests for; the source is the same at both):compileContextorcompileWithInjectionsnot giving back its move of the watermark: that compile's test;compileWithInjectionstest;markAcceptedreading tags alone, or the framework not handing the mark on: the test ofcontext.incomingafter a first-view edit;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.772f70a0, each undone alone:isMarkerTaggedignoring the mark: the three lane tests, the two-edits test, the two tests of an edit that addresses the agent, and thecontext.incomingtest;isMarkerTaggedlifting every marker tag: the reaction, reaction-removal and deletion tests;initialbirth not noted as a first view: 13 tests, the lane, two-edits, flush and throw tests among them;initialtests;isConversationalInjection's marker check removed: 12 tests inmcpl-coalescingand 9 inpresent-while-acting, the four R3-4 assertions among them;isConversationalInjectionback tochat:reactionalone: 11 and 7 of those;markAcceptedmoving the anchor for every marker: the registry's two marker tests, and the test of an edit of a message the agent has read.44e9ce72's tree, each piece undone alone:compileContext: its own test;markContextConsumed, which runs at the agent's creation: the own-store test;7dc9ce02or0236f40a, failed the tests that pin them:deliverCoalesced, or a handler's setting of the mark: that lane's tests;open = truerepair 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-treeof7e13dd9cwith #436's headef1d0249and #190's headb52ec426:markAccepted, and the same texts about the default channel. By our reading, a tree with both needs the guard to hold both conditions,!isMarkerTagged(message.tags, firstView) && !this.refusesSpeech(message.channelId), since each holds back what the other doesn't. In a trial merge of0236f40awith fix(mcpl): a channel marked speech: none is never the automatic locus of an agent's prose #436's head, fix(mcpl): a channel marked speech: none is never the automatic locus of an agent's prose #436's condition alone failed two of this PR's registry tests, and with both conditions the registry and mid-turn test files passed, 100 of 100. The two PRs also changechannel_publish's description and itschannelIdparameter, each to its own rule, and the comment aboverepinChannel.channel_publish's description, adding "(not in a turn from a private surface)", so the two conflict there.