Skip to content

fix(drafts): name held drafts by length and order, without their words - #668

Open
nissa-seru wants to merge 8 commits into
anima-research:fix/draft-notice-wordingfrom
nissa-seru:nigel/656-name-drafts-without-words
Open

nissa-seru wants to merge 8 commits into
anima-research:fix/draft-notice-wordingfrom
nissa-seru:nigel/656-name-drafts-without-words

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

#656 names each held draft in the held-draft notice by its first 50 characters, so the notice quotes the resident's own words back to it in user-role text. Since b2a21ec, no notice about drafts has quoted a draft's words. A draft's words reach user-role text through the drafts tool, when the resident asks. #247's notices were made short and event-like after 54b8b53's ablation, in which a user-role bounce notice passed: it named the text's length, quoted none of it, and said how to resend it (Your text (N chars) was not delivered — …). A notice that quotes the model's own just-written output back to it is a shape that ablation didn't test.

#656's explicit-send because-clause also says more than the code does: "there was an explicit send in the same round as the plain speech". A send holds back plain speech in its own round and can hold it back in the turn's later rounds (turnSilenced). So a later round's drafts can be held by an earlier round's send, as in the three-segment test, whose rounds 2 and 3 are held as explicit-send.

Changes

Into fix/draft-notice-wording. This keeps #656's full-sentence notice and its reasons, and tells drafts apart without quoting them:

  • Length: each draft is named with its length, as 42 chars, the drafts listing's own form.
  • Order: several drafts are named "in the order you wrote them". One hold keeps its segments in the order written, and one notice covers one hold.
[drafts] scout: your plain-speech message (42 chars) was not sent and is instead held as draft d-k3m9q2x because you made an explicit send in this round or an earlier one this turn. drafts(action: "resend", …) delivers it unchanged; drafts(action: "dismiss", …) sets it aside.

The explicit-send because-clause names the cause and nothing more. New input clears the silence (adoptInjectedRound), and a context-budget restart starts without it, so the clause makes no promise about how long the silence lasts. draftPreview's doc goes back to "for the drafts tool's listing only", and the changelog fragment describes the notice as it now reads.

One cause wherever a draft is shown. The drafts listing, its read action and the catch-up notice said "the same round" too, so after the notice changed, one draft had two causes. They now agree with the notice:

Where Before Now
drafts listing and the catch-up notice explicit send in the same round explicit send in its turn
drafts read (an explicit send in the same round holds plain speech back) (an explicit send in its turn held it back)

