@bitkyc08/opencodex 2.34.0 → 2.35.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 (71) hide show
  1. package/gui/dist/assets/{index-C4TMRloX.js → index-DNdRKXK9.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -1
  4. package/src/adapters/base.ts +26 -0
  5. package/src/adapters/cursor/catalog.ts +541 -0
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +34 -41
  8. package/src/adapters/cursor/envelope-echo.ts +128 -0
  9. package/src/adapters/cursor/request-builder.ts +19 -12
  10. package/src/adapters/cursor/tool-definitions.ts +2 -1
  11. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  12. package/src/adapters/cursor.ts +21 -2
  13. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  14. package/src/adapters/google-antigravity-replay.ts +71 -2
  15. package/src/adapters/google-antigravity-wire.ts +5 -0
  16. package/src/adapters/google.ts +15 -1
  17. package/src/adapters/kiro-constants.ts +12 -0
  18. package/src/adapters/kiro.ts +128 -11
  19. package/src/adapters/openai-chat.ts +16 -2
  20. package/src/adapters/openai-responses.ts +15 -2
  21. package/src/adapters/run-turn-queue.ts +36 -1
  22. package/src/adapters/tool-catalog-nudge.ts +2 -1
  23. package/src/adapters/xai-web-search.ts +10 -14
  24. package/src/claude/outbound.ts +14 -3
  25. package/src/cli/access.ts +46 -3
  26. package/src/cli/account-api.ts +84 -15
  27. package/src/cli/account-extended.ts +261 -28
  28. package/src/cli/account-main.ts +12 -12
  29. package/src/cli/account.ts +40 -10
  30. package/src/cli/agent.ts +8 -1
  31. package/src/cli/capabilities-command.ts +94 -0
  32. package/src/cli/capabilities.ts +496 -0
  33. package/src/cli/claude-desktop.ts +31 -11
  34. package/src/cli/dispatch.ts +195 -27
  35. package/src/cli/doctor.ts +100 -1
  36. package/src/cli/help.ts +11 -2
  37. package/src/cli/index.ts +19 -3
  38. package/src/cli/inspect.ts +230 -0
  39. package/src/cli/observe.ts +11 -3
  40. package/src/cli/registry.ts +34 -2
  41. package/src/cli/runtime-api.ts +51 -7
  42. package/src/cli/status.ts +16 -0
  43. package/src/cli/storage.ts +234 -0
  44. package/src/cli/system-command.ts +16 -0
  45. package/src/cli/usage-report.ts +52 -2
  46. package/src/cli/version-skew.ts +46 -0
  47. package/src/codex/account-label.ts +21 -0
  48. package/src/codex/catalog/provider-fetch.ts +4 -0
  49. package/src/codex/transition-state.ts +12 -3
  50. package/src/compatibility/openai-responses.ts +9 -1
  51. package/src/generated/compatibility-version.json +95 -59
  52. package/src/integrations/ownership-policy.ts +24 -5
  53. package/src/integrations/ownership.ts +36 -2
  54. package/src/integrations/state.ts +40 -7
  55. package/src/integrations/writer.ts +21 -3
  56. package/src/lib/admin-secrets.ts +24 -0
  57. package/src/lib/errors.ts +25 -1
  58. package/src/lib/service-secrets.ts +15 -0
  59. package/src/oauth/store.ts +14 -5
  60. package/src/providers/label.ts +34 -1
  61. package/src/responses/turn-termination.ts +107 -0
  62. package/src/server/management/logs-usage-routes.ts +0 -16
  63. package/src/server/management/route-registry.ts +311 -0
  64. package/src/server/proxy-liveness.ts +27 -4
  65. package/src/server/request-log.ts +29 -1
  66. package/src/server/responses/core.ts +80 -0
  67. package/src/service.ts +34 -0
  68. package/src/storage/policy-job.ts +14 -4
  69. package/src/storage/policy.ts +88 -23
  70. package/src/usage/log.ts +44 -4
  71. package/src/usage/summary.ts +10 -0
@@ -512,10 +512,10 @@ export function antigravityReplaySessionKeysForTests(): string[] {
512
512
 
513
513
  function extractSignature(part: Record<string, unknown>): string | undefined {
514
514
  const direct = part.thoughtSignature ?? part.thought_signature;
515
- if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN) return direct;
515
+ if (typeof direct === "string" && direct.length >= MIN_SIGNATURE_LEN && direct !== THOUGHT_SIGNATURE_BYPASS) return direct;
516
516
  const extra = part.extra_content as { google?: { thought_signature?: unknown } } | undefined;
517
517
  const nested = extra?.google?.thought_signature;
518
- if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN) return nested;
518
+ if (typeof nested === "string" && nested.length >= MIN_SIGNATURE_LEN && nested !== THOUGHT_SIGNATURE_BYPASS) return nested;
519
519
  return undefined;
520
520
  }
