@bitkyc08/opencodex 2.24.2 → 2.25.0

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 (37) hide show
  1. package/gui/dist/assets/{index-DW-DYWmz.js → index-DxJ7kXj9.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +42 -0
  5. package/src/adapters/client-fingerprint.ts +9 -5
  6. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  7. package/src/adapters/command-code.ts +17 -0
  8. package/src/adapters/cursor/cursor-errors.ts +49 -0
  9. package/src/adapters/cursor/live-models.ts +36 -2
  10. package/src/adapters/cursor/live-transport.ts +55 -4
  11. package/src/adapters/cursor/native-exec.ts +9 -0
  12. package/src/adapters/cursor/protobuf-request.ts +160 -9
  13. package/src/adapters/cursor/request-builder.ts +9 -1
  14. package/src/adapters/cursor/tool-definitions.ts +7 -2
  15. package/src/adapters/google-antigravity-wire.ts +1 -1
  16. package/src/adapters/google.ts +30 -12
  17. package/src/adapters/openai-responses-url.ts +5 -3
  18. package/src/adapters/registry.ts +3 -1
  19. package/src/adapters/tool-catalog-nudge.ts +76 -9
  20. package/src/bridge.ts +53 -9
  21. package/src/codex/app-server-processes.ts +69 -35
  22. package/src/codex/catalog/provider-fetch.ts +5 -1
  23. package/src/config.ts +1 -0
  24. package/src/generated/compatibility-version.json +40 -32
  25. package/src/lib/windows-elevation.ts +8 -2
  26. package/src/oauth/google-antigravity.ts +7 -2
  27. package/src/providers/antigravity-models.ts +126 -17
  28. package/src/providers/derive.ts +11 -1
  29. package/src/responses/parser.ts +4 -0
  30. package/src/responses/reasoning-replay-cache.ts +16 -1
  31. package/src/responses/thought-signature-replay.ts +17 -1
  32. package/src/responses/truncated-stop-reason.ts +60 -0
  33. package/src/router.ts +2 -10
  34. package/src/server/management/provider-routes.ts +22 -0
  35. package/src/server/request-log.ts +11 -3
  36. package/src/server/responses/core.ts +2 -0
  37. package/src/types.ts +13 -1
@@ -553,6 +553,23 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
553
553
  sawFinish = true;
554
554
  const usageValue = event.totalUsage ?? event.usage;
555
555
  const stopReason = typeof event.rawFinishReason === "string" ? event.rawFinishReason : typeof event.finishReason === "string" ? event.finishReason : undefined;
556
+ // The AI SDK's `error` finish reason means the generation failed upstream, not that it
557
+ // stopped. Reporting it as a `done` left the bridge to infer failure from a stop-reason
558
+ // string, which either read as a clean completion or (once classified) mislabelled an
559
+ // upstream error as a content filter and rejected it from the replay cache for the
560
+ // wrong reason.
561
+ if (stopReason === "error") {
562
+ // Keep the usage: a failed turn still consumed tokens, and dropping it makes the
563
+ // turn look free in accounting and reports zeros to the client.
564
+ yield {
565
+ type: "error",
566
+ message: "Command Code upstream ended the turn with finishReason \"error\"",
567
+ status: 502,
568
+ errorType: "upstream_error",
569
+ usage: usage(usageValue),
570
+ };
571
+ break;
572
+ }
556
573
  yield { type: "done", usage: usage(usageValue), stopReason };
557
574
  break;
558
575
  }
