@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
@@ -126,6 +126,7 @@ function toolResultImageParts(content: string | OcxContentPart[]): unknown[] {
126
126
  */
127
127
  const GEMINI_EMPTY_PLACEHOLDER = "(empty)";
128
128
  const GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER = "(empty tool output)";
129
+ const GEMINI_MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]";
129
130
 
130
131
  /** A Gemini text part, or undefined when the value cannot form a valid non-empty text block. */
131
132
  function geminiTextPart(text: unknown): { text: string } | undefined {
@@ -144,6 +145,42 @@ function geminiToolResultText(content: string | OcxContentPart[]): string {
144
145
  return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
145
146
  }
146
147
 
148
+ function geminiToolResultParts(
149
+ msg: OcxToolResultMessage,
150
+ wireName: string,
151
+ wireCallId: string,
152
+ ): unknown[] {
153
+ const functionResponse: Record<string, unknown> = {
154
+ name: wireName,
155
+ response: { result: geminiToolResultText(msg.content) },
156
+ id: wireCallId,
157
+ };
158
+ return [{ functionResponse }, ...toolResultImageParts(msg.content)];
159
+ }
160
+
161
+ function geminiMissingToolResultPart(wireName: string, wireCallId: string): unknown {
162
+ return {
163
+ functionResponse: {
164
+ name: wireName,
165
+ response: { result: GEMINI_MISSING_TOOL_RESULT },
166
+ id: wireCallId,
167
+ },
168
+ };
169
+ }
170
+
171
+ function geminiUnrepresentableToolCallPart(tc: OcxToolCall, wireName: string): unknown {
172
+ const args = typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments);
173
+ return { text: `[tool_use without a usable id: ${wireName}]\n${args}` };
174
+ }
175
+
176
+ function geminiOrphanToolResultParts(msg: OcxToolResultMessage): unknown[] {
177
+ const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId;
178
+ return [
179
+ { text: `[tool_result without adjacent tool_use: ${label}]\n${geminiToolResultText(msg.content)}` },
180
+ ...toolResultImageParts(msg.content),
181
+ ];
182
+ }
183
+
147
184
  function messagesToGeminiFormat(
148
185
  parsed: OcxParsedRequest,
149
186
  identityModelId: string,
@@ -170,7 +207,8 @@ function messagesToGeminiFormat(
170
207
  callIds.reserve((msg as OcxToolResultMessage).toolCallId);
171
208
  }
172
209
  }
