@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -10,12 +10,14 @@ import { normalizeCursorToolResultText } from "./tool-result-normalize";
10
10
  import { debugProviderDiagnostic } from "../../lib/debug";
11
11
  import {
12
12
  createCursorBlobRequestScope,
13
+ cursorBlobByteLength,
13
14
  cursorBlobMaxEntryBytes,
14
15
  releaseCursorBlobRequestScope,
15
16
  sealCursorBlobRequestScope,
16
17
  storeCursorBlob,
17
18
  type CursorBlobRequestScopeToken,
18
19
  } from "./native-exec";
20
+ import { CursorRootEnvelopeLimitError } from "./cursor-errors";
19
21
  import { buildSelectedContext, CURSOR_VISION_IMAGE_HISTORY_MARKER } from "./images";
20
22
  import { estimateTokens } from "../../lib/token-estimate";
21
23
  import { parseDataUrl } from "../image";
@@ -71,6 +73,13 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
71
73
  export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
72
74
  /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
73
75
  export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;
76
+ /**
77
+ * Byte budget for the serialized arguments named inside ONE replayed tool-result envelope. The
78
+ * invocation identifies the call; the result is the payload. Without an independent cap, a single
79
+ * large-but-legitimate argument (a 600 KiB file write) consumed the whole root history budget and
80
+ * the result output was truncated away instead.
81
+ */
82
+ export const CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT = 2 * 1024;
74
83
 
75
84
  /**
76
85
  * Action text for external-model tool-result continuations. Native models keep
@@ -116,6 +125,13 @@ type RootBlobCandidate = {
116
125
  messageIndex?: number;
117
126
  /** Original JSON text payload used when an active tool result must be truncated to fit. */
118
127
  text?: string;
128
+ /**
129
+ * Set when a tool result was truncated past the point where any of its own output survives — either down
130
+ * to the truncation marker alone, or mid-envelope before the `output:` line. The model reads both as an
131
+ * empty answer to its own call, so a caller deciding whether the result "survived" must be able to tell
132
+ * them apart from a real one (devlog 260829 070).
133
+ */
134
+ outputElided?: true;
119
135
  };
120
136
 
121
137
  function rootBlobCandidate(
@@ -154,7 +170,14 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo
154
170
  "toolResult",
155
171
  { messageIndex: entry.messageIndex, text: truncated },
156
172
  );
157
- if (result.byteLength <= maxBytes) return result;
173
+ if (result.byteLength <= maxBytes) {
174
+ // `output:` is the last fixed line of the envelope, so a cut landing before it leaves the header
175
+ // and no answer. Flag it: "a result root survived" would otherwise be true of a root that tells the
176
+ // model nothing about what its tool returned.
177
+ const outputStart = truncated.indexOf("\noutput:\n");
178
+ const keptOutput = outputStart >= 0 && truncated.length > outputStart + "\noutput:\n".length + marker.length;
179
+ return keptOutput ? result : { ...result, outputElided: true };
180
+ }
158
181
  if (end === 0) break;
159
182
  keepBytes = Math.max(0, end - (result.byteLength - maxBytes) - 16);
160
183
  }
@@ -163,7 +186,7 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo
163
186
  "toolResult",
164
187
  { messageIndex: entry.messageIndex, text: marker.trimStart() },
165
188
  );
166
- return markerOnly.byteLength <= maxBytes ? markerOnly : null;
189
+ return markerOnly.byteLength <= maxBytes ? { ...markerOnly, outputElided: true } : null;
167
190
  }
168
191
 
169
192
  function systemPromptBlobs(request: CursorRunRequest): RootBlobCandidate[] {
@@ -195,12 +218,59 @@ function assistantRootText(
195
218
  // [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Native resume models
196
219
  // already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto
197
220
  // few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID.
198
- function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): {
221
+ function rootPromptMessages(
222
+ request: CursorRunRequest,
223
+ requestScope: CursorBlobRequestScopeToken,
224
+ /**
225
+ * Calls indexed from the FULL history. The checkpoint path replays only a suffix of
226
+ * `rawMessages`, so a result in that suffix can have its originating call before the cut; indexing
227
+ * from the slice alone silently dropped the invocation line for every checkpoint continuation,
228
+ * which is where the defect this line prevents actually reappeared in live use.
229
+ */
230
+ knownCalls?: Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>,
231
+ /**
232
+ * Full-history index of `rawMessages[0]` for this call. Non-zero only on the checkpoint path, where
233
+ * only a suffix is replayed but `knownCalls` still spans full history; the positional bound needs
234
+ * both sides in the same space (devlog 260829 060).
235
+ */
236
+ knownCallsOffset = 0,
237
+ /**
238
+ * Roots the decoded checkpoint already carries, which this call's pruning must leave room for.
239
+ *
240
+ * The envelope guard downstream measures checkpoint roots PLUS this suffix and throws a
241
+ * non-retryable 400 when the total exceeds the limit. Pruning against the full limit therefore
242
+ * emitted a suffix that was individually legal and cumulatively fatal — invisible until suffix
243
+ * replay actually grew (devlog 260829 070, audit r8 finding 3).
244
+ */
245
+ carriedRoots: { count: number; byteLength: number } = { count: 0, byteLength: 0 },
246
+ ): {
199
247
  ids: Uint8Array[];
200
248
  byteLength: number;
201
249
  historyMessageStart: number;
202
250
  /** Serialized text of the roots that survived pruning, in wire order. */
203
251
  serialized: string[];
252
+ /**
253
+ * Source message index of each HISTORY root that survived pruning (system roots excluded).
254
+ *
255
+ * The checkpoint caller needs to know whether the specific message it is continuing from survived.
256
+ * Neither role nor text can answer that: roles repeat, and matching the result's own output against the
257
+ * serialized root fails on JSON escaping the moment real output contains a newline or a quote — which
258
+ * made live continuations abandon their checkpoint on every turn (devlog 260829 070).
259
+ */
260
+ historyMessageIndexes: number[];
261
+ /**
262
+ * Message indexes whose root survived pruning but lost ALL of its own output to truncation, so only the
263
+ * truncation marker remains. Aligned with nothing — membership is the whole signal (devlog 260829 070).
264
+ */
265
+ historyOutputElided: number[];
266
+ /**
267
+ * Message indexes of the trailing tool-result run as PRUNING saw it — root space, not raw-message space.
268
+ * The two spaces diverge: a bare tool call with no text emits no root, so two sequentially-executed
269
+ * results become adjacent roots while a raw-space scan still sees a trailing run of one. A caller that
270
+ * re-derives the run from `rawMessages` therefore cannot see a result this function dropped for count
271
+ * (audit r10). Emitted so the abandon decision reads the same set pruning acted on.
272
+ */
273
+ activeMessageIndexes: number[];
204
274
  } {
205
275
  const entries = systemPromptBlobs(request);
206
276
  const systemEntryCount = entries.length;
@@ -211,11 +281,17 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
211
281
  byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0),
212
282
  historyMessageStart: 0,
213
283
  serialized: entries.map(entry => entry.serialized),
284
+ historyMessageIndexes: [],
285
+ historyOutputElided: [],
286
+ activeMessageIndexes: [],
214
287
  };
215
288
  }
