@@ -544,6 +544,360 @@ describe("OpenAiHandler", () => {
544544 const callArgs = mockCreate . mock . calls [ 0 ] [ 0 ]
545545 expect ( callArgs . max_completion_tokens ) . toBe ( 4096 )
546546 } )
547+
548+ describe ( "TagMatcher reasoning tags" , ( ) => {
549+ it ( "should handle <think> tags from stream" , async ( ) => {
550+ mockCreate . mockImplementationOnce ( ( ) => ( {
551+ [ Symbol . asyncIterator ] : ( ) => ( {
552+ next : vi
553+ . fn ( )
554+ . mockResolvedValueOnce ( {
555+ done : false ,
556+ value : { choices : [ { delta : { content : "<think>Let me think" } } ] } ,
557+ } )
558+ . mockResolvedValueOnce ( {
559+ done : false ,
560+ value : { choices : [ { delta : { content : " about this</think>" } } ] } ,
561+ } )
562+ . mockResolvedValueOnce ( {
563+ done : false ,
564+ value : { choices : [ { delta : { content : "The answer is 42" } } ] } ,
565+ } )
566+ . mockResolvedValueOnce ( { done : true } ) ,
567+ } ) ,
568+ } ) )
569+
570+ const stream = handler . createMessage ( systemPrompt , messages )
571+ const chunks : any [ ] = [ ]
572+ for await ( const chunk of stream ) {
573+ chunks . push ( chunk )
574+ }
575+
576+ expect ( chunks ) . toEqual ( [
577+ { type : "reasoning" , text : "Let me think" } ,
578+ { type : "reasoning" , text : " about this" } ,
579+ { type : "text" , text : "The answer is 42" } ,
580+ ] )
581+ } )
582+
583+ it ( "should handle <thought> tags from stream" , async ( ) => {
584+ mockCreate . mockImplementationOnce ( ( ) => ( {
585+ [ Symbol . asyncIterator ] : ( ) => ( {
586+ next : vi
587+ . fn ( )
588+ . mockResolvedValueOnce ( {
589+ done : false ,
590+ value : { choices : [ { delta : { content : "<thought>Deep thought" } } ] } ,
591+ } )
592+ . mockResolvedValueOnce ( {
593+ done : false ,
594+ value : { choices : [ { delta : { content : " here</thought>" } } ] } ,
595+ } )
596+ . mockResolvedValueOnce ( {
597+ done : false ,
598+ value : { choices : [ { delta : { content : "Result: 42" } } ] } ,
599+ } )
600+ . mockResolvedValueOnce ( { done : true } ) ,
601+ } ) ,
602+ } ) )
603+
604+ const stream = handler . createMessage ( systemPrompt , messages )
605+ const chunks : any [ ] = [ ]
606+ for await ( const chunk of stream ) {
607+ chunks . push ( chunk )
608+ }
609+
610+ expect ( chunks ) . toEqual ( [
611+ { type : "reasoning" , text : "Deep thought" } ,
612+ { type : "reasoning" , text : " here" } ,
613+ { type : "text" , text : "Result: 42" } ,
614+ ] )
615+ } )
616+
617+ it ( "should not close <think> tag with </thought> tag" , async ( ) => {
618+ mockCreate . mockImplementationOnce ( ( ) => ( {
619+ [ Symbol . asyncIterator ] : ( ) => ( {
620+ next : vi
621+ . fn ( )
622+ . mockResolvedValueOnce ( {
623+ done : false ,
624+ value : { choices : [ { delta : { content : "<think>Thinking" } } ] } ,
625+ } )
626+ . mockResolvedValueOnce ( {
627+ done : false ,
628+ value : { choices : [ { delta : { content : " but closing with wrong tag</thought>" } } ] } ,
629+ } )
630+ . mockResolvedValueOnce ( {
631+ done : false ,
632+ value : { choices : [ { delta : { content : " still thinking</think>" } } ] } ,
633+ } )
634+ . mockResolvedValueOnce ( {
635+ done : false ,
636+ value : { choices : [ { delta : { content : "final text" } } ] } ,
637+ } )
638+ . mockResolvedValueOnce ( { done : true } ) ,
639+ } ) ,
640+ } ) )
641+
642+ const stream = handler . createMessage ( systemPrompt , messages )
643+ const chunks : any [ ] = [ ]
644+ for await ( const chunk of stream ) {
645+ chunks . push ( chunk )
646+ }
647+
648+ // The </thought> tag should not match the active <think> tag, so the closing
649+ // tag is treated as text. The " still thinking" stays reasoning since <think>
650+ // was never closed with </think>.
651+ expect ( chunks ) . toEqual ( [
652+ { type : "reasoning" , text : "Thinking" } ,
653+ { type : "reasoning" , text : " but closing with wrong tag</thought>" } ,
654+ { type : "reasoning" , text : " still thinking" } ,
655+ { type : "text" , text : "final text" } ,
656+ ] )
657+ } )
658+
659+ it ( "should handle text without any tags" , async ( ) => {
660+ mockCreate . mockImplementationOnce ( ( ) => ( {
661+ [ Symbol . asyncIterator ] : ( ) => ( {
662+ next : vi
663+ . fn ( )
664+ . mockResolvedValueOnce ( {
665+ done : false ,
666+ value : { choices : [ { delta : { content : "Just regular text" } } ] } ,
667+ } )
668+ . mockResolvedValueOnce ( {
669+ done : false ,
670+ value : { choices : [ { delta : { content : " without reasoning" } } ] } ,
671+ } )
672+ . mockResolvedValueOnce ( { done : true } ) ,
673+ } ) ,
674+ } ) )
675+
676+ const stream = handler . createMessage ( systemPrompt , messages )
677+ const chunks : any [ ] = [ ]
678+ for await ( const chunk of stream ) {
679+ chunks . push ( chunk )
680+ }
681+
682+ expect ( chunks ) . toEqual ( [
683+ { type : "text" , text : "Just regular text" } ,
684+ { type : "text" , text : " without reasoning" } ,
685+ ] )
686+ } )
687+
688+ it ( "should handle <think> tags that start at beginning of stream" , async ( ) => {
689+ mockCreate . mockImplementationOnce ( ( ) => ( {
690+ [ Symbol . asyncIterator ] : ( ) => ( {
691+ next : vi
692+ . fn ( )
693+ . mockResolvedValueOnce ( {
694+ done : false ,
695+ value : { choices : [ { delta : { content : "<think>reasoning" } } ] } ,
696+ } )
697+ . mockResolvedValueOnce ( {
698+ done : false ,
699+ value : { choices : [ { delta : { content : " content</think>" } } ] } ,
700+ } )
701+ . mockResolvedValueOnce ( {
702+ done : false ,
703+ value : { choices : [ { delta : { content : " normal text" } } ] } ,
704+ } )
705+ . mockResolvedValueOnce ( { done : true } ) ,
706+ } ) ,
707+ } ) )
708+
709+ const stream = handler . createMessage ( systemPrompt , messages )
710+ const chunks : any [ ] = [ ]
711+ for await ( const chunk of stream ) {
712+ chunks . push ( chunk )
713+ }
714+
715+ expect ( chunks ) . toEqual ( [
716+ { type : "reasoning" , text : "reasoning" } ,
717+ { type : "reasoning" , text : " content" } ,
718+ { type : "text" , text : " normal text" } ,
719+ ] )
720+ } )
721+
722+ it ( "should handle incomplete <think> tag at end of stream" , async ( ) => {
723+ mockCreate . mockImplementationOnce ( ( ) => ( {
724+ [ Symbol . asyncIterator ] : ( ) => ( {
725+ next : vi
726+ . fn ( )
727+ . mockResolvedValueOnce ( {
728+ done : false ,
729+ value : { choices : [ { delta : { content : "<think>Incomplete thought" } } ] } ,
730+ } )
731+ . mockResolvedValueOnce ( { done : true } ) ,
732+ } ) ,
733+ } ) )
734+
735+ const stream = handler . createMessage ( systemPrompt , messages )
736+ const chunks : any [ ] = [ ]
737+ for await ( const chunk of stream ) {
738+ chunks . push ( chunk )
739+ }
740+
741+ // TagMatcher should flush remaining reasoning content on final()
742+ expect ( chunks . length ) . toBeGreaterThan ( 0 )
743+ expect (
744+ chunks . some (
745+ ( c ) => ( c . type === "text" || c . type === "reasoning" ) && c . text . includes ( "Incomplete thought" ) ,
746+ ) ,
747+ ) . toBe ( true )
748+ } )
749+
750+ it ( "should handle complete <think> tag in a single chunk" , async ( ) => {
751+ mockCreate . mockImplementationOnce ( ( ) => ( {
752+ [ Symbol . asyncIterator ] : ( ) => ( {
753+ next : vi
754+ . fn ( )
755+ . mockResolvedValueOnce ( {
756+ done : false ,
757+ value : { choices : [ { delta : { content : "text before " } } ] } ,
758+ } )
759+ . mockResolvedValueOnce ( {
760+ done : false ,
761+ value : { choices : [ { delta : { content : "<think>Complete thought</think>" } } ] } ,
762+ } )
763+ . mockResolvedValueOnce ( {
764+ done : false ,
765+ value : { choices : [ { delta : { content : " text after" } } ] } ,
766+ } )
767+ . mockResolvedValueOnce ( { done : true } ) ,
768+ } ) ,
769+ } ) )
770+
771+ const stream = handler . createMessage ( systemPrompt , messages )
772+ const chunks : any [ ] = [ ]
773+ for await ( const chunk of stream ) {
774+ chunks . push ( chunk )
775+ }
776+
777+ // The TagMatcher processes the whole chunk character by character,
778+ // so the complete tag is detected and yields reasoning text
779+ expect ( chunks . length ) . toBeGreaterThan ( 0 )
780+ expect ( chunks [ 0 ] ) . toEqual ( { type : "text" , text : "text before " } )
781+ } )
782+
783+ it ( "should handle nested mixed tags with correct closure matching" , async ( ) => {
784+ mockCreate . mockImplementationOnce ( ( ) => ( {
785+ [ Symbol . asyncIterator ] : ( ) => ( {
786+ next : vi
787+ . fn ( )
788+ . mockResolvedValueOnce ( {
789+ done : false ,
790+ value : { choices : [ { delta : { content : "<think>outer" } } ] } ,
791+ } )
792+ . mockResolvedValueOnce ( {
793+ done : false ,
794+ value : { choices : [ { delta : { content : "<thought>inner</thought>" } } ] } ,
795+ } )
796+ . mockResolvedValueOnce ( {
797+ done : false ,
798+ value : { choices : [ { delta : { content : " middle</think>" } } ] } ,
799+ } )
800+ . mockResolvedValueOnce ( {
801+ done : false ,
802+ value : { choices : [ { delta : { content : "final text" } } ] } ,
803+ } )
804+ . mockResolvedValueOnce ( { done : true } ) ,
805+ } ) ,
806+ } ) )
807+
808+ const stream = handler . createMessage ( systemPrompt , messages )
809+ const chunks : any [ ] = [ ]
810+ for await ( const chunk of stream ) {
811+ chunks . push ( chunk )
812+ }
813+
814+ // With the tag stack fix, </thought> closes <thought> inner tag,
815+ // and </think> correctly closes the outer <think> tag.
816+ // inner content inside <thought> is reasoning, middle is still reasoning under <think>
817+ expect ( chunks ) . toEqual ( [
818+ { type : "reasoning" , text : "outer" } ,
819+ { type : "reasoning" , text : "<thought>inner</thought>" } ,
820+ { type : "reasoning" , text : " middle" } ,
821+ { type : "text" , text : "final text" } ,
822+ ] )
823+ } )
824+
825+ it ( "should handle nested <think> tags with correct stack unwinding" , async ( ) => {
826+ mockCreate . mockImplementationOnce ( ( ) => ( {
827+ [ Symbol . asyncIterator ] : ( ) => ( {
828+ next : vi
829+ . fn ( )
830+ . mockResolvedValueOnce ( {
831+ done : false ,
832+ value : { choices : [ { delta : { content : "<think>outer" } } ] } ,
833+ } )
834+ . mockResolvedValueOnce ( {
835+ done : false ,
836+ value : { choices : [ { delta : { content : "<think>inner</think>" } } ] } ,
837+ } )
838+ . mockResolvedValueOnce ( {
839+ done : false ,
840+ value : { choices : [ { delta : { content : " middle</think>" } } ] } ,
841+ } )
842+ . mockResolvedValueOnce ( {
843+ done : false ,
844+ value : { choices : [ { delta : { content : "final text" } } ] } ,
845+ } )
846+ . mockResolvedValueOnce ( { done : true } ) ,
847+ } ) ,
848+ } ) )
849+
850+ const stream = handler . createMessage ( systemPrompt , messages )
851+ const chunks : any [ ] = [ ]
852+ for await ( const chunk of stream ) {
853+ chunks . push ( chunk )
854+ }
855+
856+ // With the tag stack fix, </thought> closes <thought> inner tag,
857+ // and </think> correctly closes the outer <think> tag.
858+ // inner content inside <thought> is reasoning, middle is still reasoning under <think>
859+ expect ( chunks ) . toEqual ( [
860+ { type : "reasoning" , text : "outer" } ,
861+ { type : "reasoning" , text : "<think>inner</think>" } ,
862+ { type : "reasoning" , text : " middle" } ,
863+ { type : "text" , text : "final text" } ,
864+ ] )
865+ } )
866+
867+ it ( "should handle reasoning_content alongside tag matching" , async ( ) => {
868+ mockCreate . mockImplementationOnce ( ( ) => ( {
869+ [ Symbol . asyncIterator ] : ( ) => ( {
870+ next : vi
871+ . fn ( )
872+ . mockResolvedValueOnce ( {
873+ done : false ,
874+ value : { choices : [ { delta : { reasoning_content : "native reasoning" } } ] } ,
875+ } )
876+ . mockResolvedValueOnce ( {
877+ done : false ,
878+ value : { choices : [ { delta : { content : "<think>tag based</think>" } } ] } ,
879+ } )
880+ . mockResolvedValueOnce ( {
881+ done : false ,
882+ value : { choices : [ { delta : { content : " final output" } } ] } ,
883+ } )
884+ . mockResolvedValueOnce ( { done : true } ) ,
885+ } ) ,
886+ } ) )
887+
888+ const stream = handler . createMessage ( systemPrompt , messages )
889+ const chunks : any [ ] = [ ]
890+ for await ( const chunk of stream ) {
891+ chunks . push ( chunk )
892+ }
893+
894+ expect ( chunks ) . toEqual ( [
895+ { type : "reasoning" , text : "native reasoning" } ,
896+ { type : "reasoning" , text : "tag based" } ,
897+ { type : "text" , text : " final output" } ,
898+ ] )
899+ } )
900+ } )
547901 } )
548902
549903 describe ( "error handling" , ( ) => {
0 commit comments