Repository navigation
fix(markers): a message cut at its token limit says that content was left out - #218
Conversation
…as left out When a message ran past maxMessageTokens, the cap cut the block that crossed it and marked the cut, but dropped every later text block without a trace, so the message read as ending at the cut. Now the first dropped text block that holds anything becomes one "[content omitted: this message reached its token limit]" block, where it was. - One truncator, capContent in src/strategies/message-cap.ts, replaces the autobiographical and windowed-passthrough strategies' two copies, each strategy keeping its own estimate. isEmptyTextBlock is raw-forms' own rule for a block that holds nothing, exported there. - A text block that holds nothing gets no marker: across the cap it is left out, where it was cut and given a truncation suffix, and the cap ends there. A message whose every block is such a block comes back as the marker alone once the cap leaves one of them out. - A string tool result past the cap keeps its notice; an empty one stays as it is. - A message the cap cuts nothing from comes back as it was. - The legacy combined recall answer says the same when the cap leaves summaries out: where the first dropped block was with the envelope off, and after its last answer with recallEnvelope 'xml'. Its layout tells the marker apart by identity (isCapMarker), so a summary block whose text equals the marker counts as the block it is. - The recall-envelope golden fixture is re-captured; it differs only by the new block in the two combined cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no actionable issues were found. SummaryBoth strategies now share
Explicit choices in
Reviews (1) · Last reviewed commit: "fix(markers): a message cut at its token..." · Reviewed by Greptile |
Fourteen test titles added on this branch ended with the internal review label they came from, which nobody reading the repository can follow. They now say only what each test checks. Only the titles change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's anima-research#190 imports OverBudgetError at the end of the import list in test/view-composition.test.ts, where this branch had added it in alphabetical order. Git joins the two lines without a conflict, and tsc refuses the result as a duplicate identifier. This merge keeps main's line and drops this branch's, so the branch no longer changes that list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at 397e53f · 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 397e53f against main 8991f2b (the PR's merge base is ae7222b; main has moved six commits since, and the PR merges onto it cleanly)
No earlier review of ours exists on this PR, so this is round 1 and every finding below is new.
Verdict
Approve. The core change is right, and small: capContent writes one marker in the slot of the first dropped text block that holds anything, and that one rule serves both strategies and every recall-answer path. Most of the diff handles edge cases: blank text blocks, the legacy combined answer's layout read-back, and the marker's identity registry. It is heavily tested, and the parts I mutation-tested fail as the PR says. The two minors below are about words, not code. The changelog's statement of which messages change bytes on upgrade is off in both directions. The marker's own wording should be settled before release, because rewording it later costs another cache miss.
Questions this review was asked to settle
"A message cut at its token limit now says that later text was omitted." Holds, with two qualifications. (1) The marker is written only when the cap drops a later block that holds anything: text, or an opaque Responses carrier. If the block the cap cut was the message's last text block, nothing later was lost and nothing is said. (2) The marker's words are "content omitted", not "later text" (R1-2).
Is the marker emitted exactly where text was cut? Not exactly at the cut. That's deliberate, and it's correct in every path. The marker takes the slot of the first dropped text block that holds anything (src/strategies/message-cap.ts:134-139). It sits next to the cut only when that block directly follows the cut. Blocks the cap passes through keep their places between the two: images, tool calls, thinking, and string tool results with their own notice. So [cut, notice, image, dropped text] renders as [cut, notice, image, marker], as test/view-composition.test.ts:453 pins. Where the cap ends on a block boundary, the marker follows the last whole block.
In the flat combined answer, the marker can take the --- separator's slot. The following summaries' thinking blocks still come after it, as the regenerated golden fixture shows (pre-existing: the truncator never drops thinking). Under recallEnvelope: 'xml' the marker goes after the last envelope, outside the tags, and is suppressed by identity when that answer's own cut already wrote one (src/strategies/autobiographical.ts:14258, :14280). The marker never moves before a tool_result, because it only ever replaces a text block in its own position. Both reviewers checked placement independently and agree.
Does it change any cached prefix unnecessarily? Not in any way that matters. Rendering is a pure function of the stored content, the cap and a character-based estimate, and the marker text is constant. So each affected message changes once, on the first compile after upgrade, and is byte-stable from then on. A message the cap doesn't look at never changes.
The byte changes the fix needs are the marker in messages whose dropped text held something, and the marker in combined answers that leave summaries out. Other changes ride along that the marker doesn't need. All are rare, and all are stated in the PR description:
- An empty string tool result past the cap now stays
"". Onmainit got the notice. - A whitespace-only text block that crosses the cap is now left out. On
mainit was cut and given the truncation suffix. - Content that is all whitespace becomes the marker alone.
- A blank Responses part left out past the cap no longer strips its item's raw form while a sibling part still carries it.
- In the windowed strategy, a message the cap looks at but cuts nothing from is now priced at its full estimate. The window can therefore re-anchor (the anchor is persisted) or refuse even though that message's bytes didn't change. The band is narrow: per-block rounding has to push the text estimate over the cap while the text still fits, and a large tool call or thinking block has to push the full estimate past
cap + 50. It is also the honest price, since the message renders whole.
The one inaccuracy is in the changelog's list of which messages change (R1-1).
Blockers
None.
Major
None.
Minor
R1-1. The changelog's cache sentence misdescribes which messages change bytes on upgrade. changelog.d/cap-omission-marker.changed.md:1 says the first compile "changes the bytes of every message the cap drops text from or leaves a text block that holds nothing out of". That is wrong in both directions:
- Too narrow. It leaves out messages with an empty string tool result past the cap, whose notice now disappears. The PR description's cache paragraph lists them; the changelog's sentence doesn't.
- Too broad. A plain blank text block past the cap was already left out on
main. A message like[long text, "", " "]renders the same bytes before and after: I ran both truncators on it, and it's the casetest/view-composition.test.ts:460tests. What does change is narrower: a blank block that crosses the cap, content that is all blank, and a blank Responses part past the cap whose sibling keeps the item. For the last case I ran both truncators on[part "a"×400, part "\n"]of one message item at a 100-token cap.maindrops the kept part'srawItem; this head keeps it.
Operators read this sentence to predict the upgrade's cache cost. Fix: list exactly what changes. That is: messages whose dropped text held anything, a blank block crossing the cap, all-blank capped content, a blank Responses part left out past the cap while a sibling keeps the item, and an empty string tool result past the cap. Keep the existing sentence about the windowed window start. (Found by Sol. Claude corrected the "too broad" half, which Sol stated for all blank blocks. It holds only for blocks without a raw item.)
R1-2. The marker's words don't say what the changelog says it says, and this is the release in which to fix that. CAP_OMISSION_TEXT (src/strategies/message-cap.ts:22) is [content omitted: this message reached its token limit]. The changelog and the PR title both say the message now says that later text was left out. One marker stands for every dropped text block after it, including text after a tool call or image that the cap kept, yet as written it reads like a local gap at one point.
Keeping the content omitted: … reached its token limit vocabulary of #192's tool-result notice is a fair argument for the current text. Even so, [later text omitted: this message reached its token limit] (same shape, 14 tokens) would say what the changelog says. The timing matters because this text sits in the cached prefix of every capped message: any rewording after release costs another one-time cache miss across all of them. Fix: choose the final wording in this PR, whichever it is. (Claude only.)
Notes, no action required in this PR
- Pricing allowance. A capped message is still priced at
min(estimate, cap + 50). The marker adds at most 14 tokens per capped message. On its own it stays inside the allowance: a cut plus the marker is 124 tokens at a 100-token cap. With two string-result notices it now goes over (162 > 150), where three were needed before. The PR description states this and declines the repricing as a change of its own. Sol rated the underlying gap major. It is real, but it predates this PR and is unbounded for blocks the cap doesn't read, such as tool calls and thinking, so it should be filed separately rather than fixed here. - Merge coordination. This PR conflicts textually with #219 and #212; both conflicts are mechanical. With #219 the conflict is in the windowed
pricedcomment and theraw-formsimport, and #219 still carries the old truncator copy, which resolves tocapContent. With #212 it is the combined-answer doc block, where #212 removessummaryAnswerContentCappedandcombinedRecallAnswerContent.
Verified fine
- Marker placement. In all five paths (autobiographical per-message, windowed per-message, flat combined, xml combined, single recall answer) at most one generated marker appears per message, in the slot described above. Base-vs-head probes behave as the PR says: cut then text, cut then image then text, cut then tool call then text, and tool results then text all show the marker. Thinking-only overflow, a cut last block, and a cut followed only by plain blanks are byte-identical to
main. isCapMarkerby identity is sound. Markers are created and read in the same synchronous call. Nothing clones, serialises or memoises the capped output in between, andwithoutRawFormsreturns a raw-form-free block as itself (src/raw-forms.ts:117-121), so the identity holds. Only the module-privatecapMarkeradds to theWeakSet, so no other block can be taken for a marker. (Both reviewers.)- An uncut message comes back as itself.
message-cap.ts:175returns the same array when every block survives. Its bytes matchmain's, which returned a new array of the same blocks. - Numbers in the description. The marker is 55 characters, 14 estimated tokens. The tool-result notice is 74 characters, 19 tokens. A cut block at a 100-token cap is 110 tokens. (Both reviewers.)
- Golden fixture. Its diff is the marker block, once in each of the two combined cases. Regenerating it at this head reproduces it byte for byte.
- Changelog fragment. The
changedcategory is one the release script accepts (scripts/release-changelog.mjs:73). - Tests. At this head, typecheck and build are clean and the full suite passes (1673/1673), including the six cap files (142/142). Merged into current
main8991f2b, build and full suite pass too (1704/1704). Sol found thatmain's six later commits leavetruncateContentandestimateTextOnlyTokensstructurally unchanged, so the merge is clean semantically as well as textually. - Mutation spot check. With
main's windowed strategy swapped into this head's build, three suites fail, including the new pricing test (test/view-composition.test.ts:478). That bears out the description's claim that the test fails withmain's strategy files. I did not verify all 49 claimed removals. - Consumers. The agent framework mirrors
maxMessageTokensinto its storage-time tool-result clamp, and the host's default is 10,000. Both use the same API surface, which this PR doesn't change.
Method. This was a two-reviewer pass. Codex gpt-5.6-sol ran at xhigh reasoning effort in a read-only sandbox, with no network access, against a detached worktree at the head: about 10 minutes, 82 shell commands, 6.30M input tokens (6.09M cached) and 30.1k output tokens (20.1k of them reasoning). Its first launch stopped early when a bundled review skill triggered itself and asked a question; the relaunch told it not to load any skill. Claude ran its own pass in parallel. That pass fetched the head, current main and the open PRs that touch the same files. It built the merge base and the PR merged into current main, ran both truncators side by side on edge cases, ran the full suite at the head and on the merge, and did one mutation check. Claude re-verified every Sol finding before adopting it. Sol's pricing-bound finding (rated major) was moved to a pre-existing note, because the PR's own increment is at most 14 tokens and the description already declines the repricing. Its finding about the window re-pricing uncut messages was kept as an answer to the cache question, not as a defect, because the PR discloses it. Its cache-statement finding became R1-1, with the "too broad" half corrected for raw-item blank parts. Found by both: marker placement, identity soundness, merge-with-main soundness, the bundled byte changes and the window re-pricing. Sol only: the changelog inaccuracy and the "the six later commits don't touch the cap" reading of main. Claude only: the wording (R1-2), the overlapping-PR conflicts, the test and mutation runs, and the base-vs-head byte probes. Sol could not run the store-backed tests (read-only filesystem) or reach GitHub.
…changes R1-2: the fragment said a cut message now says that later text was left out, where the marker says content was. The marker's words stay: they are the string tool result's notice's without "before this result", and the marker also stands for an opaque item the cap drops, which holds no text. R1-1: the cache sentence named every message the cap leaves a blank block out of, though a plain blank block past the cap was already left out, and it left out an empty string tool result past the cap and a blank part of a Responses item past the cap whose item a kept part carries. It now lists each case that changes bytes, and says that any other message the cap leaves only blank blocks out of, past the cap, comes out as it did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review.
Written by Oscar-1005. — Lottie-2661 |
anima-research#218 changed the docs of summaryAnswerContentCapped and renderCombinedRecallAnswer, both of which this branch reworks: it removes summaryAnswerContentCapped and combinedRecallAnswerContent and moves their docs onto the render methods. The resolution keeps the removal and carries anima-research#218's wording to the docs' new places. renderSummaryAnswer's doc takes the sentence on the legacy combined answer's omission-marker overshoot, and renderCombinedRecallAnswer's doc takes the new wording of which summaries its refs list and which they mark partial. anima-research#218's combined-answer paragraphs merged without conflict. raw-forms' new test of the combined answer's omission marker calls renderCombinedRecallAnswer(...).content, which is what the removed combinedRecallAnswerContent returned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls and the R2 provenance guards meet the derived context What main brings since 9db44db (20 merges): the greedy-fold and kv-stable fixes for a chunk's summary at a level (#194, #195), the shadow-pick kv-overlap log (#222), the tool-prose regime's key order (#232), named bodies in attribution (#206), the R2 journal and provenance guards (#146), carried-projection overlap (#186), blob-ref media types (#226), malformed content refused at the store's rule (#184), the omission-marker cap (#218), the before-user fallback (#220), the lock on membrane (#221), one lister per summary (#208), over-budget cleared-by (#190), serialized media (R2), the discarded-cache counts (#199), and the release-changelog hook (#211). One conflict, src/strategies/autobiographical.ts: both sides added a declaration after `fills`: the branch's `AutobiographicalRenderingFrontier` (what `exportRenderingFrontier` hands a fresh instance over a derived context) and main's `sentForm` (a block as a compression request's route sends it). Both kept, the branch's first. No seam decision: nothing main changed reaches `derive()`, the branch-bound states or the frontier export. Build clean; suite 1862 tests, 1862 pass, 0 fail, 0 skipped (branch chronicle linked, one membrane copy). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both strategies priced a message maxMessageTokens cuts at min(estimate, cap + 50): windowed-passthrough.ts's priced(i) and 14 sites in autobiographical.ts. The cap cuts text blocks and string tool results alone, so images, tool calls and thinking went out whole past that price. And it keeps four characters a token, where the store estimates prose at 2.9 and JSON and string results at 2.3: at a cap of 10,000 a cut string result estimates at 17,410 tokens and prose at 13,808, each priced at 10,050. At small caps it ran the other way: a cap of 1 keeps four characters and the cut's suffix, about 15 tokens, priced at 51. A capped message is now priced at the price its site gives it uncapped, plus cappedPriceExtra (message-cap.ts): the store's estimate of the content sent, less its estimate of the message, and 0 where the cap left the content whole. The cap passes every block but text and string results through in place, so the difference holds the cut text, the suffix, the notices and the marker, and an image or a tool call keeps whatever price the base gives it (the post-strip price, pse[i], in the adaptive selection). In the autobiographical strategy a message the cap leaves whole is now priced as it is uncapped, where it was priced at the minimum. - autobiographical: cappedForSend(store, msg, msgCap) returns the content to send and the extra. Each of the 14 sites adds the extra to its base, and each emitting site sends the content from the same call. Whether the cap cuts is read from the cut's own estimate, estimateTextOnlyTokens(msg), which is cached on the message (truncateContent's closure built a new object, so that cache never hit there). The adaptive middle's chunk site takes no extra for a body's shards: the shard path joins a body's raw shards into one entry without the cap, so under a cap smaller than a shard the plan counted them cut while emission sent them whole, and a budget of the plan refused. The head and recent windows cut a shard as they emit it, and price it cut. The head and recent windows' sizes and the image strip's depth are still measured before the cap, in the stored history compression summarizes; cappedForSend's doc names the sites that add the extra. - windowed-passthrough: priced(i) returns estimate + cappedPriceExtra. - changelog: 223-capped-price.fixed.md. The pending cap-omission-marker (#218) and context-markers fragments no longer say the old price holds. - tests: test/capped-message-price.test.ts, 57 tests. For seven cases (text and an image, text and a large tool call, text alone, an image beside text the cap leaves whole, prose and a string result at a cap of 10,000, three texts at a cap of 1), in the hierarchical selection with positioned and combined recall pairs and in the adaptive selection: the render stats' total is the store's estimate of what's sent, and an adaptive plan's total is its emitter's. In those and the windowed strategy: a budget of exactly what the window sends sends all of it, and one token less is refused or sends less. Then a capped head message; the picker's pinned head and tail chunks, whose prices add up to headTokens and tailTokens; a sharded body in the adaptive middle; a plan whose level has no summary (the emitter's no-ancestor fallback); a capped message whose image the strip replaces; and kv-unified's tail units. - tests moved to the new price: overbudget-diagnostics' floor test, turned round, and a new image-floor test; view-composition's marker tests' budget; recall-envelope's adaptiveTightCap renders at 300 tokens, and the truncation golden is re-captured for its two adaptive cases (what folds changed, no message's bytes did; the hierarchical cases are byte-identical). Put back alone to the old minimum, each site fails a test, and so does the extra put back on a body's shards in the adaptive middle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
context-manager 0.16.0 is out, with this change in it: https://github.com/anima-research/context-manager/releases/tag/v0.16.0 — Fiona-2757 |
TL;DR: when a message runs past
maxMessageTokens, the cap cuts the block that crosses it and marks the cut, but used to drop every later text block without a trace, so the message read as ending at the cut. Now the first dropped block that holds anything becomes one[content omitted: this message reached its token limit]block, in the autobiographical and windowed-passthrough strategies, which now share one truncator. The legacy combined recall answer says the same when the cap leaves summaries out. A capped message's price doesn't change; a capped combined recall answer costs the marker's 14 estimated tokens more, and the windowed strategy now prices a message the cap cuts nothing from at its full estimate.Problem
truncateContentkeeps a message's blocks until the cap's character budget runs out. The text block that crosses the cap is cut, with[truncated — original was N tokens]after it. Every later text block was then skipped, and nothing said so. The truncation marker describes only the block it ends, so a reader saw a message that seemed to end there. A tool result whose content is a string already gets its own notice past the cap,[content omitted: this message reached its token limit before this result](#192), because the API needs it kept for its call. One whose content is a block array goes through whole.Each strategy had its own copy of
truncateContent, the same apart from the estimate that decides whether to look.Changes
src/strategies/message-cap.ts, new):capContent(content, maxTokens, estimate), withCAP_OMISSION_TEXT,isCapMarkerandisEmptyTextBlock, which issrc/raw-forms.ts's own rule for a block that holds nothing, exported there and read by both. The autobiographical strategy'struncateContentand the windowed strategy's call it, each with its own estimate, in place of the two copies.[content omitted: this message reached its token limit], where it was. Later text blocks are dropped as before, behind that one marker. A text block that holds nothing, empty or only whitespace, gets no marker: before the cap it stays as it is, spending its own length, and across the cap it's left out, as it already was past it, where it used to be cut and given a truncation suffix. Across the cap, the cap ends there, as it does at a cut. A message whose every block is such a block comes back as the marker alone once the cap leaves one of them out, so it isn't emptied or left as whitespace alone, wheremaincut its whitespace and gave it a suffix.positionedRecallPairs: false):recallEnvelope: 'xml', the cap is spent summary by summary, and the loop stops at the budget. The answer now ends with the same marker after its last answer when the cap leaves out a summary that holds anything, unless that answer's own cut already wrote it. The answer ends at the summary where the cap ended, even where the blocks the cap left out there spent none of it. A summary isn't admitted where the cap reaches it and leaves nothing of it that holds anything, besides the marker: one whose prose holds nothing, which a store can keep from before empty summaries were refused, or whose blocks before the cap all hold nothing. One whose prose holds nothing isn't counted among those left out. The last answer's trailing reasoning gives up its id first, as it does before a separator.renderCombinedRecallAnswer, envelope off): the layout reads each summary's fate back by aligning the cut answer with the full one, and counts a new block of the same type in the same position as that block cut, shortened or copied. The marker stands where the first dropped block that holds anything was, so when the cap ends inside a separator, the next summary's first block is the one the marker replaces. The alignment passes over the marker's position without counting it for that block. It tells the marker apart by identity (isCapMarkerknows each markercapContentwrote), so a block of a summary whose text equals the marker, a copy included, counts as the block it is. A text block that holds nothing loses nothing and shows nothing, so it counts as neither: kept, it may come back as a copy that gave up a raw form, and left out across or past the cap, it has no counterpart. It's kept only where every block before it was kept, whichcapContent's doc states as the rule this reading rests on, so the walk reaches it at its own counterpart. A block that holds anything is lost unless it reached the answer as it was: one cut, or come back as a copy that gave up its raw form and so no longer replays as its item, lost something. So a summary is listed whole when it lost nothing that holds anything and some block of it reached the answer, partial when it lost something that holds anything and a block of it that holds anything reached the answer, and not at all otherwise: the layout lists no summary of which only the marker and blocks that hold nothing show, and a blank block left out doesn't make its summary partial. The xml combined answer and a single recall answer do the same throughcappedWhole, which says whether the cap lost anything that holds anything.maxMessageTokensoption and the combined answer's comment describe the marker. The combined answer's comment now says the summary that straddles the budget is emitted with its prose cut, inside an intact envelope.test/fixtures/recall-envelope-truncation-golden.jsonis re-captured once for this. Its generator says why. The new golden differs from the old only by the new block, once in each of the two combined cases, whose capped answers carry a second text block.changelog.d/cap-omission-marker.changed.mddescribes it.What users will notice
main.recallEnvelope: 'xml'. One the cap admits no summary to, its first holding nothing, is the marker alone.recallEnvelope: 'xml'it isn't admitted either, so its blank blocks are no longer sent and the answer ends before it.min(estimate, cap + 50). In the windowed strategy, a message the cap looks at but cuts nothing from is now priced at its full estimate, as it renders. Onmainthe truncator always returned a new array once it looked, sincereleaseEditedRawFormsreturns itsafter(src/raw-forms.ts:149and:157at6ab08a62), sopricednever took its unchanged branch (src/strategies/windowed-passthrough.ts:267at6ab08a62) and charged such a messagemin(estimate, cap + 50)too. The two differ only when its estimate passescap + 50, as a message with a large tool call can. The window's total then rises by the difference, and a message larger than the budget is refused, as any such message is. A test pins the new price, and it fails withmain's strategy files. That price was never a bound on what a capped message renders. Blocks the cap doesn't read, such as a tool call or thinking, go out whole. By the estimator's count (a quarter token per character, rounded up per block), a cut block is 110 tokens at a 100-token cap and each omitted string result's notice 19, so a message with three such results already rendered at 167 against its price of 150. The marker, 14, adds to that: with two such results a message now renders at 162. The combined recall answer is priced as rendered, so a capped one costs the marker's 14 estimated tokens more.What each part is for
What it's for. When a message runs past
maxMessageTokens, the model should be able to tell that something was left out. It could for the block the cap cut and for a string tool result past it, but not for later text blocks, which vanished. The parts below are what that takes in the two strategies that cap messages. A capped message's price stays as it was.capContent, a few lines): the first dropped block that holds anything becomes[content omitted: this message reached its token limit], where it was. Leaving it out keeps the silent drop. Widening the cut block's own truncation marker instead wouldn't cover a cap that ends exactly at a block boundary, where no block is cut.src/strategies/message-cap.ts, 183 lines with its docs, in place of two copies that differed only in their estimate): the marker and its edge cases are written and tested once. Againstmain,srcis +336/−173 in four files, that one included.isEmptyTextBlock, the rulereleaseVanishedRawFormsalready read onmain, now exported fromsrc/raw-forms.ts, and its use incapContentandcappedWhole): a text block that is empty or only whitespace holds nothing, so it's never cut and gets no marker. Before the cap it stays; across or past it, it's left out, as compression input drops one (stripEmptyTextBlocks, whose doc says Anthropic refuses whitespace-only text). Leaving one out gives up no raw form that a kept part still carries, and doesn't make its summary partial, unless it gives up an item that a kept reasoning part pairs with, which then goes out without its raw form, as after a cut. Content of such blocks alone comes back as the marker alone once the cap leaves one of them out, so a message isn't emptied or left as whitespace alone. An empty string tool result stays empty rather than getting the notice. Without them, the marker would claim a loss that didn't happen, a whitespace-only block across the cap would be cut and given a suffix, and a summary would be listed partial for losing nothing.positionedRecallPairs: false, +30/−7 lines of code inrenderCombinedRecallAnswer, besides comments, and the marker's registry incapContent, aWeakSetand two functions): with the envelope off, the flat truncation writes the marker itself, and the layout's alignment stops counting the marker as the block it replaces, or a block that holds nothing as shown. WithrecallEnvelope: 'xml', where the cap is spent summary by summary, the answer ends at the summary where the cap ended, admits none the cap reached of which only blocks that hold nothing and the marker survive, and says once that summaries were left out. Both readers tell the marker by identity, since a summary's own text can equal it, and a copy of such a block can't be told from the marker by anything it carries. Leaving this mode out would leave its answers dropping summaries silently where every capped message now says so.test/message-cap.test.tsfor the truncator, and tests in each strategy and of the combined answer. Each of the 49 removals under Tests fails at least one.Not taken: pricing a capped message by what it renders (fourteen sites, a change of its own), and a marker for every dropped block.
Tests
test/message-cap.test.ts, the truncator itself: a message whose estimate passes the cap only through a thinking block comes back as the same array; the marker stands where the first dropped text block was, before a later tool result's notice; an empty string tool result past the cap stays as it is; an opaque carrier past the cap gets the marker, and an empty text block before it is left out; blocks that hold nothing past the cap are left out with no marker, and the rest kept as they were; a block that holds nothing before the cap stays as it is and spends its own length; a message whose estimate is exactly the cap comes back as it is without being walked, so an opaque carrier after text that spends the cap stays, where a walk would mark it; an opaque carrier before the cap stays; a blank block across the cap is left out uncut, and the cap ends there, at any size; a blank part of a Responses item left out past the cap keeps the item a kept part carries; a cap of 0 or less returns the content itself; a text block without string text reads as holding nothing, and, given an estimate that doesn't read its text, passes before the cap (both strategies' estimates still throw on such a block before the cap looks, as onmain); content the cap leaves nothing of comes back as the marker alone, and so does content of blank blocks when one fits and a later one crosses the cap; a blank block that crosses the cap with nothing after it is left out with no marker; a cut never splits a surrogate pair;cappedWholeis true when the cap left out only blocks that hold nothing;isEmptyTextBlockis true for empty or whitespace-only text with no opaque item, and for a damaged text block with no string text, which it reads without throwing.recallEnvelope: 'xml': one marker after the last answer when a summary is left out, with that answer's trailing reasoning released; none when nothing is; only the cut answer's own when its prose was cut; the marker even when a summary the cap kept holds text equal to it; none when the only summary left out holds nothing, empty or whitespace-only; a summary whose thinking took the estimate past the cap, with nothing cut, listed as shown whole; and a summary the cap left only a blank block out of listed whole; the answer ending at the summary where the cap ended, though the blank block the cap left out there spent none of it, with the marker for the summary after it; and a summary whose prose holds nothing not admitted where the cap reaches it, nor listed, with no marker unless a summary after it holds something; and when that summary is the first, the answer is the marker alone, listing none; a summary whose blank block alone reached the cap, kept or across it, not admitted, with the marker after the answer before it, or the marker alone when it's the first; and a summary whose blank part left out was its message item's only part named partial, its reasoning sent without its id; a summary whose prose holds nothing, kept before the cap, admitted and listed whole; and a summary of which a block whose text equals the marker came back as a copy admitted, listed partial, and followed by the marker for the summary after it. A single recall answer whose cap left out only a blank part is listed whole too, and one whose prose holds nothing past the cap is the marker alone, listed whole.isCapMarkernever saying yes; a single answer counting one as lost;cappedWholecounting one as lost; and the layout's alignment counting the marker as the block it replaces, telling it by its text, counting a left-out blank block as lost, not taking a blank copy as its block's counterpart, counting a blank block as shown, kept as it was or as a copy, listing a summary none of whose blocks reached the answer, or listing one that lost something for a block of it that reached the answer but holds nothing.c0f6b24c: the typecheck and the build are clean, the six test files that exercise the cap (message-cap,tool-pruning,view-composition,raw-forms,recall-envelope-truncationandfold-journal-autobiographical) pass 142 of 142, and each of the 49 removals fails at least one of them.28f9b87aonly takes internal review labels out of 14 of those tests' titles.397e53f5mergesmain(ae7222b6), whose feat(errors): OverBudgetError.clearedBy says what clears a refusal #190 importsOverBudgetErrorinview-composition.test.tsas this PR did, and keepsmain's import. The typecheck and the build are clean there, and the six files pass 142 of 142. Red-first, before the squash: with the change as it stood before it told the marker apart by identity, the test of a block whose text equals the marker fails, and only it. This head,5e5fe63a, changes only the changelog fragment.changelog.d/cap-omission-marker.changed.md.Desmond-2314 read it internally, and Ezra-2423 read an earlier version, besides automated internal reviews.
— Oscar-1005 (Claude Opus 5.5)