@bitkyc08/opencodex 2.24.1 → 2.25.0-preview.20260818

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 (58) hide show
  1. package/gui/dist/assets/{index-C3FiAveG.js → index-TFd4xi1L.js} +8 -8
  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/claude/context-windows.ts +2 -2
  22. package/src/claude/desktop-3p.ts +6 -6
  23. package/src/claude/model-info.ts +2 -2
  24. package/src/cli/claude-desktop.ts +2 -3
  25. package/src/codex/app-server-processes.ts +69 -35
  26. package/src/codex/catalog/metadata.ts +29 -10
  27. package/src/codex/catalog/provider-fetch.ts +21 -11
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/injected-marker.ts +9 -3
  30. package/src/codex/user-identity.ts +88 -6
  31. package/src/config.ts +1 -0
  32. package/src/generated/compatibility-version.json +61 -53
  33. package/src/grok/sync.ts +2 -4
  34. package/src/lab/projection/rebuild.ts +36 -18
  35. package/src/lib/windows-elevation.ts +18 -3
  36. package/src/lib/windows-secret-acl.ts +49 -19
  37. package/src/oauth/google-antigravity.ts +7 -2
  38. package/src/providers/antigravity-models.ts +126 -17
  39. package/src/providers/derive.ts +11 -1
  40. package/src/responses/parser.ts +4 -0
  41. package/src/responses/reasoning-replay-cache.ts +16 -1
  42. package/src/responses/thought-signature-replay.ts +17 -1
  43. package/src/responses/truncated-stop-reason.ts +60 -0
  44. package/src/router.ts +2 -10
  45. package/src/routing/capability.ts +5 -6
  46. package/src/server/index.ts +3 -4
  47. package/src/server/management/agent-settings-routes.ts +5 -5
  48. package/src/server/management/config-routes.ts +2 -2
  49. package/src/server/management/context.ts +2 -0
  50. package/src/server/management/native-integration-routes.ts +3 -3
  51. package/src/server/management/provider-routes.ts +22 -0
  52. package/src/server/management/shared.ts +4 -4
  53. package/src/server/management-api.ts +2 -2
  54. package/src/server/request-log.ts +11 -3
  55. package/src/server/responses/core.ts +4 -1
  56. package/src/server/responses/input-admission.ts +13 -10
  57. package/src/server/system-env.ts +3 -3
  58. package/src/types.ts +13 -1
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-C3FiAveG.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-TFd4xi1L.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DQsMZzI5.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.24.1",
3
+ "version": "2.25.0-preview.20260818",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -989,6 +989,18 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
989
989
  const emitDone = function* (): Generator<AdapterEvent> {
990
990
  if (emittedDone) return;
991
991
  emittedDone = true;
992
+ // An `error` stop reason is a failed generation, not a stop. Forwarding it as `done`
993
+ // lets the turn report success and install replacement history on a compaction turn.
994
+ if (pendingStopReason === "error") {
995
+ yield {
996
+ type: "error",
997
+ message: "upstream ended the turn with stop_reason \"error\"",
998
+ status: 502,
999
+ errorType: "upstream_error",
1000
+ usage: usageFromAnthropic(pendingUsage),
1001
+ };
1002
+ return;
1003
+ }
992
1004
  yield {
993
1005
  type: "done",
994
1006
  usage: usageFromAnthropic(pendingUsage),
@@ -1143,6 +1155,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1143
1155
  if (!emittedDone) {
1144
1156
  // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
1145
1157
  if (pendingStopReason !== undefined) {
1158
+ // Same rule as emitDone: an `error` stop reason is a failed generation, not a stop.
1159
+ // This branch bypasses emitDone entirely (it exists for providers that close after
1160
+ // message_delta without message_stop), so the check has to be repeated here or the
1161
+ // EOF route silently reports success.
1162
+ if (pendingStopReason === "error") {
1163
+ emittedDone = true;
1164
+ yield {
1165
+ type: "error",
1166
+ message: "upstream ended the turn with stop_reason \"error\"",
1167
+ status: 502,
1168
+ errorType: "upstream_error",
1169
+ usage: usageFromAnthropic(pendingUsage),
1170
+ };
1171
+ return;
1172
+ }
1146
1173
  const stopReason = pendingStopReason === "max_tokens"
1147
1174
  ? "max_tokens"
1148
1175
  : pendingStopReason === "refusal" || pendingStopReason === "content_filter"
@@ -1210,6 +1237,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1210
1237
  }
1211
1238
  const usage = json.usage as Record<string, number> | undefined;
1212
1239
  const stopReason = typeof json.stop_reason === "string" ? json.stop_reason : undefined;
1240
+ // An Anthropic-compatible upstream can forward an `error` stop reason verbatim. As a
1241
+ // `done` it reads as a clean completion, so the turn reports success and — on a compaction
1242
+ // turn — installs its partial summary as replacement history (#422). Usage is preserved:
1243
+ // a failed turn still consumed tokens.
1244
+ if (stopReason === "error") {
1245
+ events.push({
1246
+ type: "error",
1247
+ message: "upstream ended the turn with stop_reason \"error\"",
1248
+ status: 502,
1249
+ errorType: "upstream_error",
1250
+ usage: usageFromAnthropic(usage),
1251
+ });
1252
+ retainTranslatedEventBatch(events, budget);
1253
+ return events;
1254
+ }
1213
1255
  events.push({
1214
1256
  type: "done",
1215
1257
  usage: usageFromAnthropic(usage),
@@ -49,15 +49,19 @@ const ANTIGRAVITY_IDE_PLATFORM = "windows/amd64";
49
49
  export const ANTIGRAVITY_GOOG_API_CLIENT_UA = "google-api-nodejs-client/10.3.0";
50
50
 
51
51
  /**
52
- * The real Antigravity IDE User-Agent, e.g.
53
- * `antigravity/ide/2.5.5 (aidev_client; os_type=windows; arch=amd64)`.
52
+ * Real Antigravity IDE User-Agent format, decompiled from 2.5.5 Go LS (`setHeaders` @ `0x1018fbe00`):
53
+ * `antigravity/ide/${version} (os_type=${osType}; arch=${arch}; aidev_client; auth_method=oauth)`
54
54
  *
55
- * Must be the IDE client family, NOT `antigravity/cli/...`: the Cloud Code Assist backend gates
55
+ * Token ordering from decompiled binary: `os_type` -> `arch` -> `aidev_client` -> `auth_method=oauth`.
56
+ *
57
+ * Must be the IDE client family (`antigravity/ide/...`): Cloud Code Assist backend gates
56
58
  * newer agent models (e.g. `gemini-3.7-flash`) by User-Agent and answers 404 NOT_FOUND to
57
59
  * CLI-shaped UAs even with a valid OAuth token. Only `antigravity/ide/<ver>` unlocks them.
58
60
  * A `GOOGLE_ANTIGRAVITY_USER_AGENT` override (set by the caller) takes precedence upstream.
59
61
  */
60
- export function antigravityUserAgent(version = ANTIGRAVITY_IDE_VERSION): string {
62
+ export function antigravityUserAgent(version = ANTIGRAVITY_IDE_VERSION, authMethod = "oauth"): string {
63
+ const ov = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT?.trim();
64
+ if (ov) return ov;
61
65
  const [osType, arch] = ANTIGRAVITY_IDE_PLATFORM.split("/");
62
- return `antigravity/ide/${version} (${ANTIGRAVITY_IDE_CLIENT_NAME}; os_type=${osType}; arch=${arch})`;
66
+ return `antigravity/ide/${version} (os_type=${osType}; arch=${arch}; ${ANTIGRAVITY_IDE_CLIENT_NAME}; auth_method=${authMethod})`;
63
67
  }
@@ -0,0 +1,69 @@
1
+ import type { ProviderAdapter } from "./base";
2
+
3
+ const CLINE_PASS_DEEPSEEK_V4_MODELS = new Set([
4
+ "cline-pass/deepseek-v4-flash",
5
+ "cline-pass/deepseek-v4-pro",
6
+ ]);
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+
12
+ export function isClinePassDeepSeekV4Model(modelId: string): boolean {
13
+ return CLINE_PASS_DEEPSEEK_V4_MODELS.has(modelId);
14
+ }
15
+
16
+ /**
17
+ * DeepSeek V4 can copy historical pre-tool narration back into the next turn and
18
+ * eventually degenerate into text-only "I'll call the tool" loops. For the two
19
+ * affected ClinePass models, replay historical assistant tool turns as the
20
+ * structured call only. Normal assistant messages, tool results, and separate
21
+ * reasoning metadata remain untouched.
22
+ */
23
+ export function stripClinePassDeepSeekV4ToolReplayNarration(
24
+ body: string,
25
+ modelId: string,
26
+ ): string {
27
+ if (!isClinePassDeepSeekV4Model(modelId)) return body;
28
+
29
+ let parsed: unknown;
30
+ try {
31
+ parsed = JSON.parse(body);
32
+ } catch {
33
+ return body;
34
+ }
35
+ if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body;
36
+
37
+ let changed = false;
38
+ const messages = parsed.messages.map(message => {
39
+ if (!isRecord(message) || message.role !== "assistant") return message;
40
+ const toolCalls = message.tool_calls;
41
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) return message;
42
+ if (message.content === "") return message;
43
+
44
+ changed = true;
45
+ return { ...message, content: "" };
46
+ });
47
+
48
+ return changed ? JSON.stringify({ ...parsed, messages }) : body;
49
+ }
50
+
51
+ /**
52
+ * Apply the ClinePass DeepSeek V4 replay compatibility policy after the ordinary
53
+ * OpenAI-chat request has been serialized. The adapter's response parsing and all
54
+ * non-target request behavior stay identical.
55
+ */
56
+ export function withClinePassDeepSeekV4ToolReplayCompatibility(
57
+ adapter: ProviderAdapter,
58
+ ): ProviderAdapter {
59
+ return {
60
+ ...adapter,
61
+ async buildRequest(parsed, incoming) {
62
+ const request = await adapter.buildRequest(parsed, incoming);
63
+ if (!isClinePassDeepSeekV4Model(parsed.modelId)) return request;
64
+
65
+ const body = stripClinePassDeepSeekV4ToolReplayNarration(request.body, parsed.modelId);
66
+ return body === request.body ? request : { ...request, body };
67
+ },
68
+ };
69
+ }
@@ -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;