Skip to content

Commit 89931a1

Browse files
committed
fix(web-search): carry raw reasoning into the replayed web_search turn
Closes #688. DeepSeek V4 thinking mode requires the assistant reasoning that preceded a tool call to come back with it. extractIterationThinking() only accumulated thinking_delta, so for OpenAI-compatible providers — which emit reasoning_raw_delta instead (openai-chat.ts:743) — it returned null and the replayed turn went upstream as a bare tool call. DeepSeek rejected it with a 400; that surfaced downstream as a 502 only because the loop drops LoopError.status once the SSE is already open (loop.ts:564), so the reported symptom and the real upstream status differ. The status-mapping gap is left alone here: a successful replay never reaches it. The serializer side already worked — openai-chat.ts:185 emits reasoning_content from thinking parts for models in preserveReasoningContentModels, and DeepSeek V4 is registered. It was simply never handed any part. Raw reasoning becomes a SEPARATE UNSIGNED part rather than joining the signed block. A signature authenticates the exact text it closed, so appending raw text to it would invalidate the pairing and, worse, dress unrelated chain-of-thought as an authenticated provider block. Unsigned parts are skipped by the anthropic serializer and serialized by openai-chat, so both contracts hold at once. The extractor also ports the per-block flush from src/images/loop.ts: the old single-signature accumulator collapsed `thinking(a), sig1, thinking(b), sig2` into "ab" under sig2, which is the exact 400-on-replay bug that file already guards against. Redacted blocks now keep their stream position instead of being aggregated ahead of all visible thinking. Three regression tests: the replay shape for a raw-only stream (asserting the part carries no signature), exact part order and per-block signatures for a mixed redacted/signed/raw stream, and a real createOpenAIChatAdapter test that reads the second routed request body to prove reasoning_content actually lands beside tool_calls. The last one matters because the shape tests would pass while the wire contract regressed.
1 parent d5b9338 commit 89931a1

2 files changed

Lines changed: 255 additions & 16 deletions

File tree

