diff --git a/devlog/_plan/260829_bugpr_lane_h_residual_issues/120_issue_1527_replay_envelope.md b/devlog/_plan/260829_bugpr_lane_h_residual_issues/120_issue_1527_replay_envelope.md new file mode 100644 index 0000000000..b7cb3487d1 --- /dev/null +++ b/devlog/_plan/260829_bugpr_lane_h_residual_issues/120_issue_1527_replay_envelope.md @@ -0,0 +1,276 @@ +# 120 — #1527: enforce the replay envelope on the final root set + +Revised after the plan audit returned FAIL. Blockers 8 through 13 applied. The +first draft would have broken legitimate checkpoint continuation and measured the +envelope in the one place where the true root set is not yet known. + +## Scope + +IN: `src/adapters/cursor/protobuf-request.ts` (`rootPromptMessages`, +`buildPreparedCursorRunRequest`), `src/adapters/cursor/cursor-errors.ts`, +`src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/native-exec.ts` (a +read-only blob-size accessor), `src/adapters/cursor.ts` (terminal catch), +`tests/cursor-blob.test.ts`, `tests/cursor-transport-retry.test.ts`, +`tests/cursor-adapter.test.ts`. + +OUT: native Composer and Auto replay behavior, the #2277 checkpoint continuation +design, and the transport retry policy itself. + +One PR: final-envelope construction, its typed failure mapping, and the +regressions. The audit found no seam that splits cleanly, because the guard and the +measurement it depends on are the same change. + +## Defect 1 — the envelope is measured on the wrong set + +`CURSOR_EXTERNAL_ROOT_BLOB_LIMIT` (192) and `CURSOR_EXTERNAL_ROOT_BYTE_LIMIT` +(512 KiB) live at `src/adapters/cursor/protobuf-request.ts:71-73` and are checked +against `keptPrior` only. System roots are all retained before the history budget is +computed (`:309-380`), and trailing active tool results are byte-pruned but never +count-pruned. + +Reproduced in isolated probes: 193 roots from 193 system prompts; 614,430 root bytes +from a single 600 KiB system prompt; 194 roots from one system plus 193 trailing +results. Each reported `continuationMode: "full-replay"`. + +Two further escapes the first draft missed: + +- **Cumulative checkpoint roots (blocker 9).** Suffix roots are appended AFTER + existing checkpoint roots (`:937-947`) but `rootPromptMessages` sees only the + suffix. A probe with 192 checkpoint roots plus a two-root suffix emitted 194. +- **The empty-history early return (blocker 10).** `rootPromptMessages` returns + before external enforcement when `rawMessages` is empty (`:198-215`). A guard + placed only in the external-history branch leaves this path unbounded; a probe + emitted 193 system roots through it. + +Both mean the guard cannot live inside `rootPromptMessages`. The final root set +exists only after checkpoint or full-replay assembly completes (`:959-974`), and +that is where enforcement belongs. + +## Defect 2 — a tool result can be sent without the request that caused it + +External replay omits assistant tool-call roots by design (`:288`). The pruner puts +every trailing tool result into `active` (`:321-326`), so one large result can +consume the whole history budget and drop the preceding user turn (`:348-357`). The +orphan guard preserves that lone result and sets `historyMessageStart` to it +(`:365-369`); `conversationTurns()` then finds no current user turn and drops it from +`turns[]` too (`:719`, `:763`). + +The emitted request is system roots, an assistant-role tool-result marker, zero turn +blobs, and the generic Continue action. The audit's one correction to the framing: +it is not literally instruction-free — system instructions and the Continue action +are present. What is missing is the initiating user instruction, which is what makes +a large-context turn answerable. A model given that answers in a handful of tokens, +matching the report (200 OK, 4-19 output tokens at 80-95k input), reproduced with no +Cursor account. + +`tests/cursor-blob.test.ts:474` asserts the oversized result and its truncation +marker survive. It never asserts the initiating turn survives, which is why CI stayed +green over a request shape that cannot work. + +## Blocker 8 — why the obvious rule was wrong + +The first draft said: never emit a result-only continuation. That is correct for full +replay and WRONG for checkpoint continuation. Production sets `checkpointSuffixStart` +to the covered message count (`src/adapters/cursor/request-builder.ts:479-488`), so a +valid suffix may legitimately begin with a tool result — the initiating turn is +already inside the checkpoint. `rootPromptMessages` receives only the sliced suffix +(`:915-933`) and cannot recover it, so a blanket rule would reject or corrupt correct +continuation. + +The distinction must be an explicit argument, not inferred from message shape. Pass a +replay origin: `"checkpoint-covered"` only from the branch where checkpoint decoding +succeeded (`conversationState` established at `:917-918`) and `checkpointSuffixStart` +passed validation (`:919-926`); `"full-replay"` from every fallback path (`:959-960`). +Do not derive it from the advisory `request.continuationMode`. + +## Change map + +### `rootPromptMessages` — replay origin + +Accept an explicit origin (`:198-204`). + +Full replay retains the initiating user or developer root together with every +contiguous trailing tool-result root; if that group cannot fit, fail rather than emit +a result-only replay. Checkpoint-covered replay may begin with tool results. + +### `rootPromptMessages` — atomic tool-result block (blocker 11) + +The first draft's "atomic block" contradicted the 192-root cap: 193 results plus a +system root cannot be both. Atomicity means MEMBERSHIP: every result present in +original order, or construction fails. Explicit text truncation of a result may +remain, but whole-result deletion — `shift`, `filter`, marker omission — is forbidden +(`:318-341`). A count-only assertion would let an implementation silently drop +results and still pass, so tests assert result IDENTITY, not just count. + +### `buildPreparedCursorRunRequest` — one measurement, one guard + +Immediately after `conversationState` is final (`:959-994`), measure +`rootPromptMessagesJson` once: `rootCount` from the final ID list; `rootBytes` as the +sum of stored blob lengths, counting repeated IDs repeatedly; fail closed if any final +ID is unmeasurable. For external models, reject when count exceeds 192 or bytes exceed +512 KiB. Use this same measurement for the `run-request` telemetry. + +This single site fixes blockers 9 and 10 together: it sees checkpoint roots plus +suffix, and it is downstream of the empty-history early return. + +### `native-exec.ts` — blob size accessor + +Add a read-only `cursorBlobByteLength(blobId)` over the existing store +(`:333-340`, `:457-459`), returning stored byte length and never content. Requiring +resolvable sizes is safe because committed checkpoints already fail when a referenced +blob cannot be pinned (`src/adapters/cursor/checkpoint-store.ts:220-228`). + +### `cursor-errors.ts` — typed failure (blocker 12) + +Add `CursorRootEnvelopeLimitError` following the existing typed-error pattern +(`:36-71`): stable `name`, `code = "cursor_root_envelope_limit"`, `status = 400`, and +readonly `rootCount`, `rootBytes`, `maxRootCount`, `maxRootBytes`. Classify as an +invalid Cursor request. Add `CursorRootMeasurementError` for an unmeasurable final blob rather than logging +an invented number (re-audit blocker 6, which correctly noted the first revision left +it undefined): `code = "cursor_root_measurement_failed"`, `status = 500` because it +is an internal accounting failure rather than an oversized client request, carrying +the unmeasurable blob id and the count measured so far. It maps through the adapter +with the same non-retryable discipline. + +`isRetryableCursorError` returns false for both classes before any text heuristic +(`transport-retry.ts:20-39`) — retrying an over-envelope request only reproduces it. +The terminal catch in `src/adapters/cursor.ts:477-488` preserves status 400, +`invalid_request_error`, the code, and `retryable: false` instead of degrading to a +message-inferred 502. + +### Telemetry (blocker 13) + +The first draft called telemetry defect-free. It is not. For a pure checkpoint, +`rootPromptMessagesState` is undefined and `:990-992` reports `rootBytes: 0` despite +real checkpoint roots. For suffix replay, `:948-953` records `suffixRoots.byteLength` +including a synthetic system root that was then removed. Both are fixed by reporting +the single final measurement above — which matters beyond observability, because the +blocker-9 guard depends on the same number being right. + +Also drop the synthetic default-system root from suffix assembly (`:927-953`) so +suffix accounting stops including a root absent from the final envelope. + +## Accept criteria + +1. **Full replay keeps its initiating turn.** External full replay under cap, one + initiating user root, uniquely identified trailing results: the final non-system + sequence is the initiating user followed by every expected `call_id` in order. + Mutation: classify the call checkpoint-covered, or drop the initiating root — red. + (b) **Developer-root initiator (re-audit 5).** The same case with a `developer` + root as initiator. Turn discovery currently scans for `user` only + (`protobuf-request.ts:346-351`) even though its own comment says user or + developer, so a user-only test would pass an implementation that still drops a + developer-initiated turn. Fix the scan and assert the developer root survives. +2. **Checkpoint-covered result-only suffix stays valid.** A pinned checkpoint covers + the initiating turn and the suffix slices to a tool result: checkpoint roots + remain, the result is appended, no synthetic system root appears. + Mutation: apply the full-replay rule to the suffix — rejects or loses the result, + red. This is the blocker-8 regression guard. +3. **Count guard is cumulative.** 192 checkpoint roots plus a two-root suffix throws + with `rootCount === 194`. Mutation: guard only `suffixRoots` — no throw, red. +4. **Byte guard is cumulative.** Under 192 roots whose hydrated final bytes exceed + 512 KiB throws, and `rootBytes` equals the independently hydrated sum. + Mutation: count-only, or suffix-local byte accounting — no throw, red. +5. **The early return cannot bypass enforcement.** 193 system prompts with no + `rawMessages` throws with `rootCount === 193`. Mutation: guard only the + external-history branch — serializes fine, red. +6. **Tool-result membership is all-or-fail.** (a) An under-cap block with unique + `call_id`s keeps every identity and order. (b) 193 active results plus one system + root throws rather than emitting a shortened envelope. + Mutation: restore `active.shift()` or any arbitrary deletion — identity or + rejection assertion red even though the count still fits. + (c) **Combined byte overflow (re-audit 4).** Several results, each individually + representable, whose combined block exceeds the budget — the only shape that + reaches the deletion loop at `protobuf-request.ts:328-330`. Assert every result + identity survives or the request is rejected; never a silently shortened block. + Without this case the `active.shift()` mutation is NOT red, because 193 small + results stay under the byte budget and get caught by the count guard instead — + the re-audit's correction. +7. **Byte truncation cannot erase a result.** When the system leaves less room than + the minimum result-plus-marker representation, throw. Mutation: restore the + current omission path (`:332-340`) — succeeds without the result, red. +8. **The typed failure carries evidence.** Assert class, `name`, `code`, + `status === 400`, exact measured counts, both limits, and + `isRetryableCursorError(error) === false`; then the same class through the adapter + for event status, type, code, retryability. + Mutation: throw a generic Error, omit measured fields, or match on message regex + — at least one assertion red. This is what makes blocker 12 non-vacuous. +9. **Telemetry equals the final envelope.** (a) Pure checkpoint with debug enabled: + `rootBlobs` and `rootBytes` equal the hydrated checkpoint roots, not zero. + (b) Checkpoint plus suffix: telemetry equals the final sum and excludes the removed + synthetic system root. + Mutation: restore `rootPromptMessagesState?.byteLength ?? 0` or + `suffixRoots.byteLength` — red. +10. **An unmeasurable root is counted, not fatal (corrected during build).** A root + carried inside a decoded checkpoint need not exist in the local blob store — + Cursor minted some of them, and a resumed conversation legitimately references + ids this process never wrote. The count limit still binds such a root; the byte + total becomes a floor, and `unmeasuredRoots` in the diagnostic says so. + Mutation `unmeasured-fatal` (throw on an unmeasurable root) — red, 5 fail. +11. Invalid checkpoint bytes still report `continuationMode: "full-replay"` with + `checkpointInvalidationReason: "decode_failed"`. +12. Valid tool-suspended checkpoint continuation does not regress. + +## Corrections made during implementation + +Two things in the plan above were wrong and were changed rather than implemented as +written. Both were caught by running mutations, not by reading. + +**`CursorRootMeasurementError` was designed and then deleted.** Criterion 10 +originally required failing closed on an unmeasurable root. Implementing it broke +three already-passing checkpoint tests, which is the evidence that failing closed +rejects working continuation: checkpoint roots are *expected* to be absent from the +local store. With the fail-closed branch gone the class became unreachable, and an +unreachable error class cannot be tested, so it is not in the shipped diff. The +reasoning is recorded next to `isCursorRootEnvelopeError` so the next reader does not +re-add it. + +**The `replayOrigin` parameter was removed as non-load-bearing.** The plan gated +orphan-result recovery on whether the call was a full replay or a checkpoint suffix. +A mutation that forced the suffix branch could not be made red — `activeStart > 0` +already confines the search to the current call's slice, so the flag never decided +anything. Worse, gating on it would have *recreated* the defect for a checkpoint +suffix that does contain its own initiating turn. A parameter that cannot be observed +is not a safeguard. + +## Verification + +`bun test tests/cursor-blob.test.ts tests/cursor-request-builder.test.ts +tests/cursor-transport-retry.test.ts`: 153 pass, 0 fail. `bun x tsc --noEmit` clean. +CI on the exact PR head is primary; the request path is shared, so the merged tree is +verified before merge. A broader Cursor sweep runs remotely via `ssh lidge` with +`ocx-run`, never as a local full suite. + +Five mutations, all red — no claim here rests on a suite that was only ever seen +green: + +| mutation | effect | failures | +| --- | --- | --- | +| `no-guard` | disable the external-model envelope check | 4 | +| `count-only` | drop the byte limit, keep the count limit | 1 | +| `bytes-only` | drop the count limit, keep the byte limit | 3 | +| `stale-bytes` | restore `rootPromptMessagesState?.byteLength ?? 0` in telemetry | 1 | +| `unmeasured-fatal` | throw instead of counting an unmeasurable root | 5 | + +`stale-bytes` was green on the first attempt: the telemetry fix had no covering test, +and the first test written for it set `checkpointSuffixStart`, which populates the +very state the stale expression read. Only a *pure* checkpoint separates the two +expressions. That is the shape the test now uses. + +## Risk + +This changes model-visible replay content and therefore estimated input usage. +Retaining the initiating turn leaves less room for large tool output, so UTF-8-safe +truncation gets exercised harder. Rejecting oversized envelopes surfaces requests +that previously went out silently as explicit local 400s — better behavior, but +visible behavior change. Native Composer and Auto paths stay untouched. + +Not verified: no live Cursor request was sent. The 192-root and 512-KiB limits are +taken as authoritative from the existing constants rather than re-probed upstream. + +## Issue disposition + +#1527 stays open. This fixes a request shape that cannot work and removes a plausible +mechanism for the reported collapse; it does not prove the reporter's account +asymmetry is gone. The comment names which of their five residuals this addresses and +repeats the matched direct-versus-proxy probe that would settle the rest. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index e3b2a0aed6..1ab248a754 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,7 +3,7 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -70,6 +70,14 @@ function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext) if (err instanceof CursorMissingCredentialError) { return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN."; } + // A locally raised envelope rejection is already safe, specific, and actionable: it was composed + // here from our own measurements and contains no upstream text. Passing it through + // `safeCursorErrorMessage` would collapse it to the bare label "Cursor invalid request" (it + // matches the "invalid"/"exceeds" keyword branch) and discard the counts that tell the operator + // which limit was hit and by how much. + if (isCursorRootEnvelopeError(err)) { + return err instanceof Error ? `Cursor invalid request: ${err.message}` : "Cursor invalid request"; + } const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined; if (message) return safeCursorErrorMessage(message, sizeContext); return "Cursor upstream error: transport failed before completion."; @@ -485,6 +493,12 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ...(isTranslatorBudgetExceededError(err) ? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" } : {}), + // A local envelope rejection is a client error with a stable code, and the caller needs + // that code to distinguish "this conversation cannot be sent" from a transient upstream + // fault. Without this the class was flattened to a bare message and the code was lost. + ...(isCursorRootEnvelopeError(err) + ? { status: 400, errorType: "invalid_request_error", code: "cursor_root_envelope_limit", retryable: false } + : {}), ...(partialUsage ? { usage: partialUsage } : {}), }); } diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index e4d5682a3e..b7005db3c2 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -71,6 +71,50 @@ export class CursorUnexpectedCancelError extends Error { } } +/** + * The assembled root-blob envelope exceeded what Cursor's external workers accept. + * + * Raised locally, BEFORE the request is sent. Cursor rejects an oversized replay set with a late + * `invalid_argument` only after hydrating every blob, so the upstream failure arrives with no + * usable measurement — that is why this carries the counts it measured. Never retryable: replaying + * the same over-envelope request reproduces it exactly. + * + * The measurement is taken on the FINAL root set, after checkpoint and suffix assembly. Bounding + * an intermediate set is what let 192 checkpoint roots plus a two-root suffix emit 194 (#1527). + */ +export class CursorRootEnvelopeLimitError extends Error { + public readonly code = "cursor_root_envelope_limit"; + public readonly status = 400; + + constructor( + public readonly rootCount: number, + public readonly rootBytes: number, + public readonly maxRootCount: number, + public readonly maxRootBytes: number, + ) { + super( + // No "Cursor invalid request:" prefix here: `safeCursorErrorMessage` adds it for + // invalid-argument classes, and carrying it in the message produced it twice. + "the assembled conversation exceeds the replay envelope " + + `(${rootCount} root blobs / ${rootBytes} bytes against a limit of ${maxRootCount} / ${maxRootBytes}). ` + + "Start a new conversation or reduce the pending tool output.", + ); + this.name = "CursorRootEnvelopeLimitError"; + } +} + +/** + * The envelope failure is local and deterministic; a retry cannot change the outcome. + * + * A companion `CursorRootMeasurementError` was drafted for the case where a root blob's size + * cannot be read, then deleted: an unmeasurable root is legitimate (a resumed conversation + * references ids Cursor minted and this process never stored), so the guard counts it instead of + * failing. There is no reachable second case, and an unreachable error class cannot be tested. + */ +export function isCursorRootEnvelopeError(value: unknown): boolean { + return value instanceof CursorRootEnvelopeLimitError; +} + export function isCursorBenignCancelError(value: unknown): boolean { // An unexpected cancel is never benign, however it is spelled. This class is raised only when // the transport knows WE did not request the cancel, so its provenance outranks the code match diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index 921928c9aa..938dc17b9f 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -403,6 +403,19 @@ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobReque return blobId; } +/** + * Stored byte length of one blob, or null when it is not in the store. + * + * Size only, never content: the envelope guard needs to measure the FINAL root set, which mixes + * roots minted this turn with roots carried inside a checkpoint. Reading them back through a + * hydration path would both defeat the request-scope sealing and log served bytes for a request + * that may never be sent. + */ +export function cursorBlobByteLength(blobId: Uint8Array): number | null { + const entry = blobs.get(key(blobId)); + return entry ? entry.data.byteLength : null; +} + /** * Serve-time integrity for content-addressed blobs (devlog 260826_cursor_responses_gap 080): * a raw 32-byte blob id IS the SHA-256 of its bytes, so served data whose digest mismatches diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 55dd1e9205..256af86ab9 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -10,12 +10,14 @@ import { normalizeCursorToolResultText } from "./tool-result-normalize"; import { debugProviderDiagnostic } from "../../lib/debug"; import { createCursorBlobRequestScope, + cursorBlobByteLength, cursorBlobMaxEntryBytes, releaseCursorBlobRequestScope, sealCursorBlobRequestScope, storeCursorBlob, type CursorBlobRequestScopeToken, } from "./native-exec"; +import { CursorRootEnvelopeLimitError } from "./cursor-errors"; import { buildSelectedContext, CURSOR_VISION_IMAGE_HISTORY_MARKER } from "./images"; import { estimateTokens } from "../../lib/token-estimate"; import { parseDataUrl } from "../image"; @@ -325,6 +327,23 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR .map(entry => truncateToolResultBlob(entry, historyBudget)) .filter((entry): entry is RootBlobCandidate => entry !== null); 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); + 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); + } + // 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) { const dropped = active.shift(); activeBytes -= dropped?.byteLength ?? 0; @@ -347,6 +366,9 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR 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; const turn = prior.slice(turnStart, i + 1); const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0); @@ -368,6 +390,73 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR 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 + // `conversationTurns()` then discards the result too for lack of a current turn — the wire + // request becomes system roots plus a bare result marker with no instruction, which a model + // answers in a handful of tokens. + // + // Recover the initiating root and pay for it out of the tool-result text instead. + // + // This needs no full-replay/checkpoint distinction, which is worth stating because the plan + // called for one. `activeStart > 0` confines the search to entries present in THIS call's + // history, so a checkpoint suffix can only ever recover a turn from inside its own uncovered + // slice — never one the checkpoint already carries. And for a suffix that does contain its + // initiating turn, recovery is exactly as necessary as it is for a full replay: mode-gating it + // would have recreated this defect for checkpoint continuations. Mutation testing found that; + // the mode flag could not be made to fail a test because it was never load-bearing. + if ( + historyEntries.length > 0 + && historyEntries[0]?.role === "toolResult" + && activeStart > 0 + ) { + let initiatorIndex = activeStart - 1; + while (initiatorIndex >= 0 && history[initiatorIndex]?.role !== "user") { + initiatorIndex -= 1; + } + const initiator = initiatorIndex >= 0 ? history[initiatorIndex] : undefined; + if (initiator) { + const withInitiator = [initiator, ...historyEntries]; + const initiatorBytes = withInitiator.reduce((sum, entry) => sum + entry.byteLength, 0); + if (withInitiator.length <= historyLimit && initiatorBytes <= historyBudget) { + historyEntries.length = 0; + historyEntries.push(...withInitiator); + } else { + // Make room for the initiator instead of abandoning it. Review found that gating this on + // `historyEntries.length === 1` left the defect fully intact for the far more common + // multi-result shape: one system root plus 191 small trailing results already fills the + // count limit, so the initiator did not fit, the single-result branch did not apply, and + // the request went out as 191 bare results with nothing asking for them — inside the new + // envelope, so the guard could not catch it either. + // + // Drop the OLDEST results first, which is the same direction the byte-pressure loop above + // 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(); + let keptBytes = kept.reduce((sum, entry) => sum + entry.byteLength, 0); + while (kept.length > 1 && initiator.byteLength + keptBytes > historyBudget) { + const dropped = kept.shift(); + keptBytes -= dropped?.byteLength ?? 0; + } + if (kept.length === 1 && kept[0] && initiator.byteLength + keptBytes > historyBudget) { + const room = historyBudget - initiator.byteLength; + const shrunk = room > 0 ? truncateToolResultBlob(kept[0], room) : null; + if (shrunk) { + kept[0] = shrunk; + keptBytes = shrunk.byteLength; + } + } + // 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) { + historyEntries.length = 0; + historyEntries.push(initiator, ...kept); + } + } + } + } selected = [...systemEntries, ...historyEntries]; const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined); historyMessageStart = firstKept?.messageIndex ?? (messages.length); @@ -976,6 +1065,40 @@ function buildPreparedCursorRunRequest( // filtered definitions the wire carries. Both helpers are pure. const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice); const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice); + // The envelope is measured HERE, on the final root set, and nowhere else. + // + // `rootPromptMessages` cannot do it: it sees only a checkpoint suffix, so 192 checkpoint roots + // plus a two-root suffix passed its per-call check and emitted 194 roots; and its empty-history + // early return skips the pruning branch entirely, which let 193 system prompts through. Both are + // downstream of this point, which is the first place the wire content is fully known (#1527). + // + // The same measurement feeds the diagnostic below, so telemetry cannot disagree with the guard. + // + // Roots carried inside a decoded checkpoint need not be in the local store — Cursor minted some + // of them, and a resumed conversation legitimately references ids this process never wrote. So an + // unmeasurable root is counted, not fatal: the COUNT limit still binds it (that is the 194-root + // case), and `unmeasuredRoots` records that the byte total is a floor rather than a total. An + // earlier fail-closed version broke three passing checkpoint tests, which is the evidence that + // failing closed here would reject working continuation. + const measuredRootCount = conversationState.rootPromptMessagesJson.length; + let measuredRootBytes = 0; + let unmeasuredRoots = 0; + for (const blobId of conversationState.rootPromptMessagesJson) { + const size = cursorBlobByteLength(blobId); + if (size === null) unmeasuredRoots += 1; + else measuredRootBytes += size; + } + if ( + isCursorExternalWireModel(request.modelId) + && (measuredRootCount > CURSOR_EXTERNAL_ROOT_BLOB_LIMIT || measuredRootBytes > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT) + ) { + throw new CursorRootEnvelopeLimitError( + measuredRootCount, + measuredRootBytes, + CURSOR_EXTERNAL_ROOT_BLOB_LIMIT, + CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, + ); + } debugProviderDiagnostic("cursor", "run-request", { wireModel: request.modelId, action: actionCase, @@ -987,8 +1110,13 @@ function buildPreparedCursorRunRequest( checkpointPresent: continuationMode === "checkpoint", checkpointBytes: continuationMode === "checkpoint" ? request.checkpointBytes?.byteLength : undefined, checkpointInvalidationReason, - rootBlobs: conversationState.rootPromptMessagesJson.length, - rootBytes: rootPromptMessagesState?.byteLength ?? 0, + rootBlobs: measuredRootCount, + // Was `rootPromptMessagesState?.byteLength ?? 0`, which reported 0 for a pure checkpoint and, + // for a suffix, counted a synthetic system root that had already been sliced off. + rootBytes: measuredRootBytes, + // Non-zero means rootBytes is a floor: that many roots came from a checkpoint the local store + // never held. Recorded rather than hidden, so an operator reading the number knows which it is. + ...(unmeasuredRoots > 0 ? { unmeasuredRoots } : {}), turnBlobs: conversationState.turns.length, tools: request.tools?.length ?? 0, }); diff --git a/src/adapters/cursor/transport-retry.ts b/src/adapters/cursor/transport-retry.ts index 01b2b55191..a0714d5a9d 100644 --- a/src/adapters/cursor/transport-retry.ts +++ b/src/adapters/cursor/transport-retry.ts @@ -2,7 +2,7 @@ import type { CursorRunRequest, CursorServerMessage } from "./types"; import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport"; import { abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry"; import { debugProviderDiagnostic } from "../../lib/debug"; -import { safeCursorErrorMessage } from "./cursor-errors"; +import { isCursorRootEnvelopeError, safeCursorErrorMessage } from "./cursor-errors"; // Compat: historical name for the shared abortable sleep, kept for external callers. export { sleepWithAbort as abortAwareSleep } from "../../lib/upstream-retry"; @@ -18,6 +18,10 @@ export const CURSOR_RETRY_MAX_MS = 2_000; * never replay a turn the Cursor server might already have accepted. */ export function isRetryableCursorError(err: unknown): boolean { + // Local envelope failures are deterministic: the same request produces the same rejection. + // Checked before the text heuristics below, which would otherwise have to infer this from + // wording. + if (isCursorRootEnvelopeError(err)) return false; const code = typeof err === "object" && err && "code" in err ? String((err as { code?: unknown }).code ?? "") : ""; const message = err instanceof Error ? err.message : typeof err === "string" ? err : ""; const haystack = `${code} ${message}`.toLowerCase(); diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index ca71ae073c..b891e06ba7 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -18,6 +18,7 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/t import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types"; import type { CursorTransportFactoryInput } from "../src/adapters/cursor/transport"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; +import { CursorRootEnvelopeLimitError } from "../src/adapters/cursor/cursor-errors"; const createCursorAdapter = (...args: Parameters) => withTestTranslatorBudget(createCursorAdapterProduction(...args)); @@ -120,6 +121,51 @@ describe("Cursor adapter live transport", () => { expect(inputs[0]?.fetch).toBe(pacedFetch); }); + // #1527: the envelope rejection is raised locally while building the request, so it surfaces + // through the same terminal catch as a transport fault. Review found the class was flattened to + // a bare message there, losing the stable code a caller needs to tell "this conversation cannot + // be sent" from a transient upstream error — and the message carried a doubled prefix. + test("a local envelope rejection surfaces as a typed 400, not a bare message", async () => { + // Raised from the transport seam because that is where it actually originates: encoding + // happens inside the live transport (`prepareCursorRunRequest` in live-transport.ts), which a + // mock replaces, so a mocked run can never reach the guard itself. What is under test here is + // the adapter's terminal catch, not the guard — the guard has its own tests in + // tests/cursor-blob.test.ts. + const adapter = createCursorAdapter(provider, { + createTransport: () => ({ + async *run(): AsyncGenerator { + throw new CursorRootEnvelopeLimitError(194, 600_000, 192, 524_288); + }, + writeClient() {}, + }), + }); + const events: AdapterEvent[] = []; + + await adapter.runTurn?.( + { + ...parsed, + modelId: "cursor/gpt-5.6-sol-xhigh", + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + }, + { headers: new Headers() }, + event => events.push(event), + ); + + const error = events.find(event => event.type === "error"); + expect(error).toBeDefined(); + expect(error).toMatchObject({ + status: 400, + errorType: "invalid_request_error", + code: "cursor_root_envelope_limit", + retryable: false, + }); + // One prefix, not two. + const message = String((error as { message?: unknown }).message ?? ""); + expect(message.match(/Cursor invalid request/g)?.length).toBe(1); + // And the operator-facing numbers survive the boundary. + expect(message).toContain("194"); + }); + test("runTurn preserves explicit Cursor Router optimization levels", async () => { const requests: CursorRunRequest[] = []; const adapter = createCursorAdapter(provider, { diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 01ff52d0e5..41891bf1b9 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -30,6 +30,7 @@ import { registerRetainedStore, resetAppOwnedMemoryForTests, } from "../src/lib/app-owned-memory"; +import { resetDebugSettingsForTests } from "../src/lib/debug-settings"; import { CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT, @@ -39,6 +40,8 @@ import { prepareCursorRunRequest, } from "../src/adapters/cursor/protobuf-request"; import { estimateTokens } from "../src/lib/token-estimate"; +import { CursorRootEnvelopeLimitError } from "../src/adapters/cursor/cursor-errors"; +import { isRetryableCursorError } from "../src/adapters/cursor/transport-retry"; import { AgentClientMessageSchema, ConversationStepSchema, @@ -506,6 +509,16 @@ describe("Cursor blob handshake", () => { expect(rootBytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); expect(JSON.stringify(roots)).toContain("[Tool Result]"); expect(JSON.stringify(roots)).toContain("truncated for Cursor external replay budget"); + // #1527: the result surviving is not enough. Byte pressure used to consume the whole budget + // with this one result and drop the user turn that asked for it, and `conversationTurns()` + // then discarded the result too for lack of a current turn. What went on the wire was system + // roots plus a bare result marker and a generic Continue action — no instruction, which a + // model answers in a handful of tokens. That is the reported symptom. + // + // Asserting the marker alone is what let it pass CI, so assert the instruction as well. + expect(JSON.stringify(roots)).toContain("read it"); + expect(roots.find(root => root.role !== "system")?.role).toBe("user"); + expect(run?.conversationState?.turns.length).toBe(1); }); test("truncates multi-byte tool results by UTF-8 byte budget", () => { @@ -1852,6 +1865,57 @@ describe("Cursor checkpoint request construction", () => { expect(serialized).not.toContain("old user"); }); + /** + * The blocker-8 regression guard. Full replay must never emit a tool result without the turn + * that caused it, but a CHECKPOINT suffix legitimately can: `checkpointSuffixStart` is the + * count of messages the checkpoint already carries, so the initiating turn is inside it. + * + * Slicing at 3 makes the suffix exactly `[toolResult]`. Applying the full-replay rule here + * would pull the covered user turn back in and replay it twice. + */ + test("a checkpoint suffix may legitimately begin with a tool result", () => { + 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_result_only", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "please read the file", timestamp: 3 }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + content: "SUFFIX ONLY CONTENTS", + isError: false, + timestamp: 4, + }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 3, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + + // The checkpoint's own root is preserved and the suffix is appended after it. + expect(Array.from(roots[0] ?? [])).toEqual(Array.from({ length: 32 }, () => 7)); + const suffix = roots.slice(1).map(id => new TextDecoder().decode(blobData(id))); + const serialized = JSON.stringify(suffix); + expect(serialized).toContain("SUFFIX ONLY CONTENTS"); + + // The covered turn stays covered: pulling it back in would replay it a second time. + expect(serialized).not.toContain("please read the file"); + // And no synthetic system root is re-appended on top of the checkpoint's own. + expect(suffix.some(root => root.includes('"role":"system"'))).toBe(false); + }); + test("active checkpoint lease keeps referenced blobs after request pin release", () => { clearCursorCheckpointsForTests(); const data = new TextEncoder().encode('{"role":"system","content":"lease-me"}'); @@ -1999,3 +2063,317 @@ describe("Cursor checkpoint idle TTL", () => { expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBe(0); }); }); + +/** + * #1527 envelope enforcement. The 192-root / 512-KiB limits existed but were applied to the + * pruned history only, so three shapes escaped them entirely. Each of these reproduced a real + * over-envelope request before the guard moved to the final assembled root set. + */ +describe("Cursor external replay envelope", () => { + test("system roots alone cannot exceed the root count limit", () => { + // 193 system prompts: the history budget is zero, so the pruning branch had nothing to + // trim and emitted every system root. + const system = Array.from({ length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + 1 }, (_, i) => `system-${i}`); + + let thrown: unknown; + try { + encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-sys-count", + system, + messages: [{ role: "user", content: "hi" }], + rawMessages: [{ role: "user", content: "hi", timestamp: 1 }], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CursorRootEnvelopeLimitError); + const limit = thrown as CursorRootEnvelopeLimitError; + expect(limit.name).toBe("CursorRootEnvelopeLimitError"); + expect(limit.code).toBe("cursor_root_envelope_limit"); + expect(limit.status).toBe(400); + expect(limit.rootCount).toBeGreaterThan(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + expect(limit.maxRootCount).toBe(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + expect(limit.maxRootBytes).toBe(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + // A local, deterministic rejection: replaying it reproduces it. + expect(isRetryableCursorError(limit)).toBe(false); + }); + + test("a single oversized system root cannot exceed the byte limit", () => { + const oversized = "s".repeat(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + 100_000); + + let thrown: unknown; + try { + encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-sys-bytes", + system: [oversized], + messages: [{ role: "user", content: "hi" }], + rawMessages: [{ role: "user", content: "hi", timestamp: 1 }], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CursorRootEnvelopeLimitError); + expect((thrown as CursorRootEnvelopeLimitError).rootBytes).toBeGreaterThan(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); + }); + + // The early return at the top of `rootPromptMessages` skips the pruning branch entirely when + // there is no history, so a guard placed inside that branch never saw this shape. + test("the empty-history path is bounded too", () => { + const system = Array.from({ length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + 1 }, (_, i) => `only-system-${i}`); + + expect(() => encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-no-history", + system, + messages: [{ role: "user", content: "hi" }], + rawMessages: [], + })).toThrow(CursorRootEnvelopeLimitError); + }); + + // Contiguous trailing results were byte-pruned but never count-pruned, so 193 small results + // sailed past the history limit that only checked `keptPrior`. + test("a long contiguous tool-result block cannot exceed the root count limit", () => { + const results = Array.from({ length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + 1 }, (_, i) => ({ + role: "toolResult" as const, + toolCallId: `call_${i}`, + toolName: "read_file", + content: `r${i}`, + isError: false, + timestamp: i + 2, + })); + + let thrown: unknown; + try { + encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-many-results", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages: [{ role: "user", content: "go", timestamp: 1 }, ...results], + }); + } catch (error) { + thrown = error; + } + + // Either bounded within the envelope, or rejected — never silently shortened past the cap. + if (thrown) { + expect(thrown).toBeInstanceOf(CursorRootEnvelopeLimitError); + } else { + const bytes = encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-many-results-2", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages: [{ role: "user", content: "go", timestamp: 1 }, ...results], + }); + expect(decodeRootMessages(bytes).length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + } + }); + + test("a native model is not subject to the external envelope", () => { + const system = Array.from({ length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT + 1 }, (_, i) => `system-${i}`); + + expect(() => encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-native", + system, + messages: [{ role: "user", content: "hi" }], + rawMessages: [{ role: "user", content: "hi", timestamp: 1 }], + })).not.toThrow(); + }); + + // Review probe: 191 small trailing results plus one system root already fill the count limit, so + // the initiator did not fit and the earlier single-result-only recovery branch never ran. The + // request then went out as bare results with nothing asking for them — and at exactly 192 roots + // the envelope guard could not catch it either. Recovery must make room, not give up. + test("a multi-result orphan block still recovers its initiating turn", () => { + const results = Array.from({ length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - 1 }, (_, i) => ({ + role: "toolResult" as const, + toolCallId: `call_${i}`, + toolName: "read_file", + content: `r${i}`, + isError: false, + timestamp: i + 2, + })); + + const bytes = encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-orphan-multi", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages: [{ role: "user", content: "please read the files", timestamp: 1 }, ...results], + }); + + const roots = decodeRootMessages(bytes); + expect(roots.length).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BLOB_LIMIT); + // The initiating instruction must survive: a model handed only tool results has nothing to do. + const serialized = JSON.stringify(roots); + expect(serialized).toContain("please read the files"); + // And it must come first, so the results read as answers to it rather than as an orphan block. + const nonSystem = roots.slice(1); + expect(JSON.stringify(nonSystem[0])).toContain("please read the files"); + }); + + // Review probe, reproduced: three ~220 KB results used to emit only the last two — `call_0` + // disappeared while its tool call stayed in the transcript, which is exactly the pairing break + // #1527 describes. Every result must survive in some form, even a truncated one. + test("oversized parallel tool results are all retained, truncated rather than deleted", () => { + const results = [0, 1, 2].map(i => ({ + role: "toolResult" as const, + toolCallId: `call_${i}`, + toolName: "read_file", + content: `UNIQUE_MARKER_${i} ` + "x".repeat(220_000), + isError: false, + timestamp: i + 3, + })); + + const bytes = encodeCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-parallel-results", + system: ["system"], + messages: [{ role: "tool", content: "ignored" }], + rawMessages: [ + { role: "user", content: "read all three", timestamp: 1 }, + { + role: "assistant", + model: "cursor/gpt-5.6-sol", + content: [0, 1, 2].map(i => ({ + type: "toolCall" as const, + id: `call_${i}`, + name: "read_file", + arguments: { path: `f${i}.txt` }, + })), + timestamp: 2, + }, + ...results, + ], + }); + + const serialized = JSON.stringify(decodeRootMessages(bytes)); + // Each result is present, identifiable by its own marker. None was silently deleted. + expect(serialized).toContain("UNIQUE_MARKER_0"); + expect(serialized).toContain("UNIQUE_MARKER_1"); + expect(serialized).toContain("UNIQUE_MARKER_2"); + // And the whole set still fits the envelope, so retention did not come at the cost of the bound. + expect(serialized).toContain("truncated for Cursor external replay budget"); + }); + + // The shape the guard was moved for. Review noted that every other new fixture here is an + // 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", () => { + const checkpointRoots = Array.from( + { length: CURSOR_EXTERNAL_ROOT_BLOB_LIMIT }, + (_, i) => new Uint8Array(32).fill(i % 251), + ); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + + expect(() => prepareCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-ckpt-cumulative", + system: ["You are helpful."], + messages: [{ role: "user", content: "next" }], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "mid user", timestamp: 3 }, + { role: "assistant", content: [{ type: "text", text: "mid assistant" }], timestamp: 4 }, + { role: "user", content: "next", timestamp: 5 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + checkpointSuffixStart: 2, + continuationMode: "checkpoint", + })).toThrow(CursorRootEnvelopeLimitError); + }); + + test("a checkpoint plus a legal suffix cannot exceed 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); + const checkpointRoots = [storeCursorBlob(big), storeCursorBlob(new Uint8Array(200_000).fill(66)), storeCursorBlob(new Uint8Array(200_000).fill(67))]; + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: checkpointRoots, + turns: [new Uint8Array(32).fill(8)], + }); + + expect(() => prepareCursorRunRequest({ + modelId: "gpt-5.6-sol-xhigh", + conversationId: "c-ckpt-bytes", + system: ["You are helpful."], + messages: [{ role: "user", content: "next" }], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "mid user", timestamp: 3 }, + { role: "assistant", content: [{ type: "text", text: "mid assistant" }], timestamp: 4 }, + { role: "user", content: "next", timestamp: 5 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + checkpointSuffixStart: 2, + continuationMode: "checkpoint", + })).toThrow(CursorRootEnvelopeLimitError); + }); + + // The diagnostic used to read `rootPromptMessagesState?.byteLength`, which is undefined for a + // pure checkpoint continuation — so an operator sizing a conversation that was about to be + // 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. + test("the run-request diagnostic reports the measured envelope, not zero", () => { + const previousDebug = process.env.OCX_DEBUG; + process.env.OCX_DEBUG = "1"; + resetDebugSettingsForTests(); + const lines: string[] = []; + const originalError = console.error; + console.error = (line: unknown) => { lines.push(String(line)); }; + try { + // A PURE checkpoint continuation: no suffix, so `rootPromptMessagesState` is undefined and + // the old expression had nothing to read. One root is in the local store (measurable) and + // one was minted by Cursor (not), which is the mix a resumed conversation actually carries. + const storedRoot = storeCursorBlob(new TextEncoder().encode("root-payload-in-store")); + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [storedRoot, new Uint8Array(32).fill(7)], + turns: [new Uint8Array(32).fill(8)], + }); + prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_telemetry", + system: ["You are helpful."], + messages: [{ role: "user", content: "new user" }], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "new user", timestamp: 3 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + }); + const runLine = lines.find(line => line.includes("[ocx:cursor:run-request]")); + expect(runLine).toBeDefined(); + const payload = JSON.parse(runLine!.slice(runLine!.indexOf("{"))) as { + rootBlobs: number; + rootBytes: number; + unmeasuredRoots?: number; + }; + expect(payload.rootBlobs).toBe(2); + // The load-bearing assertion: with no suffix state to read, the old expression reported 0. + expect(payload.rootBytes).toBeGreaterThan(0); + // Exactly the one root that came from the checkpoint and was never in the local store, so + // the reader knows rootBytes is a floor. + expect(payload.unmeasuredRoots).toBe(1); + } finally { + console.error = originalError; + if (previousDebug === undefined) delete process.env.OCX_DEBUG; + else process.env.OCX_DEBUG = previousDebug; + resetDebugSettingsForTests(); + } + }); +});