173
- for (const msg of parsed.context.messages) {
210
+ for (let i = 0; i < parsed.context.messages.length; i++) {
211
+ const msg = parsed.context.messages[i];
174
212
  switch (msg.role) {
175
213
  case "user":
176
214
  case "developer": {
@@ -197,6 +235,7 @@ function messagesToGeminiFormat(
197
235
  case "assistant": {
198
236
  const aMsg = msg as OcxAssistantMessage;
199
237
  const parts: unknown[] = [];
238
+ const toolCalls: Array<{ wireCallId: string; wireName: string }> = [];
200
239
  for (const p of aMsg.content) {
201
240
  if (p.type === "text") {
202
241
  const textPart = geminiTextPart((p as OcxTextContent).text);
@@ -209,10 +248,19 @@ function messagesToGeminiFormat(
209
248
  // Responses parser also stashes synthetic item ids (`fc_...`) on this field, and sending
210
249
  // those as a thoughtSignature breaks continuity (the replay cache supplies the real one).
211
250
  const callId = callIds.allocate(tc.id);
212
- const functionCall: Record<string, unknown> = { name: namespacedToolName(tc.namespace, tc.name), args: tc.arguments };
251
+ const wireName = namespacedToolName(tc.namespace, tc.name);
252
+ if (callId === undefined) {
253
+ // Claude-on-Antigravity requires a usable id for every translated tool_use. An empty
254
+ // source id cannot be paired safely, so preserve the call as text and let its result
255
+ // follow the same orphan-text path instead of emitting an invalid functionCall.
256
+ parts.push(geminiUnrepresentableToolCallPart(tc, wireName));
257
+ continue;
258
+ }
259
+ const functionCall: Record<string, unknown> = { name: wireName, args: tc.arguments };
213
260
  // Claude-on-Antigravity maps this id to Anthropic `tool_use.id`; without it the upstream
214
261
  // conversion 400s. Gemini accepts the optional id and pairs call/response by it.
215
- if (callId !== undefined) functionCall.id = callId;
262
+ functionCall.id = callId;
263
+ toolCalls.push({ wireCallId: callId, wireName });
216
264
  const part: Record<string, unknown> = { functionCall };
217
265
  // Prefer the metadata that travelled with this exact call; fall back to the legacy
218
266
  // field for callers that have not been migrated. Never merge or synthesize.
@@ -232,22 +280,44 @@ function messagesToGeminiFormat(
232
280
  // adapter does for its own empty assistant content.
233
281
  if (parts.length === 0) break;
234
282
  contents.push({ role: "model", parts });
283
+ if (toolCalls.length > 0) {
284
+ // Gemini/Claude-on-Antigravity requires one adjacent response batch for the whole
285
+ // function-call turn. Replayed histories can be interrupted, reversed, duplicated, or
286
+ // contain an orphan result; repair only this wire boundary without inventing success.
287
+ const requiredIds = new Set(toolCalls.map(call => call.wireCallId));
288
+ const resultsById = new Map<string, OcxToolResultMessage>();
289
+ const orphanResults: OcxToolResultMessage[] = [];
290
+ let j = i + 1;
291
+ while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") {
292
+ const result = parsed.context.messages[j] as OcxToolResultMessage;
293
+ const wireResultId = callIds.lookup(result.toolCallId);
294
+ if (wireResultId !== undefined && requiredIds.has(wireResultId) && !resultsById.has(wireResultId)) {
295
+ resultsById.set(wireResultId, result);
296
+ } else {
297
+ orphanResults.push(result);
298
+ }
299
+ j++;
300
+ }
301
+
302
+ const responseParts: unknown[] = [];
303
+ for (const call of toolCalls) {
304
+ const result = resultsById.get(call.wireCallId);
305
+ if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId));
306
+ else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId));
307
+ }
308
+ for (const orphan of orphanResults) {
309
+ responseParts.push(...geminiOrphanToolResultParts(orphan));
310
+ }
311
+ contents.push({ role: "user", parts: responseParts });
312
+ i = j - 1;
313
+ }
235
314
  break;
236
315
  }
237
316
  case "toolResult": {
238
- // The functionResponse part carries the textual result. Gemini cannot embed images inside a
239
- // functionResponse, but it does accept sibling inline_data parts in the same user turn, so
240
- // tool-result screenshots (e.g. Computer Use) ride along as inline_data instead of being
241
- // flattened to a "[image]" marker the model can't actually see.
242
- // lookup(), not allocate(): a response must reuse its call's id and must never mint a new one.
243
- const responseId = callIds.lookup(msg.toolCallId);
244
- const functionResponse: Record<string, unknown> = { name: namespacedToolName(msg.toolNamespace, msg.toolName), response: { result: geminiToolResultText(msg.content) } };
245
- // Mirror the matching functionCall id so Claude-on-Antigravity can pair this result with its
246
- // `tool_use` block (-> Anthropic `tool_result.tool_use_id`).
247
- if (responseId !== undefined) functionResponse.id = responseId;
248
- const parts: unknown[] = [{ functionResponse }];
249
- for (const part of toolResultImageParts(msg.content)) parts.push(part);
250
- contents.push({ role: "user", parts });
317
+ // A standalone functionResponse is invalid without an immediately preceding matching
318
+ // functionCall batch. Preserve the result as explicit user text (plus any representable
319
+ // image siblings) rather than manufacturing a successful call or sending a 400-prone shape.
320
+ contents.push({ role: "user", parts: geminiOrphanToolResultParts(msg as OcxToolResultMessage) });
251
321
  break;
252
322
  }
253
323
  }
@@ -339,11 +409,16 @@ function artifactMarkdownUrl(filePath: string): string {
339
409
  }
340
410
 
341
411
  interface GoogleResponsePart {
342
- text?: string;
412
+ text?: unknown;
343
413
  thought?: boolean;
344
414
  thoughtSignature?: string;
345
415
  thought_signature?: string;
346
- functionCall?: { name: string; args: unknown };
416
+ functionCall?: unknown;
417
+ }
418
+
419
+ interface GoogleFunctionCall {
420
+ name: string;
421
+ args?: unknown;
347
422
  }
348
423
 
349
424
  /**
@@ -366,12 +441,154 @@ function googleToolCallMetadataFromPart(
366
441
  * cannot accidentally expose the same hidden reasoning through different event types.
367
442
  */
368
443
  function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined {
369
- if (!part.text) return undefined;
444
+ // A malformed scalar/object is not text and must not cross the AdapterEvent boundary. Dropping
445
+ // only this optional field preserves the rest of the part without inventing assistant output by
446
+ // coercion; an empty string keeps its existing no-event behavior.
447
+ if (typeof part.text !== "string" || part.text.length === 0) return undefined;
370
448
  return part.thought === true
371
449
  ? { type: "reasoning_raw_delta", text: part.text }
372
450
  : { type: "text_delta", text: part.text };
373
451
  }
374
452
 
453
+ interface InvalidGoogleFunctionCallDiagnostic {
454
+ reason:
455
+ | "function_call_not_object"
456
+ | "function_call_name_invalid"
457
+ | "function_call_name_blank";
458
+ partIndex: number;
459
+ valueType: string;
460
+ }
461
+
462
+ interface InvalidGoogleShapeDiagnostic {
463
+ reason:
464
+ | "candidates_not_array"
465
+ | "candidate_not_object"
466
+ | "content_not_object"
467
+ | "parts_not_array"
468
+ | "part_not_object";
469
+ partIndex?: number;
470
+ valueType: string;
471
+ }
472
+
473
+ function isGoogleRecord(value: unknown): value is Record<string, unknown> {
474
+ return value !== null && typeof value === "object" && !Array.isArray(value);
475
+ }
476
+
477
+ function googleStructuralValueType(value: unknown): string {
478
+ if (value === null) return "null";
479
+ return Array.isArray(value) ? "array" : typeof value;
480
+ }
481
+
482
+ /**
483
+ * Gemini delivers one complete functionCall per part, so a missing name cannot be repaired by a
484
+ * later delta. Validate the whole parts array before observing signatures or emitting content: a
485
+ * malformed call must terminate the claimed response rather than enter replay state or reach the
486
+ * bridge as a nameless dispatch. Null remains an absence encoding, matching other optional fields.
487
+ */
488
+ function diagnoseGoogleFunctionCalls(
489
+ parts: readonly GoogleResponsePart[],
490
+ ): InvalidGoogleFunctionCallDiagnostic | undefined {
491
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
492
+ const functionCall = parts[partIndex]?.functionCall;
493
+ if (functionCall === undefined || functionCall === null) continue;
494
+ if (!isGoogleRecord(functionCall)) {
495
+ return {
496
+ reason: "function_call_not_object",
497
+ partIndex,
498
+ valueType: googleStructuralValueType(functionCall),
499
+ };
500
+ }
501
+ if (typeof functionCall.name !== "string") {
502
+ return {
503
+ reason: "function_call_name_invalid",
504
+ partIndex,
505
+ valueType: googleStructuralValueType(functionCall.name),
506
+ };
507
+ }
508
+ if (functionCall.name.trim().length === 0) {
509
+ return {
510
+ reason: "function_call_name_blank",
511
+ partIndex,
512
+ valueType: "string",
513
+ };
514
+ }
515
+ }
516
+ return undefined;
517
+ }
518
+
519
+ function googleFunctionCall(part: GoogleResponsePart): GoogleFunctionCall | undefined {
520
+ const functionCall = part.functionCall;
521
+ if (!isGoogleRecord(functionCall) || typeof functionCall.name !== "string") return undefined;
522
+ return { name: functionCall.name, args: functionCall.args };
523
+ }
524
+
525
+ function invalidGoogleFunctionCallEvent(
526
+ diagnostic: InvalidGoogleFunctionCallDiagnostic,
527
+ ): Extract<AdapterEvent, { type: "error" }> {
528
+ const subject = diagnostic.reason === "function_call_not_object"
529
+ ? "invalid function call"
530
+ : diagnostic.reason === "function_call_name_blank"
531
+ ? "blank function call name"
532
+ : "invalid function call name";
533
+ return {
534
+ type: "error",
535
+ message: `google response contained ${subject} (${diagnostic.reason}; partIndex=${diagnostic.partIndex}; valueType=${diagnostic.valueType}) — cannot dispatch`,
536
+ };
537
+ }
538
+
539
+ /**
540
+ * A candidate's `content` is claimed model output inside a well-formed frame, so it is governed by
541
+ * the #1332 nested-shape rule (fail closed) rather than #1240's root-frame padding rule (skip).
542
+ *
543
+ * Absence stays legal, and so does one encoding of it: an empty array is how a JSON writer with no
544
+ * distinct empty-object form spells an empty `content`, and it already behaves as "no parts". A
545
+ * NON-empty array is the opposite case — `content?.parts` silently reads `undefined` from it, so a
546
+ * candidate shaped `content: [{ parts: [...] }]` dropped its own text and completed as an empty
547
+ * turn.
548
+ */
549
+ function diagnoseGoogleContent(content: unknown): InvalidGoogleShapeDiagnostic | undefined {
550
+ if (content === undefined || content === null || isGoogleRecord(content)) return undefined;
551
+ if (Array.isArray(content) && content.length === 0) return undefined;
552
+ return { reason: "content_not_object", valueType: googleStructuralValueType(content) };
553
+ }
554
+
555
+ /**
556
+ * `content.parts` sits one rung below the candidate guard added in #1332, and both parsers
557
+ * consumed it unchecked: `for (const part of {})` throws `{} is not iterable`, and a `[null]`
558
+ * element throws on `part.thoughtSignature`. A `parts` that is absent or `null` keeps its existing
559
+ * skip — only a present, non-null container is validated.
560
+ */
561
+ function diagnoseGoogleParts(parts: unknown): InvalidGoogleShapeDiagnostic | undefined {
562
+ if (!Array.isArray(parts)) {
563
+ return { reason: "parts_not_array", valueType: googleStructuralValueType(parts) };
564
+ }
565
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
566
+ const part: unknown = parts[partIndex];
567
+ if (!isGoogleRecord(part)) {
568
+ return { reason: "part_not_object", partIndex, valueType: googleStructuralValueType(part) };
569
+ }
570
+ }
571
+ return undefined;
572
+ }
573
+
574
+ function invalidGoogleShapeEvent(
575
+ diagnostic: InvalidGoogleShapeDiagnostic,
576
+ ): Extract<AdapterEvent, { type: "error" }> {
577
+ const at = diagnostic.partIndex !== undefined ? `; partIndex=${diagnostic.partIndex}` : "";
578
+ // The subject names the rung that failed, so an operator reading a log can tell a broken
579
+ // candidate list from a well-formed candidate whose parts are broken. The candidate subject keeps
580
+ // the exact wording #1332 introduced as its prefix, so an existing grep still matches.
581
+ const subject = diagnostic.reason === "candidates_not_array" || diagnostic.reason === "candidate_not_object"
582
+ ? "candidates"
583
+ : diagnostic.reason === "content_not_object"
584
+ ? "content"
585
+ : "content parts";
586
+ return {
587
+ type: "error",
588
+ message: `google response contained invalid ${subject} (${diagnostic.reason}${at}; valueType=${diagnostic.valueType})`,
589
+ };
590
+ }
591
+
375
592
  export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
376
593
  // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest
377
594
  // can stash the CCA model/session for parseStream's reasoning-replay observation.
@@ -669,22 +886,34 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
669
886
  sawTerminalSignal = true;
670
887
  }