216
289
 
217
290
  const externalModel = isCursorExternalWireModel(request.modelId);
218
291
  const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId);
292
+ // Replayed results name the invocation that produced them; without it the result is orphaned
293
+ // (devlog 260829 000_rca). Indexed once per request rather than rescanned per result.
294
+ const replayedCalls = echoToolResultInRoot ? (knownCalls ?? toolCallsByCallId(messages)) : undefined;
219
295
  const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
220
296
  const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
221
297
  // Repetition breaker (devlog 260826 gap-9): external full-replay flattens history to text,
@@ -285,7 +361,11 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
285
361
  text,
286
362
  );
287
363
  }
288
- // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
364
+ // Assistant tool CALLS are NOT replayed as a separate visible "[Tool Call]" entry: a model
365
+ // few-shot-mimics that marker and emits later tool calls as inert text (363-B guard in
366
+ // tests/cursor-tool-continuation.test.ts). The invocation is instead named INSIDE the paired
367
+ // "[Tool Result]" envelope below, which carries the same information without a mimickable
368
+ // call template (devlog 260829 002_audit_round2).
289
369
  } else if (message.role === "toolResult") {
290
370
  // Native resume models already receive the paired MCP result through turns[]. Replaying
291
371
  // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto
@@ -294,7 +374,9 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
294
374
  // #1920: the prefix must reflect the NORMALIZED error state (an empty
295
375
  // node_repl result is an error even when the runtime said isError=false).
296
376
  const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]";
297
- const text = `${prefix}\n${toolResultToText(message)}`;
377
+ // The bound compares in full-history space: this loop's `i` is already full-history on the
378
+ // full-replay path, and `knownCallsOffset` re-bases it when only a suffix is replayed.
379
+ const text = `${prefix}\n${toolResultToText(message, callBefore(replayedCalls, decodeCursorCallId(message.toolCallId), knownCallsOffset + i))}`;
298
380
  pushDeduped(toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, text);
299
381
  }
300
382
  }
@@ -308,29 +390,124 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
308
390
 
309
391
  let selected = entries;
310
392
  let historyMessageStart = 0;
393
+ // The trailing tool-result run in ROOT space, recorded before pruning can drop from it. Empty for a
394
+ // native model or a non-external one, which never assemble a trailing run here at all.
395
+ let activeMessageIndexes: number[] = [];
311
396
  if (externalModel) {
397
+ // A non-zero offset means `rawMessages[0]` is NOT the conversation start: only the checkpoint
398
+ // path passes one, and it passes `suffixStart`, the count of messages the checkpoint carries.
399
+ // Named rather than tested inline because `knownCallsOffset` answers two different questions —
400
+ // where to re-base a position (#2936) and whether this history has a covered predecessor — and
401
+ // collapsing them back into one bare `!== 0` is how the second meaning gets lost again.
402
+ const suffixContinuesCoveredTurn = knownCallsOffset > 0;
312
403
  const systemEntries = entries.slice(0, systemEntryCount);
313
404
  const history = entries.slice(systemEntryCount);
314
405
  const systemBytes = systemEntries.reduce((sum, entry) => sum + entry.byteLength, 0);
315
- const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - systemEntryCount);
316
- const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes);
406
+ // On the checkpoint path the caller appends ONLY the history roots to what the checkpoint already
407
+ // carries (`suffixHistoryIds` is `ids.slice(suffixSystemCount)`), and the system roots the checkpoint
408
+ // carries are already inside `carriedRoots`. Subtracting `systemEntryCount` there charges for them
409
+ // twice, which cost a slot that was genuinely free: at 190 carried roots the limit came out 1 when 2
410
+ // results fit, and the count bound below then dropped an answered call for no reason (audit r10).
411
+ const chargeableSystemCount = suffixContinuesCoveredTurn ? 0 : systemEntryCount;
412
+ const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - chargeableSystemCount - carriedRoots.count);
413
+ // Only the COUNT is relaxed. The byte side keeps charging `systemBytes` on both paths: the same
414
+ // double-charge argument applies in principle, but no configuration could be found where relaxing it
415
+ // changes the assembled payload — 6 crossings of carried bytes against system size against result size
416
+ // in the deciding band produced byte-identical output with and without it. Untested new code on the
417
+ // envelope path is a liability rather than a saving, and charging the bytes twice only ever errs
418
+ // conservative, so the relaxation is deliberately not made here (audit r11).
419
+ const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes - carriedRoots.byteLength);
317
420
 
318
421
  // Retain the active trailing tool-result block when it fits (may truncate text).
319
422
  // If even a truncation marker cannot fit the remaining budget, omit it rather than
320
423
  // emitting an oversized root blob.
321
- let activeStart = history.length;
424
+ //
425
+ // Walk past any SYNTHETIC trailing root first. The repetition breaker above appends a
426
+ // `[context note]` user root after the transcript, and it stands for no message, so it carries no
427
+ // `messageIndex`. Without this step the result-run walk stopped dead on that note: `activeStart`
428
+ // came out equal to `history.length`, the trailing run was empty, and the results lost their
429
+ // trailing-run status entirely — they fell through to `prior` and were pruned as ordinary history,
430
+ // with no "keep at least one" guarantee and an empty `activeMessageIndexes` that sent the abandon
431
+ // check back to the raw-message scan it must not use. Measured: a note-armed continuation at 186
432
+ // carried roots was retained where the same shape without the note correctly abandoned, and the
433
+ // note arms on three identical assistant narrations — the runaway-repetition shape this whole unit
434
+ // exists to end, so the one input most likely to hit it (audit r11).
435
+ let activeEnd = history.length;
436
+ while (activeEnd > 0 && history[activeEnd - 1]?.messageIndex === undefined) activeEnd -= 1;
437
+ let activeStart = activeEnd;
322
438
  while (activeStart > 0 && history[activeStart - 1]?.role === "toolResult") activeStart -= 1;
