@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
22import { parseRequest } from "../src/responses/parser" ;
33import { planWebSearch , shouldResolveOpenAiWebSearchSidecar , webSearchStallTimeoutSec } from "../src/web-search" ;
44import { runWithWebSearch } from "../src/web-search/loop" ;
5+ import { createOpenAIChatAdapter } from "../src/adapters/openai-chat" ;
56import { headersForCodexAuthContext } from "../src/codex/auth-context" ;
67import { listOpenAiForwardSidecarCandidates , resolveFirstUsableOpenAiSidecar } from "../src/providers/openai-sidecar" ;
78import 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