521
521
 
@@ -624,6 +624,75 @@ export function antigravityUsesReplayCache(model: string): boolean {
624
624
  return !/claude/i.test(model);
625
625
  }
626
626
 
627
+ /**
628
+ * Gemini 3 rejects a turn whose FIRST functionCall part carries no thought signature. When
629
+ * neither the wire metadata nor the replay cache can supply a real one, this is the official
630
+ * validator-bypass token.
631
+ */
632
+ const THOUGHT_SIGNATURE_BYPASS = "skip_thought_signature_validator";
633
+
634
+ /**
635
+ * True when the model speaks the Gemini wire dialect that requires a thought signature on the
636
+ * first functionCall of a turn — and therefore accepts the validator-bypass sentinel.
637
+ *
638
+ * Deliberately NOT `antigravityUsesReplayCache`. That predicate is broad on purpose (every
639
+ * non-Claude model participates in signature replay), and reusing it for the sentinel is how a
640
+ * Gemini-only control token was observed being injected into `gpt-oss-120b-medium`. Replaying a
641
+ * signature upstream gave us is harmless for any model; *fabricating* a Gemini token is not.
642
+ *
643
+ * The identity must be REDUCED to its model component before matching, not scanned whole. The
644
+ * Vertex replay key is built in `src/adapters/google.ts` as
645
+ * `vertex:<project>:<location>:<modelId>`, and the project id is operator-chosen: a project
646
+ * named `gemini-prod` made a whole-string scan return true for
647
+ * `vertex:gemini-prod:global:gpt-oss-120b`, arming the Gemini-only sentinel for a non-Gemini
648
+ * model — the exact class of defect this predicate exists to prevent, reintroduced one layer up.
649
+ *
650
+ * So: take the last `:` segment for a Vertex identity, then the last `/` segment for a
651
+ * namespaced id (`google/gemini-3-pro`), and match only that. The trailing `[-.\d]` keeps
652
+ * `geminibot` and `my-gemini-clone` out. A model outside this set that genuinely needs the
653
+ * sentinel must arrive with a captured accepted CCA contract, not by widening this predicate.
654
+ */
655
+ export function antigravitySupportsThoughtSignatureSentinel(model: string): boolean {
656
+ const afterTransport = model.slice(model.lastIndexOf(":") + 1);
657
+ const wireModel = afterTransport.slice(afterTransport.lastIndexOf("/") + 1);
658
+ return /^gemini[-.\d]/i.test(wireModel);
659
+ }
660
+
661
+ /**
662
+ * Ensure every model turn's FIRST functionCall carries a thought signature, injecting the
663
+ * validator-bypass sentinel only where one is genuinely absent.
664
+ *
665
+ * Split out of `applyAntigravityReplay` on purpose. Replay answers "what did upstream already
666
+ * tell us about this call", and its absence of a signature is meaningful — 18 assertions in the
667
+ * suite read `thoughtSignature === undefined` as "the cache did not match", covering eviction,
668
+ * TTL expiry, oversize refusal and clear-on-invalid. Folding a fabricated token into that
669
+ * function would overwrite the very signal those tests read. Keeping the sentinel as its own
670
+ * pass means a cache miss still looks like a cache miss.
671
+ *
672
+ * Three properties this must hold, each of which a naive presence-check gets wrong:
673
+ * - it decides from `extractSignature`, so a valid NESTED
674
+ * `extra_content.google.thought_signature` counts as signed (no competing sentinel) and a
675
+ * present-but-too-short value does not (the fallback still fires);
676
+ * - it looks at the FIRST functionCall only, so a later sibling receiving a cached signature
677
+ * cannot vote away the sentinel the first call requires;
678
+ * - it is gated on the Gemini wire dialect, not on replay-cache participation.
679
+ */
680
+ export function applyAntigravityThoughtSignatureFallback(model: string, contents: unknown[]): unknown[] {
681
+ if (!antigravitySupportsThoughtSignatureSentinel(model) || !Array.isArray(contents)) return contents;
682
+ for (const rawContent of contents as { role?: string; parts?: unknown[] }[]) {
683
+ if (!rawContent || typeof rawContent !== "object" || rawContent.role !== "model") continue;
684
+ if (!Array.isArray(rawContent.parts)) continue;
685
+ for (const rawPart of rawContent.parts) {
686
+ if (!rawPart || typeof rawPart !== "object") continue;
687
+ const part = rawPart as Record<string, unknown>;
688
+ if (!part.functionCall) continue;
689
+ if (!extractSignature(part)) part.thoughtSignature = THOUGHT_SIGNATURE_BYPASS;
690
+ break;
691
+ }
692
+ }
693
+ return contents;
694
+ }
695
+
627
696
  /**
628
697
  * Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
629
698
  * the functionCall identity (name + args). Accumulates across the whole session so a sequential
@@ -29,6 +29,11 @@ export const ANTIGRAVITY_REQUEST_UA = antigravityUserAgent();
29
29
  */