439
+ // Reserve the synthetic tail's slots and bytes FIRST, and express every budget below net of it.
440
+ // The tail is appended after all pruning, so a block that spends its room overruns the envelope,
441
+ // and a block that divides the gross budget produces shares that cannot fit once it returns. Both
442
+ // happened: the equal-share pass became structurally unfittable and fell through to deleting a whole
443
+ // result, and the initiator-recovery block committed 51 bytes over the limit (audit r11, r12).
444
+ // Affordability is decided BEFORE the reservation, because the reservation cannot represent a
445
+ // deficit. `Math.max(0, …)` turns "the note cannot be paid for" into "the note costs nothing", and
446
+ // the tail was appended regardless — so an envelope with 26 bytes free emitted a 246-byte note and
447
+ // overran by 220. Holding the tail out of `historyEntries` is what made that unrecoverable: no block
448
+ // below could see it to charge it.
449
+ //
450
+ // A trailing tool result hid this, because the abandon check's survival disjuncts rescue that shape.
451
+ // The exposed shape is a turn that does NOT end in a result — an ordinary user interjection after a
452
+ // repetitive stretch — where nothing else bounds the tail: 13 of 42 positions threw the
453
+ // non-retryable 400 with the note armed and none without it (audit r13).
454
+ //
455
+ // When it does not fit, the note is dropped. That is the unit's own priority order: a missing
456
+ // instruction is recoverable, a missing tool result restarts the loop this unit exists to end.
457
+ const syntheticEntries = history.slice(activeEnd);
458
+ const syntheticCountRaw = syntheticEntries.length;
459
+ const syntheticBytesRaw = syntheticEntries.reduce((sum, entry) => sum + entry.byteLength, 0);
460
+ // BOTH axes. The count conjunct was briefly dropped as inert on the reasoning that the count bound
461
+ // below keeps one result and therefore always leaves a slot — which is exactly wrong at
462
+ // `historyLimit === 1`, where that one free slot is the one the result takes. The note was then judged
463
+ // affordable on bytes, the reservation clamped to 0, and the append pushed full replay to 193 roots:
464
+ // four armed-only `CursorRootEnvelopeLimitError` throws at 191 system prompts, on both tails and both
465
+ // suffix widths, where the same request without the note assembled 192 and succeeded.
466
+ //
467
+ // The sweep that called it inert varied CARRIED roots on the checkpoint path, where the count-full
468
+ // disjunct abandons the checkpoint before `historyLimit` can reach 1. The reachable route is full
469
+ // replay with many system prompts, and full replay has no abandon branch to rescue it (audit r14).
470
+ const syntheticAffordable = historyBudget - syntheticBytesRaw >= 0
471
+ && historyLimit - syntheticCountRaw >= 1;
472
+ const syntheticCount = syntheticAffordable ? syntheticCountRaw : 0;
473
+ const syntheticBytes = syntheticAffordable ? syntheticBytesRaw : 0;
474
+ const historyLimitForReal = Math.max(0, historyLimit - syntheticCount);
475
+ const historyBudgetForReal = Math.max(0, historyBudget - syntheticBytes);
323
476
  const active = history
324
- .slice(activeStart)
325
- .map(entry => truncateToolResultBlob(entry, historyBudget))
477
+ .slice(activeStart, activeEnd)
478
+ .map(entry => truncateToolResultBlob(entry, historyBudgetForReal))
326
479
  .filter((entry): entry is RootBlobCandidate => entry !== null);
480
+ // Record the run BEFORE any pruning below can shrink it, so the abandon decision downstream compares
481
+ // against what pruning was asked to preserve rather than against a raw-message scan that cannot see
482
+ // this run's true width (audit r10).
483
+ activeMessageIndexes = history
484
+ .slice(activeStart, activeEnd)
485
+ .map(entry => entry.messageIndex)
486
+ .filter((index): index is number => index !== undefined);
327
487
  let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