671
888
  const rawCandidates = root.candidates;
672
- if (rawCandidates === undefined) return "continue";
889
+ // `null` is an absence encoding, not corruption, and terminating on it is the #1219
890
+ // failure mode one rung in: a `{"candidates":null}` frame arriving between a content
891
+ // delta and the finish chunk killed a turn whose answer had already fully arrived. An
892
+ // absent key and an empty array are already skipped here; `null` joins them. A non-null
893
+ // non-array container is still claimed structure the parser cannot read, and stays
894
+ // terminal.
895
+ if (rawCandidates === undefined || rawCandidates === null) return "continue";
673
896
  if (!Array.isArray(rawCandidates)) {
674
- yield { type: "error", message: "google response contained invalid candidates" };
897
+ yield invalidGoogleShapeEvent({
898
+ reason: "candidates_not_array",
899
+ valueType: googleStructuralValueType(rawCandidates),
900
+ });
675
901
  return "terminate";
676
902
  }
677
903
  if (rawCandidates.length === 0) return "continue";
678
904
  const rawCandidate = rawCandidates[0];
679
- if (rawCandidate === null || typeof rawCandidate !== "object" || Array.isArray(rawCandidate)) {
905
+ if (!isGoogleRecord(rawCandidate)) {
680
906
  // Unlike a root `data: null` keepalive, this is a claimed response candidate. Treat it
681
907
  // as terminal protocol corruption so the turn cannot complete after silently losing
682
908
  // a candidate or tool call (#1325).
683
- yield { type: "error", message: "google response contained invalid candidates" };
909
+ yield invalidGoogleShapeEvent({
910
+ reason: "candidate_not_object",
911
+ valueType: googleStructuralValueType(rawCandidate),
912
+ });
684
913
  return "terminate";
685
914
  }
686
915
  const candidate = rawCandidate as {
687
- content?: { parts?: unknown[] };
916
+ content?: unknown;
688
917
  finishReason?: string;
689
918
  };
690
919
 
@@ -693,7 +922,29 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
693
922
  sawTerminalSignal = true;
694
923
  }
