Skip to content

Commit b3b3dcc

Browse files
author
Sagid Magomedov
committed
fix: support nested reasoning tags in TagMatcher and add comprehensive streaming tests
1 parent 606b880 commit b3b3dcc

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
@@ -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

Comments
 (0)