Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions src/adapters/openai-responses/tool-output-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions tests/providers/xai/xai-responses-adjacency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ function buildBody(provider: OcxProviderConfig, rawBody: Record<string, unknown>
context: { messages: [] },
stream: true,
options: {},
previousResponseId: typeof rawBody.previous_response_id === "string"
? rawBody.previous_response_id
: undefined,
_rawBody: { model: MODEL, ...rawBody },
} as Parameters<ReturnType<typeof createResponsesPassthroughAdapter>["buildRequest"]>[0], {
headers: new Headers(),
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions tests/responses/chat-inline-document-bytes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading