@@ -636,6 +636,360 @@ describe("OpenAiHandler", () => {
636636 const callArgs = mockCreate . mock . calls [ 0 ] [ 0 ]
637637 expect ( callArgs . max_completion_tokens ) . toBe ( 4096 )
638638 } )
639+
640+ describe ( "TagMatcher reasoning tags" , ( ) => {
641+ it ( "should handle <think> tags from stream" , async ( ) => {
642+ mockCreate . mockImplementationOnce ( ( ) => ( {
643+ [ Symbol . asyncIterator ] : ( ) => ( {
644+ next : vi
645+ . fn ( )
646+ . mockResolvedValueOnce ( {
647+ done : false ,
648+ value : { choices : [ { delta : { content : "<think>Let me think" } } ] } ,
649+ } )
650+ . mockResolvedValueOnce ( {
651+ done : false ,
652+ value : { choices : [ { delta : { content : " about this</think>" } } ] } ,
653+ } )
654+ . mockResolvedValueOnce ( {
655+ done : false ,
656+ value : { choices : [ { delta : { content : "The answer is 42" } } ] } ,
657+ } )
658+ . mockResolvedValueOnce ( { done : true } ) ,
659+ } ) ,
660+ } ) )
661+
662+ const stream = handler . createMessage ( systemPrompt , messages )
663+ const chunks : any [ ] = [ ]
664+ for await ( const chunk of stream ) {
665+ chunks . push ( chunk )
666+ }
667+
668+ expect ( chunks ) . toEqual ( [
669+ { type : "reasoning" , text : "Let me think" } ,
670+ { type : "reasoning" , text : " about this" } ,
671+ { type : "text" , text : "The answer is 42" } ,
672+ ] )
673+ } )
674+
675+ it ( "should handle <thought> tags from stream" , async ( ) => {
676+ mockCreate . mockImplementationOnce ( ( ) => ( {
677+ [ Symbol . asyncIterator ] : ( ) => ( {
678+ next : vi
679+ . fn ( )
680+ . mockResolvedValueOnce ( {
681+ done : false ,
682+ value : { choices : [ { delta : { content : "<thought>Deep thought" } } ] } ,
683+ } )
684+ . mockResolvedValueOnce ( {
685+ done : false ,
686+ value : { choices : [ { delta : { content : " here</thought>" } } ] } ,
687+ } )
688+ . mockResolvedValueOnce ( {
689+ done : false ,
690+ value : { choices : [ { delta : { content : "Result: 42" } } ] } ,
691+ } )
692+ . mockResolvedValueOnce ( { done : true } ) ,
693+ } ) ,
694+ } ) )
695+
696+ const stream = handler . createMessage ( systemPrompt , messages )
697+ const chunks : any [ ] = [ ]
698+ for await ( const chunk of stream ) {
699+ chunks . push ( chunk )
700+ }
701+
702+ expect ( chunks ) . toEqual ( [
703+ { type : "reasoning" , text : "Deep thought" } ,
704+ { type : "reasoning" , text : " here" } ,
705+ { type : "text" , text : "Result: 42" } ,
706+ ] )
707+ } )
708+
709+ it ( "should not close <think> tag with </thought> tag" , async ( ) => {
710+ mockCreate . mockImplementationOnce ( ( ) => ( {
711+ [ Symbol . asyncIterator ] : ( ) => ( {
712+ next : vi
713+ . fn ( )
714+ . mockResolvedValueOnce ( {
715+ done : false ,
716+ value : { choices : [ { delta : { content : "<think>Thinking" } } ] } ,
717+ } )
718+ . mockResolvedValueOnce ( {
719+ done : false ,
720+ value : { choices : [ { delta : { content : " but closing with wrong tag</thought>" } } ] } ,
721+ } )
722+ . mockResolvedValueOnce ( {
723+ done : false ,
724+ value : { choices : [ { delta : { content : " still thinking</think>" } } ] } ,
725+ } )
726+ . mockResolvedValueOnce ( {
727+ done : false ,
728+ value : { choices : [ { delta : { content : "final text" } } ] } ,
729+ } )
730+ . mockResolvedValueOnce ( { done : true } ) ,
731+ } ) ,
732+ } ) )
733+
734+ const stream = handler . createMessage ( systemPrompt , messages )
735+ const chunks : any [ ] = [ ]
736+ for await ( const chunk of stream ) {
737+ chunks . push ( chunk )
738+ }
739+
740+ // The </thought> tag should not match the active <think> tag, so the closing
741+ // tag is treated as text. The " still thinking" stays reasoning since <think>
742+ // was never closed with </think>.
743+ expect ( chunks ) . toEqual ( [
744+ { type : "reasoning" , text : "Thinking" } ,
745+ { type : "reasoning" , text : " but closing with wrong tag</thought>" } ,
746+ { type : "reasoning" , text : " still thinking" } ,
747+ { type : "text" , text : "final text" } ,
748+ ] )
749+ } )
750+
751+ it ( "should handle text without any tags" , async ( ) => {
752+ mockCreate . mockImplementationOnce ( ( ) => ( {
753+ [ Symbol . asyncIterator ] : ( ) => ( {
754+ next : vi
755+ . fn ( )
756+ . mockResolvedValueOnce ( {
757+ done : false ,
758+ value : { choices : [ { delta : { content : "Just regular text" } } ] } ,
759+ } )
760+ . mockResolvedValueOnce ( {
761+ done : false ,
762+ value : { choices : [ { delta : { content : " without reasoning" } } ] } ,
763+ } )
764+ . mockResolvedValueOnce ( { done : true } ) ,
765+ } ) ,
766+ } ) )
767+
768+ const stream = handler . createMessage ( systemPrompt , messages )
769+ const chunks : any [ ] = [ ]
770+ for await ( const chunk of stream ) {
771+ chunks . push ( chunk )
772+ }
773+
774+ expect ( chunks ) . toEqual ( [
775+ { type : "text" , text : "Just regular text" } ,
776+ { type : "text" , text : " without reasoning" } ,
777+ ] )
778+ } )
779+
780+ it ( "should handle <think> tags that start at beginning of stream" , async ( ) => {
781+ mockCreate . mockImplementationOnce ( ( ) => ( {
782+ [ Symbol . asyncIterator ] : ( ) => ( {
783+ next : vi
784+ . fn ( )
785+ . mockResolvedValueOnce ( {
786+ done : false ,
787+ value : { choices : [ { delta : { content : "<think>reasoning" } } ] } ,
788+ } )
789+ . mockResolvedValueOnce ( {
790+ done : false ,
791+ value : { choices : [ { delta : { content : " content</think>" } } ] } ,
792+ } )
793+ . mockResolvedValueOnce ( {
794+ done : false ,
795+ value : { choices : [ { delta : { content : " normal text" } } ] } ,
796+ } )
797+ . mockResolvedValueOnce ( { done : true } ) ,
798+ } ) ,
799+ } ) )
800+
801+ const stream = handler . createMessage ( systemPrompt , messages )
802+ const chunks : any [ ] = [ ]
803+ for await ( const chunk of stream ) {
804+ chunks . push ( chunk )
805+ }
806+
807+ expect ( chunks ) . toEqual ( [
808+ { type : "reasoning" , text : "reasoning" } ,
809+ { type : "reasoning" , text : " content" } ,
810+ { type : "text" , text : " normal text" } ,
811+ ] )
812+ } )
813+
814+ it ( "should handle incomplete <think> tag at end of stream" , async ( ) => {
815+ mockCreate . mockImplementationOnce ( ( ) => ( {
816+ [ Symbol . asyncIterator ] : ( ) => ( {
817+ next : vi
818+ . fn ( )
819+ . mockResolvedValueOnce ( {
820+ done : false ,
821+ value : { choices : [ { delta : { content : "<think>Incomplete thought" } } ] } ,
822+ } )
823+ . mockResolvedValueOnce ( { done : true } ) ,
824+ } ) ,
825+ } ) )
826+
827+ const stream = handler . createMessage ( systemPrompt , messages )
828+ const chunks : any [ ] = [ ]
829+ for await ( const chunk of stream ) {
830+ chunks . push ( chunk )
831+ }
832+
833+ // TagMatcher should flush remaining reasoning content on final()
834+ expect ( chunks . length ) . toBeGreaterThan ( 0 )
835+ expect (
836+ chunks . some (
837+ ( c ) => ( c . type === "text" || c . type === "reasoning" ) && c . text . includes ( "Incomplete thought" ) ,
838+ ) ,
839+ ) . toBe ( true )
840+ } )
841+
842+ it ( "should handle complete <think> tag in a single chunk" , async ( ) => {
843+ mockCreate . mockImplementationOnce ( ( ) => ( {
844+ [ Symbol . asyncIterator ] : ( ) => ( {
845+ next : vi
846+ . fn ( )
847+ . mockResolvedValueOnce ( {
848+ done : false ,
849+ value : { choices : [ { delta : { content : "text before " } } ] } ,
850+ } )
851+ . mockResolvedValueOnce ( {
852+ done : false ,
853+ value : { choices : [ { delta : { content : "<think>Complete thought</think>" } } ] } ,
854+ } )
855+ . mockResolvedValueOnce ( {
856+ done : false ,
857+ value : { choices : [ { delta : { content : " text after" } } ] } ,
858+ } )
859+ . mockResolvedValueOnce ( { done : true } ) ,
860+ } ) ,
861+ } ) )
862+
863+ const stream = handler . createMessage ( systemPrompt , messages )
864+ const chunks : any [ ] = [ ]
865+ for await ( const chunk of stream ) {
866+ chunks . push ( chunk )
867+ }
868+
869+ // The TagMatcher processes the whole chunk character by character,
870+ // so the complete tag is detected and yields reasoning text
871+ expect ( chunks . length ) . toBeGreaterThan ( 0 )
872+ expect ( chunks [ 0 ] ) . toEqual ( { type : "text" , text : "text before " } )
873+ } )
874+
875+ it ( "should handle nested mixed tags with correct closure matching" , async ( ) => {
876+ mockCreate . mockImplementationOnce ( ( ) => ( {
877+ [ Symbol . asyncIterator ] : ( ) => ( {
878+ next : vi
879+ . fn ( )
880+ . mockResolvedValueOnce ( {
881+ done : false ,
882+ value : { choices : [ { delta : { content : "<think>outer" } } ] } ,
883+ } )
884+ . mockResolvedValueOnce ( {
885+ done : false ,
886+ value : { choices : [ { delta : { content : "<thought>inner</thought>" } } ] } ,
887+ } )
888+ . mockResolvedValueOnce ( {
889+ done : false ,
890+ value : { choices : [ { delta : { content : " middle</think>" } } ] } ,
891+ } )
892+ . mockResolvedValueOnce ( {
893+ done : false ,
894+ value : { choices : [ { delta : { content : "final text" } } ] } ,
895+ } )
896+ . mockResolvedValueOnce ( { done : true } ) ,
897+ } ) ,
898+ } ) )
899+
900+ const stream = handler . createMessage ( systemPrompt , messages )
901+ const chunks : any [ ] = [ ]
902+ for await ( const chunk of stream ) {
903+ chunks . push ( chunk )
904+ }
905+
906+ // With the tag stack fix, </thought> closes <thought> inner tag,
907+ // and </think> correctly closes the outer <think> tag.
908+ // inner content inside <thought> is reasoning, middle is still reasoning under <think>
909+ expect ( chunks ) . toEqual ( [
910+ { type : "reasoning" , text : "outer" } ,
911+ { type : "reasoning" , text : "<thought>inner</thought>" } ,
912+ { type : "reasoning" , text : " middle" } ,
913+ { type : "text" , text : "final text" } ,
914+ ] )
915+ } )
916+
917+ it ( "should handle nested <think> tags with correct stack unwinding" , async ( ) => {
918+ mockCreate . mockImplementationOnce ( ( ) => ( {
919+ [ Symbol . asyncIterator ] : ( ) => ( {
920+ next : vi
921+ . fn ( )
922+ . mockResolvedValueOnce ( {
923+ done : false ,
924+ value : { choices : [ { delta : { content : "<think>outer" } } ] } ,
925+ } )
926+ . mockResolvedValueOnce ( {
927+ done : false ,
928+ value : { choices : [ { delta : { content : "<think>inner</think>" } } ] } ,
929+ } )
930+ . mockResolvedValueOnce ( {
931+ done : false ,
932+ value : { choices : [ { delta : { content : " middle</think>" } } ] } ,
933+ } )
934+ . mockResolvedValueOnce ( {
935+ done : false ,
936+ value : { choices : [ { delta : { content : "final text" } } ] } ,
937+ } )
938+ . mockResolvedValueOnce ( { done : true } ) ,
939+ } ) ,
940+ } ) )
941+
942+ const stream = handler . createMessage ( systemPrompt , messages )
943+ const chunks : any [ ] = [ ]
944+ for await ( const chunk of stream ) {
945+ chunks . push ( chunk )
946+ }
947+
948+ // With the tag stack fix, </thought> closes <thought> inner tag,
949+ // and </think> correctly closes the outer <think> tag.
950+ // inner content inside <thought> is reasoning, middle is still reasoning under <think>
951+ expect ( chunks ) . toEqual ( [
952+ { type : "reasoning" , text : "outer" } ,
953+ { type : "reasoning" , text : "<think>inner</think>" } ,
954+ { type : "reasoning" , text : " middle" } ,
955+ { type : "text" , text : "final text" } ,
956+ ] )
957+ } )
958+
959+ it ( "should handle reasoning_content alongside tag matching" , async ( ) => {
960+ mockCreate . mockImplementationOnce ( ( ) => ( {
961+ [ Symbol . asyncIterator ] : ( ) => ( {
962+ next : vi
963+ . fn ( )
964+ . mockResolvedValueOnce ( {
965+ done : false ,
966+ value : { choices : [ { delta : { reasoning_content : "native reasoning" } } ] } ,
967+ } )
968+ . mockResolvedValueOnce ( {
969+ done : false ,
970+ value : { choices : [ { delta : { content : "<think>tag based</think>" } } ] } ,
971+ } )
972+ . mockResolvedValueOnce ( {
973+ done : false ,
974+ value : { choices : [ { delta : { content : " final output" } } ] } ,
975+ } )
976+ . mockResolvedValueOnce ( { done : true } ) ,
977+ } ) ,
978+ } ) )
979+
980+ const stream = handler . createMessage ( systemPrompt , messages )
981+ const chunks : any [ ] = [ ]
982+ for await ( const chunk of stream ) {
983+ chunks . push ( chunk )
984+ }
985+
986+ expect ( chunks ) . toEqual ( [
987+ { type : "reasoning" , text : "native reasoning" } ,
988+ { type : "reasoning" , text : "tag based" } ,
989+ { type : "text" , text : " final output" } ,
990+ ] )
991+ } )
992+ } )
639993 } )
640994
641995 describe ( "error handling" , ( ) => {
0 commit comments