From 3e4613f017cff21871e898da655feaf5c291575b Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:43:35 +0900 Subject: [PATCH 1/6] fix(grok): translate native edit tools for Codex --- .../content/docs/guides/codex-integration.md | 15 + .../docs/ja/guides/codex-integration.md | 13 + .../src/content/docs/ja/reference/adapters.md | 12 + .../docs/ko/guides/codex-integration.md | 13 + .../src/content/docs/ko/reference/adapters.md | 13 + .../src/content/docs/reference/adapters.md | 13 + .../docs/ru/guides/codex-integration.md | 15 + .../src/content/docs/ru/reference/adapters.md | 13 + .../docs/zh-cn/guides/codex-integration.md | 11 + .../content/docs/zh-cn/reference/adapters.md | 11 + src/adapters/anthropic.ts | 3 +- src/adapters/base.ts | 4 + src/adapters/command-code.ts | 4 +- src/adapters/google.ts | 4 +- src/adapters/grok-structured-edit.ts | 1354 +++++++++++++++++ src/adapters/openai-chat.ts | 68 +- src/adapters/openai-responses.ts | 11 + src/adapters/tool-catalog-nudge.ts | 140 +- src/bridge.ts | 25 +- src/images/loop.ts | 5 + src/lib/translator-budget.ts | 20 + src/responses/custom-tool-compat.ts | 31 +- src/server/responses-custom-tool-repair.ts | 21 +- src/server/responses/core.ts | 56 +- src/web-search/loop.ts | 5 + structure/04_transports-and-sidecars.md | 18 + tests/adapter-tool-conformance.test.ts | 34 +- tests/bridge.test.ts | 37 + tests/grok-structured-edit.test.ts | 530 +++++++ tests/openai-responses-passthrough.test.ts | 168 ++ tests/responses-custom-tool-repair.test.ts | 129 ++ tests/responses-stream-tool-events.test.ts | 46 + tests/server-xai-responses-streaming.test.ts | 211 ++- tests/tool-catalog-nudge.test.ts | 99 ++ 34 files changed, 3099 insertions(+), 53 deletions(-) create mode 100644 src/adapters/grok-structured-edit.ts create mode 100644 tests/grok-structured-edit.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 80e1c152dd..08ec30282b 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -240,6 +240,21 @@ encodes that declaration and its history as an upstream function tool, then rest function-call lifecycle to `custom_tool_call` before Codex sees it. Native OpenAI forward routing and the supported `apply_patch` custom tool stay unchanged. +For an xAI/Grok destination, a writable Code Mode turn uses a provider-native catalog instead of +asking Grok to author JavaScript for Codex's freeform `exec` tool. OpenCodex exposes `read_file`, +`grep`, `list_dir`, `search_replace`, `write`, and `run_terminal_command` upstream. It translates +file edits into the caller's existing `apply_patch` helper, translates reads and commands into the +existing `exec_command` helper, and restores the original `exec` call shape, ids, and stream events +before Codex sees the response. Supported calls in prior history are reconstructed into the same +Grok-native vocabulary for continuation turns. + +This bridge is automatic and has no configuration switch. It activates only when the destination +is xAI, Codex supplied a visible freeform `exec` declaration with `apply_patch`, and the turn allows +mutation. Plan/no-mutation turns and non-xAI providers keep their existing tool catalog. OpenCodex +only translates declarations and calls; it never executes the filesystem or shell operation. +Codex remains responsible for sandboxing and approval prompts, including escalation requested for +git mutations. + The selected provider must support function/tool calling. A text-only provider without tool-call support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool mode unchanged. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index ced7a0a112..e2d2f36554 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -146,6 +146,19 @@ Codex の `exec` custom-tool grammar を受け付けない key-auth Responses pr `custom_tool_call` へ復元します。ネイティブ OpenAI の forward routing と、対応済みの `apply_patch` custom tool は 変更されません。 +xAI/Grok 宛てでは、変更可能な Code Mode ターンで Grok に Codex の freeform `exec` 用 JavaScript を直接 +書かせる代わりに、provider-native catalog を使用します。OpenCodex は上流に `read_file`、`grep`、`list_dir`、 +`search_replace`、`write`、`run_terminal_command` を公開します。ファイル編集は呼び出し元が用意した +`apply_patch` helper に、読み取りとコマンドは既存の `exec_command` helper に変換されます。応答が Codex に +届く前に元の `exec` call shape、ID、stream event を復元し、対応している過去の history call も継続ターン用に +同じ Grok-native vocabulary へ再構成します。 + +この bridge は設定なしで自動的に動作します。宛先が xAI で、Codex が `apply_patch` を含む可視の freeform +`exec` 宣言を送り、そのターンで変更が許可されている場合にのみ有効になります。Plan/no-mutation ターンと +xAI 以外の provider は従来の tool catalog を維持し、同名の caller-owned tool も変換されません。OpenCodex は +宣言と call のみを変換し、filesystem や shell 操作を実行しません。git mutation の権限昇格要求を含め、 +sandbox と承認 prompt の責任は引き続き Codex にあります。 + 選択した provider は function/tool calling をサポートしている必要があります。tool call に対応しない text-only provider では `exec`、Browser、Computer Use は使用できません。ネイティブ OpenAI の項目は上流の tool mode を そのまま維持します。 diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 54bd07eef8..dd90d32c2e 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -41,6 +41,18 @@ interface ProviderAdapter { `xhigh`、`max` tier をそのまま保持し、`delta.reasoning_content` または `delta.reasoning` を reasoning delta として扱い、`stream_options.include_usage` でストリーム usage を要求し、非ストリームのレスポンス envelope からも usage を読み取ります。 +## xAI/Grok Code Mode bridge + +`openai-chat` と `openai-responses` は、変更可能な xAI Code Mode ターンに同じ bridge を適用します。 +Codex が `apply_patch` を含む可視の freeform `exec` 宣言を送ると、adapter は `read_file`、`grep`、 +`list_dir`、`search_replace`、`write`、`run_terminal_command` のうち、caller-owned tool と衝突しない +request-local な名前だけを公開します。Plan/no-mutation ターン、xAI 以外の宛先、同名の caller-owned tool は +変更されません。 + +live call と対応済みの history は、呼び出し元の既存 `apply_patch` / `exec_command` helper を介して変換され、 +応答の名前、ID、JSON/SSE event lifecycle は Codex に届く前に復元されます。proxy は filesystem や shell 操作を +実行しません。git mutation の権限昇格 prompt を含め、sandbox と承認の責任は Codex に残ります。 + ## `openai-responses` **対象:** OpenAI **Responses API**。**`passthrough: true`** — 通常は元のリクエストとレスポンスをそのまま渡し、ルーティング先ゲートウェイに必要な限定的な互換変換だけを適用します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index b1a15ea4c2..b8bd2d6a57 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -136,6 +136,19 @@ history를 업스트림 function tool로 인코딩한 다음 스트리밍된 fun `custom_tool_call`로 복원합니다. 네이티브 OpenAI forward routing과 지원되는 `apply_patch` custom tool은 변경되지 않습니다. +xAI/Grok 대상에서는 파일 수정이 허용된 Code Mode 턴에 Grok이 Codex의 freeform `exec`용 JavaScript를 직접 +작성하도록 요구하는 대신 provider-native catalog를 사용합니다. OpenCodex는 upstream에 `read_file`, `grep`, +`list_dir`, `search_replace`, `write`, `run_terminal_command`를 노출합니다. 파일 편집은 호출자가 원래 제공한 +`apply_patch` helper로, 읽기와 명령은 기존 `exec_command` helper로 변환하며, 응답이 Codex에 도달하기 전에 원래 +`exec` call shape, id, stream event를 복원합니다. 지원되는 이전 history call도 continuation 턴에서 같은 +Grok-native vocabulary로 재구성합니다. + +이 bridge는 별도 설정 없이 자동으로 동작합니다. 대상이 xAI이고, Codex가 `apply_patch`를 포함한 visible +freeform `exec` 선언을 보냈으며, 해당 턴이 수정을 허용할 때만 활성화됩니다. Plan/no-mutation 턴과 xAI가 아닌 +provider는 기존 tool catalog를 유지합니다. OpenCodex는 선언과 호출만 변환하며 filesystem 또는 shell 작업을 +직접 실행하지 않습니다. git mutation에 필요한 권한 상승 요청을 포함해 sandbox와 승인 prompt는 계속 Codex가 +담당합니다. + 선택한 provider는 function/tool calling을 지원해야 합니다. tool call을 지원하지 않는 text-only provider에서는 `exec`, Browser 또는 Computer Use를 사용할 수 없습니다. 네이티브 OpenAI 항목은 업스트림 tool mode를 그대로 유지합니다. diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index abf353b871..723f70829e 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -47,6 +47,19 @@ interface ProviderAdapter { 유지하고, `delta.reasoning_content` 또는 `delta.reasoning`을 reasoning delta로 처리하며, `stream_options.include_usage`로 스트림 usage를 요청하고 비스트림 응답 envelope에서도 usage를 읽습니다. +## xAI/Grok Code Mode bridge + +`openai-chat`과 `openai-responses` 경로는 수정 가능한 xAI Code Mode 턴에 같은 bridge를 적용합니다. +Codex가 `apply_patch`를 포함한 visible freeform `exec` 선언을 보내면 adapter는 `read_file`, `grep`, +`list_dir`, `search_replace`, `write`, `run_terminal_command` 중 caller-owned tool과 충돌하지 않는 +request-local 이름만 노출합니다. Plan/no-mutation 턴, xAI가 아닌 대상, 같은 이름의 caller-owned tool은 +변경하지 않습니다. + +실시간 호출과 지원되는 history는 호출자의 기존 `apply_patch` 및 `exec_command` helper를 통해 변환되며, +응답 이름, ID, JSON/SSE event lifecycle은 Codex에 도달하기 전에 복원됩니다. proxy는 filesystem 또는 shell +작업을 실행하지 않습니다. git mutation의 권한 상승 prompt를 포함해 sandbox와 승인 권한은 계속 Codex가 +담당합니다. + ## `openai-responses` **대상:** OpenAI **Responses API**. **`passthrough: true`** — 일반적으로 원본 요청과 응답을 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 29859992f7..5b68409ad9 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -47,6 +47,19 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), tiers, accepts reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. +## xAI/Grok Code Mode bridge + +The `openai-chat` and `openai-responses` paths apply the same bridge to writable xAI Code Mode +turns. When Codex supplies a visible freeform `exec` declaration containing `apply_patch`, the +adapter projects the collision-free request-local subset of `read_file`, `grep`, `list_dir`, +`search_replace`, `write`, and `run_terminal_command`. Plan/no-mutation turns, non-xAI destinations, +and caller-owned tools with the same names remain unchanged. + +Live calls and supported history are translated through the caller's existing `apply_patch` and +`exec_command` helpers; response names, IDs, and JSON/SSE event lifecycles are restored before they +reach Codex. The proxy never executes the filesystem or shell operation. Codex remains the sandbox +and approval authority, including escalation prompts for git mutations. + ## `openai-responses` **Targets:** the OpenAI **Responses API**. **`passthrough: true`** — normally forwards the raw request diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 118c663870..3fc735e375 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -217,6 +217,21 @@ opencodex кодирует объявление и историю как functio потоковый lifecycle function call в `custom_tool_call` до передачи в Codex. Нативная forward- маршрутизация OpenAI и поддерживаемый custom tool `apply_patch` остаются без изменений. +Для назначения xAI/Grok изменяемый turn Code Mode использует provider-native catalog вместо того, +чтобы Grok напрямую писал JavaScript для freeform-инструмента `exec` в Codex. OpenCodex показывает +upstream-инструменты `read_file`, `grep`, `list_dir`, `search_replace`, `write` и +`run_terminal_command`. Изменения файлов преобразуются в существующий helper `apply_patch` +вызывающей стороны, а чтение и команды — в существующий helper `exec_command`. До передачи ответа +в Codex восстанавливаются исходные shape вызова `exec`, ID и stream events; поддерживаемые вызовы +из предыдущей history также реконструируются в тот же Grok-native vocabulary для продолжения. + +Bridge работает автоматически и не требует настройки. Он включается только для назначения xAI, +когда Codex передал видимое freeform-объявление `exec` с `apply_patch` и turn разрешает изменения. +Plan/no-mutation turns и provider'ы не-xAI сохраняют прежний tool catalog; одноимённые caller-owned +tools также не преобразуются. OpenCodex меняет только объявления и вызовы, но не выполняет операции +filesystem или shell. Sandboxing и approval prompts, включая запрос повышения прав для git +mutations, остаются ответственностью Codex. + Выбранный provider должен поддерживать function/tool calling. Text-only provider без tool calls не может использовать `exec`, Browser или Computer Use. Нативные записи OpenAI сохраняют свой upstream tool mode без изменений. diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index eca16ca669..b589cb8c34 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -51,6 +51,19 @@ interface ProviderAdapter { `delta.reasoning_content` или `delta.reasoning`, запрашивает usage потока через `stream_options.include_usage` и читает usage из envelope нестримингового ответа. +## Bridge xAI/Grok Code Mode + +Пути `openai-chat` и `openai-responses` применяют один bridge к изменяемым turns xAI Code Mode. +Когда Codex передаёт видимое freeform-объявление `exec` с `apply_patch`, adapter показывает только +не конфликтующее с caller-owned tools request-local подмножество `read_file`, `grep`, `list_dir`, +`search_replace`, `write` и `run_terminal_command`. Plan/no-mutation turns, назначения не-xAI и +одноимённые caller-owned tools остаются без изменений. + +Live calls и поддерживаемая history преобразуются через существующие helpers `apply_patch` и +`exec_command` вызывающей стороны; имена ответов, ID и lifecycle событий JSON/SSE восстанавливаются +до передачи в Codex. Proxy не выполняет операции filesystem или shell. Codex остаётся владельцем +sandbox и approval prompts, включая повышение прав для git mutations. + ## `openai-responses` **Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index ef693616f6..2922d50d95 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -191,6 +191,17 @@ Codex 显示的模型来自一个磁盘上的 catalog(默认是 `$CODEX_HOME/o 历史记录编码成上游 function tool,再在 Codex 收到结果前,把流式 function-call lifecycle 还原成 `custom_tool_call`。原生 OpenAI forward routing 和已支持的 `apply_patch` custom tool 保持不变。 +对于 xAI/Grok 目标,可写的 Code Mode 回合会使用 provider-native catalog,而不是要求 Grok 直接为 Codex 的 +freeform `exec` 工具编写 JavaScript。OpenCodex 会向上游公开 `read_file`、`grep`、`list_dir`、 +`search_replace`、`write` 和 `run_terminal_command`。文件编辑会转换为调用方已有的 `apply_patch` helper, +读取和命令会转换为现有的 `exec_command` helper;在响应到达 Codex 前,还会恢复原始 `exec` 调用形状、ID 和 +流式事件。历史记录中受支持的调用也会重建为同一套 Grok-native vocabulary,以供后续回合继续使用。 + +该 bridge 无需配置,会自动工作。它只在目标为 xAI、Codex 提供了一个包含 `apply_patch` 的可见 freeform +`exec` 声明,并且当前回合允许修改时启用。Plan/no-mutation 回合和非 xAI provider 会保留原有 tool catalog; +调用方拥有的同名工具也不会被转换。OpenCodex 只转换声明和调用,绝不会执行 filesystem 或 shell 操作。 +包括 git mutation 权限提升请求在内的 sandbox 和审批提示仍由 Codex 负责。 + 所选 provider 必须支持 function/tool calling。不支持 tool call 的 text-only provider 无法使用 `exec`、 Browser 或 Computer Use。原生 OpenAI 条目会保持其上游 tool mode 不变。 diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 5786952810..c7c771adbe 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -43,6 +43,17 @@ interface ProviderAdapter { `medium`、`high`、`xhigh` 或 `max` 档位,把 `delta.reasoning_content` 或 `delta.reasoning` 作为 reasoning delta,通过 `stream_options.include_usage` 请求流式 usage,并从非流式响应 envelope 中读取 usage。 +## xAI/Grok Code Mode bridge + +`openai-chat` 和 `openai-responses` 路径会对可写的 xAI Code Mode 回合应用同一个 bridge。 +当 Codex 提供包含 `apply_patch` 的可见 freeform `exec` 声明时,adapter 只会公开 `read_file`、`grep`、 +`list_dir`、`search_replace`、`write` 和 `run_terminal_command` 中不与 caller-owned tool 冲突的 +request-local 子集。Plan/no-mutation 回合、非 xAI 目标和调用方拥有的同名工具保持不变。 + +实时调用和受支持的历史记录会通过调用方现有的 `apply_patch` 与 `exec_command` helper 转换;响应名称、ID +以及 JSON/SSE 事件生命周期会在到达 Codex 前恢复。proxy 不会执行 filesystem 或 shell 操作。包括 git +mutation 权限提升提示在内的 sandbox 和审批权仍由 Codex 负责。 + ## `openai-responses` **目标:** OpenAI **Responses API**。**`passthrough: true`** —— 通常原样转发请求与响应,仅对 diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 626a45f286..383944c151 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -24,7 +24,7 @@ import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; import { identifyRoutedModel } from "./identity"; import { redactSecretString } from "../lib/redact"; import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint"; -import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText } from "./tool-catalog-nudge"; import { decodeServerSentEvents } from "../lib/sse-decoder"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, type TranslatorBudget } from "../lib/translator-budget"; @@ -672,6 +672,7 @@ function messagesToAnthropicFormat( parsed.context.tools, parsed.options.toolChoice, tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)), + effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt), ); const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])]; const system = systemParts.length diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 3f3f3d06b6..e5c54bd196 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -74,6 +74,10 @@ export interface AdapterRequest { convertedRoutedToolSearchNames?: ReadonlySet; /** Upstream-only aliases for namespace tools flattened in this request. */ convertedRoutedNamespaceToolAliases?: ReadonlyMap; + /** Native Grok tool names introduced while replacing Codex code-mode exec. */ + convertedGrokNativeToolNames?: ReadonlySet; + /** Caller-facing custom exec name that receives converted native Grok calls. */ + grokStructuredEditExecSinkName?: string; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 8f2edbdb55..6b99767de8 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -10,7 +10,7 @@ import { readBoundedResponseBody } from "../lib/bounded-body"; import { configuredReasoningEfforts } from "../reasoning-effort"; import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from "../providers/command-code-efforts"; import { identifyRoutedModel } from "./identity"; -import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; // Retain the short ids emitted by the first local integration. New requests use the live catalog's @@ -455,7 +455,7 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA if (!provider.apiKey) throw new Error("Command Code credential missing — run ocx login command-code"); const cwd = currentWorkingDirectory(); const tools = visibleTools(parsed); - const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice); + const toolNudge = buildNonOpenAIToolCatalogNudgeForTools(tools, parsed.options.toolChoice, undefined, effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt)); const choiceInstruction = toolChoiceInstruction(parsed); const system = identifyRoutedModel([ ...(parsed.context.systemPrompt ?? []), diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 746f9490a4..38569ba17e 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -32,7 +32,7 @@ import { retainTranslatedEventBatch, type TranslatorBudget, } from "../lib/translator-budget"; -import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText } from "./tool-catalog-nudge"; import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort"; // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between @@ -187,7 +187,7 @@ function messagesToGeminiFormat( ): { systemInstruction?: unknown; contents: unknown[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. - const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice); + const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice, undefined, effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt)); const systemText = identifyRoutedModel([ ...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : []), diff --git a/src/adapters/grok-structured-edit.ts b/src/adapters/grok-structured-edit.ts new file mode 100644 index 0000000000..7933545b15 --- /dev/null +++ b/src/adapters/grok-structured-edit.ts @@ -0,0 +1,1354 @@ +import type { AdapterEvent, OcxProviderConfig, OcxRequestOptions, OcxTool } from "../types"; +import { isAllowedToolChoice, namespacedToolName, toolChoiceToolPredicate } from "../types"; +import { + declaredToolsBlock, + effectiveInstructionText, + shouldSuppressCodeModePatchGuidance, +} from "./tool-catalog-nudge"; + +/** + * Grok Build catalog for xAI code-mode turns. Cursor advertises `edit_file`; + * Grok is shown the Grok Build names (`read_file`, `grep`, `list_dir`, + * `search_replace`, `write`, `run_terminal_command`) instead of Codex `exec` / + * `ALL_TOOLS`. Completed calls are converted into `exec` or `apply_patch` for Codex. + */ +export const GROK_READ_FILE_TOOL = "read_file"; +export const GROK_GREP_TOOL = "grep"; +export const GROK_LIST_DIR_TOOL = "list_dir"; +export const GROK_SEARCH_REPLACE_TOOL = "search_replace"; +export const GROK_WRITE_TOOL = "write"; +export const GROK_RUN_TERMINAL_COMMAND_TOOL = "run_terminal_command"; +/** Older catalog name; still converted if Grok emits it. */ +const GROK_WRITE_ALIAS = "write_file"; +export const GROK_NATIVE_TOOLS = [ + GROK_READ_FILE_TOOL, + GROK_GREP_TOOL, + GROK_LIST_DIR_TOOL, + GROK_SEARCH_REPLACE_TOOL, + GROK_WRITE_TOOL, + GROK_RUN_TERMINAL_COMMAND_TOOL, +] as const; +export const GROK_STRUCTURED_EDIT_TOOLS = [GROK_SEARCH_REPLACE_TOOL, GROK_WRITE_TOOL] as const; +const GROK_NATIVE_CALL_NAMES = new Set([...GROK_NATIVE_TOOLS, GROK_WRITE_ALIAS]); + +const PATCH_BEGIN = "*** Begin Patch"; +const PATCH_END = "*** End Patch"; +const PATH_ARG_KEYS = ["target_file", "targetFile", "target_directory", "targetDirectory", "file_path", "filePath", "path", "filepath", "filename", "file"] as const; +const OLD_STRING_KEYS = ["old_string", "oldString", "old_str", "oldtext", "old_text", "old_content", "before", "search"] as const; +const NEW_STRING_KEYS = ["new_string", "newString", "new_str", "newtext", "new_text", "contents", "content", "new_contents", "after", "replace"] as const; +const WRITE_CONTENT_KEYS = ["content", "contents", ...NEW_STRING_KEYS] as const; +const COMMAND_ARG_KEYS = ["command", "cmd", "cmd_line", "cmdLine"] as const; +const PATTERN_ARG_KEYS = ["pattern", "query", "regex"] as const; +const GLOB_ARG_KEYS = ["glob", "include", "glob_pattern"] as const; +const CODEX_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const; + +export const GROK_SEARCH_REPLACE_INPUT_SCHEMA = { + type: "object", + properties: { + file_path: { type: "string", description: "Path of the file to edit, relative to the workspace root." }, + old_string: { type: "string", description: "Exact text to replace. Must match the current file content, including line breaks. Empty creates a new file when new_string is non-empty." }, + new_string: { type: "string", description: "Replacement text. Empty removes the matched text." }, + }, + required: ["file_path", "old_string", "new_string"], + additionalProperties: false, +} as const; + +export const GROK_WRITE_INPUT_SCHEMA = { + type: "object", + properties: { + file_path: { type: "string", description: "Path of the file to create, relative to the workspace root." }, + content: { type: "string", description: "Full contents of the new file." }, + }, + required: ["file_path", "content"], + additionalProperties: false, +} as const; + +export const GROK_READ_FILE_INPUT_SCHEMA = { + type: "object", + properties: { + target_file: { type: "string", description: "Path of the file to read, relative to the workspace root." }, + offset: { type: "integer", description: "Optional 1-based start line." }, + limit: { type: "integer", description: "Optional number of lines to read." }, + }, + required: ["target_file"], + additionalProperties: false, +} as const; + +export const GROK_GREP_INPUT_SCHEMA = { + type: "object", + properties: { + pattern: { type: "string", description: "Regular expression to search for." }, + path: { type: "string", description: "File or directory to search. Defaults to the workspace root." }, + glob: { type: "string", description: "Optional glob to limit which files are searched." }, + }, + required: ["pattern"], + additionalProperties: false, +} as const; + +export const GROK_LIST_DIR_INPUT_SCHEMA = { + type: "object", + properties: { + target_directory: { type: "string", description: "Directory to list. Defaults to the workspace root." }, + }, + additionalProperties: false, +} as const; + +export const GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA = { + type: "object", + properties: { + command: { type: "string", description: "Shell command to run." }, + working_directory: { type: "string", description: "Working directory for the command." }, + with_escalated_permissions: { + type: "boolean", + description: + "True asks Codex to prompt for a sandbox escalation. Required for git add/commit because the sandbox cannot write .git/index.lock.", + }, + justification: { + type: "string", + description: "Short reason shown in the Codex permission prompt when escalating.", + }, + }, + required: ["command"], + additionalProperties: false, +} as const; + +const GIT_INDEX_ESCALATION_JUSTIFICATION = + "Write the git index to stage or commit; the sandbox cannot create .git/index.lock."; + +const GIT_MUTATING_COMMANDS = new Set([ + "add", "commit", "stash", "rm", "mv", "tag", "update-index", "cherry-pick", "rebase", "merge", "notes", +]); +const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([ + "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree", "--namespace", "--super-prefix", +]); + +function shellCommandSegments(command: string): string[][] { + const segments: string[][] = [[]]; + let word = ""; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + const flushWord = () => { + if (word.length > 0) segments[segments.length - 1]!.push(word); + word = ""; + }; + const flushSegment = () => { + flushWord(); + if (segments[segments.length - 1]!.length > 0) segments.push([]); + }; + for (const character of command) { + if (escaped) { + word += character; + escaped = false; + continue; + } + if (quote) { + if (character === quote) quote = undefined; + else if (character === "\\" && quote !== "'") escaped = true; + else word += character; + continue; + } + if (character === "'" || character === '"' || character === "`") { + quote = character; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === "\n" || character === "\r") { + flushSegment(); + continue; + } + if (/\s/.test(character)) { + flushWord(); + continue; + } + if (character === ";" || character === "|" || character === "&") { + flushSegment(); + continue; + } + word += character; + } + flushWord(); + return segments.filter(segment => segment.length > 0); +} + +function gitSubcommand(words: readonly string[]): string | undefined { + let index = 0; + while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index] ?? "")) index += 1; + if (words[index] === "command") index += 1; + if (words[index] !== "git") return undefined; + index += 1; + while (index < words.length) { + const word = words[index]!; + if (!word.startsWith("-")) return word.toLowerCase(); + if (word === "--") return undefined; + if (GIT_GLOBAL_OPTIONS_WITH_VALUE.has(word)) { + index += 2; + continue; + } + if (/^-C.+/.test(word) || /^-c.+/.test(word) || /^--(?:config-env|exec-path|git-dir|work-tree|namespace|super-prefix)=/.test(word)) { + index += 1; + continue; + } + index += 1; + } + return undefined; +} + +/** Git mutations need a Codex sandbox escalation; status/log/diff do not. */ +export function grokShellNeedsGitEscalation(cmd: string): boolean { + return shellCommandSegments(cmd).some(segment => { + const subcommand = gitSubcommand(segment); + return subcommand !== undefined && GIT_MUTATING_COMMANDS.has(subcommand); + }); +} + +export type GrokStructuredEditTranslation = + | { patch: string; error?: undefined } + | { error: string; patch?: undefined }; + +export type GrokEditCodexSink = + | { kind: "exec"; name: string } + | { kind: "apply_patch" }; + +export function isGrokStructuredEditToolName(name: string): boolean { + return name === GROK_SEARCH_REPLACE_TOOL || name === GROK_WRITE_TOOL || name === GROK_WRITE_ALIAS; +} + +export function isGrokNativeToolName(name: string): boolean { + return GROK_NATIVE_CALL_NAMES.has(name); +} + +function isGrokWriteToolName(name: string): boolean { + return name === GROK_WRITE_TOOL || name === GROK_WRITE_ALIAS; +} + +export function isXaiGrokChatProvider(provider: Pick): boolean { + try { + const host = new URL(provider.baseUrl).hostname.toLowerCase(); + return host === "api.x.ai" || host.endsWith(".x.ai") || host === "cli-chat-proxy.grok.com"; + } catch { + return false; + } +} + +const CODEX_APPLY_PATCH_EDIT_CONSTRAINT = + /Use `apply_patch` for local file edits\. Do not create or edit files with `cat` or other shell write tricks\. Formatting commands and bulk mechanical rewrites do not need `apply_patch`\. Do not use Python to read or write files when a simple shell command or `apply_patch` is enough\./g; + +const GROK_FILE_EDIT_CONSTRAINT = + "File edits on this turn use the listed tools `write` and `search_replace`. " + + "OpenCodex converts those calls into Codex apply_patch. " + + "Do not call `apply_patch` or `exec` unless this turn's catalog lists those exact names. " + + "Do not create or edit files with `cat` or other shell write tricks."; + +/** + * Codex base instructions name `apply_patch` as the edit tool. Grok's advertised + * catalog does not list it. Rewrite that constraint so the callable names match. + */ +export function rewriteCodexFileEditGuidanceForGrok(text: string): string { + return text.replace(CODEX_APPLY_PATCH_EDIT_CONSTRAINT, GROK_FILE_EDIT_CONSTRAINT); +} + +function isCodexCodeModeExecTool(tool: Pick): boolean { + return !tool.namespace && tool.name === "exec" && tool.freeform === true; +} + +function isBareShellBridgeTool(tool: Pick): boolean { + return !tool.namespace && (CODEX_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(tool.name); +} + +function isExecToolChoiceName(name: string): boolean { + return name === "exec" || name.endsWith("__exec"); +} + +/** Codex Desktop often sends `{allowedTools:["exec"], mode:"auto"}`, not the string `"auto"`. */ +function grokToolChoiceAllowsNativeCatalog(toolChoice: OcxRequestOptions["toolChoice"] | undefined): boolean { + if (!toolChoice || toolChoice === "auto" || toolChoice === "required") return true; + if (toolChoice === "none") return false; + if (isAllowedToolChoice(toolChoice)) { + return toolChoice.allowedTools.some(isExecToolChoiceName); + } + return typeof toolChoice === "object" && "name" in toolChoice && isExecToolChoiceName(toolChoice.name); +} + +function firstStringArg(args: Record, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = args[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function firstBooleanArg(args: Record, keys: readonly string[]): boolean | undefined { + for (const key of keys) { + const value = args[key]; + if (typeof value === "boolean") return value; + } + return undefined; +} + +function firstNumberArg(args: Record, keys: readonly string[]): number | undefined { + for (const key of keys) { + const value = args[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value); + } + return undefined; +} + +function normalizePatchPath(path: string): string { + let next = path.trim().replace(/\\/g, "/"); + while (next.startsWith("./")) next = next.slice(2); + return next; +} + +function patchLines(text: string): string[] { + const lines = text.split("\n"); + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +function addFilePatch(path: string, contents: string): GrokStructuredEditTranslation { + const lines = patchLines(contents); + if (lines.length === 0 && contents.length === 0) { + return { error: `${GROK_WRITE_TOOL} requires non-empty content; the call was dropped.` }; + } + return { patch: [PATCH_BEGIN, `*** Add File: ${path}`, ...lines.map(line => `+${line}`), PATCH_END].join("\n") }; +} + +function replacementPatch(path: string, oldString: string, newString: string): GrokStructuredEditTranslation { + if (oldString.length === 0) return addFilePatch(path, newString); + const oldLines = patchLines(oldString); + const newLines = patchLines(newString); + if (oldLines.length === newLines.length && oldLines.every((line, i) => line === newLines[i])) { + return { error: `${GROK_SEARCH_REPLACE_TOOL} old_string and new_string are identical; the replacement is a no-op and was dropped.` }; + } + return { + patch: [ + PATCH_BEGIN, + `*** Update File: ${path}`, + "@@", + ...oldLines.map(line => `-${line}`), + ...newLines.map(line => `+${line}`), + PATCH_END, + ].join("\n"), + }; +} + +export function grokEditCodexSink( + tools: readonly Pick[] | undefined, + toolChoice?: OcxRequestOptions["toolChoice"], +): GrokEditCodexSink | undefined { + const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools)); + if (!visible || visible.length === 0) return undefined; + if (visible.some(tool => !tool.namespace && tool.name === "apply_patch")) return { kind: "apply_patch" }; + const exec = visible.find(isCodexCodeModeExecTool); + if (!exec || visible.some(isBareShellBridgeTool)) return undefined; + const helpers = declaredToolsBlock(exec.description ?? ""); + if (!helpers || !/\bapply_patch\s*\(\s*input\s*:\s*string\s*\)/i.test(helpers)) return undefined; + return { kind: "exec", name: namespacedToolName(exec.namespace, exec.name) }; +} + +function grokCodeModeExecSink( + tools: readonly Pick[] | undefined, + toolChoice?: OcxRequestOptions["toolChoice"], +): Extract | undefined { + const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools)); + if (!visible || visible.length === 0) return undefined; + const exec = visible.find(isCodexCodeModeExecTool); + if (!exec || visible.some(isBareShellBridgeTool)) return undefined; + const helpers = declaredToolsBlock(exec.description ?? ""); + if (!helpers || !/\bapply_patch\s*\(\s*input\s*:\s*string\s*\)/i.test(helpers)) return undefined; + // Live Codex often declares only apply_patch in this block. exec_command still exists + // on the isolate (ALL_TOOLS); requiring it here left exec in Grok's catalog. + return { kind: "exec", name: namespacedToolName(exec.namespace, exec.name) }; +} + +export function grokNativeCatalogTools( + tools: readonly Pick[] | undefined, + toolChoice: OcxRequestOptions["toolChoice"] | undefined, + provider: Pick, + effectiveInstructions?: readonly string[], +): OcxTool[] { + if (!isXaiGrokChatProvider(provider)) return []; + if (!grokToolChoiceAllowsNativeCatalog(toolChoice)) return []; + if (shouldSuppressCodeModePatchGuidance((effectiveInstructions ?? []).join("\n"))) return []; + if (!grokCodeModeExecSink(tools, toolChoice)) return []; + const existingBareNames = new Set((tools ?? []).filter(tool => !tool.namespace).map(tool => tool.name)); + const candidates: OcxTool[] = [ + { + name: GROK_READ_FILE_TOOL, + description: "Read a file from the workspace.", + parameters: { ...GROK_READ_FILE_INPUT_SCHEMA }, + }, + { + name: GROK_GREP_TOOL, + description: "Search file contents with a regular expression.", + parameters: { ...GROK_GREP_INPUT_SCHEMA }, + }, + { + name: GROK_LIST_DIR_TOOL, + description: "List a directory.", + parameters: { ...GROK_LIST_DIR_INPUT_SCHEMA }, + }, + { + name: GROK_SEARCH_REPLACE_TOOL, + description: + "Replace one block of text in a file. Call this tool to edit; describing the edit in assistant text does not change the file. OpenCodex converts the replacement into a Codex apply_patch change. Copy old_string and new_string with their exact leading whitespace — Codex may locate a line after trimming indent, but it writes new_string verbatim, so stripped indent silently corrupts the file. An empty old_string with a non-empty new_string creates a new file (Add File). If the same text appears more than once, the first match is updated.", + parameters: { ...GROK_SEARCH_REPLACE_INPUT_SCHEMA }, + }, + { + name: GROK_WRITE_TOOL, + description: + "Create a new file, including files produced by a refactor or split. Splitting a large file is one write per new module from slices already read, not a design outline in assistant text. Call this tool to create the file; describing the change does not write it. OpenCodex converts the content into a Codex apply_patch Add File. Use search_replace to change an existing file.", + parameters: { ...GROK_WRITE_INPUT_SCHEMA }, + }, + { + name: GROK_RUN_TERMINAL_COMMAND_TOOL, + description: + "Run a shell command. Use read_file, grep, list_dir, search_replace, and write for ordinary file work. Git add/commit must set with_escalated_permissions=true (and a short justification) so Codex can prompt to write .git/index.lock; a commentary message cannot request that permission. If a command fails with Operation not permitted, retry once with with_escalated_permissions=true.", + parameters: { ...GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA }, + }, + ]; + return candidates.filter(tool => !existingBareNames.has(tool.name)); +} + +type GrokNativeCatalogRequest = { + context?: { + tools?: readonly Pick[]; + messages?: Parameters[0]; + systemPrompt?: readonly string[]; + }; + options?: { toolChoice?: OcxRequestOptions["toolChoice"] }; +}; + +/** Exact upstream-only Grok names introduced for this request after collision filtering. */ +export function grokNativeToolNamesForRequest( + parsed: GrokNativeCatalogRequest, + provider: Pick, +): ReadonlySet { + const context = parsed.context ?? {}; + const effectiveInstructions = effectiveInstructionText(context.messages, context.systemPrompt); + return new Set(grokNativeCatalogTools( + context.tools, + parsed.options?.toolChoice, + provider, + effectiveInstructions, + ).map(tool => tool.name)); +} + +/** @deprecated Use grokNativeCatalogTools. Write tools only, for older tests. */ +export function grokStructuredEditTools( + tools: readonly Pick[] | undefined, + toolChoice: OcxRequestOptions["toolChoice"] | undefined, + provider: Pick, + effectiveInstructions?: readonly string[], +): OcxTool[] { + return grokNativeCatalogTools(tools, toolChoice, provider, effectiveInstructions) + .filter(tool => isGrokStructuredEditToolName(tool.name)); +} + +export function grokFacingTools( + tools: readonly OcxTool[] | undefined, + toolChoice: OcxRequestOptions["toolChoice"] | undefined, + provider: Pick, + effectiveInstructions?: readonly string[], +): OcxTool[] | undefined { + if (!tools) return undefined; + const native = grokNativeCatalogTools(tools, toolChoice, provider, effectiveInstructions); + const visible = tools.filter(toolChoiceToolPredicate(toolChoice, tools)); + if (native.length === 0) return visible; + return [...visible.filter(tool => tool.namespace || tool.name !== "exec"), ...native]; +} + +function isPlainRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function grokResponsesFunctionTool(tool: OcxTool): Record { + return { + type: "function", + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + parameters: tool.parameters, + ...(tool.strict !== undefined ? { strict: tool.strict } : {}), + }; +} + +function isResponsesCodeModeExecDeclaration(tool: unknown): boolean { + return isPlainRecord(tool) && tool.type === "custom" && tool.name === "exec"; +} + +function rewriteResponsesToolGroup( + tools: unknown[], + replacements: readonly Record[], + state: { injected: boolean; replaced: boolean }, +): unknown[] { + const rewritten: unknown[] = []; + for (const tool of tools) { + if (isResponsesCodeModeExecDeclaration(tool)) { + state.replaced = true; + if (!state.injected) { + rewritten.push(...replacements); + state.injected = true; + } + continue; + } + if ( + isPlainRecord(tool) + && tool.type === "namespace" + && tool.name === "functions" + && Array.isArray(tool.tools) + ) { + const inner = tool.tools.filter(entry => !isResponsesCodeModeExecDeclaration(entry)); + if (inner.length !== tool.tools.length) { + state.replaced = true; + if (inner.length > 0) rewritten.push({ ...tool, tools: inner }); + if (!state.injected) { + rewritten.push(...replacements); + state.injected = true; + } + continue; + } + } + rewritten.push(tool); + } + return rewritten; +} + +function rewriteResponsesInstructionContent(content: unknown): unknown { + if (typeof content === "string") return rewriteCodexFileEditGuidanceForGrok(content); + if (!Array.isArray(content)) return content; + let changed = false; + const next = content.map(part => { + if (!isPlainRecord(part) || typeof part.text !== "string") return part; + const text = rewriteCodexFileEditGuidanceForGrok(part.text); + if (text === part.text) return part; + changed = true; + return { ...part, text }; + }); + return changed ? next : content; +} + +function rewriteResponsesInstructions(body: Record): Record { + let next = body; + if (typeof body.instructions === "string") { + const instructions = rewriteCodexFileEditGuidanceForGrok(body.instructions); + if (instructions !== body.instructions) next = { ...next, instructions }; + } + if (!Array.isArray(next.input)) return next; + let changed = false; + const input = next.input.map(item => { + if ( + !isPlainRecord(item) + || (item.type !== undefined && item.type !== "message") + || (item.role !== "developer" && item.role !== "system") + ) return item; + const content = rewriteResponsesInstructionContent(item.content); + if (content === item.content) return item; + changed = true; + return { ...item, content }; + }); + return changed ? { ...next, input } : next; +} + +function parsedExecArguments(item: Record): Record | undefined { + if (item.type === "custom_tool_call") { + return typeof item.input === "string" ? { input: item.input } : undefined; + } + if (item.type !== "function_call" || typeof item.arguments !== "string") return undefined; + try { + const parsed: unknown = JSON.parse(item.arguments); + return isPlainRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function responsesFunctionCallItemId(id: unknown): unknown { + if (typeof id !== "string" || !id.startsWith("ctc_")) return id; + return `fc_${id.slice(4)}`; +} + +function rewriteResponsesExecHistory( + body: Record, + sinkName: string, + convertedNativeToolNames: ReadonlySet, +): Record { + if (!Array.isArray(body.input)) return body; + const reconstructed = new Map(); + for (const item of body.input) { + if (!isPlainRecord(item) || item.name !== sinkName || typeof item.call_id !== "string") continue; + const args = parsedExecArguments(item); + const call = args ? reconstructGrokToolCallFromExec(args) : undefined; + if (call && convertedNativeToolNames.has(call.name)) reconstructed.set(item.call_id, call); + } + if (reconstructed.size === 0) return body; + let changed = false; + const input = body.input.map(item => { + if (!isPlainRecord(item) || typeof item.call_id !== "string") return item; + const call = reconstructed.get(item.call_id); + if (!call) return item; + if ( + (item.type === "custom_tool_call" || item.type === "function_call") + && item.name === sinkName + ) { + const { input: _input, arguments: _arguments, ...rest } = item; + changed = true; + return { + ...rest, + type: "function_call", + id: responsesFunctionCallItemId(item.id), + name: call.name, + arguments: JSON.stringify(call.arguments), + }; + } + if (item.type === "custom_tool_call_output") { + changed = true; + return { ...item, type: "function_call_output" }; + } + return item; + }); + return changed ? { ...body, input } : body; +} + +function rewriteResponsesExecToolChoice( + body: Record, + nativeNames: readonly string[], +): Record { + const choice = body.tool_choice; + if (!isPlainRecord(choice)) return body; + if ((choice.type === "custom" || choice.type === "function") && choice.name === "exec") { + return { ...body, tool_choice: "required" }; + } + if (choice.type !== "allowed_tools" || !Array.isArray(choice.tools)) return body; + let replaced = false; + const tools: unknown[] = []; + const seen = new Set(); + for (const entry of choice.tools) { + if (isPlainRecord(entry) && entry.name === "exec") { + replaced = true; + for (const name of nativeNames) { + if (seen.has(name)) continue; + seen.add(name); + tools.push({ type: "function", name }); + } + continue; + } + const key = isPlainRecord(entry) && typeof entry.name === "string" ? entry.name : undefined; + if (key && seen.has(key)) continue; + if (key) seen.add(key); + tools.push(entry); + } + return replaced ? { ...body, tool_choice: { ...choice, tools } } : body; +} + +export type GrokResponsesRequestRewrite = { + body: unknown; + convertedNativeToolNames: ReadonlySet; + execSinkName?: string; +}; + +/** + * Apply the Grok Build catalog to raw Responses passthrough requests. Unlike the + * Chat adapter, this path serializes `_rawBody`, so parsed-context changes alone + * cannot affect the upstream catalog. + */ +export function rewriteGrokResponsesRequestBody( + body: unknown, + parsed: { + context?: { + tools?: readonly OcxTool[]; + messages?: Parameters[0]; + systemPrompt?: readonly string[]; + }; + options?: { toolChoice?: OcxRequestOptions["toolChoice"] }; + }, + provider: Pick, +): GrokResponsesRequestRewrite { + if (!isPlainRecord(body)) return { body, convertedNativeToolNames: new Set() }; + const context = parsed.context ?? {}; + const toolChoice = parsed.options?.toolChoice; + const effectiveInstructions = effectiveInstructionText( + context.messages, + context.systemPrompt, + ); + const native = grokNativeCatalogTools( + context.tools, + toolChoice, + provider, + effectiveInstructions, + ); + const sink = grokCodeModeExecSink(context.tools, toolChoice); + if (native.length === 0 || !sink) return { body, convertedNativeToolNames: new Set() }; + + const convertedNativeToolNames = new Set(native.map(tool => tool.name)); + const replacements = native.map(grokResponsesFunctionTool); + const state = { injected: false, replaced: false }; + let next: Record = body; + if (Array.isArray(next.tools)) { + const originalTools = next.tools; + const tools = rewriteResponsesToolGroup(originalTools, replacements, state); + if (tools.length !== originalTools.length || tools.some((entry, index) => entry !== originalTools[index])) { + next = { ...next, tools }; + } + } + if (Array.isArray(next.input)) { + let changed = false; + const input = next.input.map(item => { + if (!isPlainRecord(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const originalTools = item.tools; + const tools = rewriteResponsesToolGroup(originalTools, replacements, state); + if (tools.length === originalTools.length && tools.every((entry, index) => entry === originalTools[index])) return item; + changed = true; + return { ...item, tools }; + }); + if (changed) next = { ...next, input }; + } + // A parsed tool can come from replay metadata rather than a writable catalog + // container. Do not arm response rewriting unless an actual exec declaration + // was replaced on the wire. + if (!state.replaced) return { body, convertedNativeToolNames: new Set() }; + + next = rewriteResponsesInstructions(next); + next = rewriteResponsesExecHistory(next, sink.name, convertedNativeToolNames); + next = rewriteResponsesExecToolChoice(next, [...convertedNativeToolNames]); + return { + body: next, + convertedNativeToolNames, + execSinkName: sink.name, + }; +} + +export function translateGrokStructuredEditCall( + toolName: string, + argsText: string, +): GrokStructuredEditTranslation | undefined { + if (!isGrokStructuredEditToolName(toolName)) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(argsText); + if (typeof parsed === "string") parsed = JSON.parse(parsed); + } catch { + return { + error: `${toolName} arguments were not valid JSON; the call was dropped. ` + + (isGrokWriteToolName(toolName) + ? "Use file_path and content." + : "Use file_path, old_string, and new_string."), + }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { error: `${toolName} arguments must be a JSON object; the call was dropped.` }; + } + const args = parsed as Record; + const rawPath = firstStringArg(args, PATH_ARG_KEYS); + const path = rawPath ? normalizePatchPath(rawPath) : undefined; + if (!path) return { error: `${toolName} is missing a non-empty file_path; the call was dropped.` }; + if (/[\n\r\0]/.test(path)) { + return { error: `${toolName} file_path must not contain a newline, CR, or NUL; the call was dropped.` }; + } + if (args.replace_all === true || args.replaceAll === true) { + return { + error: `${toolName} replace_all is not supported; Codex apply_patch first-matches only. Split into unique old_string hunks.`, + }; + } + if (isGrokWriteToolName(toolName)) { + const content = firstStringArg(args, WRITE_CONTENT_KEYS); + if (content === undefined) return { error: `${GROK_WRITE_TOOL} requires content; the call was dropped.` }; + return addFilePatch(path, content); + } + const oldString = firstStringArg(args, OLD_STRING_KEYS); + const newString = firstStringArg(args, NEW_STRING_KEYS); + if (oldString === undefined || newString === undefined) { + return { error: `${GROK_SEARCH_REPLACE_TOOL} requires old_string and new_string; the call was dropped.` }; + } + return replacementPatch(path, oldString, newString); +} + +function parseArgsObject(argsText: string): Record | { error: string } { + try { + const parsed: unknown = JSON.parse(argsText); + const value = typeof parsed === "string" ? JSON.parse(parsed) : parsed; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { error: "arguments must be a JSON object" }; + } + return value as Record; + } catch { + return { error: "arguments were not valid JSON" }; + } +} + +type GrokExecCommandExtras = { + workdir?: string; + requireEscalatedSandbox?: boolean; + justification?: string; +}; + +function encodeExecCommand( + cmd: string, + sinkName: string, + extras: GrokExecCommandExtras = {}, +): { name: string; arguments: string } { + const fields = [`cmd: ${JSON.stringify(cmd)}`]; + if (extras.workdir) fields.push(`workdir: ${JSON.stringify(extras.workdir)}`); + if (extras.requireEscalatedSandbox) fields.push('sandbox_permissions: "require_escalated"'); + if (extras.justification) fields.push(`justification: ${JSON.stringify(extras.justification)}`); + const input = `const r = await tools.exec_command({ ${fields.join(", ")} });\ntext(r.output);`; + return { name: sinkName, arguments: JSON.stringify({ input }) }; +} + +function execCommandExtras(toolName: string, argsText: string, cmd: string): GrokExecCommandExtras { + if (toolName !== GROK_RUN_TERMINAL_COMMAND_TOOL) return {}; + const args = parseArgsObject(argsText); + if ("error" in args) return {}; + const workdir = firstStringArg(args, ["working_directory", "workingDirectory", "workdir", "cwd"]); + const justification = firstStringArg(args, ["justification", "reason", "description"]); + const explicit = firstBooleanArg(args, ["with_escalated_permissions", "withEscalatedPermissions", "escalate"]); + const auto = grokShellNeedsGitEscalation(cmd); + const escalate = explicit !== false && (explicit === true || auto || !!justification); + if (!escalate && !workdir) return {}; + return { + ...(workdir ? { workdir } : {}), + ...(escalate ? { requireEscalatedSandbox: true } : {}), + ...(escalate ? { justification: justification ?? (auto ? GIT_INDEX_ESCALATION_JUSTIFICATION : undefined) } : {}), + }; +} + +function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function powershellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function powershellEncodedCommand(script: string): string { + return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`; +} + +function posixGrokShellCommand( + toolName: string, + path: string | undefined, + offset: number | undefined, + limit: number | undefined, + pattern: string | undefined, + glob: string | undefined, + command: string | undefined, +): { cmd: string } | { error: string } { + if (toolName === GROK_READ_FILE_TOOL) { + if (!path) return { error: `${toolName} is missing a non-empty path` }; + if (offset !== undefined || limit !== undefined) { + const start = Math.max(1, Math.floor(offset ?? 1)); + const end = limit !== undefined ? start + Math.max(0, Math.floor(limit)) - 1 : "$"; + return { cmd: `sed -n ${shellSingleQuote(`${start},${end}p`)} ${shellSingleQuote(path)}` }; + } + return { cmd: `cat -- ${shellSingleQuote(path)}` }; + } + if (toolName === GROK_GREP_TOOL) { + if (!pattern) return { error: `${toolName} is missing a pattern` }; + const target = path ?? "."; + const globFlag = glob ? ` --glob ${shellSingleQuote(glob)}` : ""; + return { cmd: `rg -n${globFlag} -- ${shellSingleQuote(pattern)} ${shellSingleQuote(target)}` }; + } + if (toolName === GROK_LIST_DIR_TOOL) { + return { cmd: `ls -la -- ${shellSingleQuote(path ?? ".")}` }; + } + if (toolName === GROK_RUN_TERMINAL_COMMAND_TOOL) { + if (!command) return { error: `${toolName} is missing a command` }; + return { cmd: command }; + } + return { error: `${toolName} is not a shell-mapped Grok tool` }; +} + +function windowsGrokShellCommand( + toolName: string, + path: string | undefined, + offset: number | undefined, + limit: number | undefined, + pattern: string | undefined, + glob: string | undefined, + command: string | undefined, +): { cmd: string } | { error: string } { + if (toolName === GROK_READ_FILE_TOOL) { + if (!path) return { error: `${toolName} is missing a non-empty path` }; + const literal = powershellSingleQuote(path); + if (offset !== undefined || limit !== undefined) { + const start = Math.max(1, Math.floor(offset ?? 1)); + const skip = start - 1; + const first = limit !== undefined ? ` -First ${Math.max(0, Math.floor(limit))}` : ""; + return { + cmd: powershellEncodedCommand( + `Get-Content -LiteralPath ${literal} | Select-Object -Skip ${skip}${first} | Out-String`, + ), + }; + } + return { cmd: powershellEncodedCommand(`Get-Content -LiteralPath ${literal} -Raw`) }; + } + if (toolName === GROK_GREP_TOOL) { + if (!pattern) return { error: `${toolName} is missing a pattern` }; + const target = powershellSingleQuote(path ?? "."); + const pat = powershellSingleQuote(pattern); + const globFilter = glob + ? ` | Where-Object { $_.Name -like ${powershellSingleQuote(glob)} }` + : ""; + return { + cmd: powershellEncodedCommand( + `$items = @(Get-ChildItem -LiteralPath ${target} -Recurse -File -ErrorAction SilentlyContinue${globFilter}); ` + + `if (-not $items -and (Test-Path -LiteralPath ${target} -PathType Leaf)) { $items = @(Get-Item -LiteralPath ${target}) }; ` + + `$items | Select-String -Pattern ${pat} | ForEach-Object { '{0}:{1}:{2}' -f $_.Path, $_.LineNumber, $_.Line }`, + ), + }; + } + if (toolName === GROK_LIST_DIR_TOOL) { + const target = powershellSingleQuote(path ?? "."); + return { + cmd: powershellEncodedCommand( + `Get-ChildItem -Force -LiteralPath ${target} | Format-Table Mode, Length, LastWriteTime, Name -AutoSize | Out-String`, + ), + }; + } + if (toolName === GROK_RUN_TERMINAL_COMMAND_TOOL) { + if (!command) return { error: `${toolName} is missing a command` }; + return { cmd: command }; + } + return { error: `${toolName} is not a shell-mapped Grok tool` }; +} + +export function translateGrokShellCall( + toolName: string, + argsText: string, + platform: NodeJS.Platform = process.platform, +): { cmd: string } | { error: string } { + const args = parseArgsObject(argsText); + if ("error" in args) return { error: `${toolName} ${args.error}` }; + const rawPath = firstStringArg(args, PATH_ARG_KEYS); + const path = rawPath ? normalizePatchPath(rawPath) : undefined; + const offset = firstNumberArg(args, ["offset", "start_line", "startLine"]); + const limit = firstNumberArg(args, ["limit", "count"]); + const pattern = firstStringArg(args, PATTERN_ARG_KEYS); + const glob = firstStringArg(args, GLOB_ARG_KEYS); + const command = firstStringArg(args, COMMAND_ARG_KEYS); + if (platform === "win32") { + return windowsGrokShellCommand(toolName, path, offset, limit, pattern, glob, command); + } + return posixGrokShellCommand(toolName, path, offset, limit, pattern, glob, command); +} + +export type ReconstructedGrokToolCall = { + name: string; + arguments: Record; +}; + +function parseJsonStringLiteral(source: string): string | undefined { + const trimmed = source.trim(); + if (!trimmed.startsWith("\"")) return undefined; + try { + const value: unknown = JSON.parse(trimmed); + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +function splitJsonStringAtStart(source: string): { value: string; rest: string } | undefined { + if (!source.startsWith("\"")) return undefined; + let index = 1; + while (index < source.length) { + if (source[index] === "\\") { + index += 2; + continue; + } + if (source[index] === "\"") { + const value = parseJsonStringLiteral(source.slice(0, index + 1)); + if (value === undefined) return undefined; + return { value, rest: source.slice(index + 1) }; + } + index += 1; + } + return undefined; +} + +function unquotePowershellLiteral(quoted: string): string | undefined { + if (quoted.length < 2 || quoted[0] !== "'" || quoted[quoted.length - 1] !== "'") return undefined; + return quoted.slice(1, -1).replace(/''/g, "'"); +} + +const POWERSHELL_LITERAL_CAPTURE = "('(?:[^']|'')*')"; +const POWERSHELL_RAW_READ = new RegExp(`^Get-Content -LiteralPath ${POWERSHELL_LITERAL_CAPTURE} -Raw$`); +const POWERSHELL_RANGED_READ = new RegExp(`^Get-Content -LiteralPath ${POWERSHELL_LITERAL_CAPTURE} \\| Select-Object -Skip (\\d+)(?: -First (\\d+))? \\| Out-String$`); +const POWERSHELL_LIST_DIR = new RegExp(`^Get-ChildItem -Force -LiteralPath ${POWERSHELL_LITERAL_CAPTURE} \\| Format-Table Mode, Length, LastWriteTime, Name -AutoSize \\| Out-String$`); +const POWERSHELL_GREP_PATTERN = new RegExp(`Select-String -Pattern ${POWERSHELL_LITERAL_CAPTURE}`); +const POWERSHELL_GREP_DIRECTORY = new RegExp(`Get-ChildItem -LiteralPath ${POWERSHELL_LITERAL_CAPTURE} -Recurse -File`); +const POWERSHELL_GREP_FILE = new RegExp(`Get-Item -LiteralPath ${POWERSHELL_LITERAL_CAPTURE}`); +const POWERSHELL_GREP_GLOB = new RegExp(`Where-Object \\{ \\$_\\.Name -like ${POWERSHELL_LITERAL_CAPTURE} \\}`); + +function reconstructFromApplyPatch(patch: string): ReconstructedGrokToolCall | undefined { + const lines = patch.split("\n"); + if (lines[0] !== PATCH_BEGIN || lines[lines.length - 1] !== PATCH_END || lines.length < 3) return undefined; + const add = /^\*\*\* Add File: (.+)$/.exec(lines[1]); + if (add) { + const contents = lines.slice(2, -1).map(line => (line.startsWith("+") ? line.slice(1) : line)).join("\n"); + return { name: GROK_WRITE_TOOL, arguments: { file_path: add[1], content: contents } }; + } + const update = /^\*\*\* Update File: (.+)$/.exec(lines[1]); + if (!update) return undefined; + const bodyStart = lines[2] === "@@" ? 3 : 2; + const oldLines: string[] = []; + const newLines: string[] = []; + for (const line of lines.slice(bodyStart, -1)) { + if (line.startsWith("-")) oldLines.push(line.slice(1)); + else if (line.startsWith("+")) newLines.push(line.slice(1)); + else if (line.startsWith(" ")) { + oldLines.push(line.slice(1)); + newLines.push(line.slice(1)); + } + } + return { + name: GROK_SEARCH_REPLACE_TOOL, + arguments: { file_path: update[1], old_string: oldLines.join("\n"), new_string: newLines.join("\n") }, + }; +} + +function splitShellSingleQuotedAtStart(source: string): { value: string; rest: string } | undefined { + if (!source.startsWith("'")) return undefined; + let value = ""; + let index = 1; + while (index < source.length) { + if (source.startsWith("'\\''", index)) { + value += "'"; + index += 4; + continue; + } + if (source[index] === "'") return { value, rest: source.slice(index + 1) }; + value += source[index]; + index += 1; + } + return undefined; +} + +function reconstructFromPosixCmd(cmd: string): ReconstructedGrokToolCall | undefined { + if (cmd.startsWith("cat -- ")) { + const path = splitShellSingleQuotedAtStart(cmd.slice("cat -- ".length)); + if (path && path.rest === "" && path.value) { + return { name: GROK_READ_FILE_TOOL, arguments: { target_file: path.value } }; + } + } + const sed = /^sed -n '(\d+),(\$|\d+)p' /.exec(cmd); + if (sed) { + const path = splitShellSingleQuotedAtStart(cmd.slice(sed[0].length)); + if (!path || path.rest !== "" || !path.value) return undefined; + const offset = Number(sed[1]); + const arguments_: Record = { target_file: path.value, offset }; + if (sed[2] !== "$") arguments_.limit = Number(sed[2]) - offset + 1; + return { name: GROK_READ_FILE_TOOL, arguments: arguments_ }; + } + if (cmd.startsWith("ls -la -- ")) { + const path = splitShellSingleQuotedAtStart(cmd.slice("ls -la -- ".length)); + if (path && path.rest === "" && path.value) { + return { name: GROK_LIST_DIR_TOOL, arguments: { target_directory: path.value } }; + } + } + if (!cmd.startsWith("rg -n")) return undefined; + let rest = cmd.slice("rg -n".length); + let glob: string | undefined; + if (rest.startsWith(" --glob ")) { + const split = splitShellSingleQuotedAtStart(rest.slice(" --glob ".length)); + if (!split || !split.value) return undefined; + glob = split.value; + rest = split.rest; + } + if (!rest.startsWith(" -- ")) return undefined; + const pattern = splitShellSingleQuotedAtStart(rest.slice(" -- ".length)); + if (!pattern || !pattern.value || !pattern.rest.startsWith(" ")) return undefined; + const path = splitShellSingleQuotedAtStart(pattern.rest.slice(1)); + if (!path || path.rest !== "") return undefined; + return { + name: GROK_GREP_TOOL, + arguments: { pattern: pattern.value, path: path.value, ...(glob ? { glob } : {}) }, + }; +} + +function reconstructFromWindowsCmd(cmd: string): ReconstructedGrokToolCall | undefined { + const encoded = /^powershell\.exe -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/=]+)$/.exec(cmd.trim()); + if (!encoded) return undefined; + let script: string; + try { + script = Buffer.from(encoded[1], "base64").toString("utf16le"); + } catch { + return undefined; + } + const raw = POWERSHELL_RAW_READ.exec(script); + if (raw) { + const path = unquotePowershellLiteral(raw[1]); + return path ? { name: GROK_READ_FILE_TOOL, arguments: { target_file: path } } : undefined; + } + const ranged = POWERSHELL_RANGED_READ.exec(script); + if (ranged) { + const path = unquotePowershellLiteral(ranged[1]); + if (!path) return undefined; + const arguments_: Record = { target_file: path, offset: Number(ranged[2]) + 1 }; + if (ranged[3]) arguments_.limit = Number(ranged[3]); + return { name: GROK_READ_FILE_TOOL, arguments: arguments_ }; + } + const list = POWERSHELL_LIST_DIR.exec(script); + if (list) { + const path = unquotePowershellLiteral(list[1]); + return path ? { name: GROK_LIST_DIR_TOOL, arguments: { target_directory: path } } : undefined; + } + const grep = POWERSHELL_GREP_PATTERN.exec(script); + const grepPath = POWERSHELL_GREP_DIRECTORY.exec(script) ?? POWERSHELL_GREP_FILE.exec(script); + if (grep && grepPath) { + const pattern = unquotePowershellLiteral(grep[1]); + const path = unquotePowershellLiteral(grepPath[1]); + if (!pattern || path === undefined) return undefined; + const arguments_: Record = { pattern, path }; + const glob = POWERSHELL_GREP_GLOB.exec(script); + if (glob) { + const globValue = unquotePowershellLiteral(glob[1]); + if (globValue) arguments_.glob = globValue; + } + return { name: GROK_GREP_TOOL, arguments: arguments_ }; + } + return undefined; +} + +function reconstructFromShellCmd(cmd: string, extras: Record = {}): ReconstructedGrokToolCall { + const mapped = reconstructFromPosixCmd(cmd) ?? reconstructFromWindowsCmd(cmd); + if (mapped) return mapped; + return { name: GROK_RUN_TERMINAL_COMMAND_TOOL, arguments: { command: cmd, ...extras } }; +} + +function reconstructExecExtras(rest: string): Record { + const extras: Record = {}; + const workdir = /workdir:\s*("(?:\\.|[^"\\])*")/.exec(rest); + if (workdir) { + const value = parseJsonStringLiteral(workdir[1]); + if (value) extras.working_directory = value; + } + if (/\bsandbox_permissions:\s*"require_escalated"/.test(rest)) extras.with_escalated_permissions = true; + const justification = /justification:\s*("(?:\\.|[^"\\])*")/.exec(rest); + if (justification) { + const value = parseJsonStringLiteral(justification[1]); + if (value) extras.justification = value; + } + return extras; +} + +/** Map a Codex-side converted `exec` body back to the Grok tool Grok originally called. */ +export function reconstructGrokToolCallFromExec(args: Record): ReconstructedGrokToolCall | undefined { + const input = typeof args.input === "string" ? args.input.trim() : undefined; + if (!input) return undefined; + const applyHead = /^await tools\.apply_patch\(/.exec(input); + if (applyHead) { + const split = splitJsonStringAtStart(input.slice(applyHead[0].length)); + if (split && /^\s*\)\s*;?\s*$/.test(split.rest)) return reconstructFromApplyPatch(split.value); + } + const shellHead = /^const r = await tools\.exec_command\(\{\s*cmd:\s*/.exec(input); + if (shellHead) { + const split = splitJsonStringAtStart(input.slice(shellHead[0].length)); + if (split && /^\s*(?:,[\s\S]*?)?\}\);\s*text\(r\.output\);\s*$/.test(split.rest)) { + return reconstructFromShellCmd(split.value, reconstructExecExtras(split.rest)); + } + } + return undefined; +} + +export function encodeGrokEditForCodexSink( + translation: GrokStructuredEditTranslation, + sink: GrokEditCodexSink, +): { name: string; arguments: string } { + if (translation.error) { + if (sink.kind === "exec") { + return { + name: sink.name, + arguments: JSON.stringify({ input: `text(${JSON.stringify(translation.error)})` }), + }; + } + return { name: "apply_patch", arguments: JSON.stringify({ input: translation.error }) }; + } + if (sink.kind === "exec") { + return { + name: sink.name, + arguments: JSON.stringify({ input: `await tools.apply_patch(${JSON.stringify(translation.patch)})` }), + }; + } + return { name: "apply_patch", arguments: JSON.stringify({ input: translation.patch }) }; +} + +function encodeGrokNativeCall( + toolName: string, + argsText: string, + sink: GrokEditCodexSink, +): { name: string; arguments: string } { + if (isGrokStructuredEditToolName(toolName)) { + const translation = translateGrokStructuredEditCall(toolName, argsText) + ?? { error: `${toolName} could not be converted to apply_patch.` }; + return encodeGrokEditForCodexSink(translation, sink); + } + const shell = translateGrokShellCall(toolName, argsText); + if ("error" in shell) { + return { + name: sink.kind === "exec" ? sink.name : "exec", + arguments: JSON.stringify({ input: `text(${JSON.stringify(shell.error)})` }), + }; + } + return encodeExecCommand(shell.cmd, sink.kind === "exec" ? sink.name : "exec", execCommandExtras(toolName, argsText, shell.cmd)); +} + +/** Convert a complete native Grok function call to Codex's custom `exec` wire shape. */ +export function grokNativeCallToCodexCustomTool( + toolName: string, + argsText: string, + execSinkName: string, + complete = true, +): { name: string; input: string } { + if (!complete) return { name: execSinkName, input: "" }; + const encoded = encodeGrokNativeCall(toolName, argsText, { kind: "exec", name: execSinkName }); + try { + const args: unknown = JSON.parse(encoded.arguments); + return { + name: encoded.name, + input: isPlainRecord(args) && typeof args.input === "string" ? args.input : "", + }; + } catch { + return { name: encoded.name, input: "" }; + } +} + +function encodedGrokCallEvents( + pending: { id: string; name: string; args: string }, + sink: GrokEditCodexSink, +): AdapterEvent[] { + const encoded = encodeGrokNativeCall(pending.name, pending.args, sink); + return [ + { type: "tool_call_start", id: pending.id, name: encoded.name }, + ...(encoded.arguments.length > 0 ? [{ type: "tool_call_delta" as const, arguments: encoded.arguments }] : []), + { type: "tool_call_end" }, + ]; +} + +function* yieldEncodedGrokCall( + pending: { id: string; name: string; args: string }, + sink: GrokEditCodexSink, +): Generator { + yield* encodedGrokCallEvents(pending, sink); +} + +export async function* rewriteGrokStructuredEditEvents( + events: AsyncIterable, + advertisedNames: ReadonlySet, + sink: GrokEditCodexSink, +): AsyncGenerator { + let pending: { id: string; name: string; args: string } | undefined; + for await (const event of events) { + if (event.type === "tool_call_start") { + if (pending) yield* yieldEncodedGrokCall(pending, sink); + pending = advertisedNames.has(event.name) + ? { id: event.id, name: event.name, args: "" } + : undefined; + if (pending) continue; + } + if (pending && event.type === "tool_call_delta") { + pending.args += event.arguments; + continue; + } + if (pending && event.type === "tool_call_end") { + yield* yieldEncodedGrokCall(pending, sink); + pending = undefined; + continue; + } + if (pending && (event.type === "error" || event.type === "done" || event.type === "incomplete")) { + yield* yieldEncodedGrokCall(pending, sink); + pending = undefined; + } + yield event; + } + if (pending) yield* yieldEncodedGrokCall(pending, sink); +} + +export function grokExecSinkFromCatalog( + freeformToolNames?: ReadonlySet, + declaredToolNames?: ReadonlySet, +): GrokEditCodexSink | undefined { + for (const name of freeformToolNames ?? []) { + if (name === "exec" || name.endsWith("__exec")) return { kind: "exec", name }; + } + if (declaredToolNames?.has("exec")) return { kind: "exec", name: "exec" }; + return undefined; +} + +/** Last-mile rewrite for every Responses bridge, including web-search/image loops. */ +export function rewriteGrokNativeCallsForCodexExec( + events: AsyncIterable, + freeformToolNames?: ReadonlySet, + declaredToolNames?: ReadonlySet, + convertedNativeToolNames?: ReadonlySet, +): AsyncIterable { + const sink = grokExecSinkFromCatalog(freeformToolNames, declaredToolNames); + if (!sink || !convertedNativeToolNames || convertedNativeToolNames.size === 0) return events; + return rewriteGrokStructuredEditEvents(events, convertedNativeToolNames, sink); +} + +export function rewriteGrokNativeCallEventList( + events: AdapterEvent[], + freeformToolNames?: ReadonlySet, + declaredToolNames?: ReadonlySet, + convertedNativeToolNames?: ReadonlySet, +): { events: AdapterEvent[]; rewritten: boolean } { + const sink = grokExecSinkFromCatalog(freeformToolNames, declaredToolNames); + if (!sink || !convertedNativeToolNames || convertedNativeToolNames.size === 0) { + return { events, rewritten: false }; + } + const advertisedNames = convertedNativeToolNames; + const out: AdapterEvent[] = []; + let pending: { id: string; name: string; args: string } | undefined; + let rewritten = false; + for (const event of events) { + if (event.type === "tool_call_start") { + if (pending) out.push(...encodedGrokCallEvents(pending, sink)); + pending = advertisedNames.has(event.name) + ? { id: event.id, name: event.name, args: "" } + : undefined; + if (pending) { + rewritten = true; + continue; + } + } + if (pending && event.type === "tool_call_delta") { + pending.args += event.arguments; + continue; + } + if (pending && event.type === "tool_call_end") { + out.push(...encodedGrokCallEvents(pending, sink)); + pending = undefined; + continue; + } + if (pending && (event.type === "error" || event.type === "done" || event.type === "incomplete")) { + out.push(...encodedGrokCallEvents(pending, sink)); + pending = undefined; + } + out.push(event); + } + if (pending) out.push(...encodedGrokCallEvents(pending, sink)); + return { events: rewritten ? out : events, rewritten }; +} + +export function rewriteAdapterEventsForGrokStructuredEdits( + events: AsyncIterable, + parsed: { + context: { + tools?: readonly Pick[]; + messages?: Parameters[0]; + systemPrompt?: readonly string[]; + }; + options: { toolChoice?: OcxRequestOptions["toolChoice"] }; + }, + provider: Pick, +): AsyncIterable { + const convertedNativeToolNames = grokNativeToolNamesForRequest(parsed, provider); + if (convertedNativeToolNames.size === 0) return events; + const sink = grokCodeModeExecSink(parsed.context.tools, parsed.options.toolChoice); + if (!sink) return events; + return rewriteGrokStructuredEditEvents(events, convertedNativeToolNames, sink); +} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7ad61f1b4d..accc4e338c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -10,7 +10,8 @@ import { redactSecretString } from "../lib/redact"; import { contentPartsToText } from "./image"; import { identifyRoutedModel } from "./identity"; import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; -import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; +import { grokFacingTools, grokNativeCatalogTools, reconstructGrokToolCallFromExec, rewriteCodexFileEditGuidanceForGrok } from "./grok-structured-edit"; +import { buildNonOpenAIToolCatalogNudgeForTools, effectiveInstructionText, isCanonicalNativeOpenAIRoute, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { canForwardForeignServiceTierForChatModel, @@ -569,13 +570,7 @@ function developerSystemText(message: OcxMessage): string | undefined { return message.content.map(part => (part as OcxTextContent).text).join(""); } -function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { - try { - return new URL(provider.baseUrl).hostname === "api.openai.com"; - } catch { - return false; - } -} +const isNativeOpenAIChatTarget = isCanonicalNativeOpenAIRoute; /** * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" @@ -657,17 +652,38 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon }; const nativeOpenAI = isNativeOpenAIChatTarget(provider); + const grokInstructions = effectiveInstructionText(context.messages, context.systemPrompt); + const grokTools = grokFacingTools( + context.tools, + options.toolChoice, + provider, + grokInstructions, + ); + const grokNativeToolNames = new Set(grokNativeCatalogTools( + context.tools, + options.toolChoice, + provider, + grokInstructions, + ).map(tool => tool.name)); + const restoreGrokHistory = grokNativeToolNames.size > 0; const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) - ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) + ? buildNonOpenAIToolCatalogNudgeForTools( + grokTools ?? context.tools, + options.toolChoice, + undefined, + grokInstructions, + grokNativeToolNames, + ) : undefined; const developerSystemParts = nativeOpenAI ? [] : context.messages .map(developerSystemText) .filter((part): part is string => part !== undefined && part.length > 0); + const grokEditGuidance = restoreGrokHistory ? rewriteCodexFileEditGuidanceForGrok : (text: string) => text; const systemParts = [ - ...(context.systemPrompt ?? []), - ...developerSystemParts, + ...(context.systemPrompt ?? []).map(grokEditGuidance), + ...developerSystemParts.map(grokEditGuidance), ...(toolCatalogNudge ? [toolCatalogNudge] : []), ]; if (systemParts.length > 0) { @@ -751,11 +767,24 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon return { tc, id }; }); if (wireToolCalls.length > 0) { - chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({ - id, - type: "function", - function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) }, - })); + chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => { + const reconstructedCandidate = restoreGrokHistory + && !tc.namespace + && (tc.name === "exec" || tc.name.endsWith("__exec")) + ? reconstructGrokToolCallFromExec(tc.arguments) + : undefined; + const reconstructed = reconstructedCandidate && grokNativeToolNames.has(reconstructedCandidate.name) + ? reconstructedCandidate + : undefined; + return { + id, + type: "function", + function: { + name: reconstructed?.name ?? namespacedToolName(tc.namespace, tc.name), + arguments: JSON.stringify(reconstructed?.arguments ?? tc.arguments), + }, + }; + }); if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider); } if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) { @@ -1225,7 +1254,12 @@ function normalizeXaiToolParameters(parameters: unknown): Record { diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index eeeb38c386..22d9b3c826 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -20,6 +20,7 @@ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-com import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { normalizeXaiResponsesWebSearch } from "./xai-web-search"; +import { rewriteGrokResponsesRequestBody } from "./grok-structured-edit"; import { createAdapterTierMetadata, } from "../providers/fastwire"; @@ -1686,6 +1687,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let convertedRoutedCustomToolNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; + let convertedGrokNativeToolNames: ReadonlySet | undefined; + let grokStructuredEditExecSinkName: string | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1734,6 +1737,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = promoteClientLoadedTools(outBody); } + const grokRewrite = rewriteGrokResponsesRequestBody(outBody, parsed, provider); + outBody = grokRewrite.body; + if (grokRewrite.convertedNativeToolNames.size > 0) { + convertedGrokNativeToolNames = grokRewrite.convertedNativeToolNames; + grokStructuredEditExecSinkName = grokRewrite.execSinkName; + } if (!isCanonicalOpenAiForwardProvider(provider)) { const rewritten = rewriteRoutedCustomToolsForUpstream( outBody, @@ -1813,6 +1822,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), + ...(convertedGrokNativeToolNames ? { convertedGrokNativeToolNames } : {}), + ...(grokStructuredEditExecSinkName ? { grokStructuredEditExecSinkName } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 626bd93d8d..e10a8cea7a 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -4,8 +4,24 @@ import { type OcxRequestOptions, type OcxTool, type OcxProviderConfig, + type OcxMessage, } from "../types"; +/** Collect authoritative system/developer text plus only the latest user turn. */ +export function effectiveInstructionText(messages: readonly OcxMessage[] | undefined, system?: readonly string[]): string[] { + const out = [...(system ?? [])]; + let latestUserText: string[] = []; + for (const message of messages ?? []) { + if (message.role !== "developer" && message.role !== "user") continue; + const text = typeof message.content === "string" + ? [message.content] + : message.content.filter(part => part.type === "text").map(part => part.text); + if (message.role === "developer") out.push(...text); + else latestUserText = text; + } + return [...out, ...latestUserText]; +} + // Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one // here tells a routed model not to call it unless this turn's catalog really lists it. // @@ -57,21 +73,70 @@ function uniqueNames(names: readonly string[]): string[] { return [...new Set(names.filter(name => name.trim().length > 0))]; } -function isOpenAIOrChatGPTHost(hostname: string): boolean { - return hostname === "openai.com" - || hostname.endsWith(".openai.com") - || hostname === "chatgpt.com" - || hostname.endsWith(".chatgpt.com"); +function isOpenAIBrandedDestination(hostname: string): boolean { + // A custom endpoint containing an OpenAI/ChatGPT DNS label is ambiguous: it is not canonical + // native OpenAI, but injecting an aggressive non-OpenAI tool policy would be unsafe too. + const labels = hostname.toLowerCase().split("."); + return labels.includes("openai") || labels.includes("chatgpt"); +} + +export function shouldSuppressCodeModePatchGuidance(instructions: string): boolean { + // Codex Default-mode developer text includes "Never write a multiple choice question". + // A bare `never write` match treated that as a no-mutation turn and hid the Grok catalog. + return /\s*#?\s*Collaboration Mode:\s*Plan\b|You are in \*\*Plan Mode\*\*|\b(?:do not|must not|never)\s+(?:make|perform)\s+(?:any\s+)?mutations?\b|\b(?:do not|must not|never)\s+(?:edit|modify|use\s+apply_patch)\b|\b(?:do not|must not|never)\s+write\s+(?:to\s+)?(?:any\s+)?(?:files?|code|the\s+workspace)\b|\buse\s+(?:the\s+)?shell\s+for\s+(?:file\s+)?edits\b/i.test(instructions); +} + +export function declaredToolsBlock(description: string): string | undefined { + const declaration = /(?:declare\s+)?const\s+tools\s*:\s*\{/i.exec(description); + if (!declaration) return undefined; + const open = declaration.index + declaration[0].lastIndexOf("{"); + let depth = 0; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + for (let index = open; index < description.length; index += 1) { + const character = description[index]; + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = undefined; + continue; + } + if (character === "'" || character === '"' || character === "`") { + quote = character; + continue; + } + if (character === "{") depth += 1; + else if (character === "}" && --depth === 0) return description.slice(open + 1, index); + } + return undefined; } -export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick): boolean { +export function shouldInjectNonOpenAIToolCatalogNudge(provider: Pick & Partial>): boolean { try { - return !isOpenAIOrChatGPTHost(new URL(provider.baseUrl).hostname); + const host = new URL(provider.baseUrl).hostname; + return !isOpenAIBrandedDestination(host); } catch { return true; } } +/** True only for the two routes on which OpenAI's native tool contract is authoritative. */ +export function isCanonicalNativeOpenAIRoute( + provider: Pick, +): boolean { + if (provider.adapter !== "openai-chat" && provider.adapter !== "openai-responses") return false; + try { + const url = new URL(provider.baseUrl); + if (url.port || url.search || url.hash || url.username || url.password) return false; + const auth = provider.authMode; + const openai = url.protocol === "https:" && url.hostname === "api.openai.com" && url.pathname.replace(/\/$/, "") === "/v1"; + const chatgpt = url.protocol === "https:" && url.hostname === "chatgpt.com" && url.pathname.replace(/\/$/, "") === "/backend-api/codex"; + return (openai && (auth === undefined || auth === "key")) || (chatgpt && provider.adapter === "openai-responses" && auth === "forward"); + } catch { + return false; + } +} + /** * Codex code mode is a SEMANTIC property, not a name. * @@ -99,6 +164,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( wireNames: readonly string[] | undefined, toWireName: (name: string) => string = name => name, codeModeExecName?: string, + catalogWriteToolNames?: readonly string[], ): string | undefined { const names = uniqueNames(wireNames ?? []); if (names.length === 0) return undefined; @@ -112,6 +178,12 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( name => !advertised.has(name) && !advertised.has(toWireName(name)), ); const verifiedCodeModeExecName = codeModeExecWireName(advertised, codeModeExecName); + const writeNames = uniqueNames(catalogWriteToolNames ?? []).filter(name => advertised.has(name)); + const codeModeContract = !verifiedCodeModeExecName + ? "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." + : writeNames.length > 0 + ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, such as `await tools.exec_command(...)` or `await tools.codex_app__list_threads({})`." + : "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, such as `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.`. 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."; return [ "Tool contract: use the current tool catalog as ground truth.", @@ -119,9 +191,7 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( "These listed names are the complete top-level tool-call surface for this turn.", "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.", "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.", - verifiedCodeModeExecName - ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, 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.`. 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." - : "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.", + codeModeContract, unavailableNeighborNames.length > 0 ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names." : undefined, @@ -131,9 +201,11 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( } export function buildNonOpenAIToolCatalogNudgeForTools( - tools: readonly Pick[] | undefined, + tools: readonly Pick[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], toWireName: (tool: Pick) => string = tool => namespacedToolName(tool.namespace, tool.name), + effectiveInstructions?: readonly string[], + convertedNativeToolNames?: ReadonlySet, ): string | undefined { const visible = tools?.filter(toolChoiceToolPredicate(toolChoice, tools)); const visibleNames = visible?.map(toWireName); @@ -145,10 +217,52 @@ export function buildNonOpenAIToolCatalogNudgeForTools( && !visible?.some(isBareShellBridgeTool) ? toWireName(codeModeExecTool) : undefined; + const grokWrite = convertedNativeToolNames?.has("write") === true + && convertedNativeToolNames.has("search_replace") + && visibleNames?.includes("write") === true + && visibleNames.includes("search_replace"); + const grokWriteNames = grokWrite ? ["write", "search_replace"] : []; // Neighbor names are bare and un-namespaced, so probe the same transform with a bare tool. - return buildNonOpenAIToolCatalogNudgeFromNames( + const base = buildNonOpenAIToolCatalogNudgeFromNames( visibleNames, name => toWireName({ name }), - codeModeExecName, + grokWrite ? undefined : codeModeExecName, + grokWriteNames, ); + if (!base) return base; + if (grokWrite) { + return ( + "Codex instructions that say to use `apply_patch` do not add a top-level `apply_patch` or `exec` tool on this turn. " + + "Callable file edits are `write` and `search_replace`. " + + base + + " Create or split a file with `write` (file_path, content)." + + " Edit a file with `search_replace` (file_path, old_string, new_string) and exact leading whitespace." + + " OpenCodex converts those calls into Codex apply_patch." + + " A request to make code easier to analyze or modify is a refactor to implement, not a turn spent only reading." + + " After a short survey, start `write`/`search_replace`; do not keep ranged-reading files you have already sampled." + + " Commentary that only promises a split or refactor is not a workspace change — emit the tool calls in that same turn." + + " Splitting a large file is many `write` calls (one new file each) plus `search_replace` on the original. Start from slices already read; do not wait to ingest the whole file, and do not draft the new tree only in assistant text." + + " Git add/commit must set `with_escalated_permissions` on `run_terminal_command` so Codex can prompt to write `.git/index.lock`; commentary cannot request that permission." + ); + } + if (!codeModeExecTool) return base; + const description = codeModeExecTool.description ?? ""; + const helperDeclarations = declaredToolsBlock(description); + const hasApplyPatch = !!helperDeclarations && /\bapply_patch\s*\(\s*input\s*:\s*string\s*\)/i.test(helperDeclarations); + if (!hasApplyPatch) return base; + const instructions = (effectiveInstructions ?? []).join("\n"); + if (shouldSuppressCodeModePatchGuidance(instructions)) return base; + const mentionsExecCommand = !!helperDeclarations && /\bexec_command\s*\(/i.test(helperDeclarations); + const nested = mentionsExecCommand + ? " Use nested `tools.exec_command` for reads, searches, tests, builds, and formatters; do not print a pretend tool call." + : ""; + return base + + " File writes, creates, and splits use the nested `tools.apply_patch` helper, including large refactors rather than only one-hunk edits." + + " The `exec` body itself must remain JavaScript; never send a raw patch envelope directly as the `exec` body." + + " Call `await tools.apply_patch(patchString)` from inside the JavaScript body." + + " The patch string MUST begin with the exact line `*** Begin Patch` and end with the exact line `*** End Patch`." + + " Example: `await tools.apply_patch(\"*** Begin Patch\\n*** Update File: path\\n@@\\n-old\\n+new\\n*** End Patch\")`." + + " If `apply_patch` rejects the patch because of its envelope or delimiter, correct the patch string and retry `tools.apply_patch`; do not fall back to shell, Node, Python, sed, or heredoc for file writes." + + " Wait for its real tool result before continuing." + + nested; } diff --git a/src/bridge.ts b/src/bridge.ts index 82c73a0bee..af914092ec 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -22,11 +22,13 @@ import { usageDisplayTotalTokens } from "./usage/totals"; import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources"; import { isTranslatorBudgetExceededError, + replaceRetainedTranslatedEventBatch, releaseTranslatedEvent, createTranslatorBudget, type TranslatorBudget, type TranslatorBufferKind, } from "./lib/translator-budget"; +import { rewriteGrokNativeCallEventList, rewriteGrokNativeCallsForCodexExec } from "./adapters/grok-structured-edit"; function uuid(): string { return crypto.randomUUID().replace(/-/g, ""); @@ -198,6 +200,8 @@ export function bridgeToResponsesSSE( onUsage?: (usage: OcxUsage | undefined) => void; /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ declaredToolNames?: ReadonlySet; + /** Exact upstream-only Grok tool names introduced for this request. */ + convertedGrokNativeToolNames?: ReadonlySet; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; /** @@ -227,6 +231,12 @@ export function bridgeToResponsesSSE( }; }, ): ReadableStream { + events = rewriteGrokNativeCallsForCodexExec( + events, + freeformToolNames, + options?.declaredToolNames, + options?.convertedGrokNativeToolNames, + ); const replayCacheScope = options?.replayCacheScope; const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms)); const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType)); @@ -1468,6 +1478,8 @@ function buildResponseJSONWithBudget( toolNsMap?: Map; /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ declaredToolNames?: ReadonlySet; + /** Exact upstream-only Grok tool names introduced for this request. */ + convertedGrokNativeToolNames?: ReadonlySet; /** Declared parameter schema per tool name; repairs integral-float integer args (#1611). */ toolParameterSchemas?: ReadonlyMap>; freeformToolNames?: Set; @@ -1482,10 +1494,21 @@ function buildResponseJSONWithBudget( replayCacheScope?: OcxReasoningReplayScopeRef; }, ): Record { + const budget = options?.translatorBudget; + const sourceEvents = events; + const grokRewrite = rewriteGrokNativeCallEventList( + events, + options?.freeformToolNames, + options?.declaredToolNames, + options?.convertedGrokNativeToolNames, + ); + events = grokRewrite.events; + if (budget && grokRewrite.rewritten) { + replaceRetainedTranslatedEventBatch(sourceEvents, grokRewrite.events, budget); + } const responseId = `resp_${uuid()}`; const replayCacheScope = options?.replayCacheScope; const output: OutputItem[] = []; - const budget = options?.translatorBudget; const encoder = new TextEncoder(); const bytesOf = (value: string): number => Buffer.byteLength(value); const appendBatchString = ( diff --git a/src/images/loop.ts b/src/images/loop.ts index 834bcced93..62eb923673 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -257,6 +257,8 @@ export interface ImageBridgeDeps { waitForRequestSlot?: (signal?: AbortSignal) => Promise; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ onUsage?: (usage: OcxUsage | undefined) => void; + /** Exact upstream-only Grok tool names introduced for this request. */ + convertedGrokNativeToolNames?: ReadonlySet; /** * Optional 429 key-failover for the routed (non-xAI) model. Return a rebuilt adapter for the * rotated key, or null when the pool is exhausted. @@ -945,6 +947,9 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise deps.onUsage?.(usage), } : {}), + ...(deps.convertedGrokNativeToolNames ? { + convertedGrokNativeToolNames: deps.convertedGrokNativeToolNames, + } : {}), ...(deps.onCompletedResponse ? { onCompletedResponse: deps.onCompletedResponse } : {}), }, ); diff --git a/src/lib/translator-budget.ts b/src/lib/translator-budget.ts index 18400eebf7..b5c0920207 100644 --- a/src/lib/translator-budget.ts +++ b/src/lib/translator-budget.ts @@ -132,6 +132,26 @@ export function releaseTranslatedEvent(event: object, budget: TranslatorBudget): budget.releaseRetained(ownership.bytes, { kind: "retained_collectors" }); } +/** Replace one retained adapter-event batch while preserving its budget ownership. */ +export function replaceRetainedTranslatedEventBatch( + source: readonly T[], + replacement: T[], + budget: TranslatorBudget, +): void { + const ownerships = source.map(event => retainedEventOwnership.get(event)); + if (ownerships.every(ownership => ownership?.budget !== budget)) return; + if (ownerships.some(ownership => ownership?.budget !== budget)) { + throw new Error("cannot replace a partially retained translated event batch"); + } + let releasedBytes = 0; + for (let index = 0; index < source.length; index += 1) { + releasedBytes += ownerships[index]!.bytes; + retainedEventOwnership.delete(source[index]!); + } + budget.releaseRetained(releasedBytes, { kind: "retained_collectors" }); + retainTranslatedEventBatch(replacement, budget); +} + const liveBudgets = new Set(); let aggregateCurrentBytes = 0; let aggregateActiveCalls = 0; diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index d5d4e93b30..ad6d6c15bc 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -212,14 +212,22 @@ export function rewriteRoutedCustomToolsForUpstream( return { body: rewriteForUpstream(body, conversionNames, callIds), names }; } +export type RoutedCustomToolCallTransform = ( + name: string, + argumentsText: string, + complete: boolean, +) => { name: string; input: string }; + export function restoreRoutedCustomCalls( value: unknown, names: ReadonlySet, + transform?: RoutedCustomToolCallTransform, + complete = true, ): { value: unknown; changed: boolean } { if (Array.isArray(value)) { let changed = false; const restored = value.map(entry => { - const result = restoreRoutedCustomCalls(entry, names); + const result = restoreRoutedCustomCalls(entry, names, transform, complete); changed ||= result.changed; return result.value; }); @@ -230,16 +238,28 @@ export function restoreRoutedCustomCalls( let changed = false; const restored: Record = {}; for (const [key, entry] of Object.entries(value)) { - const result = restoreRoutedCustomCalls(entry, names); + const result = restoreRoutedCustomCalls(entry, names, transform, complete); restored[key] = result.value; changed ||= result.changed; } const wireName = routedCustomToolWireName(value); - if (value.type === "function_call" && wireName !== undefined && names.has(wireName)) { + if ( + value.type === "function_call" + && typeof value.name === "string" + && wireName !== undefined + && names.has(wireName) + ) { + const call = transform + ? transform(value.name, typeof value.arguments === "string" ? value.arguments : "", complete) + : { + name: value.name, + input: customToolInput(value.arguments), + }; restored.type = "custom_tool_call"; restored.id = customToolItemId(value.id); - restored.input = customToolInput(value.arguments); + restored.name = call.name; + restored.input = call.input; delete restored.arguments; changed = true; } @@ -249,6 +269,7 @@ export function restoreRoutedCustomCalls( export function restoreRoutedCustomCallsInJson( text: string, names: ReadonlySet, + transform?: RoutedCustomToolCallTransform, ): string { if (names.size === 0) return text; let payload: unknown; @@ -257,7 +278,7 @@ export function restoreRoutedCustomCallsInJson( } catch { return text; } - const restored = restoreRoutedCustomCalls(payload, names); + const restored = restoreRoutedCustomCalls(payload, names, transform); return restored.changed ? JSON.stringify(restored.value) : text; } diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 1aaa16c73e..4ef4133b2f 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -4,6 +4,7 @@ import { restoreRoutedCustomCalls, routedCustomToolWireName, unwrapRoutedCustomToolArguments, + type RoutedCustomToolCallTransform, } from "../responses/custom-tool-compat"; import { replaceSseDataPayload, @@ -85,6 +86,7 @@ type PendingArgumentBlock = { export function createRoutedCustomToolRestoreBlockRewrite( names: ReadonlySet, budget?: TranslatorBudget, + transform?: RoutedCustomToolCallTransform, ): SseBlockRewrite { const itemNames = new Map(); const ordinaryItemIds = new Set(); @@ -203,7 +205,12 @@ export function createRoutedCustomToolRestoreBlockRewrite( if (upstreamItemId && pending.length > 0 && !openCalls.has(upstreamItemId)) { openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); } - const restored = restoreRoutedCustomCalls(parsed, names); + const restored = restoreRoutedCustomCalls( + parsed, + names, + transform, + type !== "response.output_item.added", + ); const restoredBlock = restored.changed ? replaceSseDataPayload(block, JSON.stringify(restored.value)) : block; @@ -233,6 +240,9 @@ export function createRoutedCustomToolRestoreBlockRewrite( open.argumentsText += delta; open.retainedBytes += deltaBytes; openCalls.set(upstreamItemId, open); + // Structured transforms (for example Grok search_replace -> Codex exec) + // need the complete JSON arguments before they can produce freeform input. + if (transform) return []; // Still accumulating toward the compact wrapper, or an unrecognized shape: // suppress progressive emission and let the done event carry input. if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return []; @@ -260,17 +270,22 @@ export function createRoutedCustomToolRestoreBlockRewrite( const source = typeof parsed.arguments === "string" ? parsed.arguments : openCalls.get(upstreamItemId)?.argumentsText ?? ""; + const call = transform + ? transform(itemNames.get(upstreamItemId) ?? "", source, true) + : { + input: unwrapRoutedCustomToolArguments(source), + }; const { arguments: _arguments, ...rest } = parsed; const next = { ...rest, type: nextType, item_id: customToolItemId(upstreamItemId), - input: unwrapRoutedCustomToolArguments(source), + input: call.input, }; return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; } - const restored = restoreRoutedCustomCalls(parsed, names); + const restored = restoreRoutedCustomCalls(parsed, names, transform); const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete"; if (terminal) releaseAll(); return restored.changed diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b1bb6fadeb..34c7eda18d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -27,6 +27,11 @@ import { } from "../../responses/reasoning-replay-cache"; import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; +import { + grokNativeCallToCodexCustomTool, + grokNativeToolNamesForRequest, + rewriteAdapterEventsForGrokStructuredEdits, +} from "../../adapters/grok-structured-edit"; import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; import { copyPreviousResponseReplayProvenance, @@ -3412,6 +3417,12 @@ async function handleResponsesInner( } break; } + const grokNativeToolNames = request.convertedGrokNativeToolNames ?? new Set(); + const grokExecSinkName = request.grokStructuredEditExecSinkName; + const grokCallTransform = grokExecSinkName + ? (name: string, argumentsText: string, complete: boolean) => + grokNativeCallToCodexCustomTool(name, argumentsText, grokExecSinkName, complete) + : undefined; const headers = sanitizePassthroughHeaders(upstreamResponse.headers); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel) logCtx.resolvedModel = resolvedModel; @@ -3588,6 +3599,13 @@ async function handleResponsesInner( routedToolSearchNames.size > 0 ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget) : undefined, + grokNativeToolNames.size > 0 && grokCallTransform + ? createRoutedCustomToolRestoreBlockRewrite( + grokNativeToolNames, + translatorBudget, + grokCallTransform, + ) + : undefined, githubCopilotRepairEnabled ? createGithubCopilotResponsesBlockRewrite(translatorBudget) : undefined, @@ -3789,8 +3807,11 @@ async function handleResponsesInner( restoredNamespace, routedCustomToolNames, ); + const restoredGrok = grokCallTransform + ? restoreRoutedCustomCallsInJson(restored, grokNativeToolNames, grokCallTransform) + : restored; const restoredToolSearch = restoreRoutedToolSearchCallsInJson( - restored, + restoredGrok, routedToolSearchNames, ); const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) @@ -3996,6 +4017,7 @@ async function handleResponsesInner( const imgResponse = await runWithImageBridge({ parsed, adapter, incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, + convertedGrokNativeToolNames: grokNativeToolNamesForRequest(parsed, route.provider), ...(imgPlan ? { plan: imgPlan } : {}), ...(vidPlan ? { videoPlan: vidPlan } : {}), forwardHeaders: selectedForwardHeaders, @@ -4084,6 +4106,7 @@ async function handleResponsesInner( const wsResponse = await runWithWebSearch({ parsed, adapter, incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, + convertedGrokNativeToolNames: grokNativeToolNamesForRequest(parsed, route.provider), backend: wsPlan.backend, forwardProvider: wsPlan.forwardSidecar?.provider, anthropicSidecar: wsPlan.anthropicSidecar, @@ -4254,6 +4277,7 @@ async function handleResponsesInner( }; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const convertedGrokNativeToolNames = grokNativeToolNamesForRequest(parsed, route.provider); if (parsed.stream) { void runTurn(); let eventSource: AsyncIterable = queue.stream(); @@ -4276,7 +4300,7 @@ async function handleResponsesInner( }) : eventSource; const sseStream = bridgeToResponsesSSE( - guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + rewriteAdapterEventsForGrokStructuredEdits(guardedSource, parsed, route.provider), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -4288,6 +4312,7 @@ async function handleResponsesInner( stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, declaredToolNames, + convertedGrokNativeToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), @@ -4335,6 +4360,15 @@ async function handleResponsesInner( } else { events = firstAttemptEvents; } + { + const rewritten: AdapterEvent[] = []; + for await (const event of rewriteAdapterEventsForGrokStructuredEdits( + (async function* () { yield* events; })(), + parsed, + route.provider, + )) rewritten.push(event); + events = rewritten; + } if (options.comboAttempt) { const firstMeaningful = events.find(event => event.type !== "heartbeat"); if (!firstMeaningful || firstMeaningful.type === "error") { @@ -4351,6 +4385,7 @@ async function handleResponsesInner( hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, declaredToolNames, + convertedGrokNativeToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, @@ -5159,8 +5194,9 @@ async function handleResponsesInner( }) : eventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const convertedGrokNativeToolNames = grokNativeToolNamesForRequest(parsed, route.provider); const sseStream = bridgeToResponsesSSE( - guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + rewriteAdapterEventsForGrokStructuredEdits(guardedEventStream, parsed, route.provider), parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -5169,7 +5205,8 @@ async function handleResponsesInner( stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, declaredToolNames, - toolParameterSchemas, + convertedGrokNativeToolNames, + toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), // Same grok-surface split as the runTurn branch above. @@ -5238,7 +5275,17 @@ async function handleResponsesInner( } finally { cleanupUpstreamAbort(); } + { + const rewritten: AdapterEvent[] = []; + for await (const event of rewriteAdapterEventsForGrokStructuredEdits( + (async function* () { yield* events; })(), + parsed, + route.provider, + )) rewritten.push(event); + events = rewritten; + } const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const convertedGrokNativeToolNames = grokNativeToolNamesForRequest(parsed, route.provider); let providerState: OcxProviderContinuationState | undefined; const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, @@ -5246,6 +5293,7 @@ async function handleResponsesInner( hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, declaredToolNames, + convertedGrokNativeToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 18800555b8..901c4450b8 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -299,6 +299,8 @@ export interface WebSearchLoopDeps { onFirstOutput?: () => void; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ onUsage?: (usage: OcxUsage | undefined) => void; + /** Exact upstream-only Grok tool names introduced for this request. */ + convertedGrokNativeToolNames?: ReadonlySet; /** Observe the exact adapter request selected for each routed-model iteration. */ onRequestBuilt?: (request: AdapterRequest) => void; /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */ @@ -886,6 +888,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise(adapterId: string, run: () => Promise): P } } -async function outbound(adapterId: string, parsed: OcxParsedRequest): Promise { +async function outbound( + adapterId: string, + parsed: OcxParsedRequest, + provider?: OcxProviderConfig, +): Promise { const contract = effectiveAdapterContract(adapterId); - const adapter = createRegisteredAdapter(providerFixture(adapterId, contract.wire)); + const adapter = createRegisteredAdapter(provider ?? providerFixture(adapterId, contract.wire)); return await withMimoBootstrap(adapterId, () => TOOL_WIRE_DRIVERS[contract.wire].observeOutbound(adapter, parsed)); } @@ -414,19 +418,41 @@ describe("registry-derived routed tool conformance", () => { } }); - test("every registered adapter keeps the nested apply_patch helper in its final request", async () => { + test("every registered adapter keeps a client-executable file-edit path in its final request", async () => { for (const [adapterId] of adapterDefinitions()) { const contract = effectiveAdapterContract(adapterId); const body = await outbound(adapterId, codeModeParsed(contract.wire)); const advertised = advertisedToolNames(contract.wire, body); - expect(advertised.some(name => name === "exec" || name.endsWith("_exec")), adapterId).toBe(true); + const codeModeExec = advertised.some(name => name === "exec" || name.endsWith("_exec")); + const grokStructuredEdit = advertised.includes("write") && advertised.includes("search_replace"); + expect(codeModeExec || grokStructuredEdit, adapterId).toBe(true); const normalized = body.replace(/\\n/g, " ").replace(/\s+/g, " "); + if (grokStructuredEdit) { + expect(normalized, adapterId).toContain("OpenCodex converts those calls into Codex apply_patch"); + expect(normalized, adapterId).toContain("Callable file edits are `write` and `search_replace`"); + continue; + } expect(normalized, adapterId).toContain("apply_patch(input: string)"); expect(normalized, adapterId).not.toMatch(/(?:do not|don't|never|must not|cannot|can't)[^.]{0,260}\bapply_patch\b/i); expect(normalized, adapterId).not.toMatch(/\bapply_patch\b[^.]{0,180}\b(?:forbidden|unavailable|off-limits)\b/i); } }); + test("keeps the legacy code-mode exec contract on a non-xAI openai-chat provider", async () => { + const provider = { + ...providerFixture("openai-chat", "openai-chat"), + baseUrl: "https://api.openai.com/v1", + } satisfies OcxProviderConfig; + const body = await outbound("openai-chat", codeModeParsed("openai-chat"), provider); + const advertised = advertisedToolNames("openai-chat", body); + const normalized = body.replace(/\\n/g, " ").replace(/\s+/g, " "); + + expect(advertised.some(name => name === "exec" || name.endsWith("_exec"))).toBe(true); + expect(normalized).toContain("apply_patch(input: string)"); + expect(normalized).not.toMatch(/(?:do not|don't|never|must not|cannot|can't)[^.]{0,260}\bapply_patch\b/i); + expect(normalized).not.toMatch(/\bapply_patch\b[^.]{0,180}\b(?:forbidden|unavailable|off-limits)\b/i); + }); + test("tool_choice none disables every registered adapter's callable tool surface", async () => { for (const [adapterId] of adapterDefinitions()) { const contract = effectiveAdapterContract(adapterId); diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 22a3324580..acab3a5810 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1156,6 +1156,43 @@ describe("Responses bridge web_search_call native item", () => { }); }); +describe("Grok buffered event budget ownership", () => { + test("atomically replaces retained native events before building a near-limit response", () => { + const large = "x".repeat(128 * 1024); + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_grok_budget", name: "search_replace" }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ file_path: "src/large.ts", old_string: "old", new_string: large }), + }, + { type: "tool_call_end" }, + { type: "text_delta", text: large }, + { type: "done" }, + ]; + const budget = createTranslatorBudget({ maxTurnBytes: 480 * 1024 }); + let disposedBalance = -1; + try { + retainTranslatedEventBatch(events, budget); + const json = buildResponseJSON(events, "xai/grok-4.6", { + translatorBudget: budget, + freeformToolNames: new Set(["exec"]), + declaredToolNames: new Set(["exec"]), + convertedGrokNativeToolNames: new Set(["search_replace"]), + }); + const output = json.output as Record[]; + const outputBytes = output.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0); + + expect(json.status).toBe("completed"); + expect(output.some(item => item.type === "custom_tool_call" && item.name === "exec")).toBe(true); + expect(budget.snapshot().currentBytes).toBe(outputBytes); + } finally { + budget.dispose(); + disposedBalance = budget.snapshot().currentBytes; + } + expect(disposedBalance).toBe(0); + }); +}); + describe("Responses bridge stopReason threading (issue #246)", () => { test("done with stopReason max_tokens emits response.incomplete", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ diff --git a/tests/grok-structured-edit.test.ts b/tests/grok-structured-edit.test.ts new file mode 100644 index 0000000000..466eb85517 --- /dev/null +++ b/tests/grok-structured-edit.test.ts @@ -0,0 +1,530 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdapterEvent, OcxTool } from "../src/types"; +import { + encodeGrokEditForCodexSink, + grokEditCodexSink, + grokFacingTools, + grokNativeCatalogTools, + grokStructuredEditTools, + isXaiGrokChatProvider, + grokShellNeedsGitEscalation, + reconstructGrokToolCallFromExec, + rewriteAdapterEventsForGrokStructuredEdits, + rewriteCodexFileEditGuidanceForGrok, + rewriteGrokNativeCallEventList, + rewriteGrokStructuredEditEvents, + translateGrokShellCall, + translateGrokStructuredEditCall, +} from "../src/adapters/grok-structured-edit"; + +const xai = { baseUrl: "https://api.x.ai/v1" }; +const openai = { baseUrl: "https://api.openai.com/v1" }; + +const codeModeExec = (description: string): OcxTool => ({ + name: "exec", + freeform: true, + description, + parameters: {}, +}); + +const applyPatchHelper = "declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }"; +const liveCodexExecHelper = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + +async function collect(events: AsyncIterable): Promise { + const out: AdapterEvent[] = []; + for await (const event of events) out.push(event); + return out; +} + +async function* replay(events: AdapterEvent[]): AsyncGenerator { + for (const event of events) yield event; +} + +describe("Grok structured edit tools", () => { + test("rewrites Codex apply_patch edit constraints to Grok write/search_replace", () => { + const codex = [ + "## File editing constraints", + "", + "Use `apply_patch` for local file edits. Do not create or edit files with `cat` or other shell write tricks. Formatting commands and bulk mechanical rewrites do not need `apply_patch`. Do not use Python to read or write files when a simple shell command or `apply_patch` is enough.", + "", + "## Something else", + ].join("\n"); + const rewritten = rewriteCodexFileEditGuidanceForGrok(codex); + expect(rewritten).toContain("File edits on this turn use the listed tools `write` and `search_replace`"); + expect(rewritten).toContain("Do not call `apply_patch` or `exec` unless this turn's catalog lists those exact names"); + expect(rewritten).toContain("## Something else"); + expect(rewritten).not.toContain("Use `apply_patch` for local file edits"); + expect(rewriteCodexFileEditGuidanceForGrok("Use apply_patch in a code comment")).toBe("Use apply_patch in a code comment"); + }); + + test("detects xAI chat hosts only", () => { + expect(isXaiGrokChatProvider(xai)).toBe(true); + expect(isXaiGrokChatProvider({ baseUrl: "https://cli-chat-proxy.grok.com/v1" })).toBe(true); + expect(isXaiGrokChatProvider(openai)).toBe(false); + expect(isXaiGrokChatProvider({ baseUrl: "https://openrouter.ai/api/v1" })).toBe(false); + }); + + test("advertises the Grok Build catalog and hides exec on xAI code-mode turns", () => { + const extras = grokStructuredEditTools([codeModeExec(applyPatchHelper)], undefined, xai); + expect(extras.map(tool => tool.name)).toEqual(["search_replace", "write"]); + expect(grokNativeCatalogTools([codeModeExec(applyPatchHelper)], undefined, xai).map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + const facing = grokFacingTools( + [codeModeExec(applyPatchHelper), { name: "wait", description: "wait", parameters: {} }], + undefined, + xai, + ); + expect(facing?.map(tool => tool.name)).toEqual([ + "wait", "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + const advertised = grokNativeCatalogTools([codeModeExec(applyPatchHelper)], undefined, xai); + expect(advertised.find(tool => tool.name === "read_file")?.parameters).toMatchObject({ required: ["target_file"] }); + expect(advertised.find(tool => tool.name === "list_dir")?.parameters).toMatchObject({ + properties: { target_directory: { type: "string" } }, + }); + expect(advertised.find(tool => tool.name === "write")?.parameters).toMatchObject({ required: ["file_path", "content"] }); + expect(grokStructuredEditTools([codeModeExec(applyPatchHelper)], undefined, openai)).toEqual([]); + expect(grokStructuredEditTools([codeModeExec("JavaScript only")], undefined, xai)).toEqual([]); + expect(grokStructuredEditTools( + [codeModeExec(applyPatchHelper), { name: "exec_command", parameters: {} } as OcxTool], + undefined, + xai, + )).toEqual([]); + }); + + test("advertises the Grok catalog when Codex declares only apply_patch", () => { + expect(grokNativeCatalogTools([codeModeExec(liveCodexExecHelper)], undefined, xai).map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + expect(grokFacingTools([codeModeExec(liveCodexExecHelper)], undefined, xai)?.map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + }); + + test("does not shadow an already-listed search_replace and stays off in plan mode", () => { + const existing: OcxTool = { name: "search_replace", description: "mcp", parameters: {} }; + const extras = grokStructuredEditTools([codeModeExec(applyPatchHelper), existing], undefined, xai); + expect(extras.map(tool => tool.name)).toEqual(["write"]); + expect(grokStructuredEditTools( + [codeModeExec(applyPatchHelper)], + undefined, + xai, + ["You are in **Plan Mode**"], + )).toEqual([]); + }); + + test("keeps every caller-owned Grok-name collision byte-identical in adapter events", async () => { + for (const name of [ + "read_file", + "grep", + "list_dir", + "search_replace", + "write", + "write_file", + "run_terminal_command", + ]) { + const callerTool: OcxTool = { + name, + description: "Caller-owned tool", + parameters: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + }; + const tools = [codeModeExec(applyPatchHelper), callerTool]; + const original: AdapterEvent[] = [ + { type: "tool_call_start", id: `caller_${name}`, name }, + { type: "tool_call_delta", arguments: JSON.stringify({ message: "caller payload" }) }, + { type: "tool_call_end" }, + ]; + expect(await collect(rewriteAdapterEventsForGrokStructuredEdits(replay(original), { + context: { tools }, + options: {}, + }, xai))).toEqual(original); + } + }); + + test("keeps the Grok catalog on for Codex Default-mode developer text", () => { + const defaultMode = [ + "# Collaboration Mode: Default", + "You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.", + "Never write a multiple choice question as a textual assistant message.", + ]; + expect(grokNativeCatalogTools([codeModeExec(applyPatchHelper)], undefined, xai, defaultMode).map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + expect(grokFacingTools( + [codeModeExec(applyPatchHelper)], + undefined, + xai, + defaultMode, + )?.map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + }); + + test("chooses exec sink for code mode and apply_patch when it is top-level", () => { + expect(grokEditCodexSink([codeModeExec(applyPatchHelper)])).toEqual({ kind: "exec", name: "exec" }); + expect(grokEditCodexSink([ + { name: "apply_patch", freeform: true, description: "patch", parameters: {} } as OcxTool, + ])).toEqual({ kind: "apply_patch" }); + }); + + test("translates search_replace into a Codex update hunk", () => { + const translated = translateGrokStructuredEditCall("search_replace", JSON.stringify({ + file_path: "README.md", + old_string: "hello", + new_string: "hello\ntest", + })); + expect(translated?.patch).toBe([ + "*** Begin Patch", + "*** Update File: README.md", + "@@", + "-hello", + "+hello", + "+test", + "*** End Patch", + ].join("\n")); + }); + + test("translates empty old_string and write into Add File", () => { + expect(translateGrokStructuredEditCall("search_replace", JSON.stringify({ + file_path: "utils/time.py", + old_string: "", + new_string: "X = 1\n", + }))?.patch).toContain("*** Add File: utils/time.py"); + expect(translateGrokStructuredEditCall("write", JSON.stringify({ + file_path: "./utils/time.py", + content: "X = 1\n", + }))?.patch).toBe([ + "*** Begin Patch", + "*** Add File: utils/time.py", + "+X = 1", + "*** End Patch", + ].join("\n")); + expect(translateGrokStructuredEditCall("write_file", JSON.stringify({ + file_path: "./utils/time.py", + contents: "X = 1\n", + }))?.patch).toContain("*** Add File: utils/time.py"); + }); + + test("wraps a converted patch as nested tools.apply_patch for code-mode exec", () => { + const patch = "*** Begin Patch\n*** Update File: a.txt\n@@\n-old\n+new\n*** End Patch"; + const encoded = encodeGrokEditForCodexSink({ patch }, { kind: "exec", name: "exec" }); + expect(encoded.name).toBe("exec"); + const body = JSON.parse(encoded.arguments) as { input: string }; + expect(body.input).toBe(`await tools.apply_patch(${JSON.stringify(patch)})`); + }); + + test("rewrites read_file into exec_command cat", async () => { + const events = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "read_file" }, + { type: "tool_call_delta", arguments: JSON.stringify({ path: "README.md" }) }, + { type: "tool_call_end" }, + ]), new Set(["read_file"]), { kind: "exec", name: "exec" })); + expect(events[0]).toMatchObject({ type: "tool_call_start", name: "exec" }); + expect((events[1] as { arguments: string }).arguments).toContain("tools.exec_command"); + expect((events[1] as { arguments: string }).arguments).toContain("cat --"); + expect((events[1] as { arguments: string }).arguments).toContain("README.md"); + }); + + test("uses POSIX shell on darwin and PowerShell on win32", () => { + expect(translateGrokShellCall("read_file", JSON.stringify({ path: "README.md" }), "darwin")).toEqual({ + cmd: "cat -- 'README.md'", + }); + expect(translateGrokShellCall("list_dir", JSON.stringify({ path: "." }), "darwin")?.cmd).toContain("ls -la --"); + const winRead = translateGrokShellCall("read_file", JSON.stringify({ path: "README.md" }), "win32"); + expect(winRead && "cmd" in winRead && winRead.cmd.startsWith("powershell.exe -NoProfile -NonInteractive -EncodedCommand ")).toBe(true); + if (!winRead || "error" in winRead) throw new Error("expected windows read command"); + const decoded = Buffer.from(winRead.cmd.split(" ").pop() ?? "", "base64").toString("utf16le"); + expect(decoded).toContain("Get-Content -LiteralPath 'README.md' -Raw"); + const winList = translateGrokShellCall("list_dir", JSON.stringify({ path: "." }), "win32"); + if (!winList || "error" in winList) throw new Error("expected windows list command"); + expect(Buffer.from(winList.cmd.split(" ").pop() ?? "", "base64").toString("utf16le")).toContain("Get-ChildItem -Force"); + const winGrep = translateGrokShellCall("grep", JSON.stringify({ pattern: "foo", glob: "README*" }), "win32"); + if (!winGrep || "error" in winGrep) throw new Error("expected windows grep command"); + expect(Buffer.from(winGrep.cmd.split(" ").pop() ?? "", "base64").toString("utf16le")).toContain("Select-String"); + }); + + test.skipIf(process.platform === "win32")("does not expand POSIX path and search arguments", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-grok-shell-")); + try { + for (const [tool, args] of [ + ["read_file", { path: `$(touch ${join(root, "read.marker")})` }], + ["list_dir", { path: `\`touch ${join(root, "list.marker")}\`` }], + ["grep", { + pattern: `$(touch ${join(root, "pattern.marker")})`, + path: ".", + glob: `\`touch ${join(root, "glob.marker")}\``, + }], + ] as const) { + const translated = translateGrokShellCall(tool, JSON.stringify(args), "darwin"); + if ("error" in translated) throw new Error(translated.error); + Bun.spawnSync(["sh", "-c", translated.cmd], { cwd: root, stdout: "ignore", stderr: "ignore" }); + } + expect(existsSync(join(root, "read.marker"))).toBe(false); + expect(existsSync(join(root, "list.marker"))).toBe(false); + expect(existsSync(join(root, "pattern.marker"))).toBe(false); + expect(existsSync(join(root, "glob.marker"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rewrites ranged read_file into BSD-safe sed", async () => { + const events = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "read_file" }, + { type: "tool_call_delta", arguments: JSON.stringify({ path: "utils/database.py", offset: 160 }) }, + { type: "tool_call_end" }, + ]), new Set(["read_file"]), { kind: "exec", name: "exec" })); + const args = (events[1] as { arguments: string }).arguments; + expect(args).toContain("sed -n '160,$p' 'utils/database.py'"); + expect(args).not.toContain(" -- "); + expect(args).not.toContain("160,$p\""); + }); + + test("rewrites a streamed search_replace call into exec apply_patch", async () => { + const events = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "search_replace" }, + { type: "tool_call_delta", arguments: JSON.stringify({ file_path: "a.txt", old_string: "old", new_string: "new" }) }, + { type: "tool_call_end" }, + { type: "done" }, + ]), new Set(["search_replace", "write", "read_file"]), { kind: "exec", name: "exec" })); + expect(events[0]).toMatchObject({ type: "tool_call_start", name: "exec" }); + expect((events[1] as { arguments: string }).arguments).toContain("await tools.apply_patch"); + expect((events[1] as { arguments: string }).arguments).toContain("*** Begin Patch"); + expect((events[1] as { arguments: string }).arguments).not.toContain("search_replace"); + expect(events[2]).toMatchObject({ type: "tool_call_end" }); + }); + + test("leaves ordinary exec calls unchanged", async () => { + const original: AdapterEvent[] = [ + { type: "tool_call_start", id: "c1", name: "exec" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "await tools.exec_command({cmd:\"ls\"})" }) }, + { type: "tool_call_end" }, + ]; + expect(await collect(rewriteGrokStructuredEditEvents(replay(original), new Set(["search_replace"]), { kind: "exec", name: "exec" }))).toEqual(original); + const buffered = rewriteGrokNativeCallEventList( + original, + new Set(["exec"]), + new Set(["exec"]), + new Set(["search_replace"]), + ); + expect(buffered).toEqual({ events: original, rewritten: false }); + expect(buffered.events).toBe(original); + }); + + test("settles a converted call before an ordinary interleaved call", async () => { + const original: AdapterEvent[] = [ + { type: "tool_call_start", id: "converted", name: "search_replace" }, + { type: "tool_call_delta", arguments: JSON.stringify({ file_path: "a.txt", old_string: "old", new_string: "new" }) }, + { type: "tool_call_start", id: "ordinary", name: "wait" }, + { type: "tool_call_delta", arguments: JSON.stringify({ milliseconds: 50 }) }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + const streamed = await collect(rewriteGrokStructuredEditEvents( + replay(original), + new Set(["search_replace"]), + { kind: "exec", name: "exec" }, + )); + const buffered = rewriteGrokNativeCallEventList( + original, + new Set(["exec"]), + new Set(["exec", "wait"]), + new Set(["search_replace"]), + ); + + expect(buffered.rewritten).toBe(true); + expect(buffered.events).toEqual(streamed); + expect(streamed.map(event => event.type)).toEqual([ + "tool_call_start", "tool_call_delta", "tool_call_end", + "tool_call_start", "tool_call_delta", "tool_call_end", "done", + ]); + expect(streamed[0]).toMatchObject({ type: "tool_call_start", id: "converted", name: "exec" }); + expect(streamed[3]).toEqual({ type: "tool_call_start", id: "ordinary", name: "wait" }); + expect(streamed[4]).toEqual({ type: "tool_call_delta", arguments: JSON.stringify({ milliseconds: 50 }) }); + }); + + test("advertises the Grok catalog when Codex pins exec via allowed_tools", () => { + const toolChoice = { allowedTools: ["exec"], mode: "auto" as const }; + expect(grokNativeCatalogTools([codeModeExec(liveCodexExecHelper)], toolChoice, xai).map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + expect(grokFacingTools([codeModeExec(liveCodexExecHelper)], toolChoice, xai)?.map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + }); + + test("converts live Codex list_dir/read_file calls into exec", async () => { + const events = await collect(rewriteAdapterEventsForGrokStructuredEdits(replay([ + { type: "tool_call_start", id: "c1", name: "list_dir" }, + { type: "tool_call_delta", arguments: JSON.stringify({ path: "/workspace/project" }) }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "c2", name: "read_file" }, + { type: "tool_call_delta", arguments: JSON.stringify({ path: "README.md" }) }, + { type: "tool_call_end" }, + ]), { + context: { tools: [codeModeExec(liveCodexExecHelper)] }, + options: { toolChoice: { allowedTools: ["exec"], mode: "auto" } }, + }, xai)); + expect(events.filter(event => event.type === "tool_call_start")).toEqual([ + { type: "tool_call_start", id: "c1", name: "exec" }, + { type: "tool_call_start", id: "c2", name: "exec" }, + ]); + const args = events.filter((event): event is Extract => event.type === "tool_call_delta"); + expect(args[0]?.arguments).toContain("tools.exec_command"); + expect(args[0]?.arguments).toContain("ls -la --"); + expect(args[1]?.arguments).toContain("cat --"); + expect(args[1]?.arguments).toContain("README.md"); + }); + + test("restores Grok tool names from converted exec history", async () => { + const roundTrip = async (name: string, args: Record) => { + const events = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name }, + { type: "tool_call_delta", arguments: JSON.stringify(args) }, + { type: "tool_call_end" }, + ]), new Set(["read_file", "grep", "list_dir", "search_replace", "write", "write_file", "run_terminal_command"]), { kind: "exec", name: "exec" })); + const parsed = JSON.parse((events[1] as { arguments: string }).arguments) as Record; + return reconstructGrokToolCallFromExec(parsed); + }; + expect(await roundTrip("read_file", { target_file: "README.md" })).toEqual({ + name: "read_file", arguments: { target_file: "README.md" }, + }); + expect(await roundTrip("read_file", { path: "README.md" })).toEqual({ + name: "read_file", arguments: { target_file: "README.md" }, + }); + expect(await roundTrip("read_file", { target_file: "utils/database.py", offset: 160, limit: 20 })).toEqual({ + name: "read_file", arguments: { target_file: "utils/database.py", offset: 160, limit: 20 }, + }); + expect(await roundTrip("list_dir", { target_directory: "." })).toEqual({ + name: "list_dir", arguments: { target_directory: "." }, + }); + expect(await roundTrip("list_dir", { path: "." })).toEqual({ + name: "list_dir", arguments: { target_directory: "." }, + }); + expect(await roundTrip("grep", { pattern: "foo", path: ".", glob: "README*" })).toEqual({ + name: "grep", arguments: { pattern: "foo", path: ".", glob: "README*" }, + }); + expect(await roundTrip("grep", { + pattern: "owner's $HOME $(literal)", + path: "dir/it's here", + glob: "*.{ts,tsx}", + })).toEqual({ + name: "grep", + arguments: { pattern: "owner's $HOME $(literal)", path: "dir/it's here", glob: "*.{ts,tsx}" }, + }); + expect(await roundTrip("search_replace", { file_path: "a.txt", old_string: "old", new_string: "new" })).toEqual({ + name: "search_replace", arguments: { file_path: "a.txt", old_string: "old", new_string: "new" }, + }); + expect(await roundTrip("write", { file_path: "b.txt", content: "hello\nworld" })).toEqual({ + name: "write", arguments: { file_path: "b.txt", content: "hello\nworld" }, + }); + expect(await roundTrip("write_file", { file_path: "b.txt", contents: "hello\nworld" })).toEqual({ + name: "write", arguments: { file_path: "b.txt", content: "hello\nworld" }, + }); + expect(await roundTrip("run_terminal_command", { command: "git status" })).toEqual({ + name: "run_terminal_command", arguments: { command: "git status" }, + }); + expect(await roundTrip("run_terminal_command", { + command: "git add -- cogs/admin.py", + with_escalated_permissions: true, + justification: "stage refactor", + })).toEqual({ + name: "run_terminal_command", + arguments: { + command: "git add -- cogs/admin.py", + with_escalated_permissions: true, + justification: "stage refactor", + }, + }); + expect(reconstructGrokToolCallFromExec({ + input: "const toolsList = ALL_TOOLS.map(t => t.name).join('\\n');\ntext(toolsList);", + })).toBeUndefined(); + const win = translateGrokShellCall("read_file", JSON.stringify({ target_file: "README.md" }), "win32"); + if (!win || "error" in win) throw new Error("expected windows read"); + expect(reconstructGrokToolCallFromExec({ + input: `const r = await tools.exec_command({ cmd: ${JSON.stringify(win.cmd)} });\ntext(r.output);`, + })).toEqual({ name: "read_file", arguments: { target_file: "README.md" } }); + for (const arguments_ of [ + { pattern: "foo", path: "src", glob: "*.ts" }, + { pattern: "it's 'quoted'", path: "src/it's here", glob: "*.t's" }, + ]) { + const winGrep = translateGrokShellCall("grep", JSON.stringify(arguments_), "win32"); + if (!winGrep || "error" in winGrep) throw new Error("expected windows grep"); + expect(reconstructGrokToolCallFromExec({ + input: `const r = await tools.exec_command({ cmd: ${JSON.stringify(winGrep.cmd)} });\ntext(r.output);`, + })).toEqual({ name: "grep", arguments: arguments_ }); + } + }); + + test("escalates git add/commit through Codex exec_command permissions", async () => { + expect(grokShellNeedsGitEscalation("git add -- cogs/admin.py")).toBe(true); + expect(grokShellNeedsGitEscalation("git commit -m 'split packages'")).toBe(true); + expect(grokShellNeedsGitEscalation("git -C /repo add .")).toBe(true); + expect(grokShellNeedsGitEscalation("git -C '/repo with spaces' commit -m split")).toBe(true); + expect(grokShellNeedsGitEscalation("git -C /repo status --short")).toBe(false); + expect(grokShellNeedsGitEscalation("git -C /repo log -1")).toBe(false); + expect(grokShellNeedsGitEscalation("echo ready\ngit -C /repo add .")).toBe(true); + expect(grokShellNeedsGitEscalation("git status --short")).toBe(false); + expect(grokShellNeedsGitEscalation("git log -1")).toBe(false); + expect(grokShellNeedsGitEscalation("echo 'git add'")).toBe(false); + + const gitAdd = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, + { type: "tool_call_delta", arguments: JSON.stringify({ command: "git -C /repo add -- cogs/admin.py" }) }, + { type: "tool_call_end" }, + ]), new Set(["run_terminal_command"]), { kind: "exec", name: "exec" })); + const addInput = (JSON.parse((gitAdd[1] as { arguments: string }).arguments) as { input: string }).input; + expect(addInput).toContain('sandbox_permissions: "require_escalated"'); + expect(addInput).not.toContain("with_escalated_permissions: true"); + expect(addInput).toContain(".git/index.lock"); + expect(addInput).toContain("git -C /repo add -- cogs/admin.py"); + + const status = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, + { type: "tool_call_delta", arguments: JSON.stringify({ command: "git status --short" }) }, + { type: "tool_call_end" }, + ]), new Set(["run_terminal_command"]), { kind: "exec", name: "exec" })); + expect((JSON.parse((status[1] as { arguments: string }).arguments) as { input: string }).input) + .not.toContain("sandbox_permissions"); + + const explicit = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ + command: "ls -la .git", + working_directory: "/tmp", + with_escalated_permissions: true, + justification: "inspect git dir", + }), + }, + { type: "tool_call_end" }, + ]), new Set(["run_terminal_command"]), { kind: "exec", name: "exec" })); + const explicitInput = (JSON.parse((explicit[1] as { arguments: string }).arguments) as { input: string }).input; + expect(explicitInput).toContain('sandbox_permissions: "require_escalated"'); + expect(explicitInput).not.toContain("with_escalated_permissions: true"); + expect(explicitInput).toContain("workdir: \"/tmp\""); + expect(explicitInput).toContain("inspect git dir"); + }); + + test("forwards run_terminal_command python writes without a proxy-side refusal", async () => { + const liveSplit = "python3 << 'PY'\nfrom pathlib import Path\nPath('pool.py').write_text('x = 1\\n')\nPY"; + const events = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, + { type: "tool_call_delta", arguments: JSON.stringify({ command: liveSplit }) }, + { type: "tool_call_end" }, + ]), new Set(["run_terminal_command"]), { kind: "exec", name: "exec" })); + expect(events[0]).toMatchObject({ type: "tool_call_start", name: "exec" }); + const args = (events[1] as { arguments: string }).arguments; + expect(args).toContain("tools.exec_command"); + expect(args).toContain("write_text"); + }); +}); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 27254db95f..f4c44040f0 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -120,6 +120,174 @@ test("noncanonical pool-required providers use only their configured static cred expect(request.headers.session_id).toBeUndefined(); }); +test("xAI Responses passthrough replaces code-mode exec with the Grok edit catalog", () => { + const execDescription = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + const editConstraint = "Use `apply_patch` for local file edits. Do not create or edit files with `cat` or other shell write tricks. Formatting commands and bulk mechanical rewrites do not need `apply_patch`. Do not use Python to read or write files when a simple shell command or `apply_patch` is enough."; + const patch = "*** Begin Patch\n*** Update File: src/a.ts\n@@\n-old\n+new\n*** End Patch"; + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + headers: { authorization: "Bearer xai-oauth" }, + }); + const request = adapter.buildRequest({ + modelId: "grok-4.6", + context: { + systemPrompt: [editConstraint], + messages: [], + tools: [{ + name: "exec", + freeform: true, + description: execDescription, + parameters: {}, + }], + }, + stream: true, + options: { toolChoice: { allowedTools: ["exec"], mode: "auto" } }, + _rawBody: { + model: "grok-4.6", + instructions: editConstraint, + input: [ + { + type: "additional_tools", + role: "developer", + tools: [ + { + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "exec", description: execDescription }], + }, + { type: "tool_search", name: "tool_search", description: "Load deferred tools" }, + ], + }, + { + role: "developer", + content: [{ type: "input_text", text: editConstraint }], + }, + { + type: "custom_tool_call", + id: "ctc_edit", + call_id: "call_edit", + name: "exec", + input: `await tools.apply_patch(${JSON.stringify(patch)})`, + }, + { type: "custom_tool_call_output", call_id: "call_edit", output: "Done" }, + ], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "custom", name: "exec" }], + }, + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + instructions: string; + input: Array>; + tool_choice: { tools: Array<{ type: string; name: string }> }; + }; + const additional = body.input.find(item => item.type === "additional_tools") as { + tools: Array<{ type: string; name?: string; parameters?: Record }>; + }; + const names = additional.tools.map(tool => tool.name).filter(Boolean); + + expect(names).toContain("write"); + expect(names).toContain("search_replace"); + expect(names).toContain("tool_search"); + expect(names).not.toContain("exec"); + expect(additional.tools.find(tool => tool.name === "write")?.parameters) + .toMatchObject({ required: ["file_path", "content"] }); + expect(body.instructions).toContain("listed tools `write` and `search_replace`"); + expect(JSON.stringify(body.input)).not.toContain("Use `apply_patch` for local file edits"); + expect(body.input.find(item => item.call_id === "call_edit")).toMatchObject({ + type: "function_call", + id: "fc_edit", + name: "search_replace", + arguments: JSON.stringify({ file_path: "src/a.ts", old_string: "old", new_string: "new" }), + }); + expect(body.input.find(item => item.type === "function_call_output")) + .toMatchObject({ call_id: "call_edit", output: "Done" }); + expect(body.tool_choice.tools.map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + expect(request.convertedGrokNativeToolNames?.has("search_replace")).toBe(true); + expect(request.grokStructuredEditExecSinkName).toBe("exec"); +}); + +test("xAI Responses tracks only request-local Grok tools after caller-name collisions", () => { + const execDescription = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + const callerWrite = { + type: "function", + name: "write", + description: "Send a caller-owned message", + parameters: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + additionalProperties: false, + }, + }; + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "forward", + headers: { authorization: "Bearer xai-oauth" }, + }); + const request = adapter.buildRequest({ + modelId: "grok-4.6", + context: { + messages: [], + tools: [ + { name: "exec", freeform: true, description: execDescription, parameters: {} }, + { name: "write", description: callerWrite.description, parameters: callerWrite.parameters }, + ], + }, + stream: true, + options: { toolChoice: { allowedTools: ["exec", "write"], mode: "auto" } }, + _rawBody: { + model: "grok-4.6", + tools: [ + { type: "custom", name: "exec", description: execDescription }, + callerWrite, + ], + input: [{ + type: "function_call", + id: "fc_caller_write", + call_id: "call_caller_write", + name: "write", + arguments: JSON.stringify({ message: "caller payload" }), + }], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "custom", name: "exec" }, { type: "function", name: "write" }], + }, + }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { + tools: Array>; + input: Array>; + tool_choice: { tools: Array<{ name: string }> }; + }; + const writes = body.tools.filter(tool => tool.name === "write"); + + expect(writes).toEqual([callerWrite]); + expect(body.input[0]).toEqual({ + type: "function_call", + id: "fc_caller_write", + call_id: "call_caller_write", + name: "write", + arguments: JSON.stringify({ message: "caller payload" }), + }); + expect(body.tool_choice.tools.map(tool => tool.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "run_terminal_command", "write", + ]); + expect(request.convertedGrokNativeToolNames).toEqual(new Set([ + "read_file", "grep", "list_dir", "search_replace", "run_terminal_command", + ])); + expect(request.convertedGrokNativeToolNames?.has("write")).toBe(false); + expect(request.convertedGrokNativeToolNames?.has("write_file")).toBe(false); +}); + test("passthrough serialized-body observation releases after the request settles", () => { const budget = createTranslatorBudget(); const request = createResponsesPassthroughAdapter(provider).buildRequest({ diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 923d52af44..b8cb675ca4 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -5,6 +5,7 @@ import { rewriteRoutedCustomToolsForUpstream, } from "../src/responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../src/server/responses-custom-tool-repair"; +import { grokNativeCallToCodexCustomTool } from "../src/adapters/grok-structured-edit"; import { handleResponses } from "../src/server/responses"; import type { OcxConfig } from "../src/types"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; @@ -88,6 +89,65 @@ describe("routed Responses custom-tool compatibility", () => { expect(restored.output[1]).toMatchObject({ type: "function_call", name: "ordinary", arguments: "{}" }); }); + test("restores Grok search_replace as a Codex exec call in JSON and SSE", () => { + const names = new Set(["search_replace", "write"]); + const transform = (name: string, argumentsText: string, complete: boolean) => + grokNativeCallToCodexCustomTool(name, argumentsText, "exec", complete); + const argumentsText = JSON.stringify({ + file_path: "src/a.ts", + old_string: "old", + new_string: "new", + }); + const upstreamItem = { + type: "function_call", + id: "fc_grok_edit", + call_id: "call_grok_edit", + name: "search_replace", + arguments: argumentsText, + status: "completed", + }; + const restoredJson = JSON.parse(restoreRoutedCustomCallsInJson( + JSON.stringify({ id: "resp_grok", output: [upstreamItem] }), + names, + transform, + )) as { output: Array> }; + expect(restoredJson.output[0]).toMatchObject({ + type: "custom_tool_call", + id: "ctc_grok_edit", + name: "exec", + }); + expect(restoredJson.output[0]?.input).toContain("await tools.apply_patch"); + expect(restoredJson.output[0]?.input).toContain("*** Update File: src/a.ts"); + + const rewrite = createRoutedCustomToolRestoreBlockRewrite(names, undefined, transform); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, arguments: "", status: "in_progress" }, + })); + expect(dataPayload(added[0]!).item).toMatchObject({ + type: "custom_tool_call", + id: "ctc_grok_edit", + name: "exec", + input: "", + }); + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_grok_edit", + delta: argumentsText, + }))).toEqual([]); + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_grok_edit", + arguments: argumentsText, + })); + expect(dataPayload(done[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + item_id: "ctc_grok_edit", + }); + expect(dataPayload(done[0]!).input).toContain("await tools.apply_patch"); + rewrite.dispose?.(); + }); + test("restores the streamed exec lifecycle and unwraps progressive input", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); const added = rewrite(frame("response.output_item.added", { @@ -732,6 +792,75 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses maps Grok Responses edits back to the caller's exec tool", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const argumentsText = JSON.stringify({ + file_path: "src/a.ts", + old_string: "old", + new_string: "new", + }); + const upstreamItem = { + type: "function_call", + id: "fc_grok_edit", + call_id: "call_grok_edit", + name: "search_replace", + arguments: argumentsText, + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...upstreamItem, arguments: "", status: "in_progress" } }), + frame("response.function_call_arguments.delta", { output_index: 0, item_id: "fc_grok_edit", delta: argumentsText }), + frame("response.function_call_arguments.done", { output_index: 0, item_id: "fc_grok_edit", arguments: argumentsText }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { response: { id: "resp_grok", status: "completed", output: [upstreamItem] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const execDescription = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "edit src/a.ts" }] }], + tools: [{ type: "custom", name: "exec", description: execDescription }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + + expect(outboundTools?.map(tool => tool.name)).toContain("write"); + expect(outboundTools?.map(tool => tool.name)).toContain("search_replace"); + expect(outboundTools?.map(tool => tool.name)).not.toContain("exec"); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"name":"exec"'); + expect(clientSse).toContain("await tools.apply_patch"); + expect(clientSse).toContain("*** Update File: src/a.ts"); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { const savedFetch = globalThis.fetch; const outboundBodies: Array> = []; diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index 4031520c31..2ecf29eee1 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -44,6 +44,52 @@ describe("Responses streaming tool event contract", () => { expect((failed.error as Record).message).toContain("apply_patch"); }); + test("Grok native tools become code-mode exec when exec is freeform", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_1", name: "list_dir" }, + { type: "tool_call_delta", arguments: JSON.stringify({ path: "." }) }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "xai/grok-4.6", undefined, new Set(["exec"]), undefined, undefined, undefined, { + declaredToolNames: new Set(["exec"]), + convertedGrokNativeToolNames: new Set(["list_dir"]), + })); + + const added = frames.find(frame => frame.event === "response.output_item.added")?.data.item as Record; + expect(added).toMatchObject({ type: "custom_tool_call", name: "exec" }); + const completed = frames.find(frame => frame.event === "response.completed")?.data.response as Record; + const output = completed.output as Record[]; + expect(output[0]).toMatchObject({ type: "custom_tool_call", name: "exec", status: "completed" }); + expect(String(output[0].input)).toContain("tools.exec_command"); + expect(String(output[0].input)).toContain("ls -la --"); + }); + + test("Grok conversion leaves a caller-owned colliding tool untouched", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_write", name: "write" }, + { type: "tool_call_delta", arguments: JSON.stringify({ message: "caller payload" }) }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_list", name: "list_dir" }, + { type: "tool_call_delta", arguments: JSON.stringify({ path: "." }) }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "xai/grok-4.6", undefined, new Set(["exec"]), undefined, undefined, undefined, { + declaredToolNames: new Set(["exec", "write"]), + convertedGrokNativeToolNames: new Set(["list_dir"]), + })); + + const added = frames + .filter(frame => frame.event === "response.output_item.added") + .map(frame => frame.data.item as Record); + expect(added[0]).toMatchObject({ type: "function_call", name: "write", call_id: "call_write" }); + expect(added[1]).toMatchObject({ type: "custom_tool_call", name: "exec", call_id: "call_list" }); + const completed = frames.find(frame => frame.event === "response.completed")?.data.response as Record; + expect(completed.output).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "function_call", name: "write", arguments: JSON.stringify({ message: "caller payload" }) }), + expect.objectContaining({ type: "custom_tool_call", name: "exec" }), + ])); + }); + test("adapter tool events produce OpenAI-compatible streamed function-call frames", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "tool_call_start", id: "call_1", name: "read_file" }, diff --git a/tests/server-xai-responses-streaming.test.ts b/tests/server-xai-responses-streaming.test.ts index ee69a91953..b01eac45d4 100644 --- a/tests/server-xai-responses-streaming.test.ts +++ b/tests/server-xai-responses-streaming.test.ts @@ -228,7 +228,6 @@ describe("xAI OAuth Responses streaming opt-in", () => { test("lowers Codex namespaces for xAI and restores routed calls on the client stream", async () => { let outboundBody: Record | undefined; - globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); @@ -409,4 +408,214 @@ describe("xAI OAuth Responses streaming opt-in", () => { await server.stop(true); } }, 10_000); + + test("maps the Grok edit catalog and restores native edit calls to Codex exec", async () => { + const execDescription = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + let outboundBody: Record | undefined; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ + id: "resp_xai_edit", + object: "response", + status: "completed", + model: "grok-4.6", + output: [{ + type: "function_call", + id: "fc_xai_edit", + call_id: "call_xai_edit", + name: "search_replace", + arguments: JSON.stringify({ + file_path: "src/a.ts", + old_string: "old", + new_string: "new", + }), + status: "completed", + }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + input: "edit the file", + stream: false, + tools: [{ + type: "custom", + name: "exec", + description: execDescription, + }], + }), + }); + expect(response.status).toBe(200); + const body = await response.json() as { + output: Array>; + }; + const upstreamTools = (outboundBody?.tools ?? []) as Array>; + + expect(upstreamTools.map(tool => tool.name)).toContain("write"); + expect(upstreamTools.map(tool => tool.name)).toContain("search_replace"); + expect(upstreamTools.map(tool => tool.name)).not.toContain("exec"); + expect(body.output[0]).toMatchObject({ + type: "custom_tool_call", + id: "ctc_xai_edit", + call_id: "call_xai_edit", + name: "exec", + }); + expect(body.output[0]?.input).toContain("await tools.apply_patch"); + expect(body.output[0]?.input).toContain("*** Update File: src/a.ts"); + } finally { + await server.stop(true); + } + }, 10_000); + + test("keeps a caller-owned write call unchanged in xAI Responses JSON", async () => { + const execDescription = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + const callerParameters = { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + additionalProperties: false, + }; + let outboundBody: Record | undefined; + const call = { + type: "function_call", + id: "fc_caller_write_json", + call_id: "call_caller_write_json", + name: "write", + arguments: JSON.stringify({ message: "caller payload" }), + status: "completed", + }; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + outboundBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ + id: "resp_caller_write_json", + object: "response", + status: "completed", + model: "grok-4.6", + output: [call], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + input: "use the caller tool", + stream: false, + tools: [ + { type: "custom", name: "exec", description: execDescription }, + { type: "function", name: "write", description: "Caller-owned tool", parameters: callerParameters }, + ], + }), + }); + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + const upstreamTools = (outboundBody?.tools ?? []) as Array>; + const upstreamWrites = upstreamTools.filter(tool => tool.name === "write"); + + expect(upstreamWrites).toHaveLength(1); + expect(upstreamWrites[0]?.parameters).toEqual(callerParameters); + expect(body.output[0]).toEqual(call); + } finally { + await server.stop(true); + } + }, 10_000); + + test("keeps a caller-owned write call unchanged in xAI Responses SSE", async () => { + const execDescription = "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };"; + const callerParameters = { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + additionalProperties: false, + }; + const call = { + type: "function_call", + id: "fc_caller_write_sse", + call_id: "call_caller_write_sse", + name: "write", + arguments: JSON.stringify({ message: "caller payload" }), + status: "completed", + }; + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== RESPONSES_ENDPOINT) return originalFetch(input, init); + const outbound = JSON.parse(String(init?.body)) as { tools?: Array> }; + const writes = (outbound.tools ?? []).filter(tool => tool.name === "write"); + expect(writes).toHaveLength(1); + expect(writes[0]?.parameters).toEqual(callerParameters); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(sse({ type: "response.output_item.added", sequence_number: 0, output_index: 0, item: call })); + controller.enqueue(sse({ type: "response.output_item.done", sequence_number: 1, output_index: 0, item: call })); + controller.enqueue(sse({ + type: "response.completed", + sequence_number: 2, + response: { + id: "resp_caller_write_sse", + object: "response", + status: "completed", + model: "grok-4.6", + output: [call], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })); + controller.close(); + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + input: "use the caller tool", + stream: true, + tools: [ + { type: "custom", name: "exec", description: execDescription }, + { type: "function", name: "write", description: "Caller-owned tool", parameters: callerParameters }, + ], + }), + }); + expect(response.status).toBe(200); + const payloads = (await response.text()) + .split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6)) as Record); + const added = payloads.find(payload => payload.type === "response.output_item.added") as { + item?: Record; + } | undefined; + const completed = payloads.find(payload => payload.type === "response.completed") as { + response?: { output?: Array> }; + } | undefined; + + expect(added?.item).toEqual(call); + expect(completed?.response?.output?.[0]).toEqual(call); + } finally { + await server.stop(true); + } + }, 10_000); }); diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index 6316892034..3d6959416b 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import { buildNonOpenAIToolCatalogNudgeForTools, buildNonOpenAIToolCatalogNudgeFromNames, + effectiveInstructionText, shouldInjectNonOpenAIToolCatalogNudge, + isCanonicalNativeOpenAIRoute, + shouldSuppressCodeModePatchGuidance, } from "../src/adapters/tool-catalog-nudge"; import type { OcxTool } from "../src/types"; @@ -213,4 +216,100 @@ describe("non-OpenAI tool catalog nudge", () => { expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(false); expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://api.kimi.com/coding/v1" })).toBe(true); }); + + test("classifies only canonical native routes", () => { + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-chat", authMode: "key", baseUrl: "https://api.openai.com/v1" })).toBe(true); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-responses", authMode: "key", baseUrl: "https://api.openai.com/v1" })).toBe(true); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(true); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-chat", authMode: "oauth", baseUrl: "https://chatgpt.com/backend-api/codex" })).toBe(false); + expect(isCanonicalNativeOpenAIRoute({ adapter: "openai-chat", authMode: "key", baseUrl: "https://api.openai.com.proxy/v1" })).toBe(false); + expect(shouldInjectNonOpenAIToolCatalogNudge({ adapter: "openai-chat", authMode: "key", baseUrl: "https://api.openai.com.proxy/v1" })).toBe(false); + expect(shouldInjectNonOpenAIToolCatalogNudge({ baseUrl: "https://fooopenai.com/v1" })).toBe(true); + }); + + test("preserves structured developer instructions for mutation gating", () => { + const instructions = effectiveInstructionText([ + { role: "developer", timestamp: 1, content: [{ type: "text", text: "Do not modify files." }, { type: "image", imageUrl: "data:image/png;base64,AA==" }] }, + { role: "user", timestamp: 2, content: "stale user text" }, + { role: "user", timestamp: 3, content: [{ type: "text", text: "current user text" }] }, + ], ["system"]); + expect(instructions).toEqual(["system", "Do not modify files.", "current user text"]); + }); + + test("injects contextual patch guidance only for declared nested helpers", () => { + const exec = (description: string): OcxTool => ({ name: "exec", freeform: true, description, parameters: {} }); + const note = buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }")]); + expect(note).toContain("tools.apply_patch"); + expect(note).toContain("@@"); + expect(note).toContain("tools.exec_command"); + expect(note).toContain("`*** Begin Patch`"); + expect(note).not.toContain("`*** Begin Patch ***`"); + expect(note).toContain("exec` body itself must remain JavaScript"); + expect(note).toContain("retry `tools.apply_patch`"); + expect(note).toContain("File writes, creates, and splits"); + expect(note).toContain("large refactors"); + expect(note).toContain("nested `tools.apply_patch`"); + expect(note).not.toContain("targeted code edits"); + expect(note).not.toContain("focused patch"); + expect(note).not.toContain("mechanical transformations"); + expect(note).toContain("reads, searches, tests, builds, and formatters"); + const nestedInput = buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { exec_command(input: { cmd: string }): Promise; apply_patch(input: string): Promise }")]); + expect(nestedInput).toContain("File writes, creates, and splits"); + expect(nestedInput).toContain("tools.exec_command"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec("JavaScript; apply_patch is mentioned in prose")])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec("For example, tools.apply_patch({ patch: '...' }) may exist")])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { apply_patch(input: string): Promise }")])).not.toContain("exec_command"); + const grokWrite = buildNonOpenAIToolCatalogNudgeForTools([ + exec("declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }"), + { name: "search_replace", description: "edit", parameters: {} }, + { name: "write", description: "create", parameters: {} }, + ], undefined, undefined, undefined, new Set(["write", "search_replace"])); + expect(grokWrite?.startsWith("Codex instructions that say to use `apply_patch` do not add a top-level `apply_patch` or `exec` tool on this turn.")).toBe(true); + expect(grokWrite).toContain("Callable file edits are `write` and `search_replace`"); + expect(grokWrite).toContain("Create or split a file with `write` (file_path, content)"); + expect(grokWrite).toContain("Edit a file with `search_replace` (file_path, old_string, new_string)"); + expect(grokWrite).toContain("converts those calls into Codex apply_patch"); + expect(grokWrite).toContain("A request to make code easier to analyze or modify is a refactor to implement"); + expect(grokWrite).toContain("do not keep ranged-reading files you have already sampled"); + expect(grokWrite).toContain("Commentary that only promises a split or refactor is not a workspace change"); + expect(grokWrite).toContain("Splitting a large file is many `write` calls"); + expect(grokWrite).toContain("do not draft the new tree only in assistant text"); + expect(grokWrite).toContain("Git add/commit must set `with_escalated_permissions` on `run_terminal_command`"); + expect(grokWrite).not.toContain("Discover them from the isolate global `ALL_TOOLS`"); + expect(grokWrite).not.toContain("ALL_TOOLS"); + + const callerOwned = buildNonOpenAIToolCatalogNudgeForTools([ + exec("declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }"), + { name: "search_replace", description: "caller-owned", parameters: {} }, + { name: "write", description: "caller-owned", parameters: {} }, + { name: "run_terminal_command", description: "caller-owned", parameters: {} }, + ]); + expect(callerOwned).not.toContain("converts those calls into Codex apply_patch"); + expect(callerOwned).not.toContain("with_escalated_permissions"); + }); + + test("suppresses contextual guidance for disallowed, planned, structured, and MCP tools", () => { + const exec = { name: "exec", freeform: true, description: "declare const tools: { apply_patch(input: string): Promise }", parameters: {} } as OcxTool; + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], "none") ?? "").not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], { mode: "required", allowedTools: ["other"] }) ?? "").not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["You are in **Plan Mode**"])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["do not make any mutations"])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["# Collaboration Mode: Plan"])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([exec], undefined, undefined, ["Do not use apply_patch"])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([{ ...exec, freeform: undefined }])).not.toContain("File writes, creates, and splits"); + expect(buildNonOpenAIToolCatalogNudgeForTools([{ ...exec, namespace: "mcp__tools" }])).not.toContain("File writes, creates, and splits"); + }); + + test("does not treat Codex Default-mode copy as a no-mutation turn", () => { + const defaultMode = [ + "# Collaboration Mode: Default", + "You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.", + "Never write a multiple choice question as a textual assistant message.", + "", + ].join("\n"); + expect(shouldSuppressCodeModePatchGuidance(defaultMode)).toBe(false); + expect(shouldSuppressCodeModePatchGuidance("Never write a multiple choice question as a textual assistant message.")).toBe(false); + expect(shouldSuppressCodeModePatchGuidance("never write files")).toBe(true); + expect(shouldSuppressCodeModePatchGuidance("You are in **Plan Mode**")).toBe(true); + }); }); From c6b65e482a25a3bf9053400fb40359678b518716 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:25:45 +0900 Subject: [PATCH 2/6] fix(grok): address review edge cases --- .../src/content/docs/guides/codex-integration.md | 5 +++-- .../src/content/docs/ru/reference/adapters.md | 5 +++-- src/adapters/grok-structured-edit.ts | 4 ++-- src/adapters/tool-catalog-nudge.ts | 8 +------- tests/grok-structured-edit.test.ts | 14 ++++++++++++++ tests/tool-catalog-nudge.test.ts | 8 ++++++++ 6 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 08ec30282b..a640c321a9 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -241,8 +241,9 @@ function-call lifecycle to `custom_tool_call` before Codex sees it. Native OpenA and the supported `apply_patch` custom tool stay unchanged. For an xAI/Grok destination, a writable Code Mode turn uses a provider-native catalog instead of -asking Grok to author JavaScript for Codex's freeform `exec` tool. OpenCodex exposes `read_file`, -`grep`, `list_dir`, `search_replace`, `write`, and `run_terminal_command` upstream. It translates +asking Grok to author JavaScript for Codex's freeform `exec` tool. OpenCodex exposes the +collision-free request-local subset of `read_file`, `grep`, `list_dir`, `search_replace`, `write`, +and `run_terminal_command` upstream; same-name caller-owned tools remain unchanged. It translates file edits into the caller's existing `apply_patch` helper, translates reads and commands into the existing `exec_command` helper, and restores the original `exec` call shape, ids, and stream events before Codex sees the response. Supported calls in prior history are reconstructed into the same diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index b589cb8c34..d6e8933144 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -66,8 +66,9 @@ sandbox и approval prompts, включая повышение прав для g ## `openai-responses` -**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело -запроса и стримит ответ обратно **без преобразования**. +**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — обычно пересылает исходное тело +запроса и ответ обратно, применяя только узкие compatibility rewrites для маршрутизируемых шлюзов, +включая bridge xAI/Grok Code Mode. **Аутентификация:** `forward` (ретрансляция заголовков вызывающей стороны) или `key`. При `key`-аутентификации [`retryOn429`](/ru/reference/configuration/) действует и здесь: 429 до diff --git a/src/adapters/grok-structured-edit.ts b/src/adapters/grok-structured-edit.ts index 7933545b15..5de7d99b10 100644 --- a/src/adapters/grok-structured-edit.ts +++ b/src/adapters/grok-structured-edit.ts @@ -803,10 +803,10 @@ function execCommandExtras(toolName: string, argsText: string, cmd: string): Gro const args = parseArgsObject(argsText); if ("error" in args) return {}; const workdir = firstStringArg(args, ["working_directory", "workingDirectory", "workdir", "cwd"]); - const justification = firstStringArg(args, ["justification", "reason", "description"]); + const justification = firstStringArg(args, ["justification", "reason"]); const explicit = firstBooleanArg(args, ["with_escalated_permissions", "withEscalatedPermissions", "escalate"]); const auto = grokShellNeedsGitEscalation(cmd); - const escalate = explicit !== false && (explicit === true || auto || !!justification); + const escalate = explicit !== false && (explicit === true || auto); if (!escalate && !workdir) return {}; return { ...(workdir ? { workdir } : {}), diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index e10a8cea7a..7834190828 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -164,7 +164,6 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( wireNames: readonly string[] | undefined, toWireName: (name: string) => string = name => name, codeModeExecName?: string, - catalogWriteToolNames?: readonly string[], ): string | undefined { const names = uniqueNames(wireNames ?? []); if (names.length === 0) return undefined; @@ -178,12 +177,9 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( name => !advertised.has(name) && !advertised.has(toWireName(name)), ); const verifiedCodeModeExecName = codeModeExecWireName(advertised, codeModeExecName); - const writeNames = uniqueNames(catalogWriteToolNames ?? []).filter(name => advertised.has(name)); const codeModeContract = !verifiedCodeModeExecName ? "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." - : writeNames.length > 0 - ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, such as `await tools.exec_command(...)` or `await tools.codex_app__list_threads({})`." - : "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, such as `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.`. 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."; + : "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, such as `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.`. 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."; return [ "Tool contract: use the current tool catalog as ground truth.", @@ -221,13 +217,11 @@ export function buildNonOpenAIToolCatalogNudgeForTools( && convertedNativeToolNames.has("search_replace") && visibleNames?.includes("write") === true && visibleNames.includes("search_replace"); - const grokWriteNames = grokWrite ? ["write", "search_replace"] : []; // Neighbor names are bare and un-namespaced, so probe the same transform with a bare tool. const base = buildNonOpenAIToolCatalogNudgeFromNames( visibleNames, name => toWireName({ name }), grokWrite ? undefined : codeModeExecName, - grokWriteNames, ); if (!base) return base; if (grokWrite) { diff --git a/tests/grok-structured-edit.test.ts b/tests/grok-structured-edit.test.ts index 466eb85517..69d4e9022c 100644 --- a/tests/grok-structured-edit.test.ts +++ b/tests/grok-structured-edit.test.ts @@ -495,6 +495,20 @@ describe("Grok structured edit tools", () => { expect((JSON.parse((status[1] as { arguments: string }).arguments) as { input: string }).input) .not.toContain("sandbox_permissions"); + for (const annotation of [ + { description: "list files" }, + { justification: "list files" }, + ]) { + const annotated = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, + { type: "tool_call_delta", arguments: JSON.stringify({ command: "ls -la", ...annotation }) }, + { type: "tool_call_end" }, + ]), new Set(["run_terminal_command"]), { kind: "exec", name: "exec" })); + const annotatedInput = (JSON.parse((annotated[1] as { arguments: string }).arguments) as { input: string }).input; + expect(annotatedInput).not.toContain("sandbox_permissions"); + expect(annotatedInput).not.toContain("justification:"); + } + const explicit = await collect(rewriteGrokStructuredEditEvents(replay([ { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, { diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index 3d6959416b..5e51ffd2ee 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -286,6 +286,14 @@ describe("non-OpenAI tool catalog nudge", () => { ]); expect(callerOwned).not.toContain("converts those calls into Codex apply_patch"); expect(callerOwned).not.toContain("with_escalated_permissions"); + + const partialProvenance = buildNonOpenAIToolCatalogNudgeForTools([ + exec("declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }"), + { name: "search_replace", description: "caller-owned", parameters: {} }, + { name: "write", description: "bridge", parameters: {} }, + ], undefined, undefined, undefined, new Set(["write"])); + expect(partialProvenance).not.toContain("converts those calls into Codex apply_patch"); + expect(partialProvenance).not.toContain("with_escalated_permissions"); }); test("suppresses contextual guidance for disallowed, planned, structured, and MCP tools", () => { From 9398c28918f5cfed86bc4d8681ed72ebf8da8843 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:10:34 +0900 Subject: [PATCH 3/6] fix: harden Grok code-mode projection --- src/adapters/grok-structured-edit.ts | 6 +++++- src/adapters/openai-chat.ts | 21 +++++++++++++++++---- src/adapters/tool-catalog-nudge.ts | 18 +++++++++++++++--- tests/grok-structured-edit.test.ts | 9 +++++++++ tests/tool-catalog-nudge.test.ts | 21 +++++++++++++++++++++ tests/xai-transport.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/adapters/grok-structured-edit.ts b/src/adapters/grok-structured-edit.ts index 5de7d99b10..98811df0e7 100644 --- a/src/adapters/grok-structured-edit.ts +++ b/src/adapters/grok-structured-edit.ts @@ -411,7 +411,11 @@ export function grokNativeCatalogTools( parameters: { ...GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA }, }, ]; - return candidates.filter(tool => !existingBareNames.has(tool.name)); + const available = candidates.filter(tool => !existingBareNames.has(tool.name)); + // Hiding exec is safe only while the projected catalog still owns an edit sink. If the + // caller already owns both edit names, the remaining read/terminal helpers cannot be + // translated into file mutations, so keep the original code-mode catalog intact. + return available.some(tool => isGrokStructuredEditToolName(tool.name)) ? available : []; } type GrokNativeCatalogRequest = { diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index accc4e338c..fb6edfdeab 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1299,10 +1299,11 @@ function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxPro } function toolChoiceToChatFormat( - tc: OcxParsedRequest["options"]["toolChoice"], - tools: OcxParsedRequest["context"]["tools"], + parsed: OcxParsedRequest, provider: OcxProviderConfig, ): unknown { + const tc = parsed.options.toolChoice; + const tools = parsed.context.tools; if (!tc) return undefined; if (isAllowedToolChoice(tc)) { if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) { @@ -1311,7 +1312,19 @@ function toolChoiceToChatFormat( return tc.mode === "required" ? "required" : "auto"; } if (tc === "auto" || tc === "none" || tc === "required") return tc; - if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; + if ("name" in tc) { + // An exact exec selector authorizes the Grok projection, but that projection replaces exec + // with several native functions. Requiring one of the advertised functions preserves the + // caller's forced-tool intent without naming a tool that is absent from the outgoing catalog. + const grokNative = grokNativeCatalogTools( + tools, + tc, + provider, + effectiveInstructionText(parsed.context.messages, parsed.context.systemPrompt), + ); + if (grokNative.length > 0) return "required"; + return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; + } return undefined; } @@ -1377,7 +1390,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const { url, headers, hasCredential } = openAIChatTransport(provider); const messages = messagesToChatFormat(parsed, provider); const tools = toolsToChatFormatForProvider(parsed, provider); - const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); + const toolChoice = toolChoiceToChatFormat(parsed, provider); const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 7834190828..973fcd1b11 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -7,19 +7,31 @@ import { type OcxMessage, } from "../types"; +const COLLABORATION_MODE_BLOCK = /[\s\S]*?<\/collaboration_mode>/gi; + /** Collect authoritative system/developer text plus only the latest user turn. */ export function effectiveInstructionText(messages: readonly OcxMessage[] | undefined, system?: readonly string[]): string[] { - const out = [...(system ?? [])]; + const out: string[] = []; + let latestCollaborationMode: string | undefined; let latestUserText: string[] = []; + const appendAuthoritativeText = (text: string): void => { + const withoutSupersededModes = text.replace(COLLABORATION_MODE_BLOCK, block => { + latestCollaborationMode = block; + return ""; + }).trim(); + if (withoutSupersededModes.length > 0) out.push(withoutSupersededModes); + }; + + for (const text of system ?? []) appendAuthoritativeText(text); for (const message of messages ?? []) { if (message.role !== "developer" && message.role !== "user") continue; const text = typeof message.content === "string" ? [message.content] : message.content.filter(part => part.type === "text").map(part => part.text); - if (message.role === "developer") out.push(...text); + if (message.role === "developer") text.forEach(appendAuthoritativeText); else latestUserText = text; } - return [...out, ...latestUserText]; + return [...out, ...(latestCollaborationMode ? [latestCollaborationMode] : []), ...latestUserText]; } // Tool names that exist only in OTHER agent harnesses (Claude Code and friends). Naming one diff --git a/tests/grok-structured-edit.test.ts b/tests/grok-structured-edit.test.ts index 69d4e9022c..1da12b1d89 100644 --- a/tests/grok-structured-edit.test.ts +++ b/tests/grok-structured-edit.test.ts @@ -117,6 +117,15 @@ describe("Grok structured edit tools", () => { )).toEqual([]); }); + test("keeps exec when caller collisions remove both native edit tools", () => { + const callerSearch: OcxTool = { name: "search_replace", description: "caller search", parameters: {} }; + const callerWrite: OcxTool = { name: "write", description: "caller write", parameters: {} }; + const tools = [codeModeExec(applyPatchHelper), callerSearch, callerWrite]; + + expect(grokNativeCatalogTools(tools, undefined, xai)).toEqual([]); + expect(grokFacingTools(tools, undefined, xai)).toEqual(tools); + }); + test("keeps every caller-owned Grok-name collision byte-identical in adapter events", async () => { for (const name of [ "read_file", diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index 5e51ffd2ee..cc79da3cb4 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -236,6 +236,27 @@ describe("non-OpenAI tool catalog nudge", () => { expect(instructions).toEqual(["system", "Do not modify files.", "current user text"]); }); + test("keeps only the latest authoritative collaboration-mode block", () => { + const planMode = [ + "# Collaboration Mode: Plan", + "You are in **Plan Mode**. Do not make any mutations.", + "", + ].join("\n"); + const defaultMode = [ + "# Collaboration Mode: Default", + "You are now in Default mode. Previous Plan mode instructions are inactive.", + "", + ].join("\n"); + const instructions = effectiveInstructionText([ + { role: "developer", timestamp: 1, content: planMode }, + { role: "developer", timestamp: 2, content: "Keep this non-mode instruction.\n" + defaultMode }, + { role: "user", timestamp: 3, content: "implement it" }, + ]); + + expect(instructions).toEqual(["Keep this non-mode instruction.", defaultMode, "implement it"]); + expect(shouldSuppressCodeModePatchGuidance(instructions.join("\n"))).toBe(false); + }); + test("injects contextual patch guidance only for declared nested helpers", () => { const exec = (description: string): OcxTool => ({ name: "exec", freeform: true, description, parameters: {} }); const note = buildNonOpenAIToolCatalogNudgeForTools([exec("declare const tools: { apply_patch(input: string): Promise; exec_command(cmd: string): Promise }")]); diff --git a/tests/xai-transport.test.ts b/tests/xai-transport.test.ts index 79e6cbf5e1..9769d1739d 100644 --- a/tests/xai-transport.test.ts +++ b/tests/xai-transport.test.ts @@ -383,6 +383,29 @@ describe("xAI auth-mode transport selection", () => { expect(tool?.function.parameters.oneOf).toBeUndefined(); expect(tool?.function.parameters.properties).toEqual({}); }); + + test("remaps an exact exec choice when the Grok catalog replaces exec", () => { + const parsedRequest = parseRequest({ + model: "grok-4.5", + input: "edit the file", + tools: [{ + type: "custom", + name: "exec", + description: "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };", + }], + tool_choice: { type: "custom", name: "exec" }, + }); + const request = createOpenAIChatAdapter(provider("key")).buildRequest(parsedRequest); + const body = JSON.parse(request.body) as { + tool_choice: unknown; + tools: Array<{ function: { name: string } }>; + }; + + expect(body.tool_choice).toBe("required"); + expect(body.tools.map(tool => tool.function.name)).toEqual([ + "read_file", "grep", "list_dir", "search_replace", "write", "run_terminal_command", + ]); + }); }); describe("xAI prompt-cache conv-id affinity", () => { From c93090523420200b7dbc2c386f0bb4ae367d1140 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:27:10 +0900 Subject: [PATCH 4/6] fix: preserve Grok rewrite ownership --- src/adapters/grok-structured-edit.ts | 8 +-- src/adapters/tool-catalog-nudge.ts | 2 +- src/server/responses/core.ts | 12 ++--- tests/grok-structured-edit.test.ts | 4 +- ...erver-xai-chat-reasoning-streaming.test.ts | 52 +++++++++++++++++++ tests/tool-catalog-nudge.test.ts | 3 +- 6 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/adapters/grok-structured-edit.ts b/src/adapters/grok-structured-edit.ts index 98811df0e7..1f8aa3bb5e 100644 --- a/src/adapters/grok-structured-edit.ts +++ b/src/adapters/grok-structured-edit.ts @@ -101,7 +101,7 @@ export const GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA = { with_escalated_permissions: { type: "boolean", description: - "True asks Codex to prompt for a sandbox escalation. Required for git add/commit because the sandbox cannot write .git/index.lock.", + "True asks Codex to prompt for a sandbox escalation. Required for Git operations that update the index or refs, such as add, commit, checkout, and switch.", }, justification: { type: "string", @@ -113,10 +113,10 @@ export const GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA = { } as const; const GIT_INDEX_ESCALATION_JUSTIFICATION = - "Write the git index to stage or commit; the sandbox cannot create .git/index.lock."; + "Update Git index or refs; the sandbox cannot create .git/index.lock or other repository lock files."; const GIT_MUTATING_COMMANDS = new Set([ - "add", "commit", "stash", "rm", "mv", "tag", "update-index", "cherry-pick", "rebase", "merge", "notes", + "add", "commit", "checkout", "switch", "stash", "rm", "mv", "tag", "update-index", "cherry-pick", "rebase", "merge", "notes", ]); const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([ "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree", "--namespace", "--super-prefix", @@ -407,7 +407,7 @@ export function grokNativeCatalogTools( { name: GROK_RUN_TERMINAL_COMMAND_TOOL, description: - "Run a shell command. Use read_file, grep, list_dir, search_replace, and write for ordinary file work. Git add/commit must set with_escalated_permissions=true (and a short justification) so Codex can prompt to write .git/index.lock; a commentary message cannot request that permission. If a command fails with Operation not permitted, retry once with with_escalated_permissions=true.", + "Run a shell command. Use read_file, grep, list_dir, search_replace, and write for ordinary file work. Git operations that update the index or refs, including add, commit, checkout, and switch, must set with_escalated_permissions=true (and a short justification) so Codex can prompt to write repository lock files; a commentary message cannot request that permission. If a command fails with Operation not permitted, retry once with with_escalated_permissions=true.", parameters: { ...GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA }, }, ]; diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 973fcd1b11..68ded88e19 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -248,7 +248,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools( + " After a short survey, start `write`/`search_replace`; do not keep ranged-reading files you have already sampled." + " Commentary that only promises a split or refactor is not a workspace change — emit the tool calls in that same turn." + " Splitting a large file is many `write` calls (one new file each) plus `search_replace` on the original. Start from slices already read; do not wait to ingest the whole file, and do not draft the new tree only in assistant text." - + " Git add/commit must set `with_escalated_permissions` on `run_terminal_command` so Codex can prompt to write `.git/index.lock`; commentary cannot request that permission." + + " Git operations that update the index or refs, including add, commit, checkout, and switch, must set `with_escalated_permissions` on `run_terminal_command` so Codex can prompt to write repository lock files; commentary cannot request that permission." ); } if (!codeModeExecTool) return base; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 34c7eda18d..eb9fb794ae 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -5275,18 +5275,12 @@ async function handleResponsesInner( } finally { cleanupUpstreamAbort(); } - { - const rewritten: AdapterEvent[] = []; - for await (const event of rewriteAdapterEventsForGrokStructuredEdits( - (async function* () { yield* events; })(), - parsed, - route.provider, - )) rewritten.push(event); - events = rewritten; - } const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const convertedGrokNativeToolNames = grokNativeToolNamesForRequest(parsed, route.provider); let providerState: OcxProviderContinuationState | undefined; + // Keep the retained adapter-event objects intact until the JSON builder rewrites the Grok + // calls. The builder atomically transfers their translator-budget leases to its replacements; + // rewriting here first would strand the source leases and double-count large parallel edits. const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, { translatorBudget, replayCacheScope: parsed._reasoningReplayScope, diff --git a/tests/grok-structured-edit.test.ts b/tests/grok-structured-edit.test.ts index 1da12b1d89..ffa5004f8c 100644 --- a/tests/grok-structured-edit.test.ts +++ b/tests/grok-structured-edit.test.ts @@ -473,9 +473,11 @@ describe("Grok structured edit tools", () => { } }); - test("escalates git add/commit through Codex exec_command permissions", async () => { + test("escalates Git index and ref mutations through Codex exec_command permissions", async () => { expect(grokShellNeedsGitEscalation("git add -- cogs/admin.py")).toBe(true); expect(grokShellNeedsGitEscalation("git commit -m 'split packages'")).toBe(true); + expect(grokShellNeedsGitEscalation("git checkout feature/refactor")).toBe(true); + expect(grokShellNeedsGitEscalation("git switch -c feature/refactor")).toBe(true); expect(grokShellNeedsGitEscalation("git -C /repo add .")).toBe(true); expect(grokShellNeedsGitEscalation("git -C '/repo with spaces' commit -m split")).toBe(true); expect(grokShellNeedsGitEscalation("git -C /repo status --short")).toBe(false); diff --git a/tests/server-xai-chat-reasoning-streaming.test.ts b/tests/server-xai-chat-reasoning-streaming.test.ts index cbe7178251..69fec72510 100644 --- a/tests/server-xai-chat-reasoning-streaming.test.ts +++ b/tests/server-xai-chat-reasoning-streaming.test.ts @@ -192,4 +192,56 @@ describe("xAI OAuth Chat reasoning streaming", () => { await server.stop(true); } }, 10_000); + + test("transfers retained native-call events before non-streaming JSON assembly", async () => { + // Newline-heavy writes stay below the per-call argument cap, but their translated + // apply_patch programs are materially larger than the native calls they replace. + const payload = "x\n".repeat(330_000); + const toolCalls = Array.from({ length: 12 }, (_, index) => ({ + id: `call_large_edit_${index}`, + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: `src/generated-${index}.ts`, content: payload }), + }, + })); + + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== CHAT_ENDPOINT) return originalFetch(input, init); + return Response.json({ + id: "chatcmpl_xai_large_edits", + object: "chat.completion", + model: "grok-4.6", + choices: [{ index: 0, message: { role: "assistant", tool_calls: toolCalls }, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + + saveConfig(config()); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + input: "create generated files", + stream: false, + tools: [{ + type: "custom", + name: "exec", + description: "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };", + }], + }), + }); + expect(response.status).toBe(200); + const body = await response.json() as { output?: Array> }; + expect(body.output).toHaveLength(toolCalls.length); + expect(body.output?.every(item => item.type === "custom_tool_call" && item.name === "exec")).toBe(true); + expect(body.output?.[0]?.input).toContain("*** Add File: src/generated-0.ts"); + } finally { + await server.stop(true); + } + }, 30_000); }); diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index cc79da3cb4..352ffd9a3b 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -295,7 +295,8 @@ describe("non-OpenAI tool catalog nudge", () => { expect(grokWrite).toContain("Commentary that only promises a split or refactor is not a workspace change"); expect(grokWrite).toContain("Splitting a large file is many `write` calls"); expect(grokWrite).toContain("do not draft the new tree only in assistant text"); - expect(grokWrite).toContain("Git add/commit must set `with_escalated_permissions` on `run_terminal_command`"); + expect(grokWrite).toContain("Git operations that update the index or refs"); + expect(grokWrite).toContain("add, commit, checkout, and switch"); expect(grokWrite).not.toContain("Discover them from the isolate global `ALL_TOOLS`"); expect(grokWrite).not.toContain("ALL_TOOLS"); From 9fbfa19b1e87bd415cb6ed5bf0d3db00ec33b6a4 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:54:19 +0900 Subject: [PATCH 5/6] fix: transfer guarded Grok event ownership --- src/adapters/grok-structured-edit.ts | 7 ++- src/adapters/tool-catalog-nudge.ts | 2 +- src/lib/translator-budget.ts | 14 +++-- tests/grok-structured-edit.test.ts | 5 ++ ...erver-xai-chat-reasoning-streaming.test.ts | 56 +++++++++++++++++++ tests/tool-catalog-nudge.test.ts | 2 +- tests/translator-budget.test.ts | 28 ++++++++++ 7 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/adapters/grok-structured-edit.ts b/src/adapters/grok-structured-edit.ts index 1f8aa3bb5e..bbed0674e6 100644 --- a/src/adapters/grok-structured-edit.ts +++ b/src/adapters/grok-structured-edit.ts @@ -101,7 +101,7 @@ export const GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA = { with_escalated_permissions: { type: "boolean", description: - "True asks Codex to prompt for a sandbox escalation. Required for Git operations that update the index or refs, such as add, commit, checkout, and switch.", + "True asks Codex to prompt for a sandbox escalation. Required for Git operations that update the index or refs, for example add, commit, checkout, switch, reset, or restore.", }, justification: { type: "string", @@ -116,7 +116,8 @@ const GIT_INDEX_ESCALATION_JUSTIFICATION = "Update Git index or refs; the sandbox cannot create .git/index.lock or other repository lock files."; const GIT_MUTATING_COMMANDS = new Set([ - "add", "commit", "checkout", "switch", "stash", "rm", "mv", "tag", "update-index", "cherry-pick", "rebase", "merge", "notes", + "add", "commit", "checkout", "switch", "reset", "restore", "revert", "branch", "stash", "rm", "mv", "tag", + "update-index", "update-ref", "cherry-pick", "rebase", "merge", "notes", ]); const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([ "-C", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree", "--namespace", "--super-prefix", @@ -407,7 +408,7 @@ export function grokNativeCatalogTools( { name: GROK_RUN_TERMINAL_COMMAND_TOOL, description: - "Run a shell command. Use read_file, grep, list_dir, search_replace, and write for ordinary file work. Git operations that update the index or refs, including add, commit, checkout, and switch, must set with_escalated_permissions=true (and a short justification) so Codex can prompt to write repository lock files; a commentary message cannot request that permission. If a command fails with Operation not permitted, retry once with with_escalated_permissions=true.", + "Run a shell command. Use read_file, grep, list_dir, search_replace, and write for ordinary file work. Git operations that update the index or refs, for example add, commit, checkout, switch, reset, or restore, must set with_escalated_permissions=true (and a short justification) so Codex can prompt to write repository lock files; a commentary message cannot request that permission. If a command fails with Operation not permitted, retry once with with_escalated_permissions=true.", parameters: { ...GROK_RUN_TERMINAL_COMMAND_INPUT_SCHEMA }, }, ]; diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 68ded88e19..6120dcd798 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -248,7 +248,7 @@ export function buildNonOpenAIToolCatalogNudgeForTools( + " After a short survey, start `write`/`search_replace`; do not keep ranged-reading files you have already sampled." + " Commentary that only promises a split or refactor is not a workspace change — emit the tool calls in that same turn." + " Splitting a large file is many `write` calls (one new file each) plus `search_replace` on the original. Start from slices already read; do not wait to ingest the whole file, and do not draft the new tree only in assistant text." - + " Git operations that update the index or refs, including add, commit, checkout, and switch, must set `with_escalated_permissions` on `run_terminal_command` so Codex can prompt to write repository lock files; commentary cannot request that permission." + + " Git operations that update the index or refs, for example add, commit, checkout, switch, reset, or restore, must set `with_escalated_permissions` on `run_terminal_command` so Codex can prompt to write repository lock files; commentary cannot request that permission." ); } if (!codeModeExecTool) return base; diff --git a/src/lib/translator-budget.ts b/src/lib/translator-budget.ts index b5c0920207..7361ad79c2 100644 --- a/src/lib/translator-budget.ts +++ b/src/lib/translator-budget.ts @@ -132,7 +132,12 @@ export function releaseTranslatedEvent(event: object, budget: TranslatorBudget): budget.releaseRetained(ownership.bytes, { kind: "retained_collectors" }); } -/** Replace one retained adapter-event batch while preserving its budget ownership. */ +/** + * Replace the retained members of an adapter-event batch while preserving budget ownership. + * Terminal guards may clone the final event to merge usage, producing a source array that mixes + * retained parser events with a new unretained terminal event. Release only the source leases + * owned by this budget, then charge the complete replacement array. + */ export function replaceRetainedTranslatedEventBatch( source: readonly T[], replacement: T[], @@ -140,12 +145,11 @@ export function replaceRetainedTranslatedEventBatch( ): void { const ownerships = source.map(event => retainedEventOwnership.get(event)); if (ownerships.every(ownership => ownership?.budget !== budget)) return; - if (ownerships.some(ownership => ownership?.budget !== budget)) { - throw new Error("cannot replace a partially retained translated event batch"); - } let releasedBytes = 0; for (let index = 0; index < source.length; index += 1) { - releasedBytes += ownerships[index]!.bytes; + const ownership = ownerships[index]; + if (!ownership || ownership.budget !== budget) continue; + releasedBytes += ownership.bytes; retainedEventOwnership.delete(source[index]!); } budget.releaseRetained(releasedBytes, { kind: "retained_collectors" }); diff --git a/tests/grok-structured-edit.test.ts b/tests/grok-structured-edit.test.ts index ffa5004f8c..adbc18077b 100644 --- a/tests/grok-structured-edit.test.ts +++ b/tests/grok-structured-edit.test.ts @@ -478,6 +478,11 @@ describe("Grok structured edit tools", () => { expect(grokShellNeedsGitEscalation("git commit -m 'split packages'")).toBe(true); expect(grokShellNeedsGitEscalation("git checkout feature/refactor")).toBe(true); expect(grokShellNeedsGitEscalation("git switch -c feature/refactor")).toBe(true); + expect(grokShellNeedsGitEscalation("git reset --mixed HEAD~1")).toBe(true); + expect(grokShellNeedsGitEscalation("git restore --staged src/a.ts")).toBe(true); + expect(grokShellNeedsGitEscalation("git revert HEAD")).toBe(true); + expect(grokShellNeedsGitEscalation("git branch -f main HEAD")).toBe(true); + expect(grokShellNeedsGitEscalation("git update-ref refs/heads/main HEAD")).toBe(true); expect(grokShellNeedsGitEscalation("git -C /repo add .")).toBe(true); expect(grokShellNeedsGitEscalation("git -C '/repo with spaces' commit -m split")).toBe(true); expect(grokShellNeedsGitEscalation("git -C /repo status --short")).toBe(false); diff --git a/tests/server-xai-chat-reasoning-streaming.test.ts b/tests/server-xai-chat-reasoning-streaming.test.ts index 69fec72510..4a692b2028 100644 --- a/tests/server-xai-chat-reasoning-streaming.test.ts +++ b/tests/server-xai-chat-reasoning-streaming.test.ts @@ -244,4 +244,60 @@ describe("xAI OAuth Chat reasoning streaming", () => { await server.stop(true); } }, 30_000); + + test("rewrites a guarded non-streaming native call whose terminal usage event was copied", async () => { + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url !== CHAT_ENDPOINT) return originalFetch(input, init); + return Response.json({ + id: "chatcmpl_xai_guarded_edit", + object: "chat.completion", + model: "grok-4.6", + choices: [{ + index: 0, + message: { + role: "assistant", + tool_calls: [{ + id: "call_guarded_edit", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: "src/guarded.ts", content: "export const guarded = true;\n" }), + }, + }], + }, + finish_reason: "tool_calls", + }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }); + }) as typeof fetch; + + const configured = config(); + configured.emptyCompletionRetry = true; + saveConfig(configured); + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-4.6", + input: "create a guarded file", + stream: false, + tools: [{ + type: "custom", + name: "exec", + description: "Run JavaScript. declare const tools: { apply_patch(input: string): Promise; };", + }], + }), + }); + expect(response.status).toBe(200); + const body = await response.json() as { output?: Array> }; + expect(body.output).toHaveLength(1); + expect(body.output?.[0]).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(body.output?.[0]?.input).toContain("*** Add File: src/guarded.ts"); + } finally { + await server.stop(true); + } + }, 10_000); }); diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index 352ffd9a3b..5ca9163238 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -296,7 +296,7 @@ describe("non-OpenAI tool catalog nudge", () => { expect(grokWrite).toContain("Splitting a large file is many `write` calls"); expect(grokWrite).toContain("do not draft the new tree only in assistant text"); expect(grokWrite).toContain("Git operations that update the index or refs"); - expect(grokWrite).toContain("add, commit, checkout, and switch"); + expect(grokWrite).toContain("add, commit, checkout, switch, reset, or restore"); expect(grokWrite).not.toContain("Discover them from the isolate global `ALL_TOOLS`"); expect(grokWrite).not.toContain("ALL_TOOLS"); diff --git a/tests/translator-budget.test.ts b/tests/translator-budget.test.ts index 149f08afdc..6fb59cf2af 100644 --- a/tests/translator-budget.test.ts +++ b/tests/translator-budget.test.ts @@ -8,7 +8,9 @@ import { TRANSLATOR_MAX_TURN_BYTES, createTranslatorBudget, releaseTranslatedEvent, + replaceRetainedTranslatedEventBatch, retainTranslatedEvent, + retainTranslatedEventBatch, translatorObservedBufferSnapshot, } from "../src/lib/translator-budget"; import type { AdapterEvent } from "../src/types"; @@ -57,6 +59,32 @@ describe("translator budget", () => { } }); + test("batch replacement transfers retained members from a mixed guarded batch", () => { + const budget = createTranslatorBudget({ maxTurnBytes: 4_096 }); + const retained = { type: "tool_call_end" } satisfies AdapterEvent; + const copiedTerminal = { + type: "done", + usage: { inputTokens: 1, outputTokens: 2 }, + } satisfies AdapterEvent; + const source: AdapterEvent[] = [retained, copiedTerminal]; + const replacement: AdapterEvent[] = [ + { type: "tool_call_start", id: "call", name: "exec" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "patch" }) }, + { type: "tool_call_end" }, + copiedTerminal, + ]; + try { + retainTranslatedEventBatch([retained], budget); + replaceRetainedTranslatedEventBatch(source, replacement, budget); + expect(budget.snapshot().currentBytes).toBe(Buffer.byteLength(JSON.stringify(replacement))); + + for (const event of replacement) releaseTranslatedEvent(event, budget); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { + budget.dispose(); + } + }); + test("one one-shot tool call admits exactly 2 MiB and rejects one byte over", () => { const exact = createTranslatorBudget(); exact.openCall("call"); From b7b5c5f10dd74d5dfc5697fe4d37cd615d0b0503 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:03:27 +0900 Subject: [PATCH 6/6] fix(grok): enforce required git escalation --- src/adapters/grok-structured-edit.ts | 2 +- tests/grok-structured-edit.test.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/adapters/grok-structured-edit.ts b/src/adapters/grok-structured-edit.ts index bbed0674e6..4c675b7bcb 100644 --- a/src/adapters/grok-structured-edit.ts +++ b/src/adapters/grok-structured-edit.ts @@ -811,7 +811,7 @@ function execCommandExtras(toolName: string, argsText: string, cmd: string): Gro const justification = firstStringArg(args, ["justification", "reason"]); const explicit = firstBooleanArg(args, ["with_escalated_permissions", "withEscalatedPermissions", "escalate"]); const auto = grokShellNeedsGitEscalation(cmd); - const escalate = explicit !== false && (explicit === true || auto); + const escalate = auto || explicit === true; if (!escalate && !workdir) return {}; return { ...(workdir ? { workdir } : {}), diff --git a/tests/grok-structured-edit.test.ts b/tests/grok-structured-edit.test.ts index adbc18077b..91ef635e50 100644 --- a/tests/grok-structured-edit.test.ts +++ b/tests/grok-structured-edit.test.ts @@ -503,6 +503,23 @@ describe("Grok structured edit tools", () => { expect(addInput).toContain(".git/index.lock"); expect(addInput).toContain("git -C /repo add -- cogs/admin.py"); + for (const command of [ + "git checkout feature/refactor", + "git switch -c feature/refactor", + "git reset --mixed HEAD~1", + ]) { + const rewritten = await collect(rewriteGrokStructuredEditEvents(replay([ + { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ command, with_escalated_permissions: false }), + }, + { type: "tool_call_end" }, + ]), new Set(["run_terminal_command"]), { kind: "exec", name: "exec" })); + const input = (JSON.parse((rewritten[1] as { arguments: string }).arguments) as { input: string }).input; + expect(input).toContain('sandbox_permissions: "require_escalated"'); + } + const status = await collect(rewriteGrokStructuredEditEvents(replay([ { type: "tool_call_start", id: "c1", name: "run_terminal_command" }, { type: "tool_call_delta", arguments: JSON.stringify({ command: "git status --short" }) },