src/web-search/loop.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -103,23 +103,63 @@ async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> {
103103
* replaying a bare toolCall 400s ("Expected `thinking` or `redacted_thinking`, but found
104104
* `tool_use`"). The signature validity gate stays in the anthropic adapter; other adapters
105105
* ignore or serialize the part harmlessly.
106+
*
107+
* Each signed block keeps its OWN signature and text, mirroring src/images/loop.ts: a signature
108+
* authenticates the exact block it closed, so flattening two blocks under the last signature
109+
* 400s on replay just as it does there.
110+
*
111+
* Raw reasoning (`reasoning_raw_delta`, what OpenAI-compatible providers emit instead of signed
112+
* thinking) accumulates into a SEPARATE UNSIGNED part. It must never join a signed block: the
113+
* anthropic serializer skips signature-less parts, while openai-chat serializes their text as
114+
* `reasoning_content` — which DeepSeek V4 thinking mode requires back alongside the replayed
115+
* tool_calls, and whose absence ended the turn as a provider 400 (issue #688).
116+
*
117+
* This assumes raw reasoning never interleaves INSIDE an unfinished signed block: Anthropic-family
118+
* adapters emit thinking_delta/signature and OpenAI-compatible ones emit reasoning_raw_delta, and
119+
* the two never share a stream. Honoring a genuinely mixed stream would need per-segment state,
120+
* not another accumulator.
106121
*/
107-
function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent | null {
122+
function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[] {
123+
const parts: OcxThinkingContent[] = [];
108124
let thinking = "";
109125
let signature: string | undefined;
110-
const redacted: string[] = [];
126+
let rawReasoning = "";
127+
128+
const flushVisible = () => {
129+
if (!thinking && !signature) return;
130+
parts.push({
131+
type: "thinking",
132+
thinking,
133+
...(signature ? { signature } : {}),
134+
});
135+
thinking = "";
136+
signature = undefined;
137+
};
138+
const flushRaw = () => {
139+
if (!rawReasoning) return;
140+
parts.push({ type: "thinking", thinking: rawReasoning });
141+
rawReasoning = "";
142+
};
143+
111144
for (const e of events) {
112-
if (e.type === "thinking_delta") thinking += e.thinking;
113-
else if (e.type === "thinking_signature") signature = e.signature;
114-
else if (e.type === "redacted_thinking") redacted.push(e.data);
145+
if (e.type === "thinking_delta") {
146+
flushRaw();
147+
thinking += e.thinking;
148+
} else if (e.type === "reasoning_raw_delta") {
149+
flushVisible();
150+
rawReasoning += e.text;
151+
} else if (e.type === "thinking_signature") {
152+
signature = e.signature;
153+
flushVisible();
154+
} else if (e.type === "redacted_thinking") {
155+
flushVisible();
156+
flushRaw();
157+
parts.push({ type: "thinking", thinking: "", redacted: [e.data] });
158+
}
115159
}
116-
if (!thinking && !signature && redacted.length === 0) return null;
117-
return {
118-
type: "thinking",
119-
thinking,
120-
...(signature ? { signature } : {}),
121-
...(redacted.length > 0 ? { redacted } : {}),
122-
};
160+
flushVisible();
161+
flushRaw();
162+
return parts;
123163
}
124164

125165
/** Normalize a query for failed-query de-duplication (case/whitespace-insensitive). */
@@ -406,7 +446,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
406446
// valid, and surface as ONE search cell carrying every attempted query. A real search (one that
407447
// hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
408448
// emit a cell (matching the prior single-query behavior).
409-
async function* runSearchCall(call: WebSearchCall, precedingThinking?: OcxThinkingContent | null): AsyncGenerator<AdapterEvent> {
449+
async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = []): AsyncGenerator<AdapterEvent> {
410450
const results: { query: string; outcome: SidecarOutcome }[] = [];
411451
let beganCell = false;
412452
if (call.queries.length === 0) {
@@ -465,8 +505,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
465505
messages.push({
466506
role: "assistant",
467507
content: [
468-
// Signed thinking must precede tool_use on replay (Anthropic extended thinking).
469-
...(precedingThinking ? [precedingThinking] : []),
508+
// Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
509+
// unsigned raw reasoning has to ride along for providers that require it back (#688).
510+
...precedingThinking,
470511
{ type: "toolCall" as const, id: call.id, name: WEB_SEARCH_TOOL_NAME, arguments: callArgs },
471512
],
472513
timestamp: now,
@@ -559,7 +600,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
559600
// The thinking that led to the search belongs to the FIRST call's assistant replay turn.
560601
const iterationThinking = extractIterationThinking(split.passthrough);
561602
for (const [callIndex, call] of split.calls.entries()) {
562-
yield* runSearchCall(call, callIndex === 0 ? iterationThinking : null);
603+
yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
563604
}
564605
} catch (e) {
565606
yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };

tests/web-search.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
22
import { parseRequest } from "../src/responses/parser";
33
import { planWebSearch, shouldResolveOpenAiWebSearchSidecar, webSearchStallTimeoutSec } from "../src/web-search";
44
import { runWithWebSearch } from "../src/web-search/loop";
5+
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
56
import { headersForCodexAuthContext } from "../src/codex/auth-context";
67
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../src/providers/openai-sidecar";
78
import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../src/types";
@@ -759,6 +760,203 @@ describe("web-search sidecar native web_search_call emission", () => {
759760
expect(content[1].type).toBe("toolCall");
760761
});
761762

763+
// #688: DeepSeek V4 and other OpenAI-compatible providers emit reasoning_raw_delta rather than
764+
// signed thinking. Dropping it left the replayed turn as a bare tool call, which the provider
765+
// rejected — surfacing downstream as a 502 because the loop drops the LoopError status.
766+
test("raw reasoning before a web_search call is replayed as an unsigned thinking part", async () => {
767+
globalThis.fetch = ((input) => {
768+
const url = String(input);
769+
if (url.startsWith("https://routed.test/")) return Promise.resolve(new Response("{}", { status: 200 }));
770+
return Promise.resolve(new Response(
771+
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"docs say X"}\n\n' +
772+
'event: response.completed\ndata: {"type":"response.completed"}\n\n',
773+
{ headers: { "Content-Type": "text/event-stream" } },
774+
));
775+
}) as typeof fetch;
776+
777+
const seenBodies: OcxMessage[][] = [];
778+
let pass = 0;
779+
const adapter: ProviderAdapter = {
780+
name: "mock",
781+
buildRequest: (p: OcxParsedRequest) => {
782+
seenBodies.push(p.context.messages);
783+
return { url: "https://routed.test/v1/chat/completions", method: "POST", headers: {}, body: "{}" };
784+
},
785+
async *parseStream() {
786+
pass++;
787+
if (pass === 1) {
788+
const events: AdapterEvent[] = [
789+
{ type: "reasoning_raw_delta", text: "I should " },
790+
{ type: "reasoning_raw_delta", text: "search the docs" },
791+
{ type: "tool_call_start", id: "call_raw", name: "web_search" },
792+
{ type: "tool_call_delta", arguments: JSON.stringify({ query: "docs" }) },
793+
{ type: "tool_call_end" },
794+
{ type: "done" },
795+
];
796+
for (const event of events) yield event;
797+
return;
798+
}
799+
yield { type: "text_delta", text: "final" };
800+
yield { type: "done" };
801+
},
802+
async parseResponse() { throw new Error("parseResponse must be unreachable"); },
803+
};
804+
805+
const response = await runWithWebSearch({
806+
parsed: parseRequest({ model: "routed/model", input: "look up docs", stream: true, tools: [{ type: "web_search" }] }),
807+
adapter,
808+
forwardProvider,
809+
hostedTool: { type: "web_search" },
810+
selectedForwardHeaders: new Headers({ authorization: "Bearer token" }),
811+
settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 },
812+
maxSearches: 2,
813+
});
814+
await collectSse(response.body!);
815+
816+
// Locate the replayed turn by its tool-call id so unrelated history cannot satisfy this.
817+
const replayMessages = seenBodies.at(-1)!;
818+
const assistant = replayMessages.find(m => m.role === "assistant"
819+
&& Array.isArray(m.content)
820+
&& (m.content as { type: string; id?: string }[]).some(c => c.type === "toolCall" && c.id === "call_raw"));
821+
expect(assistant).toBeDefined();
822+
const content = assistant!.content as { type: string; thinking?: string; signature?: string }[];
823+
expect(content[0].type).toBe("thinking");
824+
expect(content[0].thinking).toBe("I should search the docs");
825+
// Raw reasoning is NOT signed: presenting it as signed would corrupt the Anthropic contract.
826+
expect(content[0].signature).toBeUndefined();
827+
expect(content[1].type).toBe("toolCall");
828+
});
829+
830+
// A signature authenticates the exact block it closed, so each block must keep its own pairing.
831+
// Flattening two blocks under the last signature is what src/images/loop.ts already guards.
832+
test("multiple signed thinking blocks keep their own signatures across a web_search replay", async () => {
833+
globalThis.fetch = ((input) => {
834+
const url = String(input);
835+
if (url.startsWith("https://routed.test/")) return Promise.resolve(new Response("{}", { status: 200 }));
836+
return Promise.resolve(new Response(
837+
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"docs say X"}\n\n' +
838+
'event: response.completed\ndata: {"type":"response.completed"}\n\n',
839+
{ headers: { "Content-Type": "text/event-stream" } },
840+
));
841+
}) as typeof fetch;
842+
843+
const seenBodies: OcxMessage[][] = [];
844+
let pass = 0;
845+
const adapter: ProviderAdapter = {
846+
name: "mock",
847+
buildRequest: (p: OcxParsedRequest) => {
848+
seenBodies.push(p.context.messages);
849+
return { url: "https://routed.test/v1/chat/completions", method: "POST", headers: {}, body: "{}" };
850+
},
851+
async *parseStream() {
852+
pass++;
853+
if (pass === 1) {
854+
const events: AdapterEvent[] = [
855+
{ type: "redacted_thinking", data: "d1" },
856+
{ type: "thinking_delta", thinking: "first" },
857+
{ type: "thinking_signature", signature: "RealSigAAAAAAAAAA==" },
858+
{ type: "thinking_delta", thinking: "second" },
859+
{ type: "thinking_signature", signature: "RealSigBBBBBBBBBB==" },
860+
{ type: "reasoning_raw_delta", text: "raw" },
861+
{ type: "tool_call_start", id: "call_multi", name: "web_search" },
862+
{ type: "tool_call_delta", arguments: JSON.stringify({ query: "docs" }) },
863+
{ type: "tool_call_end" },
864+
{ type: "done" },
865+
];
866+
for (const event of events) yield event;
867+
return;
868+
}
869+
yield { type: "text_delta", text: "final" };
870+
yield { type: "done" };
871+
},
872+
async parseResponse() { throw new Error("parseResponse must be unreachable"); },
873+
};
874+
875+
const response = await runWithWebSearch({
876+
parsed: parseRequest({ model: "routed/model", input: "look up docs", stream: true, tools: [{ type: "web_search" }] }),
877+
adapter,
878+
forwardProvider,
879+
hostedTool: { type: "web_search" },
880+
selectedForwardHeaders: new Headers({ authorization: "Bearer token" }),
881+
settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 },
882+
maxSearches: 2,
883+
});
884+
await collectSse(response.body!);
885+
886+
const replayMessages = seenBodies.at(-1)!;
887+
const assistant = replayMessages.find(m => m.role === "assistant"
888+
&& Array.isArray(m.content)
889+
&& (m.content as { type: string; id?: string }[]).some(c => c.type === "toolCall" && c.id === "call_multi"));
890+
expect(assistant).toBeDefined();
891+
const content = assistant!.content as { type: string; thinking?: string; signature?: string; redacted?: string[] }[];
892+
expect(content).toHaveLength(5);
893+
expect(content[0]).toEqual({ type: "thinking", thinking: "", redacted: ["d1"] });
894+
expect(content[1]).toEqual({ type: "thinking", thinking: "first", signature: "RealSigAAAAAAAAAA==" });
895+
expect(content[2]).toEqual({ type: "thinking", thinking: "second", signature: "RealSigBBBBBBBBBB==" });
896+
expect(content[3]).toEqual({ type: "thinking", thinking: "raw" });
897+
expect(content[4].type).toBe("toolCall");
898+
});
899+
900+
// The user-visible failure lives at the SERIALIZER boundary: openai-chat only emits
901+
// reasoning_content for models in preserveReasoningContentModels, and only from thinking parts.
902+
// The mock-adapter tests above prove the replay SHAPE; this one proves the wire contract, so a
903+
// regression that drops reasoning_content on the second request cannot pass unnoticed.
904+
test("a reasoning_content provider receives raw reasoning beside the replayed tool_calls", async () => {
905+
const routedBodies: Record<string, unknown>[] = [];
906+
let routedPass = 0;
907+
globalThis.fetch = ((input, init?: RequestInit) => {
908+
const url = String(input);
909+
if (url.startsWith("https://routed.test/")) {
910+
routedBodies.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
911+
routedPass++;
912+
const sse = routedPass === 1
913+
? 'data: {"choices":[{"delta":{"reasoning_content":"I should search the docs"}}]}\n\n'
914+
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_ds","type":"function","function":{"name":"web_search","arguments":"{\\"query\\":\\"docs\\"}"}}]}}]}\n\n'
915+
+ 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n'
916+
+ "data: [DONE]\n\n"
917+
: 'data: {"choices":[{"delta":{"content":"final"}}]}\n\n'
918+
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'
919+
+ "data: [DONE]\n\n";
920+
return Promise.resolve(new Response(sse, { headers: { "Content-Type": "text/event-stream" } }));
921+
}
922+
return Promise.resolve(new Response(
923+
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"docs say X"}\n\n' +
924+
'event: response.completed\ndata: {"type":"response.completed"}\n\n',
925+
{ headers: { "Content-Type": "text/event-stream" } },
926+
));
927+
}) as typeof fetch;
928+
929+
// modelInList matches EXACTLY, so the provider list and the request model must agree verbatim.
930+
const deepseekProvider: OcxProviderConfig = {
931+
adapter: "openai-chat",
932+
baseUrl: "https://routed.test/v1",
933+
apiKey: "routed-key",
934+
preserveReasoningContentModels: ["deepseek-v4-flash"],
935+
};
936+
937+
const response = await runWithWebSearch({
938+
parsed: parseRequest({ model: "deepseek-v4-flash", input: "look up docs", stream: true, tools: [{ type: "web_search" }] }),
939+
adapter: createOpenAIChatAdapter(deepseekProvider),
940+
forwardProvider,
941+
hostedTool: { type: "web_search" },
942+
selectedForwardHeaders: new Headers({ authorization: "Bearer token" }),
943+
settings: { model: "gpt-5.4-mini", reasoning: "low", timeoutMs: 30_000 },
944+
maxSearches: 2,
945+
});
946+
await collectSse(response.body!);
947+
948+
expect(routedBodies).toHaveLength(2);
949+
const replay = routedBodies[1]!.messages as {
950+
role: string; content?: unknown; reasoning_content?: string;
951+
tool_calls?: { id: string; function: { name: string } }[];
952+
}[];
953+
const assistant = replay.find(m => m.role === "assistant" && m.tool_calls?.some(tc => tc.id === "call_ds"));
954+
expect(assistant).toBeDefined();
955+
expect(assistant!.reasoning_content).toBe("I should search the docs");
956+
expect(assistant!.tool_calls).toHaveLength(1);
957+
expect(assistant!.tool_calls![0]!.function.name).toBe("web_search");
958+
});
959+
762960
test("an executed search emits a web_search_call item ahead of the assistant message", async () => {
763961
globalThis.fetch = ((input) => {
764962
const url = String(input);

0 commit comments

Comments
 (0)