@@ -538,4 +538,221 @@ describe("OpenAiHandler - Codex model detection", () => {
538538 } ) . rejects . toThrow ( "Response failed: Unknown failure" )
539539 } )
540540 } )
541+
542+ const codexOptions = ( overrides : Partial < ApiHandlerOptions > = { } ) : ApiHandlerOptions => ( {
543+ openAiApiKey : "test-key" ,
544+ openAiModelId : "gpt-5.3-codex" ,
545+ openAiUseAzure : true ,
546+ ...overrides ,
547+ } )
548+
549+ const collect = async (
550+ h : OpenAiHandler ,
551+ messages : Anthropic . Messages . MessageParam [ ] = [ { role : "user" , content : "Hi" } ] ,
552+ ) => {
553+ const chunks : any [ ] = [ ]
554+ for await ( const chunk of h . createMessage ( "System" , messages , { taskId : "test" } ) ) {
555+ chunks . push ( chunk )
556+ }
557+ return chunks
558+ }
559+
560+ const streamOf = ( ...events : any [ ] ) => ( {
561+ [ Symbol . asyncIterator ] : async function * ( ) {
562+ for ( const e of events ) yield e
563+ } ,
564+ } )
565+
566+ describe ( "createMessage codex non-streaming path" , ( ) => {
567+ it ( "extracts tool calls, text, message content, reasoning and usage from a non-streaming response" , async ( ) => {
568+ handler = new OpenAiHandler ( codexOptions ( { openAiStreamingEnabled : false } ) )
569+
570+ mockResponsesCreate . mockResolvedValue ( {
571+ output : [
572+ { type : "function_call" , call_id : "c1" , name : "read_file" , arguments : '{"path":"a.ts"}' } ,
573+ { type : "output_text" , text : "top-level text" } ,
574+ { type : "message" , content : [ { type : "output_text" , text : "message text" } ] } ,
575+ { type : "reasoning" , summary : [ { text : "thinking out loud" } ] } ,
576+ ] ,
577+ usage : {
578+ input_tokens : 7 ,
579+ output_tokens : 11 ,
580+ cache_read_input_tokens : 2 ,
581+ cache_creation_input_tokens : 3 ,
582+ } ,
583+ } )
584+
585+ const chunks = await collect ( handler )
586+
587+ expect ( mockResponsesCreate . mock . calls [ 0 ] [ 0 ] . stream ) . toBe ( false )
588+
589+ const toolCalls = chunks . filter ( ( c ) => c . type === "tool_call" )
590+ expect ( toolCalls ) . toHaveLength ( 1 )
591+ expect ( toolCalls [ 0 ] ) . toMatchObject ( { id : "c1" , name : "read_file" , arguments : '{"path":"a.ts"}' } )
592+
593+ const texts = chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text )
594+ expect ( texts ) . toEqual ( [ "top-level text" , "message text" ] )
595+
596+ const reasoning = chunks . filter ( ( c ) => c . type === "reasoning" )
597+ expect ( reasoning [ 0 ] . text ) . toBe ( "thinking out loud" )
598+
599+ const usage = chunks . find ( ( c ) => c . type === "usage" )
600+ expect ( usage ) . toMatchObject ( { inputTokens : 7 , outputTokens : 11 , cacheReadTokens : 2 , cacheWriteTokens : 3 } )
601+ } )
602+
603+ it ( "stringifies object tool-call arguments in the non-streaming path" , async ( ) => {
604+ handler = new OpenAiHandler ( codexOptions ( { openAiStreamingEnabled : false } ) )
605+
606+ mockResponsesCreate . mockResolvedValue ( {
607+ output : [ { type : "tool_call" , id : "c2" , name : "search" , input : { query : "x" } } ] ,
608+ } )
609+
610+ const chunks = await collect ( handler )
611+ const toolCalls = chunks . filter ( ( c ) => c . type === "tool_call" )
612+ expect ( toolCalls [ 0 ] ) . toMatchObject ( { id : "c2" , name : "search" , arguments : '{"query":"x"}' } )
613+ } )
614+
615+ it ( "wraps non-streaming API errors via handleOpenAIError" , async ( ) => {
616+ handler = new OpenAiHandler ( codexOptions ( { openAiStreamingEnabled : false } ) )
617+ mockResponsesCreate . mockRejectedValue ( new Error ( "boom" ) )
618+ await expect ( collect ( handler ) ) . rejects . toThrow ( )
619+ } )
620+ } )
621+
622+ describe ( "createMessage codex streaming event variants" , ( ) => {
623+ it ( "emits text from a done-only event when no delta was seen" , async ( ) => {
624+ handler = new OpenAiHandler ( codexOptions ( ) )
625+ mockResponsesCreate . mockResolvedValue ( streamOf ( { type : "response.output_text.done" , text : "final text" } ) )
626+ const chunks = await collect ( handler )
627+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "final text" ] )
628+ } )
629+
630+ it ( "emits text from a content_part event when no delta was seen" , async ( ) => {
631+ handler = new OpenAiHandler ( codexOptions ( ) )
632+ mockResponsesCreate . mockResolvedValue (
633+ streamOf ( { type : "response.content_part.added" , part : { type : "output_text" , text : "part text" } } ) ,
634+ )
635+ const chunks = await collect ( handler )
636+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "part text" ] )
637+ } )
638+
639+ it ( "emits reasoning from reasoning delta events" , async ( ) => {
640+ handler = new OpenAiHandler ( codexOptions ( ) )
641+ mockResponsesCreate . mockResolvedValue (
642+ streamOf ( { type : "response.reasoning_summary_text.delta" , delta : "step 1" } ) ,
643+ )
644+ const chunks = await collect ( handler )
645+ expect ( chunks . filter ( ( c ) => c . type === "reasoning" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "step 1" ] )
646+ } )
647+
648+ it ( "emits refusal text from refusal delta events" , async ( ) => {
649+ handler = new OpenAiHandler ( codexOptions ( ) )
650+ mockResponsesCreate . mockResolvedValue ( streamOf ( { type : "response.refusal.delta" , delta : "cannot help" } ) )
651+ const chunks = await collect ( handler )
652+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "[Refusal] cannot help" ] )
653+ } )
654+
655+ it ( "emits text from an output_item.added message" , async ( ) => {
656+ handler = new OpenAiHandler ( codexOptions ( ) )
657+ mockResponsesCreate . mockResolvedValue (
658+ streamOf ( {
659+ type : "response.output_item.added" ,
660+ item : { type : "message" , content : [ { type : "output_text" , text : "added msg" } ] } ,
661+ } ) ,
662+ )
663+ const chunks = await collect ( handler )
664+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "added msg" ] )
665+ } )
666+
667+ it ( "falls back to text from an output_item.done message when no text was streamed" , async ( ) => {
668+ handler = new OpenAiHandler ( codexOptions ( ) )
669+ mockResponsesCreate . mockResolvedValue (
670+ streamOf ( {
671+ type : "response.output_item.done" ,
672+ item : { type : "message" , content : [ { type : "output_text" , text : "done msg" } ] } ,
673+ } ) ,
674+ )
675+ const chunks = await collect ( handler )
676+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "done msg" ] )
677+ } )
678+
679+ it ( "extracts fallback text and usage from a response.done payload when nothing streamed" , async ( ) => {
680+ handler = new OpenAiHandler ( codexOptions ( ) )
681+ mockResponsesCreate . mockResolvedValue (
682+ streamOf ( {
683+ type : "response.completed" ,
684+ response : {
685+ output : [ { type : "message" , content : [ { type : "output_text" , text : "completed text" } ] } ] ,
686+ usage : { input_tokens : 4 , output_tokens : 6 } ,
687+ } ,
688+ } ) ,
689+ )
690+ const chunks = await collect ( handler )
691+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "completed text" ] )
692+ expect ( chunks . find ( ( c ) => c . type === "usage" ) ) . toMatchObject ( { inputTokens : 4 , outputTokens : 6 } )
693+ } )
694+
695+ it ( "supports the older choices/usage fallback shape" , async ( ) => {
696+ handler = new OpenAiHandler ( codexOptions ( ) )
697+ mockResponsesCreate . mockResolvedValue (
698+ streamOf ( {
699+ choices : [ { delta : { content : "legacy chunk" } } ] ,
700+ usage : { prompt_tokens : 9 , completion_tokens : 2 } ,
701+ } ) ,
702+ )
703+ const chunks = await collect ( handler )
704+ expect ( chunks . filter ( ( c ) => c . type === "text" ) . map ( ( c ) => c . text ) ) . toEqual ( [ "legacy chunk" ] )
705+ expect ( chunks . find ( ( c ) => c . type === "usage" ) ) . toMatchObject ( { inputTokens : 9 , outputTokens : 2 } )
706+ } )
707+ } )
708+
709+ describe ( "createMessage codex request body + conversation formatting" , ( ) => {
710+ it ( "includes max_output_tokens when includeMaxTokens is enabled" , async ( ) => {
711+ handler = new OpenAiHandler ( codexOptions ( { includeMaxTokens : true , modelMaxTokens : 1234 } ) )
712+ mockResponsesCreate . mockResolvedValue ( streamOf ( { type : "response.output_text.delta" , delta : "x" } ) )
713+ await collect ( handler )
714+ expect ( mockResponsesCreate . mock . calls [ 0 ] [ 0 ] . max_output_tokens ) . toBe ( 1234 )
715+ } )
716+
717+ it ( "formats image blocks, array tool_result content and assistant string content" , async ( ) => {
718+ handler = new OpenAiHandler ( codexOptions ( ) )
719+ mockResponsesCreate . mockResolvedValue ( streamOf ( { type : "response.output_text.delta" , delta : "ok" } ) )
720+
721+ const messages : Anthropic . Messages . MessageParam [ ] = [
722+ {
723+ role : "user" ,
724+ content : [
725+ { type : "text" , text : "look" } ,
726+ {
727+ type : "image" ,
728+ source : { type : "base64" , media_type : "image/png" , data : "AAAA" } ,
729+ } ,
730+ ] ,
731+ } ,
732+ { role : "assistant" , content : "sure thing" } ,
733+ {
734+ role : "user" ,
735+ content : [
736+ {
737+ type : "tool_result" ,
738+ tool_use_id : "call_1" ,
739+ content : [ { type : "text" , text : "result body" } ] ,
740+ } ,
741+ ] ,
742+ } ,
743+ ]
744+
745+ await collect ( handler , messages )
746+ const input = mockResponsesCreate . mock . calls [ 0 ] [ 0 ] . input
747+
748+ const userImage = input [ 0 ] . content . find ( ( c : any ) => c . type === "input_image" )
749+ expect ( userImage . image_url ) . toBe ( "data:image/png;base64,AAAA" )
750+
751+ const assistant = input . find ( ( i : any ) => i . role === "assistant" )
752+ expect ( assistant . content [ 0 ] ) . toMatchObject ( { type : "output_text" , text : "sure thing" } )
753+
754+ const toolOutput = input . find ( ( i : any ) => i . type === "function_call_output" )
755+ expect ( toolOutput . output ) . toBe ( "result body" )
756+ } )
757+ } )
541758} )
0 commit comments