Skip to content

Commit ae1a395

Browse files
Sagid Magomedovedelauna
authored andcommitted
fix: support nested reasoning tags in TagMatcher and add comprehensive streaming tests
1 parent 999ce64 commit ae1a395

2 files changed

Lines changed: 371 additions & 6 deletions

File tree

src/api/providers/__tests__/openai.spec.ts

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)