diff --git a/devlog/_plan/260827_bug_pr_merge_round/011_wp2_l1_outcome.md b/devlog/_plan/260827_bug_pr_merge_round/011_wp2_l1_outcome.md new file mode 100644 index 0000000000..552c2f09c4 --- /dev/null +++ b/devlog/_plan/260827_bug_pr_merge_round/011_wp2_l1_outcome.md @@ -0,0 +1,47 @@ +# wp2 — L1 lane outcome + +Landed on `dev` as `2feffbdc3` (PR #2720), which carried four merges: + +| PR | author | preserved as | disposition | +|---|---|---|---| +| #2672 | Ingwannu | `2a9c18dc5` | MERGED, no changes needed | +| #2674 | Ingwannu | `e3b136fb7` | MERGED, no changes needed | +| #2671 | DevonGithub | `17aadf88e` | MERGED + one added test (`4a4df12f2`) | +| #2684 | Michael-Z-Freeman | `2c85dd48d` | MERGED, checklist-only CI failures | + +All four show `MERGED` on GitHub with a comment naming the landed sha. + +## Evidence + +- `bun run test` (full suite, local, session 77096): exit 0. +- PR #2720 CI after one rerun: 23 pass, 1 skipping, 0 fail. +- `bun x tsc --noEmit`: clean on the merged tree. +- Focused: forward-prompt-envelope + posit-continuation 9/9, muse-vision 6/6, + azure-model-router 2/2, repo-hygiene 11/11. + +## The `test 1/4` failure was a flake, and it was checked rather than assumed + +First CI run failed one case: `update stops the running proxy before replacing files > +npm launcher restarts the stopped runtime after a staged update failure`, at 46797ms. +The same case passes locally in 2.6s, this branch touches no update/launcher file +(`git diff --name-only 9b838d062 HEAD | grep -E 'update|launcher'` is empty), and the +test already carries two prior timing-budget repairs (`538a602af`, `34ef53966`). Rerun +of the failed job alone: green. + +That is the standard this round applies — a rerun-to-green is only acceptable when the +causal question was actually asked first. + +## Discovered constraint: `dev` is protected + +A direct `git push origin dev` is rejected: "Changes must be made through a pull +request." So every later lane lands the same way — a `codex/` branch plus a PR, not a +local merge and push. The local merges are still how the work is built and verified; +they simply travel through a PR. + +## Carried forward + +- #2671's probe-evidence question (007, finding 10) is open on a MERGED change. +- #2690 is now guaranteed to conflict with `dev`, since #2684 landed. That was the + intended ordering, not an accident. +- `src/server/responses/core.ts` evidence for #2663/#2638/#2497/#2694 is unaffected by + this lane: none of the four L1 merges touched it. diff --git a/src/bridge.ts b/src/bridge.ts index 1ba5a3db14..dcb163553c 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -18,6 +18,7 @@ import { import { redactSecretString } from "./lib/redact"; import { repairFreeformToolInput } from "./responses/apply-patch-envelope"; import { encodeCompactionSummary } from "./responses/compaction"; +import { compileCodeModeHelperInput } from "./responses/code-mode-helper-compat"; import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; @@ -244,9 +245,14 @@ 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, namespace?: string): string => ( - repairFreeformToolInput(args, toolName, namespace) - ); + const freeformInput = ( + args: string, + toolName: string, + namespace?: string, + codeModeHelperName?: string, + ): string => codeModeHelperName + ? compileCodeModeHelperInput(args, codeModeHelperName) + : 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":"...` @@ -530,7 +536,7 @@ export function bridgeToResponsesSSE( // synthetic compaction item's payload on done. let compactionText = ""; let compactionTextBytes = 0; - let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; + let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; codeModeHelperName?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; // Open native web-search cell (between begin and end). Holds the output index allocated on // begin so the matching done reuses it; closed as `failed` if the stream terminates early. let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null; @@ -649,7 +655,7 @@ export function bridgeToResponsesSSE( 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), + input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace, currentToolCall.codeModeHelperName), }); } // Freeform tools serialize as custom_tool_call without extra_content; remember the @@ -666,7 +672,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", + input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace, currentToolCall.codeModeHelperName), status: "completed", } : { type: "function_call", id: currentToolCall.itemId, @@ -706,7 +712,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", + input: freeformInput(currentToolCall.args, currentToolCall.name, currentToolCall.namespace, currentToolCall.codeModeHelperName), status: "incomplete", } : { type: "function_call", id: currentToolCall.itemId, @@ -1052,6 +1058,9 @@ export function bridgeToResponsesSSE( } if (currentToolCall) closeCurrentToolCall(); const effectiveName = normalizeDeclaredToolName(event.name, options?.declaredToolNames); + const codeModeHelperName = effectiveName === "exec" && event.name !== effectiveName + ? event.name + : undefined; const mapped = toolNsMap?.get(effectiveName); const realName = mapped?.name ?? effectiveName; if (options?.declaredToolNames && !options.declaredToolNames.has(effectiveName)) { @@ -1083,7 +1092,7 @@ export function bridgeToResponsesSSE( ? { 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 }; + currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, codeModeHelperName, providerMetadata: event.providerMetadata }; budget?.openCall(event.id); break; } @@ -1102,7 +1111,7 @@ export function bridgeToResponsesSSE( delta: event.arguments, }); } - if (currentToolCall.freeform) { + if (currentToolCall.freeform && !currentToolCall.codeModeHelperName) { // Hold while the buffer is still an ambiguous prefix of the JSON wrapper, // then stream only the unwrapped input suffix (never rewind on mode flips). if (!FREEFORM_WRAP_PREFIX.startsWith(currentToolCall.args)) { @@ -1587,15 +1596,21 @@ function buildResponseJSONWithBudget( let batchKiroRedactedBytes = 0; let currentToolCallId = ""; let currentToolCallName = ""; + let currentToolCallCodeModeHelperName: string | undefined; let currentToolCallArgs = ""; let currentToolCallProviderMetadata: OcxProviderOpaqueToolCallMetadata | undefined; let currentToolCallArgsBytes = 0; // 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, namespace?: string): string => ( - repairFreeformToolInput(args, toolName, namespace) - ); + const freeformInput = ( + args: string, + toolName: string, + namespace?: string, + codeModeHelperName?: string, + ): string => codeModeHelperName + ? compileCodeModeHelperInput(args, codeModeHelperName) + : repairFreeformToolInput(args, toolName, namespace); const parseArgsObj = (args: string): Record => { try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; } }; @@ -1697,7 +1712,7 @@ function buildResponseJSONWithBudget( type: "custom_tool_call", id: `ctc_${uuid()}`, call_id: currentToolCallId, name: realName, ...(ns ? { namespace: ns } : {}), - input: freeformInput(currentToolCallArgs, realName, ns), status, + input: freeformInput(currentToolCallArgs, realName, ns, currentToolCallCodeModeHelperName), status, }); } else { pushOutput({ @@ -1711,6 +1726,7 @@ function buildResponseJSONWithBudget( budget?.closeCall(currentToolCallId); currentToolCallId = ""; currentToolCallName = ""; + currentToolCallCodeModeHelperName = undefined; currentToolCallProviderMetadata = undefined; currentToolCallArgs = ""; currentToolCallArgsBytes = 0; @@ -1825,6 +1841,9 @@ function buildResponseJSONWithBudget( currentToolCallId = e.id; budget?.openCall(e.id); currentToolCallName = effectiveName; + currentToolCallCodeModeHelperName = effectiveName === "exec" && e.name !== effectiveName + ? e.name + : undefined; currentToolCallArgs = ""; currentToolCallArgsBytes = 0; currentToolCallProviderMetadata = e.providerMetadata; diff --git a/src/responses/code-mode-helper-compat.ts b/src/responses/code-mode-helper-compat.ts new file mode 100644 index 0000000000..a9140f8f30 --- /dev/null +++ b/src/responses/code-mode-helper-compat.ts @@ -0,0 +1,50 @@ +import { normalizeApplyPatchDelimiters } from "./apply-patch-envelope"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function unwrapPatchInput(value: string): string { + try { + const parsed: unknown = JSON.parse(value); + if (isPlainObject(parsed)) { + if (typeof parsed.input === "string") return parsed.input; + if (typeof parsed.patch === "string") return parsed.patch; + } + } catch { + // Native custom calls carry the patch body directly. + } + return value; +} + +/** + * Convert a nested Code Mode helper call into unified-exec JavaScript. + * + * Parsed values are serialized as data, never interpolated as source, so command and patch text + * cannot escape the generated call. Invalid structured shell payloads are also passed as data so + * nested-tool validation can reject them without evaluating provider text as JavaScript. + */ +export function compileCodeModeHelperInput(argumentsText: unknown, toolName: string): string { + if (typeof argumentsText !== "string") return ""; + if (toolName === "apply_patch") { + const patch = normalizeApplyPatchDelimiters(unwrapPatchInput(argumentsText)); + return `const result = await tools.apply_patch(${JSON.stringify(patch)});\ntext(result);`; + } + let parsed: unknown = argumentsText; + try { + parsed = JSON.parse(argumentsText); + } catch { + // Keep malformed provider text as data rather than executable source. + } + const args: unknown = isPlainObject(parsed) ? { ...parsed } : parsed; + if ( + toolName === "shell_command" + && isPlainObject(args) + && typeof args.command === "string" + && args.cmd === undefined + ) { + args.cmd = args.command; + delete args.command; + } + return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`; +} diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 10711a354b..ea4c36a77f 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -1,9 +1,10 @@ -import { namespacedToolName } from "../types"; +import { namespacedToolName, normalizeDeclaredToolName } from "../types"; import { normalizeApplyPatchDelimiters, repairFreeformToolInput, unwrapFreeformToolInput, } from "./apply-patch-envelope"; +import { compileCodeModeHelperInput } from "./code-mode-helper-compat"; import { collectResponsesToolGroups } from "./tool-groups"; const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); @@ -65,6 +66,20 @@ export function routedCustomToolWireName(value: unknown): string | undefined { ); } +/** Resolve a provider-emitted wire name to the routed custom tool the client declared. */ +export function routedCustomToolTargetName( + value: unknown, + names: ReadonlySet, + declaredNames?: ReadonlySet, +): string | undefined { + const wireName = routedCustomToolWireName(value); + if (wireName === undefined) return undefined; + if (names.has(wireName)) return wireName; + if (!isPlainObject(value) || typeof value.namespace === "string") return undefined; + const normalized = normalizeDeclaredToolName(wireName, declaredNames); + return normalized !== wireName && names.has(normalized) ? normalized : undefined; +} + /** * 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. @@ -250,29 +265,37 @@ export function restoreRoutedCustomCalls( value: unknown, names: ReadonlySet, repairNames: ReadonlySet = new Set(), + declaredNames?: ReadonlySet, ): { value: unknown; changed: boolean } { 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); + const targetName = routedCustomToolTargetName(item, names, declaredNames); if ( - item.type === "function_call" + (item.type === "function_call" || item.type === "custom_tool_call") && typeof item.name === "string" && wireName !== undefined - && names.has(wireName) + && targetName !== undefined ) { + const sourceInput = item.type === "function_call" ? item.arguments : item.input; + const aliased = targetName !== wireName; const restored: Record = { ...item, type: "custom_tool_call", id: customToolItemId(item.id), - input: repairFreeformToolInput( - item.arguments, - item.name, - typeof item.namespace === "string" ? item.namespace : undefined, - ), + name: aliased ? targetName : item.name, + input: aliased && sourceInput !== "" + ? compileCodeModeHelperInput(sourceInput, item.name) + : repairFreeformToolInput( + sourceInput, + targetName, + typeof item.namespace === "string" ? item.namespace : undefined, + ), }; delete restored.arguments; + if (aliased) delete restored.namespace; return { value: restored, changed: true }; } if ( @@ -323,7 +346,7 @@ export function restoreRoutedCustomCalls( && value.type.startsWith("response.") && isPlainObject(value.response) ) { - const response = restoreRoutedCustomCalls(value.response, names, repairNames); + const response = restoreRoutedCustomCalls(value.response, names, repairNames, declaredNames); if (response.changed) { restored.response = response.value; changed = true; @@ -337,6 +360,7 @@ export function restoreRoutedCustomCallsInJson( text: string, names: ReadonlySet, repairNames: ReadonlySet = new Set(), + declaredNames?: ReadonlySet, ): string { if (names.size === 0 && repairNames.size === 0) return text; let payload: unknown; @@ -345,7 +369,7 @@ export function restoreRoutedCustomCallsInJson( } catch { return text; } - const restored = restoreRoutedCustomCalls(payload, names, repairNames); + const restored = restoreRoutedCustomCalls(payload, names, repairNames, declaredNames); 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 0b5888d0b4..4463c8481c 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -1,8 +1,10 @@ import type { TranslatorBudget } from "../lib/translator-budget"; import { normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope"; +import { compileCodeModeHelperInput } from "../responses/code-mode-helper-compat"; import { customToolItemId, restoreRoutedCustomCalls, + routedCustomToolTargetName, routedCustomToolWireName, unwrapRoutedCustomToolArguments, } from "../responses/custom-tool-compat"; @@ -87,8 +89,10 @@ export function createRoutedCustomToolRestoreBlockRewrite( names: ReadonlySet, budget?: TranslatorBudget, repairNames: ReadonlySet = new Set(), + declaredNames?: ReadonlySet, ): SseBlockRewrite { - const itemNames = new Map(); + const itemNames = new Map(); + const customAliasItemNames = new Map(); const repairItemNames = new Map(); const ordinaryItemIds = new Set(); const openCalls = new Map(); @@ -114,6 +118,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( } pendingArguments = []; itemNames.clear(); + customAliasItemNames.clear(); repairItemNames.clear(); ordinaryItemIds.clear(); }; @@ -187,11 +192,20 @@ export function createRoutedCustomToolRestoreBlockRewrite( ) { const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; const wireName = routedCustomToolWireName(parsed.item); + const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames); + const aliased = targetName !== undefined && targetName !== wireName; + if (upstreamItemId && aliased) { + customAliasItemNames.set(upstreamItemId, parsed.item.name); + if (type === "response.output_item.added") { + openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); + } + } const repairable = wireName !== undefined && repairNames.has(wireName); if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name); - const restored = repairable - ? restoreRoutedCustomCalls(parsed, names, repairNames) + const restored = repairable || aliased + ? restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames) : { value: parsed, changed: false }; + if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId); return restored.changed ? [replaceSseDataPayload(block, JSON.stringify(restored.value))] : [block]; @@ -203,12 +217,14 @@ export function createRoutedCustomToolRestoreBlockRewrite( && typeof parsed.item.name === "string" ) { const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; + const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames); + const routed = targetName !== undefined; const wireName = routedCustomToolWireName(parsed.item); - const routed = wireName !== undefined && names.has(wireName); if (upstreamItemId) { if (routed) { itemNames.set(upstreamItemId, { name: parsed.item.name, + aliased: targetName !== wireName, ...(typeof parsed.item.namespace === "string" ? { namespace: parsed.item.namespace } : {}), }); ordinaryItemIds.delete(upstreamItemId); @@ -227,7 +243,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( if (upstreamItemId && pending.length > 0 && !openCalls.has(upstreamItemId)) { openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); } - const restored = restoreRoutedCustomCalls(parsed, names, repairNames); + const restored = restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames); const restoredBlock = restored.changed ? replaceSseDataPayload(block, JSON.stringify(restored.value)) : block; @@ -239,6 +255,33 @@ export function createRoutedCustomToolRestoreBlockRewrite( } const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined; + if ( + type === "response.custom_tool_call_input.delta" + && upstreamItemId + && customAliasItemNames.has(upstreamItemId) + ) { + const open = openCalls.get(upstreamItemId) ?? { argumentsText: "", emittedInput: "", retainedBytes: 0 }; + const delta = typeof parsed.delta === "string" ? parsed.delta : ""; + const deltaBytes = Buffer.byteLength(delta, "utf8"); + if (deltaBytes > 0) budget?.chargeRetained(deltaBytes, { kind: "retained_collectors" }); + open.argumentsText += delta; + open.retainedBytes += deltaBytes; + openCalls.set(upstreamItemId, open); + return []; + } + if ( + type === "response.custom_tool_call_input.done" + && upstreamItemId + && customAliasItemNames.has(upstreamItemId) + ) { + const source = typeof parsed.input === "string" + ? parsed.input + : openCalls.get(upstreamItemId)?.argumentsText ?? ""; + return [replaceSseDataPayload(block, JSON.stringify({ + ...parsed, + input: compileCodeModeHelperInput(source, customAliasItemNames.get(upstreamItemId)!), + }))]; + } if ( type === "response.custom_tool_call_input.done" && upstreamItemId @@ -301,12 +344,14 @@ export function createRoutedCustomToolRestoreBlockRewrite( ...rest, type: nextType, item_id: customToolItemId(upstreamItemId), - input: unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace), + input: itemName?.aliased + ? compileCodeModeHelperInput(source, itemName.name) + : unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace), }; return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; } - const restored = restoreRoutedCustomCalls(parsed, names, repairNames); + const restored = restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames); 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 08a679894d..295415144e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -314,7 +314,7 @@ import { payloadRewriteAsBlockRewrite, relaySseWithBlockRewrite, } from "../sse-payload-rewrite"; -import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; +import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; @@ -3283,10 +3283,16 @@ async function handleResponsesInner( const rememberPassthroughResponseChecked = rememberPassthroughResponse ? (response: { id?: unknown; output?: unknown; status?: unknown }) => { if (inspectionSawUndeclaredTool) return; + const restoredResponse = restoreRoutedCustomCalls( + response, + routedCustomToolNames, + routedCustomToolRepairNames, + declaredWireToolNames, + ).value as { id?: unknown; output?: unknown; status?: unknown }; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( - response, + restoredResponse, declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, @@ -3294,7 +3300,7 @@ async function handleResponsesInner( ) { return; } - rememberPassthroughResponse(response); + rememberPassthroughResponse(restoredResponse); } : undefined; recordAdapterReasoning(logCtx, request); @@ -3923,6 +3929,7 @@ async function handleResponsesInner( routedCustomToolNames, translatorBudget, routedCustomToolRepairNames, + declaredWireToolNames, ) : undefined, routedToolSearchNames.size > 0 @@ -4133,6 +4140,7 @@ async function handleResponsesInner( restoredNamespace, routedCustomToolNames, routedCustomToolRepairNames, + declaredWireToolNames, ); const restoredToolSearch = restoreRoutedToolSearchCallsInJson( restored, diff --git a/src/types/tools.ts b/src/types/tools.ts index 89ebb3acb0..d79c1c0121 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -32,16 +32,17 @@ export function namespacedToolName(namespace: string | undefined, name: string): } /** - * Codex 0.149 unified-exec name normalization. + * Codex unified-exec name normalization. * * Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own * description mentions the nested `await tools.exec_command(...)` helper). Routed models — * DeepSeek in particular — sometimes echo that helper name as the tool-call name, emitting - * `exec_command` instead of the declared `exec`. Accept the legacy shell bridge names only - * when the request catalog actually declares `exec` and does not itself declare the legacy - * name (an MCP server may legitimately advertise `exec_command` under its own namespace). + * `exec_command` or `apply_patch` instead of the declared `exec`. Accept these nested helper + * names only when the request catalog actually declares `exec` and does not itself declare the + * emitted name (an MCP server may legitimately advertise one under its own namespace). */ const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const; +const CODE_MODE_HELPER_TOOL_NAMES = [...LEGACY_SHELL_BRIDGE_TOOL_NAMES, "apply_patch"] as const; export function normalizeDeclaredToolName( name: string, @@ -49,13 +50,14 @@ export function normalizeDeclaredToolName( ): string { if (!declared || !declared.has("exec")) return name; if (declared.has(name)) return name; + if (name === "apply_patch") return "exec"; // When the catalog explicitly declares any legacy shell bridge name, the environment // genuinely exposes that tool — turn normalization off so a call is never mis-routed // to `exec`. if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) { return name; } - return (LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).includes(name) ? "exec" : name; + return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(name) ? "exec" : name; } export function toolChoiceAliases(tool: Pick): string[] { diff --git a/tests/bridge-legacy-shell-normalization.test.ts b/tests/bridge-legacy-shell-normalization.test.ts index 76ce42e21a..79b4e4aa98 100644 --- a/tests/bridge-legacy-shell-normalization.test.ts +++ b/tests/bridge-legacy-shell-normalization.test.ts @@ -16,7 +16,7 @@ async function drain(stream: ReadableStream): Promise { async function* toolTurn(name: string): AsyncGenerator { yield { type: "tool_call_start", id: "call-1", name } as AdapterEvent; - yield { type: "tool_call_delta", id: "call-1", delta: '{"cmd":"ls"}' } as AdapterEvent; + yield { type: "tool_call_delta", id: "call-1", arguments: '{"cmd":"ls"}' } as AdapterEvent; yield { type: "tool_call_end", id: "call-1" } as AdapterEvent; yield { type: "done" } as AdapterEvent; } @@ -28,30 +28,49 @@ async function* toolTurn(name: string): AsyncGenerator { describe("bridge normalizes legacy shell names against the declared catalog (#2493)", () => { test("exec_command is delivered as the declared exec instead of failing the turn", async () => { const sse = await drain(bridgeToResponsesSSE( - toolTurn("exec_command"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, + toolTurn("exec_command"), "deepseek-x", undefined, new Set(["exec"]), undefined, undefined, 50_000, { declaredToolNames: new Set(["exec"]) }, )); expect(sse).not.toContain("undeclared client tool"); expect(sse).toContain('"name":"exec"'); + expect(sse).toContain('await tools.exec_command({\\"cmd\\":\\"ls\\"})'); + expect(sse).not.toContain('"input":"{\\"cmd\\":\\"ls\\"}"'); }); test("shell_command normalizes the same way", async () => { const sse = await drain(bridgeToResponsesSSE( - toolTurn("shell_command"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, + toolTurn("shell_command"), "deepseek-x", undefined, new Set(["exec"]), undefined, undefined, 50_000, { declaredToolNames: new Set(["exec"]) }, )); expect(sse).not.toContain("undeclared client tool"); expect(sse).toContain('"name":"exec"'); + expect(sse).toContain('await tools.exec_command({\\"cmd\\":\\"ls\\"})'); }); test("a genuinely undeclared tool still fails the turn", async () => { const sse = await drain(bridgeToResponsesSSE( - toolTurn("apply_patch"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, + toolTurn("other_tool"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, { declaredToolNames: new Set(["exec"]) }, )); expect(sse).toContain("undeclared client tool"); }); + test("apply_patch is wrapped through the declared exec tool", async () => { + async function* patchTurn(): AsyncGenerator { + yield { type: "tool_call_start", id: "call-patch", name: "apply_patch" } as AdapterEvent; + yield { type: "tool_call_delta", id: "call-patch", arguments: "*** Begin Patch\n*** Add File: note.txt\n+ok\n*** End Patch" } as AdapterEvent; + yield { type: "tool_call_end", id: "call-patch" } as AdapterEvent; + yield { type: "done" } as AdapterEvent; + } + const sse = await drain(bridgeToResponsesSSE( + patchTurn(), "deepseek-x", undefined, new Set(["exec"]), undefined, undefined, 50_000, + { declaredToolNames: new Set(["exec"]) }, + )); + expect(sse).not.toContain("undeclared client tool"); + expect(sse).toContain('"name":"exec"'); + expect(sse).toContain("await tools.apply_patch"); + }); + test("a catalog that declares exec_command itself is never rewritten", async () => { const sse = await drain(bridgeToResponsesSSE( toolTurn("exec_command"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, @@ -59,5 +78,6 @@ describe("bridge normalizes legacy shell names against the declared catalog (#24 )); expect(sse).not.toContain("undeclared client tool"); expect(sse).toContain('"name":"exec_command"'); + expect(sse).toContain('"arguments":"{\\"cmd\\":\\"ls\\"}"'); }); }); diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 798e8bd08c..444f3d6e77 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -351,8 +351,8 @@ describe("Responses bridge reasoning and usage parity", () => { test("non-streaming bridge fails closed when upstream calls an undeclared tool", () => { const json = buildResponseJSON([ - { type: "tool_call_start", id: "call_bad", name: "apply_patch" }, - { type: "tool_call_delta", arguments: '{"input":"*** Begin Patch"}' }, + { type: "tool_call_start", id: "call_bad", name: "other_tool" }, + { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, { type: "done" }, ], "deepseek/deepseek-v4-flash", { declaredToolNames: new Set(["exec"]) }); diff --git a/tests/legacy-shell-compat.test.ts b/tests/legacy-shell-compat.test.ts new file mode 100644 index 0000000000..9fcc217b0e --- /dev/null +++ b/tests/legacy-shell-compat.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { compileCodeModeHelperInput } from "../src/responses/code-mode-helper-compat"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new ( + ...args: string[] +) => (...args: unknown[]) => Promise; + +describe("code-mode helper compatibility", () => { + test("exec_command arguments remain data when generated JavaScript runs", async () => { + const command = "printf '%s' \"$HOME\"; }); throw new Error('escaped') //"; + const source = compileCodeModeHelperInput( + JSON.stringify({ cmd: command, workdir: "/tmp", yield_time_ms: 1_000 }), + "exec_command", + ); + let received: unknown; + let output: unknown; + const run = new AsyncFunction("tools", "text", source); + + await run({ + exec_command: async (args: unknown) => { + received = args; + return { exit_code: 0, output: "ok" }; + }, + }, (value: unknown) => { output = value; }); + + expect(received).toEqual({ cmd: command, workdir: "/tmp", yield_time_ms: 1_000 }); + expect(output).toEqual({ exit_code: 0, output: "ok" }); + }); + + test("shell_command maps command to the nested exec cmd field", async () => { + const source = compileCodeModeHelperInput( + JSON.stringify({ command: "pwd", workdir: "/tmp" }), + "shell_command", + ); + let received: unknown; + const run = new AsyncFunction("tools", "text", source); + await run({ + exec_command: async (args: unknown) => { + received = args; + return "ok"; + }, + }, () => {}); + expect(received).toEqual({ workdir: "/tmp", cmd: "pwd" }); + }); + + test("apply_patch text remains one string argument", async () => { + const patch = "*** Begin Patch\n*** Add File: note.txt\n+`); throw new Error('escaped')\n*** End Patch"; + const source = compileCodeModeHelperInput(patch, "apply_patch"); + let received: unknown; + const run = new AsyncFunction("tools", "text", source); + await run({ + apply_patch: async (input: unknown) => { + received = input; + return "done"; + }, + }, () => {}); + expect(received).toBe(patch); + }); + + test("apply_patch normalizes decorated outer delimiters before execution", async () => { + const decorated = "*** Begin Patch ***\n*** Add File: note.txt\n+hello\n*** End Patch ***"; + const canonical = "*** Begin Patch\n*** Add File: note.txt\n+hello\n*** End Patch"; + let received: unknown; + const run = new AsyncFunction( + "tools", + "text", + compileCodeModeHelperInput(JSON.stringify({ input: decorated }), "apply_patch"), + ); + + await run({ + apply_patch: async (input: unknown) => { + received = input; + return "done"; + }, + }, () => {}); + + expect(received).toBe(canonical); + }); + + test("invalid structured shell input remains data instead of becoming JavaScript", async () => { + for (const input of ["{not-json", "[]"]) { + let received: unknown; + const run = new AsyncFunction("tools", "text", compileCodeModeHelperInput(input, "exec_command")); + await run({ + exec_command: async (args: unknown) => { + received = args; + return "rejected"; + }, + }, () => {}); + expect(received).toEqual(input === "[]" ? [] : input); + } + }); +}); diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index c1bfed72e3..ba00963d3f 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -4,6 +4,7 @@ import { restoreRoutedCustomCallsInJson, rewriteRoutedCustomToolsForUpstream, } from "../src/responses/custom-tool-compat"; +import { compileCodeModeHelperInput } from "../src/responses/code-mode-helper-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../src/server/responses-custom-tool-repair"; import { handleResponses } from "../src/server/responses"; import type { OcxConfig } from "../src/types"; @@ -24,6 +25,131 @@ const CANONICAL_PATCH = "*** Begin Patch\n*** Update File: README.md\n@@\n-old\n const WRAPPED_DECORATED_PATCH = JSON.stringify({ input: DECORATED_PATCH }); describe("routed Responses custom-tool compatibility", () => { + test("restores legacy structured shell aliases as executable unified-exec input", () => { + const declared = new Set(["exec"]); + const upstream = JSON.stringify({ + id: "resp_shell", + output: [{ + type: "function_call", + id: "fc_shell", + call_id: "call_shell", + name: "shell_command", + arguments: JSON.stringify({ command: "printf '%s' \\\"$HOME\\\"", workdir: "/tmp" }), + status: "completed", + }], + }); + + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + upstream, + new Set(["exec"]), + new Set(), + declared, + )) as { output: Array> }; + expect(restored.output[0]).toMatchObject({ + type: "custom_tool_call", + name: "exec", + input: compileCodeModeHelperInput( + JSON.stringify({ command: "printf '%s' \\\"$HOME\\\"", workdir: "/tmp" }), + "shell_command", + ), + }); + expect(restored.output[0]).not.toHaveProperty("arguments"); + }); + + test("restores a native apply_patch stream through unified exec", () => { + const declared = new Set(["exec"]); + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), + undefined, + new Set(), + declared, + ); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_patch_alias", + call_id: "call_patch_alias", + name: "apply_patch", + input: "", + status: "in_progress", + }, + })); + expect(dataPayload(added[0]!).item).toMatchObject({ + type: "custom_tool_call", + name: "exec", + input: "", + }); + + expect(rewrite(frame("response.custom_tool_call_input.delta", { + output_index: 0, + item_id: "ctc_patch_alias", + delta: CANONICAL_PATCH, + }))).toEqual([]); + const inputDone = rewrite(frame("response.custom_tool_call_input.done", { + output_index: 0, + item_id: "ctc_patch_alias", + input: CANONICAL_PATCH, + })); + expect(dataPayload(inputDone[0]!).input).toBe( + compileCodeModeHelperInput(CANONICAL_PATCH, "apply_patch"), + ); + + const itemDone = rewrite(frame("response.output_item.done", { + output_index: 0, + item: { + type: "custom_tool_call", + id: "ctc_patch_alias", + call_id: "call_patch_alias", + name: "apply_patch", + input: CANONICAL_PATCH, + status: "completed", + }, + })); + expect(dataPayload(itemDone[0]!).item).toMatchObject({ + type: "custom_tool_call", + name: "exec", + input: compileCodeModeHelperInput(CANONICAL_PATCH, "apply_patch"), + }); + rewrite.dispose?.(); + }); + + test("restores streamed exec_command arguments through unified exec", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), + undefined, + new Set(), + new Set(["exec"]), + ); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_shell_alias", + call_id: "call_shell_alias", + name: "exec_command", + arguments: "", + status: "in_progress", + }, + })); + expect(dataPayload(added[0]!).item).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_shell_alias", + delta: '{"cmd":"pwd"}', + }))).toEqual([]); + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_shell_alias", + arguments: '{"cmd":"pwd"}', + })); + expect(dataPayload(done[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + input: compileCodeModeHelperInput('{"cmd":"pwd"}', "exec_command"), + }); + rewrite.dispose?.(); + }); + test("rewrites exec definitions and paired history without touching apply_patch", () => { const raw = { model: "deepseek-v4-flash", diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index 4031520c31..a5e5838043 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -29,8 +29,8 @@ async function collectSse(stream: ReadableStream): Promise<{ event?: describe("Responses streaming tool event contract", () => { test("undeclared upstream tool names fail closed with a compatibility error", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ - { type: "tool_call_start", id: "call_bad", name: "apply_patch" }, - { type: "tool_call_delta", arguments: '{"input":"*** Begin Patch"}' }, + { type: "tool_call_start", id: "call_bad", name: "other_tool" }, + { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }, { type: "done" }, ]), "deepseek/deepseek-v4-flash", undefined, undefined, undefined, undefined, undefined, { @@ -41,7 +41,7 @@ describe("Responses streaming tool event contract", () => { expect(frames.some(frame => frame.event === "response.completed")).toBe(false); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record; expect((failed.error as Record).message).toContain("undeclared client tool"); - expect((failed.error as Record).message).toContain("apply_patch"); + expect((failed.error as Record).message).toContain("other_tool"); }); test("adapter tool events produce OpenAI-compatible streamed function-call frames", async () => { diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index b27fa8e30c..d7ae5e3a43 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -237,25 +237,23 @@ describe("undeclared tool call guard", () => { expect(await relay(upstream, declared)).toBe(upstream); }); - test("replaces an undeclared apply_patch with a compatibility failure", async () => { - // The reported shape: the request-visible catalog holds exec/wait/request_user_input, and - // `apply_patch` arrives anyway because code mode nests it inside the exec description. + test("replaces an undeclared tool with a compatibility failure", async () => { const upstream = sse("response.output_item.added", { output_index: 0, - item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "apply_patch", arguments: "{}" }, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "other_tool", arguments: "{}" }, }); const out = await relay(upstream, declared); expect(out).toContain("event: response.failed"); expect(out).toContain(`"code":"${UNDECLARED_TOOL_CALL_ERROR_CODE}"`); - expect(out).toContain('routed provider emitted undeclared client tool \\"apply_patch\\"'); + expect(out).toContain('routed provider emitted undeclared client tool \\"other_tool\\"'); expect(out).toEndWith("data: [DONE]\n\n"); }); test("drops the rest of the turn so a later completed cannot contradict the failure", async () => { const upstream = sse("response.output_item.added", { output_index: 0, - item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "apply_patch", arguments: "" }, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "other_tool", arguments: "" }, }) + sse("response.function_call_arguments.delta", { item_id: "fc_1", delta: "{\"input\":\"" }) + sse("response.completed", { response: { id: "resp_1", status: "completed", output: [] } }) @@ -274,7 +272,7 @@ describe("undeclared tool call guard", () => { status: "completed", output: [ { type: "message", id: "msg_0", role: "assistant" }, - { type: "function_call", id: "fc_1", call_id: "call_1", name: "apply_patch", arguments: "{}" }, + { type: "function_call", id: "fc_1", call_id: "call_1", name: "other_tool", arguments: "{}" }, ], }, }); @@ -422,7 +420,7 @@ describe("the reported turn, end to end through handleResponses", () => { } - test("streaming: the leaked apply_patch becomes a named failure instead of a silent abort", async () => { + test("streaming: a top-level apply_patch is bridged through unified exec", async () => { const response = await post(true, () => new Response([ frame("response.output_item.added", { output_index: 0, item: { ...leakedCall, arguments: "", status: "in_progress" } }), frame("response.output_item.done", { output_index: 0, item: leakedCall }), @@ -431,23 +429,22 @@ describe("the reported turn, end to end through handleResponses", () => { ].join("\n\n") + "\n\n", { headers: { "content-type": "text/event-stream" } })); const body = await response.text(); - expect(body).toContain("response.failed"); - expect(body).toContain(`"code":"${UNDECLARED_TOOL_CALL_ERROR_CODE}"`); - expect(body).toContain("apply_patch"); - // Before the guard this reached Codex as a call it has no handler for, and the turn showed - // only `aborted`. The client must not see a completed turn now. - expect(body).not.toContain("response.completed"); + expect(body).not.toContain("response.failed"); + expect(body).toContain("response.completed"); + expect(body).toContain('"name":"exec"'); + expect(body).toContain("await tools.apply_patch"); }); - test("non-streaming: the same call is refused rather than answered", async () => { + test("non-streaming: the same call is bridged through unified exec", async () => { const response = await post(false, () => new Response( JSON.stringify({ id: "resp_1", status: "completed", output: [leakedCall] }), { headers: { "content-type": "application/json" } }, )); - expect(response.status).toBe(502); - const body = await response.json() as { error: { message: string } }; - expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(body.output[0]?.input).toContain("await tools.apply_patch"); }); test("a declared exec call still completes normally", async () => { @@ -536,9 +533,85 @@ describe("a refused turn does not become continuation state", () => { expect(expandedInputLength("resp_accepted")).toBeGreaterThan(1); }); + test("a bridged apply_patch turn is remembered as the declared exec call", async () => { + const accepted = await turn("resp_apply_patch_bridged", { + type: "function_call", + id: "fc_patch", + call_id: "call_patch", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }); + + expect(accepted.status).toBe(200); + const expanded = expandPreviousResponseInput({ + model: "fixture/deepseek-v4-flash", + previous_response_id: "resp_apply_patch_bridged", + input: [{ role: "user", content: [{ type: "input_text", text: "and again" }] }], + tools: declaredTools, + }) as { input?: Array> }; + const rememberedCall = expanded.input?.find(item => item.call_id === "call_patch"); + + expect(rememberedCall).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(rememberedCall?.input).toContain("tools.apply_patch"); + expect(JSON.stringify(expanded)).not.toContain('"name":"apply_patch"'); + }); + + test("a streamed bridged apply_patch turn is remembered as the declared exec call", async () => { + const responseId = "resp_stream_apply_patch_bridged"; + const call = { + type: "function_call", + id: "fc_stream_patch", + call_id: "call_stream_patch", + name: "apply_patch", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + status: "completed", + }; + const sse = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: responseId, status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: call })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: responseId, status: "completed", output: [call] } })}\n\n`, + "data: [DONE]\n\n", + ].join(""); + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(sse, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + let response: Response; + try { + 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: true, + input: [{ role: "user", content: [{ type: "input_text", text: "edit the file" }] }], + tools: declaredTools, + }), + }), config, { model: "", provider: "" }); + } finally { + globalThis.fetch = savedFetch; + } + + const clientStream = await response.text(); + expect(clientStream).toContain('"name":"exec"'); + expect(clientStream).not.toContain("response.failed"); + await Bun.sleep(50); + + const expanded = expandPreviousResponseInput({ + model: "fixture/deepseek-v4-flash", + previous_response_id: responseId, + input: [{ role: "user", content: [{ type: "input_text", text: "and again" }] }], + tools: declaredTools, + }) as { input?: Array> }; + const rememberedCall = expanded.input?.find(item => item.call_id === "call_stream_patch"); + expect(rememberedCall).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(rememberedCall?.input).toContain("tools.apply_patch"); + }); + test("a refused turn is not", async () => { const refused = await turn("resp_refused", { - type: "function_call", id: "fc_bad", call_id: "call_bad", name: "apply_patch", arguments: "{}", status: "completed", + type: "function_call", id: "fc_bad", call_id: "call_bad", name: "other_tool", arguments: "{}", status: "completed", }); expect(refused.status).toBe(502); @@ -557,7 +630,7 @@ describe("a refused turn does not become continuation state", () => { `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, - item: { type: "function_call", id: "fc_bad", call_id: "call_bad", name: "apply_patch", arguments: "{}" }, + item: { type: "function_call", id: "fc_bad", call_id: "call_bad", name: "other_tool", arguments: "{}" }, })}\n\n`, `data: ${JSON.stringify({ type: "response.completed", response: { id: responseId, status: "completed", output: [] } })}\n\n`, "data: [DONE]\n\n", @@ -1233,7 +1306,7 @@ describe("undeclaredToolCallNameInResponse", () => { ], }; - expect(undeclaredToolCallNameInResponse(response, new Set(["exec"]))).toBe("apply_patch"); + expect(undeclaredToolCallNameInResponse(response, new Set(["exec"]))).toBeUndefined(); expect(undeclaredToolCallNameInResponse(response, new Set(["exec", "apply_patch"]))).toBeUndefined(); });