328
- while (active.length > 1 && activeBytes > historyBudget) {
488
+ // Shrink every active result toward an equal share before dropping any of them. Review found
489
+ // that the previous `active.shift()` loop DELETED whole results: three ~220 KB results emitted
490
+ // only the last two, and `call_0` vanished with its tool call still in the transcript. A
491
+ // missing result is worse than a truncated one — the model sees a call it never got an answer
492
+ // to, which is the pairing break #1527 reports, and the caller cannot tell it happened.
493
+ if (active.length > 1 && activeBytes > historyBudgetForReal) {
494
+ const share = Math.floor(historyBudgetForReal / active.length);
495
+ for (let index = 0; index < active.length; index++) {
496
+ const entry = active[index];
497
+ if (!entry || entry.byteLength <= share) continue;
498
+ const shrunk = truncateToolResultBlob(entry, share);
499
+ if (shrunk) active[index] = shrunk;
500
+ }
501
+ activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
502
+ }
503
+ // Only when even an equal share cannot fit — the marker alone has a floor, so enough results
504
+ // still overflow — fall back to dropping the oldest.
505
+ while (active.length > 1 && activeBytes > historyBudgetForReal) {
329
506
  const dropped = active.shift();
330
507
  activeBytes -= dropped?.byteLength ?? 0;
331
508
  }
332
- if (active.length === 1 && active[0] && activeBytes > historyBudget) {
333
- const truncated = truncateToolResultBlob(active[0], historyBudget);
509
+ if (active.length === 1 && active[0] && activeBytes > historyBudgetForReal) {
510
+ const truncated = truncateToolResultBlob(active[0], historyBudgetForReal);
334
511
  if (truncated) {
335
512
  active[0] = truncated;
336
513
  activeBytes = truncated.byteLength;
@@ -339,20 +516,48 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
339
516
  activeBytes = 0;
340
517
  }
341
518
  }
519
+ // COUNT-bound the trailing run, not only its bytes. `historyLimit` already subtracts what the
520
+ // checkpoint carries, but until now it was consulted ONLY by the prior-history loop below, and
521
+ // `historyEntries` was assembled as `[...keptPrior, ...active]` with no count check at all. The
522
+ // shrink/drop loops above answer to `historyBudget` alone — `truncateToolResultBlob` makes a
523
+ // result smaller, it never removes one to free a root SLOT — so a parallel tool-call batch
524
+ // arrived unbounded: 190 carried roots plus a 3-result batch assembled 193 and threw the
525
+ // non-retryable 400 this unit exists to remove (audit r9). Sequential pairs hid it, because a
526
+ // trailing run of length 1 is the one case where the abandon test's `+ 1` is exactly right.
527
+ //
528
+ // Drop the OLDEST results first, matching the direction byte pressure already prunes, and keep
529
+ // at least one: a continuation with no result is worthless, and the abandon decision downstream
530
+ // reads `historyMessageIndexes` to notice exactly that and fall back to a full replay.
531
+ while (active.length > 1 && active.length > historyLimitForReal) {
532
+ const dropped = active.shift();
533
+ activeBytes -= dropped?.byteLength ?? 0;
534
+ }
342
535
 
343
536
  const prior = history.slice(0, activeStart);
344
537
  const keptPrior: RootBlobCandidate[] = [];
345
538
  let priorBytes = 0;
346
539
  // Take complete turns from the end: a turn starts at a user/developer root entry.
540
+ //
541
+ // Turn-granular admission needs a turn boundary to exist. A checkpoint suffix has NO user root at
542
+ // all — its initiating turn is inside the checkpoint — so `turnStart` walks to 0, the entire prior
543
+ // block becomes one all-or-nothing pseudo-turn, and the first budget overrun drops ALL of it.
544
+ // Measured on the checkpoint path with 8 pairs of 64 KiB results: 2 roots, unchanged by the orphan
545
+ // guard fix, because there was nothing left for that guard to strip. Admitting entry-by-entry keeps
546
+ // as much recent history as fits instead of none (devlog 260829 070, audit r8 finding 2).
347
547
  let i = prior.length - 1;
348
- while (i >= 0 && keptPrior.length + active.length < historyLimit) {
548
+ while (i >= 0 && keptPrior.length + active.length < historyLimitForReal) {
349
549
  let turnStart = i;
350
- while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1;
550
+ if (!suffixContinuesCoveredTurn) {
551
+ // Root-blob roles are a closed set of four (system, user, assistant, toolResult): a
552
+ // developer message is normalized to a user root upstream, so "user" IS the turn start.
553
+ // Review suspected a developer-role gap here; the type says it cannot occur.
554
+ while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1;
555
+ }
351
556
  const turn = prior.slice(turnStart, i + 1);
352
557
  const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0);
353
558
  if (
354
- keptPrior.length + active.length + turn.length > historyLimit
355
- || priorBytes + activeBytes + turnBytes > historyBudget
559
+ keptPrior.length + active.length + turn.length > historyLimitForReal
560
+ || priorBytes + activeBytes + turnBytes > historyBudgetForReal
356
561
  ) {
357
562
  break;
358
563
  }
@@ -361,14 +566,107 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
361
566
  i = turnStart - 1;
362
567
  }
363
568
 
569
+ const trailingSynthetic = syntheticAffordable ? syntheticEntries : [];
570
+ // Synthetic trailing roots — today only the repetition-breaker note — are held OUT of
571
+ // `historyEntries` while the blocks below decide what survives, and appended once at assembly.
572
+ //
573
+ // They were briefly appended here instead, and every subsequent block then had to recognise a tail
574
+ // it could not identify except by position. The initiator-recovery loop below could not: its floor
575
+ // is "stop when one entry is left", so with `[toolResult, note]` it counted the note as the
576
+ // survivor and shifted off the RESULT — a 600 KB tool output replaced by 193 bytes of note, leaving
577
+ // a prompt that instructs the model to change strategy while showing it nothing its command
578
+ // returned. Measured 166 of 432 byte-pressure configurations losing an answer that way. Keeping the
579
+ // tail out means those blocks stay purely about real history and cannot mistake one for the other;
580
+ // the budgets still charge for it, which is what stops it overrunning the envelope (audit r12).
364
581
  const historyEntries = [...keptPrior, ...active];
365
582
  // Guard against orphan assistant / toolResult at the start of the retained suffix.
366
- while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") {
367
- // Never drop the sole active tool-result block.
368
- if (historyEntries.length <= active.length) break;
369
- historyEntries.shift();
583
+ //
584
+ // Premised on `history` starting where the CONVERSATION starts: only then does a leading
585
+ // assistant/result entry mean its user turn was pruned. A checkpoint suffix breaks that premise —
586
+ // it begins at `suffixStart`, so its first entry is routinely the assistant message whose
587
+ // initiating user turn is inside the checkpoint. Running the loop there strips pair after pair
588
+ // until only `active` survives, because the `break` fires only once the survivors ARE `active`:
589
+ // measured 2 roots for 1, 2, 3 and 4 completed pairs in the suffix, so a growing conversation
590
+ // replayed a constant payload and the model never saw the output of the command it just ran
591
+ // (devlog 260829 070).
592
+ if (!suffixContinuesCoveredTurn) {
593
+ while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") {
594
+ // Never drop the sole active tool-result block.
595
+ if (historyEntries.length <= active.length) break;
596
+ historyEntries.shift();
597
+ }
370
598
  }
371
- selected = [...systemEntries, ...historyEntries];
599
+ // #1527: the surviving history must not begin with a tool result. Byte pressure can consume the
600
+ // whole budget with one large active result and drop the user turn that asked for it, and
601
+ // `conversationTurns()` then discards the result too for lack of a current turn — the wire
602
+ // request becomes system roots plus a bare result marker with no instruction, which a model
603
+ // answers in a handful of tokens.
604
+ //
605
+ // Recover the initiating root and pay for it out of the tool-result text instead.
606
+ //
607
+ // This needs no full-replay/checkpoint distinction, which is worth stating because the plan
608
+ // called for one. `activeStart > 0` confines the search to entries present in THIS call's
609
+ // history, so a checkpoint suffix can only ever recover a turn from inside its own uncovered
610
+ // slice — never one the checkpoint already carries. And for a suffix that does contain its
611
+ // initiating turn, recovery is exactly as necessary as it is for a full replay: mode-gating it
612
+ // would have recreated this defect for checkpoint continuations. Mutation testing found that;
613
+ // the mode flag could not be made to fail a test because it was never load-bearing.
614
+ if (
615
+ historyEntries.length > 0
616
+ && historyEntries[0]?.role === "toolResult"
617
+ && activeStart > 0
618
+ ) {
619
+ let initiatorIndex = activeStart - 1;
620
+ while (initiatorIndex >= 0 && history[initiatorIndex]?.role !== "user") {
621
+ initiatorIndex -= 1;
622
+ }
623
+ const initiator = initiatorIndex >= 0 ? history[initiatorIndex] : undefined;
624
+ if (initiator) {
625
+ const withInitiator = [initiator, ...historyEntries];
626
+ const initiatorBytes = withInitiator.reduce((sum, entry) => sum + entry.byteLength, 0);
627
+ if (withInitiator.length <= historyLimitForReal && initiatorBytes <= historyBudgetForReal) {
628
+ historyEntries.length = 0;
629
+ historyEntries.push(...withInitiator);
630
+ } else {
631
+ // Make room for the initiator instead of abandoning it. Review found that gating this on
632
+ // `historyEntries.length === 1` left the defect fully intact for the far more common
633
+ // multi-result shape: one system root plus 191 small trailing results already fills the
634
+ // count limit, so the initiator did not fit, the single-result branch did not apply, and
635
+ // the request went out as 191 bare results with nothing asking for them — inside the new
636
+ // envelope, so the guard could not catch it either.
637
+ //
638
+ // Drop the OLDEST results first, which is the same direction the byte-pressure loop above
639
+ // already prunes, then truncate whatever survives. An instruction with fewer or shorter
640
+ // results is answerable; results with no instruction are not.
641
+ const kept = [...historyEntries];
642
+ while (kept.length > 1 && kept.length + 1 > historyLimitForReal) kept.shift();
643
+ let keptBytes = kept.reduce((sum, entry) => sum + entry.byteLength, 0);
644
+ while (kept.length > 1 && initiator.byteLength + keptBytes > historyBudgetForReal) {
645
+ const dropped = kept.shift();
646
+ keptBytes -= dropped?.byteLength ?? 0;
647
+ }
648
+ if (kept.length === 1 && kept[0] && initiator.byteLength + keptBytes > historyBudgetForReal) {
649
+ const room = historyBudgetForReal - initiator.byteLength;
650
+ const shrunk = room > 0 ? truncateToolResultBlob(kept[0], room) : null;
651
+ if (shrunk) {
652
+ kept[0] = shrunk;
653
+ keptBytes = shrunk.byteLength;
654
+ }
655
+ }
656
+ // Only commit when the initiator genuinely fits alongside what is left. If the system
657
+ // prompt has consumed the budget so completely that not even a truncation marker fits,
658
+ // there is nothing honest to send here; the envelope guard downstream owns that case.
659
+ if (kept.length + 1 <= historyLimitForReal && initiator.byteLength + keptBytes <= historyBudgetForReal) {
660
+ historyEntries.length = 0;
661
+ historyEntries.push(initiator, ...kept);
662
+ }
663
+ }
664
+ }
665
+ }
666
+ // The synthetic tail goes on last, after every pruning decision is made, so telling the model to
667
+ // change strategy is not dropped by the walk that stopped ignoring it — and so no pruning block has
668
+ // to distinguish it from a real result by position. Its slots and bytes were already reserved above.
669
+ selected = [...systemEntries, ...historyEntries, ...trailingSynthetic];
372
670
  const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined);
