diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d60bd6ce1f..18070ddc71 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1014,15 +1014,32 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk * call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally. * A missing or empty `call_id`, however, cannot identify stored state on any destination. */ -function repairUnidentifiedToolOutputItems(body: unknown): unknown { +export function repairUnidentifiedToolOutputItems( + body: unknown, + options?: { preserveExternalTaskEnvelopes?: boolean }, +): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; let changed = false; const input = body.input.map(item => { - if (!isPlainObject(item) - || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output") - || (typeof item.call_id === "string" && item.call_id.length > 0)) { + if (!isPlainObject(item)) return item; + const isToolOutput = item.type === "function_call_output" || item.type === "custom_tool_call_output" || item.type === "tool_search_output"; + if (!isToolOutput) return item; + const hasValidCallId = typeof item.call_id === "string" && item.call_id.length > 0; + if (hasValidCallId) return item; + // Translating parse-time repair must leave Codex external-task envelopes alone so + // the parser can admit complete ones and fail closed on invalid ones. Passthrough + // still converts the raw item because it never reads parsed messages. + if (options?.preserveExternalTaskEnvelopes && "id" in item && "name" in item && "namespace" in item) { return item; } + if (item.type === "tool_search_output") { + changed = true; + return { + type: "message", + role: "user", + content: orphanedToolOutputContent(item.error || item.status || "tool_search"), + }; + } if (!isRepairableToolOutput(item.output)) return item; changed = true; return { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b961e7cef9..b4addc50eb 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -37,7 +37,7 @@ import { } from "../../responses/reasoning-replay-cache"; import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay"; import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction"; -import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses"; +import { FORWARD_HEADERS, sanitizeReasoningInputContent, repairUnidentifiedToolOutputItems } from "../../adapters/openai-responses"; import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema"; import { copyPreviousResponseReplayProvenance, @@ -3222,6 +3222,7 @@ async function handleResponsesInner( let parsed: OcxParsedRequest; let toolBridgeMaps: ReturnType; try { + body = repairUnidentifiedToolOutputItems(body, { preserveExternalTaskEnvelopes: true }) as typeof body; parsed = parseRequest(body); parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // Captured before any parser mutates it, so both grammars see the client's id. @@ -3538,6 +3539,7 @@ async function handleResponsesInner( ); if (!unreadableEncryptedAgentTask) { try { + body = repairUnidentifiedToolOutputItems(body, { preserveExternalTaskEnvelopes: true }) as typeof body; const reparsed = parseRequest(body); const kept: Array = [ "_previousResponseInputExpanded", @@ -6096,7 +6098,9 @@ async function handleResponsesInner( || (message as { toolCallId: string }).toolCallId.length === 0), ); if (unpaired) { - // Never interpolate the tool output: this message reaches the client and the logs. + // Ordinary missing/empty call_id items are rewritten before parseRequest. + // Anything still unpaired here is envelope-shaped or otherwise unrepairable: + // fail closed without interpolating the tool output into a client-visible message. return formatErrorResponse( 400, "invalid_request_error", diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index fafbbd6806..7c46d642e4 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -2576,11 +2576,19 @@ describe("unpaired tool result boundary (#3259)", () => { }, } as unknown as OcxConfig); - test("a translating adapter rejects a call_id-less tool result with 400 and sends nothing upstream", async () => { - let fetches = 0; - globalThis.fetch = (async () => { - fetches += 1; - throw new Error("the guard must reject before any upstream request"); + test("a translating adapter converts a call_id-less tool result into user context", async () => { + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); }) as typeof fetch; const res = await handleResponses( @@ -2589,19 +2597,25 @@ describe("unpaired tool result boundary (#3259)", () => { { model: "", provider: "" }, ); - expect(res.status).toBe(400); - const json = await res.json() as { error?: { message?: string; type?: string; code?: string } }; - expect(json.error?.message).toBe("tool result requires a non-empty string call_id"); - expect(json.error?.type).toBe("invalid_request_error"); - expect(json.error?.code).toBe("invalid_request_error"); - // The tool output itself must never be interpolated into a client-visible message. - expect(JSON.stringify(json)).not.toContain("bootstrap result"); - expect(fetches).toBe(0); + expect(res.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toContain("bootstrap result"); + expect(bodies[0]).not.toContain("undefined"); }); - test("an empty-string call_id is rejected identically (it can never pair)", async () => { - globalThis.fetch = (async () => { - throw new Error("the guard must reject before any upstream request"); + test("an empty-string call_id is converted identically (it can never pair)", async () => { + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); }) as typeof fetch; const res = await handleResponses( @@ -2609,7 +2623,34 @@ describe("unpaired tool result boundary (#3259)", () => { anthropicConfig(), { model: "", provider: "" }, ); - expect(res.status).toBe(400); + expect(res.status).toBe(200); + expect(bodies).toHaveLength(1); + }); + + test("a call_id-less tool_search_output is converted into user context", async () => { + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "tool_search_output", status: "failed" })), + anthropicConfig(), + { model: "", provider: "" }, + ); + expect(res.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toContain("failed"); + expect(bodies[0]).not.toContain("undefined"); }); test("a paired tool result on the same translating route still reaches the upstream", async () => {