695
924
 
696
- const parts = candidate.content?.parts as GoogleResponsePart[] | undefined;
925
+ // One rung below the candidate guard above, same rule: this is claimed content, not
926
+ // padding, so it fails closed rather than being iterated or silently dropped (#1325).
927
+ const rawContent: unknown = candidate.content;
928
+ const invalidContent = diagnoseGoogleContent(rawContent);
929
+ if (invalidContent) {
930
+ yield invalidGoogleShapeEvent(invalidContent);
931
+ return "terminate";
932
+ }
933
+ const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined;
934
+ let parts: GoogleResponsePart[] | undefined;
935
+ if (rawParts !== undefined && rawParts !== null) {
936
+ const invalidParts = diagnoseGoogleParts(rawParts);
937
+ if (invalidParts) {
938
+ yield invalidGoogleShapeEvent(invalidParts);
939
+ return "terminate";
940
+ }
941
+ parts = rawParts as GoogleResponsePart[];
942
+ const invalidFunctionCall = diagnoseGoogleFunctionCalls(parts);
943
+ if (invalidFunctionCall) {
944
+ yield invalidGoogleFunctionCallEvent(invalidFunctionCall);
945
+ return "terminate";
946
+ }
947
+ }
697
948
  // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and
698
949
  // Antigravity use separate model namespaces so opaque provider state cannot cross routes.
