Skip to content

Commit 34898d2

Browse files
sagidMSagid Magomedovedelauna
authored
fix: parse Gemma 4 <thought> reasoning tags alongside <think> (#324)
* fix: parse Gemma 4 <thought> reasoning tags alongside <think> Gemma 4 streams reasoning inside <thought>...</thought> instead of <think>...</think>. Without this the content leaks into chat text and the agent triggers a retry on the first turn. - TagMatcher: support multiple tag names - string[], track activeTagName so <think> is never closed by </thought> (and vice-versa). - base-openai-compatible-provider and openai handler: match both tags. - Tests: <thought> parsing, cross-tag isolation, and invariants. * fix: support nested reasoning tags in TagMatcher and add comprehensive streaming tests * test(tag-matcher): add tests for unmatched closing tags Add two regression tests that verify depth never goes negative: 1. stray closer with no opener "final</think>text" → stays text 2. duplicate closer after a proper close "<think>thinking</think>final</think>text" → second </think> stays text Both cases ensure we only decrement depth and pop activeTagNames when depth > 0, preventing underflow and treating the extra tag as plain text. * Apply suggestion from @edelauna Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> * refactor(providers): updating tag matcher * test(TagMatcher): adding tests for new logic * test(tag-matcher): consolidate reasoning tag tests into tag-matcher.spec.ts --------- Co-authored-by: Sagid Magomedov <sagidsmagomedov@gmail.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> Co-authored-by: Elliott de Launay <edelauna@gmail.com>
1 parent f75b64e commit 34898d2

8 files changed

Lines changed: 452 additions & 29 deletions

File tree

src/api/providers/__tests__/base-openai-compatible-provider.spec.ts

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,75 @@ describe("BaseOpenAiCompatibleProvider", () => {
9797
])
9898
})
9999

