From 878c27afd4d1089bf43db4ba3161628802fd4ef1 Mon Sep 17 00:00:00 2001 From: "kunqi.lai" Date: Mon, 31 Aug 2026 17:58:39 +0800 Subject: [PATCH 1/5] fix(responses): always carry query on web_search_call for Console Go DeepSeek's native Responses parser requires 'queries', while Console Go's upstream validator requires 'query'. A multi-query web_search_call emitted by webSearchAction only carried 'queries', so replayed history 400'd on every subsequent turn with: input[N].action missing required field 'query' (sibling of #930). - webSearchAction now always includes action.query (first query) alongside queries, satisfying both strict parsers. - backfillWebSearchQueries repairs pre-existing recorded items in either missing direction (adds queries from query, or query from queries[0]). Verified: a Responses request replaying an old-format multi-query web_search_call now completes instead of 400ing on upstream. --- src/adapters/openai-responses.ts | 16 +++++++++++++--- src/bridge.ts | 22 +++++++++------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 047c60a6a3..a420434336 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -920,9 +920,19 @@ function backfillWebSearchQueries(body: unknown): unknown { if (!isPlainObject(item) || item.type !== "web_search_call") return item; const action = item.action; if (!isPlainObject(action) || action.type !== "search") return item; - if (typeof action.query !== "string" || Array.isArray(action.queries)) return item; - changed = true; - return { ...item, action: { ...action, queries: [action.query] } }; + // Repair whichever side is missing so both strict parsers pass: + // DeepSeek native Responses requires `queries`; Console Go requires `query`. + const rep: Record = { ...action }; + let itemChanged = false; + if (typeof action.query !== "string" && Array.isArray(action.queries) && action.queries.length > 0) { + rep.query = action.queries[0]; // multi-query item recorded before the fix + itemChanged = true; + } else if (typeof action.query === "string" && !Array.isArray(action.queries)) { + rep.queries = [action.query]; // single-query item recorded before the fix + itemChanged = true; + } + if (itemChanged) changed = true; + return itemChanged ? { ...item, action: rep } : item; }); return changed ? { ...body, input } : body; } diff --git a/src/bridge.ts b/src/bridge.ts index dcb163553c..e45fb48ab6 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -149,24 +149,20 @@ export { adapterFailureFromMessage } from "./lib/errors"; * Single query → `{ query, queries: [query] }`. Batch → `{ queries }` with NO singular * `query`. Empty → `{ query: "", queries: [""] }`. * - * The asymmetry is load-bearing in both directions. codex-rs prefers a non-empty `query` - * for the cell label and renders " ..." only when `query` is ABSENT and - * `queries.len() > 1`, so adding `query` to a batch would collapse the plural ellipsis. - * Meanwhile DeepSeek's native Responses parser makes `queries` a required field, so a - * replayed one-term `web_search_call` — carried in the history of every subsequent turn - * — fails deserialization with `missing field 'queries'` and 400s the rest of the - * conversation (#930). Carrying both keys in the single case satisfies the strict parser - * without changing what codex-rs displays. + * The asymmetry is load-bearing in both directions. DeepSeek's native Responses parser + * makes `queries` a required field, and Console Go's upstream validator makes `query` a + * required field — so a replayed `web_search_call` carried in the history of every + * subsequent turn fails deserialization with `missing field 'queries'` (#930) or 400s + * with `missing required field 'query'` unless both keys are present. Carrying both keys + * in every case satisfies both strict parsers; the trade-off is that a multi-query batch + * loses the " ..." ellipsis in codex-rs and shows the first query as the label. * * This fixes items created from here on. History recorded before it is repaired at the * replay boundary by `backfillWebSearchQueries()` in the Responses adapter. */ function webSearchAction(queries: string[]): Record { - if (queries.length <= 1) { - const query = queries[0] ?? ""; - return { type: "search", query, queries: [query] }; - } - return { type: "search", queries }; + const first = queries[0] ?? ""; + return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } interface OutputItem { From 085d0342550aeb4af1da1340038c108a9be580fe Mon Sep 17 00:00:00 2001 From: "kunqi.lai" Date: Mon, 31 Aug 2026 18:26:59 +0800 Subject: [PATCH 2/5] test(responses): cover query+queries parity on web_search_call (#3071) - bridge: batched search now asserts both 'query' and 'queries' (was: queries-only), matching the new webSearchAction output. - passthrough: backfill test now asserts the reverse repair direction (multi-query action gains singular 'query'), matching the extended backfillWebSearchQueries. --- tests/bridge.test.ts | 9 +++++---- tests/openai-responses-passthrough.test.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 444f3d6e77..420b2f6996 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1075,7 +1075,7 @@ describe("Responses bridge web_search_call native item", () => { }); }); - test("a batched (plural) search emits action.search.queries without a singular query", () => { + test("a batched (plural) search carries both query and queries for Console Go (#3071)", () => { const json = buildResponseJSON([ { type: "web_search_call_begin", id: "ws_3" }, { type: "web_search_call_end", id: "ws_3", queries: ["rust async", "tokio runtime"] }, @@ -1085,9 +1085,10 @@ describe("Responses bridge web_search_call native item", () => { const output = json.output as Record[]; const action = (output[0] as Record).action as Record; - // Native renders " ..." only when `query` is absent and queries.len() > 1. - expect(action).toEqual({ type: "search", queries: ["rust async", "tokio runtime"] }); - expect(action.query).toBeUndefined(); + // Console Go's upstream validator requires singular `query` on the search action, + // and DeepSeek native Responses requires `queries` — so a batch carries both now. + expect(action).toEqual({ type: "search", query: "rust async", queries: ["rust async", "tokio runtime"] }); + expect(action.query).toBe("rust async"); }); test("a single-query search also carries queries so strict parsers accept the replay (#930)", () => { diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 6687671023..167a679039 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -1295,11 +1295,11 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(input[0]).not.toHaveProperty("id"); }); - test("backfills queries on a replayed single-query web_search_call (#930)", () => { + test("backfills web_search_call actions in either missing direction (#930, #3071)", () => { // The bridge fix only helps items created after it. A conversation that already - // recorded {type:"search", query:"..."} replays that stored item every turn, and - // DeepSeek's parser rejects the whole request over it — so upgrading alone would - // leave those threads permanently broken. + // recorded a legacy web_search_call replays that stored item every turn. DeepSeek's + // parser rejects an action without `queries` (#930) and Console Go rejects one + // without `query` (#3071) — so upgrading alone would leave those threads broken. const adapter = createResponsesPassthroughAdapter(provider); const request = adapter.buildRequest({ modelId: "provider-model", @@ -1319,9 +1319,8 @@ describe("OpenAI Responses passthrough sanitization", () => { // Repaired: singular query gains the array the strict parser requires. expect(input[0].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] }); - // Untouched: a batch already satisfies the parser, and adding `query` would collapse - // the native plural rendering. - expect(input[1].action).toEqual({ type: "search", queries: ["a", "b"] }); + // Repaired: multi-query batch gains the singular `query` Console Go requires. + expect(input[1].action).toEqual({ type: "search", query: "a", queries: ["a", "b"] }); // Untouched: not a search action. expect(input[2].action).toEqual({ type: "open_page", url: "https://example.test" }); }); From 8f426e8ac8407fbfb11a65d44862ca351027b20d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 00:34:08 +0900 Subject: [PATCH 3/5] fix(responses): reject a malformed queries array instead of forging a query Follow-up on the #3071 carry. The bidirectional repair copied action.queries[0] into the singular query without checking it is a string. Input items use a loose schema, so a replayed queries: [42] became query: 42 - a shape that satisfies the presence check, still fails the Console Go validator the repair exists to satisfy, and reports success while doing it. A non-string first member is now left alone. An empty queries: [] canonicalizes to { query: "", queries: [""] }, the same shape the bridge emits for an empty search, rather than being passed through as a value neither validator accepts. Also updates the bridge summary comment, which still described the old batch-omits-query contract two lines above the code that carries both, and records why the codex-rs ellipsis is traded away so it is not restored. The regression drives both branches red against the pre-fix adapter: queries: [42] and [{q:"x"}] are left untouched, [] canonicalizes. --- src/adapters/openai-responses.ts | 32 +++++++++++++++++----- src/bridge.ts | 10 +++++-- tests/openai-responses-passthrough.test.ts | 30 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a420434336..45260c8ba7 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -901,13 +901,21 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk * Runs on every forward request; with intact pairs it returns the original reference. */ /** - * Backfill `queries` on a replayed single-query `web_search_call`. + * Repair a replayed `web_search_call` action that is missing either key. * * `webSearchAction()` in the bridge now emits both keys, but that only helps items * created after the fix. A conversation that already recorded - * `{type:"search", query:"..."}` replays that stored item on every subsequent turn, and - * DeepSeek's native Responses parser requires `queries` — so upgrading alone leaves - * those threads permanently 400ing with `missing field 'queries'` (#930). + * `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored + * item on every subsequent turn. DeepSeek's native Responses parser requires `queries` + * (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves + * those threads permanently 400ing in one direction or the other. The repair runs both + * ways. + * + * Input items carry a loose schema, so a stored `queries` is not necessarily an array of + * strings. A non-string first member is left alone rather than copied into `query`: + * writing `query: 123` would satisfy the presence check and still fail the validator this + * repair exists to satisfy, while claiming a successful repair. An empty `queries: []` + * canonicalizes to the same shape the bridge emits for an empty search. * * Runs on every Responses request, on both `input` items and the `action` nested inside * them. Returns the original reference when nothing needs repair, so the common path @@ -924,9 +932,19 @@ function backfillWebSearchQueries(body: unknown): unknown { // DeepSeek native Responses requires `queries`; Console Go requires `query`. const rep: Record = { ...action }; let itemChanged = false; - if (typeof action.query !== "string" && Array.isArray(action.queries) && action.queries.length > 0) { - rep.query = action.queries[0]; // multi-query item recorded before the fix - itemChanged = true; + if (typeof action.query !== "string" && Array.isArray(action.queries)) { + if (action.queries.length === 0) { + // An empty array satisfies neither validator. Canonicalize to the empty-search + // shape the bridge emits rather than inventing a query. + rep.query = ""; + rep.queries = [""]; + itemChanged = true; + } else if (typeof action.queries[0] === "string") { + rep.query = action.queries[0]; // multi-query item recorded before the fix + itemChanged = true; + } + // A non-string first member is left untouched: copying it would produce an invalid + // singular field while reporting a successful repair. } else if (typeof action.query === "string" && !Array.isArray(action.queries)) { rep.queries = [action.query]; // single-query item recorded before the fix itemChanged = true; diff --git a/src/bridge.ts b/src/bridge.ts index e45fb48ab6..145913ddab 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -146,10 +146,10 @@ export { adapterFailureFromMessage } from "./lib/errors"; /** * Build the native `WebSearchAction::Search` payload from the queries that ran. * - * Single query → `{ query, queries: [query] }`. Batch → `{ queries }` with NO singular - * `query`. Empty → `{ query: "", queries: [""] }`. + * Every action carries BOTH keys: `{ query, queries }`, where `query` is the first + * member. Empty → `{ query: "", queries: [""] }`. * - * The asymmetry is load-bearing in both directions. DeepSeek's native Responses parser + * Carrying both is load-bearing in both directions. DeepSeek's native Responses parser * makes `queries` a required field, and Console Go's upstream validator makes `query` a * required field — so a replayed `web_search_call` carried in the history of every * subsequent turn fails deserialization with `missing field 'queries'` (#930) or 400s @@ -157,6 +157,10 @@ export { adapterFailureFromMessage } from "./lib/errors"; * in every case satisfies both strict parsers; the trade-off is that a multi-query batch * loses the " ..." ellipsis in codex-rs and shows the first query as the label. * + * That trade is deliberate: a cosmetic label against a conversation that 400s on every + * subsequent turn. Do not restore the old batch-omits-`query` shape to win the ellipsis + * back — it reopens #3071. + * * This fixes items created from here on. History recorded before it is repaired at the * replay boundary by `backfillWebSearchQueries()` in the Responses adapter. */ diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 167a679039..7090e2a6ea 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -1325,6 +1325,36 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(input[2].action).toEqual({ type: "open_page", url: "https://example.test" }); }); + test("does not forge a singular query from a malformed or empty queries array (#3071)", () => { + // Input items use a loose schema, so a stored `queries` need not be an array of + // strings. Copying a non-string first member would satisfy the presence check and + // still fail the Console Go validator this repair exists to satisfy — a repair that + // reports success and produces an invalid shape is worse than no repair. + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "provider-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-model", + input: [ + { type: "web_search_call", id: "ws_num", status: "completed", action: { type: "search", queries: [42] } }, + { type: "web_search_call", id: "ws_obj", status: "completed", action: { type: "search", queries: [{ q: "x" }] } }, + { type: "web_search_call", id: "ws_empty", status: "completed", action: { type: "search", queries: [] } }, + ], + }, + }, meta); + const input = (JSON.parse(request.body) as { input: Array<{ action: Record }> }).input; + + // Left alone: a non-string first member is not a query. + expect(input[0].action).toEqual({ type: "search", queries: [42] }); + expect(input[1].action).toEqual({ type: "search", queries: [{ q: "x" }] }); + // Canonicalized: an empty array satisfies neither validator, so it becomes the same + // empty-search shape the bridge emits rather than being passed through. + expect(input[2].action).toEqual({ type: "search", query: "", queries: [""] }); + }); + test("strips invalid type-specific ids from serialized input items", () => { const adapter = createResponsesPassthroughAdapter(provider); const encryptedContent = "opaque-openai-encrypted-content"; From ed31953a8e14f1278c1a60b4baabb81ea1903984 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 00:46:25 +0900 Subject: [PATCH 4/5] fix(responses): close two malformed-array holes in the replay repair Review of the #3071 carry found the first guard incomplete in both directions. An action carrying { query: "legacy", queries: [] } bypassed every branch: the empty-array repair only ran when query was absent, so the item kept an empty plural array that DeepSeek rejects. Empty-array canonicalization now runs first and keeps the existing query instead of discarding it. An action carrying { queries: ["a", 42] } gained query: "a" and kept the invalid array, satisfying Console Go while leaving DeepSeek to reject the same replay. The singular field is now derived only when every member is a string; a partly-malformed array is left untouched, on the same fail-closed reasoning as a wholly-malformed one - coercing or dropping members would invent semantics the stored item never had. Also updates the call-site comment, which described only the old one-way repair and #930. Both cases are red against the previous commit. --- src/adapters/openai-responses.ts | 47 +++++++++++++--------- tests/openai-responses-passthrough.test.ts | 24 +++++++++++ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 45260c8ba7..97044bc804 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -912,10 +912,12 @@ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unk * ways. * * Input items carry a loose schema, so a stored `queries` is not necessarily an array of - * strings. A non-string first member is left alone rather than copied into `query`: - * writing `query: 123` would satisfy the presence check and still fail the validator this - * repair exists to satisfy, while claiming a successful repair. An empty `queries: []` - * canonicalizes to the same shape the bridge emits for an empty search. + * strings. A partly- or wholly-malformed array is left alone rather than used as a source + * for the singular field: writing `query: 123` would satisfy the presence check and still + * fail the validator this repair exists to satisfy, and deriving `query` from + * `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay. + * An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search, + * keeping an existing `query` when the item has one. * * Runs on every Responses request, on both `input` items and the `action` nested inside * them. Returns the original reference when nothing needs repair, so the common path @@ -932,20 +934,26 @@ function backfillWebSearchQueries(body: unknown): unknown { // DeepSeek native Responses requires `queries`; Console Go requires `query`. const rep: Record = { ...action }; let itemChanged = false; - if (typeof action.query !== "string" && Array.isArray(action.queries)) { - if (action.queries.length === 0) { - // An empty array satisfies neither validator. Canonicalize to the empty-search - // shape the bridge emits rather than inventing a query. - rep.query = ""; - rep.queries = [""]; - itemChanged = true; - } else if (typeof action.queries[0] === "string") { - rep.query = action.queries[0]; // multi-query item recorded before the fix + const hasQuery = typeof action.query === "string"; + const queries = Array.isArray(action.queries) ? action.queries : undefined; + if (queries !== undefined && queries.length === 0) { + // An empty array satisfies neither validator. Canonicalize to the empty-search + // shape the bridge emits, keeping an existing query rather than discarding it. + const query = hasQuery ? action.query as string : ""; + rep.query = query; + rep.queries = [query]; + itemChanged = true; + } else if (!hasQuery && queries !== undefined) { + // A plural array is only a usable source for the singular field when EVERY member + // is a string: deriving `query` from a partly-malformed array would satisfy Console + // Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are + // left untouched — coercing or dropping members would invent semantics the stored + // item never had. + if (queries.every(entry => typeof entry === "string")) { + rep.query = queries[0]; // multi-query item recorded before the fix itemChanged = true; } - // A non-string first member is left untouched: copying it would produce an invalid - // singular field while reporting a successful repair. - } else if (typeof action.query === "string" && !Array.isArray(action.queries)) { + } else if (hasQuery && queries === undefined) { rep.queries = [action.query]; // single-query item recorded before the fix itemChanged = true; } @@ -2125,9 +2133,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = repairOversizedReplayCallIds(outBody); } outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId); - // Repair stored history from before the bridge emitted both keys: a conversation - // that already recorded a single-query web_search_call replays it every turn, and - // a strict parser rejects the whole request over it (#930). + // Repair stored history from before the bridge emitted both keys, in either + // direction: a conversation that already recorded a web_search_call replays it + // every turn, and a strict parser rejects the whole request over the missing key — + // `queries` for DeepSeek (#930), `query` for Console Go (#3071). outBody = backfillWebSearchQueries(outBody); if (!isCanonicalOpenAiForwardProvider(provider)) { outBody = promoteClientLoadedTools(outBody); diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 7090e2a6ea..edba6c393e 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -1355,6 +1355,30 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(input[2].action).toEqual({ type: "search", query: "", queries: [""] }); }); + test("repairs a partly-malformed or already-queried empty action (#3071)", () => { + const adapter = createResponsesPassthroughAdapter(provider); + const request = adapter.buildRequest({ + modelId: "provider-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "provider-model", + input: [ + { type: "web_search_call", id: "ws_mixed", status: "completed", action: { type: "search", queries: ["a", 42] } }, + { type: "web_search_call", id: "ws_qempty", status: "completed", action: { type: "search", query: "legacy", queries: [] } }, + ], + }, + }, meta); + const input = (JSON.parse(request.body) as { input: Array<{ action: Record }> }).input; + + // Left alone: deriving `query: "a"` would satisfy Console Go and leave DeepSeek to + // reject the same replay over the non-string second member. + expect(input[0].action).toEqual({ type: "search", queries: ["a", 42] }); + // Canonicalized without discarding the query the item already carried. + expect(input[1].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] }); + }); + test("strips invalid type-specific ids from serialized input items", () => { const adapter = createResponsesPassthroughAdapter(provider); const encryptedContent = "opaque-openai-encrypted-content"; From 58d0a42e5828ec35a921746d750f6aa4f2e09217 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 1 Sep 2026 01:25:12 +0900 Subject: [PATCH 5/5] test(bridge): drop an assertion the preceding equality already makes The review lane flagged expect(action.query).toBe("rust async") as redundant: the toEqual above it already pins the whole action shape, including that field. A redundant assertion is not free - it reads as an independent check and gives false weight to the coverage count. --- tests/bridge.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 420b2f6996..f031ce8f8b 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -1088,7 +1088,6 @@ describe("Responses bridge web_search_call native item", () => { // Console Go's upstream validator requires singular `query` on the search action, // and DeepSeek native Responses requires `queries` — so a batch carries both now. expect(action).toEqual({ type: "search", query: "rust async", queries: ["rust async", "tokio runtime"] }); - expect(action.query).toBe("rust async"); }); test("a single-query search also carries queries so strict parsers accept the replay (#930)", () => {