699
950
  const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
@@ -733,17 +984,19 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
733
984
  }
734
985
  }
735
986
  }
736
- if (part.functionCall) {
987
+ const functionCall = googleFunctionCall(part);
988
+ if (functionCall) {
737
989
  const id = `call_${crypto.randomUUID().slice(0, 8)}`;
738
990
  toolCallsStarted++;
739
991
  emittedContentEvent = true;
992
+ const restoredName = restoreGoogleToolName(functionCall.name);
740
993
  yield {
741
994
  type: "tool_call_start",
742
995
  id,
743
- name: restoreGoogleToolName(part.functionCall.name),
996
+ name: restoredName,
744
997
  ...googleToolCallMetadataFromPart(part, pendingStreamThoughtSig),
745
998
  };
746
- yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) };
999
+ yield { type: "tool_call_delta", arguments: JSON.stringify(functionCall.args ?? {}) };
747
1000
  yield { type: "tool_call_end" };
748
1001
  }
749
1002
  }
@@ -923,23 +1176,54 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
923
1176
  }
924
1177
  const events: AdapterEvent[] = [];
925
1178
 
926
- const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined;
1179
+ const rawCandidates: unknown = json.candidates;
1180
+ // Parity with the streaming path, which has rejected a non-array `candidates` since #1332.
1181
+ // Buffered accepted `"abc"` outright (`"abc".length` is 3, so the emptiness check below
1182
+ // passed and `candidates[0]` was the character `"a"`), and reported `{}`/`5` as an absent
1183
+ // candidate list rather than a malformed one.
1184
+ if (rawCandidates !== undefined && rawCandidates !== null && !Array.isArray(rawCandidates)) {
1185
+ return finish([invalidGoogleShapeEvent({
1186
+ reason: "candidates_not_array",
1187
+ valueType: googleStructuralValueType(rawCandidates),
1188
+ })]);
1189
+ }
1190
+ const candidates = rawCandidates as { finishReason?: string }[] | undefined;
927
1191
  if (!candidates?.length) {
928
1192
  return finish([{ type: "error", message: "google response contained no candidates" }]);
929
1193
  }