373
671
  historyMessageStart = firstKept?.messageIndex ?? (messages.length);
374
672
  }
@@ -378,6 +676,16 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
378
676
  byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0),
379
677
  historyMessageStart,
380
678
  serialized: selected.map(entry => entry.serialized),
679
+ historyMessageIndexes: selected
680
+ .slice(systemEntryCount)
681
+ .map(entry => entry.messageIndex)
682
+ .filter((index): index is number => index !== undefined),
683
+ historyOutputElided: selected
684
+ .slice(systemEntryCount)
685
+ .filter(entry => entry.outputElided === true)
686
+ .map(entry => entry.messageIndex)
687
+ .filter((index): index is number => index !== undefined),
688
+ activeMessageIndexes,
381
689
  };
382
690
  }
383
691
 
@@ -571,12 +879,171 @@ function toolResultContentItems(
571
879
  return items;
572
880
  }
573
881
 
574
- function toolResultToText(message: OcxToolResultMessage): string {
882
+ /**
883
+ * Serialize tool-call arguments for the replayed transcript, or `undefined` when they cannot be
884
+ * serialized at all. `OcxToolCall.arguments` is always an object, but it originates in provider
885
+ * JSON, so a cyclic or BigInt-bearing value must degrade instead of throwing inside request
886
+ * encoding. The failure is reported as `undefined` rather than a marker string so callers can tell
887
+ * "these two argument sets are equal" apart from "neither could be read" — collapsing both onto one
888
+ * marker made every unserializable argument set compare equal to every other.
889
+ */
890
+ function serializeToolCallArguments(args: Record<string, unknown>): string | undefined {
891
+ try {
892
+ const serialized = JSON.stringify(args);
893
+ return typeof serialized === "string" ? serialized : undefined;
894
+ } catch {
895
+ return undefined;
896
+ }
897
+ }
898
+
899
+ /** Truncate to a byte budget without splitting a UTF-8 sequence. */
900
+ function truncateUtf8(text: string, maxBytes: number): string {
901
+ const encoded = encoder.encode(text);
902
+ if (encoded.byteLength <= maxBytes) return text;
903
+ let end = Math.max(0, maxBytes);
904
+ while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end -= 1;
905
+ return decoder.decode(encoded.subarray(0, end));
906
+ }
907
+
908
+ /**
909
+ * Rendered argument text for one invocation line, bounded independently of the result it describes.
910
+ *
911
+ * The invocation is CONTEXT for a replayed result; the result itself is the payload. Serializing
912
+ * arguments in full inverted that: a legitimate 600 KiB `write_file` argument consumed the entire
913
+ * `CURSOR_EXTERNAL_ROOT_BYTE_LIMIT` history budget, so `truncateToolResultBlob` kept the invocation
914
+ * prefix and cut the actual output away — reproducing the very orphaned-result failure this line
915
+ * exists to prevent. A bounded prefix still identifies the call (tool name plus the head of its
916
+ * arguments) while leaving the output room to survive.
917
+ */
918
+ function toolCallArgumentsText(args: Record<string, unknown>): string {
919
+ const serialized = serializeToolCallArguments(args);
920
+ if (serialized === undefined) return "[unserializable arguments]";
921
+ if (encoder.encode(serialized).byteLength <= CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) return serialized;
922
+ // The budget is the size of the RENDERED line, so the marker has to come out of it rather than be
923
+ // added on top: otherwise every truncated invocation exceeds the declared limit by the marker.
924
+ const marker = "…[arguments truncated]";
925
+ const markerBytes = encoder.encode(marker).byteLength;
926
+ const keep = Math.max(0, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT - markerBytes);
927
+ return `${truncateUtf8(serialized, keep)}${marker}`;
928
+ }
929
+
930
+ /**
931
+ * The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the
932
+ * result envelope.
933
+ *
934
+ * Why not a separate "[Tool Call]" entry: a model few-shot-mimics that marker and starts emitting
935
+ * later tool calls as inert text instead of real tool frames, which halts multi-tool continuations
936
+ * (363-B guard, tests/cursor-tool-continuation.test.ts). Why it must exist at all: without any
937
+ * record of the invocation, the replayed result is orphaned — its `call_id` refers to nothing the
938
+ * model can see — and live cursor/grok-4.6 turns re-ran commands that had already succeeded while
939
+ * narrating a phantom interrupt (devlog 260829 000_rca). A prose line inside the result satisfies
940
+ * both: the invocation is visible, but there is no call-shaped template to copy.
941
+ */
942
+ function toolInvocationLine(call: Extract<OcxAssistantContentPart, { type: "toolCall" }>): string {
943
+ return `invoked: ${namespacedToolName(call.namespace, call.name)} with ${toolCallArgumentsText(call.arguments)}`;
944
+ }
945
+
946
+ /**
947
+ * History position of each indexed call, keyed by the map `toolCallsByCallId` returned.
948
+ *
949
+ * A side table rather than a wider return type: the map is threaded through two builders and the
950
+ * checkpoint site, and changing its shape would touch every one of them for data only the bound reads.
951
+ */
952
+ const callPositions = new WeakMap<
953
+ Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>,
954
+ Map<string, number>
955
+ >();
956
+
957
+ /**
958
+ * The indexed call for `callId`, but only when it appears BEFORE `resultIndex` in history.
959
+ *
960
+ * `toolCallsByCallId` has no ordering constraint, so it would happily name a call that runs LATER than
961
+ * the result being labelled — a result whose own output is `EARLY-OUT` was measured on the shipped tree
962
+ * as `invoked: exec_command with {"cmd":"echo LATER"}`. That is the mislabel the index's own comment
963
+ * calls worse than no label, because nothing downstream can detect it (devlog 260829 060).
964
+ *
965
+ * `resultIndex` MUST be in full-history space. The checkpoint path replays a suffix and the turn
966
+ * builder starts at `historyMessageStart`, so a caller composes `knownCallsOffset + start + local`
967
+ * before calling; comparing a full-history call index against a slice-local result index silently
968
+ * drops legitimate pairings and re-creates the orphan #2910 fixed.
969
+ */
970
+ function callBefore(
971
+ calls: Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>> | undefined,
972
+ callId: string,
973
+ resultIndex: number,
974
+ ): Extract<OcxAssistantContentPart, { type: "toolCall" }> | undefined {
975
+ const call = calls?.get(callId);
976
+ if (!call || !calls) return undefined;
977
+ const position = callPositions.get(calls)?.get(callId);
978
+ if (position === undefined || position >= resultIndex) return undefined;
979
+ return call;
980
+ }
981
+
982
+ /**
983
+ * Index assistant tool calls by decoded call id so a replayed result can name its invocation.
984
+ *
985
+ * A call id is supposed to be unique, but nothing upstream guarantees it across a long history, and
986
+ * `decodeCursorCallId` can map distinct wire ids onto the same decoded id. Two calls sharing one id
987
+ * would make the LAST one describe every result bearing it, so an early result could be labelled
988
+ * with a later command — a wrong invocation is worse than none, since it is the kind of mislabel the
989
+ * model cannot detect. Keep the FIRST call for an id (results follow their call, so the first
990
+ * binding is the one an earlier result belongs to) and drop the ambiguous id entirely once a second
991
+ * distinct call claims it, which degrades to the honest no-invocation-line path.
992
+ */
993
+ function toolCallsByCallId(messages: readonly OcxMessage[]): Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>> {
994
+ const calls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
995
+ const ambiguous = new Set<string>();
996
+ const positions = new Map<string, number>();
997
+ for (let index = 0; index < messages.length; index++) {
998
+ const message = messages[index];
999
+ if (!message || message.role !== "assistant" || !Array.isArray(message.content)) continue;
1000
+ for (const part of message.content) {
1001
+ if (part.type !== "toolCall") continue;
1002
+ const callId = decodeCursorCallId(part.id);
1003
+ if (ambiguous.has(callId)) continue;
1004
+ const existing = calls.get(callId);
1005
+ if (!existing) {
1006
+ calls.set(callId, part);
1007
+ positions.set(callId, index);
1008
+ continue;
1009
+ }
1010
+ // Same id, and not the same invocation: neither claim can be trusted for a given result.
1011
+ // Identity is the FULL namespaced name — `one__read` and `two__read` are different tools, and
1012
+ // comparing bare `name` labelled both results with the first namespace. Arguments count as
1013
+ // different whenever either side cannot be serialized: two distinct unserializable argument
1014
+ // sets are not evidence of the same call, so they must not compare equal.
1015
+ const existingArgs = serializeToolCallArguments(existing.arguments);
1016
+ const partArgs = serializeToolCallArguments(part.arguments);
1017
+ const sameInvocation = namespacedToolName(existing.namespace, existing.name) === namespacedToolName(part.namespace, part.name)
1018
+ && existingArgs !== undefined
1019
+ && partArgs !== undefined
1020
+ && existingArgs === partArgs;
1021
+ if (!sameInvocation) {
1022
+ calls.delete(callId);
1023
+ positions.delete(callId);
1024
+ ambiguous.add(callId);
1025
+ }
1026
+ }
1027
+ }
1028
+ callPositions.set(calls, positions);
1029
+ return calls;
1030
+ }
1031
+
1032
+ /**
1033
+ * The replayed text of one tool result. When `call` is supplied, the invocation that produced it is
1034
+ * named inline so the result is not orphaned; when it is absent (no match, or an ambiguous call id)
1035
+ * the envelope is emitted unchanged rather than guessing.
1036
+ */
1037
+ function toolResultToText(
1038
+ message: OcxToolResultMessage,
1039
+ call?: Extract<OcxAssistantContentPart, { type: "toolCall" }>,
1040
+ ): string {
575
1041
  const normalized = normalizedToolResult(message, contentToText(message.content));
576
1042
  return [
577
1043
  "[tool_result]",
578
1044
  `call_id: ${decodeCursorCallId(message.toolCallId)}`,
579
1045
  `name: ${namespacedToolName(message.toolNamespace, message.toolName)}`,
1046
+ ...(call ? [toolInvocationLine(call)] : []),
580
1047
  `is_error: ${normalized.isError}`,
581
1048
  "output:",
582
1049
  normalized.text,
@@ -710,6 +1177,10 @@ function conversationTurns(
710
1177
  request: CursorRunRequest,
711
1178
  requestScope: CursorBlobRequestScopeToken,
712
1179
  historyMessageStart = 0,
1180
+ /** Calls indexed from the FULL history; see {@link rootPromptMessages}. */
1181
+ knownCalls?: Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>,
1182
+ /** Full-history index of `rawMessages[0]`; see {@link rootPromptMessages}. */
1183
+ knownCallsOffset = 0,
713
1184
  ): Uint8Array[] {
714
1185
  const messages = request.rawMessages;
715
1186
  if (!messages?.length) return [];
@@ -717,6 +1188,7 @@ function conversationTurns(
717
1188
  const externalModel = isCursorExternalWireModel(request.modelId);
718
1189
  const historyEnd = messages.at(-1)?.role === "toolResult" ? messages.length : Math.max(0, end);
719
1190
  const start = externalModel ? Math.max(0, historyMessageStart) : 0;
1191
+ const turnCalls = externalModel ? (knownCalls ?? toolCallsByCallId(messages)) : undefined;
720
1192
  const turns: Uint8Array[] = [];
721
1193
  let current: { userMessage: Uint8Array; steps: Uint8Array[] } | undefined;
722
1194
  const pendingToolCalls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
@@ -733,7 +1205,15 @@ function conversationTurns(
733
1205
  pendingToolCalls.clear();
734
1206
  };
735
1207
 
736
- for (const message of messages.slice(start, historyEnd)) {
1208
+ const walked = messages.slice(start, historyEnd);
1209
+ for (let w = 0; w < walked.length; w++) {
1210
+ const message = walked[w];
1211
+ // `for…of` gave this for free; keep it explicit so the indexed loop behaves identically.
1212
+ if (!message) continue;
1213
+ // Full-history position of this message: the slice offset the caller passed, plus where this
1214
+ // loop starts inside `rawMessages`, plus the local step. All three terms are needed — dropping
1215
+ // `start` still passes every test except the checkpoint-plus-pruned-root case (devlog 060).
1216
+ const fullIndex = knownCallsOffset + start + w;
737
1217
  if (message.role === "assistant") {
738
1218
  if (!current) continue;
739
1219
  for (const part of message.content) {
@@ -768,10 +1248,14 @@ function conversationTurns(
768
1248
  // reported repro path for empty Computer Use results.
769
1249
  const normalized = normalizedToolResult(message, contentToText(message.content));
770
1250
  const prefix = normalized.isError ? "[Tool Error]" : "[Tool Result]";
1251
+ // Name the invocation here as well, for the same reason the root replay does: a result with
1252
+ // no visible originating call reads as an interrupted attempt (devlog 260829 000_rca).
1253
+ const call = callBefore(turnCalls, decodeCursorCallId(message.toolCallId), fullIndex);
1254
+ const invocation = call ? `${toolInvocationLine(call)}\n` : "";
771
1255
  current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
772
1256
  message: {
773
1257
  case: "assistantMessage",
774
- value: create(AssistantMessageSchema, { text: `${prefix}\n${normalized.text}` }),
1258
+ value: create(AssistantMessageSchema, { text: `${prefix}\n${invocation}${normalized.text}` }),
775
1259
  },
776
1260
  })), requestScope));
777
1261
  continue;
@@ -929,9 +1413,104 @@ function buildPreparedCursorRunRequest(
929
1413
  system: [],
930
1414
  rawMessages: request.rawMessages.slice(suffixStart),
931
1415
  };
932
- const suffixRoots = rootPromptMessages(suffixRequest, requestScope);
933
- const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart);
1416
+ // Index calls from the FULL history, not the suffix: the cut can fall between a call and
1417
+ // its result, and a result replayed without its invocation is the orphaned-result defect.
1418
+ const fullHistoryCalls = toolCallsByCallId(request.rawMessages);
1419
+ // What the checkpoint already spends against the envelope. An id the local store never held
1420
+ // still occupies a root slot, so it counts toward the COUNT budget with a zero byte
1421
+ // contribution rather than being skipped entirely.
1422
+ let carriedBytes = 0;
1423
+ for (const blobId of conversationState.rootPromptMessagesJson) {
1424
+ carriedBytes += cursorBlobByteLength(blobId) ?? 0;
1425
+ }
1426
+ const carriedRoots = {
1427
+ count: conversationState.rootPromptMessagesJson.length,
1428
+ byteLength: carriedBytes,
1429
+ };
1430
+ // A checkpoint can be so large that nothing useful is left for the suffix. Pruning to fit then
1431
+ // emits the covered prefix and silently drops the uncovered messages — the exact failure this unit
1432
+ // exists to remove — while throwing would hand the caller a non-retryable 400. Abandon the
1433
+ // checkpoint instead: full replay rebuilds a self-contained prompt and prunes it coherently
1434
+ // (devlog 260829 070, audit r8).
1435
+ //
1436
+ // The decision is made on the RESULT of pruning, not on a byte threshold. A threshold has to
1437
+ // predict what pruning will do, and the first attempt mispredicted it: comparing carried bytes
1438
+ // against the raw limit left a band of a few hundred bytes below it where the checkpoint was kept,
1439
+ // the suffix budget collapsed, and the newest tool result vanished. Adding `systemBytes` moved the
1440
+ // band without closing it. Asking pruning what survived cannot drift from what pruning does.
1441
+ const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart, carriedRoots);
934
1442
  const suffixSystemCount = systemPromptBlobs(suffixRequest).length;
1443
+ // A tool continuation whose own result did not survive is worthless: that result is the whole
1444
+ // reason the turn exists. "Kept SOMETHING" is not enough either — inside the band this fix first
1445
+ // missed, pruning kept the assistant narration and dropped the result, which is worse than keeping
1446
+ // nothing because the model then sees a call it never got an answer to. The test is therefore on
1447
+ // the LAST replayed message specifically, identified by its index rather than its content.
1448
+ const suffixMessages = suffixRequest.rawMessages ?? [];
1449
+ const lastSuffixIndex = suffixMessages.length - 1;
1450
+ // Only models whose results are replayed as root text can answer this question. A native resume
1451
+ // model gets its result through server-side turn state, so `rootPromptMessages` never emits a
1452
+ // toolResult root for it (`echoToolResultInRoot` is false) — asking whether that root survived
1453
+ // returns "no" every single time, and an unguarded check therefore threw away the checkpoint of
1454
+ // every native continuation, including the default `cursor/auto`. The checkpoint is the only place
1455
+ // pendingToolCalls, readPaths and previousWorkspaceUris live, and full replay does not rebuild
1456
+ // them, so that was this unit's own defect relocated to the native path (audit r8 round 3).
1457
+ const resultReplayedAsRoot = cursorNeedsExternalToolContinuation(request.modelId);
1458
+ // Kept, and kept with its output: a root reduced to the truncation marker alone answers the call
1459
+ // with nothing, which is the same failure as dropping it. `outputElided` is set at the one place
1460
+ // that can produce it, so this needs no threshold to guess at.
1461
+ //
1462
+ // Every trailing result is checked, not just the last one. Parallel tool calls land as a run of
1463
+ // results, and under byte pressure the older ones were the ones getting emptied: measured a prompt
1464
+ // carrying three calls and one answer, which is the shape this comment calls worse than keeping
1465
+ // nothing. `historyOutputElided` already knew; only the last index was being read (audit r8
1466
+ // round 3).
1467
+ // The run is read from PRUNING's own report, not re-derived from `suffixMessages`. The two spaces
1468
+ // disagree: root space skips an assistant message that emitted no root — a bare tool call with no
1469
+ // narration, or whitespace-only text — so two sequentially-executed results sit adjacent as roots
1470
+ // while a raw-message scan still sees a trailing run of one. Pruning's count bound acts on the root
1471
+ // run, so a raw-space scan could not see the result it dropped: measured at 190 carried roots with
1472
+ // bare-call pairs, the older answer vanished from the wire entirely while this check reported
1473
+ // "kept" and the checkpoint was retained — an unanswered call, which the comment above rightly
1474
+ // calls worse than keeping nothing (audit r10).
1475
+ //
1476
+ // `activeMessageIndexes` is that run as pruning saw it, recorded before pruning could shrink it.
1477
+ // Falling back to the raw-space scan when it is empty keeps the full-replay and native shapes,
1478
+ // which never populate it, behaving exactly as before.
1479
+ let trailingStart = suffixMessages.length;
1480
+ while (trailingStart > 0 && suffixMessages[trailingStart - 1]?.role === "toolResult") trailingStart -= 1;
1481
+ const trailingIndexes = suffixRoots.activeMessageIndexes.length > 0
1482
+ ? suffixRoots.activeMessageIndexes
1483
+ : suffixMessages.slice(trailingStart).map((_, offset) => trailingStart + offset);
1484
+ const keptEnough = trailingIndexes.every(index =>
1485
+ suffixRoots.historyMessageIndexes.includes(index)
1486
+ && !suffixRoots.historyOutputElided.includes(index));
1487
+ const suffixKeptItsResult = !resultReplayedAsRoot
1488
+ || suffixMessages[lastSuffixIndex]?.role !== "toolResult"
1489
+ || keptEnough;
1490
+ if (
1491
+ carriedRoots.count + suffixSystemCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT
1492
+ // Both survival disjuncts are about a REPLAYED root going missing, so both are meaningless for a
1493
+ // model whose results never become roots. Gating only the second one still discarded every native
1494
+ // checkpoint whose assistant turn was a bare tool call: no text root, no result root, zero history
1495
+ // roots, condition true (audit r8 round 4). The count-full disjunct above stays ungated — it is a
1496
+ // real envelope fact, independent of who echoes results.
1497
+ || (resultReplayedAsRoot && suffixRoots.ids.length <= suffixSystemCount)
1498
+ || !suffixKeptItsResult
1499
+ ) {
1500
+ conversationState = undefined;
1501
+ continuationMode = "full-replay";
1502
+ checkpointInvalidationReason = "envelope_exhausted";
1503
+ // NOT propagated to the checkpoint store, and deliberately so after measuring the attempt.
1504
+ // `src/adapters/cursor.ts` drops a dead checkpoint by reading
1505
+ // `request.checkpointInvalidationReason`, but `live-transport.ts` prepares a SPREAD COPY of that
1506
+ // request, so writing the field here lands on the copy and the caller never sees it — measured
1507
+ // inert, `outer.checkpointInvalidationReason` stayed undefined. Reaching the store needs the
1508
+ // reason threaded back through `PreparedCursorRunRequest`, which is a signature change on the
1509
+ // shared prepare path and belongs to its own phase. The cost of not doing it is bounded: the
1510
+ // checkpoint is re-decoded and re-abandoned each turn until TTL, which is wasted work rather
1511
+ // than wrong output (audit r8 rounds 3 and 4).
1512
+ } else {
1513
+ const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart);
935
1514
  const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount);
936
1515
  const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount);
937
1516
  conversationState = create(ConversationStateStructureSchema, {
@@ -950,7 +1529,11 @@ function buildPreparedCursorRunRequest(
950
1529
  byteLength: suffixRoots.byteLength,
951
1530
  historyMessageStart: suffixRoots.historyMessageStart,
952
1531
  serialized: suffixHistorySerialized,
1532
+ historyMessageIndexes: suffixRoots.historyMessageIndexes,
1533
+ historyOutputElided: suffixRoots.historyOutputElided,
1534
+ activeMessageIndexes: suffixRoots.activeMessageIndexes,
953
1535
  };
1536
+ }
954
1537
  }
955
1538
  } catch {
956
1539
  checkpointInvalidationReason = "decode_failed";
@@ -976,6 +1559,40 @@ function buildPreparedCursorRunRequest(
976
1559
  // filtered definitions the wire carries. Both helpers are pure.
977
1560
  const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice);
978
1561
  const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice);
