diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index cadcb2a38c2..90bc0880a06 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -295,7 +295,15 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): } const synthesizeMissingCallOutputs = !forward && (stateless || pairedToolResults); if (forward || stateless || pairedToolResults) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs); + // A stateful destination can resolve an output-only delta against the call stored behind + // an unexpanded previous_response_id. All other shapes have no hidden call to preserve. + const repairOrphanOutputs = forward || stateless || !unexpandedMiss; + outBody = repairOrphanedInputItems( + outBody, + unexpandedMiss, + synthesizeMissingCallOutputs, + repairOrphanOutputs, + ); } if (provider.dropResponsesReasoningItems === true) { outBody = dropResponsesReasoningInputItems(outBody); diff --git a/src/adapters/openai-responses/tool-output-recovery.ts b/src/adapters/openai-responses/tool-output-recovery.ts index bd05234cbbd..af35ee7f97b 100644 --- a/src/adapters/openai-responses/tool-output-recovery.ts +++ b/src/adapters/openai-responses/tool-output-recovery.ts @@ -194,7 +194,8 @@ export function repairUnidentifiedToolOutputItems(body: unknown): unknown { * reasoning-bearing assistant turn (#1477). Gated on * `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps * fail-closed behavior. - * - `function_call_output`/`custom_tool_call_output` without their paired call item + * - `function_call_output`/`custom_tool_call_output` without their paired call item, when + * `repairOrphanOutputs` is enabled * ("No tool call found for function call output with call_id ..."). Converted to user * messages so the result text survives. `function_call_output` also pairs with * `local_shell_call` (codex-rs emits shell outputs as function_call_output). @@ -340,7 +341,12 @@ export function restoreBridgedWebSearchCalls(body: unknown, destinationScope: st return changed ? { ...body, input: restored } : body; } -export function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown { +export function repairOrphanedInputItems( + body: unknown, + dropReasoning: boolean, + synthesizeMissingCallOutputs = false, + repairOrphanOutputs = true, +): unknown { if (!isPlainObject(body) || !Array.isArray(body.input)) return body; const input = body.input; @@ -379,7 +385,7 @@ export function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, // incomplete. With no call id and no output, preserve the invalid item so validation fails // closed rather than pretending any tool result exists. const knownNullOutput = callId.length > 0 && item.output == null; - if (!paired && (knownNullOutput || usableOutput)) { + if (repairOrphanOutputs && !paired && (knownNullOutput || usableOutput)) { changed = true; repaired.push({ type: "message", diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index cf601215ed8..1b29c00f654 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -194,8 +194,10 @@ xAI's public Responses API is stateful (`store` defaults true; `previous_respons stored conversation), so the provider is not marked `statelessResponses`. The pairing repair synthesizes an honest unknown-status placeholder without touching `store` or `previous_response_id`: repairing an interrupted history must not cost the thread its server-side -state. Forward auth suppresses the synthesis regardless of the flag, because the backend that holds -the conversation can resolve the pair itself. +state. An output-only continuation is preserved because its call may live in that server-side state; +pairing only synthesizes results for calls present in the current input. Forward auth suppresses the +synthesis regardless of the flag, because the backend that holds the conversation can resolve the +pair itself. > Decision record: [ADR-0052](../decisions/ADR-0052-reasoning-and-tool-result-compatibility.md) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..075e3c33186 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { diff --git a/tests/providers/xai/xai-responses-adjacency.test.ts b/tests/providers/xai/xai-responses-adjacency.test.ts index 1ccc885aa35..f902600d4d5 100644 --- a/tests/providers/xai/xai-responses-adjacency.test.ts +++ b/tests/providers/xai/xai-responses-adjacency.test.ts @@ -27,6 +27,9 @@ function buildBody(provider: OcxProviderConfig, rawBody: Record context: { messages: [] }, stream: true, options: {}, + previousResponseId: typeof rawBody.previous_response_id === "string" + ? rawBody.previous_response_id + : undefined, _rawBody: { model: MODEL, ...rawBody }, } as Parameters["buildRequest"]>[0], { headers: new Headers(), @@ -91,6 +94,25 @@ describe("xAI Responses tool-result adjacency", () => { expect(body.input).toEqual([call, output, injected]); }); + test("preserves output-only continuations whose call remains in xAI state", () => { + const functionOutput = { type: "function_call_output", call_id: "call_stored", output: "result" }; + const customOutput = { type: "custom_tool_call_output", call_id: "custom_stored", output: "patch" }; + const body = buildBody(xaiOauthResponses({ requiresPairedResponsesToolResults: true }), { + previous_response_id: "resp_xai_store", + store: true, + input: [functionOutput, customOutput], + }); + + expect(body.previous_response_id).toBe("resp_xai_store"); + expect(body.store).toBe(true); + expect(body.input).toEqual([functionOutput, customOutput]); + + const standalone = buildBody(xaiOauthResponses({ requiresPairedResponsesToolResults: true }), { + input: [functionOutput], + }); + expect(standalone.input).toEqual([expect.objectContaining({ type: "message", role: "user" })]); + }); + test("keeps call_id pairing for two outstanding replayed calls and synthesizes only the missing output", () => { const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true, diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..a718c1c376a 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic",