@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
@@ -48,19 +48,17 @@ const GOOGLE_BREVITY_INSTRUCTION = [
48
48
  ].join("\n");
49
49
 
50
50
  /**
51
- * Google renamed the current Gemini Flash generations on the Generative Language API,
52
- * appending a `-tiered` suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). The
53
- * old `gemini-3.7-flash` path 404s, so a saved config or registry entry naming the base
54
- * id must be resolved here before it reaches the URL. The user-facing id is deliberately
55
- * left alone: the picker, the catalog, the usage log and the price overlays all stay
56
- * keyed on the base id, and only the wire path learns the new spelling.
51
+ * Some Google direct deployments expose current Gemini Flash generations with a `-tiered`
52
+ * wire suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). Keep the picker-visible id
53
+ * stable and make the mapping configurable for deployments that still serve the bare id.
57
54
  */
58
55
  const GEMINI_DIRECT_WIRE_RENAMES: Record<string, string> = {
59
56
  "gemini-3.7-flash": "gemini-3.7-flash-tiered",
60
57
  "gemini-3.6-flash": "gemini-3.6-flash-tiered",
61
58
  };
62
59
 
63
- function resolveDirectGeminiWireModelId(modelId: string): string {
60
+ function resolveDirectGeminiWireModelId(modelId: string, applyRenames: boolean): string {
61
+ if (!applyRenames) return modelId;
64
62
  return Object.hasOwn(GEMINI_DIRECT_WIRE_RENAMES, modelId)
65
63
  ? GEMINI_DIRECT_WIRE_RENAMES[modelId]!
66
64
  : modelId;
@@ -147,7 +145,7 @@ function geminiToolResultText(content: string | OcxContentPart[]): string {
147
145
 
148
146
  function messagesToGeminiFormat(
149
147
  parsed: OcxParsedRequest,
150
- routedModelId = parsed.modelId,
148
+ identityModelId: string,
151
149
  ): { systemInstruction?: unknown; contents: unknown[] } {
152
150
  // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
153
151
  // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
@@ -156,7 +154,7 @@ function messagesToGeminiFormat(
156
154
  ...(parsed.context.systemPrompt ?? []),
157
155
  ...(toolCatalogNudge ? [toolCatalogNudge] : []),
158
156
  GOOGLE_BREVITY_INSTRUCTION,
159
- ].join("\n\n"), routedModelId);
157
+ ].join("\n\n"), identityModelId);
160
158
  const systemInstruction = { parts: [{ text: systemText }] };
161
159
 
162
160
  const contents: unknown[] = [];
@@ -395,9 +393,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
395
393
  ? resolveAntigravityEffortWireModel(
396
394
  parsed.modelId,
397
395
  mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning),
396
+ provider.baseUrl,
398
397
  ).wireModelId
399
- : resolveDirectGeminiWireModelId(parsed.modelId);
400
- const { systemInstruction, contents } = messagesToGeminiFormat(parsed, routedModelId);
398
+ : provider.googleMode === "vertex"
399
+ ? parsed.modelId
400
+ : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false);
401
+ // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation.
402
+ const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId;
403
+ const { systemInstruction, contents } = messagesToGeminiFormat(parsed, identityModelId);
401
404
  const tools = toolsToGeminiFormat(parsed);
402
405
 
403
406
  const body: Record<string, unknown> = { contents };
@@ -450,7 +453,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
450
453
  if (!project) throw new Error("Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).");
451
454
  const sessionId = antigravitySessionId(parsed);
452
455
  const mappedEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
453
- const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(parsed.modelId, mappedEffort);
456
+ const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(
457
+ parsed.modelId,
458
+ mappedEffort,
459
+ provider.baseUrl,
460
+ );
454
461
  antigravityModel = wireModelId;
455
462
  antigravitySession = sessionId;
456
463
  // Effort → thinkingConfig for CCA (CLIProxyAPI proven: request.generationConfig.thinkingConfig).
@@ -939,9 +946,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
939
946
  }
940
947
 
