@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.
- package/gui/dist/assets/{index-DW-DYWmz.js → index-DxJ7kXj9.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +42 -0
- package/src/adapters/client-fingerprint.ts +9 -5
- package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
- package/src/adapters/command-code.ts +17 -0
- package/src/adapters/cursor/cursor-errors.ts +49 -0
- package/src/adapters/cursor/live-models.ts +36 -2
- package/src/adapters/cursor/live-transport.ts +55 -4
- package/src/adapters/cursor/native-exec.ts +9 -0
- package/src/adapters/cursor/protobuf-request.ts +160 -9
- package/src/adapters/cursor/request-builder.ts +9 -1
- package/src/adapters/cursor/tool-definitions.ts +7 -2
- package/src/adapters/google-antigravity-wire.ts +1 -1
- package/src/adapters/google.ts +30 -12
- package/src/adapters/openai-responses-url.ts +5 -3
- package/src/adapters/registry.ts +3 -1
- package/src/adapters/tool-catalog-nudge.ts +76 -9
- package/src/bridge.ts +53 -9
- package/src/codex/app-server-processes.ts +69 -35
- package/src/codex/catalog/provider-fetch.ts +5 -1
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +40 -32
- package/src/lib/windows-elevation.ts +8 -2
- package/src/oauth/google-antigravity.ts +7 -2
- package/src/providers/antigravity-models.ts +126 -17
- package/src/providers/derive.ts +11 -1
- package/src/responses/parser.ts +4 -0
- package/src/responses/reasoning-replay-cache.ts +16 -1
- package/src/responses/thought-signature-replay.ts +17 -1
- package/src/responses/truncated-stop-reason.ts +60 -0
- package/src/router.ts +2 -10
- package/src/server/management/provider-routes.ts +22 -0
- package/src/server/request-log.ts +11 -3
- package/src/server/responses/core.ts +2 -0
- 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
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
}))
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|