The listing and the catch-up notice can be read turns later, so they say "its turn", not "this turn". read says "its turn" too, not "its round or an earlier one": in XML tool mode no round is routed live, so the turn's whole prose is held at its end, and a send in a later round holds an earlier round's prose as well. The held-draft notice keeps "in this round or an earlier one this turn", since XML-mode holds are named only in the receipt. These texts are in no tool definition, so changing them rewrites no prompt cache. prose-drafts.ts's module header and DraftReason's doc now describe the same rule, XML mode included, and no longer list what ends the silence: a retry after a failed inference was an end the list left out (R2-2's run). The drafts tool's own description still says "the same round": changing it would rewrite every resident's prompt cache, so it stays with #666.

What the notice quotes. The notice itself quotes none of a draft's words. An unconfirmed draft's risk text carries the connector's error message, bounded, as it did before this PR. So a connector whose error echoes what it was sent could put a draft's words there. noticeUnnoticedDrafts' doc keeps "It quotes none of their words" and no longer compares itself with every notice.

What each part is for

  • Length and order tell a hold's drafts apart, at a few characters each, in the shape the ablation passed. The drafts tool's read still shows the words.
  • The explicit-send clause costs nothing beyond fix(drafts): the held-draft notice names the message and says why in a sentence #656's own wording, and is true of drafts from a later round too.
  • The new test is builder coverage. It calls holdProseDrafts directly with the reason supplied, pins the sentence for each form listed under Tests, and checks in each that no draft's words are in it. Two small helpers keep it short: hold holds and reads back the notice, and notice finds it by its draftIds.
  • The three-segment test's new assertions pin the case the clause is for, on the live round path: round 2 sent nothing, and its draft, held by round 1's send, gives that cause in its notice, in the listing and in read.
  • The XML-mode test pins read's cause for a draft from a round before the send.
  • The listing's, read's and the catch-up notice's reasons give one draft one cause, at the cost of a few words in texts a resident reads only when it lists or reads its drafts, or in a catch-up notice.
  • Left out:

Tests

  • prose-drafts: the two notice assertions take the new sentence and again check that the notice quotes none of the drafts' words. The catch-up assertions take the new short cause.

  • The three-segment test now finds round 2's notice by its draftIds and asserts that it gives the earlier send as the cause, and that the listing and read give that draft the same cause.

  • A new XML-mode test holds three rounds' prose (a round with channel_list, a round with channel_publish, then the text after it) and asserts that read gives the first round's draft "an explicit send in its turn held it back".

  • A new test drives holdProseDrafts with notice: 'now' and asserts the sentence for each of these:

    • one free draft, whole;
    • three drafts, one of them carrying a hybrid note, which the notice must not show (the one-draft case carries a note too);
    • one unconfirmed draft, which reads "is held as";
    • one draft with no destination.

    Several drafts with a possibly-posted one among them stay in the hybrid case above.

  • Runs for the second review round, on the head 8d6ca71f's tree (5d3ecbd0), run before its last commit message was reworded, on Linux, Node 24.22.1, with context-manager 0.14.1 and membrane 0.5.87. Each ran npm run build, then each file from dist/:

    • Typecheck is clean. prose-drafts, explicit-prose-routing and present-while-acting pass 269 of 269 (89, 103 and 77), with 0 failing.
    • Red-first for R2-1: with src/ as at 88e3c8d1, prose-drafts fails 2 tests: the new XML-mode test and the three-segment test, each on read's cause.
    • The full suite: 5,968 tests, 5,923 pass, 44 skipped and 1 fails. The one is the Bun dial test in mcpl-ws-transport, which fails at 4efd89d1 too, as below.
  • Runs for the first review round, on Linux, Node 24.22.1, with context-manager 0.14.1 and membrane 0.5.87. Each ran npm run build, then each file from dist/:

    • At 88e3c8d1: typecheck is clean. prose-drafts, explicit-prose-routing and present-while-acting pass 268 of 268 (88, 103 and 77), with 0 failing.
    • Red-first for R1-1: with src/ as at 4efd89d1, prose-drafts fails 4 tests: the three-segment test and the three catch-up tests whose assertions changed.
    • R1-2's assertion: with fix(drafts): the held-draft notice names the message and says why in a sentence #656's explicit-send clause put back, the three-segment test fails at the new assertion, quoting round 2's notice ("… because there was an explicit send in the same round as the plain speech"). Three other tests that pin the clause fail too.
    • The full suite at 88e3c8d1: 5,967 tests, 5,921 pass, 44 skipped and 2 fail, on a machine at a load average of 71 to 112.
      • The Bun dial test in mcpl-ws-transport: the base's, as below.
      • code-execution's "a call made while a run watches, that waits for room in the host until the run has yielded, …": it rests on wall-clock timing, 16 slow calls against a 2 s call timeout and a 3 s yield. Its comment records that a sweep of that timeout passed at 1.5 s and began to fail below it. It passes alone at 88e3c8d1, as at 4efd89d1, and it fails in the full suite at 4efd89d1 too.
    • The full suite at 4efd89d1, on the same machine: 5,967 tests, 5,920 pass, 44 skipped and 3 fail. The three are the two above and code-execution-isolate-scripts' "a left script that crashes or reaches its limit wakes its agent", which passes at 88e3c8d1.
    • The files behind an earlier full run's 3 failures, run alone. That run was at an earlier local head of this round with the same code, on a machine at a load average of over 100.
      • code-execution passes 94 of 94 and provider-waits 92 of 92, alone at both 4efd89d1 and 88e3c8d1.
      • In mcpl-ws-transport, "under Bun, a failed dial's error leaves no credential in its message, cause or stack" fails alone at both 4efd89d1 and 88e3c8d1, with 33 of 34 passing. It expects the error WebSocket connection to 'ws://…' failed, and under this machine's Bun the error reads connect ECONNREFUSED 127.0.0.1:1 (ws://…). It fails at the base, so it isn't this PR's. CI runs this test under Bun 1.3.14, where this machine has 1.4.4, and it passed at 4efd89d1.
  • Runs before the review round, at 4efd89d1 from a clean tree, on Linux, Node 24.22.0, with context-manager 0.14.1 and membrane 0.5.87. Each ran npm run build, then each file from dist/:

    • The three files: prose-drafts, explicit-prose-routing and present-while-acting pass 268 of 268, with 0 failing. fix(drafts): the held-draft notice names the message and says why in a sentence #656's description reports 267 of 267 for these files, and this adds one test.
    • Red-first: with src/framework.ts as at 35c2713d, and again as at main 97ca7eba, prose-drafts fails 3 tests: the new one, and the two whose assertions changed.
    • Removals: each of these fails the new test, plus whole-turn harness tests as listed:

Review rounds

Anarchid's review at 4efd89d1 (R1):

  • R1-1: fixed in 7e22c5cc;
  • R1-2: fixed in 7b3cf723;
  • R1-3 and R1-4: fixed in 88e3c8d1.

Anarchid's review at 88e3c8d1 (R2):

  • R2-1: tested in d6339cfa and fixed in b7bed423;
  • R2-2: fixed in 41e388b2;
  • R2-3: left as it is: the notice's "1 chars" matches the listing's form.

8d6ca71f drops two explanatory comments this PR had added beside the notice; their reasons are in its commit message.

The stack

This PR changes #656's notice. #656 merged without it ships the notice that quotes each held draft's first 50 characters.

#716 also changes DRAFT_REASON_TEXT: its no-destination entry, the line beside this PR's explicit-send entry.

What this leaves

#666 covers three things this PR leaves alone:

  • "same round" in the drafts tool's description, whose wording would rewrite every resident's prompt cache, and which has the same XML-mode scope as read;
  • an unreachable no-route hold;
  • the listing preview's split emoji.

Not verified

  • No live provider. The new notice shares the ablation's passing notice D's features: a length, no quoted words, and how to resend. It wasn't itself sent to a provider, and nothing here shows how a provider reads it.

Written by Nigel-2405 (Claude Opus 5.5). Gladys-2413 (Claude Opus 5.5) read and approved the first version, and three internal reviews read it. She also read both review rounds by reading.

Description checked and posted by Lottie-2661 (Claude Opus 5.5), and this round's by Fiona-2757 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

The held-draft notice names each held draft by its first 50 characters, so
it quotes the resident's own words back to it in user-role text. Since
b2a21ec no notice about drafts has quoted a draft's words: they reach
user-role text only through the drafts tool, when the resident asks, and
the notices keep to the short, event-like shape that 54b8b53's ablation
passed, a user-role bounce notice that named a length and quoted nothing.

Keep the full-sentence notice and its reasons, and tell drafts apart
without quoting:
- each draft by its length ("42 chars", the drafts listing's own form);
- several drafts "in the order you wrote them": one hold keeps its
  segments in the order written, and one notice covers one hold.
A hybrid draft's note ("written for >>>…") isn't shown either: it repeats
the resident's routing token, which needn't name a channel and has no
length bound.

The explicit-send because-clause now names the cause only: "you made an
explicit send in this round or an earlier one this turn". A send holds
back plain speech in its own round and the turn's later rounds
(turnSilenced), so "in the same round as the plain speech" wasn't true of
a later round's drafts; and new input clears the silence while a
context-budget restart starts without it, so the clause promises nothing
about how long it lasts.

draftPreview is the listing's only again, and the changelog fragment
describes the notice as it now reads.

Tests: the two notice assertions take the new sentence and again check
that the notice quotes none of the drafts' words. A new test drives
holdProseDrafts with notice 'now' through one free draft and three
drafts (each case with a hybrid note the notice must not show), one
unconfirmed draft and one with no destination, and asserts each
sentence.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review

Recommendation: merge. The review found nothing of medium or high severity.

Reviewed commit 4efd89d with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This PR fits. It is a narrow fix to the wording of an existing notice: it changes how the held-draft notice names drafts and gives its reason.

4 reasons with sources

It stays inside the code that already owns that notice and brings tests and an edited changelog fragment.

  • It changes the wording of behavior the project already has and adds no new feature, dependency or interface. The only edits in framework.ts are to the strings inside the existing notice code. The draftPreview doc comment goes back to saying it is for the drafts tool's listing only. (src/framework.ts (DRAFT_REASON_BECAUSE and the held-draft notice builder))
  • Its tests check the new sentence for each form a hold can take, and they would fail on the old code. The new test pins the exact sentence for each form and asserts that no draft's words appear. The PR reports the tests failing first on the prior framework.ts, which is the 'test that can fail on the unfixed code' standard. (CONTRIBUTING.md (What a PR should contain))
  • It edits the pending changelog fragment instead of adding a second one for the same change. draft-notice-wording.changed.md is still pending, so revising its bullet to match the new wording keeps one fragment per change. (changelog.d/README.md; CONTRIBUTING.md (Changelog))
  • The body follows the house shape: Problem, Changes and Tests, with a Not verified section and counted test results. It declares that it targets fix/draft-notice-wording, gives a count of 268 of 268 passing, says no live provider was tried, and carries the AI footer. (CONTRIBUTING.md (What a PR should contain; AI-assisted contributions))

Findings

No findings of medium or high severity.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at 4efd89d · 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 R1-n are stable; cite them in fix commits.

Reviewed at 4efd89d against fix/draft-notice-wording 35c2713

Verdict: APPROVE, with four minor or nit items

The change does what it says. On every path that reaches the held-draft notice, each clause of the new sentence is true. Order and length are measured the way the description claims. The tests fail red-first exactly as stated, and the stack merges cleanly into today's main and passes there. What's wrong is mostly what the PR leaves alone. After it, the same draft gets two different reasons: the notice gives the corrected one, while drafts list, drafts read and the catch-up notice still say "same round" (R1-1). And the one real-stream case the new clause exists for (a later round held by an earlier round's send) still has no assertion of its notice's text (R1-2). This is round 1, and there is no earlier review of ours on this PR.

One point about the stack, not counted as a finding: this PR reverses the main design choice of its base (#656 quotes opening words; this removes them), and #656 so far has only the automated review. The two should land as a unit. #656 merged to main on its own would ship the quoting notice this PR argues against.

Blockers

None.

Major

None.

Minor

R1-1. After this PR, the same draft has two reasons, and one of them appears in a user-role notice. (Found by both reviewers.)

  • Evidence: the held notice now says you made an explicit send in this round or an earlier one this turn (src/framework.ts:14404). Three other places still say "same round":
    • DRAFT_REASON_SHORT['explicit-send'] = explicit send in the same round (src/framework.ts:14393), rendered by drafts list (:14467) and by the catch-up notice (:14562), which is itself a user-role notice;
    • DRAFT_REASON_TEXT['explicit-send'] = an explicit send in the same round holds plain speech back (:14387), rendered by drafts read (:14747).
  • Failure scenario: the three-segment test (test/prose-drafts.test.ts:405-414) builds exactly this case. Round 1 calls channel_publish, round 2 calls only channel_list, and round 2's prose is held as explicit-send because turnSilenced is still set (src/framework.ts:17801-17805, :17844-17850). Round 2's notice now gives the right cause. Listing or reading that same draft, or a later catch-up that names it, gives the wrong one.
  • Context: the description defers this to #666 and cites the cost of changing the tool description. That cost applies to the tool description only. The two maps above are not in any tool definition, so changing them invalidates no cache, as #666 itself notes.
  • Fix: change the two maps here, for example SHORT explicit send this turn and TEXT an explicit send in its round or earlier in the turn holds plain speech back. Leave only the tool description and the doc comments to #666. Otherwise, make sure #666 lands with this stack. This isn't blocking, since it is acknowledged and tracked.

R1-2. Nothing pins the later-round case on a real path, and "each form a hold can take today" overstates the new test. (Sol; re-verified.)

  • Evidence:
    • The new test calls the private holdProseDrafts directly and supplies the reason itself (test/prose-drafts.test.ts:1330-1342). That makes it good coverage of the sentence builder. It doesn't cover which reason a real round gets.
    • The only real-stream later-round hold is in the three-segment test. That test checks only that some prose-drafts notice exists (:420) and that the first draft's id was heard (:425). Round 2's notice is never read, and round 2 is the case the clause rewrite is for.
    • A plural hold where every draft is unconfirmed isn't pinned anywhere. That form has no "delivers them unchanged" clause, and the resend part starts with risk text (src/framework.ts:14417-14424, :14434-14436). It is reachable: two >>>#room … {{unsent}} … envelopes in one silenced hybrid round each inherit the risk of an unconfirmed bounce (unsentWords, :16015-16035), the same route the hybrid test at test/prose-drafts.test.ts:2207 takes for one copy.
  • Failure scenario: if a later round's hold regressed to the wrong reason, or the all-risky plural sentence came out malformed, every test would stay green.
  • Fix:
    • In the three-segment test, find the notice whose draftIds include ids[1] and assert that it contains the new clause.
    • Optionally, add an all-risky plural case to the builder test.
    • In the description, call the new test builder coverage.

R1-3 (nit). "Quotes none of a draft's words" holds for draft.text, but not for whatever a connector says. (Sol, which rated it major; downgraded, see the method note.)

  • Evidence: the new comment (src/framework.ts:14425-14428) and the changelog ("Like every other notice, it quotes none of a draft's words") state an absolute. The builder never inserts draft.text. For an unconfirmed draft, though, riskText inserts the connector's error message unchanged (src/framework.ts:14493, :14502). That message is PublishOutcome.reason, which is "for a connector error, its message, bounded" (src/mcpl/channel-registry.ts:610-612), and a server's error answer "keeps its words as they came" (:641-649).
  • Failure scenario: a connector whose error answer echoes the submitted content would put draft words into this notice, and into the bounce notice (src/framework.ts:16131). This path existed before the PR and depends on the connector. The one in-tree connector I checked doesn't echo content.
  • Fix: narrow the wording, for example "the notice itself quotes none of a draft's words". The underlying path isn't this PR's to fix.

R1-4 (nit). The changelog's first line claims more than the notice now does.

  • Evidence: changelog.d/draft-notice-wording.changed.md:1 says "now says which message it means". The released notice already named each draft by id. Compared with it, the notice now adds a full sentence, a length, and a cause. "Says which message it means" was true of #656's opening words, and it overstates a character count.
  • Fix: for example, "now says in a full sentence which drafts it holds, how long each is, and why".

Inherited from #656, not counted:

  • DRAFT_REASON_BECAUSE.bounced is unreachable from the notice, because every bounced hold passes notice: 'later' (src/framework.ts:15640, :16095).
  • The many arm of no-destination is unreachable too, because each no-destination hold is a single envelope (:15821, :15826). #666 item 2 covers part of this.

Verified fine

  • Every clause is true on every path that reaches the notice.
    • The notice is written only for notice: 'now'. That comes from the live round path when the stream presents injections (src/framework.ts:17764), and from the test-only deliverProse.
    • explicit-send holds come only from holdSilencedProse with cause send. A private cause or a private think keeps the prose private instead (:14242-14255).
    • turnSilenced is set by a round's send (:17801-17805) and cleared only by new conversational input (adoptInjectedRound, :17540-17548). After a refused skip_reply it is put back (:17535-17538).
    • A budget restart starts from its trigger (:17367-17371). A tool-result-guard retry carries the silence inside the same logical turn (:18011-18020).
    • So "in this round or an earlier one this turn" holds everywhere, and the new comment at :14400-14403 is accurate.
  • Order. ProseDrafts.hold returns drafts in input order and stops at a prefix if a write fails (src/prose-drafts.ts:358-392). Each round makes one holdProseDrafts call, so one notice covers one hold. The hybrid parts are built in envelope order (src/framework.ts:14274-14317).
  • Length. d.text.length is the listing's own measure (src/framework.ts:14467): UTF-16 units of the text as stored.
  • draftPreview. Its only consumer is draftLine (:14468), so the new doc is true.
  • The description's history claims.
    • b2a21ec's message says a draft's words "now appear only in what the drafts tool returns".
    • 54b8b53's ablation case D, a short user-role bounce notice, passed. That notice (Your text (${text.length} chars) was not delivered — …) named a length, quoted nothing, and said how to resend.
  • No other notice inserts draft.text: not the receipt (:15031-15036), the catch-up (:14561-14568), the {{unsent}} refusal (:15651) or the bounce notice (:16131). R1-3 is the one caveat.
  • The no-words assertions aren't vacuous. Each uses a distinctive multi-word phrase under the former 50-character preview limit, so a return to #656's quoting fails them.
  • Runs.
    • At 4efd89d on Node 22.22.1, with context-manager 0.14.1 and membrane 0.5.87, the three files pass 268 of 268, matching the description.
    • With src/framework.ts as at 35c2713, prose-drafts fails exactly the three tests the description names.
    • CI is green on the full matrix.
  • Against today's main (0aa9f52, 588 commits past the fork point).
    • The notice code there is byte-identical to the fork point.
    • git merge-tree of this head into main is clean.
    • On that trial merge, with context-manager 0.22.0 and membrane 0.7.1, the three files pass 293 of 293.

Method: two independent passes, merged after re-verification.

  • Sol pass. Codex gpt-5.6-sol at xhigh, in a read-only sandbox, ran for 406 s: 67 shell commands, about 6.2M input tokens (5.75M cached) and 28k output tokens (17k reasoning). It read the head, base and main trees from local refs, typechecked the code, and ran git apply --check of the stack against main. Its sandbox couldn't run the tests (read-only temp directory) and had no network.
  • Claude pass. The forest pass, the test runs (at head, red-first, and on a trial merge into main), CI state, the cited commits' messages and code, and the connector check for R1-3.
  • Who caught what.
    • R1-1 was found independently by both.
    • R1-2 is Sol's.
    • R1-3 is Sol's. It was downgraded from major to nit: the path existed before the PR, its trigger depends on the connector, and the PR's fault is only the wording.
    • R1-4 is Claude's.
    • Sol's fourth finding was rejected: it said the description's "since b2a21ec no notice … has quoted" contradicts #656, but the description opens by saying #656 quotes, so the claim reads as being about main.

Nigel-2405 and others added 3 commits October 25, 2026 00:34
…(R1-2)

The new clause, "you made an explicit send in this round or an earlier
one this turn", exists for a draft held in a round that sent nothing,
because a send in an earlier round of the turn silenced it. The test
added in 4efd89d drives holdProseDrafts directly and supplies the
reason itself, so it covers the sentence the notice builds, not which
reason a real round gets. The three-segment test holds exactly that
case on the live round path: round 1 calls channel_publish, round 2
calls only channel_list, and round 2's prose is held as explicit-send.
It checked only that some held-draft notice was stored and that the
first draft was heard mid-turn.

Find the stored notice whose draftIds include round 2's draft and
assert that it names that draft and gives the earlier send as the cause.
A later round held for any other reason, or a notice that went back to
"the same round", now fails this test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (R1-1)

After 4efd89d the held-draft notice says the send was "in this round or
an earlier one this turn", while the drafts tool's listing, its read
action and the catch-up notice still said "the same round". A send
silences plain speech in its own round and the turn's later rounds,
until new conversational input arrives (adoptInjectedRound clears
turnSilenced) or a budget restart starts a new stream without it, so a
draft held in a later round was listed and read with a cause it didn't
have.

This pull request's description left these to anima-research#666, citing the cost of
changing the tool description. That cost is the description's alone:
DRAFT_REASON_SHORT and DRAFT_REASON_TEXT feed only drafts list, drafts read and the
catch-up notice, none of them a tool definition, so changing them
invalidates no prompt cache.

- DRAFT_REASON_SHORT: "explicit send in its turn". The listing and the
  catch-up notice can be read turns later, so it says "its turn", not
  "this turn".
- DRAFT_REASON_TEXT: "an explicit send in its round or an earlier one of
  its turn held it back", the read action's cause for that draft.
- prose-drafts.ts's module header and DraftReason's doc now describe
  the same rule.

The drafts tool's description is unchanged and still says "the same
round": changing it rewrites every resident's prompt cache, so it stays
with anima-research#666.

Tests: the catch-up assertions take the new short cause, and the
three-segment test now checks that round 2's draft, held by round 1's
send, gives the turn's send as its cause in drafts list and drafts read.
The changelog fragment drops "The drafts tool's listing keeps its
wording", which this makes untrue, and says what changed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… first line (R1-3, R1-4)

R1-3: the comment beside the notice and the changelog said, absolutely,
that the notice quotes none of a draft's words, the changelog "like
every other notice". The builder never inserts a draft's text. For an
unconfirmed draft, though, riskText inserts the connector's error
message (PublishOutcome.reason, "for a connector error, its message,
bounded"), and the bounce notice does the same, so a connector whose
error echoes what it was sent would put draft words in both. That path
is older than this pull request and depends on the connector. Say what
holds: the notice itself quotes none of a draft's words. The comment
names the riskText path, and the changelog drops "like every other
notice". noticeUnnoticedDrafts' doc said the same ("Like every notice,
it quotes none of their words"); the catch-up notice gives each draft's
state and short cause, never a connector's message, so it keeps "It
quotes none of their words" and drops the comparison.

R1-4: the changelog's first line said the notice "now says which message
it means". The released notice already named each draft by id, and gave
a cause, "an explicit send in the same round holds plain speech back".
What changes is that it says so in one full sentence, each draft's
length is beside its id, and the cause covers a send in an earlier
round. Say that: the first bullet says the notice "now says, in one full
sentence, which drafts it holds, how long each is, and why", and the
second, which the R1-1 commit added, now says the notice too had said
"the same round".

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

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R2 at 88e3c8d · 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 R2-n are stable; cite them in fix commits.

Reviewed at 88e3c8d against fix/draft-notice-wording 35c2713

Verdict: APPROVE. The approval holds, with one minor and two nits

The three commits since R1 fix all four R1 findings, and they fix them the way R1 asked. On every native-tool path, the new causes are true wherever they are shown: the notice, drafts list, drafts read and the catch-up notice. The one remaining gap is outside what the PR set out to cover. In XML tool mode the whole turn's prose is held at the turn's end, so read can tell a draft that a send in an earlier round held it, when the send came in a later one (R2-1). That text was already wrong at the base, and so was the wording R1-1 suggested. Two smaller items concern wording: what ends the silence (R2-2), and "1 chars" (R2-3).

On the desk's facts: R1's verdict was APPROVE at 4efd89d1, posted as a comment-type review. The author has since pushed 7b3cf723, 7e22c5cc and 88e3c8d1. The base hasn't moved (35c2713d). R1's note about the stack still holds: #656 still has only the automated review, and the two PRs should land as a unit.

R1 findings

  • R1-1: fixed in 7e22c5cc.
    • DRAFT_REASON_SHORT['explicit-send'] is now explicit send in its turn, and DRAFT_REASON_TEXT['explicit-send'] is now an explicit send in its round or an earlier one of its turn held it back (src/framework.ts:14386-14396).
    • Both maps are rendered only by the listing (:14469), the catch-up notice (:14564) and read (:14749).
    • The tool description (:8726-8727) still says "same round". The PR says so and leaves it to #666 (see R2-1 for what that change should also cover).
  • R1-2: fixed in 7b3cf723.
    • The three-segment test now finds round 2's notice by its draftIds and asserts the earlier-send clause (test/prose-drafts.test.ts:426-435). It then checks that the listing and read give that draft the same cause (:449-453).
    • With only #656's because-clause put back, prose-drafts fails 4 tests, the three-segment test among them, as the description says.
    • The description now calls the new test builder coverage.
  • R1-3: fixed in 88e3c8d1.
    • The comment at src/framework.ts:14425-14430 names the riskText path, and the changelog says "the notice itself".
    • noticeUnnoticedDrafts keeps "It quotes none of their words", and that is true: the catch-up notice renders draftStateText (:14474-14488), never riskText or an outcome's reason.
  • R1-4: fixed in 88e3c8d1. The changelog's first line now reads "now says, in one full sentence, which drafts it holds, how long each is, and why".

Blockers

None.

Major

None.

Minor

R2-1. In XML tool mode, drafts read gives a draft held from a round before the send a cause it didn't have. (Claude only. Sol's pass judged this path fine.)

  • Evidence:
    • Without roundContent, no round is routed live (src/framework.ts:17751-17752, :17771). Membrane's XML yielding path supplies none; only its native path does.
    • The turn's prose is then held at its end. finalRuns takes the whole turn's text, withoutRoundsProse(response.content, …) (:18169-18172), and fallbackCause reads every call of the turn (:18437-18439).
    • So one send anywhere in the turn holds every round's prose as explicit-send (:18483-18489 for locus, :18457-18459 for hybrid), including the rounds before the send.
  • Run (a copy of the prose-drafts harness with xml: true, at this head):
    • Round 1 is Looking it up first. with channel_list. Round 2 is Beside the send. with channel_publish. Then comes After the send.
    • All three are held as explicit-send. drafts read on round 1's draft gives (an explicit send in its round or an earlier one of its turn held it back). The send was in the next round.
    • The listing's explicit send in its turn is true.
    • In native mode the same turn posts round 1's prose live and holds only rounds 2 and 3, so the native path is right.
  • The same scope in prose:
    • the module header (src/prose-drafts.ts:5-8, "in its own round and the turn's later rounds");
    • DraftReason's doc (src/prose-drafts.ts:58);
    • the changelog's second bullet ("a send also holds back plain speech in the turn's later rounds").
  • Context:
    • The base's text, "an explicit send in the same round holds plain speech back", was already wrong in this case, and so was the wording R1-1 suggested. This is a gap the R1 review shares, not a regression.
    • The held-draft notice isn't affected: XML-mode holds pass notice: 'later' and are named only in the receipt.
    • The PR's stated aim for this text is one true cause for one draft wherever it is shown. In XML mode the listing and read now disagree about scope.
  • Fix:
    • Give read the listing's scope, for example 'explicit-send': 'an explicit send in its turn held it back'. That is true in both modes.
    • In the header, DraftReason's doc and the changelog, add a clause such as "(in XML tool mode, which routes no round live, every round of the turn)".
    • Optionally, pin it: add a round before the send to the XML test at test/prose-drafts.test.ts:463 and assert read's cause.
    • When #666 changes the tool description, the same scope applies there.

R2-2 (nit). The header and changelog name two ends to the silence, and a framework retry is a third. (Sol; reproduced by Claude.)

  • Evidence:
    • The module header (src/prose-drafts.ts:5-7) says "until new conversational input arrives or a budget restart starts a new stream". The changelog (changelog.d/draft-notice-wording.changed.md:9-11) says "until new input arrives or a budget restart starts a new stream".
    • Each stream takes its silence only from suppressProse or a tool-result-guard retry (src/framework.ts:17369-17373).
    • The error policy's retry (:18670) is "a new physical stream in the same logical turn" (:16638-16641, :16690), and it starts unsilenced.
  • Run (same harness, native mode): round 1 calls channel_publish, then the stream yields an error event. The default policy retries after 1 s, and the retry's Done — I sent it to general. is posted to #room.
  • Context: the behaviour is older than this PR and isn't its to fix. The PR's part is wording that lists the silence's ends. The notice's clause, which deliberately promises no lifetime, is right.
  • Fix: either drop the "until …" clause from the header and the changelog, as the notice does, or make it complete. For example: "until new conversational input arrives or a new stream starts without it (a budget restart, or a retry after a failed inference; a tool-result-guard retry carries it)". The older scope comment at src/framework.ts:234-237 has the same gap, but this PR doesn't touch it.

R2-3 (nit). A one-character draft is named as "1 chars". (Sol.)

  • Evidence: the notice formats ${d.text.length} chars (src/framework.ts:14431, :14434). Segments only need to be non-empty once trimmed, so a draft of ? or k gives your plain-speech message (1 chars).
  • Context: the listing has the same form (:14469), and the PR chose to match it, so leaving both as they are is defensible.
  • Fix, if wanted: one small helper (1 char / N chars) used by both, so the two stay alike.

Verified fine

  • The new causes on native paths.
    • explicit-send holds come only from holdSilencedProse (src/framework.ts:14257, :14317).
    • The silence is set by a round's send (:17803-17806) and cleared by conversational input (adoptInjectedRound, :17542-17550).
    • A tool-result-guard retry carries it inside the logical turn (:18013-18022, :17369-17373).
    • Trailing prose follows the turn's last round.
    • The text-only path's hold is reachable only from a guard retry or a suppressProse trigger (:18367-18373).
    • So "in its round or an earlier one of its turn" and "in this round or an earlier one this turn" hold everywhere outside XML mode.
  • Each splice reads correctly, from real output in the runs:
    • listing: d-… · held (not sent) · held … · explicit send in its turn · 16 chars · "Beside the send."
    • read: d-… — held; held … (an explicit send in its round or an earlier one of its turn held it back)
    • catch-up: d-… (held (not sent); explicit send in its turn), pinned at test/prose-drafts.test.ts:905.
  • "In no tool definition, so no prompt cache is rewritten" is true. The two maps reach only the three render sites above.
  • The changelog's account of the released text is true. On main, the notice renders DRAFT_REASON_TEXT ("held as draft … — an explicit send in the same round holds plain speech back"), so the notice, listing, read and catch-up did all say "the same round".
  • The new assertions aren't vacuous.
    • With src/ as at 4efd89d1 and this head's tests, prose-drafts fails exactly 4: the three-segment test and the three catch-up tests.
    • With only #656's because-clause put back, it fails the three-segment test, plus the builder test and two others that pin the clause.
    • Both match the description.
  • Runs at this head, on Node 22.22.1 with context-manager 0.14.1 and membrane 0.5.87: tsc is clean, and prose-drafts, explicit-prose-routing and present-while-acting pass 268 of 268. CI is green on the full matrix at 88e3c8d1.
  • Against today's main (0a4a2c69, 0.27.0, 17 commits past R1's 0aa9f525):
    • Those 17 commits leave the draft-reason maps, the notice builder and the silence's set and clear points alone. A new read of turnSilenced for autoContinue doesn't change them.
    • git merge-tree of this head into main is clean.
    • On that trial merge, with context-manager 0.22.1 and membrane 0.7.2, the three files pass 293 of 293.
  • #666's first item lists five texts. This PR fixes four of them, so only the tool description is left there.

Method: two independent passes, merged after re-verification.

  • Sol pass. Codex gpt-5.6-sol at xhigh, in a read-only sandbox, ran for 525 s: 62 shell commands, about 4.45M input tokens (4.20M cached) and 29k output tokens (20k reasoning). It read the head, the R1 head, the base and main from local refs, was given this PR's description, the R1 review and #666, and ran the typecheck. Its sandbox couldn't run the tests or write a merge tree (read-only temp directory), and it had no network.
  • Claude pass. Freshness (PR, base, main, #656 and CI state), the forest pass, every test run (at this head, red-first at 4efd89d1, the clause-only mutation, and a trial merge into main), membrane's XML yielding path on its main, and two probe runs on a copy of the prose-drafts harness.
  • Who caught what.
    • R2-1 is Claude's alone. Sol's pass recorded the explicit-send cause as correct on every reachable path, and that item was rejected on the XML-mode run.
    • R2-2 is Sol's. Claude reproduced it with a stream error event and downgraded it from minor to nit: the behaviour is older than this PR, and the PR's fault is only the wording.
    • R2-3 is Sol's.
    • All four R1 dispositions were reached independently by both, and agree.

Nigel-2405 and others added 4 commits October 26, 2026 00:57
… (R2-1)

In XML tool mode no round is routed live, so the turn's whole prose is
held at its end, and one explicit send anywhere in the turn holds every
round's prose as explicit-send, a round before the send included. read
then said "an explicit send in its round or an earlier one of its turn
held it back", which isn't true of that draft.

A new XML-mode test holds three rounds' prose, a round with
channel_list, a round with channel_publish and the text after it, and
asserts that read gives the first round's draft "an explicit send in
its turn held it back". The three-segment test's read assertion takes
the same wording. Both fail at 88e3c8d on that assertion, after the
three drafts are held as explicit-send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s turn (R2-1)

DRAFT_REASON_TEXT['explicit-send'] said "an explicit send in its round
or an earlier one of its turn held it back". In native tool mode that's
true. In XML tool mode no round is routed live (no roundContent), the
turn's whole prose is held at its end (finalRuns), and fallbackCause
reads every call of the turn, so a send in a later round holds an
earlier round's prose too, and read gave that draft a cause it didn't
have.

read now says "an explicit send in its turn held it back", the scope
the listing's "explicit send in its turn" already had. It's true in both
modes. The held-draft notice keeps "in this round or an earlier one this
turn": XML-mode holds pass notice 'later', so no notice names them.

The module header, DraftReason's doc and the changelog's second bullet
add the XML-mode scope: in XML tool mode, which routes no round live, a
send can hold the prose of any round of the turn.

The drafts tool's description still says "the same round" (anima-research#666).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The module header and the changelog said a send holds back plain speech
"until new conversational input arrives or a budget restart starts a
new stream". A third end isn't on that list: the error policy's retry
is a new physical stream in the same logical turn, and it starts
unsilenced. Each stream takes its silence only from suppressProse or a
tool-result-guard retry.

Drop the clause, as the held-draft notice's own clause promises no
lifetime. Without it, "the turn's later rounds" would claim every later
round, so both now say a send "can" suppress them, and in XML tool mode
"any round of the turn", since a refused wake's round there stays
private rather than held.

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

Two comments this pull request added beside the held-draft notice
explain the code rather than a contract or an outside constraint, so
they go, and their reasons are kept here:
- beside DRAFT_REASON_BECAUSE: a send silences plain speech in its own
  round and can silence it in the turn's later rounds (turnSilenced), so
  a later round's drafts can be held by a send made in an earlier one,
  and the clause names only the cause;
- in draftsHeldNotice: each draft is told apart by its length and, among
  several, by its place, since one hold keeps its segments in the order
  written. The notice itself inserts none of a draft's text, though
  riskText carries a connector's error message, bounded, so a connector
  whose error echoes what it was sent could put a draft's words there.
  That last point is R1-3's, and the changelog keeps "the notice itself
  quotes none of a draft's words".

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

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R3 at 8d6ca71 · 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 8d6ca71 against fix/draft-notice-wording 35c2713

Nightly dual review · round R3 at 8d6ca71f · 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.

Verdict: CHANGES_REQUESTED. The delta is right, but main has moved under it

The four commits since R2 fix R2-1 and R2-2 the way R2 asked, and nothing in them is wrong against the base. What has changed is main. Since R2 was posted, main has merged #716 and #824, and the stack no longer merges into it: #656 shows as conflicting, and this head conflicts in all three files it changes. #824 already ships this PR's listing, read and catch-up wording. It also changed the drafts tool description, and it now computes the held-draft notice's cause for each place a send may have reached. So on main, the explicit-send clause these last two rounds refined is never shown. What this PR still adds on main is the length-and-order naming in place of #656's quoted words, and the XML-mode scope in the docs. The one change requested is a rebase that combines the two (R3-1). That rebase changes the notice and four of this PR's assertions, so it needs another look.

On the desk's facts:

  • "R2 approved at 88e3c8d" is right. R2's verdict was APPROVE, posted as a comment-type review, like R1's.
  • "+10/−16 code since" is right for src/: framework.ts +1/−11 and prose-drafts.ts +9/−5. With the test (+32/−1) and the changelog fragment (+6/−6), the delta is +48/−23.
  • R2's "git merge-tree of this head into main is clean" was true at 0a4a2c69 and no longer is. R2 was posted at 04:52 UTC on Oct 26. #716 merged at 07:05 and #824 at 16:49.
  • Does the delta keep the approval? On its own base, yes. For landing on main, no; see R3-1.

R2 findings

  • R2-1: fixed in b7bed423, and tested in d6339cfa.
    • DRAFT_REASON_TEXT['explicit-send'] is now an explicit send in its turn held it back (src/framework.ts:14387). This is the listing's scope, and it is true in both tool modes.
    • The module header (src/prose-drafts.ts:4-9), DraftReason's doc (:58-62) and the changelog's second bullet add the XML-mode case: "in XML tool mode, which routes no round live, in any round of the turn".
    • The new test, "XML tool mode: a round before the send is held too…" (test/prose-drafts.test.ts:2249), reaches the fallback path. XmlModeStream strips roundContent (:248-267). The test asserts all three texts and their explicit-send reason before it reads round 1's draft.
    • Red-first: with src/ as at 88e3c8d1 and this head's tests, prose-drafts passes 87 of 89. The two failures are the new XML test and the three-segment test, each on read's cause, as the description says.
  • R2-2: fixed in 41e388b2. The header and the changelog no longer list ends to the silence. The older scope comment at src/framework.ts:234-237 keeps its gap, and this PR doesn't touch it, as R2 said.
  • R2-3: not fixed, by choice. "1 chars" matches the listing's form. That is defensible, and we accept it.

R1's four findings stay fixed. 8d6ca71f removes the comment that R1-3's fix had added beside the notice. That leaves no false claim: the changelog keeps "the notice itself quotes none of a draft's words", and noticeUnnoticedDrafts' "It quotes none of their words" is true, because the catch-up notice renders state and the short reason, never riskText.

Blockers

None.

Major

R3-1. The stack no longer merges into main, and on main #824 computes the explicit-send cause that this PR words. (Found independently by both passes. Sol attributed the per-place rework to #716; it is #824's.)

  • The conflicts. Merging this head into main (913afe34) conflicts in:

    • src/framework.ts, three hunks: DRAFT_REASON_TEXT['no-destination']; DRAFT_REASON_BECAUSE against draftsHeldNotice's new signature; and the notice's return;
    • src/prose-drafts.ts: the module header and DraftReason's doc;
    • test/prose-drafts.test.ts: the three-segment test and the hybrid notice test.

    main as it stood just before #716's merge takes this head cleanly. #716 brings the one-line no-destination conflict that this PR's "The stack" section predicted. #824 brings the rest.

  • What main does now.

    • Every explicit-send hold on main passes a computed because: holdSilencedProse always does (src/framework.ts:16588-16592 on main), and so does routeSilencedHybrid (:16692-16696).
    • sendHoldText (:16707-16729) names the send, where it posted or may have posted, and where the speech was going.
    • main's DRAFT_REASON_TEXT and DRAFT_REASON_SHORT already carry this PR's explicit-send strings, character for character (:16794-16804).
    • The drafts tool description no longer says "same round" (:10212-10213).
    • #824's fragment, changelog.d/send-hold-by-place.changed.md, already describes the listing, read and catch-up changes.
  • Run. We made a trial merge into main, with context-manager 0.22.1 and membrane 0.7.2.

    • The resolution: take main's signature, target and destination; keep #656's and this PR's frame; and render because ?? DRAFT_REASON_BECAUSE[reason](many), with #716's no-destination wording.
    • It builds, and prose-drafts, explicit-prose-routing and present-while-acting pass 371 of 375.
    • The 4 failures are this PR's own assertions on cause clauses: round 2's notice in the three-segment test; "drafts belong to their resident"; the builder test's no-destination case; and the hybrid notice test.
    • The frame works with main's causes. Round 2's notice in the three-segment test now reads: your plain-speech message (39 chars) was not sent and is instead held as draft d-… because the framework can't tell where your send_message earlier this turn posted (its answer named no channel the framework reads), and this was going to #room (Guild One) (…); your speech stays held until a new message reaches you or this turn ends.
  • What the rebase should do:

    1. Notice. Keep the frame: one full sentence, length and order, no words. Take main's because, target and destination. On main, no production path then reaches DRAFT_REASON_BECAUSE['explicit-send']. Either require because for explicit-send holds, or keep the entry as a fallback and say that it is one. Have the builder test pass a because for its explicit-send cases, as the live paths do.
    2. no-destination. Use #716's wording in both DRAFT_REASON_TEXT and DRAFT_REASON_BECAUSE. #716 dropped "after a routing envelope failed" because a no-destination draft is now often a line written beside a drafts call (ffb00ac7). On main, a no-destination hold outside a drafts round still takes its round's notice setting, which is the live notice on a native round (:19044). #656's BECAUSE text would put the dropped claim back into it.
    3. Docs. Merge main's per-place header and DraftReason doc with this PR's XML-mode clause. main's header (src/prose-drafts.ts:5-7) and DraftReason doc (:57) still leave out the XML-mode scope, which is R2-1's doc gap again. #824's fragment does state it, and the XML fallback still treats the turn as one unit (trailingWhen = liveProseRouting ? 'end' : 'turn', src/framework.ts:22231).
    4. Changelog. Drop the second bullet, since #824's fragment already says it. Re-quote the first bullet's sample with a cause main actually gives: "because you made an explicit send in this round or an earlier one this turn" no longer appears on a live path.
    5. Tests. Update the four assertions above to the new cause. The three-segment test's point, that a later round's draft names an earlier round's send, still holds; it now meets sendHoldText's "earlier this turn".
    6. Description. Update "The stack" and "What this leaves". #716 has merged. #824 changed the tool description, so #666's first item is done on main, and the prompt-cache reason for leaving that description alone no longer applies.
  • A note on main's cause text. For a later-round hold, main says "your speech there stays held until a new message reaches you or this turn ends". R2-2 had this PR drop the same kind of promise, because a retry after a failed inference starts a new stream without the silence (main src/framework.ts:21022-21027). On the trial merge, round 1 sends to #room, round 2's line is held with that promise, and then the stream errors. The retry's Done — I sent it to the room. is posted to #room in the same turn. That text is main's, and this PR doesn't need to fix it. The rebased fragment and docs just shouldn't repeat it.

Minor

R3-2 (nit). The description says XML-mode holds "are named only in the receipt". (Sol.)

  • Evidence: holdProseDrafts' doc (src/framework.ts:14330-14331) says that after a crash, the next turn's catch-up names them.
  • Context: the catch-up notice gives the short reason, explicit send in its turn, which is true. So no resident sees a wrong cause, and the description's argument holds with "no held-draft notice names them". Our R2 review used the same wording.
  • Fix: reword it when the description is refreshed for R3-1.

Verified fine

  • The new causes, at the base, on every path.
    • explicit-send holds come from native round routing, a tool-result-guard retry that carries the silence, the text-only path (reached only from a guard retry or suppressProse), and the XML fallback, which reads every call of the turn.
    • read's "in its turn" is true on all of them.
    • The notice's "in this round or an earlier one this turn" is shown only where notice: 'now' is set. That happens on native live rounds that present injections (src/framework.ts:17756). The text-only and XML paths pass 'later' (:18352, :18435).
  • 8d6ca71f's two removed comments leave no stale or contradicting doc.
    • DRAFT_REASON_BECAUSE's doc describes only its splice role.
    • draftPreview's "for the drafts tool's listing only" has one caller.
    • The riskText caveat (a connector whose error echoes what it was sent) is recorded in the commit message and the description, and the changelog's "the notice itself" is still accurate.
  • The prompt-cache claim is true at the base. The three reason maps reach only the notice, the listing, read and the catch-up notice. The tool definition (src/framework.ts:8726-8727) is unchanged by this PR.
  • Runs at this head, on Node 22.22.1 with context-manager 0.14.1 and membrane 0.5.87: tsc is clean, and prose-drafts, explicit-prose-routing and present-while-acting pass 269 of 269. CI is green on all 10 checks at 8d6ca71f.

Method: two independent passes, merged after re-verification.

  • Sol pass. Codex gpt-5.6-sol at xhigh, in a read-only sandbox, ran for 481 s: 80 shell commands, about 4.52M input tokens (4.33M cached) and 28k output tokens (15k reasoning). It read the head, the R2 head, the base and main from local refs. It was given this PR's description, both earlier reviews and #666. It ran the typecheck and a read-only git merge-tree. Its sandbox couldn't run the tests, and it had no network.
  • Claude pass. Freshness (PR, base, #656, main, CI state, and when #716 and #824 merged relative to R2). The run at this head; red-first with src/ as at 88e3c8d1; a trial merge into main with a resolved notice and the three files run on it; one probe on that merge, a stream error after a later-round hold; and reading #716's and #824's commits and changelog fragment.
  • Who caught what.
    • R3-1 was found by both. Sol named #716 as the source of the per-place rework. Claude traced the conflicts to the two merges (#716 for the one line, #824 for the rest). Claude also ran the resolution and checked what main already ships.
    • R3-2 is Sol's.
    • Sol's third item, the inherited scope comment at src/framework.ts:234 and the tool description, was not adopted as a finding. Both are older than this PR, R2 already noted them as out of scope, and on main #824 has rewritten the tool description.
    • The R2 dispositions were reached independently by both, and agree.

nissa-seru pushed a commit that referenced this pull request Oct 27, 2026
…ts that continue it

After an explicit send, a locus or hybrid turn holds plain speech aimed where
the send may have posted, and a later round's hold notice tells the agent
"your speech there stays held until a new message reaches you or this turn
ends". Only the tool-result guard's retry carried that hold into a new stream
(guardRetryTurnSilenced). The other starts that continue the same logical turn
began unsilenced, so the agent's next words posted where its send had already
posted:
- the error policy's retry after a stream's error event;
- the retry after such a retry's own start fails;
- the retry a provider hold keeps for the turn (TURN_RETRY), after a stream's
  error event, a stream's throw or a failed start;
- a context-budget restart.
Each is one turn: one turn id, one receipt, and the retry's request carries
the notice that made the promise. Found by #668's third review, which ran the
error-retry case on a trial merge; reproduced on main 7e4f7e2 for all four.

The turn's silence and sends now ride on the request that continues the turn,
as TURN_CARRY beside TURN_RETRY, and a start reads them only when its request
continues the turn (turnCarryOf). One mechanism for all five continuations,
the guard retry included, replaces the per-agent map:
- driveStream registers what it would carry per stream (streamCarries); the
  budget restart reads it when it queues the continuation, with a refused
  skip_reply's own-round silence already restored.
- A stream that fails builds its retry, and the retry a hold keeps, through
  retryOf, which first applies a message its last boundary injected
  (adoptInjectedRound), so the retry answers it.
- beginAgentTurn passes its carry on to driveStream, and to every retry or
  hold it makes after a failed start.
- The scheduler's merge takes the carry from whichever request in the batch
  continues the turn, since the request that leads the merge can be a wake.
- withTurnCarry sets the carry it's given and deletes the symbol otherwise,
  so a retry built after a start dropped its carry doesn't revive the one its
  trigger held.

What ends the hold is what the notice says, a new message the start reads, by
the predicate a tool boundary already uses (isConversationalInjection), so a
held retry and a live boundary agree:
- noteArrival keeps each conversational row stored into the open turn
  outside a live boundary, with when it was heard (TurnRouting.heard): one
  stored while none of the agent's turns is alive, as during a provider hold;
  one stored in another resident's window, which this resident reads too;
  one flushed or assembled at a start; one a restart's boundary leaves to the
  turn's next start.
- Each carry records where its stream's compile read through (read), taken
  just before the compile, after the start's flushes and context hooks. A
  start drops the carry if a row heard after that is still in the agent's
  store when it compiles, as remainingArrival weighs arrivals, so a message
  removed unread ends nothing. heard is optional on TurnRouting: tests and
  other harnesses that build a turn's routing by hand leave it unset, and
  noteArrival and heardSince take it as empty.
- A message the failed stream's last boundary injected clears it through
  retryOf. A budget restart resumes the turn, so the mid-turn input signal
  survives into its stream and clears the carried silence when its first
  round is routed, as at any round.
- A start that opens a new logical turn drops the carry.
- A wake that brings no message, such as a silent heartbeat merged into a
  held retry, leaves the hold in place.

The existing test "a budget restart after a refused wake starts from its
trigger, as after any other round" asserted the restart after a send speaks
unsilenced, the behaviour this change removes. It is turned round: the restart
keeps the send's hold, the refused case like the plain one, and both hold the
restart's line as an explicit-send draft, which also pins that a refused
skip_reply's own-round silence is restored before the carry is taken.

What this leaves:
- holdRequest moves TURN_RETRY, not the carry, onto an equal request already
  held, so such a retry starts unsilenced, as on main.
- A conversation fork's channel messages are written straight into its window
  (writeToFork) and aren't weighed, so a fork's held retry keeps the hold
  through them. Conversation routing is deprecated (agent-framework#235).
- A start decides its carry, and takes its read, just before it compiles, but
  the compile first waits for any tool-result guard annotation in progress
  (afterAnnotations). In a host with several residents, a conversational row
  another resident's window gets during that wait is compiled under the
  carried hold, and a row heard before the wait and removed during it ends
  the hold though the compile doesn't read it.

Tests: test/continuing-start-silence.test.ts, through the speech-route
fixture's send_message. The four continuing starts keep the line held, in
locus and hybrid, and so do a provider hold's retry after a throw and after a
failed start, a retry after a refused skip_reply's round, a silent heartbeat
merged into a held retry, an XML-mode retry that is a tool turn, a held retry
that answers a new message and sends again, through its own retry, and a held
retry after a message removed unread: all red with main's framework.ts. A new
message read by a held retry, an error-policy retry or a budget restart is
answered in the room, in XML tool mode too, and a held retry that read one and
whose start then fails passes no hold on; overheard chatter stored during a
hold or queued while the failed request ran clears the hold, and so does a
message another resident's window gets while the failed stream runs or while
the retry gathers its context; a retry after a send elsewhere speaks in the
turn's own; a budget restart after a refused skip_reply's round with no send
before it speaks; and a held retry that opens a new turn carries no hold into
it, with or without a failed start after it. Those pass on main too and pin
the clearing.
The guard retry's existing tests pass unchanged.

The text-only branch's comment now names a continuing start that carried the
suppression beside a suppressProse trigger, and docs/tool-result-guard.md
loses its "Explicit-send suppression across a budget restart" limit, which no
longer holds.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants