From fe427aa278132147b7785365d61769d87915fed6 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:29:42 +0900 Subject: [PATCH 1/7] fix(responses): repair apply_patch envelopes --- src/adapters/base.ts | 2 + src/adapters/openai-responses.ts | 3 + src/bridge.ts | 24 ++- src/responses/apply-patch-envelope.ts | 55 +++++++ src/responses/custom-tool-compat.ts | 62 ++++--- src/server/responses-custom-tool-repair.ts | 40 ++++- src/server/responses/core.ts | 13 +- structure/04_transports-and-sidecars.md | 8 + tests/apply-patch-envelope.test.ts | 96 +++++++++++ tests/bridge.test.ts | 21 +++ tests/custom-tool-compat.test.ts | 3 + tests/responses-custom-tool-repair.test.ts | 178 +++++++++++++++++++++ 12 files changed, 462 insertions(+), 43 deletions(-) create mode 100644 src/responses/apply-patch-envelope.ts create mode 100644 tests/apply-patch-envelope.test.ts diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 3f3f3d06b6..d4715d01d6 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -70,6 +70,8 @@ export interface AdapterRequest { body: string; /** Final upstream wire names of custom tools lowered to functions while building this request. */ convertedRoutedCustomToolNames?: ReadonlySet; + /** Native custom-tool wire names authorized for representation-only response repair. */ + routedCustomToolRepairNames?: ReadonlySet; /** Client tool-search names actually lowered to upstream function calls for this request. */ convertedRoutedToolSearchNames?: ReadonlySet; /** Upstream-only aliases for namespace tools flattened in this request. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index eeeb38c386..83b348028c 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1684,6 +1684,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; + let routedCustomToolRepairNames: Set | undefined; let convertedRoutedToolSearchNames: Set | undefined; let convertedRoutedNamespaceToolAliases: Map | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; @@ -1741,6 +1742,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; + routedCustomToolRepairNames = rewritten.repairNames; } if (!isCanonicalOpenAiForwardProvider(provider)) { // Run after custom-tool lowering so the search compatibility layer can choose a @@ -1811,6 +1813,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): body, releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), + ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}), ...(tierLog ? { tierLog } : {}), diff --git a/src/bridge.ts b/src/bridge.ts index 82c73a0bee..44ea550646 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -8,6 +8,7 @@ import type { } from "./types"; import { coerceIntegerToolArguments } from "./lib/tool-argument-integers"; import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors"; +import { repairFreeformToolInput } from "./responses/apply-patch-envelope"; import { encodeCompactionSummary } from "./responses/compaction"; import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; @@ -230,12 +231,10 @@ export function bridgeToResponsesSSE( 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)); - // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a - // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call. - const freeformInput = (args: string): string => { - try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ } - return args; - }; + // Freeform/custom tools (apply_patch, code-mode exec) carry their body in `input`; the + // model is given a function with `{input:string}`, so unwrap it here when relaying back + // as a custom_tool_call. Decorated apply_patch envelopes are repaired at this boundary. + const freeformInput = (args: string, toolName: string): string => repairFreeformToolInput(args, toolName); // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only; // the completed custom_tool_call item stays authoritative). Compact `{"input":"...` @@ -636,7 +635,7 @@ export function bridgeToResponsesSSE( if (currentToolCall.freeform) { emit("response.custom_tool_call_input.done", { item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, - input: freeformInput(currentToolCall.args), + input: freeformInput(currentToolCall.args, currentToolCall.name), }); } // Freeform tools serialize as custom_tool_call without extra_content; remember the @@ -652,7 +651,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: currentToolCall.itemId, call_id: currentToolCall.callId, name: currentToolCall.name, - input: freeformInput(currentToolCall.args), status: "completed", + input: freeformInput(currentToolCall.args, currentToolCall.name), status: "completed", } : { type: "function_call", id: currentToolCall.itemId, @@ -691,7 +690,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: currentToolCall.itemId, call_id: currentToolCall.callId, name: currentToolCall.name, - input: freeformInput(currentToolCall.args), status: "incomplete", + input: freeformInput(currentToolCall.args, currentToolCall.name), status: "incomplete", } : { type: "function_call", id: currentToolCall.itemId, @@ -1567,10 +1566,7 @@ function buildResponseJSONWithBudget( // Web-search citations awaiting the next assistant message (attached as url_citation annotations). let pendingWebSources: { url: string; title?: string }[] = []; - const freeformInput = (args: string): string => { - try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ } - return args; - }; + const freeformInput = (args: string, toolName: string): string => repairFreeformToolInput(args, toolName); const parseArgsObj = (args: string): Record => { try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; } }; @@ -1670,7 +1666,7 @@ function buildResponseJSONWithBudget( pushOutput({ type: "custom_tool_call", id: `ctc_${uuid()}`, call_id: currentToolCallId, name: realName, - input: freeformInput(currentToolCallArgs), status, + input: freeformInput(currentToolCallArgs, realName), status, }); } else { pushOutput({ diff --git a/src/responses/apply-patch-envelope.ts b/src/responses/apply-patch-envelope.ts new file mode 100644 index 0000000000..20de7ba4aa --- /dev/null +++ b/src/responses/apply-patch-envelope.ts @@ -0,0 +1,55 @@ +// Representation repair for top-level Codex apply_patch custom-tool payloads. +// +// Some routed models decorate the first and last lines as +// `*** Begin Patch ***` / `*** End Patch ***`. Codex rejects those otherwise +// valid custom-tool payloads. Repair is deliberately limited to a complete, +// structurally recognizable top-level patch: arbitrary `exec` JavaScript is +// caller-authored executable input and must remain byte-identical. +// +// This is the same intent boundary as `src/lib/tool-argument-integers.ts`: +// repair the one faithful reading, leave genuine patch content alone. + +const PATCH_BEGIN = "*** Begin Patch"; +const PATCH_END = "*** End Patch"; +const TOP_LEVEL_PATCH_ENVELOPE = /^(\*\*\* Begin Patch(?: \*\*\*)?)(\r?\n)([\s\S]*)(\r?\n)(\*\*\* End Patch(?: \*\*\*)?)(\r?\n)?$/; +const PATCH_OPERATION_LINE = /^\*\*\* (?:Add|Update|Delete) File: .+$/m; + +/** Unwrap the `{input:string}` function-call wrapper used for freeform tools. */ +export function unwrapFreeformToolInput(argumentsText: unknown): string { + if (typeof argumentsText !== "string") return ""; + try { + const parsed: unknown = JSON.parse(argumentsText); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const input = (parsed as { input?: unknown }).input; + if (typeof input === "string") return input; + } + } catch { + // The string is the freeform body, not nested JSON. + } + return argumentsText; +} + +/** + * Strip trailing `***` only from the outer lines of one complete patch. + * Internal patch content, incomplete envelopes, and non-patch text are exact + * pass-throughs. + */ +export function normalizeApplyPatchDelimiters(text: string): string { + const match = TOP_LEVEL_PATCH_ENVELOPE.exec(text); + if (!match) return text; + const [, begin, beginBreak, body, endBreak, end, trailingBreak = ""] = match; + if (!PATCH_OPERATION_LINE.test(body)) return text; + if (begin === PATCH_BEGIN && end === PATCH_END) return text; + return `${PATCH_BEGIN}${beginBreak}${body}${endBreak}${PATCH_END}${trailingBreak}`; +} + +/** + * Repair freeform input before Codex sees it. + * + * Only a top-level `apply_patch` payload may receive delimiter repair. `exec` + * JavaScript and every other freeform body are unwrapped and left byte-exact. + */ +export function repairFreeformToolInput(argumentsText: unknown, toolName = ""): string { + const unwrapped = unwrapFreeformToolInput(argumentsText); + return toolName === "apply_patch" ? normalizeApplyPatchDelimiters(unwrapped) : unwrapped; +} diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index d5d4e93b30..44feff3694 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -1,4 +1,5 @@ import { namespacedToolName } from "../types"; +import { repairFreeformToolInput, unwrapFreeformToolInput } from "./apply-patch-envelope"; import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); @@ -15,15 +16,6 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function customToolInput(argumentsText: unknown): string { - if (typeof argumentsText !== "string") return ""; - try { - const parsed = JSON.parse(argumentsText) as unknown; - if (isPlainObject(parsed) && typeof parsed.input === "string") return parsed.input; - } catch { /* malformed arguments stay visible to the client */ } - return argumentsText; -} - function customToolWireName(namespace: string | undefined, name: string): string { return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); } @@ -38,12 +30,13 @@ export function routedCustomToolWireName(value: unknown): string | undefined { } /** - * Names of converted custom declarations after namespace lowering. Restoration uses these exact - * wire identities so same-named function and custom children in different namespaces stay distinct. + * Names of custom declarations after namespace lowering. The selection flag separates converted + * names from native passthrough names while keeping same-named function and custom children distinct. */ function collectRoutedCustomToolWireNames( body: unknown, supportsResponsesCustomTools?: boolean, + passthrough = false, ): Set { const names = new Set(); const groups = collectResponsesToolGroups(body); @@ -64,7 +57,7 @@ function collectRoutedCustomToolWireNames( if ( tool.type === "custom" && typeof tool.name === "string" - && !routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) + && routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) === passthrough ) { names.add(tool.name); continue; @@ -77,7 +70,7 @@ function collectRoutedCustomToolWireNames( isPlainObject(child) && child.type === "custom" && typeof child.name === "string" - && !routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) + && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) === passthrough && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); } @@ -85,7 +78,6 @@ function collectRoutedCustomToolWireNames( } return names; } - export function customToolItemId(id: unknown): unknown { if (typeof id !== "string") return id; return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; @@ -203,23 +195,26 @@ export function rewriteRoutedCustomToolsForUpstream( ): { body: unknown; names: Set; + repairNames: Set; } { const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); - if (conversionNames.size === 0) return { body, names }; + const repairNames = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools, true); + if (conversionNames.size === 0) return { body, names, repairNames }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); - return { body: rewriteForUpstream(body, conversionNames, callIds), names }; + return { body: rewriteForUpstream(body, conversionNames, callIds), names, repairNames }; } export function restoreRoutedCustomCalls( value: unknown, names: ReadonlySet, + repairNames: ReadonlySet = new Set(), ): { 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, repairNames); changed ||= result.changed; return result.value; }); @@ -230,37 +225,56 @@ 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, repairNames); 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) + ) { restored.type = "custom_tool_call"; restored.id = customToolItemId(value.id); - restored.input = customToolInput(value.arguments); + restored.input = repairFreeformToolInput(value.arguments, value.name); delete restored.arguments; changed = true; } + if ( + value.type === "custom_tool_call" + && typeof value.name === "string" + && wireName !== undefined + && repairNames.has(wireName) + && typeof value.input === "string" + ) { + const input = repairFreeformToolInput(value.input, value.name); + if (input !== value.input) { + restored.input = input; + changed = true; + } + } return changed ? { value: restored, changed: true } : { value, changed: false }; } export function restoreRoutedCustomCallsInJson( text: string, names: ReadonlySet, + repairNames: ReadonlySet = new Set(), ): string { - if (names.size === 0) return text; + if (names.size === 0 && repairNames.size === 0) return text; let payload: unknown; try { payload = JSON.parse(text); } catch { return text; } - const restored = restoreRoutedCustomCalls(payload, names); + const restored = restoreRoutedCustomCalls(payload, names, repairNames); return restored.changed ? JSON.stringify(restored.value) : text; } -export function unwrapRoutedCustomToolArguments(argumentsText: unknown): string { - return customToolInput(argumentsText); +export function unwrapRoutedCustomToolArguments(argumentsText: unknown, toolName = ""): string { + return toolName ? repairFreeformToolInput(argumentsText, toolName) : unwrapFreeformToolInput(argumentsText); } diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 1aaa16c73e..bbc4e2ed92 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -85,8 +85,10 @@ type PendingArgumentBlock = { export function createRoutedCustomToolRestoreBlockRewrite( names: ReadonlySet, budget?: TranslatorBudget, + repairNames: ReadonlySet = new Set(), ): SseBlockRewrite { const itemNames = new Map(); + const repairItemNames = new Map(); const ordinaryItemIds = new Set(); const openCalls = new Map(); let pendingArguments: PendingArgumentBlock[] = []; @@ -111,6 +113,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( } pendingArguments = []; itemNames.clear(); + repairItemNames.clear(); ordinaryItemIds.clear(); }; @@ -175,6 +178,23 @@ export function createRoutedCustomToolRestoreBlockRewrite( && parsed.output_index >= 0 ? parsed.output_index : undefined; + if ( + (type === "response.output_item.added" || type === "response.output_item.done") + && isPlainObject(parsed.item) + && parsed.item.type === "custom_tool_call" + && typeof parsed.item.name === "string" + ) { + const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; + const wireName = routedCustomToolWireName(parsed.item); + const repairable = wireName !== undefined && repairNames.has(wireName); + if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name); + const restored = repairable + ? restoreRoutedCustomCalls(parsed, names, repairNames) + : { value: parsed, changed: false }; + return restored.changed + ? [replaceSseDataPayload(block, JSON.stringify(restored.value))] + : [block]; + } if ( (type === "response.output_item.added" || type === "response.output_item.done") && isPlainObject(parsed.item) @@ -203,7 +223,7 @@ 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, repairNames); const restoredBlock = restored.changed ? replaceSseDataPayload(block, JSON.stringify(restored.value)) : block; @@ -215,6 +235,20 @@ export function createRoutedCustomToolRestoreBlockRewrite( } const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined; + if ( + type === "response.custom_tool_call_input.done" + && upstreamItemId + && repairItemNames.has(upstreamItemId) + && typeof parsed.input === "string" + ) { + const input = unwrapRoutedCustomToolArguments( + parsed.input, + repairItemNames.get(upstreamItemId) ?? "", + ); + if (input !== parsed.input) { + return [replaceSseDataPayload(block, JSON.stringify({ ...parsed, input }))]; + } + } const argumentEvent = type === "response.function_call_arguments.delta" || type === "response.function_call_arguments.done"; if (argumentEvent && (!upstreamItemId || (!itemNames.has(upstreamItemId) && !ordinaryItemIds.has(upstreamItemId)))) { @@ -265,12 +299,12 @@ export function createRoutedCustomToolRestoreBlockRewrite( ...rest, type: nextType, item_id: customToolItemId(upstreamItemId), - input: unwrapRoutedCustomToolArguments(source), + input: unwrapRoutedCustomToolArguments(source, itemNames.get(upstreamItemId) ?? ""), }; return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; } - const restored = restoreRoutedCustomCalls(parsed, names); + const restored = restoreRoutedCustomCalls(parsed, names, repairNames); 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..73c6a52673 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2848,6 +2848,7 @@ async function handleResponsesInner( ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); const routedCustomToolNames = new Set(); + const routedCustomToolRepairNames = new Set(); const routedToolSearchNames = new Set(); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex @@ -2891,6 +2892,9 @@ async function handleResponsesInner( || toolBridgeMaps.toolNsMap.get(name)?.freeform === true ) routedCustomToolNames.add(name); } + for (const name of request.routedCustomToolRepairNames ?? []) { + routedCustomToolRepairNames.add(name); + } } for (const name of request.convertedRoutedToolSearchNames ?? []) { // The adapter already keeps this set empty when tool_choice forbids the private search. @@ -3582,8 +3586,12 @@ async function handleResponsesInner( payloadRewrites.length > 0 ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites)) : undefined, - routedCustomToolNames.size > 0 - ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget) + routedCustomToolNames.size > 0 || routedCustomToolRepairNames.size > 0 + ? createRoutedCustomToolRestoreBlockRewrite( + routedCustomToolNames, + translatorBudget, + routedCustomToolRepairNames, + ) : undefined, routedToolSearchNames.size > 0 ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget) @@ -3788,6 +3796,7 @@ async function handleResponsesInner( const restored = restoreRoutedCustomCallsInJson( restoredNamespace, routedCustomToolNames, + routedCustomToolRepairNames, ); const restoredToolSearch = restoreRoutedToolSearchCallsInJson( restored, diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 927a8f2fd5..970e1cdae2 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -48,6 +48,14 @@ Responses-compatible streaming output. - 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. - 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. +[Decision Log] +- 목적과 의도: Accept a routed model's decorated outer `apply_patch` delimiter lines without changing the executable meaning of any provider-returned program. +- 기존 구현 및 제약 조건: Routed custom tools arrive through a public function wrapper and are restored at the response boundary, but arbitrary `exec` JavaScript is caller-executable source whose strings, comments, templates, and helper arguments cannot be safely rewritten with text patterns. +- 검토한 주요 대안: Regex-rewrite nested helper calls in `exec`; wrap a raw `exec` patch body as a helper call; reject every decorated patch; or normalize only the outer lines of a complete top-level `apply_patch` custom-tool payload. +- 선택한 방식: After unwrapping the request-authorized custom-tool function shape, normalize only exact decorated Begin/End lines when the entire `apply_patch` input is one structurally recognizable patch with a file operation. Keep `exec` and all other freeform bodies byte-identical. +- 다른 대안 대신 이 방식을 선택한 이유: A top-level `apply_patch` call already carries explicit executable intent, so its unambiguous outer-line spelling can be repaired without inventing a call or parsing JavaScript. Every broader rewrite could reinterpret ordinary data as code. +- 장점, 단점 및 영향: Decorated top-level patches regain compatibility while strings, comments, generated source, raw `exec` text, incomplete envelopes, and patch-file content remain untouched. Nested malformed helper source must be corrected by the provider instead of being guessed at the response boundary. + [Decision Log] - 목적과 의도: Keep Codex client-side deferred tool discovery usable through third-party Responses-compatible gateways that implement public function tools but reject the private `tool_search` declaration. - 기존 구현 및 제약 조건: The chat translation path already exposed search as a function and bridged its call back to `tool_search_call`; passthrough only promoted definitions returned by an earlier search, so it could not initiate discovery on a strict third-party Responses endpoint. diff --git a/tests/apply-patch-envelope.test.ts b/tests/apply-patch-envelope.test.ts new file mode 100644 index 0000000000..2c420cd918 --- /dev/null +++ b/tests/apply-patch-envelope.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeApplyPatchDelimiters, + repairFreeformToolInput, +} from "../src/responses/apply-patch-envelope"; + +const DECORATED_PATCH = `*** Begin Patch *** +*** Update File: README.md +@@ +-old ++new +*** End Patch ***`; + +const CANONICAL_PATCH = `*** Begin Patch +*** Update File: README.md +@@ +-old ++new +*** End Patch`; + +describe("apply_patch envelope repair", () => { + test("repairs only the outer lines of a complete top-level apply_patch payload", () => { + expect(repairFreeformToolInput(DECORATED_PATCH, "apply_patch")).toBe(CANONICAL_PATCH); + expect(normalizeApplyPatchDelimiters(DECORATED_PATCH)).toBe(CANONICAL_PATCH); + }); + + test("preserves CRLF and an existing trailing newline", () => { + const decorated = DECORATED_PATCH.replaceAll("\n", "\r\n") + "\r\n"; + const canonical = CANONICAL_PATCH.replaceAll("\n", "\r\n") + "\r\n"; + expect(repairFreeformToolInput(decorated, "apply_patch")).toBe(canonical); + }); + + test("unwraps the function-call {input} wrapper before top-level repair", () => { + expect(repairFreeformToolInput(JSON.stringify({ input: DECORATED_PATCH }), "apply_patch")).toBe(CANONICAL_PATCH); + }); + + test("keeps exec JavaScript strings, comments, templates, and regexes byte-identical", () => { + const cases = [ + 'const sample = "tools.apply_patch({ input: patchText })";', + "// tools.apply_patch({ input: patchText })\nconst ok = true;", + "const source = `await tools.apply_patch(\\`*** Begin Patch ***\\`)`;", + "const marker = /\\*\\*\\* Begin Patch \\*\\*\\*/;", + `await tools.apply_patch(\`*** Begin Patch *** +*** Update File: README.md +@@ +-old ++new +*** End Patch ***\`)`, + ]; + for (const source of cases) { + expect(repairFreeformToolInput(source, "exec")).toBe(source); + } + }); + + test("does not turn a raw exec body into an executable helper call", () => { + expect(repairFreeformToolInput(DECORATED_PATCH, "exec")).toBe(DECORATED_PATCH); + expect(repairFreeformToolInput(JSON.stringify({ input: DECORATED_PATCH }), "exec")).toBe(DECORATED_PATCH); + }); + + test("does not rewrite decorated delimiter text inside patch-file content", () => { + const body = `*** Begin Patch +*** Update File: docs.md +@@ +-old ++A patch starts with *** Begin Patch *** if you add extra stars. ++Do not rewrite *** End Patch *** in file content. +*** End Patch`; + expect(repairFreeformToolInput(body, "apply_patch")).toBe(body); + expect(normalizeApplyPatchDelimiters(body)).toBe(body); + }); + + test("leaves incomplete, prefixed, suffixed, and non-operation envelopes alone", () => { + const cases = [ + "*** Begin Patch ***", + `prefix\n${DECORATED_PATCH}`, + `${DECORATED_PATCH}\nsuffix`, + "*** Begin Patch ***\nplain text\n*** End Patch ***", + ]; + for (const source of cases) { + expect(repairFreeformToolInput(source, "apply_patch")).toBe(source); + } + }); + + test("repairs one decorated outer line without touching an already canonical peer", () => { + const decoratedBegin = CANONICAL_PATCH.replace("*** Begin Patch", "*** Begin Patch ***"); + const decoratedEnd = CANONICAL_PATCH.replace("*** End Patch", "*** End Patch ***"); + expect(repairFreeformToolInput(decoratedBegin, "apply_patch")).toBe(CANONICAL_PATCH); + expect(repairFreeformToolInput(decoratedEnd, "apply_patch")).toBe(CANONICAL_PATCH); + }); + + test("unwraps other freeform tools without changing their body", () => { + const body = "*** Begin Patch ***"; + expect(repairFreeformToolInput(JSON.stringify({ input: body }), "render_diagram")).toBe(body); + expect(repairFreeformToolInput(body, "")).toBe(body); + }); +}); diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 22a3324580..a27aa61027 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -676,6 +676,27 @@ describe("Responses bridge reasoning and usage parity", () => { expect(frames.some(f => f.event === "response.function_call_arguments.done")).toBe(false); }); + test("repairs a complete decorated top-level apply_patch payload", () => { + const body = `*** Begin Patch *** +*** Update File: README.md +@@ +-old ++new +*** End Patch ***`; + const json = buildResponseJSON([ + { type: "tool_call_start", id: "c1", name: "apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: body }) }, + { type: "tool_call_end" }, + { type: "done" }, + ], "model", { freeformToolNames: new Set(["apply_patch"]) }); + + const output = json.output as Record[]; + expect(output[0]).toMatchObject({ type: "custom_tool_call", name: "apply_patch" }); + expect(output[0].input).toContain("*** Begin Patch\n"); + expect(output[0].input).toContain("*** End Patch"); + expect(output[0].input).not.toContain("*** Begin Patch ***"); + }); + test("non-streaming error produces failed status", () => { const json = buildResponseJSON([ { diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index d04535581d..8ee402004d 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -32,6 +32,7 @@ describe("routed custom-tool compatibility", () => { expect(rewritten.body).toBe(raw); expect(JSON.stringify(rewritten.body)).toBe(before); expect(rewritten.names).toEqual(new Set()); + expect(rewritten.repairNames).toEqual(new Set(["apply_patch"])); }); test("lowers apply_patch declarations and replay items on an explicit capability denial", () => { @@ -47,6 +48,7 @@ describe("routed custom-tool compatibility", () => { const body = rewritten.body as typeof raw; expect(rewritten.names).toEqual(new Set(["apply_patch"])); + expect(rewritten.repairNames).toEqual(new Set()); expect(body.tools[0]).toMatchObject({ type: "function", name: "apply_patch", @@ -73,6 +75,7 @@ describe("routed custom-tool compatibility", () => { expect(body.tools[0]).toMatchObject({ type: "function", name: "review_patch" }); expect(rewritten.names).toEqual(new Set(["review_patch"])); + expect(rewritten.repairNames).toEqual(new Set()); }); test("converted exec preserves the JavaScript input contract", () => { diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 923d52af44..3b997f2373 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -19,6 +19,9 @@ function frame(event: string, payload: Record): string { return `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; } +const DECORATED_PATCH = "*** Begin Patch ***\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch ***"; +const CANONICAL_PATCH = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; + describe("routed Responses custom-tool compatibility", () => { test("rewrites exec definitions and paired history without touching apply_patch", () => { const raw = { @@ -39,6 +42,7 @@ describe("routed Responses custom-tool compatibility", () => { expect(collectRoutedCustomToolNames(raw)).toEqual(new Set(["exec"])); const rewritten = rewriteRoutedCustomToolsForUpstream(raw); expect(rewritten.names).toEqual(new Set(["exec"])); + expect(rewritten.repairNames).toEqual(new Set(["apply_patch"])); expect(rewritten.body).not.toBe(raw); expect(raw.tools[0]?.type).toBe("custom"); @@ -88,6 +92,87 @@ describe("routed Responses custom-tool compatibility", () => { expect(restored.output[1]).toMatchObject({ type: "function_call", name: "ordinary", arguments: "{}" }); }); + test("repairs an authorized native apply_patch custom call without changing its type", () => { + const upstream = JSON.stringify({ + id: "resp_patch", + output: [{ + type: "custom_tool_call", + id: "ctc_patch", + call_id: "call_patch", + name: "apply_patch", + input: DECORATED_PATCH, + status: "completed", + }], + }); + + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + upstream, + new Set(), + new Set(["apply_patch"]), + )) as { output: Array> }; + expect(restored.output[0]).toMatchObject({ + type: "custom_tool_call", + id: "ctc_patch", + name: "apply_patch", + input: CANONICAL_PATCH, + }); + + const unnamed = JSON.stringify({ + output: [{ type: "custom_tool_call", input: DECORATED_PATCH }], + }); + expect(restoreRoutedCustomCallsInJson( + unnamed, + new Set(), + new Set(["apply_patch"]), + )).toBe(unnamed); + + expect(restoreRoutedCustomCallsInJson(upstream, new Set())).toBe(upstream); + }); + + test("repairs native apply_patch item and input-done events in an SSE lifecycle", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(), + undefined, + new Set(["apply_patch"]), + ); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_patch", + call_id: "call_patch", + name: "apply_patch", + input: "", + status: "in_progress", + }, + })); + expect(dataPayload(added[0]!).item).toMatchObject({ type: "custom_tool_call", name: "apply_patch" }); + + const inputDone = rewrite(frame("response.custom_tool_call_input.done", { + output_index: 0, + item_id: "ctc_patch", + input: DECORATED_PATCH, + })); + expect(dataPayload(inputDone[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + input: CANONICAL_PATCH, + }); + + const itemDone = rewrite(frame("response.output_item.done", { + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_patch", + call_id: "call_patch", + name: "apply_patch", + input: DECORATED_PATCH, + status: "completed", + }, + })); + expect(dataPayload(itemDone[0]!).item).toMatchObject({ input: CANONICAL_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", { @@ -1100,6 +1185,99 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses repairs authorized native apply_patch calls in JSON and SSE", async () => { + const savedFetch = globalThis.fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const upstreamItem = { + type: "custom_tool_call", + id: "ctc_patch", + call_id: "call_patch", + name: "apply_patch", + input: DECORATED_PATCH, + status: "completed", + }; + + globalThis.fetch = (async (_input, init) => { + const outbound = JSON.parse(String(init?.body)) as { stream?: boolean }; + if (outbound.stream === true) { + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, input: "", status: "in_progress" }, + }), + frame("response.custom_tool_call_input.done", { + output_index: 0, + item_id: "ctc_patch", + input: DECORATED_PATCH, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch_stream", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ id: "resp_patch_json", status: "completed", output: [upstreamItem] }); + }) as typeof fetch; + + try { + for (const stream of [false, true]) { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream, + input: [{ role: "user", content: [{ type: "input_text", text: "patch" }] }], + tools: [{ + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + + if (!stream) { + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ + type: "custom_tool_call", + name: "apply_patch", + input: CANONICAL_PATCH, + }); + continue; + } + + const blocks = (await response.text()).split("\n\n").filter(block => block.includes("data: {")); + const payloads = blocks.map(dataPayload); + const inputDone = payloads.find(payload => payload.type === "response.custom_tool_call_input.done"); + expect(inputDone).toMatchObject({ input: CANONICAL_PATCH }); + const itemDone = payloads.find(payload => payload.type === "response.output_item.done") as { + item?: Record; + } | undefined; + expect(itemDone?.item).toMatchObject({ type: "custom_tool_call", input: CANONICAL_PATCH }); + const completed = payloads.find(payload => payload.type === "response.completed") as { + response?: { output?: Array> }; + } | undefined; + expect(completed?.response?.output?.[0]).toMatchObject({ input: CANONICAL_PATCH }); + } + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses does not restore a custom image tool replaced by hosted preference", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; From e56471c3a1d0c369ce494877d100084797e0278f Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:59:35 +0900 Subject: [PATCH 2/7] fix(responses): honor tool choice during patch repair --- src/responses/custom-tool-compat.ts | 34 +++++++ src/server/responses/core.ts | 5 +- tests/custom-tool-compat.test.ts | 36 +++++++ tests/responses-custom-tool-repair.test.ts | 104 +++++++++++++++++++++ 4 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 44feff3694..bba225036a 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -20,6 +20,37 @@ function customToolWireName(namespace: string | undefined, name: string): string return namespace === BUILTIN_FUNCTIONS_NAMESPACE ? name : namespacedToolName(namespace, name); } +function toolChoiceAllowsRoutedCustomTool( + body: unknown, + wireName: string, + candidateNames: ReadonlySet, +): boolean { + if (!isPlainObject(body)) return true; + const choice = body.tool_choice; + if (choice === undefined || choice === null || choice === "auto" || choice === "required") { + return true; + } + if (choice === "none") return false; + if (!isPlainObject(choice)) return true; + + const selectorAllows = (selector: unknown): boolean => { + if (!isPlainObject(selector) || typeof selector.name !== "string") return false; + if (typeof selector.namespace === "string") { + return customToolWireName(selector.namespace, selector.name) === wireName; + } + if (selector.name === wireName) return true; + const suffix = `__${selector.name}`; + const candidates = [...candidateNames].filter(name => name.endsWith(suffix)); + return candidates.length === 1 && candidates[0] === wireName; + }; + + if (choice.type === "function" || choice.type === "custom") return selectorAllows(choice); + if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + return choice.tools.some(selectorAllows); + } + return false; +} + /** Final upstream identity of a call, including a namespace restored by an earlier rewrite. */ export function routedCustomToolWireName(value: unknown): string | undefined { if (!isPlainObject(value) || typeof value.name !== "string") return undefined; @@ -200,6 +231,9 @@ export function rewriteRoutedCustomToolsForUpstream( const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); const repairNames = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools, true); + for (const name of repairNames) { + if (!toolChoiceAllowsRoutedCustomTool(body, name, repairNames)) repairNames.delete(name); + } if (conversionNames.size === 0) return { body, names, repairNames }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 73c6a52673..2db836f7fc 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2893,7 +2893,10 @@ async function handleResponsesInner( ) routedCustomToolNames.add(name); } for (const name of request.routedCustomToolRepairNames ?? []) { - routedCustomToolRepairNames.add(name); + if ( + toolBridgeMaps.freeformToolNames.has(name) + || toolBridgeMaps.toolNsMap.get(name)?.freeform === true + ) routedCustomToolRepairNames.add(name); } } for (const name of request.convertedRoutedToolSearchNames ?? []) { diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 8ee402004d..763d4a5273 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -35,6 +35,42 @@ describe("routed custom-tool compatibility", () => { expect(rewritten.repairNames).toEqual(new Set(["apply_patch"])); }); + test.each([ + ["none", "none"], + ["a forced other tool", { type: "function", name: "ordinary" }], + ["an allowlist exclusion", { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "ordinary" }], + }], + ] as const)("does not arm apply_patch repair under %s", (_label, toolChoice) => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [ + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }, + { type: "function", name: "ordinary", parameters: { type: "object" } }, + ], + tool_choice: toolChoice, + }); + + expect(rewritten.repairNames).toEqual(new Set()); + }); + + test.each([ + { type: "custom", name: "apply_patch" }, + { + type: "allowed_tools", + mode: "required", + tools: [{ type: "custom", name: "apply_patch" }], + }, + ] as const)("arms apply_patch repair when the selector authorizes it", toolChoice => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], + tool_choice: toolChoice, + }); + + expect(rewritten.repairNames).toEqual(new Set(["apply_patch"])); + }); + test("lowers apply_patch declarations and replay items on an explicit capability denial", () => { const raw = { tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "text" } }], diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 3b997f2373..2c358ed697 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -1185,6 +1185,110 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses preserves disallowed native apply_patch input in JSON and SSE", async () => { + const savedFetch = globalThis.fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const upstreamItem = { + type: "custom_tool_call", + id: "ctc_patch", + call_id: "call_patch", + name: "apply_patch", + input: DECORATED_PATCH, + status: "completed", + }; + + globalThis.fetch = (async (_input, init) => { + const outbound = JSON.parse(String(init?.body)) as { stream?: boolean }; + if (outbound.stream === true) { + const upstream = [ + frame("response.output_item.added", { + output_index: 0, + item: { ...upstreamItem, input: "", status: "in_progress" }, + }), + frame("response.custom_tool_call_input.done", { + output_index: 0, + item_id: "ctc_patch", + input: DECORATED_PATCH, + }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { + response: { id: "resp_patch_stream", status: "completed", output: [upstreamItem] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ id: "resp_patch_json", status: "completed", output: [upstreamItem] }); + }) as typeof fetch; + + try { + for (const stream of [false, true]) { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream, + input: [{ role: "user", content: [{ type: "input_text", text: "patch" }] }], + tools: [ + { + type: "custom", + name: "apply_patch", + description: "Apply a patch", + format: { type: "grammar", syntax: "lark" }, + }, + { + type: "function", + name: "ordinary", + description: "Ordinary function", + parameters: { type: "object" }, + }, + ], + tool_choice: stream + ? { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "ordinary" }], + } + : { type: "function", name: "ordinary" }, + }), + }), config, { model: "", provider: "" }); + + if (!stream) { + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toEqual(upstreamItem); + continue; + } + + const blocks = (await response.text()).split("\n\n").filter(block => block.includes("data: {")); + const payloads = blocks.map(dataPayload); + const inputDone = payloads.find(payload => payload.type === "response.custom_tool_call_input.done"); + expect(inputDone).toMatchObject({ input: DECORATED_PATCH }); + const itemDone = payloads.find(payload => payload.type === "response.output_item.done") as { + item?: Record; + } | undefined; + expect(itemDone?.item).toMatchObject({ type: "custom_tool_call", input: DECORATED_PATCH }); + const completed = payloads.find(payload => payload.type === "response.completed") as { + response?: { output?: Array> }; + } | undefined; + expect(completed?.response?.output?.[0]).toMatchObject({ input: DECORATED_PATCH }); + } + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses repairs authorized native apply_patch calls in JSON and SSE", async () => { const savedFetch = globalThis.fetch; const config = { From 14b6e43779cc5b772be0c129e8434fbb9d058fb4 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:10:25 +0900 Subject: [PATCH 3/7] fix(responses): scope custom repair to authorized items --- src/responses/custom-tool-compat.ts | 89 +++++++++++++++------- tests/custom-tool-compat.test.ts | 6 ++ tests/responses-custom-tool-repair.test.ts | 34 ++++++++- 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index bba225036a..91d6243a4e 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -35,6 +35,7 @@ function toolChoiceAllowsRoutedCustomTool( const selectorAllows = (selector: unknown): boolean => { if (!isPlainObject(selector) || typeof selector.name !== "string") return false; + if (selector.type !== "custom") return false; if (typeof selector.namespace === "string") { return customToolWireName(selector.namespace, selector.name) === wireName; } @@ -245,51 +246,81 @@ export function restoreRoutedCustomCalls( names: ReadonlySet, repairNames: ReadonlySet = new Set(), ): { value: unknown; changed: boolean } { - if (Array.isArray(value)) { + if (!isPlainObject(value)) return { value, changed: false }; + + const restoreItem = (item: unknown): { value: unknown; changed: boolean } => { + if (!isPlainObject(item)) return { value: item, changed: false }; + const wireName = routedCustomToolWireName(item); + if ( + item.type === "function_call" + && typeof item.name === "string" + && wireName !== undefined + && names.has(wireName) + ) { + const restored: Record = { + ...item, + type: "custom_tool_call", + id: customToolItemId(item.id), + input: repairFreeformToolInput(item.arguments, item.name), + }; + delete restored.arguments; + return { value: restored, changed: true }; + } + if ( + item.type === "custom_tool_call" + && typeof item.name === "string" + && wireName !== undefined + && repairNames.has(wireName) + && typeof item.input === "string" + ) { + const input = repairFreeformToolInput(item.input, item.name); + if (input !== item.input) return { value: { ...item, input }, changed: true }; + } + return { value: item, changed: false }; + }; + + const restoreOutput = (output: unknown): { value: unknown; changed: boolean } => { + if (!Array.isArray(output)) return { value: output, changed: false }; let changed = false; - const restored = value.map(entry => { - const result = restoreRoutedCustomCalls(entry, names, repairNames); + const restored = output.map(item => { + const result = restoreItem(item); changed ||= result.changed; return result.value; }); - return changed ? { value: restored, changed: true } : { value, changed: false }; - } - if (!isPlainObject(value)) return { value, changed: false }; + return changed ? { value: restored, changed: true } : { value: output, changed: false }; + }; let changed = false; - const restored: Record = {}; - for (const [key, entry] of Object.entries(value)) { - const result = restoreRoutedCustomCalls(entry, names, repairNames); - restored[key] = result.value; - changed ||= result.changed; + const restored: Record = { ...value }; + const output = restoreOutput(value.output); + if (output.changed) { + restored.output = output.value; + changed = true; } - const wireName = routedCustomToolWireName(value); if ( - value.type === "function_call" - && typeof value.name === "string" - && wireName !== undefined - && names.has(wireName) + (value.type === "response.output_item.added" || value.type === "response.output_item.done") + && isPlainObject(value.item) ) { - restored.type = "custom_tool_call"; - restored.id = customToolItemId(value.id); - restored.input = repairFreeformToolInput(value.arguments, value.name); - delete restored.arguments; - changed = true; + const item = restoreItem(value.item); + if (item.changed) { + restored.item = item.value; + changed = true; + } } + if ( - value.type === "custom_tool_call" - && typeof value.name === "string" - && wireName !== undefined - && repairNames.has(wireName) - && typeof value.input === "string" + typeof value.type === "string" + && value.type.startsWith("response.") + && isPlainObject(value.response) ) { - const input = repairFreeformToolInput(value.input, value.name); - if (input !== value.input) { - restored.input = input; + const response = restoreRoutedCustomCalls(value.response, names, repairNames); + if (response.changed) { + restored.response = response.value; changed = true; } } + return changed ? { value: restored, changed: true } : { value, changed: false }; } diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 763d4a5273..062a5ad1d2 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -38,11 +38,17 @@ describe("routed custom-tool compatibility", () => { test.each([ ["none", "none"], ["a forced other tool", { type: "function", name: "ordinary" }], + ["a same-name function selector", { type: "function", name: "apply_patch" }], ["an allowlist exclusion", { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "ordinary" }], }], + ["a same-name function allowlist", { + type: "allowed_tools", + mode: "required", + tools: [{ type: "function", name: "apply_patch" }], + }], ] as const)("does not arm apply_patch repair under %s", (_label, toolChoice) => { const rewritten = rewriteRoutedCustomToolsForUpstream({ tools: [ diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 2c358ed697..9b7f6cb65f 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -126,6 +126,23 @@ describe("routed Responses custom-tool compatibility", () => { new Set(["apply_patch"]), )).toBe(unnamed); + const metadataOnly = JSON.stringify({ + id: "resp_metadata", + output: [], + metadata: { + shadow: { + type: "custom_tool_call", + name: "apply_patch", + input: DECORATED_PATCH, + }, + }, + }); + expect(restoreRoutedCustomCallsInJson( + metadataOnly, + new Set(), + new Set(["apply_patch"]), + )).toBe(metadataOnly); + expect(restoreRoutedCustomCallsInJson(upstream, new Set())).toBe(upstream); }); @@ -160,6 +177,13 @@ describe("routed Responses custom-tool compatibility", () => { const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, + metadata: { + shadow: { + type: "custom_tool_call", + name: "apply_patch", + input: DECORATED_PATCH, + }, + }, item: { type: "custom_tool_call", id: "ctc_patch", @@ -169,7 +193,15 @@ describe("routed Responses custom-tool compatibility", () => { status: "completed", }, })); - expect(dataPayload(itemDone[0]!).item).toMatchObject({ input: CANONICAL_PATCH }); + const itemDonePayload = dataPayload(itemDone[0]!); + expect(itemDonePayload.item).toMatchObject({ input: CANONICAL_PATCH }); + expect(itemDonePayload.metadata).toEqual({ + shadow: { + type: "custom_tool_call", + name: "apply_patch", + input: DECORATED_PATCH, + }, + }); rewrite.dispose?.(); }); From 647a7ab7e5875e2480ba2d24d303174689077a6b Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:36:01 +0900 Subject: [PATCH 4/7] fix(responses): preserve native custom wrappers --- src/responses/custom-tool-compat.ts | 8 ++++-- src/server/responses-custom-tool-repair.ts | 6 ++-- tests/responses-custom-tool-repair.test.ts | 32 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 91d6243a4e..8c13753e78 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -1,5 +1,9 @@ import { namespacedToolName } from "../types"; -import { repairFreeformToolInput, unwrapFreeformToolInput } from "./apply-patch-envelope"; +import { + normalizeApplyPatchDelimiters, + repairFreeformToolInput, + unwrapFreeformToolInput, +} from "./apply-patch-envelope"; import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); @@ -273,7 +277,7 @@ export function restoreRoutedCustomCalls( && repairNames.has(wireName) && typeof item.input === "string" ) { - const input = repairFreeformToolInput(item.input, item.name); + const input = normalizeApplyPatchDelimiters(item.input); if (input !== item.input) return { value: { ...item, input }, changed: true }; } return { value: item, changed: false }; diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index bbc4e2ed92..3f4b620824 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -1,4 +1,5 @@ import type { TranslatorBudget } from "../lib/translator-budget"; +import { normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope"; import { customToolItemId, restoreRoutedCustomCalls, @@ -241,10 +242,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( && repairItemNames.has(upstreamItemId) && typeof parsed.input === "string" ) { - const input = unwrapRoutedCustomToolArguments( - parsed.input, - repairItemNames.get(upstreamItemId) ?? "", - ); + const input = normalizeApplyPatchDelimiters(parsed.input); if (input !== parsed.input) { return [replaceSseDataPayload(block, JSON.stringify({ ...parsed, input }))]; } diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 9b7f6cb65f..497358a45e 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -21,6 +21,7 @@ function frame(event: string, payload: Record): string { const DECORATED_PATCH = "*** Begin Patch ***\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch ***"; const CANONICAL_PATCH = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** End Patch"; +const WRAPPED_DECORATED_PATCH = JSON.stringify({ input: DECORATED_PATCH }); describe("routed Responses custom-tool compatibility", () => { test("rewrites exec definitions and paired history without touching apply_patch", () => { @@ -143,6 +144,21 @@ describe("routed Responses custom-tool compatibility", () => { new Set(["apply_patch"]), )).toBe(metadataOnly); + const wrappedNative = JSON.stringify({ + id: "resp_wrapped_patch", + output: [{ + type: "custom_tool_call", + id: "ctc_wrapped_patch", + name: "apply_patch", + input: WRAPPED_DECORATED_PATCH, + }], + }); + expect(restoreRoutedCustomCallsInJson( + wrappedNative, + new Set(), + new Set(["apply_patch"]), + )).toBe(wrappedNative); + expect(restoreRoutedCustomCallsInJson(upstream, new Set())).toBe(upstream); }); @@ -175,6 +191,22 @@ describe("routed Responses custom-tool compatibility", () => { input: CANONICAL_PATCH, }); + rewrite(frame("response.output_item.added", { + output_index: 1, + item: { + type: "custom_tool_call", + id: "ctc_wrapped_patch", + name: "apply_patch", + input: "", + }, + })); + const wrappedInputDone = rewrite(frame("response.custom_tool_call_input.done", { + output_index: 1, + item_id: "ctc_wrapped_patch", + input: WRAPPED_DECORATED_PATCH, + })); + expect(dataPayload(wrappedInputDone[0]!)).toMatchObject({ input: WRAPPED_DECORATED_PATCH }); + const itemDone = rewrite(frame("response.output_item.done", { output_index: 0, metadata: { From 19befba3260f6bbf33a28b752ba84d47f1a7c932 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:31:55 +0900 Subject: [PATCH 5/7] fix: scope apply patch response repair --- src/responses/custom-tool-compat.ts | 1 + tests/custom-tool-compat.test.ts | 19 ++++++++++++ tests/responses-custom-tool-repair.test.ts | 36 ++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 8c13753e78..9b1f313b8e 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -107,6 +107,7 @@ function collectRoutedCustomToolWireNames( && child.type === "custom" && typeof child.name === "string" && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) === passthrough + && (!passthrough || tool.name === BUILTIN_FUNCTIONS_NAMESPACE) && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); } diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 062a5ad1d2..3d581f6e7d 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -35,6 +35,25 @@ describe("routed custom-tool compatibility", () => { expect(rewritten.repairNames).toEqual(new Set(["apply_patch"])); }); + test("repairs apply_patch only when bare or in the reserved functions namespace", () => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [ + { + type: "namespace", + name: "mcp", + tools: [{ type: "custom", name: "apply_patch", description: "Remote patch grammar" }], + }, + { + type: "namespace", + name: "functions", + tools: [{ type: "custom", name: "apply_patch", description: "Built-in patch grammar" }], + }, + ], + }); + + expect(rewritten.repairNames).toEqual(new Set(["apply_patch"])); + }); + test.each([ ["none", "none"], ["a forced other tool", { type: "function", name: "ordinary" }], diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 497358a45e..0d4317be4c 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -162,6 +162,42 @@ describe("routed Responses custom-tool compatibility", () => { expect(restoreRoutedCustomCallsInJson(upstream, new Set())).toBe(upstream); }); + test("preserves decorated delimiters for a non-functions namespaced apply_patch tool", () => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ + type: "namespace", + name: "mcp", + tools: [{ type: "custom", name: "apply_patch", description: "Remote patch grammar" }], + }], + }); + expect(rewritten.repairNames).toEqual(new Set()); + + const item = { + type: "custom_tool_call", + id: "ctc_remote_patch", + call_id: "call_remote_patch", + namespace: "mcp", + name: "apply_patch", + input: DECORATED_PATCH, + status: "completed", + }; + const upstream = JSON.stringify({ id: "resp_remote_patch", output: [item] }); + expect(restoreRoutedCustomCallsInJson( + upstream, + rewritten.names, + rewritten.repairNames, + )).toBe(upstream); + + const block = frame("response.output_item.done", { output_index: 0, item }); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + rewritten.names, + undefined, + rewritten.repairNames, + ); + expect(rewrite(block)).toEqual([block]); + rewrite.dispose?.(); + }); + test("repairs native apply_patch item and input-done events in an SSE lifecycle", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite( new Set(), From 1acf73439ba2aaa6d336f86d845727709fad6bd8 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:14:23 +0900 Subject: [PATCH 6/7] fix: preserve namespaced patch payloads --- src/bridge.ts | 16 ++++-- src/responses/apply-patch-envelope.ts | 16 ++++-- src/responses/custom-tool-compat.ts | 16 +++++- src/server/responses-custom-tool-repair.ts | 10 +++- tests/apply-patch-envelope.test.ts | 6 ++ tests/bridge.test.ts | 28 +++++++++ tests/responses-custom-tool-repair.test.ts | 66 ++++++++++++++++++++++ 7 files changed, 142 insertions(+), 16 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index 44ea550646..e38b9d65ad 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -234,7 +234,9 @@ export function bridgeToResponsesSSE( // Freeform/custom tools (apply_patch, code-mode exec) carry their body in `input`; the // model is given a function with `{input:string}`, so unwrap it here when relaying back // as a custom_tool_call. Decorated apply_patch envelopes are repaired at this boundary. - const freeformInput = (args: string, toolName: string): string => repairFreeformToolInput(args, toolName); + const freeformInput = (args: string, toolName: string, namespace?: string): string => ( + repairFreeformToolInput(args, toolName, namespace) + ); // Best-effort unwrap of a PARTIAL freeform arg buffer for live input streaming // (`response.custom_tool_call_input.delta` — codex-rs uses it for UI preview only; // the completed custom_tool_call item stays authoritative). Compact `{"input":"...` @@ -635,7 +637,7 @@ export function bridgeToResponsesSSE( if (currentToolCall.freeform) { emit("response.custom_tool_call_input.done", { item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, - input: freeformInput(currentToolCall.args, currentToolCall.name), + input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace), }); } // Freeform tools serialize as custom_tool_call without extra_content; remember the @@ -651,7 +653,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: currentToolCall.itemId, call_id: currentToolCall.callId, name: currentToolCall.name, - input: freeformInput(currentToolCall.args, currentToolCall.name), status: "completed", + input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace), status: "completed", } : { type: "function_call", id: currentToolCall.itemId, @@ -690,7 +692,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: currentToolCall.itemId, call_id: currentToolCall.callId, name: currentToolCall.name, - input: freeformInput(currentToolCall.args, currentToolCall.name), status: "incomplete", + input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace), status: "incomplete", } : { type: "function_call", id: currentToolCall.itemId, @@ -1566,7 +1568,9 @@ function buildResponseJSONWithBudget( // Web-search citations awaiting the next assistant message (attached as url_citation annotations). let pendingWebSources: { url: string; title?: string }[] = []; - const freeformInput = (args: string, toolName: string): string => repairFreeformToolInput(args, toolName); + const freeformInput = (args: string, toolName: string, namespace?: string): string => ( + repairFreeformToolInput(args, toolName, namespace) + ); const parseArgsObj = (args: string): Record => { try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; } }; @@ -1666,7 +1670,7 @@ function buildResponseJSONWithBudget( pushOutput({ type: "custom_tool_call", id: `ctc_${uuid()}`, call_id: currentToolCallId, name: realName, - input: freeformInput(currentToolCallArgs, realName), status, + input: freeformInput(currentToolCallArgs, realName, ns), status, }); } else { pushOutput({ diff --git a/src/responses/apply-patch-envelope.ts b/src/responses/apply-patch-envelope.ts index 20de7ba4aa..465f9a6e16 100644 --- a/src/responses/apply-patch-envelope.ts +++ b/src/responses/apply-patch-envelope.ts @@ -46,10 +46,18 @@ export function normalizeApplyPatchDelimiters(text: string): string { /** * Repair freeform input before Codex sees it. * - * Only a top-level `apply_patch` payload may receive delimiter repair. `exec` - * JavaScript and every other freeform body are unwrapped and left byte-exact. + * Only a bare or reserved-`functions` `apply_patch` payload may receive delimiter + * repair. Remote namespaces own their grammar; those bodies and every other + * freeform input are unwrapped and left byte-exact. */ -export function repairFreeformToolInput(argumentsText: unknown, toolName = ""): string { +export function repairFreeformToolInput( + argumentsText: unknown, + toolName = "", + namespace?: string, +): string { const unwrapped = unwrapFreeformToolInput(argumentsText); - return toolName === "apply_patch" ? normalizeApplyPatchDelimiters(unwrapped) : unwrapped; + const ownsApplyPatchGrammar = namespace === undefined || namespace === "functions"; + return ownsApplyPatchGrammar && toolName === "apply_patch" + ? normalizeApplyPatchDelimiters(unwrapped) + : unwrapped; } diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 9b1f313b8e..10711a354b 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -266,7 +266,11 @@ export function restoreRoutedCustomCalls( ...item, type: "custom_tool_call", id: customToolItemId(item.id), - input: repairFreeformToolInput(item.arguments, item.name), + input: repairFreeformToolInput( + item.arguments, + item.name, + typeof item.namespace === "string" ? item.namespace : undefined, + ), }; delete restored.arguments; return { value: restored, changed: true }; @@ -345,6 +349,12 @@ export function restoreRoutedCustomCallsInJson( return restored.changed ? JSON.stringify(restored.value) : text; } -export function unwrapRoutedCustomToolArguments(argumentsText: unknown, toolName = ""): string { - return toolName ? repairFreeformToolInput(argumentsText, toolName) : unwrapFreeformToolInput(argumentsText); +export function unwrapRoutedCustomToolArguments( + argumentsText: unknown, + toolName = "", + namespace?: string, +): string { + return toolName + ? repairFreeformToolInput(argumentsText, toolName, namespace) + : unwrapFreeformToolInput(argumentsText); } diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 3f4b620824..0b5888d0b4 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -88,7 +88,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( budget?: TranslatorBudget, repairNames: ReadonlySet = new Set(), ): SseBlockRewrite { - const itemNames = new Map(); + const itemNames = new Map(); const repairItemNames = new Map(); const ordinaryItemIds = new Set(); const openCalls = new Map(); @@ -207,7 +207,10 @@ export function createRoutedCustomToolRestoreBlockRewrite( const routed = wireName !== undefined && names.has(wireName); if (upstreamItemId) { if (routed) { - itemNames.set(upstreamItemId, parsed.item.name); + itemNames.set(upstreamItemId, { + name: parsed.item.name, + ...(typeof parsed.item.namespace === "string" ? { namespace: parsed.item.namespace } : {}), + }); ordinaryItemIds.delete(upstreamItemId); } else { ordinaryItemIds.add(upstreamItemId); @@ -293,11 +296,12 @@ export function createRoutedCustomToolRestoreBlockRewrite( ? parsed.arguments : openCalls.get(upstreamItemId)?.argumentsText ?? ""; const { arguments: _arguments, ...rest } = parsed; + const itemName = itemNames.get(upstreamItemId); const next = { ...rest, type: nextType, item_id: customToolItemId(upstreamItemId), - input: unwrapRoutedCustomToolArguments(source, itemNames.get(upstreamItemId) ?? ""), + input: unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace), }; return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; } diff --git a/tests/apply-patch-envelope.test.ts b/tests/apply-patch-envelope.test.ts index 2c420cd918..646cce3449 100644 --- a/tests/apply-patch-envelope.test.ts +++ b/tests/apply-patch-envelope.test.ts @@ -34,6 +34,12 @@ describe("apply_patch envelope repair", () => { expect(repairFreeformToolInput(JSON.stringify({ input: DECORATED_PATCH }), "apply_patch")).toBe(CANONICAL_PATCH); }); + test("repairs only bare and reserved-functions apply_patch grammars", () => { + const wrapped = JSON.stringify({ input: DECORATED_PATCH }); + expect(repairFreeformToolInput(wrapped, "apply_patch", "functions")).toBe(CANONICAL_PATCH); + expect(repairFreeformToolInput(wrapped, "apply_patch", "mcp")).toBe(DECORATED_PATCH); + }); + test("keeps exec JavaScript strings, comments, templates, and regexes byte-identical", () => { const cases = [ 'const sample = "tools.apply_patch({ input: patchText })";', diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index a27aa61027..1862c78c44 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -697,6 +697,34 @@ describe("Responses bridge reasoning and usage parity", () => { expect(output[0].input).not.toContain("*** Begin Patch ***"); }); + test("preserves namespaced apply_patch payloads across streaming and buffered bridges", async () => { + const decorated = `*** Begin Patch *** +*** Update File: README.md +@@ +-old ++new +*** End Patch ***`; + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "c1", name: "mcp__apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: decorated }) }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + const toolNsMap = new Map([ + ["mcp__apply_patch", { namespace: "mcp", name: "apply_patch", freeform: true as const }], + ]); + + const json = buildResponseJSON(events, "model", { toolNsMap }); + const output = json.output as Record[]; + expect(output[0]).toMatchObject({ type: "custom_tool_call", name: "apply_patch", input: decorated }); + + const frames = await collectSse(bridgeToResponsesSSE(replay(events), "model", toolNsMap)); + const inputDone = frames.find(frame => frame.event === "response.custom_tool_call_input.done")?.data; + expect(inputDone?.input).toBe(decorated); + const itemDone = frames.find(frame => frame.event === "response.output_item.done")?.data.item as Record; + expect(itemDone).toMatchObject({ type: "custom_tool_call", name: "apply_patch", input: decorated }); + }); + test("non-streaming error produces failed status", () => { const json = buildResponseJSON([ { diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 0d4317be4c..c1bfed72e3 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -198,6 +198,72 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("preserves a converted non-functions namespaced apply_patch payload", () => { + const rewritten = rewriteRoutedCustomToolsForUpstream({ + tools: [{ + type: "namespace", + name: "mcp", + tools: [{ type: "custom", name: "apply_patch", description: "Remote patch grammar" }], + }], + }, false); + expect(rewritten.names).toEqual(new Set(["mcp__apply_patch"])); + + const upstream = JSON.stringify({ + id: "resp_remote_patch", + output: [{ + type: "function_call", + id: "fc_remote_patch", + call_id: "call_remote_patch", + namespace: "mcp", + name: "apply_patch", + arguments: WRAPPED_DECORATED_PATCH, + status: "completed", + }], + }); + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + upstream, + rewritten.names, + rewritten.repairNames, + )) as { output: Record[] }; + expect(restored.output[0]).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + input: DECORATED_PATCH, + }); + + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + rewritten.names, + undefined, + rewritten.repairNames, + ); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_remote_patch", + call_id: "call_remote_patch", + namespace: "mcp", + name: "apply_patch", + arguments: "", + status: "in_progress", + }, + })); + expect(dataPayload(added[0]!).item).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + input: "", + }); + const inputDone = rewrite(frame("response.function_call_arguments.done", { + item_id: "fc_remote_patch", + output_index: 0, + arguments: WRAPPED_DECORATED_PATCH, + })); + expect(dataPayload(inputDone[0]!).input).toBe(DECORATED_PATCH); + rewrite.dispose?.(); + }); + test("repairs native apply_patch item and input-done events in an SSE lifecycle", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite( new Set(), From 93b977d3b7a6941211dc926588279f8f68c60952 Mon Sep 17 00:00:00 2001 From: goodwilliam0126 <211597002+goodwilliam0126@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:03:29 +0900 Subject: [PATCH 7/7] fix(bridge): preserve custom tool namespaces --- src/bridge.ts | 6 ++++- tests/bridge.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index e38b9d65ad..dbd6b7d06f 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -637,6 +637,7 @@ export function bridgeToResponsesSSE( if (currentToolCall.freeform) { emit("response.custom_tool_call_input.done", { item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, + ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace), }); } @@ -653,6 +654,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: currentToolCall.itemId, call_id: currentToolCall.callId, name: currentToolCall.name, + ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace), status: "completed", } : { @@ -692,6 +694,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: currentToolCall.itemId, call_id: currentToolCall.callId, name: currentToolCall.name, + ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace), status: "incomplete", } : { @@ -1065,7 +1068,7 @@ export function bridgeToResponsesSSE( const item = toolSearch ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" } : freeform - ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" } + ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, ...(ns ? { namespace: ns } : {}), input: "", status: "in_progress" } : { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) }; emit("response.output_item.added", { output_index: outputIndex, item }); currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, providerMetadata: event.providerMetadata }; @@ -1670,6 +1673,7 @@ function buildResponseJSONWithBudget( pushOutput({ type: "custom_tool_call", id: `ctc_${uuid()}`, call_id: currentToolCallId, name: realName, + ...(ns ? { namespace: ns } : {}), input: freeformInput(currentToolCallArgs, realName, ns), status, }); } else { diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 1862c78c44..798e8bd08c 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -716,13 +716,61 @@ describe("Responses bridge reasoning and usage parity", () => { const json = buildResponseJSON(events, "model", { toolNsMap }); const output = json.output as Record[]; - expect(output[0]).toMatchObject({ type: "custom_tool_call", name: "apply_patch", input: decorated }); + expect(output[0]).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + input: decorated, + }); const frames = await collectSse(bridgeToResponsesSSE(replay(events), "model", toolNsMap)); + const itemAdded = frames.find(frame => frame.event === "response.output_item.added")?.data.item as Record; + expect(itemAdded).toMatchObject({ type: "custom_tool_call", namespace: "mcp", name: "apply_patch" }); const inputDone = frames.find(frame => frame.event === "response.custom_tool_call_input.done")?.data; - expect(inputDone?.input).toBe(decorated); + expect(inputDone).toMatchObject({ namespace: "mcp", input: decorated }); const itemDone = frames.find(frame => frame.event === "response.output_item.done")?.data.item as Record; - expect(itemDone).toMatchObject({ type: "custom_tool_call", name: "apply_patch", input: decorated }); + expect(itemDone).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + input: decorated, + }); + const completed = frames.find(frame => frame.event === "response.completed")?.data.response as Record; + expect((completed.output as Record[])[0]).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + input: decorated, + }); + + const incompleteEvents: AdapterEvent[] = [ + { type: "tool_call_start", id: "c2", name: "mcp__apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: decorated }) }, + { type: "incomplete", reason: "upstream_truncated", retryable: true }, + ]; + const incompleteJson = buildResponseJSON(incompleteEvents, "model", { toolNsMap }); + expect((incompleteJson.output as Record[])[0]).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + status: "incomplete", + }); + + const incompleteFrames = await collectSse(bridgeToResponsesSSE(replay(incompleteEvents), "model", toolNsMap)); + const incompleteItem = incompleteFrames.find(frame => frame.event === "response.output_item.done")?.data.item; + expect(incompleteItem).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + status: "incomplete", + }); + const incompleteResponse = incompleteFrames.find(frame => frame.event === "response.incomplete")?.data.response as Record; + expect((incompleteResponse.output as Record[])[0]).toMatchObject({ + type: "custom_tool_call", + namespace: "mcp", + name: "apply_patch", + status: "incomplete", + }); }); test("non-streaming error produces failed status", () => {