30
30
  export function isLikelyRealThoughtSignature(sig: string | undefined): boolean {
31
31
  if (typeof sig !== "string" || sig.length < 16) return false;
32
+ // The validator-bypass sentinel is something WE fabricate for outbound requests when no real
33
+ // signature exists. It is alphanumeric with underscores, so it would otherwise satisfy every
34
+ // check below and be re-ingested as genuine — cached, replayed, and eventually treated as
35
+ // evidence that a turn was signed. It is never a real signature.
36
+ if (sig === "skip_thought_signature_validator") return false;
32
37
  // Reject synthetic Responses/tool-call ids and Anthropic tool-use ids (`_` or `-` separators).
33
38
  if (/^(fc|ctc|tsc|call|msg|rs|resp|reasoning|item|ws|toolu|tool|func|function)[-_]/i.test(sig)) return false;
34
39
  // Real Gemini thought signatures are opaque base64/base64url blobs: only [A-Za-z0-9+/_=-].
@@ -23,7 +23,13 @@ import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-tr
23
23
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
24
24
  import { compileGoogleWireBody } from "./google-wire-compiler";
25
25
  import { identifyRoutedModel } from "./identity";
26
- import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
26
+ import {
27
+ antigravityUsesReplayCache,
28
+ applyAntigravityReplay,
29
+ applyAntigravityThoughtSignatureFallback,
30
+ clearAntigravityReplay,
31
+ observeAntigravityReplay,
32
+ } from "./google-antigravity-replay";
27
33
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
28
34
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
29
35
  import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
@@ -826,6 +832,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
826
832
  } else {
827
833
  sanitizeAntigravityClaudeSignatures(contents);
828
834
  }
835
+ // After replay, not instead of it: a real signature always wins, and the sentinel only
836
+ // fills a first functionCall that replay could not sign. Outside the cache branch too,
837
+ // because the turn still needs a signature when no session was ever recorded.
838
+ applyAntigravityThoughtSignatureFallback(wireModelId, contents);
829
839
  // Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories
830
840
  // as prefill: "This model does not support assistant message prefill. The conversation
831
841
  // must end with a user message." Context compaction, previous_response_id expansion,
@@ -870,6 +880,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
870
880
  vertexReplaySession,
871
881
  (compiled.body as { contents: unknown[] }).contents,
872
882
  );
883
+ applyAntigravityThoughtSignatureFallback(
884
+ vertexReplayModel,
885
+ (compiled.body as { contents: unknown[] }).contents,
886
+ );
873
887
  }
874
888
  // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path.
875
889
  const apiKey = resolveVertexApiKey(provider.apiKey);
@@ -22,6 +22,18 @@ export const KIRO_COMPLETION_RETRY_MESSAGE =
22
22
  export const KIRO_TOOL_RESULT_CARRIER_MESSAGE = "The requested tool result is attached.";
23
23
  export const KIRO_EMPTY_TOOL_RESULT_MESSAGE = "The tool completed without textual output.";
24
24
 
25
+ /**
26
+ * Placeholder for the user turn Kiro requires after an assistant turn that ALREADY delivered its
27
+ * final answer.
28
+ *
29
+ * The protocol needs a trailing user turn, but the usual continuation/retry text instructs the
30
+ * model to keep working, which reopens a finished task and reads as a still-open goal. This states
31
+ * the delivered state and explicitly withholds a new request, so the turn stays structurally valid
32
+ * without asking for more work.
33
+ */
34
+ export const KIRO_ANSWER_DELIVERED_MESSAGE =
35
+ "The previous final answer was delivered to the user and that task is closed. No new request has been made yet. Do not repeat, revise, or continue that work; wait for the user's next instruction.";
36
+
25
37
  export const KIRO_COMPLETION_INSTRUCTIONS =
26
38
  `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
27
39
 
@@ -36,15 +36,18 @@ import type {
36
36
  OcxTool,
37
37
  OcxUsage,
38
38
  } from "../types";
39
+ import { hasRecordedTrailingDeliveredFinalAnswer } from "../responses/turn-termination";
39
40
  import type { ProviderAdapter } from "./base";
40
41
  import type { AdapterFetchContext, AdapterRequest } from "./base";
41
42
  import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
42
43
  import { sniffImageDimensions } from "./anthropic-image-guard";
43
44
  import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
44
45
  import { convertKiroToolContext } from "./kiro-tools";
46
+ import { normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize";
45
47
  import { identifyRoutedModel } from "./identity";
46
48
  import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
47
49
  import {
50
+ KIRO_ANSWER_DELIVERED_MESSAGE,
48
51
  KIRO_COMPLETION_INSTRUCTIONS,
49
52
  KIRO_COMPLETION_RETRY_MESSAGE,
50
53
  KIRO_COMPLETION_TOOL_NAME,
@@ -343,8 +346,54 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {
343
346
  }
344
347
 
345
348
  type KiroTurn =
346
- | { kind: "user"; content: string; images: KiroImage[]; toolResults: KiroToolResult[] }
347
- | { kind: "assistant"; content: string; toolUses: KiroToolUse[]; redactedReasoning?: string };
349
+ | {
350
+ kind: "user";
351
+ content: string;
352
+ images: KiroImage[];
353
+ toolResults: KiroToolResult[];
354
+ /**
355
+ * True only for the proxy-generated acknowledgement that follows a delivered final answer.
356
+ * A flag rather than a content comparison: a real user message may legitimately quote the
357
+ * same sentence, and treating that as internal state would strip its thinking tags and
358
+ * completion retry.
359
+ */
360
+ answerDeliveredAck?: boolean;
361
+ }
362
+ | {
363
+ kind: "assistant";
364
+ content: string;
365
+ toolUses: KiroToolUse[];
366
+ redactedReasoning?: string;
367
+ /**
368
+ * True when this assistant turn was the DELIVERED final answer (Responses
369
+ * `phase: "final_answer"`). A trailing assistant turn normally means the model stopped
370
+ * mid-task and needs a continuation prompt, but a delivered final answer already ended its
371
+ * turn — prompting it again restarts finished work as if a goal were still open.
372
+ */
373
+ finalAnswer?: boolean;
374
+ };
375
+
376
+ /**
377
+ * True when the LAST content-bearing message is an assistant final answer that closed its turn.
378
+ *
379
+ * Mirrors the turn-merge rule: a tool call in that message, or any later user/tool-result message,
380
+ * means work continued, so the turn is no longer terminal. Empty assistant messages are skipped
381
+ * rather than treated as continuation, since they carry no visible turn.
382
+ */
383
+ function hasTrailingDeliveredFinalAnswer(messages: readonly OcxMessage[], parsed?: OcxParsedRequest): boolean {
384
+ for (let i = messages.length - 1; i >= 0; i--) {
385
+ const msg = messages[i];
386
+ if (msg.role !== "assistant") return false;
387
+ const aMsg = msg as OcxAssistantMessage;
388
+ const hasToolCall = (aMsg.content ?? []).some(part => part.type === "toolCall");
389
+ if (hasToolCall) return false;
390
+ const hasText = (aMsg.content ?? []).some(part => part.type === "text" && part.text.trim());
391
+ if (!hasText) continue;
392
+ return aMsg.phase === "final_answer"
393
+ || (parsed !== undefined && hasRecordedTrailingDeliveredFinalAnswer(parsed, messages));
394
+ }
395
+ return false;
396
+ }
348
397
 
349
398
  function appendTurnText(target: string, next: string): string {
350
399
  if (!next) return target;
@@ -453,8 +502,19 @@ export function buildKiroPayload(
453
502
  const registry = createKiroToolNameRegistry();
454
503
  const toolContext = convertKiroToolContext(parsed, registry);
455
504
  const ordinaryTools = toolContext.tools;
505
+ // A turn whose history already ENDS with a delivered final answer has nothing to complete.
506
+ // Leaving completion "required" here would keep advertising codex_kiro_final_answer with its
507
+ // instructions, so the model answers again, or replies with ordinary text and trips the
508
+ // `needsFallback` retry, which ends its payload with KIRO_COMPLETION_RETRY_MESSAGE and reopens
509
+ // the finished task. Suppressing the mode is what actually closes that loop; the neutral
510
+ // acknowledgement below only stops the resume wording.
511
+ //
512
+ // Read from parsed messages because `completionMode` is needed to build the tool catalog, which
513
+ // happens before the turn list exists. `forcedCompletionMode` still wins: the fallback retry
514
+ // passes "text_fallback" explicitly and must not be silently downgraded.
515
+ const trailingDeliveredAnswer = hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed);
456
516
  const completionMode: KiroCompletionMode = forcedCompletionMode
457
- ?? (ordinaryTools.length > 0 ? "required" : "disabled");
517
+ ?? (ordinaryTools.length > 0 && !trailingDeliveredAnswer ? "required" : "disabled");
458
518
  const kiroTools = completionMode === "disabled"
459
519
  ? ordinaryTools
460
520
  : [...ordinaryTools, kiroCompletionTool()];
@@ -521,15 +581,24 @@ export function buildKiroPayload(
521
581
  turns.push({ kind: "user", content, images: [...images], toolResults: [...toolResults] });
522
582
  }
523
583
  };
524
- const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string): void => {
584
+ const pushAssistant = (content: string, toolUses: KiroToolUse[], redactedReasoning?: string, finalAnswer?: boolean): void => {
525
585
  const last = turns.at(-1);
526
586
  if (last?.kind === "assistant") {
527
587
  last.content = appendTurnText(last.content, content);
528
588
  last.toolUses.push(...toolUses);
529
589
  // Merged turns keep the newest blob: it covers the reasoning up to the merged turn's end.
530
590
  if (redactedReasoning) last.redactedReasoning = redactedReasoning;
591
+ // A merged turn is final only if its LAST component was: commentary appended after a final
592
+ // answer means the model kept working, so the turn is no longer terminal.
593
+ last.finalAnswer = finalAnswer === true;
531
594
  } else {
532
- turns.push({ kind: "assistant", content, toolUses: [...toolUses], ...(redactedReasoning ? { redactedReasoning } : {}) });
595
+ turns.push({
596
+ kind: "assistant",
597
+ content,
598
+ toolUses: [...toolUses],
599
+ ...(redactedReasoning ? { redactedReasoning } : {}),
600
+ ...(finalAnswer ? { finalAnswer: true } : {}),
601
+ });
533
602
  }
534
603
  };
535
604
 
@@ -559,14 +628,24 @@ export function buildKiroPayload(
559
628
  const hasReasoning = aMsg.content.some(part => part.type === "thinking" && part.thinking.trim());
560
629
  if (hasReasoning) continue;
561
630
  }
562
- pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning);
631
+ // `phase` survives the Responses round trip (parser.ts assistant branch), so a replayed
632
+ // final answer is identifiable here rather than guessed from turn position.
633
+ pushAssistant(text, toolUses, aMsg.kiroRedactedReasoning, aMsg.phase === "final_answer" && toolUses.length === 0);
563
634
  } else if (msg.role === "toolResult") {
564
635
  const tr = msg as OcxToolResultMessage;
565
636
  if (tr.containsEncryptedContent) {
566
637
  throw new Error(`Kiro cannot translate encrypted output for tool call ${JSON.stringify(tr.toolCallId)}`);
567
638
  }
568
639
  const text = userContentText(tr.content);
569
- const resultText = text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE;
640
+ // An empty code-mode exec result needs the SPECIFIC reason, not the generic fallback: the
641
+ // model otherwise reads a blank result, concludes its earlier context was lost, and restarts
642
+ // the task instead of calling text()/notify(). Checked before `text.trim()` because the
643
+ // wrapper form ("Script completed\nWall time ...\nOutput:\n") is non-blank and would
644
+ // otherwise pass through as if it were real output.
645
+ const resultText = normalizeEmptyExecToolResultText(text, {
646
+ toolName: tr.toolName,
647
+ toolNamespace: tr.toolNamespace,
648
+ }) ?? (text.trim() ? text : KIRO_EMPTY_TOOL_RESULT_MESSAGE);
570
649
  const images = extractKiroImages(tr.content);
571
650
  const toolUseId = normalizeToolId(tr.toolCallId);
572
651
  if (!priorCalls.has(toolUseId)) {
@@ -587,12 +666,23 @@ export function buildKiroPayload(
587
666
  if (turns.length === 0 || turns[0].kind === "assistant") {
588
667
  turns.unshift({ kind: "user", content: KIRO_CONTINUATION_MESSAGE, images: [], toolResults: [] });
589
668
  }
590
- if (turns.at(-1)?.kind === "assistant") {
669
+ // Kiro requires the request to end with a user turn, so a trailing assistant turn always gets
670
+ // one appended (the pop below throws otherwise). What that turn SAYS is the load-bearing part.
671
+ //
672
+ // Normally a trailing assistant turn means the model stopped mid-task, and a continuation/retry
673
+ // prompt is correct. A DELIVERED final answer is the exception: the turn already ended, and
674
+ // telling that model to "continue" or to call the completion tool again reopens finished work —
675
+ // the completed-task-behaves-like-an-open-goal loop. It gets a neutral acknowledgement instead:
676
+ // structurally valid, but carrying no instruction to resume.
677
+ const trailing = turns.at(-1);
678
+ if (trailing?.kind === "assistant") {
679
+ const resumeText = completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE;
591
680
  turns.push({
592
681
  kind: "user",
593
- content: completionMode === "text_fallback" ? KIRO_COMPLETION_RETRY_MESSAGE : KIRO_CONTINUATION_MESSAGE,
682
+ content: trailing.finalAnswer ? KIRO_ANSWER_DELIVERED_MESSAGE : resumeText,
594
683
  images: [],
595
684
  toolResults: [],
685
+ ...(trailing.finalAnswer ? { answerDeliveredAck: true } : {}),
596
686
  });
597
687
  }
598
688
 
@@ -608,6 +698,8 @@ export function buildKiroPayload(
608
698
 
609
699
  const currentTurn = turns.pop();
610
700
  if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
701
+ // Survives the pop as state, so the checks below never infer intent from user-supplied text.
702
+ const answerDeliveredAck = currentTurn.answerDeliveredAck === true;
611
703
  const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"
612
704
  ? {
613
705
  assistantResponseMessage: {
@@ -638,10 +730,17 @@ export function buildKiroPayload(
638
730
  currentUim.userInputMessageContext = { ...(currentUim.userInputMessageContext ?? {}), tools: kiroTools };
639
731
  }
640
732
  if (completionMode === "text_fallback") {
641
- if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE) {
733
+ // Never append the retry instruction onto the answer-delivered acknowledgement: it exists
734
+ // precisely to avoid asking a finished turn for another completion call, and appending here
735
+ // would reinstate the loop it prevents.
736
+ if (currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE && !answerDeliveredAck) {
642
737
  currentUim.content = appendTurnText(currentUim.content, KIRO_COMPLETION_RETRY_MESSAGE);
643
738
  }
644
- } else if (!currentUim.userInputMessageContext?.toolResults && currentUim.content !== KIRO_CONTINUATION_MESSAGE) {
739
+ } else if (
740
+ !currentUim.userInputMessageContext?.toolResults
741
+ && currentUim.content !== KIRO_CONTINUATION_MESSAGE
742
+ && !answerDeliveredAck
743
+ ) {
645
744
  currentUim.content = injectKiroThinkingTags(currentUim.content, parsed);
646
745
  }
647
746
 
@@ -1905,6 +2004,24 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1905
2004
 
1906
2005
  return {
1907
2006
  name: "kiro",
2007
+ // A replayed history that already ENDS with a delivered final answer has nothing to ask Kiro.
2008
+ // Before this hook the adapter still appended a trailing user turn — a neutral acknowledgement,
2009
+ // but structurally still a prompt — and performed a real inference, so the model answered the
2010
+ // closed task again and the finished turn behaved like a still-open goal.
2011
+ //
2012
+ // Suppressing the completion contract (above) removed the instruction to complete; it could not
2013
+ // remove the inference. This is the boundary: no request is built, nothing is sent, and no token
2014
+ // estimate is recorded.
2015
+ //
2016
+ // The forced-fallback build is deliberately NOT consulted here: this hook runs on the inbound
2017
+ // turn only, and the adapter-owned bounded retry passes "text_fallback" through `build`
2018
+ // directly, never through this path.
2019
+ localTerminal(parsed: OcxParsedRequest) {
2020
+ return hasTrailingDeliveredFinalAnswer(kiroPayloadMessages(parsed), parsed)
2021
+ ? { reason: "kiro_final_answer_already_delivered" }
2022
+ : undefined;
2023
+ },
2024
+
1908
2025
  async buildRequest(parsed: OcxParsedRequest, incoming) {
1909
2026
  const built = await build(parsed);
1910
2027
  modelId = parsed.modelId;
@@ -1113,7 +1113,8 @@ function intersectBound(target: unknown, sibling: unknown, direction: "max" | "m
1113
1113
  * Compose two `properties` maps. A property named in BOTH the referenced target and the
1114
1114
  * node is the same conjunction problem `required` had: letting the sibling win discards
1115
1115
  * the target's constraints for that member. Merge the two member schemas so neither side
1116
- * loses its keywords, and let the node narrow on a genuine conflict.
1116
+ * loses its keywords. Shared member bounds are the same conjunction one level down,
1117
+ * and nested object members recurse through this helper instead of replacing the target.
1117
1118
  */
1118
1119
  function composeProperties(
1119
1120
  target: Record<string, unknown>,
@@ -1127,7 +1128,20 @@ function composeProperties(
1127
1128
  const member: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
1128
1129
  for (const [k, v] of Object.entries(existing)) member[k] = v;
1129
1130
  for (const [k, v] of Object.entries(sub)) {
1130
- member[k] = k === "required" ? unionRequired(member[k], v) : v;
1131
+ if (k === "required") {
1132
+ member[k] = unionRequired(member[k], v);
1133
+ continue;
1134
+ }
1135
+ if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) {
1136
+ member[k] = composeProperties(member[k] as Record<string, unknown>, v);
1137
+ continue;
1138
+ }
1139
+ const boundDirection = MOONSHOT_BOUND_KEYWORDS[k];
1140
+ if (boundDirection && k in member) {
1141
+ member[k] = intersectBound(member[k], v, boundDirection);
1142
+ continue;
1143
+ }
1144
+ member[k] = v;
1131
1145
  }
1132
1146
  combined[name] = member;
1133
1147
  continue;
@@ -318,8 +318,8 @@ function stripUnsupportedReasoningParams(body: unknown): unknown {
318
318
  }
319
319
 
320
320
  /**
321
- * GPT-5.6 replaced the legacy 24-hour retention field with `prompt_cache_options.ttl`, and the
322
- * ChatGPT backend 400s the whole request when the retired field is present (issue #2092).
321
+ * GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole
322
+ * request when that field is present (issue #2092).
323
323
  *
324
324
  * The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract,
325
325
  * and implicit caching still applies when the caller sent no replacement options. Inventing a
@@ -339,6 +339,18 @@ function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): u
339
339
  return rest;
340
340
  }
341
341
 
342
+ /**
343
+ * Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex
344
+ * backend rejects the top-level field before inference (issue #2765). Custom forward gateways and
345
+ * API-key Responses providers own different wire contracts, so the caller applies this only after
346
+ * the canonical destination predicate succeeds.
347
+ */
348
+ function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown {
349
+ if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body;
350
+ const { prompt_cache_options: _options, ...rest } = body;
351
+ return rest;
352
+ }
353
+
342
354
  /**
343
355
  * A false model capability prevents Codex from emitting summary fields after the catalog refresh.
344
356
  * Strip them here as well so an already-running client with a stale catalog cannot keep sending an
@@ -2001,6 +2013,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2001
2013
  // third-party forward gateway may still accept it, so this must not be widened.
2002
2014
  if (isCanonicalOpenAiForwardProvider(provider)) {
2003
2015
  outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
2016
+ outBody = stripCanonicalForwardPromptCacheOptions(outBody);
2004
2017
  outBody = normalizeCanonicalForwardPromptEnvelope(outBody);
2005
2018
  outBody = normalizeCanonicalForwardContinuationEnvelope(outBody);
2006
2019
  }
@@ -4,6 +4,13 @@ type QueueReader = (result: IteratorResult<AdapterEvent>) => void;
4
4
 
5
5
  export const PREFLIGHT_HEARTBEAT_RETAIN_LIMIT = 16;
6
6
 
7
+ /**
8
+ * Coalescing threshold for adjacent text/thinking deltas buffered with no
9
+ * waiting reader (UTF-16 code units). This is a merge-size ceiling, not a
10
+ * byte-memory cap: a single oversized incoming event stays one item.
11
+ */
12
+ export const COALESCE_MAX_CHUNK_LENGTH = 64 * 1024;
13
+
7
14
  export interface AdapterEventQueue {
8
15
  push(event: AdapterEvent): void;
9
16
  close(): void;
@@ -64,6 +71,33 @@ export function createAdapterEventQueue(opts?: {
64
71
  const maxBacklog = opts?.maxBacklog ?? 1_024;
65
72
  let closed = false;
66
73
 
74
+ // Merge an incoming delta into the buffered tail when no reader is waiting.
75
+ // The backlog cap counts events, not tokens, so a detached or briefly
76
+ // stalled consumer (e.g. a Codex app mid-reconnect whose disconnect Bun has
77
+ // not yet delivered) used to hit the cap within seconds of token-granular
78
+ // streaming and abort a healthy turn. Adjacent same-phase text deltas,
79
+ // adjacent thinking deltas, and consecutive heartbeats carry no ordering
80
+ // information between themselves, so merging them preserves every consumer
81
+ // contract while making the cap approximate buffered items again.
82
+ // Pushed objects may be retained by adapters, so the tail is REPLACED with
83
+ // a fresh object — never mutated (alias safety).
84
+ const coalesceIntoTail = (event: AdapterEvent): boolean => {
85
+ const tail = queued[queued.length - 1];
86
+ if (!tail) return false;
87
+ if (event.type === "heartbeat") return tail.type === "heartbeat";
88
+ if (event.type === "text_delta" && tail.type === "text_delta" && tail.phase === event.phase) {
89
+ if (tail.text.length + event.text.length > COALESCE_MAX_CHUNK_LENGTH) return false;
90
+ queued[queued.length - 1] = { type: "text_delta", text: tail.text + event.text, phase: tail.phase };
91
+ return true;
92
+ }
93
+ if (event.type === "thinking_delta" && tail.type === "thinking_delta") {
94
+ if (tail.thinking.length + event.thinking.length > COALESCE_MAX_CHUNK_LENGTH) return false;
95
+ queued[queued.length - 1] = { type: "thinking_delta", thinking: tail.thinking + event.thinking };
96
+ return true;
97
+ }
98
+ return false;
99
+ };
100
+
67
101
  const push = (event: AdapterEvent): void => {
68
102
  if (closed) return;
69
103
  const reader = readers.shift();
@@ -71,9 +105,10 @@ export function createAdapterEventQueue(opts?: {
71
105
  reader({ done: false, value: event });
72
106
  return;
73
107
  }
108
+ if (coalesceIntoTail(event)) return;
74
109
  if (queued.length >= maxBacklog) {
75
110
  opts?.onBacklogExceeded?.();
76
- queued.push({ type: "error", message: "consumer backlog exceeded — turn aborted" });
111
+ queued.push({ type: "error", message: "consumer stalled: adapter event backlog exceeded — turn aborted" });
77
112
  close();
78
113
  return;
79
114
  }
@@ -5,6 +5,7 @@ import {
5
5
  type OcxTool,
6
6
  type OcxProviderConfig,
7
7
  } from "../types";
8
+ import { CODE_MODE_RESULT_ECHO_SENTENCE } from "./exec-tool-result-normalize";
8
9
 
9
10
  // Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one
10
11
  // here tells a routed model not to call it unless this turn's catalog really lists it.
@@ -120,7 +121,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames(
120
121
  "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.",
121
122
  "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.",
122
123
  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. Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
124
+ ? "`" + 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. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched."
124
125
  : "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.",
125
126
  unavailableNeighborNames.length > 0
126
127
  ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names."
@@ -1,8 +1,8 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
+ import { isXaiResponsesDestination } from "../providers/xai-transport";
2
3
 
3
4
  const CODEX_WEB_SEARCH_TOOL = "web_search";
4
5
  const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
5
- const XAI_API_HOST = "api.x.ai";
6
6
 
7
7
  function isPlainObject(value: unknown): value is Record<string, unknown> {
8
8
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -12,18 +12,6 @@ function isCodexWebSearchToolType(value: unknown): boolean {
12
12
  return value === CODEX_WEB_SEARCH_TOOL || value === CODEX_WEB_SEARCH_PREVIEW_TOOL;
13
13
  }
14
14
 
15
- /** Match only xAI's documented public API, not arbitrary Responses-compatible gateways. */
16
- function isXaiPublicApi(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
17
- try {
18
- const url = new URL(provider.baseUrl);
19
- return url.protocol === "https:"
20
- && url.hostname.toLowerCase() === XAI_API_HOST
21
- && (url.port === "" || url.port === "443");
22
- } catch {
23
- return false;
24
- }
25
- }
26
-
27
15
  type ToolGroupRewrite = {
28
16
  tools: unknown[];
29
17
  changed: boolean;
@@ -150,12 +138,20 @@ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unkn
150
138
  /**
151
139
  * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
152
140
  * providers or mutating the caller-owned request body.
141
+ *
142
+ * Scoped to BOTH xAI Responses hosts, not just the public API. The 2026-08-22 probe recorded in
143
+ * `normalizeToolGroup` and in `isXaiResponsesDestination` already found the two hosts to be one
144
+ * dialect, but this gate stayed on `api.x.ai` alone, so the Grok CLI proxy — the OAuth lane — was
145
+ * left unnormalized. Re-probed 2026-08-27 against `cli-chat-proxy.grok.com`:
146
+ * `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value,
147
+ * `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical
148
+ * to the public API, which is what makes one shared gate correct.
153
149
  */
154
150
  export function normalizeXaiResponsesWebSearch(
155
151
  body: unknown,
156
152
  provider: Pick<OcxProviderConfig, "baseUrl">,
157
153
  ): unknown {
158
- if (!isXaiPublicApi(provider) || !isPlainObject(body)) return body;
154
+ if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body;
159
155
 
160
156
  let next: Record<string, unknown> = body;
161
157
  if (Array.isArray(body.tools)) {
@@ -11,6 +11,7 @@
11
11
  * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
+ import { httpStatusFromTerminalError } from "../lib/errors";
14
15
  import { isTransientUpstreamStatus } from "../lib/upstream-retry";
15
16
  import {
16
17
  isTranslatorBudgetExceededError,
@@ -555,9 +556,19 @@ export function responsesSseToAnthropicSse(
555
556
  }
556
557
  const status = code === "translation_buffer_limit"
557
558
  ? 413
558
- : typeof error.status === "number" ? error.status : 500;
559
- // status-absent response.failed (relaySseWithFailedTail synthetic tail) defaults
560
- // to 500, which is in the transient set the mid-stream reset shape maps to
559
+ : typeof error.status === "number"
560
+ ? error.status
561
+ // Internal response.failed envelopes carry the classified {type, code, message}
562
+ // but no numeric status. Derive it with the same mapping /api/logs uses so a
563
+ // classified 429/401/400 reaches Claude Code as its real Anthropic error type
564
+ // instead of being masked as retryable overload.
565
+ : httpStatusFromTerminalError({
566
+ type: typeof error.type === "string" ? error.type : undefined,
567
+ code: typeof error.code === "string" ? error.code : null,
568
+ message,
569
+ });
570
+ // Unclassified status-absent response.failed (relaySseWithFailedTail synthetic
571
+ // tail) still lands on a transient 5xx here — the mid-stream reset shape maps to
561
572
  // overloaded_error by design.
562
573
  fail(
563
574
  status,