941
948
  const usage = json.usageMetadata as Record<string, number> | undefined;
949
+ // Mirror the streaming path: a buffered turn cut off by the token limit or a content filter
950
+ // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated
951
+ // turn as completed — and, on a compaction turn, installs the half-written summary as
952
+ // replacement history (#422).
953
+ const finishReason = candidates?.[0]?.finishReason as string | undefined;
954
+ const stopReason = finishReason === "MAX_TOKENS"
955
+ ? "max_tokens"
956
+ : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "")
957
+ ? "content_filter"
958
+ : undefined;
942
959
  events.push({
943
960
  type: "done",
944
961
  usage: usageFromGemini(usage),
962
+ ...(stopReason ? { stopReason } : {}),
945
963
  });
946
964
  return finish(events);
947
965
  },
@@ -7,8 +7,10 @@ const TRAILING_V1 = /\/v1\/?$/;
7
7
  * Custom `responsesPath` stays on the adapter; this helper is only the legacy /v1/responses branch.
8
8
  */
9
9
  export function openaiResponsesUrl(baseUrl: string): string {
10
- const trimmed = baseUrl.trim().replace(TRAILING_SLASHES, "");
11
- const withoutEndpoint = trimmed.replace(TRAILING_RESPONSES, "");
10
+ const url = new URL(baseUrl.trim());
11
+ const trimmedPath = url.pathname.replace(TRAILING_SLASHES, "");
12
+ const withoutEndpoint = trimmedPath.replace(TRAILING_RESPONSES, "");
12
13
  const withoutV1 = withoutEndpoint.replace(TRAILING_V1, "");
13
- return `${withoutV1}/v1/responses`;
14
+ url.pathname = `${withoutV1}/v1/responses`;
15
+ return url.toString();
14
16
  }
@@ -1,6 +1,7 @@
1
1
  import { createAnthropicAdapter } from "./anthropic";
2
2
  import { createAzureAdapter } from "./azure";
3
3
  import type { ProviderAdapter } from "./base";
4
+ import { withClinePassDeepSeekV4ToolReplayCompatibility } from "./cline-pass-deepseek-v4-tool-replay";
4
5
  import { createCommandCodeAdapter } from "./command-code";
5
6
  import { createCursorAdapter } from "./cursor";
6
7
  import { createGoogleAdapter } from "./google";
@@ -57,7 +58,8 @@ export const ADAPTER_REGISTRY = {
57
58
  "openai-chat": {
58
59
  wire: "openai-chat",
59
60
  mutation: "codex-owned",
60
- create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOpenAIChatAdapter(provider),
61
+ create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) =>
62
+ withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)),
61
63
  },
62
64
  anthropic: {
63
65
  wire: "anthropic",
@@ -17,8 +17,40 @@ import {
17
17
  // included it either.
18
18
  const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const;
19
19
 
20
+ /**
21
+ * The two halves of the code-mode shape, kept provider-neutral here.
22
+ *
23
+ * `./cursor/tool-definitions.ts` owns the Cursor-scoped versions of these
24
+ * (`isCursorCodeModeExecTool` / `isBareCodexShellBridgeTool`), but those additionally require
25
+ * the Cursor Responses namespace. This nudge is shared by Anthropic, Google, Kiro,
26
+ * OpenAI-chat and command-code, so it needs the same semantics without that provider gate.
27
+ */
28
+ const CODEX_UNIFIED_EXEC_TOOL_NAME = "exec";
29
+ const CODEX_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const;
30
+
31
+ function isCodexCodeModeExecTool(tool: Pick<OcxTool, "namespace" | "name" | "freeform">): boolean {
32
+ return !tool.namespace && tool.name === CODEX_UNIFIED_EXEC_TOOL_NAME && tool.freeform === true;
33
+ }
34
+
35
+ /**
36
+ * BARE means un-namespaced, and the word is load-bearing.
37
+ *
38
+ * An MCP server can advertise its own `exec_command` or `shell_command` — a docker, k8s or ssh
39
+ * server plausibly does — and those arrive namespaced (`mcp__docker__exec_command`). They are
40
+ * not Codex's shell bridge, so they must not cancel code mode: a genuine code-mode turn that
41
+ * merely happens to sit beside an MCP shell tool would lose its guidance and fall back to the
42
+ * generic sentence.
43
+ *
44
+ * The Cursor original this was ported from (`isBareCodexShellBridgeTool`) carries the same
45
+ * `!tool.namespace` requirement; dropping it here made the name assert a check the body did not
46
+ * perform.
47
+ */
48
+ function isBareShellBridgeTool(tool: Pick<OcxTool, "namespace" | "name">): boolean {
49
+ return !tool.namespace && (CODEX_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(tool.name);
50
+ }
51
+
20
52
  function quoteNames(names: readonly string[]): string {
21
- return names.map(name => `\`${name}\``).join(", ");
53
+ return names.map(name => "`" + name + "`").join(", ");
22
54
  }
23
55
 
24
56
  function uniqueNames(names: readonly string[]): string[] {
@@ -40,9 +72,33 @@ export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick<OcxProvider
40
72
  }
41
73
  }
42
74
 
75
+ /**
76
+ * Codex code mode is a SEMANTIC property, not a name.
77
+ *
78
+ * The tool that carries it is a `freeform` `exec` whose body is JavaScript evaluated in a V8
79
+ * isolate, advertised alongside no bare shell bridge. A provider is free to advertise an
80
+ * ordinary structured tool called `exec` that runs a shell string — and a catalog can list
81
+ * `exec` next to `exec_command`/`shell_command`, which is the flat-bridge shape, not code mode.
82
+ *
83
+ * Classifying on the name alone would tell those turns that `exec` takes JavaScript and that
84
+ * shell is only reachable as a nested `tools.*` helper. Both are false there, and a model that
85
+ * believes them sends the wrong arguments or avoids a legitimate execution tool entirely.
86
+ *
87
+ * So callers that HAVE the tool objects decide with the semantic predicate and pass the verified
88
+ * wire name in; the name-only entry point cannot decide it and does not try.
89
+ */
90
+ function codeModeExecWireName(
91
+ advertised: ReadonlySet<string>,
92
+ verifiedName: string | undefined,
93
+ ): string | undefined {
94
+ if (!verifiedName) return undefined;
95
+ return advertised.has(verifiedName) ? verifiedName : undefined;
96
+ }
97
+
43
98
  export function buildNonOpenAIToolCatalogNudgeFromNames(
44
99
  wireNames: readonly string[] | undefined,
45
100
  toWireName: (name: string) => string = name => name,
101
+ codeModeExecName?: string,
46
102
  ): string | undefined {
47
103
  const names = uniqueNames(wireNames ?? []);
48
104
  if (names.length === 0) return undefined;
@@ -50,21 +106,24 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
50
106
  const advertised = new Set(names);
51
107
  // Compare in the catalog's own coordinate system. `advertised` holds WIRE names, so a
52
108
  // provider that rewrites them (Claude OAuth `custom_`, Anthropic compat `cx_`) would never
53
- // match a bare neighbor name and would forbid tools the turn actually advertises the
109
+ // match a bare neighbor name and would forbid tools the turn actually advertises -- the
54
110
  // catalog would list `custom_apply_patch` while the same sentence banned `apply_patch`.
55
111
  const unavailableNeighborNames = NEIGHBOR_AGENT_TOOL_NAMES.filter(
56
112
  name => !advertised.has(name) && !advertised.has(toWireName(name)),
57
113
  );
114
+ const verifiedCodeModeExecName = codeModeExecWireName(advertised, codeModeExecName);
58
115
 
59
116
  return [
60
117
  "Tool contract: use the current tool catalog as ground truth.",
61
- `Valid tool names for this turn are exactly ${quoteNames(names)}.`,
118
+ "Valid tool names for this turn are exactly " + quoteNames(names) + ".",
62
119
  "These listed names are the complete top-level tool-call surface for this turn.",
63
120
  "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
64
121
  "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.",
65
- "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.",
122
+ verifiedCodeModeExecName
123
+ ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.<name>(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.<name>`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names."
124
+ : "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.",
66
125
  unavailableNeighborNames.length > 0
67
- ? `Do not use neighboring-agent tool names ${quoteNames(unavailableNeighborNames)} unless this turn's catalog lists those exact names.`
126
+ ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names."
68
127
  : undefined,
69
128
  "If you need shell, file search, file read, edit, or discovery behavior, choose the listed tool that provides that capability.",
70
129
  "Count a tool call only after its tool result returns; batch independent read-only calls when the runtime supports it.",
@@ -72,16 +131,24 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
72
131
  }
73
132
 
74
133
  export function buildNonOpenAIToolCatalogNudgeForTools(
75
- tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined,
134
+ tools: readonly Pick<OcxTool, "namespace" | "name" | "freeform">[] | undefined,
76
135
  toolChoice?: OcxRequestOptions["toolChoice"],
77
136
  toWireName: (tool: Pick<OcxTool, "namespace" | "name">) => string = tool => namespacedToolName(tool.namespace, tool.name),
78
137
  ): string | undefined {
79
- const visibleNames = tools
80
- ?.filter(toolChoiceToolPredicate(toolChoice))
81
- .map(toWireName);
138
+ const visible = tools?.filter(toolChoiceToolPredicate(toolChoice));
139
+ const visibleNames = visible?.map(toWireName);
140
+ // Decide code mode from the tool OBJECTS, while the `freeform` flag still exists — reducing
141
+ // to wire names first throws away the only thing that distinguishes Codex's JavaScript
142
+ // `exec` from an ordinary structured tool that happens to share the name.
143
+ const codeModeExecTool = visible?.find(isCodexCodeModeExecTool);
144
+ const codeModeExecName = codeModeExecTool
145
+ && !visible?.some(isBareShellBridgeTool)
146
+ ? toWireName(codeModeExecTool)
147
+ : undefined;
82
148
  // Neighbor names are bare and un-namespaced, so probe the same transform with a bare tool.
83
149
  return buildNonOpenAIToolCatalogNudgeFromNames(
84
150
  visibleNames,
85
151
  name => toWireName({ name }),
152
+ codeModeExecName,
86
153
  );
87
154
  }
package/src/bridge.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  import { coerceIntegerToolArguments } from "./lib/tool-argument-integers";
10
10
  import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
11
11
  import { encodeCompactionSummary } from "./responses/compaction";
12
+ import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason";
12
13
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
13
14
  import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
14
15
  import {
@@ -1154,7 +1155,11 @@ export function bridgeToResponsesSSE(
1154
1155
  // After every close above, so the blob lands AFTER the assistant message it belongs
1155
1156
  // to and the parser's backwards pairing finds it.
1156
1157
  flushKiroRedactedReasoning();
1157
- if (options?.compaction) {
1158
+ // Truncated turns must never install replacement history (#422). The buffered path
1159
+ // has always checked this; streaming emitted the item BEFORE reading stopReason, so
1160
+ // a max_tokens/content_filter turn shipped a half-written summary and then declared
1161
+ // itself incomplete — the same hazard, one branch over.
1162
+ if (options?.compaction && !isTruncatedStopReason(event.stopReason)) {
1158
1163
  // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
1159
1164
  const item = {
1160
1165
  type: "compaction", id: `cmp_${uuid()}`,
@@ -1164,14 +1169,18 @@ export function bridgeToResponsesSSE(
1164
1169
  retainFinishedItem(item as OutputItem, compactionTextBytes);
1165
1170
  outputIndex++;
1166
1171
  }
1167
- if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") {
1172
+ // Recognize every adapter's truncation vocabulary, not just the canonical pair.
1173
+ // Suppression and terminal status must agree: withholding the compaction item while
1174
+ // still reporting success hands codex-rs a completed response with zero compaction
1175
+ // items, which it treats as fatal.
1176
+ if (truncationReasonFor(event.stopReason)) {
1168
1177
  // Upstream stopped before a normal completion. Surface as incomplete so the
1169
1178
  // client can distinguish a truncated/filtered turn from a finished one.
1170
1179
  const response = {
1171
1180
  ...responseSnapshot("incomplete", finishedItems, event.endTurn),
1172
1181
  usage: responsesUsage(event.usage),
1173
1182
  incomplete_details: {
1174
- reason: event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter",
1183
+ reason: truncationReasonFor(event.stopReason) ?? "content_filter",
1175
1184
  },
1176
1185
  };
1177
1186
  // Cache max-output partials so previous_response_id replay can continue them;
@@ -1467,7 +1476,15 @@ function buildResponseJSONWithBudget(
1467
1476
  let incompleteEvent: Extract<AdapterEvent, { type: "incomplete" }> | undefined;
1468
1477
  let endTurn: boolean | undefined;
1469
1478
  let stopReason: string | undefined;
1479
+ // The adapter's stop reason exactly as it arrived. `stopReason` above is deliberately narrowed
1480
+ // to the two reasons that map onto a Responses `incomplete_details`; the raw value is what the
1481
+ // truncation guard needs, because adapters disagree on vocabulary (`length`, `refusal`, ...).
1482
+ let rawStopReason: string | undefined;
1470
1483
  let cleanDone = false;
1484
+ // Whether the adapter emitted ANY terminal (done/error/incomplete). Distinct from `cleanDone`,
1485
+ // which is only true for a `done` without a stop reason. A buffered turn whose adapter simply
1486
+ // stopped emitting has no terminal at all, and must not be reported as a success.
1487
+ let sawTerminal = false;
1471
1488
  let compactionText = "";
1472
1489
  let compactionTextBytes = 0;
1473
1490
 
@@ -1782,20 +1799,30 @@ function buildResponseJSONWithBudget(
1782
1799
  break;
1783
1800
  case "error":
1784
1801
  errorEvent = e;
1802
+ sawTerminal = true;
1785
1803
  usage = e.usage ?? usage;
1786
1804
  break;
1787
1805
  case "incomplete":
1788
1806
  incompleteEvent = e;
1807
+ sawTerminal = true;
1789
1808
  endTurn = e.endTurn;
1790
1809
  if (e.providerState) options?.onProviderState?.(e.providerState);
1791
1810
  break;
1792
1811
  case "done":
1793
1812
  usage = e.usage;
1813
+ sawTerminal = true;
1794
1814
  endTurn = e.endTurn;
1795
1815
  cleanDone = e.stopReason === undefined;
1816
+ rawStopReason = e.stopReason;
1796
1817
  if (e.providerState) options?.onProviderState?.(e.providerState);
1797
1818
  // Match streaming: max_tokens and content_filter both terminate as incomplete.
1798
- if (e.stopReason === "max_tokens" || e.stopReason === "content_filter") stopReason = e.stopReason;
1819
+ // Normalize every adapter's truncation vocabulary to the canonical pair, so a raw
1820
+ // `length` or `refusal` reaches the status/incomplete_details logic below instead of
1821
+ // silently reading as a clean stop.
1822
+ {
1823
+ const truncation = truncationReasonFor(e.stopReason);
1824
+ if (truncation) stopReason = truncation === "max_output_tokens" ? "max_tokens" : "content_filter";
1825
+ }
1799
1826
  break;
1800
1827
  }
1801
1828
  if (budget) releaseTranslatedEvent(e, budget);
@@ -1803,8 +1830,11 @@ function buildResponseJSONWithBudget(
1803
1830
  flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined);
1804
1831
  flushSummaryReasoning();
1805
1832
  flushRawReasoning();
1806
- // Open tool call on a failed/incomplete turn must not land as status:"completed".
1807
- if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
1833
+ // Open tool call on a failed/incomplete turn must not land as status:"completed" — and neither
1834
+ // must one left open by a stream that stopped without any terminal at all. That case previously
1835
+ // fell through to "completed", handing back a function_call whose arguments were half-written
1836
+ // JSON, inside a turn also marked completed.
1837
+ if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent || !sawTerminal ? "incomplete" : "completed");
1808
1838
  if (batchKiroRedacted) {
1809
1839
  // pushOutput reserves the item itself and releases the retained raw blob it replaces.
1810
1840
  pushOutput({
@@ -1820,8 +1850,12 @@ function buildResponseJSONWithBudget(
1820
1850
  options?.compaction
1821
1851
  && !errorEvent
1822
1852
  && !incompleteEvent
1823
- && stopReason !== "max_tokens"
1824
- && stopReason !== "content_filter"
1853
+ // A stream that stopped without any terminal did not complete either. The original guard
1854
+ // could only see explicit failure events, so an adapter EOF slipped past it and installed a
1855
+ // truncated summary as replacement history — the exact #422 hazard, reached by a route that
1856
+ // did not exist when the guard was written.
1857
+ && sawTerminal
1858
+ && !isTruncatedStopReason(rawStopReason)
1825
1859
  ) {
1826
1860
  pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes);
1827
1861
  }
@@ -1831,7 +1865,13 @@ function buildResponseJSONWithBudget(
1831
1865
  ? "failed"
1832
1866
  : incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter"
1833
1867
  ? "incomplete"
1834
- : "completed";
1868
+ : sawTerminal
1869
+ ? "completed"
1870
+ // The adapter stopped emitting without any terminal, so the turn was cut short. Streaming
1871
+ // already reports this as response.incomplete / adapter_eof (see the !terminated branch);
1872
+ // defaulting the buffered path to "completed" handed callers a truncated turn — including
1873
+ // one carrying a never-closed tool call with half-written JSON arguments — as a success.
1874
+ : "incomplete";
1835
1875
  options?.onUsage?.(incompleteEvent?.usage ?? usage);
1836
1876
  return {
1837
1877
  id: responseId, object: "response",
@@ -1851,6 +1891,10 @@ function buildResponseJSONWithBudget(
1851
1891
  incomplete_details: { reason: "max_output_tokens" },
1852
1892
  } : stopReason === "content_filter" ? {
1853
1893
  incomplete_details: { reason: "content_filter" },
1894
+ } : !sawTerminal ? {
1895
+ // Same reason string the streaming path uses, so a caller sees one signal for one condition
1896
+ // regardless of which surface it asked for.
1897
+ incomplete_details: { reason: "adapter_eof" },
1854
1898
  } : {}),
1855
1899
  usage: responsesUsage(incompleteEvent?.usage ?? usage),
1856
1900
  };
@@ -348,8 +348,34 @@ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] {
348
348
  * Exported for the Windows integration regression that exercises the real
349
349
  * PowerShell enumeration.
350
350
  */
351
- export function listWindowsSnapshots(): ProcessSnapshot[] {
351
+ /**
352
+ * Turn one PowerShell enumeration's stdout into snapshots.
353
+ *
354
+ * Split out from the spawn so the failure contract is testable off-Windows: the
355
+ * sentinel path is the difference between "no Codex process is running" and "we could
356
+ * not read the process list", and only one of those is safe to act on.
357
+ */
358
+ export function parseWindowsSnapshotOutput(output: string): ProcessSnapshot[] {
352
359
  const out: ProcessSnapshot[] = [];
360
+ for (const line of output.split(/\r?\n/)) {
361
+ // A candidate whose owner could not be verified — or a top-level query that
362
+ // failed outright — makes the whole enumeration incomplete. The staleness
363
+ // collector must not read the partial result as "nothing running".
364
+ if (line.trim() === "__OCX_ENUM_INCOMPLETE__") throw new Error("windows_enum_incomplete");
365
+ const tab = line.indexOf("\t");
366
+ if (tab <= 0) continue;
367
+ const tab2 = line.indexOf("\t", tab + 1);
368
+ if (tab2 <= tab) continue;
369
+ const pid = Number(line.slice(0, tab));
370
+ const commandLine = line.slice(tab + 1, tab2).trim();
371
+ const owner = line.slice(tab2 + 1).trim();
372
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue;
373
+ out.push({ pid, commandLine, owner });
374
+ }
375
+ return out;
376
+ }
377
+
378
+ export function listWindowsSnapshots(runPowerShell?: (psCommand: string) => string): ProcessSnapshot[] {
353
379
  // Newlines keep -Command as a real script (space-joined statements need ';').
354
380
  // Double-quoted format string so `t expands to a real tab.
355
381
  // Codex candidates only: basename token codex / codex.exe / codex.cmd /
@@ -361,7 +387,15 @@ export function listWindowsSnapshots(): ProcessSnapshot[] {
361
387
  const psCommand = [
362
388
  "$ErrorActionPreference='SilentlyContinue'",
363
389
  "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name",
364
- "Get-CimInstance Win32_Process | Where-Object {",
390
+ // -ErrorAction Stop plus the outer try is what makes a TOP-LEVEL query failure
391
+ // observable. Under SilentlyContinue alone, a failing Get-CimInstance emits nothing
392
+ // and the enumeration is indistinguishable from "no Codex process is running" —
393
+ // the parse loop finds no rows, no sentinel is produced, and the staleness collector
394
+ // reports not_running for a machine whose process list it never actually read.
395
+ // The per-process catch below cannot cover this: it only runs once the pipeline has
396
+ // objects to iterate.
397
+ "try {",
398
+ "Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object {",
365
399
  " -not [string]::IsNullOrWhiteSpace($_.CommandLine) -and (",
366
400
  ` $_.CommandLine -match ${basenameMatch} -or`,
367
401
  ` $_.CommandLine -match ${codeModeMatch}`,
@@ -376,31 +410,19 @@ export function listWindowsSnapshots(): ProcessSnapshot[] {
376
410
  " \"{0}`t{1}`t{2}\" -f $_.ProcessId, $cmd, $owner",
377
411
  " } catch { \"__OCX_ENUM_INCOMPLETE__\" }",
378
412
  "}",
413
+ "} catch { \"__OCX_ENUM_INCOMPLETE__\" }",
379
414
  ].join("\n");
380
415
  // Top-level exec failure propagates (see listDarwinSnapshots note). The
381
416
  // executable resolves from the trusted System32 directory (never PATH), and
382
417
  // windowsHide keeps the enumeration console-less on desktop sessions (#1278).
383
- const output = execFileSync(resolveTrustedWindowsPowerShellExe(), [
384
- "-NoProfile", "-NoLogo", "-NonInteractive",
385
- "-Command",
386
- psCommand,
387
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true });
388
- for (const line of output.split(/\r?\n/)) {
389
- // A candidate whose owner could not be verified makes the whole
390
- // enumeration incomplete — the staleness collector must not read the
391
- // partial result as "nothing running".
392
- if (line.trim() === "__OCX_ENUM_INCOMPLETE__") throw new Error("windows_enum_incomplete");
393
- const tab = line.indexOf("\t");
394
- if (tab <= 0) continue;
395
- const tab2 = line.indexOf("\t", tab + 1);
396
- if (tab2 <= tab) continue;
397
- const pid = Number(line.slice(0, tab));
398
- const commandLine = line.slice(tab + 1, tab2).trim();
399
- const owner = line.slice(tab2 + 1).trim();
400
- if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue;
401
- out.push({ pid, commandLine, owner });
402
- }
403
- return out;
418
+ const output = runPowerShell
419
+ ? runPowerShell(psCommand)
420
+ : execFileSync(resolveTrustedWindowsPowerShellExe(), [
421
+ "-NoProfile", "-NoLogo", "-NonInteractive",
422
+ "-Command",
423
+ psCommand,
424
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 8_000, windowsHide: true });
425
+ return parseWindowsSnapshotOutput(output);
404
426
  }
405
427
 
406
428
  function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] {
@@ -592,6 +614,18 @@ function defaultCatalogMtimeMs(): number | null {
592
614
  // guidance calls (#857).
593
615
  let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null;
594
616
  const CATALOG_STATE_TTL_MS = 5_000;
617
+ /**
618
+ * `unknown` is a failure to observe, not an observation, so it gets a much shorter
619
+ * window than a real reading. At the full 5s a single transient enumeration failure
620
+ * suppresses guidance for every call in that window, and the retry that would have
621
+ * succeeded never runs. Keeping a brief window still collapses a burst of per-turn
622
+ * calls into one probe, which is what the cache is for.
623
+ */
624
+ const CATALOG_STATE_UNKNOWN_TTL_MS = 250;
625
+
626
+ export function catalogStateTtlMs(state: CodexAppServerCatalogState): number {
627
+ return state === "unknown" ? CATALOG_STATE_UNKNOWN_TTL_MS : CATALOG_STATE_TTL_MS;
628
+ }
595
629
 
596
630
  /**
597
631
  * Compare the on-disk catalog mtime against the start time of running Codex
@@ -616,7 +650,8 @@ export function collectCodexAppServerCatalogState(
616
650
  const fullyDefault = !io.listSnapshots && !io.readStartMs && !io.catalogMtimeMs
617
651
  && !io.platform && !io.getuid && !io.now;
618
652
  if (fullyDefault
619
- && catalogStateCache && now - catalogStateCache.atMs < CATALOG_STATE_TTL_MS) {
653
+ && catalogStateCache
654
+ && now - catalogStateCache.atMs < catalogStateTtlMs(catalogStateCache.status.state)) {
620
655
  return catalogStateCache.status;
621
656
  }
622
657
  const compute = (): CodexAppServerCatalogStatus => {
@@ -630,17 +665,16 @@ export function collectCodexAppServerCatalogState(
630
665
  });
631
666
  let snapshots: ProcessSnapshot[];
632
667
  let enumerationFailed = false;
633
- if (io.listSnapshots) {
634
- snapshots = io.listSnapshots();
635
- } else {
636
- try {
637
- snapshots = defaultListSnapshots(platform, getuid);
638
- } catch {
639
- // Enumeration failure must never read as "nothing running" that
640
- // would let positive model guidance through on guesswork (#857).
641
- snapshots = [];
642
- enumerationFailed = true;
643
- }
668
+ const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid));
669
+ try {
670
+ snapshots = enumerate();
671
+ } catch {
672
+ // Enumeration failure must never read as "nothing running" — that would let
673
+ // positive model guidance through on guesswork (#857). The injected seam gets
674
+ // the same contract as the default path: whoever enumerates, a failure to read
675
+ // the process list is unknown, not an empty machine.
676
+ snapshots = [];
677
+ enumerationFailed = true;
644
678
  }
645
679
  const processes: CodexAppServerProcess[] = [];
646
680
  const seen = new Set<number>();
@@ -38,7 +38,7 @@ import {
38
38
  type CapturedServiceTierAdapterAuthority,
39
39
  } from "../../providers/service-tier";
40
40
  import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
41
- import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
41
+ import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
42
42
  import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
43
43
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
44
44
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
@@ -1374,6 +1374,10 @@ async function fetchProviderModelsWithAuth(
1374
1374
  if (!setCached(name, forCache, Date.now(), cacheGeneration)) {
1375
1375
  return observed(withConfiguredRetention(configured), "degraded");
1376
1376
  }
1377
+ registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, {
1378
+ provider: name,
1379
+ cacheGeneration,
1380
+ });
1377
1381
  markProviderDiscoveryOk(name, live.length);
1378
1382
  return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
1379
1383
  }
package/src/config.ts CHANGED
@@ -741,6 +741,7 @@ const providerConfigSchema = z.object({
741
741
  upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
742
742
  .nullish()
743
743
  .transform(value => value ?? undefined),
744
+ directGeminiWireRenames: z.boolean().optional(),
744
745
  noStructuredOutputModels: z.array(z.string().min(1))
745
746
  .transform(normalizeNonBlankStringArray)
746
747
  .optional(),