From 3481b617645611180e6fb0a81cfd6f8239ef48f5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 04:06:14 +0900 Subject: [PATCH 01/13] fix(cursor): keep a checkpoint suffix's completed pairs out of the orphan strip The orphan-strip guard in rootPromptMessages is premised on the replayed history starting where the CONVERSATION starts. That holds for a full replay and not for a checkpoint suffix, which starts at checkpointSuffixStart -- so its first entry is routinely the assistant message whose initiating user turn is inside the checkpoint. The loop read that as an orphan and shifted it off, then the next entry, and kept going: its break fires only once the survivors ARE the active block, so every completed pair was discarded. Measured 2 roots for 1, 2, 3 and 4 pairs in the suffix. Live on cursor/grok-4.6 that meant a growing conversation replayed a constant payload. Three sequential echo commands produced 14 tool executions -- STEP1 and STEP2 seven times each, STEP3 never -- with six was-interrupted narrations and no terminal answer, because the model could not see the output of the command it had just run. Diagnostics showed rawMessages 9,11,13,15,17,19 with rootBlobs pinned at 5. knownCallsOffset already carries the fact the guard was missing: only the checkpoint path passes one, and it passes suffixStart. Naming it suffixContinuesCoveredTurn and skipping the loop when set leaves the #1527 full-replay behaviour and the initiator recovery below it untouched. After the fix the same live run executes each command exactly once, narrates no interrupt, and answers ALLDONE; roots track history at 4, 6, 8, 10. Three assertions added, both mutation directions checked: restoring the unconditional guard turns two red, skipping it unconditionally turns the full-replay orphan case red. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 116 ++++++++++++++++++ src/adapters/cursor/protobuf-request.ts | 25 +++- tests/cursor-blob.test.ts | 114 +++++++++++++++++ 3 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/260829_cursor_tool_continuation_pairing/070_phase8_checkpoint_suffix_orphan_strip.md 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..d3809c9b1d --- /dev/null +++ b/devlog/_plan/260829_cursor_tool_continuation_pairing/070_phase8_checkpoint_suffix_orphan_strip.md @@ -0,0 +1,116 @@ +# 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: + +| observed | value | +|---|---| +| distinct commands requested | 3 (STEP1, STEP2, STEP3) | +| `command_execution` items emitted | 14 | +| STEP1 runs | 7 | +| STEP2 runs | 7 | +| STEP3 runs | **0** | +| "interrupted" narrations | 6 | +| terminal answer | never (`ALLDONE` absent) | + +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 | + +The suffix grows and the payload does not. Live diagnostics agree — `rawMessages` 9, 11, 13, 15, 17, 19 +across consecutive tool-continuation turns with `rootBlobs` pinned at 5 and `continuationMode` +`checkpoint` every time. + +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. + diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 14f7f1c4e6..f7d19cdc51 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -343,6 +343,12 @@ function rootPromptMessages( let selected = entries; let historyMessageStart = 0; 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); @@ -417,10 +423,21 @@ function rootPromptMessages( 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 diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index a223b83380..083b25ef22 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2408,3 +2408,117 @@ 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]!); + }); + + 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"); + }); +}); From a6e3f4255ecaace36937d7a2720206f2e4da06fb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 04:20:08 +0900 Subject: [PATCH 02/13] fix(cursor): prune a checkpoint suffix incrementally and never overrun the envelope Audit r8 measured two further paths to the same symptom the orphan-strip fix addressed, and both are closed here. The orphan fix was INERT under byte pressure: 8 pairs of 64 KiB results emitted 2 roots with and without it. The keptPrior loop admits complete TURNS, and a turn starts at a user root -- which a checkpoint suffix does not have, by definition. So turnStart walked to 0, the prior block became one all-or-nothing pseudo-turn, the first budget overrun dropped all of it, and the orphan guard never ran. A suffix that continues a covered turn now admits entries individually: 2 -> 15 roots. Root replay is the only channel carrying suffix history -- conversationTurns opens no turn without a user message, measured 0 turns either way -- so this was a total loss. Restored growth then collided with the cumulative envelope guard, which began throwing a non-retryable 400 where the code used to degrade silently: 50 pairs behind 100 checkpoint roots, 10 behind 180, 4 behind 190, plus a cliff at 96 pairs. Suffix pruning now subtracts the checkpoint's own roots and bytes, and a checkpoint with no room left for its suffix is abandoned for a full replay under a new envelope_exhausted reason. Pruning to fit would have emitted the covered prefix and silently dropped every uncovered message, which is this unit's own defect at the top of the range. All three fixtures now stay at 191 roots, no throw, no cliff. Two tests asserted that throw; they assert the bound now, which is stronger -- the assembled request stays inside the envelope AND keeps its uncovered history. Removing the carriedRoots subtraction turns both red, so this is not a weakened expectation. The plan's live figures were recounted against the completed artifacts: 21 and 133 executions for three commands, not 14, and the turn does terminate. The earlier never-terminates claim came from reading a file mid-run and is withdrawn in the doc. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 97 ++++++++++++++++--- src/adapters/cursor/checkpoint-store.ts | 7 +- src/adapters/cursor/protobuf-request.ts | 58 +++++++++-- tests/cursor-blob.test.ts | 95 ++++++++++++++++-- 4 files changed, 229 insertions(+), 28 deletions(-) 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 index d3809c9b1d..dffce3032d 100644 --- 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 @@ -9,17 +9,22 @@ Cursor models "무한 출력" and "툴 출력을 못받고" — the turn never t 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: - -| observed | value | -|---|---| -| distinct commands requested | 3 (STEP1, STEP2, STEP3) | -| `command_execution` items emitted | 14 | -| STEP1 runs | 7 | -| STEP2 runs | 7 | -| STEP3 runs | **0** | -| "interrupted" narrations | 6 | -| terminal answer | never (`ALLDONE` absent) | +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 @@ -60,9 +65,17 @@ the active block, so every earlier pair is discarded no matter how many there ar | 3 | 7 | 2 | seed + result **3** only | | 4 | 9 | 2 | seed + result **4** only | -The suffix grows and the payload does not. Live diagnostics agree — `rawMessages` 9, 11, 13, 15, 17, 19 -across consecutive tool-continuation turns with `rootBlobs` pinned at 5 and `continuationMode` -`checkpoint` every time. +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, @@ -114,3 +127,59 @@ full replay in every respect that matters to the guard. 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. + +Two pre-existing tests asserted the throw. They now assert the bound instead, which is the stronger +property: the assembled request stays inside the envelope, and the uncovered history is still present. +Both were mutation-checked — removing the `carriedRoots` subtraction turns them red — so the rewrite is +not a weakened expectation. + +## 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` — 133 pass / 0 fail. +- Every new assertion driven red against the code it exists to catch: 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 the two envelope rows; skipping the orphan guard + unconditionally reddens the full-replay orphan row. +- Live re-measurement on an isolated proxy built from the patched tree, counted after the run exited: + 3 commands, one execution each, 0 interrupt mentions, terminal `ALLDONE`. Diagnostics show roots + tracking history at 4, 6, 8, 10 rather than a pinned constant. +- 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. 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 f7d19cdc51..e1c1d4a03a 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -220,6 +220,15 @@ 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; @@ -352,8 +361,8 @@ function rootPromptMessages( 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); + const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - systemEntryCount - carriedRoots.count); + 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 @@ -401,13 +410,22 @@ function rootPromptMessages( 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) { 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 ( @@ -1246,9 +1264,34 @@ 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); + // 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 is left for the suffix. Pruning to fit would then + // emit the covered prefix and silently drop every uncovered message — 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 finding 3). + const systemRootCount = systemPromptBlobs(suffixRequest).length; + if ( + carriedRoots.count + systemRootCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + || carriedRoots.byteLength >= CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + ) { + conversationState = undefined; + continuationMode = "full-replay"; + checkpointInvalidationReason = "envelope_exhausted"; + } else { // `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 suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart, carriedRoots); const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); const suffixSystemCount = systemPromptBlobs(suffixRequest).length; const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); @@ -1270,6 +1313,7 @@ function buildPreparedCursorRunRequest( historyMessageStart: suffixRoots.historyMessageStart, serialized: suffixHistorySerialized, }; + } } } catch { checkpointInvalidationReason = "decode_failed"; diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 083b25ef22..9001644342 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, @@ -2297,7 +2298,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 +2315,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 +2330,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 +2351,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 +2366,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 @@ -2500,6 +2523,66 @@ describe("Cursor checkpoint suffix keeps its completed pairs", () => { 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. From 2742c8a99519b80fd40efcfec307ed193ddfb1aa Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 05:00:08 +0900 Subject: [PATCH 03/13] fix(cursor): decide checkpoint abandonment from pruning's result, and cover it Re-audit of the previous commit found the load-bearing half of it untested and one live gap left open. Both are closed here. The claim that the two rewritten envelope tests were mutation-checked against the carriedRoots subtraction was wrong. Both exit through the abandon branch -- the count case uses unmeasurable checkpoint roots, the byte case a checkpoint big enough to trip abandonment -- so neither touched the subtraction. Deleting it reintroduced all three throws with the suite still 97/0 green. A new case reaches 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. M1 now reddens three tests. The abandon threshold also left a live band. Comparing carried bytes against the raw limit kept the checkpoint a few hundred bytes below it while the suffix budget collapsed, silently dropping the newest tool result -- where the old code at least threw. Adding systemBytes moved the band instead of closing it. The decision now reads what pruning actually did. rootPromptMessages returns the source message index of every surviving root plus the indexes whose output truncation elided entirely, and the caller asks whether the message it is continuing from is in the first set and out of the second. Two earlier predicates are recorded in the devlog because each failed differently: matching the result's output text broke on JSON escaping and made every live turn abandon its checkpoint, and matching roles could not tell the result from the narration beside it. outputElided is set at the one place that can produce an answerless root -- the marker-only fallback, and a cut landing before the envelope's output: line. Both shapes were live in the band. Swept 15 positions from 100 KiB below the byte limit to 100 bytes above it: the newest result is present at every one, where five dropped it before. Live turns still resume from their checkpoint, so the predicate costs nothing on ordinary conversations, and the repro still runs each command exactly once and answers ALLDONE. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 64 ++++++++-- src/adapters/cursor/protobuf-request.ts | 84 ++++++++++--- tests/cursor-blob.test.ts | 110 ++++++++++++++++++ 3 files changed, 235 insertions(+), 23 deletions(-) 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 index dffce3032d..e77172b780 100644 --- 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 @@ -165,21 +165,65 @@ uncovered message — this unit's own defect, reintroduced at the top of the ran 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. -Two pre-existing tests asserted the throw. They now assert the bound instead, which is the stronger -property: the assembled request stays inside the envelope, and the uncovered history is still present. -Both were mutation-checked — removing the `carriedRoots` subtraction turns them red — so the rewrite is -not a weakened expectation. +### 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` — 133 pass / 0 fail. -- Every new assertion driven red against the code it exists to catch: 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 the two envelope rows; skipping the orphan guard +- 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 patched tree, counted after the run exited: - 3 commands, one execution each, 0 interrupt mentions, terminal `ALLDONE`. Diagnostics show roots - tracking history at 4, 6, 8, 10 rather than a pinned constant. +- 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index e1c1d4a03a..c23feefa23 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[] { @@ -235,6 +249,20 @@ function rootPromptMessages( 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[]; } { const entries = systemPromptBlobs(request); const systemEntryCount = entries.length; @@ -245,6 +273,8 @@ function rootPromptMessages( byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0), historyMessageStart: 0, serialized: entries.map(entry => entry.serialized), + historyMessageIndexes: [], + historyOutputElided: [], }; } @@ -534,6 +564,15 @@ 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), }; } @@ -1275,25 +1314,42 @@ function buildPreparedCursorRunRequest( count: conversationState.rootPromptMessagesJson.length, byteLength: carriedBytes, }; - // A checkpoint can be so large that nothing is left for the suffix. Pruning to fit would then - // emit the covered prefix and silently drop every uncovered message — the exact failure this - // unit exists to remove — while throwing would hand the caller a non-retryable 400. Abandon the + // 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 finding 3). - const systemRootCount = systemPromptBlobs(suffixRequest).length; + // (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; + // 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. + const keptEnough = suffixRoots.historyMessageIndexes.includes(lastSuffixIndex) + && !suffixRoots.historyOutputElided.includes(lastSuffixIndex); + const suffixKeptItsResult = suffixMessages[lastSuffixIndex]?.role !== "toolResult" || keptEnough; if ( - carriedRoots.count + systemRootCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - || carriedRoots.byteLength >= CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + carriedRoots.count + suffixSystemCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + || suffixRoots.ids.length <= suffixSystemCount + || !suffixKeptItsResult ) { conversationState = undefined; continuationMode = "full-replay"; checkpointInvalidationReason = "envelope_exhausted"; } else { - // `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, carriedRoots); const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); - const suffixSystemCount = systemPromptBlobs(suffixRequest).length; const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount); conversationState = create(ConversationStateStructureSchema, { @@ -1312,6 +1368,8 @@ function buildPreparedCursorRunRequest( byteLength: suffixRoots.byteLength, historyMessageStart: suffixRoots.historyMessageStart, serialized: suffixHistorySerialized, + historyMessageIndexes: suffixRoots.historyMessageIndexes, + historyOutputElided: suffixRoots.historyOutputElided, }; } } diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 9001644342..c650ad0a2d 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2381,6 +2381,116 @@ 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 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); + }); + test("the run-request diagnostic reports the measured envelope, not zero", () => { const previousDebug = process.env.OCX_DEBUG; process.env.OCX_DEBUG = "1"; From e18c62dcb5626d44d370916a8a0e015fe2248df1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 05:32:41 +0900 Subject: [PATCH 04/13] fix(cursor): scope the result-survival check to the path that can satisfy it Audit round 3 found the previous commit's predicate correct for external models and wrong for two other cases. Both were measured before changing anything. Native resume models were losing their checkpoint on EVERY tool continuation, including the default cursor/auto. Their result travels in server-side turn state, so echoToolResultInRoot is false and rootPromptMessages emits no toolResult root at all -- asking whether that root survived answers no unconditionally. pendingToolCalls, readPaths and previousWorkspaceUris live only in the checkpoint and full replay does not rebuild them, so this was the unit's own defect relocated to the native path. Measured readPaths 2 -> 0 for auto, composer-2.5-fast and composer-3 while composer-2.5 and grok-4.6 were unaffected, which is exactly the split cursorNeedsExternalToolContinuation draws; the check is gated on it now. Parallel results were protected one at a time. The check read only the last replayed index, and under byte pressure the OLDER results were the ones being emptied -- three calls, one answer, which the code's own comment calls worse than keeping nothing. historyOutputElided already recorded them and nothing read it. The whole trailing run of results is checked now: 628 swept positions went from 10 partial-answer positions to 0. envelope_exhausted reached nothing. It was assigned to a local, so it landed in the debug diagnostic while src/adapters/cursor.ts drops a dead checkpoint by reading request.checkpointInvalidationReason -- the exhausted checkpoint was re-decoded and re-abandoned every turn until TTL. Written back onto the request now, as request-builder.ts already does for every other reason. Three assertions added, each driven red against the implementation it catches. The parallel fixture's 375-byte offset is derived from the sweep, not guessed: it is the one position where a last-index-only check leaves exactly one answer standing. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 51 +++++++ src/adapters/cursor/protobuf-request.ts | 34 ++++- tests/cursor-blob.test.ts | 140 ++++++++++++++++++ 3 files changed, 222 insertions(+), 3 deletions(-) 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 index e77172b780..2ee308a3d5 100644 --- 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 @@ -227,3 +227,54 @@ checkpoint spends. Removing the subtraction now reddens three tests. 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 reached nothing + +`envelope_exhausted` was assigned to a local, so it landed in the debug diagnostic and stopped there. +`src/adapters/cursor.ts` drops a dead checkpoint by reading `request.checkpointInvalidationReason`, so an +exhausted checkpoint was re-decoded and re-abandoned every turn until its TTL. It is written back onto the +request now, which is what `request-builder.ts` already does for every other reason. + +### Verification of this round + +- `bun test` across `cursor-blob`, `cursor-tool-result-invocation`, `cursor-tool-continuation` and + `cursor-request-builder`: 187 pass / 0 fail before the three new assertions, 102 / 0 in `cursor-blob` + after. +- 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index c23feefa23..fd4d900ad9 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1334,12 +1334,35 @@ function buildPreparedCursorRunRequest( // 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. - const keptEnough = suffixRoots.historyMessageIndexes.includes(lastSuffixIndex) - && !suffixRoots.historyOutputElided.includes(lastSuffixIndex); - const suffixKeptItsResult = suffixMessages[lastSuffixIndex]?.role !== "toolResult" || keptEnough; + // + // 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). + let trailingStart = suffixMessages.length; + while (trailingStart > 0 && suffixMessages[trailingStart - 1]?.role === "toolResult") trailingStart -= 1; + const keptEnough = suffixMessages + .slice(trailingStart) + .every((_, offset) => { + const index = trailingStart + offset; + return 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 || suffixRoots.ids.length <= suffixSystemCount @@ -1348,6 +1371,11 @@ function buildPreparedCursorRunRequest( conversationState = undefined; continuationMode = "full-replay"; checkpointInvalidationReason = "envelope_exhausted"; + // Write it back onto the request, not just the local: `src/adapters/cursor.ts` reads + // `request.checkpointInvalidationReason` to drop the dead checkpoint from the store. A local-only + // assignment reached the diagnostic and nothing else, so the exhausted checkpoint was re-decoded + // and re-abandoned every turn until its TTL (audit r8 round 3). + request.checkpointInvalidationReason = "envelope_exhausted"; } else { const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart); const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index c650ad0a2d..fc80ebe28a 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2491,6 +2491,146 @@ describe("Cursor external replay envelope", () => { 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", () => { + for (const modelId of ["cursor/auto", "cursor/composer-2.5-fast", "cursor/composer-3"]) { + 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}`, + system: ["You are helpful."], + messages: [{ role: "tool", content: "FILE-CONTENTS" }], + rawMessages: [ + { role: "user", content: "Read the file.", timestamp: 1 }, + { + role: "assistant", + content: [ + { type: "text", text: "Reading." }, + { type: "toolCall", id: "n1", name: "read_file", arguments: { path: "a.txt" } }, + ], + 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. + */ + /** + * Audit r8 round 3, MINOR. `envelope_exhausted` was assigned to a local only, so it reached the debug + * diagnostic and nothing else. `src/adapters/cursor.ts` drops a dead checkpoint by reading + * `request.checkpointInvalidationReason`, so the exhausted one was re-decoded and re-abandoned on every + * later turn until its TTL expired. Writing it back is the same thing `request-builder.ts` does for every + * other invalidation reason. + */ + test("an exhausted checkpoint reports itself as invalidated to the caller", () => { + 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, + }; + prepareCursorRunRequest(request); + expect(request.checkpointInvalidationReason).toBe("envelope_exhausted"); + }); + + 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"; From 5b26daae8ff8f550edb2f887143bb6883a9d4020 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 05:35:01 +0900 Subject: [PATCH 05/13] fix(cursor): record the unpropagated invalidation reason instead of faking it Round 3 asked for envelope_exhausted to reach the checkpoint store. The obvious fix -- write the field back onto the request argument, which is what request-builder.ts does -- was implemented in the previous commit and is inert. live-transport.ts prepares a SPREAD COPY of the request, so the write lands on the copy and the outer object src/adapters/cursor.ts reads stays undefined. Measured directly: copy sees envelope_exhausted, outer sees undefined. So the write is removed and the limitation is documented at the site instead. Reaching the store needs the reason threaded 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 stated: the checkpoint is re-decoded and re-abandoned each turn until TTL, which is wasted work rather than wrong output. The accompanying test asserted request.checkpointInvalidationReason, which would have passed on the argument while proving nothing about the real path -- the same vacuous coverage round 2 caught. It now asserts what is actually observable: an exhausted checkpoint still assembles a legal full-replay request that carries its own history. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 17 +++++++++---- src/adapters/cursor/protobuf-request.ts | 14 +++++++---- tests/cursor-blob.test.ts | 24 ++++++++++++------- 3 files changed, 38 insertions(+), 17 deletions(-) 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 index 2ee308a3d5..622bada9b3 100644 --- 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 @@ -259,12 +259,21 @@ code's own comment calls worse than keeping nothing. `historyOutputElided` alrea 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 reached nothing +### The invalidation reason still reaches nothing, and that is now a recorded decision -`envelope_exhausted` was assigned to a local, so it landed in the debug diagnostic and stopped there. +`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 was re-decoded and re-abandoned every turn until its TTL. It is written back onto the -request now, which is what `request-builder.ts` already does for every other reason. +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 diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index fd4d900ad9..e5bdf0898c 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1371,11 +1371,15 @@ function buildPreparedCursorRunRequest( conversationState = undefined; continuationMode = "full-replay"; checkpointInvalidationReason = "envelope_exhausted"; - // Write it back onto the request, not just the local: `src/adapters/cursor.ts` reads - // `request.checkpointInvalidationReason` to drop the dead checkpoint from the store. A local-only - // assignment reached the diagnostic and nothing else, so the exhausted checkpoint was re-decoded - // and re-abandoned every turn until its TTL (audit r8 round 3). - request.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); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index fc80ebe28a..1848b00182 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2551,13 +2551,15 @@ describe("Cursor external replay envelope", () => { * index was consulted. The whole trailing run is checked now. */ /** - * Audit r8 round 3, MINOR. `envelope_exhausted` was assigned to a local only, so it reached the debug - * diagnostic and nothing else. `src/adapters/cursor.ts` drops a dead checkpoint by reading - * `request.checkpointInvalidationReason`, so the exhausted one was re-decoded and re-abandoned on every - * later turn until its TTL expired. Writing it back is the same thing `request-builder.ts` does for every - * other invalidation reason. + * 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 reports itself as invalidated to the caller", () => { + test("an exhausted checkpoint still assembles a legal full-replay request", () => { const request = { modelId: "grok-4.6", conversationId: "cursor_ckpt_exhausted_reason", @@ -2582,8 +2584,14 @@ describe("Cursor external replay envelope", () => { continuationMode: "checkpoint" as const, checkpointSuffixStart: 1, }; - prepareCursorRunRequest(request); - expect(request.checkpointInvalidationReason).toBe("envelope_exhausted"); + 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", () => { From bde5b19ddfc5c5239c525885dad85aa53bae3724 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 05:58:42 +0900 Subject: [PATCH 06/13] fix(cursor): gate both result-survival disjuncts, not just the last one Round 3 gated the result-survival check on cursorNeedsExternalToolContinuation. The abandon condition is a three-way disjunction and only the last term was gated. The middle one -- the suffix produced no history roots at all -- asks the same question, whether a replayed root went missing, so it is 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, checkpoint discarded. Measured on that 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 and the same loss as round 3's blocker, one disjunct over. The count-full term stays ungated because it is a real envelope fact independent of who echoes results. The test that let this through was round 3's own: it asserted the native path with narration, so the narration-free shape of that path stayed invisible. It is a cross product now -- four model ids by four assistant shapes (narrated, silent, empty text, whitespace text) -- because that is the axis these bugs keep hiding along. Restoring the ungated disjunct reddens it. Also corrects two counts in the devlog: the four-suite total is 188, not 187, and the three-suite figure is 138 at head rather than the 133 true when written. Both were flagged by the audit; neither matched any commit in the stack. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 32 +++++++++++++++++-- src/adapters/cursor/protobuf-request.ts | 7 +++- tests/cursor-blob.test.ts | 26 +++++++++------ 3 files changed, 51 insertions(+), 14 deletions(-) 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 index 622bada9b3..cd026254dc 100644 --- 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 @@ -212,7 +212,8 @@ 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` — 133 pass / 0 fail. + 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; @@ -278,8 +279,8 @@ stating: wasted work each turn, not wrong output — the request assembled is co ### Verification of this round - `bun test` across `cursor-blob`, `cursor-tool-result-invocation`, `cursor-tool-continuation` and - `cursor-request-builder`: 187 pass / 0 fail before the three new assertions, 102 / 0 in `cursor-blob` - after. + `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 @@ -287,3 +288,28 @@ stating: wasted work each turn, not wrong output — the request assembled is co - 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index e5bdf0898c..88eb33615d 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1365,7 +1365,12 @@ function buildPreparedCursorRunRequest( || keptEnough; if ( carriedRoots.count + suffixSystemCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - || suffixRoots.ids.length <= suffixSystemCount + // 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; diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 1848b00182..9b3f09e0c0 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -41,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"; @@ -2502,7 +2503,18 @@ describe("Cursor external replay envelope", () => { * 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", () => { - for (const modelId of ["cursor/auto", "cursor/composer-2.5-fast", "cursor/composer-3"]) { + // 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" }], @@ -2514,19 +2526,12 @@ describe("Cursor external replay envelope", () => { }); const prepared = prepareCursorRunRequest({ modelId, - conversationId: `cursor_native_ckpt_${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: [ - { type: "text", text: "Reading." }, - { type: "toolCall", id: "n1", name: "read_file", arguments: { path: "a.txt" } }, - ], - timestamp: 2, - }, + { 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), @@ -2542,6 +2547,7 @@ describe("Cursor external replay envelope", () => { const roots = state?.rootPromptMessagesJson ?? []; expect(roots.some(id => Array.from(id).join(",") === Array.from(carriedRoot).join(","))).toBe(true); } + } }); /** From 011a990ba5f139a79554349d577cbb3738248013 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 06:37:53 +0900 Subject: [PATCH 07/13] fix(cursor): bound the trailing result run by count, not only by bytes historyLimit already subtracted the roots a checkpoint carries, but it was consulted only by the prior-history admission loop. The trailing tool-result block was assembled under byte pressure alone and concatenated with no count check, so for the ordinary checkpoint-continuation shape - empty keptPrior - the payload size was bounded by nothing. truncateToolResultBlob cannot help: it frees bytes, never a root slot. The abandon condition was meant to catch the overflow and tested carried plus system count, which asks whether there is room for ONE more root. A parallel tool-call batch needs active.length of them: 190 carried roots plus a 3-result batch assembled 193 and threw a non-retryable 400, 188 plus 8 threw 196. Reachable by ordinary growth - replaying each turn's state as the next checkpoint, 3 calls per turn died at turn 48 and 5 at turn 32; all shapes now survive 200 turns. Bound active where it is assembled instead of adding a disjunct that must predict the suffix width. Oldest results drop first, matching byte pruning, and one always survives so the abandon check can see the loss through historyMessageIndexes and fall back to a coherent full replay. Sequential fixtures hid this: their trailing run is always length 1, the one width where the old test was exactly right. The parallel sweep used the byte axis, where abandonment fires first. All 188 tests passed with and without the fix; the three new count-by-parallel rows are the first to redden. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 59 +++++++++++++++ src/adapters/cursor/protobuf-request.ts | 16 ++++ tests/cursor-blob.test.ts | 73 +++++++++++++++++++ 3 files changed, 148 insertions(+) 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 index cd026254dc..ee21d6017b 100644 --- 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 @@ -313,3 +313,62 @@ 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 88eb33615d..083883cdcf 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -435,6 +435,22 @@ 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 > historyLimit) { + const dropped = active.shift(); + activeBytes -= dropped?.byteLength ?? 0; + } const prior = history.slice(0, activeStart); const keptPrior: RootBlobCandidate[] = []; diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 9b3f09e0c0..c0ba5c75b2 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2443,6 +2443,79 @@ describe("Cursor external replay envelope", () => { 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 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 From e530f33b9a3bd1e7971d0b0a080c63f8e24a38cb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 07:29:24 +0900 Subject: [PATCH 08/13] fix(cursor): make the count drop visible to the check that guards it The count bound added for the previous round acts on root entries; the abandon check derived its trailing tool-result run from raw messages. Those spaces diverge on the most ordinary assistant shape there is: a bare tool call with no narration emits no root, so two sequentially-executed results become adjacent roots while raw space still separates them. Both results therefore entered the root-space run, the bound dropped the older one, and the raw-space scan saw a run of length one - the newest result, which survived - and reported "kept". The checkpoint was retained and the request went out with a tool call answered by nothing: measured at 190 carried roots, the first answer was in no root and in no turn, with no throw and no diagnostic. The model's only sensible response is to re-issue the call, which is the loop this unit exists to end. rootPromptMessages now reports 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. It falls back to the raw scan when the field is empty, so full-replay and native shapes keep their behaviour. The drop was also unnecessary. historyLimit subtracted systemEntryCount on a path where the caller appends only ids.slice(suffixSystemCount) and the checkpoint's own system roots already sit inside carriedRoots.count, so one free slot was charged twice: the limit came out 1 where 2 results fit. Mutation evidence: raw-space check 2 red, double charge 1 red, the previous bound removed 5 red. The first silent-loss test passed with the double charge still present, because that defect abandons the checkpoint and a full replay carries every answer - correct output reached wastefully. Pinning it needed a second case asserting the exact root count at exact fit. Also covers outputElided on the marker-only return, which had none: removing the flag left all 191 tests green. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 49 +++++ src/adapters/cursor/protobuf-request.ts | 57 +++++- tests/cursor-blob.test.ts | 193 ++++++++++++++++++ 3 files changed, 290 insertions(+), 9 deletions(-) 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 index ee21d6017b..d9201cabe8 100644 --- 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 @@ -372,3 +372,52 @@ 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 083883cdcf..fe933a6594 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -263,6 +263,14 @@ function rootPromptMessages( * 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; @@ -275,6 +283,7 @@ function rootPromptMessages( serialized: entries.map(entry => entry.serialized), historyMessageIndexes: [], historyOutputElided: [], + activeMessageIndexes: [], }; } @@ -381,6 +390,9 @@ 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. @@ -391,8 +403,15 @@ function rootPromptMessages( 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 - carriedRoots.count); - const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes - carriedRoots.byteLength); + // 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 chargeableSystemBytes = suffixContinuesCoveredTurn ? 0 : systemBytes; + const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - chargeableSystemCount - carriedRoots.count); + const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - chargeableSystemBytes - 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 @@ -403,6 +422,13 @@ function rootPromptMessages( .slice(activeStart) .map(entry => truncateToolResultBlob(entry, historyBudget)) .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) + .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 @@ -589,6 +615,7 @@ function rootPromptMessages( .filter(entry => entry.outputElided === true) .map(entry => entry.messageIndex) .filter((index): index is number => index !== undefined), + activeMessageIndexes, }; } @@ -1367,15 +1394,26 @@ function buildPreparedCursorRunRequest( // 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 keptEnough = suffixMessages - .slice(trailingStart) - .every((_, offset) => { - const index = trailingStart + offset; - return suffixRoots.historyMessageIndexes.includes(index) - && !suffixRoots.historyOutputElided.includes(index); - }); + 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; @@ -1423,6 +1461,7 @@ function buildPreparedCursorRunRequest( serialized: suffixHistorySerialized, historyMessageIndexes: suffixRoots.historyMessageIndexes, historyOutputElided: suffixRoots.historyOutputElided, + activeMessageIndexes: suffixRoots.activeMessageIndexes, }; } } diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index c0ba5c75b2..8b625055f5 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2516,6 +2516,199 @@ describe("Cursor external replay envelope", () => { 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 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 From 95b79ba25153d67fb84b96667a21af4c0fca7efd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 08:17:00 +0900 Subject: [PATCH 09/13] fix(cursor): keep the repetition note from hiding the trailing results The trailing-result walk tested only for a toolResult role and started at the very end of history. The repetition breaker appends a synthetic [context note] user root after the transcript when the same output repeats three or more times, and that note stands for no message, so it carries no messageIndex. The walk hit it and stopped: activeStart came out equal to history.length, the trailing run was empty, activeMessageIndexes was empty. Two failures followed, both worse than the one the previous commit fixed. The results lost trailing-run status entirely, falling through to prior history where the keep-at-least-one floor does not apply. And the empty field sent the abandon check into its raw-space fallback, the scan the previous commit exists to avoid: at 186 carried roots the note-armed shape was retained where the identical shape without the note correctly abandoned. The trigger is the worst one available. The note arms on three consecutive identical assistant narrations, which is the runaway-repetition shape this unit exists to end. The walk now skips trailing roots with no messageIndex before looking for the result run, and those roots are re-appended afterwards so the note still reaches the model. A root added after pruning must be paid for during pruning: left uncharged, note-armed continuations at 188-190 carried roots threw the non-retryable 400 for sequential and parallel suffixes alike, so syntheticCount and syntheticBytes are charged in the count bound, the prior-history loop and the byte accounting, and the orphan-strip floor counts them so the strip cannot eat into the run. Also drops chargeableSystemBytes. Review found it had no coverage, and no configuration could be found where relaxing the byte budget changes the payload - six crossings in the deciding band were byte-identical either way. Charging system bytes twice only errs conservative. The count relaxation stays; its case reddens without it. Mutation evidence: messageIndex walk 1 red, syntheticCount in the count bound 1 red, note dropped from the payload 1 red, syntheticCount in the prior loop 2 red. The two charges had no failing test at first, which is the same condition the previous round was caught on. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 64 +++++++ src/adapters/cursor/protobuf-request.ts | 63 +++++-- tests/cursor-blob.test.ts | 162 ++++++++++++++++++ 3 files changed, 273 insertions(+), 16 deletions(-) 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 index d9201cabe8..abafbd5d5f 100644 --- 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 @@ -421,3 +421,67 @@ flag left all 191 tests green, and `tests/` is outside `tsconfig`'s `include`, s 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index fe933a6594..5e25253f38 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -409,27 +409,50 @@ function rootPromptMessages( // 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 chargeableSystemBytes = suffixContinuesCoveredTurn ? 0 : systemBytes; const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - chargeableSystemCount - carriedRoots.count); - const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - chargeableSystemBytes - carriedRoots.byteLength); + // 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; const active = history - .slice(activeStart) + .slice(activeStart, activeEnd) .map(entry => truncateToolResultBlob(entry, historyBudget)) .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) + .slice(activeStart, activeEnd) .map(entry => entry.messageIndex) .filter((index): index is number => index !== undefined); - let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0); + // Synthetic trailing roots are re-appended after pruning, so they must be PAID FOR here or the + // envelope is overrun by exactly their number: they are counted with the trailing run rather than + // added afterwards, which is the mistake the count budget already made once (audit r11). + const syntheticCount = history.length - activeEnd; + const syntheticBytes = history.slice(activeEnd).reduce((sum, entry) => sum + entry.byteLength, 0); + let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0) + syntheticBytes; // 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 @@ -443,7 +466,7 @@ function rootPromptMessages( const shrunk = truncateToolResultBlob(entry, share); if (shrunk) active[index] = shrunk; } - activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0); + activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0) + syntheticBytes; } // 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. @@ -452,13 +475,13 @@ function rootPromptMessages( activeBytes -= dropped?.byteLength ?? 0; } if (active.length === 1 && active[0] && activeBytes > historyBudget) { - const truncated = truncateToolResultBlob(active[0], historyBudget); + const truncated = truncateToolResultBlob(active[0], Math.max(0, historyBudget - syntheticBytes)); if (truncated) { active[0] = truncated; - activeBytes = truncated.byteLength; + activeBytes = truncated.byteLength + syntheticBytes; } else { active.length = 0; - activeBytes = 0; + activeBytes = syntheticBytes; } } // COUNT-bound the trailing run, not only its bytes. `historyLimit` already subtracts what the @@ -473,7 +496,7 @@ function rootPromptMessages( // 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 > historyLimit) { + while (active.length > 1 && active.length + syntheticCount > historyLimit) { const dropped = active.shift(); activeBytes -= dropped?.byteLength ?? 0; } @@ -490,7 +513,9 @@ function rootPromptMessages( // 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) { + // `syntheticCount` is charged here too: those roots are re-appended after this loop, so admitting + // prior history against a limit that ignores them overruns the envelope by their number. + while (i >= 0 && keptPrior.length + active.length + syntheticCount < historyLimit) { let turnStart = i; if (!suffixContinuesCoveredTurn) { // Root-blob roles are a closed set of four (system, user, assistant, toolResult): a @@ -501,7 +526,7 @@ function rootPromptMessages( 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 + keptPrior.length + active.length + syntheticCount + turn.length > historyLimit || priorBytes + activeBytes + turnBytes > historyBudget ) { break; @@ -511,7 +536,12 @@ function rootPromptMessages( i = turnStart - 1; } - const historyEntries = [...keptPrior, ...active]; + // The synthetic trailing roots excluded from the run above — today only the repetition-breaker note — + // are re-appended so telling the model to change strategy is not silently dropped by the very walk + // that stopped ignoring it. They are budgeted with `active`, which is why they sit inside the count + // bound rather than after it. + const trailingSynthetic = history.slice(activeEnd); + const historyEntries = [...keptPrior, ...active, ...trailingSynthetic]; // Guard against orphan assistant / toolResult at the start of the retained suffix. // // Premised on `history` starting where the CONVERSATION starts: only then does a leading @@ -524,8 +554,9 @@ function rootPromptMessages( // (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; + // Never drop the sole active tool-result block. The floor counts the re-appended synthetic roots + // too, or the strip eats into the trailing run once a repetition note is present. + if (historyEntries.length <= active.length + trailingSynthetic.length) break; historyEntries.shift(); } } diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 8b625055f5..acb682d42d 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2653,6 +2653,168 @@ describe("Cursor external replay envelope", () => { 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 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 From af0954bb6055392d1c4a89db5230c39a51e76530 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 09:00:03 +0900 Subject: [PATCH 10/13] fix(cursor): keep the repetition note out of the pruning decision The previous commit re-appended the note into historyEntries before the pruning blocks ran, so each of them had to recognise a tail it could only identify by position. The initiator-recovery block could not: its floor stops when one entry remains, so with [toolResult, note] it counted the note as the survivor and shifted off the result. With one 600 KB result and three identical narrations instead of two, the model received 193 bytes of "take a DIFFERENT action" and no tool output at all. The result had already been truncated to fit; it was deleted anyway. That is the reported symptom exactly - no 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 whole budget and adding the note back always exceeded it. The shrink-instead-of-drop pass became structurally unfittable and fell through to deleting a whole result: 246 bytes of note cost a 200 KB answer. Review measured 166 of 432 byte-pressure configurations losing an answer. Rather than add the tail's length to each floor - which works and leaves the next block to find the same trap - the tail is held out of historyEntries until assembly, and every budget is expressed net of it. historyLimitForReal and historyBudgetForReal are computed once, before the first result is measured, so the pruning blocks reason only about real history. An intermediate version that held the tail out without reserving its bytes committed 51 bytes over the limit, which is why the reservation is separate from the hold-out. Also covers the byte reservation, which review found had no test at all while a sweep against its removal threw 148 envelope errors. Mutation evidence: byte reservation 2 red, count reservation 3 red, note dropped 4 red, messageIndex walk 3 red, and the full r12 defect - re-append plus gross budget - 2 red. Re-appending alone is now harmless because the reservation prevents the loss by itself. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 69 ++++++++++++++ src/adapters/cursor/protobuf-request.ts | 81 +++++++++-------- tests/cursor-blob.test.ts | 90 +++++++++++++++++++ 3 files changed, 205 insertions(+), 35 deletions(-) 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 index abafbd5d5f..24fc04f99f 100644 --- 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 @@ -485,3 +485,72 @@ Four-suite total is 198 pass / 0 fail; `cursor-blob` alone 115. Sweeps re-run cl 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 5e25253f38..aeab508d2f 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -436,9 +436,18 @@ function rootPromptMessages( 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). + const syntheticCount = history.length - activeEnd; + const syntheticBytes = history.slice(activeEnd).reduce((sum, entry) => sum + entry.byteLength, 0); + const historyLimitForReal = Math.max(0, historyLimit - syntheticCount); + const historyBudgetForReal = Math.max(0, historyBudget - syntheticBytes); const active = history .slice(activeStart, activeEnd) - .map(entry => truncateToolResultBlob(entry, historyBudget)) + .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 @@ -447,41 +456,36 @@ function rootPromptMessages( .slice(activeStart, activeEnd) .map(entry => entry.messageIndex) .filter((index): index is number => index !== undefined); - // Synthetic trailing roots are re-appended after pruning, so they must be PAID FOR here or the - // envelope is overrun by exactly their number: they are counted with the trailing run rather than - // added afterwards, which is the mistake the count budget already made once (audit r11). - const syntheticCount = history.length - activeEnd; - const syntheticBytes = history.slice(activeEnd).reduce((sum, entry) => sum + entry.byteLength, 0); - let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0) + syntheticBytes; + 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; const shrunk = truncateToolResultBlob(entry, share); if (shrunk) active[index] = shrunk; } - activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0) + syntheticBytes; + activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0); } // 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], Math.max(0, historyBudget - syntheticBytes)); + const truncated = truncateToolResultBlob(active[0], historyBudgetForReal); if (truncated) { active[0] = truncated; - activeBytes = truncated.byteLength + syntheticBytes; + activeBytes = truncated.byteLength; } else { active.length = 0; - activeBytes = syntheticBytes; + activeBytes = 0; } } // COUNT-bound the trailing run, not only its bytes. `historyLimit` already subtracts what the @@ -496,7 +500,7 @@ function rootPromptMessages( // 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 + syntheticCount > historyLimit) { + while (active.length > 1 && active.length > historyLimitForReal) { const dropped = active.shift(); activeBytes -= dropped?.byteLength ?? 0; } @@ -513,9 +517,7 @@ function rootPromptMessages( // 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; - // `syntheticCount` is charged here too: those roots are re-appended after this loop, so admitting - // prior history against a limit that ignores them overruns the envelope by their number. - while (i >= 0 && keptPrior.length + active.length + syntheticCount < historyLimit) { + while (i >= 0 && keptPrior.length + active.length < historyLimitForReal) { let turnStart = i; if (!suffixContinuesCoveredTurn) { // Root-blob roles are a closed set of four (system, user, assistant, toolResult): a @@ -526,8 +528,8 @@ function rootPromptMessages( const turn = prior.slice(turnStart, i + 1); const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0); if ( - keptPrior.length + active.length + syntheticCount + turn.length > historyLimit - || priorBytes + activeBytes + turnBytes > historyBudget + keptPrior.length + active.length + turn.length > historyLimitForReal + || priorBytes + activeBytes + turnBytes > historyBudgetForReal ) { break; } @@ -536,12 +538,19 @@ function rootPromptMessages( i = turnStart - 1; } - // The synthetic trailing roots excluded from the run above — today only the repetition-breaker note — - // are re-appended so telling the model to change strategy is not silently dropped by the very walk - // that stopped ignoring it. They are budgeted with `active`, which is why they sit inside the count - // bound rather than after it. const trailingSynthetic = history.slice(activeEnd); - const historyEntries = [...keptPrior, ...active, ...trailingSynthetic]; + // 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. // // Premised on `history` starting where the CONVERSATION starts: only then does a leading @@ -554,9 +563,8 @@ function rootPromptMessages( // (devlog 260829 070). if (!suffixContinuesCoveredTurn) { while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") { - // Never drop the sole active tool-result block. The floor counts the re-appended synthetic roots - // too, or the strip eats into the trailing run once a repetition note is present. - if (historyEntries.length <= active.length + trailingSynthetic.length) break; + // Never drop the sole active tool-result block. + if (historyEntries.length <= active.length) break; historyEntries.shift(); } } @@ -588,7 +596,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 { @@ -603,14 +611,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; @@ -620,14 +628,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); } diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index acb682d42d..746723dc01 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2815,6 +2815,96 @@ describe("Cursor external replay envelope", () => { 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 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 From 0086b99b4085fa9e6823274195c6149d5513a647 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 09:54:11 +0900 Subject: [PATCH 11/13] fix(cursor): drop the repetition note when the envelope cannot pay for it The reservation was a subtraction clamped at zero while the append was unconditional, and those are compatible only while the difference is non-negative. Below that the clamp reports that the note costs nothing, every pruning block correctly reasons about a budget of zero and emits nothing, and the note is appended regardless - 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, non-retryable 400. Holding the tail out of historyEntries is what made it unrecoverable, since no block below could see it to charge it. Every fixture missed this because the exposed shape is a turn that does not end in a tool result. With a trailing result the abandon check's survival disjuncts rescue the turn; on a plain user interjection they structurally cannot. Across 42 carried-byte positions: 13 throws with the note armed, none without, all on the interjection tail. Affordability is now decided before the reservation, and an unaffordable note is dropped - this unit's own priority order, since a missing instruction is recoverable and a missing tool result restarts the loop. The first version of that 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 either way, because the count bound already stops at one surviving result. Removed rather than shipped, for the same reason chargeableSystemBytes was - an envelope condition that cannot fail is indistinguishable from one that is wrong. Also corrects one activeBytes gate that read the gross budget while its body wrote the net one. No behavioural difference, but it is the drift that seeded two earlier rounds. Mutation evidence: affordability removed 3 red, tail appended regardless 3 red. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 47 +++++++++++++ src/adapters/cursor/protobuf-request.ts | 30 ++++++-- tests/cursor-blob.test.ts | 69 +++++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) 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 index 24fc04f99f..6f5fa50dfd 100644 --- 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 @@ -554,3 +554,50 @@ created by the previous fix. The through-line is not carelessness about the cond 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index aeab508d2f..4006e8e1d4 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -441,8 +441,30 @@ function rootPromptMessages( // 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). - const syntheticCount = history.length - activeEnd; - const syntheticBytes = history.slice(activeEnd).reduce((sum, entry) => sum + entry.byteLength, 0); + // 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); + // Bytes only. A count deficit cannot arise here: the count bound below already stops at one surviving + // result, so a slot for the tail is free whenever `historyLimit` is at least 1, and at 0 there is no + // history to append it to. Adding `historyLimit - syntheticCountRaw >= 1` made no difference across 60 + // boundary positions at and past the root limit, and an inert condition on the envelope path is the + // same liability this unit removed once already (audit r13). + const syntheticAffordable = historyBudget - syntheticBytesRaw >= 0; + 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 @@ -478,7 +500,7 @@ function rootPromptMessages( const dropped = active.shift(); activeBytes -= dropped?.byteLength ?? 0; } - if (active.length === 1 && active[0] && activeBytes > historyBudget) { + if (active.length === 1 && active[0] && activeBytes > historyBudgetForReal) { const truncated = truncateToolResultBlob(active[0], historyBudgetForReal); if (truncated) { active[0] = truncated; @@ -538,7 +560,7 @@ function rootPromptMessages( i = turnStart - 1; } - const trailingSynthetic = history.slice(activeEnd); + 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. // diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 746723dc01..6666c11e21 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2905,6 +2905,75 @@ describe("Cursor external replay envelope", () => { 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 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 From 34e14f17b3ec5e89c05c5f56a70bd8fe2502252e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 10:33:51 +0900 Subject: [PATCH 12/13] fix(cursor): restore the count half of the note affordability test The previous commit dropped it as inert, reasoning that the count bound below always leaves a slot free because it keeps one result. That holds 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 envelope 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 to rescue it. The sweep that supported "inert" varied carried roots on the checkpoint path, where the count-full disjunct abandons long before historyLimit can reach 1. The reachable route is full replay with many system prompts - a different axis. Inert across 60 positions was a true statement about the wrong sixty. Also closes a coverage gap review found in the same area: the affordability check was pinned at the append site only. Neutering syntheticCount and syntheticBytes while leaving the append gated left the suite green, because asserting on the assembled payload cannot separate "the deficit was charged" from "the tail was not appended". Asserting the exact root count at the boundary separates them. Mutation evidence: count conjunct 4 red, byte conjunct 3 red, reservation neutered 6 red, append ungated 7 red. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 45 ++++++++++++++ src/adapters/cursor/protobuf-request.ts | 18 ++++-- tests/cursor-blob.test.ts | 60 +++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) 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 index 6f5fa50dfd..98bfaef187 100644 --- 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 @@ -601,3 +601,48 @@ Four-suite total is 208 pass / 0 fail; `cursor-blob` alone 122. Every sweep re-r 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. diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 4006e8e1d4..494e4e48ca 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -457,12 +457,18 @@ function rootPromptMessages( const syntheticEntries = history.slice(activeEnd); const syntheticCountRaw = syntheticEntries.length; const syntheticBytesRaw = syntheticEntries.reduce((sum, entry) => sum + entry.byteLength, 0); - // Bytes only. A count deficit cannot arise here: the count bound below already stops at one surviving - // result, so a slot for the tail is free whenever `historyLimit` is at least 1, and at 0 there is no - // history to append it to. Adding `historyLimit - syntheticCountRaw >= 1` made no difference across 60 - // boundary positions at and past the root limit, and an inert condition on the envelope path is the - // same liability this unit removed once already (audit r13). - const syntheticAffordable = historyBudget - syntheticBytesRaw >= 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); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 6666c11e21..121b618205 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -2974,6 +2974,66 @@ describe("Cursor external replay envelope", () => { 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 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 From 0340d17599b65dda8b739a30107f59297e0d145b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 30 Aug 2026 11:15:27 +0900 Subject: [PATCH 13/13] test(cursor): pin the note affordability threshold from the tight side Review found the bound was only constrained from the loose direction: relaxing historyLimit - syntheticCountRaw >= 1 reddens four cases, but tightening it to >= 2 left all 212 green. Over-conservative is safer than over-eager, but a suite that cannot tell a correct bound from an unnecessarily strict one is the gap that cost the previous round. Two free slots is the tight case - the result takes one, the note takes the other - and the new case asserts both arrive at exactly 192 roots. Relaxing the bound now reddens 4, tightening it reddens 1. Also corrects the record. The previous commit message claimed the reservation had been pinned at the append site only, and that neutering syntheticCount and syntheticBytes left the suite green. On the parent that mutation already reddens 6, all pre-existing cases from earlier rounds. The count-conjunct finding stands on its own evidence; that secondary claim did not. The devlog records the remaining known gap: on the extreme byte axis a ~523 KB system prompt can keep the note while the result truncates to a marker, inverting this unit's priority order. Identical on the parent and far worse on dev, so pre-existing and improved here, but a genuine follow-up. --- ...0_phase8_checkpoint_suffix_orphan_strip.md | 40 +++++++++++++ tests/cursor-blob.test.ts | 57 +++++++++++++++++++ 2 files changed, 97 insertions(+) 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 index 98bfaef187..cd47672280 100644 --- 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 @@ -646,3 +646,43 @@ it absent. Rounds 5 through 10 were each triggered by the previous round's own f 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/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 121b618205..3fde1260ce 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -3034,6 +3034,63 @@ describe("Cursor external replay envelope", () => { 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