diff --git a/devlog/_plan/260829_cursor_tool_continuation_pairing/070_phase8_checkpoint_suffix_orphan_strip.md b/devlog/_plan/260829_cursor_tool_continuation_pairing/070_phase8_checkpoint_suffix_orphan_strip.md new file mode 100644 index 0000000000..cd47672280 --- /dev/null +++ b/devlog/_plan/260829_cursor_tool_continuation_pairing/070_phase8_checkpoint_suffix_orphan_strip.md @@ -0,0 +1,688 @@ +# wp6 — the orphan-strip loop eats the whole checkpoint suffix + +Status: plan. Work-phase wp6, criterion `c-2`. Predecessors: `050` (superseded), `060` (merged as +#2936 / `d882caed5`). + +## Symptom the user reported + +Cursor models "무한 출력" and "툴 출력을 못받고" — the turn never terminates and the model behaves as if it +never saw its tool output. + +Reproduced on merged `dev` `d882caed5`, isolated proxy, `cursor/grok-4.6`, three sequential `echo` +commands requested one at a time. Counts are from the COMPLETED artifacts, recounted after audit r8 +found the first table had been read from a file that was still being written: + +| observed | `live3b.jsonl` | `live3.jsonl` | +|---|---|---| +| distinct commands requested | 3 | 3 | +| `command_execution` items emitted | 21 | 133 | +| STEP1 runs | 10 | 64 | +| STEP2 runs | 10 | 67 | +| STEP3 runs | 1 | 2 | +| "interrupted" mentions | 8 | 134 | +| terminal answer | reached, after 21 executions | reached, after 133 | + +The turn does eventually terminate. The defect is that it burns 21 to 133 tool executions to run three +commands, repeatedly re-running work that already succeeded. The earlier claim that it never terminates +was an artifact of counting a file mid-run and is withdrawn. + +The narration alternates verbatim: "STEP1 already ran. Next is STEP2." then "STEP1 was interrupted last +time, so I'll run it now." The model contradicts itself every other turn, which is the signature of a +prompt whose history changes shape between turns rather than of a confused model. + +## Root cause + +`rootPromptMessages` ends its external-model pruning with an orphan guard: + +```ts +const historyEntries = [...keptPrior, ...active]; +// Guard against orphan assistant / toolResult at the start of the retained suffix. +while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") { + if (historyEntries.length <= active.length) break; + historyEntries.shift(); +} +``` + +On a **full replay** the premise holds: history starts at the real conversation start, so a leading +assistant or result entry means the user turn was pruned and the entry is genuinely orphaned. + +On the **checkpoint path** the premise is false. `buildPreparedCursorRunRequest` replays only +`rawMessages.slice(suffixStart)`, and `suffixStart` is `coveredMessageCount` — the count of messages the +checkpoint already carries. A suffix therefore legitimately **begins** with the assistant message +whose initiating user turn sits inside the checkpoint. The loop reads that as an orphan and shifts it +off, then reads the next entry the same way, and keeps going until `historyEntries.length <= active.length` +stops it — that is, until nothing but the trailing active result block is left. + +The `break` is what makes this total rather than partial: it fires only when the survivors are exactly +the active block, so every earlier pair is discarded no matter how many there are. + +### Measured, with a checkpoint covering message 0 and N completed pairs in the suffix + +| pairs in suffix | `rawMessages` | roots emitted | what the model sees | +|---|---|---|---| +| 1 | 3 | 2 | seed + result 1 | +| 2 | 5 | 2 | seed + result **2** only | +| 3 | 7 | 2 | seed + result **3** only | +| 4 | 9 | 2 | seed + result **4** only | + +This table needs one qualifier audit r8 supplied: it holds for the shape a real agent produces, where +the assistant NARRATES before calling a tool. With a bare tool call and no assistant text there is no +strippable entry at the head of the suffix, `activeStart` walks back over the whole block, and the counts +grow normally (2, 3, 4, 5). The narration root is what arms the loop — which is why the defect looked +intermittent rather than universal. + +The suffix grows and the payload does not. Live diagnostics agree: one checkpoint series measured +`rawMessages` 8, 10, 12, 14, 16, 18 across consecutive tool-continuation turns with `rootBlobs` pinned at +8 and `continuationMode: checkpoint` every time. (An earlier draft cited 9..19 against a pinned 5 and a +proxy port that no artifact contains; the property is real, those specific figures were not, and they are +corrected here rather than restated.) + +That explains both halves of the report. The model cannot see the output of the command it just ran two +turns ago ("툴 출력을 못받고"), so it re-runs it; and because every turn presents the same collapsed shape, +it never accumulates enough state to finish ("무한 출력"). + +### Causation, not correlation + +Gating the loop off behind a scratch environment variable, changing nothing else, turns the roots +column from 2, 2, 2, 2 into 3, 5, 7, 9. The scratch mutation was reverted; `git diff` is empty. + +## Why the guard cannot simply be deleted + +It is load-bearing on the full-replay path. `tests/cursor-blob.test.ts` covers the case it was written +for: byte pressure consumes the budget with one large active result, the user turn that asked for it is +pruned, and `conversationTurns()` then discards the result too for lack of a current turn — the wire +request degenerates to system roots plus a bare result marker. #1527. + +The fix must keep that behaviour for full replay and stop applying it to a suffix whose initiating turn +is covered by the checkpoint. + +## Change + +`src/adapters/cursor/protobuf-request.ts`, `rootPromptMessages`: + +1. The function already receives `knownCallsOffset` (added by #2936), which is `suffixStart` on the + checkpoint path and `0` on full replay. A non-zero offset is exactly the "my history starts + mid-conversation" signal the guard is missing. Introduce a named boolean from it — + `suffixContinuesCoveredTurn` — rather than testing the arithmetic inline, because the two meanings + (positional re-basing vs. provenance) must not silently merge again. +2. Skip the orphan-strip loop when that flag is set. A covered-turn suffix has no orphan to strip: its + initiating turn exists, upstream, inside the checkpoint. +3. Leave the `#1527` initiator-recovery block below it unchanged. Its own comment already argues it + needs no mode distinction, and `activeStart > 0` confines it to this call's own slice — so it stays + correct for both paths and is not part of this defect. + +Not in scope: the `suffixStart === 0` edge, where a checkpoint reports zero covered messages and the +suffix is the full history. The flag is false there, which is the correct answer — that request *is* a +full replay in every respect that matters to the guard. + +## Verification + +- Red first: the growth table above becomes a test that asserts roots grow with pairs. It must fail on + `d882caed5` and pass after. +- The `#1527` full-replay assertions in `tests/cursor-blob.test.ts` must stay green untouched; they are + the guard's reason to exist and the only proof this change is narrow. +- `a checkpoint suffix may legitimately begin with a tool result` must stay green — it is the existing + expectation that most nearly overlaps this change. +- Live re-measurement of the exact repro above on an isolated proxy: three commands, one run each, + zero interrupt narrations, terminal `ALLDONE`. +- `bun x tsc --noEmit` and `bun run privacy:scan`; full suite on `ssh lidge`, never locally. + +## Audit r8 reopened the change: one mechanism was not enough + +The first implementation fixed only the orphan-strip loop. An independent audit measured two further +paths to the same user-visible symptom, both confirmed here before anything was changed. + +### The orphan fix is inert under byte pressure + +Eight pairs of 64 KiB results still produced 2 roots, with and without the orphan fix. The `keptPrior` +loop above the guard admits **complete turns**, and a turn starts at a `user` root — which a checkpoint +suffix does not have, by definition. `turnStart` walks to 0, the whole prior block becomes one +all-or-nothing pseudo-turn, and the first budget overrun drops every entry. The orphan guard then has +nothing left to strip, so it never runs and the fix cannot help. + +The remedy is to admit entries individually when the suffix continues a covered turn: without a turn +boundary to respect there is nothing for turn-granularity to protect, and keeping the most recent +history that fits beats keeping none. Measured 2 → 15 roots on that fixture. + +This matters more than a partial loss would, because root replay is the **only** channel carrying suffix +history. `conversationTurns` walks from `historyMessageStart` and never meets a `user` message in a +suffix, so `current` is never created and every entry hits `if (!current) continue` — the suffix +contributes 0 turns both before and after this change. Verified directly rather than assumed. + +### Restored growth collided with the cumulative envelope + +Suffix pruning measured only its own slice, so it produced suffixes that were individually legal and +cumulatively fatal. Once replay actually grew, the downstream envelope guard began throwing +`CursorRootEnvelopeLimitError` — a non-retryable 400 — on conversations that previously degraded +silently: 50 pairs behind 100 checkpoint roots, 10 behind 180, 4 behind 190. Growth was also +non-monotonic, with 95 pairs giving 191 roots and 96 collapsing back to 2. + +Two things were wrong and both are fixed. Pruning now subtracts the checkpoint's own roots and bytes, so +the suffix is measured against the room that actually remains. And when a checkpoint leaves no room at +all, the checkpoint is **abandoned** for a full replay under a new `envelope_exhausted` invalidation +reason rather than pruned to fit. Pruning to fit would emit the covered prefix and silently drop every +uncovered message — this unit's own defect, reintroduced at the top of the range — and throwing would +hand the caller a 400 it cannot retry. A full replay rebuilds a self-contained prompt and prunes it +coherently. After the change all three fixtures stay at 191 roots with no throw and no cliff. + +### The abandon decision reads pruning's result, not a byte threshold + +Two threshold attempts both left a live gap, which is why the predicate ended up where it is. Comparing +carried bytes against the raw limit left a few-hundred-byte band below it where the checkpoint was kept, +the suffix budget collapsed, and the newest tool result vanished — silently, where the old code at least +threw. Adding `systemBytes` moved the band instead of closing it, and the surviving positions were the +instructive ones: pruning kept the assistant narration and dropped the result, then kept the result +truncated so hard that only the truncation marker remained. Both leave the model looking at a call with no +answer, which is worse than keeping nothing. + +So the condition is not predictive. Pruning runs first, and the checkpoint is abandoned when the message +the turn continues from did not survive it. Two earlier attempts at that predicate are worth recording +because each failed differently. Matching the result's own output text against the serialized root broke +on JSON escaping the moment real output contained a newline, which made every live continuation abandon +its checkpoint — correct output, checkpointing silently dead. Checking the surviving roots' roles could not +distinguish the result from the narration beside it. The predicate is now positional: `rootPromptMessages` +returns the source message index of every root that survived, plus the indexes whose output was elided +entirely by truncation, and the caller asks whether the last replayed message is in the first set and out +of the second. + +That second set exists because "the result root survived" is not the same as "the result survived". +Truncation has two ways to leave a root that answers nothing: reduce it to the marker alone, or cut +mid-envelope before the `output:` line. Both were live in the band, and both now set `outputElided` at the +single place that produces them, so no threshold has to guess. + +Swept across 15 positions from 100 KiB below the byte limit to 100 bytes above it, the newest result is +present at every one; before, five positions dropped it. Live turns still resume from their checkpoint +(`mode=checkpoint`, no invalidation reason) — the predicate costs nothing on ordinary conversations. + +Scoped out explicitly rather than silently: the abandon branch sits inside the `suffixStart`-valid block, +so a plain resume turn with an oversized checkpoint still throws as it did before this unit. That path has +no suffix to lose and no measurement here, so widening it belongs to its own phase. + +Two pre-existing tests asserted the throw. They now assert the bound instead: the assembled request stays +inside the envelope and the uncovered history is still present. + +An earlier draft claimed those two rewrites were mutation-checked against the `carriedRoots` subtraction. +The re-audit measured otherwise and it was wrong: both exit through the abandon branch — the count case +uses unmeasurable checkpoint roots, the byte case a checkpoint large enough to trip abandonment — so +neither touched the subtraction. Deleting it reintroduced all three throws with the suite still 97/0 +green. The subtraction now has its own case built to reach it: measurable checkpoint roots, a count three +below the limit so abandonment does not fire, and a suffix that only fits if pruning knows what the +checkpoint spends. Removing the subtraction now reddens three tests. + +## Verification (as performed) + +- Focused suite: `bun test tests/cursor-blob.test.ts tests/cursor-tool-result-invocation.test.ts + tests/cursor-tool-continuation.test.ts` — 138 pass / 0 fail at the head of this unit (133 when this line + was first written, before the later rounds added assertions). +- Every assertion driven red against the implementation it exists to catch, each mutation applied alone: + restoring the unconditional orphan guard reddens the two suffix-growth rows; restoring turn-granular + admission reddens the byte-pressure row; removing the `carriedRoots` subtraction reddens three rows; + neutering the result-survival predicate reddens the byte-band row; skipping the orphan guard + unconditionally reddens the full-replay orphan row. +- Live re-measurement on an isolated proxy built from the final tree, counted after the run exited + (`/tmp/ocxv2.ojEUBe/v2.jsonl`): 3 commands, one execution each, 0 interrupt mentions, terminal + `ALLDONE`. The run-request diagnostics from that same proxy's debug buffer report `rawMessages`/`rootBlobs` + of 3/4, 5/6, 7/8, 9/10 across the four turns, with the last three in `checkpoint` mode and no + invalidation reason — roots tracking history instead of pinned to a constant, and checkpointing intact. + An earlier draft cited a series read from a snapshot log copied out of the operator's home, which could + not be traced to the run it described. +- The operator's own proxy (port 10100, pid 62773, 2.35.0) was never touched; every probe ran against a + scratch `OPENCODEX_HOME` on a scratch port. + +## Audit round 3: the predicate had to learn which path it applies to + +The positional predicate was correct for the path it was written against and wrong for two others. Both +were measured before being changed. + +### Native models were losing their checkpoint on every continuation + +`suffixKeptItsResult` asked whether the replayed result root survived pruning. A native resume model has +no such root: its result travels in server-side turn state, so `echoToolResultInRoot` is false and +`rootPromptMessages` skips it. The question answered "no" unconditionally, which meant the checkpoint was +discarded on **every** native tool continuation — including `cursor/auto`, the default id — regardless of +size or byte pressure. + +That is not a cosmetic loss. `pendingToolCalls`, `readPaths` and `previousWorkspaceUris` exist only inside +the checkpoint, and a full replay does not rebuild them, so this unit's own defect had been relocated to +the native path. Measured through the real builder: `readPaths` went 2 → 0 for `auto`, +`composer-2.5-fast` and `composer-3`, while `composer-2.5` and `grok-4.6` were unaffected — exactly the +split `cursorNeedsExternalToolContinuation` draws. The predicate is now gated on it. + +Worth stating plainly: this was introduced by the fix for the previous round's finding, not by the original +defect. Three rounds of audit each found one, which is the argument for the rounds rather than against +them. + +### Parallel results were protected one at a time + +The check read the last replayed index only. Parallel tool calls arrive as a run of results, and under byte +pressure the older ones were the ones being emptied — a prompt with three calls and one answer, which the +code's own comment calls worse than keeping nothing. `historyOutputElided` already recorded them; nothing +read them. The whole trailing run of results is checked now. Swept 628 (carried-bytes, payload-size) +positions: 10 partial-answer positions before, 0 after. + +### The invalidation reason still reaches nothing, and that is now a recorded decision + +`envelope_exhausted` is assigned to a local, so it lands in the debug diagnostic and stops there. +`src/adapters/cursor.ts` drops a dead checkpoint by reading `request.checkpointInvalidationReason`, so an +exhausted checkpoint is re-decoded and re-abandoned every turn until its TTL. + +Round 3 asked for it to be propagated and the obvious fix — writing the field back onto the argument, which +is what `request-builder.ts` does — was implemented and then measured inert. `live-transport.ts` prepares a +**spread copy** of the request, so the write lands on the copy: the outer object the adapter reads stayed +`undefined`. A test asserting on the argument would have passed while proving nothing about the real path, +which is the same vacuous-coverage trap round 2 caught. + +Reaching the store means threading the reason back through `PreparedCursorRunRequest`, a signature change +on the shared prepare path. That belongs to its own phase. The cost of leaving it is bounded and worth +stating: wasted work each turn, not wrong output — the request assembled is correct either way. + +### Verification of this round + +- `bun test` across `cursor-blob`, `cursor-tool-result-invocation`, `cursor-tool-continuation` and + `cursor-request-builder`: 188 pass / 0 fail, and 102 / 0 in `cursor-blob` alone. An earlier draft said 187, + which matched no commit in the stack — recounted after audit round 4 flagged it. +- Each new assertion driven red against the implementation it catches: removing the native gate reddens the + native-checkpoint row; reading only the last index reddens the parallel row. The parallel fixture's + 375-byte offset was derived from the sweep rather than guessed — it is the one position where a + last-index-only check leaves exactly one answer standing. +- Sweeps re-run clean after the change: 15/15 band positions deliver the newest result, 222 edge positions + (multi-byte UTF-8, empty, whitespace-only, error, self-referential `output:` payload) with no loss, 628 + parallel positions with no partial answers and no throws. + +## Audit round 4: the gate covered one disjunct out of three + +The abandon condition is a three-way disjunction, and round 3 gated only the last term. The middle one — +"the suffix produced no history roots at all" — is about the same thing, a replayed root going missing, so +it was equally meaningless for a model whose results never become roots. + +It fired whenever a native assistant turn was a **bare tool call with no narration**: no text root, no +result root, zero history roots, condition true, checkpoint discarded. Measured on the silent shape, +`readPaths` went 2 → 0 for `auto`, `composer-1`, `composer-2.5-fast` and `composer-3` while +`composer-2.5` and `grok-4.6` were unaffected — the same split, the same loss, one disjunct over. Both +survival terms are gated now; the count-full term stays ungated because it is a real envelope fact +independent of who echoes results. + +### Why four rounds each found something + +Every fix in this unit was correct for the path it was written against and silent about a sibling path in +the same condition. The fixture that let round 4's blocker through was round 3's own test: it asserted the +native path with narration, so the narration-free shape of the same path stayed invisible. The test is now +a cross product — four model ids by four assistant shapes (narrated, silent, empty text, whitespace text) — +because that is the axis the bugs kept hiding along, not because sixteen cases are inherently better than +four. + +Two counts in this document were also wrong and are corrected: the four-suite total is 188, not 187, and +the three-suite figure is 138 at head rather than the 133 true when it was written. + +## Audit round 5: the count budget was computed and never applied to the trailing run + +`historyLimit` subtracts `carriedRoots.count`, and every prior round reasoned about that subtraction as if +it bounded the assembled payload. It did not. It was read by the prior-history `while` loop alone. The +trailing tool-result block was assembled before that loop under **byte** pressure only, and +`historyEntries` was then built as `[...keptPrior, ...active]` with no count check anywhere. When +`keptPrior` is empty — the ordinary checkpoint-continuation shape — `historyEntries.length` equals +`active.length`, bounded by nothing at all. + +`truncateToolResultBlob` cannot save it: shrinking a result frees bytes, never a root slot. + +The abandon condition was supposed to catch the overflow, and it tested +`carriedRoots.count + suffixSystemCount` — carried plus system, asking whether there is room for **one** +more root. A parallel tool-call batch needs `active.length` of them. With 190 carried roots and a +3-result batch the test computes `190 + 1 >= 192` → false, keeps the checkpoint, appends 3 to 190, and +throws `CursorRootEnvelopeLimitError`: status 400, `retryable: false`, and `src/adapters/cursor.ts` fails +closed on the invalid-argument retry path when the last raw message is a tool result, which is exactly +this shape. + +Measured at `bde5b19dd`, before the fix: + +``` +carried=190 parallel=2 -> OK roots=192 +carried=190 parallel=3 -> THROW 193 roots +carried=189 parallel=4 -> THROW 193 roots +carried=188 parallel=8 -> THROW 196 roots +carried=170 parallel=25 -> THROW 195 roots +``` + +Reachable by ordinary growth, not a crafted fixture. Feeding each turn's assembled state back as the next +checkpoint — what `commitCursorCheckpoint` does — a plain conversation of 3-parallel-call turns died at +turn 48, and 5 calls per turn at turn 32. Both survive 200 turns after the fix, as do 1, 2 and 8 calls +per turn. + +The fix bounds `active` by count where it is assembled, rather than adding a fourth disjunct that has to +predict the suffix width. Oldest results drop first, matching the direction byte pressure already prunes, +and at least one always survives; the existing abandon check then reads `historyMessageIndexes`, sees the +dropped result, and falls back to a coherent full replay. That is why the grid shows the newest result +delivered at all 78 positions rather than merely "no throw". + +### Why the existing 188 could not see it + +The three pressure fixtures this document already claims — 50 pairs behind 100 roots, 10 behind 180, 4 +behind 190 — are all **sequential** pairs, and a sequential suffix has a trailing run of exactly 1, the +single width at which `+ 1` predicts the suffix correctly. The 628-position parallel sweep applied +**byte** pressure, where the abandon branch fires before the count cliff is reachable. Both axes existed +in the suite; neither case crossed them. All 188 tests passed identically with and without the production +fix, which is the sharpest available proof that no assertion covered this path. + +`tests/cursor-blob.test.ts` now crosses them: three `test.each` rows (carried 190 × 3 results, 188 × 8, +170 × 25) assert both halves — inside `CURSOR_EXTERNAL_ROOT_BLOB_LIMIT` **and** the newest output still +present, because staying inside the envelope by sending nothing useful is the other half of this defect. +Disabling the new bound reddens exactly those three and nothing else. Four-suite total is 191 pass / 0 +fail, `cursor-blob` alone 105. + +The pattern named after round 4 held for a fifth time, one level up: rounds 2 through 4 all reasoned about +the count budget as a settled fact and argued about the disjuncts consuming it, while the budget itself was +never applied to the wider of the two things it was supposed to bound. + +## Audit round 6: the r5 fix dropped in root space, and the check that guards it read raw space + +The count bound from round 5 acts on `active`, a list of ROOT entries. The abandon check derived its +trailing run by scanning `suffixMessages`, which is RAW messages. The two spaces are not the same, and they +diverge on the most ordinary assistant shape there is: a bare tool call with no narration emits no root at +all, so two sequentially-executed results sit ADJACENT as roots while raw space still separates them with an +assistant message. + +Consequence: both results entered the root-space trailing run, the count bound dropped the older one, and +the raw-space scan — seeing a run of length one, the newest result, which survived — reported "kept". The +checkpoint was retained and the request went out with a tool call answered by nothing. Measured at 190 +carried roots with bare-call pairs: the first answer was absent from every root and from `turns[]`. No +throw, no diagnostic, and the model's only sensible response is to re-issue the call — the exact loop this +unit exists to end, reintroduced by the fix for the previous round's blocker. + +`tests/cursor-blob.test.ts` uses that bare-call shape in nine fixtures, so this was not an exotic input. + +Two separable defects sat in the same place. The drop was also unnecessary: `historyLimit` subtracted +`systemEntryCount` on the checkpoint path, where the caller appends only `ids.slice(suffixSystemCount)` and +the checkpoint's own system roots are already inside `carriedRoots.count`. One free slot was charged twice, +so at 190 carried roots the limit came out 1 where 2 results fit. + +Both are fixed at the origin of the mismatch rather than at the call site. `rootPromptMessages` now returns +`activeMessageIndexes` — the trailing run as pruning saw it, recorded before pruning can shrink it — and the +abandon check reads that instead of re-deriving a run it cannot see correctly. It falls back to the +raw-space scan when the field is empty, which is how the full-replay and native shapes keep their previous +behaviour. `chargeableSystemCount` is zero on the covered-turn path, closing the double charge. + +Measured after the fix: 24 bare-call configurations across carried 170-190 and 2-8 pairs lose no answer at +all, and the reclaimed slot is visible — 192 roots where the defect emitted 191. + +### Mutation evidence, including one gap this caught in its own first attempt + +- abandon check re-derives from raw space → 2 red +- system count charged twice → 1 red +- the round 5 count bound removed → 5 red + +The middle row is worth keeping. The first version of the silent-loss test passed with the double charge +still in place, because that defect abandons the checkpoint and a full replay carries every answer — correct +output, reached wastefully, which no assertion about answer presence can distinguish. It took a second case +asserting the exact root count at exact fit to pin the arithmetic. A test that cannot fail against the +defect it was written for is the thing five of these six rounds actually kept finding. + +Round 6 also found that `outputElided` on the marker-only truncation return had no coverage: removing the +flag left all 191 tests green, and `tests/` is outside `tsconfig`'s `include`, so nothing else would have +noticed either. Covered now by asserting the abandonment it is supposed to trigger. + +Four-suite total is 197 pass / 0 fail; `cursor-blob` alone 111. + +## Audit round 7: the repetition note stopped the walk that protects the results + +The trailing-result walk tested one thing — `role === "toolResult"` — and walked backwards from the very end +of `history`. The repetition breaker appends a synthetic `[context note]` **user** root after the transcript +when the same output repeats three times or more. That note stands for no message, so it carries no +`messageIndex`, and the walk hit it immediately and stopped: `activeStart === history.length`, the trailing +run came out empty, `activeMessageIndexes` came out `[]`. + +Two failures at once, both worse than the defect round 6 fixed: + +The results lost trailing-run status altogether. They fell through into `prior` and were pruned as ordinary +history, so the "keep at least one result" floor never applied to them. + +And the empty `activeMessageIndexes` sent the abandon check into its raw-space fallback — the exact scan +round 6 exists to avoid. Measured: at 186 carried roots the note-armed shape was RETAINED where the +identical shape without the note correctly abandoned to a coherent full replay. + +The trigger is the worst possible one. The note arms on three consecutive identical assistant narrations, +which is the runaway-repetition shape this entire unit exists to end — so the input most likely to hit the +defect is the input the fix was written for. + +Instrumented state at the moment of the break: + +``` +PRUNE {historyLen:10, activeStart:10, active:0, activeIdx:[], historyLimit:6, lastRole:"user"} +ABANDON {activeIdx:[], usedFallback:true, trailingIndexes:[19], keptEnough:true} +``` + +`activeStart` equal to `historyLen` is the whole bug in one number. + +The walk now skips trailing roots that carry no `messageIndex` before looking for the result run, and the +excluded roots are re-appended afterwards so the note itself still reaches the model. That re-append is the +part that needed care: a root added after pruning has to be paid for DURING pruning, or the envelope is +overrun by exactly its number. Left uncharged, note-armed continuations at 188-190 carried roots threw the +non-retryable 400 for both sequential and parallel suffixes. `syntheticCount` and `syntheticBytes` are +therefore charged in the count bound, in the prior-history admission loop, and in the byte accounting, and +the orphan-strip floor counts them too so the strip cannot eat into the trailing run. + +### The byte relaxation was dropped rather than covered + +Round 7 also found that `chargeableSystemBytes = 0` had no coverage: reverting it alone left all four suites +green. The double-charge argument applies to bytes in principle, but no configuration could be found where +relaxing it changes the assembled payload — six crossings of carried bytes against system size against +result size in the deciding band produced byte-identical output either way. So it is gone. Charging the +system bytes twice only ever errs conservative, and untested new code on the envelope path is a liability, +not a saving. The count relaxation stays: it is covered, and its own case reddens without it. + +### Mutation evidence + +- the `messageIndex` walk removed (r11 defect restored) → 1 red +- `syntheticCount` uncharged in the count bound → 1 red +- the note dropped from the payload instead of re-appended → 1 red +- `syntheticCount` uncharged in the prior-history loop → 2 red + +The middle two are why this round's first attempt was not finished: both charges initially had no failing +test, exactly the condition round 6 had already been caught on once. A 224-configuration count sweep across +carried 185-191 by note-armed sequential and parallel suffixes showed the uncharged version throwing and the +charged version clean, which is what the new boundary case now asserts. + +Four-suite total is 198 pass / 0 fail; `cursor-blob` alone 115. Sweeps re-run clean at this head: 1440 +configurations across narrated, bare-call, whitespace-text and parallel shapes with zero envelope overruns, +zero orphaned calls and zero lost newest results; 78-position count-by-parallel grid clean; all five +multi-turn growth shapes survive 200 turns. + +## Audit round 8: the note was inside the array every pruning block reasons about + +Round 7 re-appended the note into `historyEntries` before the pruning blocks ran, and from that point every +one of them had to recognise a tail it could only identify by position. The initiator-recovery block could +not. Its floor is "stop when one entry is left", so with `[toolResult, note]` it counted the note as the +survivor and shifted off the **result**. + +What reached the model, one 600 KB result, three identical narrations instead of two the only difference: + +``` +PLAIN roots=3 lens=[16, 24, 524067] <- the answer +ARMED roots=3 lens=[16, 24, 193] <- the note, and nothing else +``` + +193 bytes of "take a DIFFERENT action" in place of the output the model was waiting for. The result had +already been truncated to fit; the recovery block deleted it anyway. This is the reported symptom exactly — +no tool output, so the model runs the command again — re-entered through the fix for it. + +A second mechanism compounded it. `activeBytes` included `syntheticBytes` while the equal-share divisor did +not, so shares summed to the entire budget and adding the note back always exceeded it. The +shrink-toward-equal-share pass — whose whole purpose is "a missing result is worse than a truncated one" — +became structurally unfittable, and control fell through to the loop that deletes a whole result. 246 bytes +of note cost a 200 KB answer. Reviewer measured 166 of 432 byte-pressure configurations losing an answer. + +### The fix is structural, not another floor + +Adding `+ trailingSynthetic.length` to each floor would have worked and would have left the next block to +discover the same trap. Instead the tail is held **out** of `historyEntries` entirely until assembly, and +every budget below is expressed net of it: `historyLimitForReal` and `historyBudgetForReal` are computed +once, before the first result is measured. The pruning blocks then reason only about real history and cannot +mistake one kind of root for the other, and the reservation is what keeps the tail from overrunning the +envelope when it returns. + +That the reservation is load-bearing was proved twice over: with it removed the same shapes 400 on the byte +limit, and an intermediate version that held the tail out without reserving its bytes committed 51 bytes +over. + +### Coverage, which was the round's second finding + +The entire `syntheticBytes` charge family had no test: neutralizing it in one edit left the suite green +while a sweep against that mutation threw 148 envelope errors. That is the third uncovered hunk in this +unit, and it landed in the same commit whose message drops `chargeableSystemBytes` for being uncovered — +the argument was made and then not applied to the new code beside it. + +Mutation evidence at this head: + +- byte reservation removed → 2 red +- count reservation removed → 3 red +- note dropped from the payload → 4 red +- `messageIndex` walk removed (r11 defect) → 3 red +- note re-appended into `historyEntries` **and** the gross budget spent (r12 defect in full) → 2 red + +The last row is worth stating precisely: re-appending alone is now harmless, because the reservation +prevents the loss on its own. The defect needed both halves, and the test catches the pair. + +Four-suite total is 201 pass / 0 fail; `cursor-blob` alone 118. Sweeps at this head: 896 note-armed +configurations across four assistant shapes crossed with count and byte pressure, 1440-case +call-answer-invariant sweep, 224-case count sweep, 78-position grid — zero overruns, zero orphaned calls, +zero lost answers, zero notes lost. Five multi-turn growth shapes survive 200 turns. + +### What eight rounds actually found + +One defect, re-entering through each of its own fixes. Every round's patch was correct for the path it was +written against and silent about a sibling path in the same condition — and three times the sibling was +created by the previous fix. The through-line is not carelessness about the condition; it is that each fix +added a fact to the pruning code (`carriedRoots`, a count bound, a root-space run, a synthetic tail) without +asking which existing block already assumed that fact absent. The last fix is the first that removes a +distinction rather than adding one. + +## Audit round 9: a subtraction clamped at zero cannot say "unaffordable" + +The reservation was `Math.max(0, historyBudget - syntheticBytes)`, and the tail was appended +unconditionally. Those two facts are compatible only while the difference is non-negative. Below that the +clamp reports "the note costs nothing", every pruning block correctly reasons about a budget of zero and +emits nothing, and the note is appended anyway — so the payload lands over the limit by exactly the deficit +the clamp erased. With 26 bytes free and a 246-byte note, 220 bytes over and a non-retryable 400. + +Holding the tail out of `historyEntries` is what made it unrecoverable. No block below could see it, so +none could charge it. + +Ninth iteration of the same pattern, and this time the new fact was *the tail is always appended*; the +construct that assumed otherwise was the clamp introduced beside it. + +### Why every fixture missed it + +The exposed shape is a turn that does **not** end in a tool result — an ordinary user interjection after a +repetitive stretch. With a trailing result the abandon check's survival disjuncts fire and rescue the turn; +on a plain follow-up they structurally cannot, and nothing else bounded the tail. Every fixture in +`cursor-blob` is a tool continuation. Measured across 42 carried-byte positions: 13 throws with the note +armed, 0 without, all on the interjection tail. + +The note is now dropped when it cannot be paid for. That is this unit's own priority order, stated in the +round 8 record and applied here: a missing instruction is recoverable, a missing tool result restarts the +loop. + +### One inert condition removed rather than shipped + +The first version of the affordability test also required a free root slot. It could not be made to matter: +60 boundary positions at and past the root limit behaved identically with and without it, because the count +bound already stops at one surviving result. It is gone. Byte affordability alone decides. + +That is the second time in this unit an inert guard was written and then dropped, and the reason is worth +recording: an envelope condition that cannot fail is indistinguishable from one that is wrong, so keeping it +costs the next reader the same audit it cost this one. + +Also corrected: one `activeBytes > historyBudget` gate still read the gross budget while its body wrote the +net one. Provably no behavioural difference — the entry has already been truncated to net by then — but it +is the exact drift that seeded rounds 5 and 6. + +Mutation evidence: affordability removed → 3 red; tail appended regardless of affordability → 3 red. + +Four-suite total is 208 pass / 0 fail; `cursor-blob` alone 122. Every sweep re-run clean at this head: 42 +deficit positions, 60 count-boundary positions, 150 zero-budget boundary cases, 896 note-armed +configurations, 1440-case call-answer invariant, 224-case count sweep, 78-position grid, 24 bare-call cases, +and five multi-turn growth shapes surviving 200 turns. + +## Audit round 10: the guard removed as inert was load-bearing at exactly one value + +Round 9 dropped the count half of the affordability test, arguing that the count bound below always leaves a +slot free because it keeps one result. That is true for every value of `historyLimit` except 1 — where the +one free slot is precisely the one the surviving result takes. The note was then judged affordable on bytes +alone, the reservation clamped to zero, and the append pushed full replay to 193 roots. + +Four armed-only `CursorRootEnvelopeLimitError` throws at 191 system prompts, across both tails and both +suffix widths, where the same request without the note assembled 192 and succeeded. Full replay has no +abandon branch, so nothing rescued it. + +The reasoning error is worth naming precisely, because the sweep that supported it was real. It varied +**carried roots on the checkpoint path**, where the count-full disjunct abandons the checkpoint long before +`historyLimit` can reach 1. The reachable route is full replay with many system prompts — a different axis +entirely, and one no earlier round had needed. "Inert across 60 positions" was a true statement about the +wrong sixty. + +Both conjuncts are restored. The lesson is not that removing inert guards was wrong; it is that "inert" +needs the axis that can make it fire, and a sweep along one axis does not establish it along another. + +### The reservation was uncovered, distinctly from the append + +Round 9's own mutation table claimed the affordability check was covered. It was covered at the **append** +site only: neutering `syntheticCount`/`syntheticBytes` while leaving `trailingSynthetic` gated left the +suite green, because asserting on the assembled payload cannot separate "the deficit was charged" from "the +tail simply was not appended". Asserting the exact root count at the boundary does separate them, and that +case is now present. + +Mutation evidence at this head, each applied alone: + +- count conjunct removed (the r14 defect) → 4 red +- byte conjunct removed (the r13 defect) → 3 red +- reservation neutered, append still gated → 6 red +- append ungated → 7 red + +Four-suite total is 212 pass / 0 fail; `cursor-blob` alone 126. + +### Ten rounds, one shape + +Every round found the same class of defect: a fact added to the pruning code beside a construct that assumed +it absent. Rounds 5 through 10 were each triggered by the previous round's own fix. Two of those were +arguments about whether a guard could fire — one dropped correctly, one dropped wrongly and restored here — +which suggests the code's real difficulty is that its budget arithmetic has several axes and any single sweep +silently fixes all but one of them. + +## Audit round 11: PASS, and the two notes it left + +Round 11 found no blocker. It confirmed `syntheticCountRaw` can only be 0 or 1 — one push site, once per +request — so the conjunct reduces to `historyLimit >= 2` when the note exists, and checked that threshold in +both directions: at 1 the single free slot belongs to the result, at 2 both fit exactly at 192 roots. It +audited all 25 budget references and found gross values only in the affordability test itself, which is +where they belong. Across 5040 checkpoint configurations and 200-turn feedback growth at five call widths: +no throw, no overrun, no lost newest result, no orphaned call. + +Its attribution rig is the more useful artifact. Driving HEAD, the parent, and base `dev` through identical +576-position grids: HEAD is never worse than its parent anywhere, and the 8 positions where HEAD throws and +`dev` did not are all 192 system prompts, where the prompts alone exceed the envelope and HEAD throws with +or without the note. On those same positions `dev` emitted 192 roots carrying **zero** tool results — the +re-run loop this unit exists to end. Totals: HEAD 104 throws / 232 newest-lost, parent 112 / 232, `dev` +96 / 372. + +### The threshold is now pinned from the tight side too + +Round 11's one actionable note: tightening `>= 1` to `>= 2` left all 212 tests green. Over-conservative is +safer than over-eager, but a suite that cannot tell a correct bound from an unnecessarily strict one is +exactly the gap that cost round 14. A case at two free slots now asserts that the note and the answer both +arrive at exactly 192 roots: relaxing the bound reddens 4, tightening it reddens 1. + +### A claim in the round 10 record was wrong + +That record said the reservation had been pinned at the append site only, and that neutering +`syntheticCount`/`syntheticBytes` left the suite green. On the parent commit that mutation already reddens +6, all of them pre-existing round 8 and 9 cases. The count-conjunct finding stands on its own evidence; this +secondary claim did not, and the root-count case is not what closed it. + +### Remaining known gap, scoped out deliberately + +On the extreme byte axis — a single system prompt near 523 KB — the note can be kept while the result +truncates to a marker, which inverts this unit's stated priority order. That band is identical on the parent +(24 positions) and far worse on `dev` (180), so it is pre-existing and improved here rather than introduced. +Full replay has no abandon branch to rescue it, which makes it a genuine follow-up rather than a +non-problem, and it belongs to its own phase. + +Four-suite total is 213 pass / 0 fail; `cursor-blob` alone 127. diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index b9d78d9716..720bc8dc2e 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -23,7 +23,12 @@ export type CursorCheckpointInvalidationReason = | "trailing_tool_result" | "force_fresh" | "upstream_invalid_argument" - | "lineage_mismatch"; + | "lineage_mismatch" + /** + * The checkpoint's own roots leave no room for the uncovered suffix inside Cursor's root envelope. + * Resuming would send history the model cannot see; a full replay prunes coherently instead. + */ + | "envelope_exhausted"; export interface CursorCheckpointSnapshot { ref: string; diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 14f7f1c4e6..494e4e48ca 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -125,6 +125,13 @@ type RootBlobCandidate = { messageIndex?: number; /** Original JSON text payload used when an active tool result must be truncated to fit. */ text?: string; + /** + * Set when a tool result was truncated past the point where any of its own output survives — either down + * to the truncation marker alone, or mid-envelope before the `output:` line. The model reads both as an + * empty answer to its own call, so a caller deciding whether the result "survived" must be able to tell + * them apart from a real one (devlog 260829 070). + */ + outputElided?: true; }; function rootBlobCandidate( @@ -163,7 +170,14 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo "toolResult", { messageIndex: entry.messageIndex, text: truncated }, ); - if (result.byteLength <= maxBytes) return result; + if (result.byteLength <= maxBytes) { + // `output:` is the last fixed line of the envelope, so a cut landing before it leaves the header + // and no answer. Flag it: "a result root survived" would otherwise be true of a root that tells the + // model nothing about what its tool returned. + const outputStart = truncated.indexOf("\noutput:\n"); + const keptOutput = outputStart >= 0 && truncated.length > outputStart + "\noutput:\n".length + marker.length; + return keptOutput ? result : { ...result, outputElided: true }; + } if (end === 0) break; keepBytes = Math.max(0, end - (result.byteLength - maxBytes) - 16); } @@ -172,7 +186,7 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo "toolResult", { messageIndex: entry.messageIndex, text: marker.trimStart() }, ); - return markerOnly.byteLength <= maxBytes ? markerOnly : null; + return markerOnly.byteLength <= maxBytes ? { ...markerOnly, outputElided: true } : null; } function systemPromptBlobs(request: CursorRunRequest): RootBlobCandidate[] { @@ -220,12 +234,43 @@ function rootPromptMessages( * both sides in the same space (devlog 260829 060). */ knownCallsOffset = 0, + /** + * Roots the decoded checkpoint already carries, which this call's pruning must leave room for. + * + * The envelope guard downstream measures checkpoint roots PLUS this suffix and throws a + * non-retryable 400 when the total exceeds the limit. Pruning against the full limit therefore + * emitted a suffix that was individually legal and cumulatively fatal — invisible until suffix + * replay actually grew (devlog 260829 070, audit r8 finding 3). + */ + carriedRoots: { count: number; byteLength: number } = { count: 0, byteLength: 0 }, ): { ids: Uint8Array[]; byteLength: number; historyMessageStart: number; /** Serialized text of the roots that survived pruning, in wire order. */ serialized: string[]; + /** + * Source message index of each HISTORY root that survived pruning (system roots excluded). + * + * The checkpoint caller needs to know whether the specific message it is continuing from survived. + * Neither role nor text can answer that: roles repeat, and matching the result's own output against the + * serialized root fails on JSON escaping the moment real output contains a newline or a quote — which + * made live continuations abandon their checkpoint on every turn (devlog 260829 070). + */ + historyMessageIndexes: number[]; + /** + * Message indexes whose root survived pruning but lost ALL of its own output to truncation, so only the + * truncation marker remains. Aligned with nothing — membership is the whole signal (devlog 260829 070). + */ + historyOutputElided: number[]; + /** + * Message indexes of the trailing tool-result run as PRUNING saw it — root space, not raw-message space. + * The two spaces diverge: a bare tool call with no text emits no root, so two sequentially-executed + * results become adjacent roots while a raw-space scan still sees a trailing run of one. A caller that + * re-derives the run from `rawMessages` therefore cannot see a result this function dropped for count + * (audit r10). Emitted so the abandon decision reads the same set pruning acted on. + */ + activeMessageIndexes: number[]; } { const entries = systemPromptBlobs(request); const systemEntryCount = entries.length; @@ -236,6 +281,9 @@ function rootPromptMessages( byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0), historyMessageStart: 0, serialized: entries.map(entry => entry.serialized), + historyMessageIndexes: [], + historyOutputElided: [], + activeMessageIndexes: [], }; } @@ -342,30 +390,108 @@ function rootPromptMessages( let selected = entries; let historyMessageStart = 0; + // The trailing tool-result run in ROOT space, recorded before pruning can drop from it. Empty for a + // native model or a non-external one, which never assemble a trailing run here at all. + let activeMessageIndexes: number[] = []; if (externalModel) { + // A non-zero offset means `rawMessages[0]` is NOT the conversation start: only the checkpoint + // path passes one, and it passes `suffixStart`, the count of messages the checkpoint carries. + // Named rather than tested inline because `knownCallsOffset` answers two different questions — + // where to re-base a position (#2936) and whether this history has a covered predecessor — and + // collapsing them back into one bare `!== 0` is how the second meaning gets lost again. + const suffixContinuesCoveredTurn = knownCallsOffset > 0; const systemEntries = entries.slice(0, systemEntryCount); const history = entries.slice(systemEntryCount); const systemBytes = systemEntries.reduce((sum, entry) => sum + entry.byteLength, 0); - const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - systemEntryCount); - const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes); + // On the checkpoint path the caller appends ONLY the history roots to what the checkpoint already + // carries (`suffixHistoryIds` is `ids.slice(suffixSystemCount)`), and the system roots the checkpoint + // carries are already inside `carriedRoots`. Subtracting `systemEntryCount` there charges for them + // twice, which cost a slot that was genuinely free: at 190 carried roots the limit came out 1 when 2 + // results fit, and the count bound below then dropped an answered call for no reason (audit r10). + const chargeableSystemCount = suffixContinuesCoveredTurn ? 0 : systemEntryCount; + const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - chargeableSystemCount - carriedRoots.count); + // Only the COUNT is relaxed. The byte side keeps charging `systemBytes` on both paths: the same + // double-charge argument applies in principle, but no configuration could be found where relaxing it + // changes the assembled payload — 6 crossings of carried bytes against system size against result size + // in the deciding band produced byte-identical output with and without it. Untested new code on the + // envelope path is a liability rather than a saving, and charging the bytes twice only ever errs + // conservative, so the relaxation is deliberately not made here (audit r11). + const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes - carriedRoots.byteLength); // Retain the active trailing tool-result block when it fits (may truncate text). // If even a truncation marker cannot fit the remaining budget, omit it rather than // emitting an oversized root blob. - let activeStart = history.length; + // + // Walk past any SYNTHETIC trailing root first. The repetition breaker above appends a + // `[context note]` user root after the transcript, and it stands for no message, so it carries no + // `messageIndex`. Without this step the result-run walk stopped dead on that note: `activeStart` + // came out equal to `history.length`, the trailing run was empty, and the results lost their + // trailing-run status entirely — they fell through to `prior` and were pruned as ordinary history, + // with no "keep at least one" guarantee and an empty `activeMessageIndexes` that sent the abandon + // check back to the raw-message scan it must not use. Measured: a note-armed continuation at 186 + // carried roots was retained where the same shape without the note correctly abandoned, and the + // note arms on three identical assistant narrations — the runaway-repetition shape this whole unit + // exists to end, so the one input most likely to hit it (audit r11). + let activeEnd = history.length; + while (activeEnd > 0 && history[activeEnd - 1]?.messageIndex === undefined) activeEnd -= 1; + let activeStart = activeEnd; while (activeStart > 0 && history[activeStart - 1]?.role === "toolResult") activeStart -= 1; + // Reserve the synthetic tail's slots and bytes FIRST, and express every budget below net of it. + // The tail is appended after all pruning, so a block that spends its room overruns the envelope, + // and a block that divides the gross budget produces shares that cannot fit once it returns. Both + // happened: the equal-share pass became structurally unfittable and fell through to deleting a whole + // result, and the initiator-recovery block committed 51 bytes over the limit (audit r11, r12). + // Affordability is decided BEFORE the reservation, because the reservation cannot represent a + // deficit. `Math.max(0, …)` turns "the note cannot be paid for" into "the note costs nothing", and + // the tail was appended regardless — so an envelope with 26 bytes free emitted a 246-byte note and + // overran by 220. Holding the tail out of `historyEntries` is what made that unrecoverable: no block + // below could see it to charge it. + // + // A trailing tool result hid this, because the abandon check's survival disjuncts rescue that shape. + // The exposed shape is a turn that does NOT end in a result — an ordinary user interjection after a + // repetitive stretch — where nothing else bounds the tail: 13 of 42 positions threw the + // non-retryable 400 with the note armed and none without it (audit r13). + // + // When it does not fit, the note is dropped. That is the unit's own priority order: a missing + // instruction is recoverable, a missing tool result restarts the loop this unit exists to end. + const syntheticEntries = history.slice(activeEnd); + const syntheticCountRaw = syntheticEntries.length; + const syntheticBytesRaw = syntheticEntries.reduce((sum, entry) => sum + entry.byteLength, 0); + // BOTH axes. The count conjunct was briefly dropped as inert on the reasoning that the count bound + // below keeps one result and therefore always leaves a slot — which is exactly wrong at + // `historyLimit === 1`, where that one free slot is the one the result takes. The note was then judged + // affordable on bytes, the reservation clamped to 0, and the append pushed full replay to 193 roots: + // four armed-only `CursorRootEnvelopeLimitError` throws at 191 system prompts, on both tails and both + // suffix widths, where the same request without the note assembled 192 and succeeded. + // + // The sweep that called it inert varied CARRIED roots on the checkpoint path, where the count-full + // disjunct abandons the checkpoint before `historyLimit` can reach 1. The reachable route is full + // replay with many system prompts, and full replay has no abandon branch to rescue it (audit r14). + const syntheticAffordable = historyBudget - syntheticBytesRaw >= 0 + && historyLimit - syntheticCountRaw >= 1; + const syntheticCount = syntheticAffordable ? syntheticCountRaw : 0; + const syntheticBytes = syntheticAffordable ? syntheticBytesRaw : 0; + const historyLimitForReal = Math.max(0, historyLimit - syntheticCount); + const historyBudgetForReal = Math.max(0, historyBudget - syntheticBytes); const active = history - .slice(activeStart) - .map(entry => truncateToolResultBlob(entry, historyBudget)) + .slice(activeStart, activeEnd) + .map(entry => truncateToolResultBlob(entry, historyBudgetForReal)) .filter((entry): entry is RootBlobCandidate => entry !== null); + // Record the run BEFORE any pruning below can shrink it, so the abandon decision downstream compares + // against what pruning was asked to preserve rather than against a raw-message scan that cannot see + // this run's true width (audit r10). + activeMessageIndexes = history + .slice(activeStart, activeEnd) + .map(entry => entry.messageIndex) + .filter((index): index is number => index !== undefined); let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0); // Shrink every active result toward an equal share before dropping any of them. Review found // that the previous `active.shift()` loop DELETED whole results: three ~220 KB results emitted // only the last two, and `call_0` vanished with its tool call still in the transcript. A // missing result is worse than a truncated one — the model sees a call it never got an answer // to, which is the pairing break #1527 reports, and the caller cannot tell it happened. - if (active.length > 1 && activeBytes > historyBudget) { - const share = Math.floor(historyBudget / active.length); + if (active.length > 1 && activeBytes > historyBudgetForReal) { + const share = Math.floor(historyBudgetForReal / active.length); for (let index = 0; index < active.length; index++) { const entry = active[index]; if (!entry || entry.byteLength <= share) continue; @@ -376,12 +502,12 @@ function rootPromptMessages( } // Only when even an equal share cannot fit — the marker alone has a floor, so enough results // still overflow — fall back to dropping the oldest. - while (active.length > 1 && activeBytes > historyBudget) { + while (active.length > 1 && activeBytes > historyBudgetForReal) { const dropped = active.shift(); activeBytes -= dropped?.byteLength ?? 0; } - if (active.length === 1 && active[0] && activeBytes > historyBudget) { - const truncated = truncateToolResultBlob(active[0], historyBudget); + if (active.length === 1 && active[0] && activeBytes > historyBudgetForReal) { + const truncated = truncateToolResultBlob(active[0], historyBudgetForReal); if (truncated) { active[0] = truncated; activeBytes = truncated.byteLength; @@ -390,23 +516,48 @@ function rootPromptMessages( activeBytes = 0; } } + // COUNT-bound the trailing run, not only its bytes. `historyLimit` already subtracts what the + // checkpoint carries, but until now it was consulted ONLY by the prior-history loop below, and + // `historyEntries` was assembled as `[...keptPrior, ...active]` with no count check at all. The + // shrink/drop loops above answer to `historyBudget` alone — `truncateToolResultBlob` makes a + // result smaller, it never removes one to free a root SLOT — so a parallel tool-call batch + // arrived unbounded: 190 carried roots plus a 3-result batch assembled 193 and threw the + // non-retryable 400 this unit exists to remove (audit r9). Sequential pairs hid it, because a + // trailing run of length 1 is the one case where the abandon test's `+ 1` is exactly right. + // + // Drop the OLDEST results first, matching the direction byte pressure already prunes, and keep + // at least one: a continuation with no result is worthless, and the abandon decision downstream + // reads `historyMessageIndexes` to notice exactly that and fall back to a full replay. + while (active.length > 1 && active.length > historyLimitForReal) { + const dropped = active.shift(); + activeBytes -= dropped?.byteLength ?? 0; + } const prior = history.slice(0, activeStart); const keptPrior: RootBlobCandidate[] = []; let priorBytes = 0; // Take complete turns from the end: a turn starts at a user/developer root entry. + // + // Turn-granular admission needs a turn boundary to exist. A checkpoint suffix has NO user root at + // all — its initiating turn is inside the checkpoint — so `turnStart` walks to 0, the entire prior + // block becomes one all-or-nothing pseudo-turn, and the first budget overrun drops ALL of it. + // Measured on the checkpoint path with 8 pairs of 64 KiB results: 2 roots, unchanged by the orphan + // guard fix, because there was nothing left for that guard to strip. Admitting entry-by-entry keeps + // as much recent history as fits instead of none (devlog 260829 070, audit r8 finding 2). let i = prior.length - 1; - while (i >= 0 && keptPrior.length + active.length < historyLimit) { + while (i >= 0 && keptPrior.length + active.length < historyLimitForReal) { let turnStart = i; - // Root-blob roles are a closed set of four (system, user, assistant, toolResult): a - // developer message is normalized to a user root upstream, so "user" IS the turn start. - // Review suspected a developer-role gap here; the type says it cannot occur. - while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1; + if (!suffixContinuesCoveredTurn) { + // Root-blob roles are a closed set of four (system, user, assistant, toolResult): a + // developer message is normalized to a user root upstream, so "user" IS the turn start. + // Review suspected a developer-role gap here; the type says it cannot occur. + while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1; + } const turn = prior.slice(turnStart, i + 1); const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0); if ( - keptPrior.length + active.length + turn.length > historyLimit - || priorBytes + activeBytes + turnBytes > historyBudget + keptPrior.length + active.length + turn.length > historyLimitForReal + || priorBytes + activeBytes + turnBytes > historyBudgetForReal ) { break; } @@ -415,12 +566,35 @@ function rootPromptMessages( i = turnStart - 1; } + const trailingSynthetic = syntheticAffordable ? syntheticEntries : []; + // Synthetic trailing roots — today only the repetition-breaker note — are held OUT of + // `historyEntries` while the blocks below decide what survives, and appended once at assembly. + // + // They were briefly appended here instead, and every subsequent block then had to recognise a tail + // it could not identify except by position. The initiator-recovery loop below could not: its floor + // is "stop when one entry is left", so with `[toolResult, note]` it counted the note as the + // survivor and shifted off the RESULT — a 600 KB tool output replaced by 193 bytes of note, leaving + // a prompt that instructs the model to change strategy while showing it nothing its command + // returned. Measured 166 of 432 byte-pressure configurations losing an answer that way. Keeping the + // tail out means those blocks stay purely about real history and cannot mistake one for the other; + // the budgets still charge for it, which is what stops it overrunning the envelope (audit r12). const historyEntries = [...keptPrior, ...active]; // Guard against orphan assistant / toolResult at the start of the retained suffix. - while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") { - // Never drop the sole active tool-result block. - if (historyEntries.length <= active.length) break; - historyEntries.shift(); + // + // Premised on `history` starting where the CONVERSATION starts: only then does a leading + // assistant/result entry mean its user turn was pruned. A checkpoint suffix breaks that premise — + // it begins at `suffixStart`, so its first entry is routinely the assistant message whose + // initiating user turn is inside the checkpoint. Running the loop there strips pair after pair + // until only `active` survives, because the `break` fires only once the survivors ARE `active`: + // measured 2 roots for 1, 2, 3 and 4 completed pairs in the suffix, so a growing conversation + // replayed a constant payload and the model never saw the output of the command it just ran + // (devlog 260829 070). + if (!suffixContinuesCoveredTurn) { + while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") { + // Never drop the sole active tool-result block. + if (historyEntries.length <= active.length) break; + historyEntries.shift(); + } } // #1527: the surviving history must not begin with a tool result. Byte pressure can consume the // whole budget with one large active result and drop the user turn that asked for it, and @@ -450,7 +624,7 @@ function rootPromptMessages( if (initiator) { const withInitiator = [initiator, ...historyEntries]; const initiatorBytes = withInitiator.reduce((sum, entry) => sum + entry.byteLength, 0); - if (withInitiator.length <= historyLimit && initiatorBytes <= historyBudget) { + if (withInitiator.length <= historyLimitForReal && initiatorBytes <= historyBudgetForReal) { historyEntries.length = 0; historyEntries.push(...withInitiator); } else { @@ -465,14 +639,14 @@ function rootPromptMessages( // already prunes, then truncate whatever survives. An instruction with fewer or shorter // results is answerable; results with no instruction are not. const kept = [...historyEntries]; - while (kept.length > 1 && kept.length + 1 > historyLimit) kept.shift(); + while (kept.length > 1 && kept.length + 1 > historyLimitForReal) kept.shift(); let keptBytes = kept.reduce((sum, entry) => sum + entry.byteLength, 0); - while (kept.length > 1 && initiator.byteLength + keptBytes > historyBudget) { + while (kept.length > 1 && initiator.byteLength + keptBytes > historyBudgetForReal) { const dropped = kept.shift(); keptBytes -= dropped?.byteLength ?? 0; } - if (kept.length === 1 && kept[0] && initiator.byteLength + keptBytes > historyBudget) { - const room = historyBudget - initiator.byteLength; + if (kept.length === 1 && kept[0] && initiator.byteLength + keptBytes > historyBudgetForReal) { + const room = historyBudgetForReal - initiator.byteLength; const shrunk = room > 0 ? truncateToolResultBlob(kept[0], room) : null; if (shrunk) { kept[0] = shrunk; @@ -482,14 +656,17 @@ function rootPromptMessages( // Only commit when the initiator genuinely fits alongside what is left. If the system // prompt has consumed the budget so completely that not even a truncation marker fits, // there is nothing honest to send here; the envelope guard downstream owns that case. - if (kept.length + 1 <= historyLimit && initiator.byteLength + keptBytes <= historyBudget) { + if (kept.length + 1 <= historyLimitForReal && initiator.byteLength + keptBytes <= historyBudgetForReal) { historyEntries.length = 0; historyEntries.push(initiator, ...kept); } } } } - selected = [...systemEntries, ...historyEntries]; + // The synthetic tail goes on last, after every pruning decision is made, so telling the model to + // change strategy is not dropped by the walk that stopped ignoring it — and so no pruning block has + // to distinguish it from a real result by position. Its slots and bytes were already reserved above. + selected = [...systemEntries, ...historyEntries, ...trailingSynthetic]; const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined); historyMessageStart = firstKept?.messageIndex ?? (messages.length); } @@ -499,6 +676,16 @@ function rootPromptMessages( byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0), historyMessageStart, serialized: selected.map(entry => entry.serialized), + historyMessageIndexes: selected + .slice(systemEntryCount) + .map(entry => entry.messageIndex) + .filter((index): index is number => index !== undefined), + historyOutputElided: selected + .slice(systemEntryCount) + .filter(entry => entry.outputElided === true) + .map(entry => entry.messageIndex) + .filter((index): index is number => index !== undefined), + activeMessageIndexes, }; } @@ -1229,11 +1416,101 @@ function buildPreparedCursorRunRequest( // Index calls from the FULL history, not the suffix: the cut can fall between a call and // its result, and a result replayed without its invocation is the orphaned-result defect. const fullHistoryCalls = toolCallsByCallId(request.rawMessages); - // `suffixStart` re-bases the replayed slice into full-history space, which is the space - // `fullHistoryCalls` positions live in. Without it the positional bound compares two origins. - const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart); - const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); + // What the checkpoint already spends against the envelope. An id the local store never held + // still occupies a root slot, so it counts toward the COUNT budget with a zero byte + // contribution rather than being skipped entirely. + let carriedBytes = 0; + for (const blobId of conversationState.rootPromptMessagesJson) { + carriedBytes += cursorBlobByteLength(blobId) ?? 0; + } + const carriedRoots = { + count: conversationState.rootPromptMessagesJson.length, + byteLength: carriedBytes, + }; + // A checkpoint can be so large that nothing useful is left for the suffix. Pruning to fit then + // emits the covered prefix and silently drops the uncovered messages — the exact failure this unit + // exists to remove — while throwing would hand the caller a non-retryable 400. Abandon the + // checkpoint instead: full replay rebuilds a self-contained prompt and prunes it coherently + // (devlog 260829 070, audit r8). + // + // The decision is made on the RESULT of pruning, not on a byte threshold. A threshold has to + // predict what pruning will do, and the first attempt mispredicted it: comparing carried bytes + // against the raw limit left a band of a few hundred bytes below it where the checkpoint was kept, + // the suffix budget collapsed, and the newest tool result vanished. Adding `systemBytes` moved the + // band without closing it. Asking pruning what survived cannot drift from what pruning does. + const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart, carriedRoots); const suffixSystemCount = systemPromptBlobs(suffixRequest).length; + // A tool continuation whose own result did not survive is worthless: that result is the whole + // reason the turn exists. "Kept SOMETHING" is not enough either — inside the band this fix first + // missed, pruning kept the assistant narration and dropped the result, which is worse than keeping + // nothing because the model then sees a call it never got an answer to. The test is therefore on + // the LAST replayed message specifically, identified by its index rather than its content. + const suffixMessages = suffixRequest.rawMessages ?? []; + const lastSuffixIndex = suffixMessages.length - 1; + // Only models whose results are replayed as root text can answer this question. A native resume + // model gets its result through server-side turn state, so `rootPromptMessages` never emits a + // toolResult root for it (`echoToolResultInRoot` is false) — asking whether that root survived + // returns "no" every single time, and an unguarded check therefore threw away the checkpoint of + // every native continuation, including the default `cursor/auto`. The checkpoint is the only place + // pendingToolCalls, readPaths and previousWorkspaceUris live, and full replay does not rebuild + // them, so that was this unit's own defect relocated to the native path (audit r8 round 3). + const resultReplayedAsRoot = cursorNeedsExternalToolContinuation(request.modelId); + // Kept, and kept with its output: a root reduced to the truncation marker alone answers the call + // with nothing, which is the same failure as dropping it. `outputElided` is set at the one place + // that can produce it, so this needs no threshold to guess at. + // + // Every trailing result is checked, not just the last one. Parallel tool calls land as a run of + // results, and under byte pressure the older ones were the ones getting emptied: measured a prompt + // carrying three calls and one answer, which is the shape this comment calls worse than keeping + // nothing. `historyOutputElided` already knew; only the last index was being read (audit r8 + // round 3). + // The run is read from PRUNING's own report, not re-derived from `suffixMessages`. The two spaces + // disagree: root space skips an assistant message that emitted no root — a bare tool call with no + // narration, or whitespace-only text — so two sequentially-executed results sit adjacent as roots + // while a raw-message scan still sees a trailing run of one. Pruning's count bound acts on the root + // run, so a raw-space scan could not see the result it dropped: measured at 190 carried roots with + // bare-call pairs, the older answer vanished from the wire entirely while this check reported + // "kept" and the checkpoint was retained — an unanswered call, which the comment above rightly + // calls worse than keeping nothing (audit r10). + // + // `activeMessageIndexes` is that run as pruning saw it, recorded before pruning could shrink it. + // Falling back to the raw-space scan when it is empty keeps the full-replay and native shapes, + // which never populate it, behaving exactly as before. + let trailingStart = suffixMessages.length; + while (trailingStart > 0 && suffixMessages[trailingStart - 1]?.role === "toolResult") trailingStart -= 1; + const trailingIndexes = suffixRoots.activeMessageIndexes.length > 0 + ? suffixRoots.activeMessageIndexes + : suffixMessages.slice(trailingStart).map((_, offset) => trailingStart + offset); + const keptEnough = trailingIndexes.every(index => + suffixRoots.historyMessageIndexes.includes(index) + && !suffixRoots.historyOutputElided.includes(index)); + const suffixKeptItsResult = !resultReplayedAsRoot + || suffixMessages[lastSuffixIndex]?.role !== "toolResult" + || keptEnough; + if ( + carriedRoots.count + suffixSystemCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + // Both survival disjuncts are about a REPLAYED root going missing, so both are meaningless for a + // model whose results never become roots. Gating only the second one still discarded every native + // checkpoint whose assistant turn was a bare tool call: no text root, no result root, zero history + // roots, condition true (audit r8 round 4). The count-full disjunct above stays ungated — it is a + // real envelope fact, independent of who echoes results. + || (resultReplayedAsRoot && suffixRoots.ids.length <= suffixSystemCount) + || !suffixKeptItsResult + ) { + conversationState = undefined; + continuationMode = "full-replay"; + checkpointInvalidationReason = "envelope_exhausted"; + // NOT propagated to the checkpoint store, and deliberately so after measuring the attempt. + // `src/adapters/cursor.ts` drops a dead checkpoint by reading + // `request.checkpointInvalidationReason`, but `live-transport.ts` prepares a SPREAD COPY of that + // request, so writing the field here lands on the copy and the caller never sees it — measured + // inert, `outer.checkpointInvalidationReason` stayed undefined. Reaching the store needs the + // reason threaded back through `PreparedCursorRunRequest`, which is a signature change on the + // shared prepare path and belongs to its own phase. The cost of not doing it is bounded: the + // checkpoint is re-decoded and re-abandoned each turn until TTL, which is wasted work rather + // than wrong output (audit r8 rounds 3 and 4). + } else { + const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount); conversationState = create(ConversationStateStructureSchema, { @@ -1252,7 +1529,11 @@ function buildPreparedCursorRunRequest( byteLength: suffixRoots.byteLength, historyMessageStart: suffixRoots.historyMessageStart, serialized: suffixHistorySerialized, + historyMessageIndexes: suffixRoots.historyMessageIndexes, + historyOutputElided: suffixRoots.historyOutputElided, + activeMessageIndexes: suffixRoots.activeMessageIndexes, }; + } } } catch { checkpointInvalidationReason = "decode_failed"; diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index a223b83380..3fde1260ce 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -5,6 +5,7 @@ import { toBinary } from "@bufbuild/protobuf"; import { createCursorBlobRequestScope, cursorBlobMetrics, + cursorBlobByteLength, cursorBlobRetainedStoreSnapshot, cursorBlobStoreDebugSnapshotForTests, CursorBlobAdmissionError, @@ -40,6 +41,7 @@ import { prepareCursorRunRequest, } from "../src/adapters/cursor/protobuf-request"; import { estimateTokens } from "../src/lib/token-estimate"; +import type { OcxAssistantContentPart } from "../src/types"; import { CursorRootEnvelopeLimitError } from "../src/adapters/cursor/cursor-errors"; import { isRetryableCursorError } from "../src/adapters/cursor/transport-retry"; import { encodeCursorCallId, resetCursorCallIdProvenanceForTests } from "../src/adapters/cursor/call-id"; @@ -2297,7 +2299,14 @@ describe("Cursor external replay envelope", () => { // oversized full replay, so a guard that measured only the suffix (or only // `rootPromptMessagesState`) would still satisfy them. These two do not: the suffix is tiny and // legal on its own, and only the checkpoint plus the suffix crosses a limit. - test("a checkpoint plus a legal suffix cannot exceed the root count limit cumulatively", () => { + // + // These two asserted a THROW until devlog 260829 070 (audit r8 finding 3). The throw was reachable + // because suffix pruning measured only its own slice, so it happily produced a suffix that was + // individually legal and cumulatively fatal — a non-retryable 400 on a long conversation. Pruning now + // subtracts the checkpoint's own roots, and a checkpoint with no room left for the suffix is abandoned + // for a full replay. The invariant is what matters and it is now stronger: the assembled request stays + // inside the envelope. Asserting the throw would pin the old mechanism, so these assert the bound. + test("a checkpoint plus a legal suffix stays inside the root count limit cumulatively", () => { const checkpointRoots = Array.from( { length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT }, (_, i) => new Uint8Array(32).fill(i % 251), @@ -2307,7 +2316,7 @@ describe("Cursor external replay envelope", () => { turns: [new Uint8Array(32).fill(8)], }); - expect(() => prepareCursorRunRequest({ + const prepared = prepareCursorRunRequest({ modelId: "gpt-5.6-sol-xhigh", conversationId: "c-ckpt-cumulative", system: ["You are helpful."], @@ -2322,10 +2331,18 @@ describe("Cursor external replay envelope", () => { checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), checkpointSuffixStart: 2, continuationMode: "checkpoint", - })).toThrow(CursorRootEnvelopeLimitError); + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // Abandoned rather than pruned-to-fit: a full replay carries the whole conversation, so the + // uncovered messages are present instead of silently dropped. + const serialized = JSON.stringify(roots.map(id => JSON.parse(new TextDecoder().decode(blobData(id))))); + expect(serialized).toContain("mid user"); }); - test("a checkpoint plus a legal suffix cannot exceed the byte limit cumulatively", () => { + test("a checkpoint plus a legal suffix stays inside the byte limit cumulatively", () => { // Roots the local store actually holds, so their bytes are measurable: a suffix-only or // `rootPromptMessagesState`-only measurement reports far less than the assembled total. const big = new Uint8Array(200_000).fill(65); @@ -2335,7 +2352,7 @@ describe("Cursor external replay envelope", () => { turns: [new Uint8Array(32).fill(8)], }); - expect(() => prepareCursorRunRequest({ + const prepared = prepareCursorRunRequest({ modelId: "gpt-5.6-sol-xhigh", conversationId: "c-ckpt-bytes", system: ["You are helpful."], @@ -2350,7 +2367,14 @@ describe("Cursor external replay envelope", () => { checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), checkpointSuffixStart: 2, continuationMode: "checkpoint", - })).toThrow(CursorRootEnvelopeLimitError); + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const measured = roots.reduce((sum, id) => sum + (cursorBlobByteLength(id) ?? 0), 0); + expect(measured).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + // The 600 KB of checkpoint roots is gone, not merely trimmed: an exhausted checkpoint is dropped. + expect(roots.length).toBeLessThan(checkpointRoots.length + 5); }); // The diagnostic used to read `rootPromptMessagesState?.byteLength`, which is undefined for a @@ -2358,6 +2382,973 @@ describe("Cursor external replay envelope", () => { // rejected saw rootBytes=0. The guard and the telemetry now read the same measurement, and a // root the local store never held is disclosed as `unmeasuredRoots` rather than silently // making the total look small. + /** + * Audit r8 re-review finding D. The two rewritten envelope tests above both exit through the ABANDON + * branch — the count case uses unmeasurable checkpoint roots, the byte case a checkpoint large enough to + * trip abandonment — so neither exercises the `carriedRoots` subtraction that pruning depends on. + * Deleting that subtraction reintroduced every throw the fix removed while the whole suite stayed green. + * + * This case is built to discriminate: MEASURABLE checkpoint roots, a count just under the limit so + * abandonment does not fire, and a suffix that only fits if pruning knows what the checkpoint spends. + */ + test("suffix pruning respects the roots the checkpoint already spends", () => { + const carried = CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 3; + const checkpointRoots = Array.from( + { length: carried }, + (_, i) => storeCursorBlob(new TextEncoder().encode(JSON.stringify({ role: "user", content: [{ type: "text", text: `covered ${i}` }] }))), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Run each step once.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let n = 1; n <= 6; n++) { + rawMessages!.push({ + role: "assistant", + content: [ + { type: "text", text: `Running STEP${n}.` }, + { type: "toolCall", id: `call_${n}`, name: "exec_command", arguments: { cmd: `echo STEP${n}` } }, + ], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_${n}`, + toolName: "exec_command", + content: `STEP${n}`, + isError: false, + timestamp: timestamp++, + }); + } + // Without the subtraction this throws CursorRootEnvelopeLimitError: the suffix is legal on its own and + // fatal once assembled. The assertion is that it does not throw AND stays inside the bound. + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt_carried_measurable", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // The checkpoint was kept, not abandoned: this is the pruning path, not the fallback path. + expect(roots.length).toBeGreaterThan(carried); + }); + + /** + * Audit r9. The case above and the byte-pressure cases cross COUNT pressure with a SEQUENTIAL suffix + * only, where the trailing tool-result run is always length 1 — the single width at which the abandon + * test's `carriedRoots.count + suffixSystemCount` happens to predict the suffix exactly. A parallel + * tool-call batch makes that run wider than 1, and nothing bounded it: `historyLimit` was consulted by + * the prior-history loop alone, and `truncateToolResultBlob` shrinks a result without ever freeing a + * root slot. Measured at the head this test was added to: 190 carried roots plus a 3-result batch + * assembled 193 roots, 188 carried plus 8 assembled 196, and each threw a NON-RETRYABLE 400 — the exact + * failure this unit exists to remove, and reachable by ordinary conversation growth rather than a + * crafted fixture. All 188 tests passed with and without the production fix before this case existed. + */ + test.each([ + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 2, 3], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 4, 8], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 22, 25], + ])("a count-pressured parallel result batch stays inside the envelope (carried=%i, results=%i)", (carried, batch) => { + const checkpointRoots = Array.from( + { length: carried }, + (_, i) => storeCursorBlob(new TextEncoder().encode(JSON.stringify({ role: "user", content: [{ type: "text", text: `covered ${i}` }] }))), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + const calls: OcxAssistantContentPart[] = Array.from({ length: batch }, (_, i) => ({ + type: "toolCall", + id: `call_par_${i}`, + name: "exec_command", + arguments: { cmd: `echo P${i}` }, + })); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Run every step once.", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "Running every step." }, ...calls], timestamp: 2 }, + ]; + for (let i = 0; i < batch; i++) { + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_par_${i}`, + toolName: "exec_command", + content: `PARALLEL_OUT_${i}`, + isError: false, + timestamp: 3 + i, + }); + } + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_ckpt_count_parallel_${carried}_${batch}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + // The envelope bound is the whole point: over it, Cursor answers 400 and the retry path fails closed. + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // And the newest result still has to reach the model, whether the checkpoint was kept or abandoned — + // staying inside the envelope by sending nothing useful is the other half of this defect. + const texts = roots.map(id => { + try { + const parsed = JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + } catch { + return ""; + } + }); + expect(texts.some(text => text.includes(`PARALLEL_OUT_${batch - 1}`))).toBe(true); + }); + + /** + * Audit r10. The count bound added for r9 acts in ROOT space; the abandon check derived its trailing run + * from `rawMessages`. The two disagree exactly when an assistant message emits no root — a bare tool call + * with no narration, which is the most common assistant shape in this file's own fixtures. Two + * sequentially-executed results then sit adjacent as roots, both enter the trailing run, the count bound + * drops the older one, and a raw-space scan that sees a run of one never notices: the request went out + * with a tool call answered by nothing, checkpoint retained, no throw, no diagnostic. Measured at 190 + * carried roots with bare-call pairs, the first answer vanished from the wire entirely — not in a root, not + * in `turns[]`. + * + * Two defects, and this case covers both. The drop was also UNNECESSARY: `historyLimit` subtracted + * `systemEntryCount` on a path where the caller appends only history roots and the checkpoint's own + * system roots are already inside `carriedRoots`, so the limit came out 1 where 2 results fit. + */ + test.each([ + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 2, 2], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 2, 4], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 3, 3], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 6, 8], + ])("a bare-tool-call continuation never answers a call with nothing (carried=%i, pairs=%i)", (carried, pairs) => { + const checkpointRoots = Array.from( + { length: carried }, + (_, i) => storeCursorBlob(new TextEncoder().encode(JSON.stringify({ role: "user", content: [{ type: "text", text: `covered ${i}` }] }))), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Read each file once.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let n = 0; n < pairs; n++) { + // No text part: this assistant message emits no root, which is what puts the two results next to + // each other in root space while raw space still separates them. + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: `call_bare_${n}`, name: "read_file", arguments: { path: `f${n}.txt` } }], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_bare_${n}`, + toolName: "read_file", + content: `BARE_ANSWER_${n}`, + isError: false, + timestamp: timestamp++, + }); + } + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_ckpt_bare_call_${carried}_${pairs}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + const texts = roots.map(id => { + try { + const parsed = JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + } catch { + return ""; + } + }); + // Either the checkpoint was abandoned and the full replay carries every answer, or it was kept and every + // replayed call still has its own. What must never happen is a kept checkpoint missing an answer: the + // model then sees a call with no result and re-issues it, which is the defect this whole unit exists to + // remove. A per-pair assertion states that without having to know which branch was taken. + for (let n = 0; n < pairs; n++) { + expect(texts.some(text => text.includes(`BARE_ANSWER_${n}`))).toBe(true); + } + }); + + /** + * Audit r10, second half. The silent-loss assertion above passes either way — with the double charge the + * checkpoint is abandoned and the full replay carries every answer, which is correct output reached + * wastefully. This case pins the arithmetic instead: with exactly as many free slots as results, the + * checkpoint must be KEPT and every slot used. `historyLimit` subtracted `systemEntryCount` on a path + * where the caller appends only `ids.slice(suffixSystemCount)` and the checkpoint's own system roots are + * already inside `carriedRoots.count`, so one genuinely free slot was paid for twice: the limit came out 1 + * where 2 results fit, and a fully answerable continuation was thrown away. + */ + test("a checkpoint continuation uses every root slot the envelope actually leaves free", () => { + const carried = CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 2; + const checkpointRoots = Array.from( + { length: carried }, + (_, i) => storeCursorBlob(new TextEncoder().encode(JSON.stringify({ role: "user", content: [{ type: "text", text: `covered ${i}` }] }))), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Read each file once.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let n = 0; n < 2; n++) { + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: `call_fit_${n}`, name: "read_file", arguments: { path: `fit${n}.txt` } }], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_fit_${n}`, + toolName: "read_file", + content: `FIT_ANSWER_${n}`, + isError: false, + timestamp: timestamp++, + }); + } + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt_exact_fit", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + // Two results, two free slots, and the checkpoint retained: exactly the envelope, not one short of it. + expect(roots.length).toBe(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + expect(roots.length).toBeGreaterThan(carried); + }); + + /** + * Audit r11. The repetition breaker appends a synthetic `[context note]` user root AFTER the transcript, + * standing for no message and therefore carrying no `messageIndex`. The trailing-result walk tested only + * for `role === "toolResult"`, so it stopped dead on that note: the trailing run came out EMPTY, the + * results lost their trailing-run status entirely and were pruned as ordinary history with no "keep at + * least one" floor, and the empty `activeMessageIndexes` sent the abandon check back to the raw-message + * scan that r10 exists to avoid. Measured: at 186 carried roots the note-armed shape was RETAINED while + * the identical shape without the note correctly abandoned. + * + * The note arms on three consecutive identical assistant narrations — the runaway-repetition shape this + * whole unit exists to end — so the input most likely to trigger it is the one the fix is for. + * + * Asserted as an A/B against the same pressure, because the defect is a DIVERGENCE: whatever the no-note + * shape does, the note must not change whether a call keeps its answer, and the note itself must survive. + */ + test("a repetition note does not cost the trailing results their answers", () => { + const carried = CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 6; + const pairs = 8; + const build = (withNote: boolean) => { + const checkpointRoots = Array.from( + { length: carried }, + (_, i) => storeCursorBlob(new TextEncoder().encode(JSON.stringify({ role: "user", content: [{ type: "text", text: `covered ${i}` }] }))), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Work through the plan.", timestamp: 1 }, + ]; + let timestamp = 2; + if (withNote) { + // Three consecutive identical narrations with no root between them: this is what arms the breaker. + for (let r = 0; r < 4; r++) { + rawMessages!.push({ role: "assistant", content: [{ type: "text", text: "Still working on it." }], timestamp: timestamp++ }); + } + } + for (let n = 0; n < pairs; n++) { + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: `call_note_${n}`, name: "exec_command", arguments: { cmd: `echo N${n}` } }], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_note_${n}`, + toolName: "exec_command", + content: `NOTE_OUT_${String(n).padStart(3, "0")}`, + isError: false, + timestamp: timestamp++, + }); + } + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_ckpt_repetition_note_${withNote}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const texts = roots.map(id => { + try { + const parsed = JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + } catch { + return ""; + } + }); + const blob = texts.join("\n"); + return { + roots: roots.length, + kept: roots.length > carried, + note: blob.includes("[context note]"), + answered: Array.from({ length: pairs }, (_, n) => blob.includes(`NOTE_OUT_${String(n).padStart(3, "0")}`)), + }; + }; + const plain = build(false); + const noted = build(true); + expect(noted.roots).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // The note reached the model: excluding it from the trailing-result walk must not drop it. + expect(noted.note).toBe(true); + // Every replayed call still has its answer, and the note did not change which calls those are. + expect(noted.answered).toEqual(plain.answered); + expect(noted.answered.every(Boolean)).toBe(true); + // And the note did not flip a coherent full replay into a retained checkpoint. + expect(noted.kept).toBe(plain.kept); + }); + + /** + * Audit r11, second half. Excluding the repetition note from the trailing-result walk means it is + * re-appended after pruning, so its root slot has to be PAID FOR during pruning — the same mistake the + * count budget already made once, one root further along. Left uncharged, a note-armed continuation under + * count pressure assembles past the envelope and throws the non-retryable 400: measured at 188-190 carried + * roots for sequential and parallel suffixes alike. + * + * The note-armed A/B case above cannot catch this — it uses byte-free small results and lands below the + * count cliff — which is why the charge needs its own case at the boundary. + */ + test.each([ + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 4, 3, false], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 3, 2, false], + [CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 4, 4, true], + ])("a repetition note is paid for out of the envelope, not added to it (carried=%i, results=%i, parallel=%s)", (carried, results, parallel) => { + const checkpointRoots = Array.from( + { length: carried }, + (_, i) => storeCursorBlob(new TextEncoder().encode(JSON.stringify({ role: "user", content: [{ type: "text", text: `covered ${i}` }] }))), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Work through the plan.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let r = 0; r < 4; r++) { + rawMessages!.push({ role: "assistant", content: [{ type: "text", text: "Same line again." }], timestamp: timestamp++ }); + } + if (parallel) { + const calls: OcxAssistantContentPart[] = Array.from({ length: results }, (_, i) => ({ + type: "toolCall", + id: `call_np_${i}`, + name: "exec_command", + arguments: { cmd: `echo NP${i}` }, + })); + rawMessages!.push({ role: "assistant", content: calls, timestamp: timestamp++ }); + for (let i = 0; i < results; i++) { + rawMessages!.push({ role: "toolResult", toolCallId: `call_np_${i}`, toolName: "exec_command", content: `NP_OUT_${i}`, isError: false, timestamp: timestamp++ }); + } + } else { + for (let i = 0; i < results; i++) { + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: `call_ns_${i}`, name: "exec_command", arguments: { cmd: `echo NS${i}` } }], + timestamp: timestamp++, + }); + rawMessages!.push({ role: "toolResult", toolCallId: `call_ns_${i}`, toolName: "exec_command", content: `NS_OUT_${i}`, isError: false, timestamp: timestamp++ }); + } + } + // Throwing here is the failure: the envelope error is a non-retryable 400 at the provider. + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_ckpt_note_budget_${carried}_${results}_${parallel}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + }); + + /** + * Audit r12. The note's BYTE reservation had no coverage at all: neutralizing every byte charge in one + * edit left the whole suite green, while a sweep against that same mutation produced 148 + * `CursorRootEnvelopeLimitError` throws. Two distinct failures live here, and this case is built to + * catch both. + * + * The reservation must exist: the note is appended after pruning, so a budget that does not deduct it + * assembles past `CURSOR_EXTERNAL_ROOT_BYTE_LIMIT` and 400s non-retryably. + * + * And it must be deducted BEFORE the equal-share division, not after. Dividing the gross budget by + * `active.length` produces shares summing to the whole budget, so adding the note back always exceeds + * it: the shrink-toward-equal-share pass becomes structurally unfittable and control falls through to + * the loop that deletes a whole result. 246 bytes of note cost an entire 200 KB answer that way, and + * with a single large result the recovery block dropped it outright — the model received a 193-byte + * instruction to change strategy and no tool output whatsoever, which is precisely the re-execution + * loop this unit exists to end. + * + * Asserted as an A/B on the note alone, because the defect is a divergence: arming it must not cost an + * answer, and must not push the request out of the envelope. + */ + test.each([ + [1, 600_000], + [3, 200_000], + [4, 130_000], + ])("an armed repetition note costs no answer and no envelope room (results=%i, bytes=%i)", (results, resultBytes) => { + const build = (armed: boolean) => { + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Run the plan and report.", timestamp: 1 }, + ]; + let timestamp = 2; + // Three consecutive identical narrations arm the breaker; two do not. Nothing else differs. + for (let r = 0; r < (armed ? 3 : 2); r++) { + rawMessages!.push({ role: "assistant", content: [{ type: "text", text: "Same line." }], timestamp: timestamp++ }); + } + for (let i = 0; i < results; i++) { + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: `call_byte_${i}`, name: "exec_command", arguments: { cmd: `echo B${i}` } }], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_byte_${i}`, + toolName: "exec_command", + content: `BYTE_OUT_${i}_` + "y".repeat(resultBytes), + isError: false, + timestamp: timestamp++, + }); + } + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_note_bytes_${results}_${resultBytes}_${armed}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + let bytes = 0; + const texts = roots.map(id => { + const data = blobData(id); + bytes += data.byteLength; + try { + const parsed = JSON.parse(new TextDecoder().decode(data)) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + } catch { + return ""; + } + }); + const blob = texts.join("\n"); + return { + bytes, + note: blob.includes("[context note]"), + answered: Array.from({ length: results }, (_, i) => blob.includes(`BYTE_OUT_${i}_`)), + }; + }; + const plain = build(false); + const armed = build(true); + // The note reached the model, and paid for itself: still inside the byte envelope. + expect(armed.note).toBe(true); + expect(plain.note).toBe(false); + expect(armed.bytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + // Every answer the un-armed request carried is still there. A truncated answer counts; a deleted one + // does not, which is the distinction the equal-share pass exists to make. + expect(armed.answered).toEqual(plain.answered); + expect(armed.answered.every(Boolean)).toBe(true); + }); + + /** + * Audit r13. The note's reservation is a subtraction clamped at zero, so it cannot represent a DEFICIT: + * when the note costs more than the budget has left, `Math.max(0, …)` reported "the note costs nothing" + * and the tail was appended anyway. An envelope with 26 bytes free emitted a 246-byte note and overran + * by 220, throwing the non-retryable 400 this unit exists to remove. + * + * Every fixture in this file is a tool continuation, and a trailing tool result HIDES this: the abandon + * check's survival disjuncts rescue that shape. The exposed shape is a turn that does not end in a + * result — an ordinary user interjection after a repetitive stretch — where nothing else bounds the + * tail. Measured 13 of 42 carried-byte positions throwing with the note armed and none without it. + * + * The note is dropped when it cannot be paid for, which is this unit's priority order: a missing + * instruction is recoverable, a missing tool result restarts the loop. + */ + test.each([0, 100, 220, 400])("an unaffordable repetition note is dropped, not sent past the envelope (deficit=%i)", deficit => { + const build = (armed: boolean) => { + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [storeCursorBlob(new Uint8Array(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - deficit).fill(65))], + turns: [new Uint8Array(32).fill(8)], + }); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Go.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let r = 0; r < (armed ? 4 : 2); r++) { + rawMessages!.push({ role: "assistant", content: [{ type: "text", text: "Same." }], timestamp: timestamp++ }); + } + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: "call_deficit", name: "exec_command", arguments: { cmd: "echo D" } }], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: "call_deficit", + toolName: "exec_command", + content: "DEFICIT_OUT", + isError: false, + timestamp: timestamp++, + }); + // The shape the suite never had: the turn ends with a plain user message, so the abandon check's + // result-survival disjuncts cannot fire and nothing else bounds the appended note. + rawMessages!.push({ role: "user", content: "Actually, try something else.", timestamp: timestamp++ }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_note_deficit_${deficit}_${armed}`, + system: ["Be brief."], + messages: [{ role: "user", content: "next" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const bytes = roots.reduce((sum, id) => sum + blobData(id).byteLength, 0); + return { roots: roots.length, bytes }; + }; + // Arming the note must not push the request past the envelope, and must not throw at all: the guard + // raises a 400 the caller cannot retry. + const armed = build(true); + expect(armed.bytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + expect(armed.roots).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // And the un-armed request is unaffected, so the bound is the note's cost rather than a blanket cut. + const plain = build(false); + expect(plain.bytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + }); + + /** + * Audit r14. Two things the deficit case above cannot see, both proven by mutation to be real. + * + * The count axis. Affordability was briefly decided on bytes alone, on the reasoning that the count bound + * always leaves a slot free — wrong at exactly `historyLimit === 1`, where the one free slot is the one + * the surviving result takes. The note was then appended anyway and full replay assembled 193 roots: an + * armed-only non-retryable 400 where the same request without the note sent 192 and succeeded. Reached by + * full replay with many system prompts, which has no abandon branch to rescue it — not by carried roots, + * which is why a checkpoint-path sweep missed it. + * + * And the reservation itself, as distinct from the append. Neutralizing `syntheticCount`/`syntheticBytes` + * while leaving the append gated left the whole suite green, because asserting on the assembled payload + * cannot distinguish "the deficit was charged" from "the tail was simply not appended". Asserting the + * exact root count at the boundary does: with the reservation the result is admitted and the note is + * dropped, giving 192; without it the reservation is a no-op and pruning admits one root too few. + */ + test.each([ + ["result" as const, 1], + ["result" as const, 3], + ["user" as const, 1], + ["user" as const, 3], + ])("an armed note never spends a root slot it was not given (tail=%s, results=%i)", (tail, results) => { + // 191 system roots leaves exactly one free slot out of 192: the result takes it, so the note cannot fit. + const systemCount = CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 1; + const build = (armed: boolean) => { + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Go.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let r = 0; r < (armed ? 4 : 2); r++) { + rawMessages!.push({ role: "assistant", content: [{ type: "text", text: "Same." }], timestamp: timestamp++ }); + } + const calls: OcxAssistantContentPart[] = Array.from({ length: results }, (_, i) => ({ + type: "toolCall", + id: `call_slot_${i}`, + name: "exec_command", + arguments: { cmd: `echo S${i}` }, + })); + rawMessages!.push({ role: "assistant", content: calls, timestamp: timestamp++ }); + for (let i = 0; i < results; i++) { + rawMessages!.push({ role: "toolResult", toolCallId: `call_slot_${i}`, toolName: "exec_command", content: `SLOT_OUT_${i}`, isError: false, timestamp: timestamp++ }); + } + if (tail === "user") rawMessages!.push({ role: "user", content: "Try something else.", timestamp: timestamp++ }); + // Full replay on purpose: there is no abandon branch here, so nothing rescues an overrun. + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_note_slot_${tail}_${results}_${armed}`, + system: Array.from({ length: systemCount }, (_, i) => `S${i}`), + messages: [{ role: tail === "user" ? "user" : "tool", content: "result" }], + rawMessages, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + return (run?.conversationState?.rootPromptMessagesJson ?? []).length; + }; + // Arming the note must not throw and must not cost a slot: exactly the envelope, same as un-armed. + expect(build(true)).toBe(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + expect(build(false)).toBe(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + }); + + /** + * Audit r15. The case above pins the affordability threshold from the loose side only: relaxing it + * reddens, but TIGHTENING `historyLimit - syntheticCountRaw >= 1` to `>= 2` left all 212 tests green. + * Over-conservative is safer than over-eager, but this unit has already dropped a guard as inert and had + * to restore it, so a suite that cannot tell a correct bound from an unnecessarily strict one is exactly + * the gap that cost round 14. + * + * Two free slots is the tight case: the result takes one, the note takes the other, and both must arrive. + */ + test("a note that exactly fits the last free slot is kept, not dropped", () => { + // 190 system roots leaves two free slots out of 192: one for the result, one for the note. + const systemCount = CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 2; + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Go.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let r = 0; r < 4; r++) { + rawMessages!.push({ role: "assistant", content: [{ type: "text", text: "Same." }], timestamp: timestamp++ }); + } + rawMessages!.push({ + role: "assistant", + content: [{ type: "toolCall", id: "call_exact", name: "exec_command", arguments: { cmd: "echo E" } }], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: "call_exact", + toolName: "exec_command", + content: "EXACT_FIT_OUT", + isError: false, + timestamp: timestamp++, + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_note_exact_fit", + system: Array.from({ length: systemCount }, (_, i) => `S${i}`), + messages: [{ role: "tool", content: "result" }], + rawMessages, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const blob = roots.map(id => { + try { + const parsed = JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + } catch { + return ""; + } + }).join("\n"); + expect(roots.length).toBe(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // Both arrive. Dropping either one at an exact fit is a defect in a different direction. + expect(blob).toContain("EXACT_FIT_OUT"); + expect(blob).toContain("[context note]"); + }); + + /** + * Audit r10 finding 2. `outputElided` on the marker-only return had no coverage: removing the flag left + * all 191 tests green, and `tests/` is not typechecked (`tsconfig` include is `["src"]`), so nothing would + * have caught its removal. A result reduced to the truncation marker answers its call with nothing, which + * is why the abandon decision reads the flag — so assert the abandonment, not the flag. + */ + test("a result truncated to the bare marker abandons the checkpoint instead of answering with nothing", () => { + // A checkpoint that consumes nearly the whole byte budget leaves room for a marker and no output. + const carriedBytes = CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - 400; + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [storeCursorBlob(new Uint8Array(carriedBytes).fill(65))], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt_marker_only", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages: [ + { role: "user", content: "Read the file.", timestamp: 1 }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_marker", name: "read_file", arguments: { path: "big.txt" } }], + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "call_marker", + toolName: "read_file", + content: "MARKER_ONLY_PAYLOAD".repeat(4096), + isError: false, + timestamp: 3, + }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + // Abandoned: the oversized carried root is gone, so the reply is a self-contained full replay. + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + const texts = roots.map(id => { + try { + const parsed = JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + } catch { + return ""; + } + }); + // The replay carries the call's own output, not a marker standing in for it. + expect(texts.some(text => text.includes("MARKER_ONLY_PAYLOAD"))).toBe(true); + }); + + /** + * Audit r8 re-review finding B. The abandon test compared `carriedRoots.byteLength` against the raw byte + * limit while pruning subtracts the system prompt too, so a ~128-byte band just under the limit kept the + * checkpoint, gave the suffix a zero budget, and dropped the tool result the model was waiting for — + * silently, where the old code at least threw. + */ + test("a checkpoint just under the byte limit still lets the latest result through", () => { + const systemPrompt = "You are helpful."; + // Land inside the old gap: below the limit, but not far enough below to leave the suffix any room once + // the system prompt is paid for. + const carriedBytes = CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - 200; + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [storeCursorBlob(new Uint8Array(carriedBytes).fill(65))], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt_byte_band", + system: [systemPrompt], + messages: [{ role: "tool", content: "result" }], + rawMessages: [ + { role: "user", content: "Run it.", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "Running." }, + { type: "toolCall", id: "call_band", name: "exec_command", arguments: { cmd: "echo BAND" } }, + ], + timestamp: 2, + }, + { role: "toolResult", toolCallId: "call_band", toolName: "exec_command", content: "BAND-OUTPUT", isError: false, timestamp: 3 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const serialized = JSON.stringify(roots.map(id => { + const data = cursorBlobByteLength(id) === null ? undefined : blobData(id); + return data ? JSON.parse(new TextDecoder().decode(data)) : null; + })); + // The whole point: whatever the pruning decides, the result the model is waiting on must be visible. + expect(serialized).toContain("BAND-OUTPUT"); + const measured = roots.reduce((sum, id) => sum + (cursorBlobByteLength(id) ?? 0), 0); + expect(measured).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + }); + + /** + * Audit r8 round 3, BLOCKER. The result-survival check asks whether the replayed result root survived + * pruning. A native resume model never HAS one: its result travels in server-side turn state, so + * `echoToolResultInRoot` is false and `rootPromptMessages` skips the root entirely. Unguarded, the check + * answered "no" on every native continuation and discarded the checkpoint 100% of the time — including + * `cursor/auto`, the default id. + * + * That is not cosmetic. `pendingToolCalls`, `readPaths` and `previousWorkspaceUris` exist only inside the + * checkpoint, and a full replay does not rebuild them, so the accumulated state was simply lost. + */ + test("a native model keeps its checkpoint through a tool continuation", () => { + // Model class CROSSED with narration shape. Round 3 fixed the narrated shape and round 4 found the + // silent one still broken, because a bare tool call produces no assistant root either — so the suffix + // has zero history roots and a different disjunct of the same condition fired. Testing one shape per + // model class is what let the second path hide; the cross product is the point of this loop. + const assistantShapes: Array<{ label: string; content: OcxAssistantContentPart[] }> = [ + { label: "narrated", content: [{ type: "text", text: "Reading." }, { type: "toolCall", id: "n1", name: "read_file", arguments: { path: "a.txt" } }] }, + { label: "silent", content: [{ type: "toolCall", id: "n1", name: "read_file", arguments: { path: "a.txt" } }] }, + { label: "empty-text", content: [{ type: "text", text: "" }, { type: "toolCall", id: "n1", name: "read_file", arguments: { path: "a.txt" } }] }, + { label: "whitespace-text", content: [{ type: "text", text: " " }, { type: "toolCall", id: "n1", name: "read_file", arguments: { path: "a.txt" } }] }, + ]; + for (const modelId of ["cursor/auto", "cursor/composer-1", "cursor/composer-2.5-fast", "cursor/composer-3"]) { + for (const shape of assistantShapes) { + const carriedRoot = storeCursorBlob(new TextEncoder().encode(JSON.stringify({ + role: "user", + content: [{ type: "text", text: "covered by checkpoint" }], + }))); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [carriedRoot], + turns: [new Uint8Array(32).fill(8)], + readPaths: ["a.txt", "b.txt"], + }); + const prepared = prepareCursorRunRequest({ + modelId, + conversationId: `cursor_native_ckpt_${modelId}_${shape.label}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "FILE-CONTENTS" }], + rawMessages: [ + { role: "user", content: "Read the file.", timestamp: 1 }, + { role: "assistant", content: shape.content, timestamp: 2 }, + { role: "toolResult", toolCallId: "n1", toolName: "read_file", content: "FILE-CONTENTS", isError: false, timestamp: 3 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const state = run?.conversationState; + // readPaths only ever comes from the decoded checkpoint, so it is the load-bearing assertion: + // it is empty exactly when the checkpoint was thrown away. + expect(state?.readPaths ?? []).toEqual(["a.txt", "b.txt"]); + const roots = state?.rootPromptMessagesJson ?? []; + expect(roots.some(id => Array.from(id).join(",") === Array.from(carriedRoot).join(","))).toBe(true); + } + } + }); + + /** + * Audit r8 round 3, MAJOR. The survival check read only the LAST replayed message. Parallel tool calls + * land as a run of results, and under byte pressure the older ones were the ones being emptied — measured + * a prompt carrying three calls and one answer. `historyOutputElided` already recorded them; only one + * index was consulted. The whole trailing run is checked now. + */ + /** + * An exhausted checkpoint must still produce a usable request: the assembled roots stay inside the + * envelope and the conversation continues by full replay. + * + * It does NOT assert that the checkpoint store hears about it. Audit round 3 asked for that and round 4 + * measured why it cannot be done here: `live-transport.ts` prepares a spread copy of the request, so a + * field written on the argument never reaches the caller that would invalidate the stored checkpoint. + * Asserting on the argument would have passed while proving nothing about the real path. + */ + test("an exhausted checkpoint still assembles a legal full-replay request", () => { + const request = { + modelId: "grok-4.6", + conversationId: "cursor_ckpt_exhausted_reason", + system: ["You are helpful."], + messages: [{ role: "tool" as const, content: "x" }], + rawMessages: [ + { role: "user" as const, content: "Run it.", timestamp: 1 }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Running." }, + { type: "toolCall" as const, id: "p1", name: "exec_command", arguments: { cmd: "echo X" } }, + ], + timestamp: 2, + }, + { role: "toolResult" as const, toolCallId: "p1", toolName: "exec_command", content: "OUT", isError: false, timestamp: 3 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + // A checkpoint that fills the root budget on its own: nothing is left for the suffix. + rootPromptMessagesJson: Array.from({ length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT }, (_, i) => new Uint8Array(32).fill(i % 251)), + })), + continuationMode: "checkpoint" as const, + checkpointSuffixStart: 1, + }; + const prepared = prepareCursorRunRequest(request); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // The oversized checkpoint is gone rather than pruned to fit, so the replay carries its own history. + const serialized = JSON.stringify(roots.map(id => (cursorBlobByteLength(id) === null ? null : JSON.parse(new TextDecoder().decode(blobData(id)))))); + expect(serialized).toContain("OUT"); + }); + + test("parallel results are never delivered as a partial answer set", () => { + const filler = "Q".repeat(40 * 1024); + // 375 bytes below the limit is where a last-index-only check leaves exactly one answer standing: any + // further down and pruning keeps all three, any further up and it keeps none. Derived by sweeping 628 + // (delta, payload) positions against the last-index-only implementation, not guessed. + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [storeCursorBlob(new Uint8Array(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - 375).fill(65))], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt_parallel_partial", + system: ["You are helpful."], + messages: [{ role: "tool", content: "x" }], + rawMessages: [ + { role: "user", content: "Run three at once.", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "Running three." }, + { type: "toolCall", id: "r1", name: "exec_command", arguments: { cmd: "echo A" } }, + { type: "toolCall", id: "r2", name: "exec_command", arguments: { cmd: "echo B" } }, + { type: "toolCall", id: "r3", name: "exec_command", arguments: { cmd: "echo C" } }, + ], + timestamp: 2, + }, + { role: "toolResult", toolCallId: "r1", toolName: "exec_command", content: `SENTINEL-ONE${filler}`, isError: false, timestamp: 3 }, + { role: "toolResult", toolCallId: "r2", toolName: "exec_command", content: `SENTINEL-TWO${filler}`, isError: false, timestamp: 4 }, + { role: "toolResult", toolCallId: "r3", toolName: "exec_command", content: `SENTINEL-THREE${filler}`, isError: false, timestamp: 5 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const serialized = roots + .map(id => (cursorBlobByteLength(id) === null ? "" : new TextDecoder().decode(blobData(id)))) + .join("||"); + const present = ["SENTINEL-ONE", "SENTINEL-TWO", "SENTINEL-THREE"].filter(s => serialized.includes(s)); + // All three or none — a subset is a prompt with three calls and fewer answers. + expect(present.length === 0 || present.length === 3).toBe(true); + }); + test("the run-request diagnostic reports the measured envelope, not zero", () => { const previousDebug = process.env.OCX_DEBUG; process.env.OCX_DEBUG = "1"; @@ -2408,3 +3399,177 @@ describe("Cursor external replay envelope", () => { } }); }); + +/** + * devlog 260829 070. The orphan-strip guard in `rootPromptMessages` assumed the replayed history + * starts where the CONVERSATION starts, which holds only for a full replay. A checkpoint suffix starts + * at `checkpointSuffixStart`, so its first entry is routinely the assistant message whose initiating + * user turn lives inside the checkpoint — and the loop stripped pair after pair until only the trailing + * active result survived, because its `break` fires only once the survivors ARE the active block. + * + * The consequence was not a cosmetic omission. Measured live against `cursor/grok-4.6`, three + * sequential commands produced 14 tool executions — STEP1 and STEP2 seven times each, STEP3 never — + * with six "was interrupted" narrations and no terminal answer. Each turn replayed the same collapsed + * payload, so the model never saw the output of the command it had just run. + * + * These assert the property the collapse violated: the replayed suffix grows with the history. + */ +describe("Cursor checkpoint suffix keeps its completed pairs", () => { + afterEach(() => { + clearCursorCheckpointsForTests(); + resetCursorBlobStateForTests(); + }); + + /** One covered opening user message, then `pairs` completed call/result exchanges. */ + function growingHistory(pairs: number) { + const messages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Run each step once.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let n = 1; n <= pairs; n++) { + messages!.push({ + role: "assistant", + content: [ + { type: "text", text: `Running STEP${n}.` }, + { type: "toolCall", id: `call_${n}`, name: "exec_command", arguments: { cmd: `echo STEP${n}` } }, + ], + timestamp: timestamp++, + }); + messages!.push({ + role: "toolResult", + toolCallId: `call_${n}`, + toolName: "exec_command", + content: `STEP${n}`, + isError: false, + timestamp: timestamp++, + }); + } + return messages; + } + + /** Replayed root texts, minus the checkpoint-carried root this process cannot read back. */ + function suffixTexts(pairs: number): string[] { + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [new Uint8Array(32).fill(7)], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: `cursor_ckpt_pairs_${pairs}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages: growingHistory(pairs), + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + // Only the opening user message is covered; every pair below it must be replayed. + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + return roots.slice(1).map(id => { + const parsed = JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: string | [{ text?: string }] }; + const content = parsed.content; + return typeof content === "string" ? content : (content?.[0]?.text ?? ""); + }); + } + + test("every completed pair in the uncovered suffix reaches the model", () => { + const texts = suffixTexts(3); + // Before the fix this was a single entry: the STEP3 result, with STEP1 and STEP2 discarded. + for (const step of ["STEP1", "STEP2", "STEP3"]) { + expect(texts.some(text => text.includes(`echo ${step}`))).toBe(true); + expect(texts.some(text => text.startsWith("[Tool Result]") && text.includes(step))).toBe(true); + } + }); + + test("the replayed suffix grows with the history instead of collapsing to a constant", () => { + // Exact counts are an implementation detail; a payload that does not grow at all is the defect. + const counts = [1, 2, 3, 4].map(pairs => suffixTexts(pairs).length); + expect(counts).toEqual([...counts].sort((a, b) => a - b)); + expect(new Set(counts).size).toBeGreaterThan(1); + expect(counts.at(-1)!).toBeGreaterThan(counts[0]!); + }); + + /** + * Audit r8 finding 2: the orphan-guard fix alone was INERT under byte pressure, and measurably so — + * 8 pairs of 64 KiB results still emitted 2 roots. The `keptPrior` loop admits COMPLETE TURNS, and a + * turn starts at a user root; a checkpoint suffix has no user root at all, so `turnStart` walked to 0, + * the whole prior block became one all-or-nothing pseudo-turn, and the first budget overrun dropped + * every entry — leaving the orphan guard nothing to strip and the model nothing to read. + * + * Root replay is the only channel carrying suffix history (`conversationTurns` never opens a turn for a + * suffix with no user message), so this was a total loss of that history, not a partial one. + */ + test("byte pressure prunes a checkpoint suffix incrementally instead of dropping all of it", () => { + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [new Uint8Array(32).fill(7)], + turns: [new Uint8Array(32).fill(8)], + }); + // Eight pairs whose results together far exceed CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, so pruning must run. + const bulky = "X".repeat(64 * 1024); + const rawMessages: Parameters[0]["rawMessages"] = [ + { role: "user", content: "Run each step once.", timestamp: 1 }, + ]; + let timestamp = 2; + for (let n = 1; n <= 8; n++) { + rawMessages!.push({ + role: "assistant", + content: [ + { type: "text", text: `Running STEP${n}.` }, + { type: "toolCall", id: `call_${n}`, name: "exec_command", arguments: { cmd: `echo STEP${n}` } }, + ], + timestamp: timestamp++, + }); + rawMessages!.push({ + role: "toolResult", + toolCallId: `call_${n}`, + toolName: "exec_command", + content: bulky, + isError: false, + timestamp: timestamp++, + }); + } + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt_byte_pressure", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages, + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 1, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + // Two roots (checkpoint seed + one active result) is the defect. More than a handful survive now. + expect(roots.length).toBeGreaterThan(4); + // And the bound still holds: retention did not come at the cost of the envelope. + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + const measured = roots.reduce((sum, id) => sum + (cursorBlobByteLength(id) ?? 0), 0); + expect(measured).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + }); + + test("a full replay still strips a genuinely orphaned leading entry", () => { + // The guard's reason to exist (#1527). With no checkpoint there is no covered turn, so a leading + // assistant entry IS orphaned and must not survive — this is what keeps the fix narrow. + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_full_replay_orphan", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages: [ + { role: "assistant", content: [{ type: "text", text: "ORPHAN LEADING ASSISTANT" }], timestamp: 1 }, + { role: "user", content: "please read", timestamp: 2 }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "FILE", isError: false, timestamp: 3 }, + ], + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const serialized = JSON.stringify(roots.map(id => JSON.parse(new TextDecoder().decode(blobData(id))))); + expect(serialized).not.toContain("ORPHAN LEADING ASSISTANT"); + expect(serialized).toContain("FILE"); + }); +});