100+
it("should handle reasoning tags (<thought>) from stream", async () => {
101+
mockCreate.mockImplementationOnce(() => {
102+
return {
103+
[Symbol.asyncIterator]: () => ({
104+
next: vi
105+
.fn()
106+
.mockResolvedValueOnce({
107+
done: false,
108+
value: { choices: [{ delta: { content: "<thought>Deep thought" } }] },
109+
})
110+
.mockResolvedValueOnce({
111+
done: false,
112+
value: { choices: [{ delta: { content: " here</thought>" } }] },
113+
})
114+
.mockResolvedValueOnce({
115+
done: false,
116+
value: { choices: [{ delta: { content: "Result: 42" } }] },
117+
})
118+
.mockResolvedValueOnce({ done: true }),
119+
}),
120+
}
121+
})
122+
const stream = handler.createMessage("system prompt", [])
123+
const chunks = []
124+
for await (const chunk of stream) {
125+
chunks.push(chunk)
126+
}
127+
expect(chunks).toEqual([
128+
{ type: "reasoning", text: "Deep thought" },
129+
{ type: "reasoning", text: " here" },
130+
{ type: "text", text: "Result: 42" },
131+
])
132+
})
133+
134+
it("should not close <think> tag with </thought> tag", async () => {
135+
mockCreate.mockImplementationOnce(() => {
136+
return {
137+
[Symbol.asyncIterator]: () => ({
138+
next: vi
139+
.fn()
140+
.mockResolvedValueOnce({
141+
done: false,
142+
value: { choices: [{ delta: { content: "<think>Thinking" } }] },
143+
})
144+
.mockResolvedValueOnce({
145+
done: false,
146+
value: { choices: [{ delta: { content: " but closing with wrong tag</thought>" } }] },
147+
})
148+
.mockResolvedValueOnce({
149+
done: false,
150+
value: { choices: [{ delta: { content: " still thinking" } }] },
151+
})
152+
.mockResolvedValueOnce({ done: true }),
153+
}),
154+
}
155+
})
156+
const stream = handler.createMessage("system prompt", [])
157+
const chunks = []
158+
for await (const chunk of stream) {
159+
chunks.push(chunk)
160+
}
161+
// The </thought> tag should be treated as text since it doesn't match the active <think> tag
162+
expect(chunks).toEqual([
163+
{ type: "reasoning", text: "Thinking" },
164+
{ type: "reasoning", text: " but closing with wrong tag</thought>" },
165+
{ type: "reasoning", text: " still thinking" },
166+
])
167+
})
168+
100169
it("should handle complete <think> tag in a single chunk", async () => {
101170
mockCreate.mockImplementationOnce(() => {
102171
return {
@@ -153,13 +222,8 @@ describe("BaseOpenAiCompatibleProvider", () => {
153222
chunks.push(chunk)
154223
}
155224

156-
// TagMatcher should handle incomplete tags and flush remaining content
157-
expect(chunks.length).toBeGreaterThan(0)
158-
expect(
159-
chunks.some(
160-
(c) => (c.type === "text" || c.type === "reasoning") && c.text.includes("Incomplete thought"),
161-
),
162-
).toBe(true)
225+
// TagMatcher should flush incomplete reasoning content on stream end
226+
expect(chunks).toContainEqual({ type: "reasoning", text: "Incomplete thought" })
163227
})
164228

165229
it("should handle text without any <think> tags", async () => {

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

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,175 @@ 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 treat stray closing tag as plain text when no tag is open", async () => {
642+
mockCreate.mockImplementationOnce(() => ({
643+
[Symbol.asyncIterator]: () => ({
644+
next: vi
645+
.fn()
646+
.mockResolvedValueOnce({
647+
done: false,
648+
value: { choices: [{ delta: { content: "final</think>text" } }] },
649+
})
650+
.mockResolvedValueOnce({ done: true }),
651+
}),
652+
}))
653+
654+
const stream = handler.createMessage(systemPrompt, messages)
655+
const chunks: any[] = []
656+
for await (const chunk of stream) {
657+
chunks.push(chunk)
658+
}
659+
660+
expect(chunks).toEqual([{ type: "text", text: "final</think>text" }])
661+
})
662+
663+
it("should treat extra closing tag after a closed block as plain text", async () => {
664+
mockCreate.mockImplementationOnce(() => ({
665+
[Symbol.asyncIterator]: () => ({
666+
next: vi
667+
.fn()
668+
.mockResolvedValueOnce({
669+
done: false,
670+
value: {
671+
choices: [{ delta: { content: "<think>thinking</think>final</think>text" } }],
672+
},
673+
})
674+
.mockResolvedValueOnce({ done: true }),
675+
}),
676+
}))
677+
678+
const stream = handler.createMessage(systemPrompt, messages)
679+
const chunks: any[] = []
680+
for await (const chunk of stream) {
681+
chunks.push(chunk)
682+
}
683+
684+
expect(chunks).toEqual([
685+
{ type: "reasoning", text: "thinking" },
686+
{ type: "text", text: "final</think>text" },
687+
])
688+
})
689+
690+
it("should handle nested mixed tags with correct closure matching", async () => {
691+
mockCreate.mockImplementationOnce(() => ({
692+
[Symbol.asyncIterator]: () => ({
693+
next: vi
694+
.fn()
695+
.mockResolvedValueOnce({
696+
done: false,
697+
value: { choices: [{ delta: { content: "<think>outer" } }] },
698+
})
699+
.mockResolvedValueOnce({
700+
done: false,
701+
value: { choices: [{ delta: { content: "<thought>inner</thought>" } }] },
702+
})
703+
.mockResolvedValueOnce({
704+
done: false,
705+
value: { choices: [{ delta: { content: " middle</think>" } }] },
706+
})
707+
.mockResolvedValueOnce({
708+
done: false,
709+
value: { choices: [{ delta: { content: "final text" } }] },
710+
})
711+
.mockResolvedValueOnce({ done: true }),
712+
}),
713+
}))
714+
715+
const stream = handler.createMessage(systemPrompt, messages)
716+
const chunks: any[] = []
717+
for await (const chunk of stream) {
718+
chunks.push(chunk)
719+
}
720+
721+
// With the tag stack fix, </thought> closes <thought> inner tag,
722+
// and </think> correctly closes the outer <think> tag.
723+
// inner content inside <thought> is reasoning, middle is still reasoning under <think>
724+
expect(chunks).toEqual([
725+
{ type: "reasoning", text: "outer" },
726+
{ type: "reasoning", text: "<thought>inner</thought>" },
727+
{ type: "reasoning", text: " middle" },
728+
{ type: "text", text: "final text" },
729+
])
730+
})
731+
732+
it("should handle nested <think> tags with correct stack unwinding", async () => {
733+
mockCreate.mockImplementationOnce(() => ({
734+
[Symbol.asyncIterator]: () => ({
735+
next: vi
736+
.fn()
737+
.mockResolvedValueOnce({
738+
done: false,
739+
value: { choices: [{ delta: { content: "<think>outer" } }] },
740+
})
741+
.mockResolvedValueOnce({
742+
done: false,
743+
value: { choices: [{ delta: { content: "<think>inner</think>" } }] },
744+
})
745+
.mockResolvedValueOnce({
746+
done: false,
747+
value: { choices: [{ delta: { content: " middle</think>" } }] },
748+
})
749+
.mockResolvedValueOnce({
750+
done: false,
751+
value: { choices: [{ delta: { content: "final text" } }] },
752+
})
753+
.mockResolvedValueOnce({ done: true }),
754+
}),
755+
}))
756+
757+
const stream = handler.createMessage(systemPrompt, messages)
758+
const chunks: any[] = []
759+
for await (const chunk of stream) {
760+
chunks.push(chunk)
761+
}
762+
763+
// With the tag stack fix, </thought> closes <thought> inner tag,
764+
// and </think> correctly closes the outer <think> tag.
765+
// inner content inside <thought> is reasoning, middle is still reasoning under <think>
766+
expect(chunks).toEqual([
767+
{ type: "reasoning", text: "outer" },
768+
{ type: "reasoning", text: "<think>inner</think>" },
769+
{ type: "reasoning", text: " middle" },
770+
{ type: "text", text: "final text" },
771+
])
772+
})
773+
774+
it("should handle reasoning_content alongside tag matching", async () => {
775+
mockCreate.mockImplementationOnce(() => ({
776+
[Symbol.asyncIterator]: () => ({
777+
next: vi
778+
.fn()
779+
.mockResolvedValueOnce({
780+
done: false,
781+
value: { choices: [{ delta: { reasoning_content: "native reasoning" } }] },
782+
})
783+
.mockResolvedValueOnce({
784+
done: false,
785+
value: { choices: [{ delta: { content: "<think>tag based</think>" } }] },
786+
})
787+
.mockResolvedValueOnce({
788+
done: false,
789+
value: { choices: [{ delta: { content: " final output" } }] },
790+
})
791+
.mockResolvedValueOnce({ done: true }),
792+
}),
793+
}))
794+
795+
const stream = handler.createMessage(systemPrompt, messages)
796+
const chunks: any[] = []
797+
for await (const chunk of stream) {
798+
chunks.push(chunk)
799+
}
800+
801+
expect(chunks).toEqual([
802+
{ type: "reasoning", text: "native reasoning" },
803+
{ type: "reasoning", text: "tag based" },
804+
{ type: "text", text: " final output" },
805+
])
806+
})
807+
})
639808
})
640809

641810
describe("error handling", () => {

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
118118
const stream = await this.createStream(systemPrompt, messages, metadata)
119119

120120
const matcher = new TagMatcher(
121-
"think",
121+
["think", "thought"],
122122
(chunk) =>
123123
({
124124
type: chunk.matched ? "reasoning" : "text",

src/api/providers/lm-studio.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
104104
}
105105

106106
const matcher = new TagMatcher(
107-
"think",
107+
["think", "thought"],
108108
(chunk) =>
109109
({
110110
type: chunk.matched ? "reasoning" : "text",

src/api/providers/native-ollama.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
215215
]
216216

217217
const matcher = new TagMatcher(
218-
"think",
218+
["think", "thought"],
219219
(chunk) =>
220220
({
221221
type: chunk.matched ? "reasoning" : "text",

src/api/providers/openai.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
184184
}
185185

186186
const matcher = new TagMatcher(
187-
"think",
187+
["think", "thought"],
188188
(chunk) =>
189189
({
190190
type: chunk.matched ? "reasoning" : "text",

0 commit comments

Comments
 (0)