From 7026676aeef17666071c20a5ce96c3420ee8beeb Mon Sep 17 00:00:00 2001 From: MerryEcho Date: Thu, 17 Sep 2026 14:53:11 +0800 Subject: [PATCH 1/5] fix(xai): seed Responses tool-result adjacency for interrupted Codex threads Grok 4.6/4.5 OAuth Responses replays Codex tool history. After a mid-stream 502/reset the client can resend a function_call without its output, or with hook-injected developer context between the pair. Google already synthesizes a missing tool_result; xAI did not, so later turns in the same thread 400. Reuse requiresAdjacentResponsesToolResults (Kimi #4726, DeepSeek #1292) and run the existing orphan-call placeholder for non-forward adjacency providers. Do not set statelessResponses: xAI stores responses for 30 days. Closes #4870 --- scripts/test-layout/layout.json | 1 + src/adapters/openai-responses/passthrough.ts | 14 +- src/providers/registry/entries-core.ts | 8 ++ structure/providers/chat-compat.md | 9 +- tests/fixtures/test-layout-expected.json | 1 + .../xai/xai-responses-adjacency.test.ts | 123 ++++++++++++++++++ 6 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 tests/providers/xai/xai-responses-adjacency.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c56dfe6117..f2d26e28ff 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1458,6 +1458,7 @@ "xai-client.test.ts": "images", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", + "xai-responses-adjacency.test.ts": "providers/xai", "xai-tool-schema.test.ts": "providers/xai", "xai-transport.test.ts": "providers/xai", "xai-video-client.test.ts": "videos", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 740130ab04..6df7419e40 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -270,19 +270,27 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // tier write so a force-fast/default decision can never mutate parsed._rawBody. outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision); const stateless = provider.statelessResponses === true; + const adjacentToolResults = provider.requiresAdjacentResponsesToolResults === true; if (stateless) outBody = stripStatefulResponsesParams(outBody); // A replay miss can leave a function_call_output whose paired function_call sat // in the prefix that was never expanded. A stateless upstream cannot resolve the // pair from its own storage either, so it needs the same repair the forward // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. + // Strict parsers that require adjacent call/result batches also 400 on a + // function_call with no matching output. DeepSeek gets that repair through + // statelessResponses. xAI cannot: its Responses API stores conversations for + // 30 days and documents previous_response_id. The adjacency capability is the + // existing strict tool-history gate, so non-forward adjacency providers + // reuse the orphan-call placeholder without stripping store. if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } - if (forward || stateless) { - outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward); + const synthesizeMissingCallOutputs = !forward && (stateless || adjacentToolResults); + if (forward || stateless || adjacentToolResults) { + outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs); } - if (provider.requiresAdjacentResponsesToolResults === true) { + if (adjacentToolResults) { outBody = normalizeResponsesToolResultAdjacency(outBody); } if (forward) { diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 54196adda1..9ecbb4d1f8 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -282,6 +282,14 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ forwardCallerServiceTier: false, }, }, + // Grok 4.6/4.5 OAuth Responses replays Codex tool history. After a mid-stream 502/reset, + // the client can resend a function_call without a matching output, or with hook-injected + // developer context between the pair. Google already synthesizes a missing tool_result + // (#2199). xAI's Responses parser does not, so the next turns 400 and the thread snowballs. + // Reuse the existing adjacency capability (Kimi #4726, DeepSeek #1292). Do not set + // statelessResponses: xAI stores responses for 30 days and documents previous_response_id. + // https://docs.x.ai/developers/model-capabilities/text/comparison + requiresAdjacentResponsesToolResults: true, // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 381276d04b..489ff42270 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -145,7 +145,14 @@ That pass is gated by `requiresAdjacentResponsesToolResults`, not by provider na Responses endpoint enforces the same strict shape and rejects a hook-split pair with HTTP 400 (#4726), so `kimi` and `kimi-code` carry the flag as well. The flag is inert while those presets use the Chat wire and takes effect when a row is configured onto `openai-responses`, which is the configuration the -report exercised. No upstream specification documents the requirement; the evidence is the observed +report exercised. xAI Grok 4.6/4.5 subscription Responses carries the same flag: after a mid-stream +interrupt, Codex can replay a `function_call` without its output, or with hook-injected developer +context between the pair, and later turns 400. xAI's public Responses API is stateful (`store` +defaults true; `previous_response_id` continues a stored conversation), so the provider is not marked +`statelessResponses`. For a non-forward adjacency provider the existing orphan-call repair still +synthesizes an honest placeholder output without stripping store. The adjacency pass itself still +does not invent duplicate or backwards pairs. No upstream specification documents the adjacency +requirement; the evidence is the observed 400 and DeepSeek's identical failure shape, which is why this stays a per-provider capability rather than a wire-wide default — upstream Codex leaves an intervening developer message where it is. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36a7c442d2..21ee14a2fe 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1286,6 +1286,7 @@ "xai-client.test.ts": "images", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", + "xai-responses-adjacency.test.ts": "providers/xai", "xai-tool-schema.test.ts": "providers/xai", "xai-transport.test.ts": "providers/xai", "xai-video-client.test.ts": "videos", diff --git a/tests/providers/xai/xai-responses-adjacency.test.ts b/tests/providers/xai/xai-responses-adjacency.test.ts new file mode 100644 index 0000000000..084e09de87 --- /dev/null +++ b/tests/providers/xai/xai-responses-adjacency.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../../src/adapters/openai-responses"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { routedProviderConfig } from "../../../src/router"; +import type { OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const MODEL = "grok-4.6"; + +const createResponsesPassthroughAdapter = ( + ...args: Parameters +) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function xaiOauthResponses(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + ...overrides, + }; +} + +function buildBody(provider: OcxProviderConfig, rawBody: Record): Record { + const built = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: MODEL, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: MODEL, ...rawBody }, + } as Parameters["buildRequest"]>[0], { + headers: new Headers(), + }); + return JSON.parse(String(built.body)) as Record; +} + +describe("xAI Responses tool-result adjacency", () => { + test("the xAI registry entry seeds adjacency without marking the provider stateless", () => { + const entry = getProviderRegistryEntry("xai")!; + expect(entry.requiresAdjacentResponsesToolResults).toBe(true); + expect(entry.statelessResponses).toBeUndefined(); + const seed = providerConfigSeed(entry); + expect(seed.requiresAdjacentResponsesToolResults).toBe(true); + expect(seed.statelessResponses).toBeUndefined(); + }); + + test("a stale persisted xAI row is backfilled and repairs a dangling function_call on replay", () => { + const stale: OcxProviderConfig = xaiOauthResponses(); + const routedStale = routedProviderConfig("xai", { ...stale }); + const call = { type: "function_call", call_id: "call_interrupted", name: "exec_command", arguments: "{}" }; + const nextTurn = { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }; + + expect(stale.requiresAdjacentResponsesToolResults).toBeUndefined(); + expect(routedStale.requiresAdjacentResponsesToolResults).toBe(true); + enrichProviderFromRegistry("xai", stale); + expect(stale.requiresAdjacentResponsesToolResults).toBe(true); + expect(stale.statelessResponses).toBeUndefined(); + + const body = buildBody(stale, { + previous_response_id: "resp_xai_store", + store: true, + input: [call, nextTurn], + }); + expect(body.previous_response_id).toBe("resp_xai_store"); + expect(body.store).toBe(true); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_interrupted" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_interrupted" }); + expect(String(input[1].output)).toContain("no tool result was recorded"); + expect(input[2]).toMatchObject({ type: "message", role: "user" }); + }); + + test("moves a result next to its call while preserving an intervening developer message", () => { + const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true }); + const call = { type: "function_call", call_id: "call_exec", name: "exec_command", arguments: "{}" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] LSP diagnostics: none" }], + }; + const output = { type: "function_call_output", call_id: "call_exec", output: "ok" }; + const body = buildBody(provider, { input: [call, injected, output] }); + expect(body.input).toEqual([call, output, injected]); + }); + + test("keeps call_id pairing for two outstanding replayed calls and synthesizes only the missing output", () => { + const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true }); + const callA = { type: "function_call", call_id: "call_a", name: "exec_command", arguments: "{}" }; + const callB = { type: "function_call", call_id: "call_b", name: "exec_command", arguments: "{}" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] replay diagnostics" }], + }; + const outputB = { type: "function_call_output", call_id: "call_b", output: "B" }; + const body = buildBody(provider, { input: [callA, callB, injected, outputB] }); + const input = body.input as Array>; + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_a" }); + expect(input[1]).toMatchObject({ type: "function_call", call_id: "call_b" }); + expect(input[2]).toMatchObject({ type: "function_call_output", call_id: "call_a" }); + expect(String(input[2].output)).toContain("no tool result was recorded"); + expect(input[3]).toMatchObject({ type: "function_call_output", call_id: "call_b", output: "B" }); + expect(input[4]).toMatchObject({ type: "message", role: "developer" }); + }); + + test("forward-auth xAI replay still does not synthesize a dangling call", () => { + const provider = xaiOauthResponses({ + authMode: "forward", + requiresAdjacentResponsesToolResults: true, + headers: { authorization: "Bearer xai-oauth" }, + }); + const call = { type: "function_call", call_id: "call_fwd", name: "exec_command", arguments: "{}" }; + const body = buildBody(provider, { input: [call] }); + const input = body.input as Array>; + expect(input).toHaveLength(1); + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_fwd" }); + expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); + }); +}); From d7e4efc3b41817248e32fccd93e22d9a05efce4f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 18:00:09 +0900 Subject: [PATCH 2/5] fix(responses): separate tool-result pairing from adjacency Gating the missing-output synthesis on requiresAdjacentResponsesToolResults enrolled kimi and kimi-code in it too. Both carry that flag because their parser rejects a hook-split pair, but the same report (#4726) shows a call left without any result is accepted, so they would have started receiving placeholder tool turns for a shape they never rejected. Adjacency reorders items the upstream would accept in some order. Pairing synthesizes an item the client never sent, which is a larger claim about what happened in the conversation, so it gets its own capability: requiresPairedResponsesToolResults, seeded on xai only. statelessResponses still implies it, which is how DeepSeek already had the repair. Stateful behavior is untouched: store and previous_response_id survive, and forward auth still suppresses synthesis. --- src/adapters/openai-responses/passthrough.ts | 20 +++++--- src/config/schema/leaf-validators.ts | 1 + src/providers/derive.ts | 6 +++ src/providers/registry/entries-core.ts | 3 ++ src/providers/registry/types.ts | 5 ++ src/router.ts | 4 ++ src/types/provider.ts | 13 +++++ structure/providers/chat-compat.md | 26 +++++++--- .../xai/xai-responses-adjacency.test.ts | 50 ++++++++++++++++++- 9 files changed, 111 insertions(+), 17 deletions(-) diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 6df7419e40..64dcbc61e4 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -271,23 +271,27 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision); const stateless = provider.statelessResponses === true; const adjacentToolResults = provider.requiresAdjacentResponsesToolResults === true; + // Adjacency reorders items the upstream would accept in some order. Pairing synthesizes an + // item the client never sent, which is a larger claim about the conversation, so it is its + // own capability: Kimi carries the adjacency flag but accepts a dangling call (#4726) and + // must not start receiving placeholders it never needed. + const pairedToolResults = provider.requiresPairedResponsesToolResults === true; if (stateless) outBody = stripStatefulResponsesParams(outBody); // A replay miss can leave a function_call_output whose paired function_call sat // in the prefix that was never expanded. A stateless upstream cannot resolve the // pair from its own storage either, so it needs the same repair the forward // backend gets — dropping previous_response_id is not much use if the body that // reaches the wire is unparseable. - // Strict parsers that require adjacent call/result batches also 400 on a - // function_call with no matching output. DeepSeek gets that repair through - // statelessResponses. xAI cannot: its Responses API stores conversations for - // 30 days and documents previous_response_id. The adjacency capability is the - // existing strict tool-history gate, so non-forward adjacency providers - // reuse the orphan-call placeholder without stripping store. + // A parser can also 400 on a function_call with no matching output at all. DeepSeek gets + // that repair through statelessResponses. xAI cannot be marked stateless: its Responses API + // stores conversations for 30 days and documents previous_response_id. So it carries the + // pairing capability instead, which reuses the orphan-call placeholder without touching + // store or previous_response_id. if (provider.annotateEmptyToolOutputs === true) { outBody = annotateEmptyResponsesToolOutputs(outBody, true); } - const synthesizeMissingCallOutputs = !forward && (stateless || adjacentToolResults); - if (forward || stateless || adjacentToolResults) { + const synthesizeMissingCallOutputs = !forward && (stateless || pairedToolResults); + if (forward || stateless || pairedToolResults) { outBody = repairOrphanedInputItems(outBody, unexpandedMiss, synthesizeMissingCallOutputs); } if (adjacentToolResults) { diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index f9deab442b..b0ace501c5 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -243,6 +243,7 @@ export const providerConfigSchema = z.object({ chatCompletionsPath: z.string().min(1).optional(), statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), + requiresPairedResponsesToolResults: z.boolean().optional(), annotateEmptyToolOutputs: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), supportsServiceTier: z.boolean().optional(), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 02b3ba20dd..fb1050522f 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -266,6 +266,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults } : {}), + ...(entry.requiresPairedResponsesToolResults !== undefined + ? { requiresPairedResponsesToolResults: entry.requiresPairedResponsesToolResults } + : {}), ...(entry.annotateEmptyToolOutputs !== undefined ? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs } : {}), @@ -541,6 +544,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) { prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults; } + if (prov.requiresPairedResponsesToolResults === undefined && seed.requiresPairedResponsesToolResults !== undefined) { + prov.requiresPairedResponsesToolResults = seed.requiresPairedResponsesToolResults; + } if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) { prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs; } diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 9ecbb4d1f8..1c36394256 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -290,6 +290,9 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // statelessResponses: xAI stores responses for 30 days and documents previous_response_id. // https://docs.x.ai/developers/model-capabilities/text/comparison requiresAdjacentResponsesToolResults: true, + // The dangling half of the same failure: a call whose output never arrived. Kimi accepts that + // shape, so this is a second capability rather than a widening of the one above. + requiresPairedResponsesToolResults: true, // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index f71c0fafe4..a940be2390 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -217,6 +217,11 @@ export interface ProviderRegistryEntry { * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * Responses upstream that also rejects a tool call with no matching output anywhere in the + * replayed input. Seeded/backfilled like other fixed wire capabilities. + */ + requiresPairedResponsesToolResults?: boolean; /** * When enabled, tool results that are present but empty are annotated on the wire. * Seeded/backfilled like other fixed wire capabilities. diff --git a/src/router.ts b/src/router.ts index 70e427b74b..3e28a91186 100644 --- a/src/router.ts +++ b/src/router.ts @@ -384,6 +384,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider && registryEntry.requiresAdjacentResponsesToolResults !== undefined ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults } : {}), + ...(provider.requiresPairedResponsesToolResults === undefined + && registryEntry.requiresPairedResponsesToolResults !== undefined + ? { requiresPairedResponsesToolResults: registryEntry.requiresPairedResponsesToolResults } + : {}), ...(provider.annotateEmptyToolOutputs === undefined && registryEntry.annotateEmptyToolOutputs !== undefined ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs } diff --git a/src/types/provider.ts b/src/types/provider.ts index 49c9132b0a..ae69ecc39d 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -311,6 +311,19 @@ export interface OcxProviderConfig { * preserved after it, and parallel calls stay together with the reasoning turn that produced them. */ requiresAdjacentResponsesToolResults?: boolean; + /** + * Responses upstream whose parser also rejects a tool call that has no matching output + * anywhere in the replayed input, not merely one whose result sits out of order. A call left + * dangling by an interrupted stream is answered with an explicit unknown-status placeholder + * so the thread can continue. + * + * Separate from `requiresAdjacentResponsesToolResults` on purpose: adjacency reorders items a + * strict parser already accepts in some order, while this synthesizes an item the client never + * sent. Kimi accepts a dangling call (#4726), so it must not inherit the synthesis. + * `statelessResponses` implies this, because an upstream that stores nothing cannot resolve + * the missing half from its own history either. + */ + requiresPairedResponsesToolResults?: boolean; /** * When enabled, a tool result that is present but empty (no usable text or content * part) is rewritten to an explicit annotation before it reaches the upstream wire, diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 489ff42270..f143be0f6e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -146,16 +146,28 @@ Responses endpoint enforces the same strict shape and rejects a hook-split pair so `kimi` and `kimi-code` carry the flag as well. The flag is inert while those presets use the Chat wire and takes effect when a row is configured onto `openai-responses`, which is the configuration the report exercised. xAI Grok 4.6/4.5 subscription Responses carries the same flag: after a mid-stream -interrupt, Codex can replay a `function_call` without its output, or with hook-injected developer -context between the pair, and later turns 400. xAI's public Responses API is stateful (`store` -defaults true; `previous_response_id` continues a stored conversation), so the provider is not marked -`statelessResponses`. For a non-forward adjacency provider the existing orphan-call repair still -synthesizes an honest placeholder output without stripping store. The adjacency pass itself still -does not invent duplicate or backwards pairs. No upstream specification documents the adjacency -requirement; the evidence is the observed +interrupt, Codex can replay a `function_call` with hook-injected developer context between it and +its output, and later turns 400. The adjacency pass itself still does not invent duplicate or +backwards pairs. No upstream specification documents the adjacency requirement; the evidence is the observed 400 and DeepSeek's identical failure shape, which is why this stays a per-provider capability rather than a wire-wide default — upstream Codex leaves an intervening developer message where it is. +A mid-stream interrupt produces a second, different shape: a call whose output never arrived at all. +That is `requiresPairedResponsesToolResults`, a separate capability, and the separation is the whole +point. Adjacency reorders items the upstream would accept in some order; pairing synthesizes an item +the client never sent, which puts a tool turn into the conversation that did not happen. The evidence +differs too — #4726 shows Kimi accepting a call with no result at all, so `kimi` and `kimi-code` keep +adjacency and do not receive placeholders. `xai` carries both. `statelessResponses` implies pairing, +which is how DeepSeek already had it: an upstream that stores nothing cannot resolve the missing half +from its own history either. + +xAI's public Responses API is stateful (`store` defaults true; `previous_response_id` continues a +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. + > Decision record: [ADR-0052](../decisions/ADR-0052-reasoning-and-tool-result-compatibility.md) ## OpenRouter provider routing diff --git a/tests/providers/xai/xai-responses-adjacency.test.ts b/tests/providers/xai/xai-responses-adjacency.test.ts index 084e09de87..d9d8d9aeb4 100644 --- a/tests/providers/xai/xai-responses-adjacency.test.ts +++ b/tests/providers/xai/xai-responses-adjacency.test.ts @@ -35,12 +35,14 @@ function buildBody(provider: OcxProviderConfig, rawBody: Record } describe("xAI Responses tool-result adjacency", () => { - test("the xAI registry entry seeds adjacency without marking the provider stateless", () => { + test("the xAI registry entry seeds adjacency and pairing without marking the provider stateless", () => { const entry = getProviderRegistryEntry("xai")!; expect(entry.requiresAdjacentResponsesToolResults).toBe(true); + expect(entry.requiresPairedResponsesToolResults).toBe(true); expect(entry.statelessResponses).toBeUndefined(); const seed = providerConfigSeed(entry); expect(seed.requiresAdjacentResponsesToolResults).toBe(true); + expect(seed.requiresPairedResponsesToolResults).toBe(true); expect(seed.statelessResponses).toBeUndefined(); }); @@ -56,8 +58,10 @@ describe("xAI Responses tool-result adjacency", () => { expect(stale.requiresAdjacentResponsesToolResults).toBeUndefined(); expect(routedStale.requiresAdjacentResponsesToolResults).toBe(true); + expect(routedStale.requiresPairedResponsesToolResults).toBe(true); enrichProviderFromRegistry("xai", stale); expect(stale.requiresAdjacentResponsesToolResults).toBe(true); + expect(stale.requiresPairedResponsesToolResults).toBe(true); expect(stale.statelessResponses).toBeUndefined(); const body = buildBody(stale, { @@ -88,7 +92,10 @@ describe("xAI Responses tool-result adjacency", () => { }); test("keeps call_id pairing for two outstanding replayed calls and synthesizes only the missing output", () => { - const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true }); + const provider = xaiOauthResponses({ + requiresAdjacentResponsesToolResults: true, + requiresPairedResponsesToolResults: true, + }); const callA = { type: "function_call", call_id: "call_a", name: "exec_command", arguments: "{}" }; const callB = { type: "function_call", call_id: "call_b", name: "exec_command", arguments: "{}" }; const injected = { @@ -111,6 +118,7 @@ describe("xAI Responses tool-result adjacency", () => { const provider = xaiOauthResponses({ authMode: "forward", requiresAdjacentResponsesToolResults: true, + requiresPairedResponsesToolResults: true, headers: { authorization: "Bearer xai-oauth" }, }); const call = { type: "function_call", call_id: "call_fwd", name: "exec_command", arguments: "{}" }; @@ -120,4 +128,42 @@ describe("xAI Responses tool-result adjacency", () => { expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_fwd" }); expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); }); + + test("adjacency alone never synthesizes an output the client did not send", () => { + // Kimi and kimi-code carry the adjacency flag because their parser rejects a hook-split pair + // (#4726), but that same report shows a call left without any result is accepted. Inventing a + // placeholder there would put a tool turn into the conversation that never happened, so the + // two capabilities stay separate rather than one widening into the other. + for (const providerName of ["kimi", "kimi-code"]) { + const entry = getProviderRegistryEntry(providerName)!; + expect(entry.requiresAdjacentResponsesToolResults).toBe(true); + expect(entry.requiresPairedResponsesToolResults).toBeUndefined(); + expect(entry.statelessResponses).toBeUndefined(); + } + + const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true }); + const call = { type: "function_call", call_id: "call_dangling", name: "exec_command", arguments: "{}" }; + const next = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + const body = buildBody(provider, { input: [call, next] }); + + expect(body.input).toEqual([call, next]); + expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); + }); + + test("a dangling custom_tool_call is paired before it is lowered for the upstream", () => { + // xAI rejects the native custom-tool shape, so the repair has to happen before + // rewriteRoutedCustomToolsForUpstream converts the call; otherwise the lowering would carry a + // call with nothing to pair against. + const provider = xaiOauthResponses({ + requiresAdjacentResponsesToolResults: true, + requiresPairedResponsesToolResults: true, + }); + const call = { type: "custom_tool_call", call_id: "call_custom", name: "apply_patch", input: "patch" }; + const next = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; + const body = buildBody(provider, { input: [call, next] }); + const input = body.input as Array>; + + expect(input[1]).toMatchObject({ call_id: "call_custom" }); + expect(String(input[1].output)).toContain("no tool result was recorded"); + }); }); From d03c2b90aeae05cc4fcfaadc02f1df219ecc2758 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 18:06:28 +0900 Subject: [PATCH 3/5] test(xai): pin the dangling custom tool call through the lowering it actually takes The previous case declared no custom tool, so collectRoutedCustomToolNames found nothing and the lowering never ran; it proved only that the repair indexes custom calls. xAI sets supportsResponsesCustomTools: false, so the production shape is a declared custom tool that gets lowered, and that is what this now asserts end to end. --- .../xai/xai-responses-adjacency.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/providers/xai/xai-responses-adjacency.test.ts b/tests/providers/xai/xai-responses-adjacency.test.ts index d9d8d9aeb4..1ccc885aa3 100644 --- a/tests/providers/xai/xai-responses-adjacency.test.ts +++ b/tests/providers/xai/xai-responses-adjacency.test.ts @@ -150,20 +150,27 @@ describe("xAI Responses tool-result adjacency", () => { expect(JSON.stringify(body)).not.toContain("no tool result was recorded"); }); - test("a dangling custom_tool_call is paired before it is lowered for the upstream", () => { - // xAI rejects the native custom-tool shape, so the repair has to happen before - // rewriteRoutedCustomToolsForUpstream converts the call; otherwise the lowering would carry a - // call with nothing to pair against. + test("a dangling custom_tool_call reaches xAI as a paired, lowered function call", () => { + // The pairing repair runs before rewriteRoutedCustomToolsForUpstream, so a custom call + // interrupted mid-stream is answered first and the pair is lowered together. xAI rejects the + // native custom shape (supportsResponsesCustomTools: false on the registry entry), which is + // what makes the lowering run at all, so the production shape is what this pins. const provider = xaiOauthResponses({ requiresAdjacentResponsesToolResults: true, requiresPairedResponsesToolResults: true, + supportsResponsesCustomTools: false, }); const call = { type: "custom_tool_call", call_id: "call_custom", name: "apply_patch", input: "patch" }; const next = { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }; - const body = buildBody(provider, { input: [call, next] }); + const body = buildBody(provider, { + tools: [{ type: "custom", name: "apply_patch", description: "Apply a patch." }], + input: [call, next], + }); const input = body.input as Array>; - expect(input[1]).toMatchObject({ call_id: "call_custom" }); + expect(input[0]).toMatchObject({ type: "function_call", call_id: "call_custom", name: "apply_patch" }); + expect(input[1]).toMatchObject({ type: "function_call_output", call_id: "call_custom" }); expect(String(input[1].output)).toContain("no tool result was recorded"); + expect(input[2]).toMatchObject({ type: "message", role: "user" }); }); }); From cb5cafaba66e95f663a894a91b9000dce2bf9469 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 18:07:39 +0900 Subject: [PATCH 4/5] docs(structure): note the xAI-only pairing capability on the provider page providers/xai-grok.md co-owns src/providers/ and says the surface retains its existing behavior, which stopped being true once a capability was seeded for xAI alone. Points at chat-compat.md rather than restating the contract in two places. --- structure/providers/xai-grok.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index e2b5ee6f0f..8fc9514b1a 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -3,6 +3,12 @@ xAI uses the same shared credential and delivery policies through the Responses [core module ownership](../transports/responses.md#core-module-ownership). This surface retains its existing behavior. +One Responses capability is seeded for xAI alone: `requiresPairedResponsesToolResults`, which +answers a replayed tool call whose output never arrived. It is deliberately not the same flag as +`requiresAdjacentResponsesToolResults`, which xAI also carries and shares with the Kimi presets. +The contract for both, and the reason they do not collapse into one, is specified in +[chat-compat](./chat-compat.md); it is not restated here. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. From 7c30bce5266ba9cafc2b7c66fa4230af0427f228 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 19:38:37 +0900 Subject: [PATCH 5/5] fix(server): classify the new pairing capability in the provider field policy PROVIDER_CONFIG_FIELD_POLICY is satisfies Record, so adding a field to OcxProviderConfig without a policy row fails the whole typecheck from a different file. That is what broke gates and the production adapter contract test on Linux and Windows. editor matches the sibling wire capabilities (statelessResponses, requiresAdjacentResponsesToolResults, annotateEmptyToolOutputs) and matches what the policy means: the field is user-authorable through the leaf validator schema and is seeded from the registry only when the user has not set it. It carries no credential and is not a runtime observation. --- src/server/auth-cors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index be6bfd3fca..32fc9fe8ce 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -913,6 +913,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { commandCodeVersion: "editor", statelessResponses: "editor", requiresAdjacentResponsesToolResults: "editor", + requiresPairedResponsesToolResults: "editor", annotateEmptyToolOutputs: "editor", supportsServiceTier: "editor", modelSupportsServiceTier: "editor",