1194
+ const rawCandidate: unknown = candidates[0];
1195
+ if (!isGoogleRecord(rawCandidate)) {
1196
+ // The streaming parser already treats this as terminal protocol corruption (#1325/#1332).
1197
+ // Buffered returned a bare `done`, so a claimed-but-malformed candidate was reported to
1198
+ // the caller as a successful empty turn.
1199
+ return finish([invalidGoogleShapeEvent({
1200
+ reason: "candidate_not_object",
1201
+ valueType: googleStructuralValueType(rawCandidate),
1202
+ })]);
1203
+ }
1204
+ const candidate = rawCandidate as { content?: unknown; finishReason?: string };
930
1205
  let toolCallsStarted = 0;
931
1206
  const imageBudget = createImageBudget();
932
- if (candidates?.[0]?.content?.parts) {
1207
+ const rawContent: unknown = candidate.content;
1208
+ const invalidContent = diagnoseGoogleContent(rawContent);
1209
+ if (invalidContent) return finish([invalidGoogleShapeEvent(invalidContent)]);
1210
+ const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined;
1211
+ if (rawParts !== undefined && rawParts !== null) {
1212
+ const invalidParts = diagnoseGoogleParts(rawParts);
1213
+ if (invalidParts) return finish([invalidGoogleShapeEvent(invalidParts)]);
1214
+ const parts = rawParts as GoogleResponsePart[];
1215
+ const invalidFunctionCall = diagnoseGoogleFunctionCalls(parts);
1216
+ if (invalidFunctionCall) return finish([invalidGoogleFunctionCallEvent(invalidFunctionCall)]);
933
1217
  // Non-streaming Google-family response: observe thought signatures for the next turn,
934
1218
  // using the same transport-scoped namespace as the streaming path.
935
1219
  const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
936
1220
  const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
937
1221
  if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
938
1222
  && replayModel && replaySession) {
939
- observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]);
1223
+ observeAntigravityReplay(replayModel, replaySession, parts as unknown[]);
940
1224
  }
941
1225
  let pendingThoughtSig: string | undefined;