1562
+ // The envelope is measured HERE, on the final root set, and nowhere else.
1563
+ //
1564
+ // `rootPromptMessages` cannot do it: it sees only a checkpoint suffix, so 192 checkpoint roots
1565
+ // plus a two-root suffix passed its per-call check and emitted 194 roots; and its empty-history
1566
+ // early return skips the pruning branch entirely, which let 193 system prompts through. Both are
1567
+ // downstream of this point, which is the first place the wire content is fully known (#1527).
1568
+ //
1569
+ // The same measurement feeds the diagnostic below, so telemetry cannot disagree with the guard.
1570
+ //
1571
+ // Roots carried inside a decoded checkpoint need not be in the local store — Cursor minted some
1572
+ // of them, and a resumed conversation legitimately references ids this process never wrote. So an
1573
+ // unmeasurable root is counted, not fatal: the COUNT limit still binds it (that is the 194-root
1574
+ // case), and `unmeasuredRoots` records that the byte total is a floor rather than a total. An
1575
+ // earlier fail-closed version broke three passing checkpoint tests, which is the evidence that
1576
+ // failing closed here would reject working continuation.
1577
+ const measuredRootCount = conversationState.rootPromptMessagesJson.length;
1578
+ let measuredRootBytes = 0;
1579
+ let unmeasuredRoots = 0;
1580
+ for (const blobId of conversationState.rootPromptMessagesJson) {
1581
+ const size = cursorBlobByteLength(blobId);
1582
+ if (size === null) unmeasuredRoots += 1;
1583
+ else measuredRootBytes += size;
1584
+ }
1585
+ if (
1586
+ isCursorExternalWireModel(request.modelId)
1587
+ && (measuredRootCount > CURSOR_EXTERNAL_ROOT_BLOB_LIMIT || measuredRootBytes > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT)
1588
+ ) {
1589
+ throw new CursorRootEnvelopeLimitError(
1590
+ measuredRootCount,
1591
+ measuredRootBytes,
1592
+ CURSOR_EXTERNAL_ROOT_BLOB_LIMIT,
1593
+ CURSOR_EXTERNAL_ROOT_BYTE_LIMIT,
1594
+ );
1595
+ }
979
1596
  debugProviderDiagnostic("cursor", "run-request", {
980
1597
  wireModel: request.modelId,
981
1598
  action: actionCase,
@@ -987,8 +1604,13 @@ function buildPreparedCursorRunRequest(
987
1604
  checkpointPresent: continuationMode === "checkpoint",
988
1605
  checkpointBytes: continuationMode === "checkpoint" ? request.checkpointBytes?.byteLength : undefined,
989
1606
  checkpointInvalidationReason,
990
- rootBlobs: conversationState.rootPromptMessagesJson.length,
991
- rootBytes: rootPromptMessagesState?.byteLength ?? 0,
1607
+ rootBlobs: measuredRootCount,
1608
+ // Was `rootPromptMessagesState?.byteLength ?? 0`, which reported 0 for a pure checkpoint and,
1609
+ // for a suffix, counted a synthetic system root that had already been sliced off.
1610
+ rootBytes: measuredRootBytes,
1611
+ // Non-zero means rootBytes is a floor: that many roots came from a checkpoint the local store
1612
+ // never held. Recorded rather than hidden, so an operator reading the number knows which it is.
1613
+ ...(unmeasuredRoots > 0 ? { unmeasuredRoots } : {}),
992
1614
  turnBlobs: conversationState.turns.length,
993
1615
  tools: request.tools?.length ?? 0,
994
1616
  });