@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925

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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -22,6 +22,12 @@ import { validatesRestoredValue } from "./command-code-restored-schema";
22
22
  * A text block that opens with `<tool_call>` is therefore held instead of streamed. It is dropped
23
23
  * when a native call proves it is a duplicate, or restored on an eligible clean MiMo finish when
24
24
  * it names a declared tool with arguments that fit its schema. Other markup is released unchanged.
25
+ * MiMo can also append the markup after ordinary prose inside one text block; the stream filter
26
+ * splits such a delta at the marker and holds the markup part the same way (#5698; a marker split
27
+ * across deltas after prose is still released as text).
28
+ * A malformed envelope that still opens and closes around a declared function name, but that the
29
+ * strict parser rejects, is dropped instead of released when the native call for that same function
30
+ * arrives, and on the clean-finish path, so the echo never reaches the client.
25
31
  * Later text waits behind unresolved markup within the same byte bound.
26
32
  */
27
33
 
@@ -81,6 +87,21 @@ function tryJson(value: string): unknown {
81
87
  try { return JSON.parse(value); } catch { return undefined; }
82
88
  }
83
89
 
90
+ /**
91
+ * The declared function name of an envelope echo the strict parser rejected, or undefined when the
92
+ * text is anything else: malformed parameter tags, a missing `</function>`, garbage inside the body.
93
+ * Opening and closing as an envelope with a known function name is enough to keep it off the client
94
+ * once the native duplicate call for that same name — which carries the canonical execution —
95
+ * arrives; a native call for another tool says nothing about this envelope.
96
+ */
97
+ function looseEnvelopeName(text: string, declared: CommandCodeDeclaredTools | undefined): string | undefined {
98
+ const trimmed = text.trim();
99
+ if (!trimmed.startsWith(TOOL_CALL_MARKER) || !trimmed.endsWith("</tool_call>")) return undefined;
100
+ if (trimmed.slice(TOOL_CALL_MARKER.length).includes(TOOL_CALL_MARKER)) return undefined;
101
+ const fn = /<function=([^>\s]+)>/.exec(trimmed);
102
+ return fn !== null && (declared?.has(fn[1]!) ?? false) ? fn[1]! : undefined;
103
+ }
104
+
84
105
  function deepEqual(left: unknown, right: unknown): boolean {
85
106
  if (Object.is(left, right)) return true;
86
107
  if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
@@ -251,7 +272,10 @@ const encoder = new TextEncoder();
251
272
  export class CommandCodeToolTextFilter {
252
273
  private readonly openInputs = new Map<string, string>();
253
274
  private readonly blocks = new Map<string, TextBlock>();
254
- /** Only nonempty blocks still deciding whether their text is markup need boundary visits. */
275
+ /**
276
+ * Only blocks still deciding whether their text is markup (state "probing") need boundary visits;
277
+ * a held block is deliberately absent, so no interleaved event can interrupt it.
278
+ */
255
279
  private readonly activeProbes = new Map<string, TextBlock>();
256
280
  /** Held blocks in arrival order, including ended ones awaiting a verdict. */
257
281
  private held: TextBlock[] = [];
@@ -283,6 +307,13 @@ export class CommandCodeToolTextFilter {
283
307
  for (const [key, block] of this.activeProbes) {
284
308
  this.queueOperations++;
285
309
  if (key === exceptKey) continue;
310
+ // Guard only: a held block is not tracked here (textDelta adds probing blocks and drops them
311
+ // from the map on the transition to held). It must stay held until settle, in arrival order —
312
+ // interrupting it on interleaved events (reasoning deltas, other blocks, the native call
313
+ // itself) cleared complete and malformed envelopes alike and released the echoed call as text.
314
+ // The queued-byte bound (makeRoom) still caps memory and wait, flushing everything as text if a
315
+ // held envelope never resolves while the stream keeps producing.
316
+ if (block.state !== "probing") continue;
286
317
  this.activeProbes.delete(key);
287
318
  block.interrupted = true;
288
319
  block.state = "queued";
@@ -310,8 +341,44 @@ export class CommandCodeToolTextFilter {
310
341
  block = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
311
342
  this.blocks.set(key, block);
312
343
  }
313
- if (block.state === "streaming" && this.head === this.pending.length) {
314
- return [...boundaryEvents, { type: "text_delta", text }];
344
+ // MiMo can append tool-call markup after ordinary prose inside one text block. The probe below
345
+ // only recognizes a block that opens with the marker, so a marker arriving after prose would
346
+ // reach the client (captured 2026-09-23 from xiaomi/mimo-v2.6-pro: prose, then
347
+ // "<tool_call><function=exec>..." echoed by the gateway as one text delta). Split the delta at
348
+ // the marker: prose keeps its queued or streamed path, the markup starts a fresh probe block
349
+ // and follows the normal hold-and-restore route. A probing block that has consumed nothing but
350
+ // whitespace keeps its probe instead, because that probe already holds the marker.
351
+ if (block.state !== "held") {
352
+ const markerIndex = text.indexOf(TOOL_CALL_MARKER);
353
+ const whitespaceLead = markerIndex > 0 && block.state === "probing" && block.probe === ""
354
+ && text.slice(0, markerIndex).trim() === "";
355
+ // markerIndex === 0 on a probing block is the ordinary hold path; on any other state the
356
+ // block is ordinary text and the marker must still start a fresh probe block.
357
+ if (!whitespaceLead && (markerIndex > 0 || (markerIndex === 0 && block.state !== "probing"))) {
358
+ const prose = markerIndex > 0 ? text.slice(0, markerIndex) : "";
359
+ const marked = markerIndex > 0 ? text.slice(markerIndex) : text;
360
+ let proseEvents: AdapterEvent[] = [];
361
+ if (prose) {
362
+ if (block.state === "streaming" && this.head === this.pending.length) {
363
+ proseEvents = [{ type: "text_delta", text: prose }];
364
+ } else {
365
+ if (block.state === "dropped" || block.state === "streaming") {
366
+ block = { id: key, markupParts: [], probe: "", bytes: 0, state: "queued", ended: false, interrupted: true, candidates: new Set() };
367
+ this.blocks.set(key, block);
368
+ }
369
+ proseEvents = this.queueProseDelta(block, prose);
370
+ this.probeBlockText(block, prose);
371
+ }
372
+ }
373
+ if (block.state === "queued") block.state = "streaming";
374
+ this.activeProbes.delete(key);
375
+ const probeBlock: TextBlock = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) };
376
+ this.blocks.set(key, probeBlock);
377
+ return [...boundaryEvents, ...proseEvents, ...this.textDelta(id, marked)];
378
+ }
379
+ if (markerIndex === -1 && block.state === "streaming" && this.head === this.pending.length) {
380
+ return [...boundaryEvents, { type: "text_delta", text }];
381
+ }
315
382
  }
316
383
  // Once a duplicate is dropped, later text is a new chunk at its own wire position.
317
384
  if (block.state === "dropped" || block.state === "streaming") {
@@ -320,7 +387,7 @@ export class CommandCodeToolTextFilter {
320
387
  }
321
388
  const preceding = this.makeRoom(encoder.encode(text).byteLength);
322
389
  this.retain(block, text);
323
- if (block.state === "probing" || block.state === "held") this.activeProbes.set(key, block);
390
+ if (block.state === "probing") this.activeProbes.set(key, block);
324
391
  const bytes = encoder.encode(text).byteLength;
325
392
  const tail = this.pending.at(-1);
326
393
  if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) {
@@ -332,24 +399,8 @@ export class CommandCodeToolTextFilter {
332
399
  this.queueOperations++;
333
400
  }
334
401
  this.queuedBytes += bytes;
335
- if (block.state === "probing") {
336
- for (const char of text) {
337
- if (!block.probe && char.trim() === "") continue;
338
- block.probe += char;
339
- if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
340
- block.state = "queued";
341
- block.markupParts = [];
342
- break;
343
- }
344
- if (block.probe === TOOL_CALL_MARKER) {
345
- block.state = "held";
346
- block.probe = "";
347
- this.held.push(block);
348
- break;
349
- }
350
- }
351
- }
352
- if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key);
402
+ this.probeBlockText(block, text);
403
+ if (block.state !== "probing") this.activeProbes.delete(key);
353
404
  return [...boundaryEvents, ...preceding, ...this.limitPending()];
354
405
  }
355
406
 
@@ -411,7 +462,8 @@ export class CommandCodeToolTextFilter {
411
462
  remaining.push(block);
412
463
  continue;
413
464
  }
414
- const markup = parseToolCallMarkup(block.markupParts.join(""));
465
+ const text = block.markupParts.join("");
466
+ const markup = parseToolCallMarkup(text);
415
467
  if (markup && markup.name === name && markupMatchesInput(markup, input)) {
416
468
  this.drop(block);
417
469
  block.state = "dropped";
@@ -420,6 +472,16 @@ export class CommandCodeToolTextFilter {
420
472
  }
421
473
  block.candidates.delete(id);
422
474
  if (block.candidates.size === 0) {
475
+ // A malformed envelope cannot match a native input, but it is still an envelope: when the
476
+ // native call is for the function it declares, that call carries the execution, so drop the
477
+ // echo rather than releasing it as text. A native call for any other tool proves nothing
478
+ // about this envelope, so it keeps the release-as-text path below.
479
+ if (markup === undefined && looseEnvelopeName(text, this.declared) === name) {
480
+ this.drop(block);
481
+ block.state = "dropped";
482
+ this.activeProbes.delete(block.id);
483
+ continue;
484
+ }
423
485
  block.state = "queued";
424
486
  block.markupParts = [];
425
487
  this.activeProbes.delete(block.id);
@@ -472,6 +534,16 @@ export class CommandCodeToolTextFilter {
472
534
  events.push({ type: "tool_call_end" });
473
535
  salvaged = true;
474
536
  lastTextBlock = undefined;
537
+ } else if (restore && !block.interrupted && markup === undefined
538
+ && looseEnvelopeName(block.markupParts.join(""), this.declared) !== undefined) {
539
+ // A malformed envelope is still an envelope: the native duplicate (observed in every
540
+ // capture) carries the call, so the echo is dropped rather than rendered as text.
541
+ // Releasing it would put the raw markup back on screen; restoring it could execute a
542
+ // second time alongside the native call. The parser must have rejected the text, so an
543
+ // envelope that parses but does not fit its schema keeps the release-as-text contract.
544
+ this.drop(block);
545
+ block.state = "dropped";
546
+ lastTextBlock = undefined;
475
547
  } else {
476
548
  block.state = "queued";
477
549
  block.markupParts = [];
@@ -501,6 +573,44 @@ export class CommandCodeToolTextFilter {
501
573
  block.bytes += bytes;
502
574
  }
503
575
 
576
+ /** The incremental open-of-block probe: decide whether the block's text is tool-call markup. */
577
+ private probeBlockText(block: TextBlock, text: string): void {
578
+ if (block.state !== "probing") return;
579
+ for (const char of text) {
580
+ if (!block.probe && char.trim() === "") continue;
581
+ block.probe += char;
582
+ if (!TOOL_CALL_MARKER.startsWith(block.probe)) {
583
+ block.state = "queued";
584
+ block.markupParts = [];
585
+ break;
586
+ }
587
+ if (block.probe === TOOL_CALL_MARKER) {
588
+ block.state = "held";
589
+ block.probe = "";
590
+ this.held.push(block);
591
+ break;
592
+ }
593
+ }
594
+ }
595
+
596
+ /** Route ordinary prose through the queued wire path (shared by the mid-stream marker split). */
597
+ private queueProseDelta(block: TextBlock, prose: string): AdapterEvent[] {
598
+ const preceding = this.makeRoom(encoder.encode(prose).byteLength);
599
+ this.retain(block, prose);
600
+ const bytes = encoder.encode(prose).byteLength;
601
+ const tail = this.pending.at(-1);
602
+ if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) {
603
+ tail.parts.push(prose);
604
+ tail.bytes += bytes;
605
+ this.queueOperations++;
606
+ } else {
607
+ this.pending.push({ kind: "chunk", block, parts: [prose], bytes });
608
+ this.queueOperations++;
609
+ }
610
+ this.queuedBytes += bytes;
611
+ return preceding;
612
+ }
613
+
504
614
  private drop(block: TextBlock): void {
505
615
  this.budget.releaseRetained(block.bytes, { kind: "live_transient" });
506
616
  this.queuedBytes = Math.max(0, this.queuedBytes - block.bytes);
@@ -184,19 +184,15 @@ export const CURSOR_CAPABILITIES: Record<string, CursorCapability> = {
184
184
  thinkingFast: { levels: FULL, order: T },
185
185
  },
186
186
  },
187
- // 260923 Claude Opus 5.5: cursor.com/docs/models/claude-opus-5-5 publishes the id
188
- // `claude-opus-5-5`, a thinking variant, a `claude-opus-5-5-fast` tier and a 1M max context.
189
- // The ladders mirror the measured claude-opus-5 rows (fast stops at high) until the live
190
- // GetUsableModels roster is dumped; the live filter drops any id the account cannot use.
187
+ // 260923 Claude Opus 5.5: live GetUsableModels roster advertises flat effort-suffixed
188
+ // wire ids (claude-opus-5-5-{low..max} and -fast) rather than a thinking variant.
191
189
  "claude-opus-5-5": {
192
190
  displayName: "Claude Opus 5.5",
193
191
  window: CONTEXT_1M,
194
- defaultVariant: "thinking",
192
+ defaultVariant: "regular",
195
193
  variants: {
196
194
  regular: { levels: FULL },
197
- thinking: { levels: FULL, order: T },
198
- fast: { levels: ["low", "medium", "high"] },
199
- thinkingFast: { levels: FULL, order: T },
195
+ fast: { levels: FULL },
200
196
  },
201
197
  },
202
198
  "glm-5.2": {
@@ -270,15 +270,20 @@ export function isCursorExternalWireModel(modelId: string): boolean {
270
270
  * Observed on live Cursor Connect traffic (2026-08-20): `composer-2.5` (the
271
271
  * standard, non-fast build) resumes a tool-result turn with server-side native
272
272
  * tool calls (read/grep/exec) instead of answering, or completes with zero text
273
- * (empty `content` + `stop`). `composer-2.5-fast` answers correctly on the same
274
- * resumeAction path, so only the affected id is listed here. Sending the same
275
- * continuation as an explicit user message (external path) makes the model
276
- * answer reliably.
273
+ * (empty `content` + `stop`). Sending the same continuation as an explicit user
274
+ * message (external path) makes the model answer reliably.
275
+ *
276
+ * `composer-2.5-fast` was left on resumeAction after that same capture because
277
+ * it answered on that path then. A later live proxy log (2026-09-21) shows
278
+ * `cursor/composer-2.5-fast completed with no output text and no tool call` on
279
+ * the Chat Completions → Responses bridge used by OpenAI-compatible clients
280
+ * (GJC executor). That is the same empty-stop shape, so the fast id uses the
281
+ * external continuation path too.
277
282
  */
278
283
  export function cursorNeedsExternalToolContinuation(modelId: string): boolean {
279
284
  if (isCursorExternalWireModel(modelId)) return true;
280
285
  const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase();
281
- return wire === "composer-2.5";
286
+ return wire === "composer-2.5" || wire === "composer-2.5-fast";
282
287
  }
283
288
 
284
289
  function stripCursorEffortSuffix(wireModelId: string): string {
@@ -36,9 +36,9 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
36
36
  "claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"],
37
37
  "claude-opus-5": ["low", "medium", "high", "xhigh", "max"],
38
38
  "claude-opus-5-fast": ["low", "medium", "high"],
39
- // 260923 Opus 5.5 (cursor.com/docs/models/claude-opus-5-5), mirroring the measured opus-5 rows.
39
+ // 260923 Opus 5.5: live GetUsableModels roster advertises low..max in both regular and fast forms.
40
40
  "claude-opus-5-5": ["low", "medium", "high", "xhigh", "max"],
41
- "claude-opus-5-5-fast": ["low", "medium", "high"],
41
+ "claude-opus-5-5-fast": ["low", "medium", "high", "xhigh", "max"],
42
42
  "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"],
43
43
  "glm-5.2": ["high", "max"],
44
44
  // 260825 live GetUsableModels. gemini-3.6-flash was the first Cursor model exposing
@@ -56,8 +56,6 @@ const CURSOR_MODEL_EFFORT_TIERS: Record<string, readonly string[]> = {
56
56
  // 4.6-opus thinks only at high/max, 4.5-opus only at high, 4.6-sonnet only at medium.
57
57
  "claude-opus-5-thinking": ["low", "medium", "high", "xhigh", "max"],
58
58
  "claude-opus-5-thinking-fast": ["low", "medium", "high", "xhigh", "max"],
59
- "claude-opus-5-5-thinking": ["low", "medium", "high", "xhigh", "max"],
60
- "claude-opus-5-5-thinking-fast": ["low", "medium", "high", "xhigh", "max"],
61
59
  "claude-opus-4-8-thinking": ["low", "medium", "high", "xhigh", "max"],
62
60
  "claude-opus-4-8-thinking-fast": ["low", "medium", "high", "xhigh", "max"],
63
61
  "claude-opus-4-7-thinking": ["low", "medium", "high", "xhigh", "max"],
@@ -139,8 +137,6 @@ const CANONICAL_CODEX_EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"] a
139
137
  const CURSOR_THINKING_FAMILIES: Readonly<Record<string, { source: string; order: "thinking-then-effort" | "effort-then-thinking" | "bare" }>> = {
140
138
  "claude-opus-5-thinking": { source: "claude-opus-5", order: "thinking-then-effort" },
141
139
  "claude-opus-5-thinking-fast": { source: "claude-opus-5-fast", order: "thinking-then-effort" },
142
- "claude-opus-5-5-thinking": { source: "claude-opus-5-5", order: "thinking-then-effort" },
143
- "claude-opus-5-5-thinking-fast": { source: "claude-opus-5-5-fast", order: "thinking-then-effort" },
144
140
  "claude-opus-4-8-thinking": { source: "claude-opus-4-8", order: "thinking-then-effort" },
145
141
  "claude-opus-4-8-thinking-fast": { source: "claude-opus-4-8-fast", order: "thinking-then-effort" },
146
142
  "claude-opus-4-7-thinking": { source: "claude-opus-4-7", order: "thinking-then-effort" },
@@ -12,11 +12,13 @@
12
12
  * cursor.ts to retry before any invalid text reaches the client.
13
13
  */
14
14
 
15
+ import { closedFenceLines, isWholeLineEchoMarker } from "../../lib/tool-envelope-echo-filter";
16
+
15
17
  const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const;
18
+ const REPLAY_ECHO_PREFIXES = ["[Tool call:", "[Tool Call]", "[Tool Result", "[Tool Error", "[tool_result"] as const;
16
19
 
17
- function isEchoMarkerLine(line: string): boolean {
18
- return (ECHO_MARKERS as readonly string[]).includes(line.replace(/^[ \t]+/, ""));
19
- }
20
+ // Whole-line only, the same rule as the live filter: prose that starts with a marker survives.
21
+ const isEchoMarkerLine = isWholeLineEchoMarker;
20
22
 
21
23
  /**
22
24
  * Drop echoed tool-result envelopes from assistant history before Cursor root replay.
@@ -42,15 +44,18 @@ function isEchoMarkerLine(line: string): boolean {
42
44
  * Only whole-line markers count, so prose such as "the string [Tool Result] appeared" survives.
43
45
  */
44
46
  export function stripAssistantEchoedToolEnvelope(text: string): string {
45
- if (!text || !ECHO_MARKERS.some(marker => text.includes(marker))) return text;
47
+ if (!text || !REPLAY_ECHO_PREFIXES.some(marker => text.includes(marker))) return text;
46
48
  const newline = text.includes("\r\n") ? "\r\n" : "\n";
47
49
  const lines = text.split(/\r?\n/);
48
50
  const kept: string[] = [];
49
51
  let dropped = false;
50
52
  let index = 0;
53
+ // A marker inside a fenced code block that closes is an example the model showed, not an echo;
54
+ // the live filter releases it as code, so replay keeps it too. An unclosed block shields nothing.
55
+ const shielded = closedFenceLines(lines);
51
56
  while (index < lines.length) {
52
57
  const line = lines[index] ?? "";
53
- if (!isEchoMarkerLine(line)) {
58
+ if (shielded[index] || !isEchoMarkerLine(line)) {
54
59
  kept.push(line);
55
60
  index += 1;
56
61
  continue;
@@ -26,7 +26,7 @@ import {
26
26
  isCursorExecutionPathTool,
27
27
  isCursorWaitTool,
28
28
  } from "./tool-definitions";
29
- import { lookupCursorThreadConversation } from "./thread-continuity";
29
+ import { lookupCursorThreadConversation, resolveCursorConversationRewrite } from "./thread-continuity";
30
30
  import {
31
31
  getCursorCheckpoint,
32
32
  getCursorCheckpointForPrefix,
@@ -367,11 +367,16 @@ export function resolveCursorConversationId(
367
367
  // the override check has to exclude it explicitly rather than rely on that flag.
368
368
  if (threadId && parsed._compactionRequest !== true) {
369
369
  const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope);
370
- if (recovered) return recovered;
370
+ if (recovered) return resolveCursorConversationRewrite(recovered, parsed._cursorIdentityScope);
371
+ }
372
+ if (parsed._cursorConversationId) {
373
+ return resolveCursorConversationRewrite(parsed._cursorConversationId, parsed._cursorIdentityScope);
371
374
  }
372
- if (parsed._cursorConversationId) return parsed._cursorConversationId;
373
375
  if (threadId) {
374
- return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope);
376
+ return resolveCursorConversationRewrite(
377
+ cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope),
378
+ parsed._cursorIdentityScope,
379
+ );
375
380
  }
376
381
  return generatedCursorConversationId();
377
382
  }
@@ -6,10 +6,56 @@
6
6
  * recovered id instead of recomputing the stale deterministic thread hash.
7
7
  */
8
8
 
9
+ import { createHash } from "node:crypto";
10
+
9
11
  const OVERRIDE_TTL_MS = 60 * 60 * 1000;
10
12
  const OVERRIDE_MAX_ENTRIES = 2048;
11
13
 
12
14
  const overrides = new Map<string, { conversationId: string; updatedAt: number }>();
15
+ const conversationRewrites = new Map<string, { to: string; root: string; updatedAt: number }>();
16
+
17
+ function rewriteKey(conversationId: string, identityScope?: string): string {
18
+ const identity = createHash("sha256").update(identityScope?.trim() || "local").digest("hex");
19
+ return `${identity}\0${conversationId}`;
20
+ }
21
+
22
+ function pruneRewrites(at: number): void {
23
+ for (const [key, entry] of conversationRewrites) {
24
+ if (at - entry.updatedAt > OVERRIDE_TTL_MS) conversationRewrites.delete(key);
25
+ }
26
+ while (conversationRewrites.size > OVERRIDE_MAX_ENTRIES) {
27
+ const oldest = conversationRewrites.keys().next().value;
28
+ if (oldest === undefined) break;
29
+ conversationRewrites.delete(oldest);
30
+ }
31
+ }
32
+
33
+ export function rememberCursorConversationRewrite(from: string, to: string, identityScope?: string): void {
34
+ if (!from || !to || from === to) return;
35
+ const at = now();
36
+ pruneRewrites(at);
37
+ const scope = rewriteKey("", identityScope);
38
+ const root = conversationRewrites.get(rewriteKey(from, identityScope))?.root ?? from;
39
+ const redirects = [...conversationRewrites].filter(([key, entry]) => key.startsWith(scope) && entry.root === root);
40
+ for (const [key] of redirects) {
41
+ conversationRewrites.delete(key);
42
+ conversationRewrites.set(key, { to, root, updatedAt: at });
43
+ }
44
+ conversationRewrites.set(rewriteKey(from, identityScope), { to, root, updatedAt: at });
45
+ conversationRewrites.set(rewriteKey(to, identityScope), { to, root, updatedAt: at });
46
+ pruneRewrites(at);
47
+ }
48
+
49
+ export function resolveCursorConversationRewrite(conversationId: string, identityScope?: string): string {
50
+ const at = now();
51
+ pruneRewrites(at);
52
+ const key = rewriteKey(conversationId, identityScope);
53
+ const entry = conversationRewrites.get(key);
54
+ if (!entry) return conversationId;
55
+ conversationRewrites.delete(key);
56
+ conversationRewrites.set(key, { ...entry, updatedAt: at });
57
+ return entry.to;
58
+ }
13
59
 
14
60
  function now(): number {
15
61
  return Date.now();
@@ -64,6 +110,7 @@ export function lookupCursorThreadConversation(
64
110
 
65
111
  export function clearCursorThreadContinuityForTests(): void {
66
112
  overrides.clear();
113
+ conversationRewrites.clear();
67
114
  }
68
115
 
69
116
  /** Max conversation-id remints after the first surfaced overflow per retained scope. */
@@ -278,7 +325,13 @@ const envelopeEchoRemintBudget = createCursorRemintBudget(
278
325
  export function cursorEnvelopeEchoRemintScopeKey(
279
326
  threadOwner: string | undefined,
280
327
  identityScope?: string,
328
+ conversationId?: string,
281
329
  ): string | null {
330
+ if (conversationId) {
331
+ pruneRewrites(now());
332
+ const root = conversationRewrites.get(rewriteKey(conversationId, identityScope))?.root ?? conversationId;
333
+ return `echo\0${rewriteKey(root, identityScope)}`;
334
+ }
282
335
  return cursorOverflowRemintScopeKey(threadOwner, identityScope);
283
336
  }
284
337
 
@@ -4,7 +4,7 @@ import type { ProviderAdapter } from "./base";
4
4
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
5
5
  import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
6
6
  import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
7
- import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
7
+ import { cursorCheckpointModelAffinityId, cursorNeedsExternalToolContinuation, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery";
8
8
  import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
9
9
  import { mapCursorServerMessage } from "./cursor/message-mapper";
10
10
  import {
@@ -31,6 +31,7 @@ import { debugProviderDiagnostic } from "../lib/debug";
31
31
  import { isDebugEnabled } from "../lib/debug-settings";
32
32
  import { createAdapterTierMetadata } from "../providers/fastwire";
33
33
  import { estimateTokens } from "../lib/token-estimate";
34
+ import { ToolEnvelopeEchoFilter } from "../lib/tool-envelope-echo-filter";
34
35
  import {
35
36
  clearCursorIncompleteToolRemint,
36
37
  cursorIncompleteToolRemintScopeKey,
@@ -41,6 +42,7 @@ import {
41
42
  recordCursorIncompleteToolRemint,
42
43
  recordCursorEnvelopeEchoRemint,
43
44
  recordCursorOverflowRemint,
45
+ rememberCursorConversationRewrite,
44
46
  rememberCursorThreadConversation,
45
47
  shouldSkipCursorOverflowRemint,
46
48
  shouldSurfaceCursorOverflowFirst,
@@ -299,13 +301,18 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
299
301
  // Armed for ANY external turn whose replayed history contains a tool result — echo
300
302
  // priming was observed live on user-action rounds too (the envelope lives in the
301
303
  // flattened history regardless of which role ends the input).
302
- const armEchoSniffer =
303
- isCursorExternalWireModel(activeRequest.modelId)
304
- && (_parsed.context.messages ?? []).some(message => message.role === "toolResult");
304
+ const replaysToolResult = (_parsed.context.messages ?? []).some(message => message.role === "toolResult");
305
+ const armEchoSniffer = isCursorExternalWireModel(activeRequest.modelId) && replaysToolResult;
305
306
  const echoSniffer = armEchoSniffer ? new CursorEnvelopeEchoSniffer() : undefined;
306
307
  // Mid-stream observer (devlog 260828 F1/F2): diagnostic-only; armed with the
307
308
  // prefix sniffer because both fire on flattened tool-result replay priming.
308
- const midstreamObserver = armEchoSniffer ? new CursorMidstreamEchoObserver() : undefined;
309
+ // The composer-2.5 builds are native-wire but also replay the tool result as root text
310
+ // (cursorNeedsExternalToolContinuation), so the client-visible strip and the next-turn
311
+ // remint follow that predicate. The prefix retry above stays external-only: its
312
+ // corrective continuation text is encoded for external wire models alone.
313
+ const armMidstreamEcho = cursorNeedsExternalToolContinuation(activeRequest.modelId) && replaysToolResult;
314
+ const midstreamObserver = armMidstreamEcho ? new CursorMidstreamEchoObserver() : undefined;
315
+ const midstreamFilter = armMidstreamEcho ? new ToolEnvelopeEchoFilter() : undefined;
309
316
  const armRoutingCommentarySniffer =
310
317
  isCursorExternalWireModel(activeRequest.modelId)
311
318
  && (
@@ -321,8 +328,11 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
321
328
  // Exactly-once observation: every client-bound text delta passes through here
322
329
  // exactly once — held deltas only on release, ordinary deltas at emit time.
323
330
  const emitTextObserved = (event: AdapterEvent): void => {
324
- if (event.type === "text_delta") midstreamObserver?.feed(event.text);
325
- emit(event);
331
+ if (event.type !== "text_delta") { emit(event); return; }
332
+ midstreamObserver?.feed(event.text);
333
+ const text = midstreamFilter?.feed(event.text) ?? event.text;
334
+ if (midstreamFilter?.matched) sawMidstreamEnvelopeEcho = true;
335
+ if (text) emit({ ...event, text });
326
336
  };
327
337
  const releaseGuardHeld = () => {
328
338
  for (const held of guardHeld) {
@@ -453,8 +463,13 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
453
463
  }
454
464
  if (event.type !== "heartbeat") emittedOutput = true;
455
465
  if (event.type === "done") {
466
+ const suffix = midstreamFilter?.finish();
467
+ // A fenced marker released by hold overflow is unproven code: remint to be safe.
468
+ if (midstreamFilter?.matched || midstreamFilter?.unverifiedMarker) sawMidstreamEnvelopeEcho = true;
469
+ if (suffix) emit({ type: "text_delta", text: suffix });
470
+ // The observer is diagnostic only: the fence-aware filter's verdict decides the
471
+ // remint, so a quoted marker in a closed code block does not rotate the thread.
456
472
  const midstreamFindings = midstreamObserver?.findings() ?? [];
457
- if (midstreamFindings.length > 0) sawMidstreamEnvelopeEcho = true;
458
473
  for (const finding of midstreamFindings) {
459
474
  debugProviderDiagnostic("cursor", "midstream-envelope-echo", {
460
475
  wireModel: activeRequest.modelId,
@@ -507,6 +522,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
507
522
  // the next turn does not recompute the stale deterministic thread hash. Isolated helper /
508
523
  // compaction turns must not park their throwaway id under the parent or Desktop owner.
509
524
  const threadOwner = cursorClientThreadOwner(_parsed);
525
+ if (_parsed._cursorIsolateConversation !== true) {
526
+ rememberCursorConversationRewrite(failedConversationId, next.conversationId, _parsed._cursorIdentityScope);
527
+ }
510
528
  if (threadOwner && _parsed._cursorIsolateConversation !== true) {
511
529
  rememberCursorThreadConversation(
512
530
  threadOwner,
@@ -629,11 +647,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
629
647
  } else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) {
630
648
  clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey);
631
649
  }
632
- // A mid-stream envelope echo has ALREADY reached the client — the prefix sniffer only
633
- // watches the first bytes of a turn, and grok-4.6 writes a real sentence before pasting
634
- // the envelope. It cannot be quarantined, so the recovery is the same as the
635
- // incomplete-tool case: leave this turn alone and rotate the next turn's id, otherwise
636
- // the stored echo is replayed and primes the model to echo again.
650
+ // Strip a split mid-stream marker before it reaches the client, then rotate the next
651
+ // conversation because the upstream checkpoint may still contain the echoed envelope.
637
652
  //
638
653
  // Its own budget, not the incomplete-tool one: echoing is cheap and repeatable while an
639
654
  // incomplete client-tool stream is rare and structural, so a shared counter would let a
@@ -645,6 +660,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
645
660
  ? cursorEnvelopeEchoRemintScopeKey(
646
661
  cursorClientThreadOwner(_parsed),
647
662
  _parsed._cursorIdentityScope,
663
+ request.conversationId,
648
664
  )
649
665
  : null;
650
666
  if (sawMidstreamEnvelopeEcho && !sawIncompleteToolCall && envelopeEchoRemintScopeKey) {
@@ -1559,7 +1559,9 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<C
1559
1559
  // trace id are the only upstream-controlled fields that reach the error.
1560
1560
  // The stated delay rides along in our own words so a client can still
1561
1561
  // tell how long to wait when local retry gives up, exceeds its cap, or
1562
- // is disabled; the `~` keeps it from re-parsing as a downstream hint.
1562
+ // is disabled. The `~` marks the delay as approximate; the shared
1563
+ // parser accepts it once after Retry-After, so the outer client cooldown
1564
+ // reads this same delay back from the message.
1563
1565
  throw new CloudChatError(
1564
1566
  `Cognition chat failed${trailerError.code ? ` (${trailerError.code})` : ''} ` +
1565
1567
  `(cloud trace ID: ${trailerError.traceId ?? 'n/a'})` +
@@ -604,6 +604,28 @@ function sanitizeProperties(
604
604
  return properties;
605
605
  }
606
606
 
607
+ /**
608
+ * Gemini rejects an array declaration that carries no `items` (#5689), so no return from
609
+ * `sanitizeSchema` may leave an array incomplete. A string item keeps the declaration valid: it
610
+ * narrows an unconstrained item rather than widening a constraint, so the loss report, which counts
611
+ * widened or dropped constraints, does not record it. The synthesized node is part of the emitted
612
+ * tree and charges the node budget like any other, because a wide enough fan-out of `items`-less
613
+ * arrays otherwise pushed the output past MAX_SCHEMA_NODES. When the budget cannot pay for that
614
+ * item, the array is omitted (`BUDGET_EXHAUSTED`) for its caller to drop instead of being emitted
615
+ * bare, which Gemini would reject for the whole request. A parent whose own `items` came back
616
+ * exhausted reaches this same rule, so an incomplete array is never nested in a retained one.
617
+ */
618
+ function completeArrayItems(out: Schema, state: SanitizeState): SanitizeResult {
619
+ if (out.type !== "array" || Object.hasOwn(out, "items")) return out;
620
+ if (state.remainingNodes <= 0) {
621
+ reportBudgetExhausted(state);
622
+ return BUDGET_EXHAUSTED;
623
+ }
624
+ state.remainingNodes -= 1;
625
+ out.items = { type: "string" };
626
+ return out;
627
+ }
628
+
607
629
  function sanitizeSchema(
608
630
  node: unknown,
609
631
  defs: Map<string, unknown>,
@@ -725,7 +747,8 @@ function sanitizeSchema(
725
747
 
726
748
  if (state.remainingNodes <= 0) {
727
749
  if (Object.hasOwn(node, "items") || Object.hasOwn(node, "anyOf")) reportBudgetExhausted(state);
728
- return out;
750
+ // An array that the budget stopped before its `items` traversal takes the same rule.
751
+ return completeArrayItems(out, state);
729
752
  }
730
753
 
731
754
  if (Array.isArray(node.items)) {
@@ -739,7 +762,7 @@ function sanitizeSchema(
739
762
 
740
763
  if (state.remainingNodes <= 0) {
741
764
  if (Object.hasOwn(node, "anyOf")) reportBudgetExhausted(state);
742
- return out;
765
+ return completeArrayItems(out, state);
743
766
  }
744
767
  if (Object.hasOwn(node, "anyOf")) {
745
768
  const normalized = normalizeAnyOf(node.anyOf, defs, depth, refDepth, state);
@@ -753,7 +776,7 @@ function sanitizeSchema(
753
776
  }
754
777
  Object.assign(out, normalized);
755
778
  }
756
- return out;
779
+ return completeArrayItems(out, state);
757
780
  }
758
781
 
759
782
  export function sanitizeGeminiToolParametersWithReport(