Skip to content

Commit 6a7351b

Browse files
authored
Merge pull request #932 from lidge-jun/codex/930-websearch-queries
fix(web-search): carry queries on a single-query search, and repair replayed history Closes #930. A one-term web_search_call emitted only singular query, which DeepSeek's native Responses parser rejects as missing field 'queries' — and since the item replays in every later turn, one search 400s the rest of the thread. Single queries now carry both keys; batches stay clean so codex-rs keeps its plural ellipsis. Conversations already carrying the legacy shape are repaired at the replay boundary. 3 regressions, two ablations, 7590 pass / 0 fail.
2 parents d74b73f + 185bb44 commit 6a7351b

4 files changed

Lines changed: 115 additions & 6 deletions

File tree

src/adapters/openai-responses.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,33 @@ function toolOutputText(output: unknown): string {
473473
* reasoning chain is intact and must be preserved.
474474
* Runs on every forward request; with intact pairs it returns the original reference.
475475
*/
476+
/**
477+
* Backfill `queries` on a replayed single-query `web_search_call`.
478+
*
479+
* `webSearchAction()` in the bridge now emits both keys, but that only helps items
480+
* created after the fix. A conversation that already recorded
481+
* `{type:"search", query:"..."}` replays that stored item on every subsequent turn, and
482+
* DeepSeek's native Responses parser requires `queries` — so upgrading alone leaves
483+
* those threads permanently 400ing with `missing field 'queries'` (#930).
484+
*
485+
* Runs on every Responses request, on both `input` items and the `action` nested inside
486+
* them. Returns the original reference when nothing needs repair, so the common path
487+
* allocates nothing.
488+
*/
489+
function backfillWebSearchQueries(body: unknown): unknown {
490+
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
491+
let changed = false;
492+
const input = body.input.map(item => {
493+
if (!isPlainObject(item) || item.type !== "web_search_call") return item;
494+
const action = item.action;
495+
if (!isPlainObject(action) || action.type !== "search") return item;
496+
if (typeof action.query !== "string" || Array.isArray(action.queries)) return item;
497+
changed = true;
498+
return { ...item, action: { ...action, queries: [action.query] } };
499+
});
500+
return changed ? { ...body, input } : body;
501+
}
502+
476503
function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknown {
477504
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
478505
const input = body.input;
@@ -1145,6 +1172,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
11451172
outBody = repairOversizedReplayCallIds(outBody);
11461173
}
11471174
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
1175+
// Repair stored history from before the bridge emitted both keys: a conversation
1176+
// that already recorded a single-query web_search_call replays it every turn, and
1177+
// a strict parser rejects the whole request over it (#930).
1178+
outBody = backfillWebSearchQueries(outBody);
11481179
// Same predicate as the routedCompaction gate in handleResponses(): an
11491180
// authMode check would let a noncanonical custom forward provider skip this
11501181
// rewrite while the server still routes it as a summarizer turn (#422).

src/bridge.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,28 @@ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>
115115
export { adapterFailureFromMessage } from "./lib/errors";
116116

117117
/**
118-
* Build the native `WebSearchAction::Search` payload from the queries that ran. codex-rs prefers a
119-
* non-empty `query` over `queries` for the cell label, and only renders "<first> ..." when `query`
120-
* is absent and `queries.len() > 1`. So a single query → `{ query }`; multiple → `{ queries }` with
121-
* no singular `query`, so Codex shows the native plural ellipsis. Empty → `{ query: "" }`.
118+
* Build the native `WebSearchAction::Search` payload from the queries that ran.
119+
*
120+
* Single query → `{ query, queries: [query] }`. Batch → `{ queries }` with NO singular
121+
* `query`. Empty → `{ query: "", queries: [""] }`.
122+
*
123+
* The asymmetry is load-bearing in both directions. codex-rs prefers a non-empty `query`
124+
* for the cell label and renders "<first> ..." only when `query` is ABSENT and
125+
* `queries.len() > 1`, so adding `query` to a batch would collapse the plural ellipsis.
126+
* Meanwhile DeepSeek's native Responses parser makes `queries` a required field, so a
127+
* replayed one-term `web_search_call` — carried in the history of every subsequent turn
128+
* — fails deserialization with `missing field 'queries'` and 400s the rest of the
129+
* conversation (#930). Carrying both keys in the single case satisfies the strict parser
130+
* without changing what codex-rs displays.
131+
*
132+
* This fixes items created from here on. History recorded before it is repaired at the
133+
* replay boundary by `backfillWebSearchQueries()` in the Responses adapter.
122134
*/
123135
function webSearchAction(queries: string[]): Record<string, unknown> {
124-
if (queries.length <= 1) return { type: "search", query: queries[0] ?? "" };
136+
if (queries.length <= 1) {
137+
const query = queries[0] ?? "";
138+
return { type: "search", query, queries: [query] };
139+
}
125140
return { type: "search", queries };
126141
}
127142

tests/bridge.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,9 @@ describe("Responses bridge web_search_call native item", () => {
882882
expect((addedItem.id as string).startsWith("ws_")).toBe(true);
883883
expect(doneItem.id).toBe(addedItem.id);
884884
expect(doneItem.status).toBe("completed");
885-
expect(doneItem.action).toEqual({ type: "search", query: "current docs" });
885+
// Both shapes: codex-rs reads `query`, and DeepSeek's native Responses parser
886+
// requires `queries` when the item is replayed in later turns (#930).
887+
expect(doneItem.action).toEqual({ type: "search", query: "current docs", queries: ["current docs"] });
886888

887889
const completed = frames.find(f => f.event === "response.completed")?.data.response as Record<string, unknown>;
888890
const output = completed.output as Record<string, unknown>[];
@@ -920,6 +922,36 @@ describe("Responses bridge web_search_call native item", () => {
920922
expect(action.query).toBeUndefined();
921923
});
922924

925+
test("a single-query search also carries queries so strict parsers accept the replay (#930)", () => {
926+
// DeepSeek's native Responses parser requires `queries`. Without it, the replayed
927+
// web_search_call in every later turn of the conversation fails deserialization with
928+
// `missing field 'queries'` and 400s the whole thread.
929+
const json = buildResponseJSON([
930+
{ type: "web_search_call_begin", id: "ws_930" },
931+
{ type: "web_search_call_end", id: "ws_930", queries: ["deepseek responses"] },
932+
{ type: "text_delta", text: "answer" },
933+
{ type: "done" },
934+
], "routed/model");
935+
936+
const action = (json.output as Record<string, unknown>[])[0].action as Record<string, unknown>;
937+
expect(action.queries).toEqual(["deepseek responses"]);
938+
// `query` stays present: codex-rs reads it, and the single-query rendering depends
939+
// on it, so this is additive rather than a swap.
940+
expect(action.query).toBe("deepseek responses");
941+
});
942+
943+
test("an empty-query search still carries a queries array (#930)", () => {
944+
const json = buildResponseJSON([
945+
{ type: "web_search_call_begin", id: "ws_931" },
946+
{ type: "web_search_call_end", id: "ws_931", queries: [] },
947+
{ type: "done" },
948+
], "routed/model");
949+
950+
const action = (json.output as Record<string, unknown>[])[0].action as Record<string, unknown>;
951+
expect(action.queries).toEqual([""]);
952+
expect(action.query).toBe("");
953+
});
954+
923955
test("streaming: web_search_call_end sources attach as url_citation annotations on the next message", async () => {
924956
const frames = await collectSse(bridgeToResponsesSSE(replay([
925957
{ type: "web_search_call_begin", id: "ws_4" },

tests/openai-responses-passthrough.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,37 @@ describe("OpenAI Responses passthrough sanitization", () => {
309309
expect(input[0]).not.toHaveProperty("id");
310310
});
311311

312+
test("backfills queries on a replayed single-query web_search_call (#930)", () => {
313+
// The bridge fix only helps items created after it. A conversation that already
314+
// recorded {type:"search", query:"..."} replays that stored item every turn, and
315+
// DeepSeek's parser rejects the whole request over it — so upgrading alone would
316+
// leave those threads permanently broken.
317+
const adapter = createResponsesPassthroughAdapter(provider);
318+
const request = adapter.buildRequest({
319+
modelId: "provider-model",
320+
context: { messages: [] },
321+
stream: true,
322+
options: {},
323+
_rawBody: {
324+
model: "provider-model",
325+
input: [
326+
{ type: "web_search_call", id: "ws_legacy", status: "completed", action: { type: "search", query: "legacy" } },
327+
{ type: "web_search_call", id: "ws_batch", status: "completed", action: { type: "search", queries: ["a", "b"] } },
328+
{ type: "web_search_call", id: "ws_other", status: "completed", action: { type: "open_page", url: "https://example.test" } },
329+
],
330+
},
331+
}, meta);
332+
const input = (JSON.parse(request.body) as { input: Array<{ action: Record<string, unknown> }> }).input;
333+
334+
// Repaired: singular query gains the array the strict parser requires.
335+
expect(input[0].action).toEqual({ type: "search", query: "legacy", queries: ["legacy"] });
336+
// Untouched: a batch already satisfies the parser, and adding `query` would collapse
337+
// the native plural rendering.
338+
expect(input[1].action).toEqual({ type: "search", queries: ["a", "b"] });
339+
// Untouched: not a search action.
340+
expect(input[2].action).toEqual({ type: "open_page", url: "https://example.test" });
341+
});
342+
312343
test("strips invalid type-specific ids from serialized input items", () => {
313344
const adapter = createResponsesPassthroughAdapter(provider);
314345
const encryptedContent = "opaque-openai-encrypted-content";

0 commit comments

Comments
 (0)