@@ -27,7 +27,56 @@ function errorCode(value: unknown): string {
27
27
  * True when Cursor intentionally cancelled the HTTP/2 stream after a client-tool suspend.
28
28
  * These are expected between multi-turn Responses bridge cycles, not upstream failures.
29
29
  */
30
+ /**
31
+ * A Cursor stream that ended cleanly at the HTTP/2 layer while a client tool call was still
32
+ * open — no `turnEnded`, no error trailer, just EOF. The call's buffered arguments are lost,
33
+ * so the turn is truncated: reporting it as success would hand Codex a turn whose tool call
34
+ * silently never happened. Not retryable — the request is committed once the session connects.
35
+ */
36
+ export class CursorStreamTruncatedError extends Error {
37
+ constructor(
38
+ public readonly openCallIds: readonly string[],
39
+ public readonly framesReceived: number,
40
+ ) {
41
+ super(
42
+ `Cursor stream ended without terminating the turn; ${openCallIds.length} tool call(s) left incomplete `
43
+ + `(${openCallIds.join(", ")}) after ${framesReceived} frame(s). Arguments may be truncated; the call was not committed.`,
44
+ );
45
+ this.name = "CursorStreamTruncatedError";
46
+ }
47
+ }
48
+
49
+ /**
50
+ * A cancel-shaped stream failure that WE did not request. `cancelCursorRun` is the only place
51
+ * that cancels our own stream, and it sets `expectedClose` first, so a cancel arriving without it
52
+ * came from Cursor or the network and is a real transport failure.
53
+ *
54
+ * It carries its own message on purpose. Left as a raw `NGHTTP2_CANCEL` error, the text is
55
+ * re-matched downstream (`classifyCursorError`) and labelled "Cursor stream suspended" — a turn
56
+ * that failed unexpectedly would report an intentional suspension and misdirect diagnosis.
57
+ */
58
+ export class CursorUnexpectedCancelError extends Error {
59
+ /**
60
+ * The originating error's transport code (typically `NGHTTP2_CANCEL`), re-exposed so the
61
+ * per-turn `turn-failed` diagnostic still records how the stream actually died. Wrapping
62
+ * without this made the summary for exactly this failure the one with no code.
63
+ */
64
+ public readonly code?: string;
65
+
66
+ constructor(public readonly cause?: unknown) {
67
+ super("Cursor connection was cancelled by the server before the turn completed");
68
+ this.name = "CursorUnexpectedCancelError";
69
+ const causeCode = errorCode(cause);
70
+ if (causeCode) this.code = causeCode;
71
+ }
72
+ }
73
+
30
74
  export function isCursorBenignCancelError(value: unknown): boolean {
75
+ // An unexpected cancel is never benign, however it is spelled. This class is raised only when
76
+ // the transport knows WE did not request the cancel, so its provenance outranks the code match
77
+ // below — otherwise the adapter would re-decide the same question from the error code alone
78
+ // and swallow a real transport failure (cursor.ts:181).
79
+ if (value instanceof CursorUnexpectedCancelError) return false;
31
80
  const message = errorMessage(value).toLowerCase();
32
81
  const code = errorCode(value).toUpperCase();
33
82
  if (code === "NGHTTP2_CANCEL") return true;
@@ -19,6 +19,8 @@ import { GetUsableModelsResponseSchema } from "./gen/agent_pb";
19
19
  const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
20
20
  const CURSOR_DISCOVERY_CLIENT_VERSION = "cli-2026.02.13-41ac335";
21
21
  const CURSOR_MODEL_DISCOVERY_MAX_BYTES = 4 * 1024 * 1024;
22
+ type CursorUsableModelsFetcher = (opts: CursorUsableModelsOptions) => Promise<CursorUsableModelsResult>;
23
+ let cursorUsableModelsFetcherForTests: CursorUsableModelsFetcher | null = null;
22
24
 
23
25
  export interface CursorUsableModelsOptions {
24
26
  apiKey: string;
@@ -31,6 +33,11 @@ export type CursorUsableModelsResult =
31
33
  | { ok: true; models: string[] }
32
34
  | { ok: false; error: "auth" | "http" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string };
33
35
 
36
+ /** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */
37
+ export function setFetchCursorUsableModelsForTests(next: CursorUsableModelsFetcher | null): void {
38
+ cursorUsableModelsFetcherForTests = next;
39
+ }
40
+
34
41
  const RETRYABLE_DISCOVERY_ERRORS = new Set(["timeout", "transport"]);
35
42
  const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
36
43
 
@@ -42,10 +49,37 @@ const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
42
49
  * devlog 260723_cursor_context_continuity/030).
43
50
  */
44
51
  export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
45
- const first = await fetchCursorUsableModelsOnce(opts);
52
+ if (cursorUsableModelsFetcherForTests) return cursorUsableModelsFetcherForTests(opts);
53
+ const resolved = resolveCursorDiscoveryBaseUrl(opts.baseUrl ?? "https://api2.cursor.sh");
54
+ if (!resolved.ok) return resolved;
55
+ const first = await fetchCursorUsableModelsOnce({ ...opts, baseUrl: resolved.baseUrl });
46
56
  if (first.ok || !RETRYABLE_DISCOVERY_ERRORS.has(first.error)) return first;
47
57
  await new Promise(resolve => setTimeout(resolve, 250 + Math.floor(Math.random() * 250)));
48
- return fetchCursorUsableModelsOnce({ ...opts, timeoutMs: Math.min(opts.timeoutMs ?? 8000, DISCOVERY_RETRY_TIMEOUT_MS) });
58
+ return fetchCursorUsableModelsOnce({
59
+ ...opts,
60
+ baseUrl: resolved.baseUrl,
61
+ timeoutMs: Math.min(opts.timeoutMs ?? 8000, DISCOVERY_RETRY_TIMEOUT_MS),
62
+ });
63
+ }
64
+
65
+ function resolveCursorDiscoveryBaseUrl(raw: string): { ok: true; baseUrl: string } | Extract<CursorUsableModelsResult, { ok: false }> {
66
+ const baseUrl = raw.replace(/\/+$/, "");
67
+ let parsed: URL;
68
+ try {
69
+ parsed = new URL(baseUrl);
70
+ } catch {
71
+ return { ok: false, error: "transport", detail: "Cursor discovery URL is invalid" };
72
+ }
73
+ if (parsed.protocol === "https:") return { ok: true, baseUrl };
74
+ // Local h2c fixtures (and an operator loopback proxy) never leave the machine.
75
+ // Anything else with a Bearer token must be HTTPS, matching providerOutbound POST.
76
+ if (parsed.protocol === "http:") {
77
+ const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
78
+ if (host === "127.0.0.1" || host === "::1" || host === "localhost" || host.endsWith(".localhost")) {
79
+ return { ok: true, baseUrl };
80
+ }
81
+ }
82
+ return { ok: false, error: "transport", detail: "Cursor discovery URL must use HTTPS" };
49
83
  }
50
84
 
51
85
  async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
@@ -48,7 +48,7 @@ import {
48
48
  type InteractionResponse,
49
49
  } from "./gen/agent_pb";
50
50
  import { debugProviderDiagnostic } from "../../lib/debug";
51
- import { classifyCursorError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
51
+ import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
52
52
  import { mcpArgsFromToolCall } from "./protobuf-events";
53
53
  import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
54
54
  import {
@@ -410,6 +410,12 @@ class LiveCursorTransport implements CursorTransport {
410
410
  private firstFrameTimer?: ReturnType<typeof setTimeout>;
411
411
  private committed = false;
412
412
  private expectedClose = false;
413
+ /**
414
+ * True once a terminal (`done` or `error`) has been admitted to the outbound queue. Read only
415
+ * by the EOF branch below: after a mapper error the bridge has already failed the turn, so
416
+ * failing again on EOF would add a duplicate adapter error for no benefit.
417
+ */
418
+ private emittedTerminal = false;
413
419
  private pendingFinalize?: ReturnType<typeof setTimeout>;
414
420
  private readonly clientToolFinalizeGraceMs: number;
415
421
  private activeClientToolFinalizeGraceMs: number;
@@ -428,6 +434,7 @@ class LiveCursorTransport implements CursorTransport {
428
434
  // close; safe to read after a stream failure because open() owns the only writer before run().
429
435
  private turnStartedAt = 0;
430
436
  private framesReceived = 0;
437
+ private sawAssistantText = false;
431
438
  private firstFrameAt?: number;
432
439
  private firstFrameLogged = false;
433
440
  /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */
@@ -522,6 +529,22 @@ class LiveCursorTransport implements CursorTransport {
522
529
  }
523
530
  return err;
524
531
  };
532
+ /**
533
+ * A cancel we did not request is a real transport failure, but as a raw `NGHTTP2_CANCEL` it
534
+ * gets swallowed twice over: the adapter re-decides "benign" from the error code alone
535
+ * (`cursor.ts:181`) and drops the turn, and any message that survives is re-matched
536
+ * downstream and labelled an intentional "Cursor stream suspended". Raising a typed error
537
+ * carries the provenance this class already holds.
538
+ *
539
+ * Suppressed once a terminal was emitted: the turn already ended, and a second terminal flips
540
+ * a completed buffered response to failed.
541
+ */
542
+ const classifyTurnFailure = (err: Error): Error => {
543
+ if (!this.expectedClose && !this.emittedTerminal && isCursorBenignCancelError(err)) {
544
+ return summarizeFailure(new CursorUnexpectedCancelError(err));
545
+ }
546
+ return summarizeFailure(err);
547
+ };
525
548
  const wake = () => {
526
549
  const fn = notify;
527
550
  notify = undefined;
@@ -531,6 +554,7 @@ class LiveCursorTransport implements CursorTransport {
531
554
  const push = (message: CursorServerMessage) => {
532
555
  const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
533
556
  this.reserveTransportBytes(bytes);
557
+ if (message.type === "done" || message.type === "error") this.emittedTerminal = true;
534
558
  queue.push({ message, bytes });
535
559
  wake();
536
560
  };
@@ -620,7 +644,7 @@ class LiveCursorTransport implements CursorTransport {
620
644
  // A CANCEL is benign only on the client-tool suspend path (expectedClose); an
621
645
  // unexpected server-side NGHTTP2_CANCEL must surface as a real transport error.
622
646
  if (this.expectedClose && isCursorBenignCancelError(failure)) return;
623
- throw attachPartialUsage(summarizeFailure(failure), state);
647
+ throw attachPartialUsage(classifyTurnFailure(failure), state);
624
648
  }
625
649
  if (done) break;
626
650
  await new Promise<void>(resolve => {
@@ -629,7 +653,7 @@ class LiveCursorTransport implements CursorTransport {
629
653
  }
630
654
  if (failure) {
631
655
  if (this.expectedClose && isCursorBenignCancelError(failure)) return;
632
- throw attachPartialUsage(summarizeFailure(failure), state);
656
+ throw attachPartialUsage(classifyTurnFailure(failure), state);
633
657
  }
634
658
  }
635
659
 
@@ -769,6 +793,8 @@ class LiveCursorTransport implements CursorTransport {
769
793
  ): void {
770
794
  this.turnStartedAt = Date.now();
771
795
  this.framesReceived = 0;
796
+ this.sawAssistantText = false;
797
+ this.emittedTerminal = false;
772
798
  this.firstFrameAt = undefined;
773
799
  this.firstFrameLogged = false;
774
800
  const dialHost = cursorHostLabel(this.input.provider.baseUrl || "https://api2.cursor.sh");
@@ -1026,6 +1052,27 @@ class LiveCursorTransport implements CursorTransport {
1026
1052
  settler.settleFail(new Error("Cursor stream ended before any response frame (unexpected EOF)"));
1027
1053
  return;
1028
1054
  }
1055
+ // `emittedTerminal` joins dev's two conditions so EOF finalization cannot append a
1056
+ // second terminal after a mapper error already failed the turn (integration 010).
1057
+ if (state.terminated || this.expectedClose || this.emittedTerminal) {
1058
+ releaseBacklogLease();
1059
+ settler.settleFinish();
1060
+ return;
1061
+ }
1062
+ // Open tools fail-closed as a truncation *event* (finalizeTurnEvents), not a thrown
1063
+ // transport error. settleFail here would hide that typed message as adapter_eof.
1064
+ if (state.openToolCalls.size > 0) {
1065
+ for (const event of finalizeTurnEvents(state)) push(event);
1066
+ releaseBacklogLease();
1067
+ settler.settleFinish();
1068
+ return;
1069
+ }
1070
+ if (this.framesReceived > 0 && this.sawAssistantText) {
1071
+ for (const event of finalizeTurnEvents(state)) push(event);
1072
+ releaseBacklogLease();
1073
+ settler.settleFinish();
1074
+ return;
1075
+ }
1029
1076
  releaseBacklogLease();
1030
1077
  settler.settleFinish();
1031
1078
  }, (err) => {
@@ -1087,7 +1134,10 @@ class LiveCursorTransport implements CursorTransport {
1087
1134
  debugProviderDiagnostic("cursor", "interaction-query", { id: query.id, queryCase: query.query.case ?? "unknown", reply: plan.replyCase });
1088
1135
  this.stream.write(encodeClientMessage({ message: { case: "interactionResponse", value: plan.response } }));
1089
1136
  if (!state.terminated) {
1090
- if (plan.planText) push({ type: "text", text: plan.planText });
1137
+ if (plan.planText) {
1138
+ this.sawAssistantText = true;
1139
+ push({ type: "text", text: plan.planText });
1140
+ }
1091
1141
  push({ type: "heartbeat" });
1092
1142
  }
1093
1143
  return;
@@ -1100,6 +1150,7 @@ class LiveCursorTransport implements CursorTransport {
1100
1150
  const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted"
1101
1151
  && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
1102
1152
  const mapped = mapCursorProtobufServerMessage(message, state);
1153
+ if (mapped.some(event => event.type === "text")) this.sawAssistantText = true;
1103
1154
  const beganAwaitingNativeClientToolArgs = update?.case === "toolCallCompleted"
1104
1155
  && !awaitedNativeArgsBeforeMapping
1105
1156
  && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true;
@@ -461,6 +461,15 @@ export function setCursorBlobLimitsForTests(limits?: Partial<CursorBlobLimits>):
461
461
  blobLimits = limits ? { ...DEFAULT_BLOB_LIMITS, ...limits } : { ...DEFAULT_BLOB_LIMITS };
462
462
  }
463
463
 
464
+ /**
465
+ * The live per-blob admission ceiling. Callers that build a blob must budget against THIS value
466
+ * rather than a copy of the constant: the limit is test-overridable, and a hardcoded 16 MiB would
467
+ * silently drift from admission the moment either side changes.
468
+ */
469
+ export function cursorBlobMaxEntryBytes(): number {
470
+ return blobLimits.maxEntryBytes;
471
+ }
472
+
464
473
  export function resetCursorBlobStateForTests(): void {
465
474
  if (blobExpiryAccountingTimer) clearTimeout(blobExpiryAccountingTimer);
466
475
  blobExpiryAccountingTimer = undefined;
@@ -8,12 +8,14 @@ import { isCursorExternalWireModel } from "./discovery";
8
8
  import { debugProviderDiagnostic } from "../../lib/debug";
9
9
  import {
10
10
  createCursorBlobRequestScope,
11
+ cursorBlobMaxEntryBytes,
11
12
  releaseCursorBlobRequestScope,
12
13
  sealCursorBlobRequestScope,
13
14
  storeCursorBlob,
14
15
  type CursorBlobRequestScopeToken,
15
16
  } from "./native-exec";
16
17
  import { estimateTokens } from "../../lib/token-estimate";
18
+ import { parseDataUrl } from "../image";
17
19
  import {
18
20
  AgentClientMessageSchema,
19
21
  AgentConversationTurnStructureSchema,
@@ -26,6 +28,7 @@ import {
26
28
  McpArgsSchema,
27
29
  McpSuccessSchema,
28
30
  McpTextContentSchema,
31
+ McpImageContentSchema,
29
32
  McpToolCallSchema,
30
33
  McpToolResultContentItemSchema,
31
34
  McpToolResultSchema,
@@ -318,7 +321,7 @@ function contentText(message: OcxMessage): string {
318
321
  .map(part => {
319
322
  if (part.type === "text") return part.text;
320
323
  if (part.type === "thinking") return part.thinking;
321
- if (part.type === "image") return `[image input unsupported by Cursor adapter phase 3: ${part.detail ?? "auto"}]`;
324
+ if (part.type === "image") return `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`;
322
325
  return undefined;
323
326
  })
324
327
  .filter((value): value is string => typeof value === "string" && value.length > 0)
@@ -328,10 +331,146 @@ function contentText(message: OcxMessage): string {
328
331
  function contentToText(content: OcxToolResultMessage["content"]): string {
329
332
  if (typeof content === "string") return content;
330
333
  return content
331
- .map(part => part.type === "text" ? part.text : `[image input unsupported by Cursor adapter phase 3: ${part.detail ?? "auto"}]`)
334
+ .map(part => part.type === "text" ? part.text : `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`)
332
335
  .join("\n");
333
336
  }
334
337
 
338
+ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
339
+
340
+ /**
341
+ * Decode a Codex inline image into Cursor wire bytes.
342
+ *
343
+ * `OcxImageContent.imageUrl` is either a `data:` URL or a remote https URL, so this cannot reuse
344
+ * the MCP helper (which takes bare base64 plus a separate mime). It layers strict validation over
345
+ * the shared `parseDataUrl` rather than tightening it, because Anthropic, Google, and Command Code
346
+ * share that parser. `Buffer.from(x, "base64")` accepts many invalid strings silently, so the
347
+ * charset is checked explicitly. Remote URLs are out of scope: `McpImageContent` needs bytes, and
348
+ * fetching here would put network IO inside request construction.
349
+ */
350
+ function decodeInlineImage(imageUrl: string): { bytes: Uint8Array; mimeType: string } | undefined {
351
+ const parsed = parseDataUrl(imageUrl);
352
+ if (!parsed) return undefined;
353
+ const base64 = parsed.base64.trim();
354
+ if (base64.length === 0 || base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) return undefined;
355
+ try {
356
+ const bytes = Uint8Array.from(Buffer.from(base64, "base64"));
357
+ if (bytes.length === 0) return undefined;
358
+ return { bytes, mimeType: parsed.mediaType || "application/octet-stream" };
359
+ } catch {
360
+ return undefined;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * A degraded image must never make a step LARGER than the legacy encoding did, or this change
366
+ * could fail admission for a request that previously fit. The old placeholder was
367
+ * `[image input unsupported by Cursor adapter phase 3: <detail>]`; anything we emit in its place
368
+ * is truncated to that budget so the zero-image case is byte-bounded by the pre-change behavior.
369
+ */
370
+ const LEGACY_IMAGE_PLACEHOLDER_BUDGET =
371
+ "[image input unsupported by Cursor adapter phase 3: auto]".length;
372
+
373
+ function imagePlaceholder(reason: string): string {
374
+ const text = `[image omitted: ${reason}]`;
375
+ return text.length <= LEGACY_IMAGE_PLACEHOLDER_BUDGET
376
+ ? text
377
+ : `${text.slice(0, LEGACY_IMAGE_PLACEHOLDER_BUDGET - 1)}]`;
378
+ }
379
+
380
+ type DecodedResultPart =
381
+ | { kind: "text"; text: string }
382
+ | { kind: "image"; bytes: Uint8Array; mimeType: string }
383
+ | { kind: "undecodable" };
384
+
385
+ /**
386
+ * Decode a tool result's parts ONCE. `toolCallStep` may re-serialize a step several times while
387
+ * shrinking it to fit blob admission, and decoding base64 on every attempt made that loop
388
+ * quadratic (an audit measured ~3s for 100 images).
389
+ */
390
+ function decodeResultParts(message: OcxToolResultMessage): DecodedResultPart[] | undefined {
391
+ const content = message.content;
392
+ if (typeof content === "string") return undefined;
393
+ return content.map((part): DecodedResultPart => {
394
+ if (part.type === "text") return { kind: "text", text: part.text };
395
+ const decoded = decodeInlineImage(part.imageUrl);
396
+ return decoded ? { kind: "image", ...decoded } : { kind: "undecodable" };
397
+ });
398
+ }
399
+
400
+ function countImages(parts: DecodedResultPart[] | undefined): number {
401
+ return parts ? parts.filter(p => p.kind === "image").length : 0;
402
+ }
403
+
404
+ /**
405
+ * Build the wire content items for a tool result, preserving part order.
406
+ *
407
+ * Images become real `McpImageContent` — the Cursor schema has an image case on
408
+ * `McpToolResultContentItem`, and `native-exec-mcp.ts` already uses it for MCP-invoked tools.
409
+ * Flattening them to placeholder text blinded every screenshot-returning tool (Computer Use,
410
+ * browser QA) that Codex routes through this path.
411
+ */
412
+ function toolResultContentItems(
413
+ message: OcxToolResultMessage,
414
+ decoded?: DecodedResultPart[],
415
+ maxImages = Number.POSITIVE_INFINITY,
416
+ ) {
417
+ const parts = decoded ?? decodeResultParts(message);
418
+ if (!parts) {
419
+ const text = typeof message.content === "string" ? message.content : "";
420
+ return [create(McpToolResultContentItemSchema, {
421
+ content: { case: "text" as const, value: create(McpTextContentSchema, { text }) },
422
+ })];
423
+ }
424
+ // Images are dropped OLDEST first when the step must shrink: the most recent screenshot is the
425
+ // one the model is reasoning about, so it is the last to go.
426
+ const totalImages = countImages(parts);
427
+ const allowed = Math.max(0, Math.min(totalImages, maxImages));
428
+ let seen = 0;
429
+ // Consecutive text runs are newline-joined into ONE item, exactly as the legacy encoding did.
430
+ // Emitting one protobuf item per part adds per-item framing, which was enough to push a
431
+ // previously admissible step past the blob ceiling (round-3 audit: 1020 -> 1025 bytes at a
432
+ // 1024 limit). A result with no images must serialize identically to before this feature.
433
+ const items: ReturnType<typeof create<typeof McpToolResultContentItemSchema>>[] = [];
434
+ let pendingText: string[] = [];
435
+ const flushText = () => {
436
+ if (pendingText.length === 0) return;
437
+ const text = pendingText.join("\n");
438
+ pendingText = [];
439
+ items.push(create(McpToolResultContentItemSchema, {
440
+ content: { case: "text" as const, value: create(McpTextContentSchema, { text }) },
441
+ }));
442
+ };
443
+ for (const part of parts) {
444
+ if (part.kind === "text") {
445
+ pendingText.push(part.text);
446
+ continue;
447
+ }
448
+ if (part.kind === "undecodable") {
449
+ pendingText.push(imagePlaceholder("no inline data"));
450
+ continue;
451
+ }
452
+ seen++;
453
+ if (seen <= totalImages - allowed) {
454
+ pendingText.push(imagePlaceholder(`${part.bytes.byteLength}B over step limit`));
455
+ continue;
456
+ }
457
+ flushText();
458
+ items.push(create(McpToolResultContentItemSchema, {
459
+ content: { case: "image" as const, value: create(McpImageContentSchema, {
460
+ data: part.bytes,
461
+ mimeType: part.mimeType,
462
+ }) },
463
+ }));
464
+ }
465
+ flushText();
466
+ if (items.length === 0) {
467
+ items.push(create(McpToolResultContentItemSchema, {
468
+ content: { case: "text" as const, value: create(McpTextContentSchema, { text: "" }) },
469
+ }));
470
+ }
471
+ return items;
472
+ }
473
+
335
474
  function toolResultToText(message: OcxToolResultMessage): string {
336
475
  return [
337
476
  "[tool_result]",
@@ -359,7 +498,8 @@ function toolCallStep(
359
498
  const args: Record<string, Uint8Array> = {};
360
499
  for (const [key, value] of Object.entries(part.arguments ?? {})) args[key] = argBytes(value);
361
500
  const toolName = namespacedToolName(part.namespace, part.name);
362
- return storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
501
+ const decodedResult = result ? decodeResultParts(result) : undefined;
502
+ const serialize = (maxImages: number): Uint8Array => toBinary(ConversationStepSchema, create(ConversationStepSchema, {
363
503
  message: {
364
504
  case: "toolCall",
365
505
  value: create(ToolCallSchema, {
@@ -373,23 +513,34 @@ function toolCallStep(
373
513
  providerIdentifier: OCX_RESPONSES_TOOL_PROVIDER,
374
514
  args,
375
515
  }),
376
- ...(result ? { result: toolResultPart(result) } : {}),
516
+ ...(result ? { result: toolResultPart(result, decodedResult, maxImages) } : {}),
377
517
  }),
378
518
  },
379
519
  }),
380
520
  },
381
- })), requestScope);
521
+ }));
522
+
523
+ // A step is stored as ONE blob, so its images share an entry with the call's arguments, text,
524
+ // mime strings, and protobuf framing. A byte budget over decoded images alone cannot bound that
525
+ // (an audit reproduced a 448-byte-argument call whose 460-byte image pushed a previously
526
+ // admitted step past the ceiling). Measure the real serialized size instead, then drop images —
527
+ // oldest first, so the most recent screenshot survives — until the step fits.
528
+ const limit = cursorBlobMaxEntryBytes();
529
+ const imageCount = countImages(decodedResult);
530
+ let encoded = serialize(imageCount);
531
+ for (let allowed = imageCount - 1; allowed >= 0 && encoded.byteLength > limit; allowed--) {
532
+ encoded = serialize(allowed);
533
+ }
534
+ return storeCursorBlob(encoded, requestScope);
382
535
  }
383
536
 
384
- function toolResultPart(message: OcxToolResultMessage) {
537
+ function toolResultPart(message: OcxToolResultMessage, decoded?: DecodedResultPart[], maxImages?: number) {
385
538
  return create(McpToolResultSchema, {
386
539
  result: {
387
540
  case: "success",
388
541
  value: create(McpSuccessSchema, {
389
542
  isError: message.isError,
390
- content: [create(McpToolResultContentItemSchema, {
391
- content: { case: "text", value: create(McpTextContentSchema, { text: contentToText(message.content) }) },
392
- })],
543
+ content: toolResultContentItems(message, decoded, maxImages),
393
544
  }),
394
545
  },
395
546
  });
@@ -205,7 +205,15 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri
205
205
  case "thinking":
206
206
  return part.thinking;
207
207
  case "image":
208
- return `[image input unsupported by Cursor adapter phase 3: ${part.detail ?? "auto"}]`;
208
+ // User-message images are still flattened here: this path builds the plain-text prompt, and
209
+ // the schema slot that could carry them (UserMessage.selectedContext.selectedImages) is not
210
+ // populated by this adapter. The tool-result ENCODER does build real McpImageContent
211
+ // (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no
212
+ // longer true of the encoder — but note that nothing reaches Cursor today either way:
213
+ // every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar
214
+ // describes or strips images before this adapter runs. Kept the same length to avoid
215
+ // shifting any byte-budgeted prompt path.
216
+ return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`;
209
217
  case "toolCall":
210
218
  // Cursor does not accept OpenAI Responses assistant tool-call parts as native history here.
211
219
  // Rendering them as visible "[tool_call]" text leaks synthetic protocol markers back into
@@ -525,7 +525,12 @@ export function nonEmptyShellBridgeCommandFromArgs(
525
525
  }
526
526
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
527
527
  const record = parsed as Record<string, unknown>;
528
- for (const key of shellBridgeRequiredCommandKeys(toolName, schema)) {
528
+ const requiredKeys = shellBridgeRequiredCommandKeys(toolName, schema);
529
+ const candidateKeys = new Set<"cmd" | "command">([
530
+ ...requiredKeys,
531
+ requiredKeys.includes("cmd") ? "command" : "cmd",
532
+ ]);
533
+ for (const key of candidateKeys) {
529
534
  const value = record[key];
530
535
  if (typeof value === "string" && value.trim().length > 0) return value.trim();
531
536
  }
@@ -615,7 +620,7 @@ export function buildCursorToolGuidanceSystemNote(
615
620
  // Code mode: shell/edit/MCP live inside freeform `exec` as nested helpers. Without this the
616
621
  // model probes for a top-level shell tool that is not there.
617
622
  codeMode
618
- ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.<name>(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description for the exact nested helpers this turn provides. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\`, \`shell_command\`, or \`apply_patch\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.`
623
+ ? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.<name>(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description and the isolate global \`ALL_TOOLS\` (not \`tools.ALL_TOOLS\`) for helpers this turn provides; absence from the top-level catalog or from \`exec\`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\` or \`shell_command\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.`
619
624
  : undefined,
620
625
  codeMode
621
626
  ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers."
@@ -9,7 +9,7 @@ import { antigravityUserAgent } from "./client-fingerprint";
9
9
  * sends. The IDE client family is also required to unlock newer agent models (the backend 404s
10
10
  * CLI-shaped UAs for `gemini-3.7-*`). A `GOOGLE_ANTIGRAVITY_USER_AGENT` override still wins.
11
11
  */
12
- export const ANTIGRAVITY_REQUEST_UA = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT || antigravityUserAgent();
12
+ export const ANTIGRAVITY_REQUEST_UA = antigravityUserAgent();
13
13
 
14
14
  /**
15
15
  * Whether a stored `OcxToolCall.thoughtSignature` is a REAL upstream Gemini signature versus a