942
- for (const part of candidates[0].content.parts) {
1226
+ for (const part of parts) {
943
1227
  const sig = part.thoughtSignature ?? part.thought_signature;
944
1228
  if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) {
945
1229
  pendingThoughtSig = sig;
@@ -960,16 +1244,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
960
1244
  }
961
1245
  }
962
1246
  }
963
- if (part.functionCall) {
1247
+ const functionCall = googleFunctionCall(part);
1248
+ if (functionCall) {
964
1249
  const id = `call_${crypto.randomUUID().slice(0, 8)}`;
965
1250
  toolCallsStarted++;
966
1251
  events.push({
967
1252
  type: "tool_call_start",
968
1253
  id,
969
- name: restoreGoogleToolName(part.functionCall.name),
1254
+ name: restoreGoogleToolName(functionCall.name),
970
1255
  ...googleToolCallMetadataFromPart(part, pendingThoughtSig),
971
1256
  });
972
- events.push({ type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) });
1257
+ events.push({ type: "tool_call_delta", arguments: JSON.stringify(functionCall.args ?? {}) });
973
1258
  events.push({ type: "tool_call_end" });
974
1259
  }
975
1260
  }
@@ -978,8 +1263,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
978
1263
  // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call
979
1264
  // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done.
980
1265
  if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist")
981
- && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) {
982
- return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]);
1266
+ && isVertexTruncatedTurn(candidate.finishReason, toolCallsStarted)) {
1267
+ return finish([{ type: "error", message: vertexTruncationErrorMessage(candidate.finishReason) }]);
983
1268
  }
984
1269
 
985
1270
  const usage = json.usageMetadata as Record<string, number> | undefined;
@@ -987,7 +1272,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
987
1272
  // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated
988
1273
  // turn as completed — and, on a compaction turn, installs the half-written summary as
989
1274
  // replacement history (#422).
990
- const finishReason = candidates?.[0]?.finishReason as string | undefined;
1275
+ const finishReason = candidate.finishReason as string | undefined;
991
1276
  const stopReason = finishReason === "MAX_TOKENS"
992
1277
  ? "max_tokens"
993
1278
  : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "")
@@ -1563,6 +1563,16 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1563
1563
  pendingToolCalls.length = 0;
1564
1564
  return calls;
1565
1565
  };
1566
+ const pendingToolCallsAreCompleteJsonObjects = (): boolean =>
1567
+ pendingToolCalls.length > 0 && pendingToolCalls.every(call => {
1568
+ if (call.name.trim().length === 0 || !call.sawArgumentsString || call.args.length === 0) return false;
1569
+ try {
1570
+ const parsed = JSON.parse(call.args) as unknown;
1571
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
1572
+ } catch {
1573
+ return false;
1574
+ }
1575
+ });
1566
1576
  // Returns "terminate" when a pending call cannot be dispatched, so every flush site
1567
1577
  // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
1568
1578
  // so budget reservations are released for every pending call even on the early return.
@@ -1823,6 +1833,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1823
1833
  }
1824
1834
  const sawFinish = finishReason !== undefined;
1825
1835
  if (!sawFinish && pendingToolCalls.length > 0) {
1836
+ // Some OpenAI-compatible gateways close immediately after a complete function-call
1837
+ // delta and omit both terminal conventions. Keep the default fail-closed policy, and
1838
+ // let an opted-in provider recover only calls whose assembled argument payload is a
1839
+ // complete JSON object. A partial JSON prefix still takes the truncation path below.
1840
+ if (provider.openaiChatEofTolerance === true && pendingToolCallsAreCompleteJsonObjects()) {
1841
+ if ((yield* flushToolCalls()) === "terminate") return;
1842
+ yield { type: "done", usage: pendingUsage };
1843
+ return;
1844
+ }
1826
1845
  debugProviderDiagnostic("openai-chat", "stream-truncated", {
1827
1846
  finishReason: null,
1828
1847
  hadUsage: pendingUsage !== undefined,