Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 9bcf4ca

Browse files
committed
fix: support <thought> tag rendering for Gemma 4 reasoning
Gemma 4 31B uses <thought>...</thought> tags for its reasoning process, but Roo Code only recognized <think>...</think> tags. This change: - Updates TagMatcher to accept multiple tag names (string | string[]) - Adds "thought" as an additional recognized reasoning tag in all 4 providers (openai, base-openai-compatible, lm-studio, native-ollama) - Strips <thought> tags in presentAssistantMessage alongside <thinking> - Adds comprehensive test coverage for multi-tag matching Fixes #12093
1 parent 7adbfec commit 9bcf4ca

7 files changed

Lines changed: 207 additions & 13 deletions

File tree

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
@@ -105,7 +105,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
105105
}
106106

107107
const matcher = new TagMatcher(
108-
"think",
108+
["think", "thought"],
109109
(chunk) =>
110110
({
111111
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
@@ -178,7 +178,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
178178
}
179179

180180
const matcher = new TagMatcher(
181-
"think",
181+
["think", "thought"],
182182
(chunk) =>
183183
({
184184
type: chunk.matched ? "reasoning" : "text",

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,9 +287,11 @@ export async function presentAssistantMessage(cline: Task) {
287287
// Have to do this for partial and complete since sending
288288
// content in thinking tags to markdown renderer will
289289
// automatically be removed.
290-
// Strip any streamed <thinking> tags from text output.
290+
// Strip any streamed <thinking> or <thought> tags from text output.
291291
content = content.replace(/<thinking>\s?/g, "")
292292
content = content.replace(/\s?<\/thinking>/g, "")
293+
content = content.replace(/<thought>\s?/g, "")
294+
content = content.replace(/\s?<\/thought>/g, "")
293295
}
294296

295297
await cline.say("text", content, undefined, block.partial)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { TagMatcher } from "../tag-matcher"
2+
3+
describe("TagMatcher", () => {
4+
describe("single tag name (backward compatibility)", () => {
5+
it("should extract content inside <think> tags", () => {
6+
const matcher = new TagMatcher("think")
7+
const result = matcher.final("<think>reasoning here</think> output text")
8+
expect(result).toEqual([
9+
{ matched: true, data: "reasoning here" },
10+
{ matched: false, data: " output text" },
11+
])
12+
})
13+
14+
it("should handle streamed chunks", () => {
15+
const matcher = new TagMatcher("think")
16+
const chunks = []
17+
chunks.push(...matcher.update("<thi"))
18+
chunks.push(...matcher.update("nk>reason"))
19+
chunks.push(...matcher.update("ing</think>"))
20+
chunks.push(...matcher.final(" done"))
21+
const allData = chunks.reduce(
22+
(acc, c) => {
23+
const key = c.matched ? "matched" : "unmatched"
24+
acc[key] += c.data
25+
return acc
26+
},
27+
{ matched: "", unmatched: "" },
28+
)
29+
expect(allData.matched).toBe("reasoning")
30+
expect(allData.unmatched).toBe(" done")
31+
})
32+
33+
it("should pass through text with no tags", () => {
34+
const matcher = new TagMatcher("think")
35+
const result = matcher.final("just some text")
36+
expect(result).toEqual([{ matched: false, data: "just some text" }])
37+
})
38+
39+
it("tagName getter returns first tag name", () => {
40+
const matcher = new TagMatcher("think")
41+
expect(matcher.tagName).toBe("think")
42+
})
43+
})
44+
45+
describe("multiple tag names", () => {
46+
it("should extract content inside <thought> tags", () => {
47+
const matcher = new TagMatcher(["think", "thought"])
48+
const result = matcher.final("<thought>reasoning here</thought> output text")
49+
expect(result).toEqual([
50+
{ matched: true, data: "reasoning here" },
51+
{ matched: false, data: " output text" },
52+
])
53+
})
54+
55+
it("should still extract content inside <think> tags", () => {
56+
const matcher = new TagMatcher(["think", "thought"])
57+
const result = matcher.final("<think>reasoning here</think> output text")
58+
expect(result).toEqual([
59+
{ matched: true, data: "reasoning here" },
60+
{ matched: false, data: " output text" },
61+
])
62+
})
63+
64+
it("should handle streamed <thought> tags across chunks", () => {
65+
const matcher = new TagMatcher(["think", "thought"])
66+
const chunks = []
67+
chunks.push(...matcher.update("<thou"))
68+
chunks.push(...matcher.update("ght>my rea"))
69+
chunks.push(...matcher.update("soning</thought>"))
70+
chunks.push(...matcher.final(" answer"))
71+
const allData = chunks.reduce(
72+
(acc, c) => {
73+
const key = c.matched ? "matched" : "unmatched"
74+
acc[key] += c.data
75+
return acc
76+
},
77+
{ matched: "", unmatched: "" },
78+
)
79+
expect(allData.matched).toBe("my reasoning")
80+
expect(allData.unmatched).toBe(" answer")
81+
})
82+
83+
it("should not match mismatched open/close tags", () => {
84+
// <think> opened but </thought> close - should not match as valid close
85+
const matcher = new TagMatcher(["think", "thought"])
86+
const result = matcher.final("<think>content</thought>more")
87+
// The close tag won't match because activeTagName is "think"
88+
// so </thought> is not recognized as closing it
89+
const matchedData = result.filter((c) => c.matched).map((c) => c.data)
90+
const unmatchedData = result.filter((c) => !c.matched).map((c) => c.data)
91+
// Content stays matched because the tag was never properly closed
92+
expect(matchedData.join("")).toContain("content")
93+
expect(unmatchedData.join("")).not.toContain("content")
94+
})
95+
96+
it("should handle text before thought tag", () => {
97+
const matcher = new TagMatcher(["think", "thought"], undefined, 0)
98+
const result = matcher.final("<thought>reasoning</thought>answer")
99+
expect(result).toEqual([
100+
{ matched: true, data: "reasoning" },
101+
{ matched: false, data: "answer" },
102+
])
103+
})
104+
105+
it("should ignore non-matching tags", () => {
106+
const matcher = new TagMatcher(["think", "thought"])
107+
const result = matcher.final("<div>not a match</div>")
108+
expect(result).toEqual([{ matched: false, data: "<div>not a match</div>" }])
109+
})
110+
111+
it("tagName getter returns first tag name from array", () => {
112+
const matcher = new TagMatcher(["think", "thought"])
113+
expect(matcher.tagName).toBe("think")
114+
})
115+
116+
it("tagNames contains all provided tag names", () => {
117+
const matcher = new TagMatcher(["think", "thought"])
118+
expect(matcher.tagNames).toEqual(["think", "thought"])
119+
})
120+
})
121+
122+
describe("with transform", () => {
123+
it("should apply transform to thought tag results", () => {
124+
const matcher = new TagMatcher(["think", "thought"], (chunk) => ({
125+
type: chunk.matched ? "reasoning" : "text",
126+
text: chunk.data,
127+
}))
128+
const result = matcher.final("<thought>my reasoning</thought>my answer")
129+
expect(result).toEqual([
130+
{ type: "reasoning", text: "my reasoning" },
131+
{ type: "text", text: "my answer" },
132+
])
133+
})
134+
})
135+
})

src/utils/tag-matcher.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,23 @@ export class TagMatcher<Result = TagMatcherResult> {
1717
state: "TEXT" | "TAG_OPEN" | "TAG_CLOSE" = "TEXT"
1818
depth = 0
1919
pointer = 0
20+
readonly tagNames: string[]
21+
private candidates: number[] = []
22+
private activeTagName: string | undefined
2023
constructor(
21-
readonly tagName: string,
24+
tagName: string | string[],
2225
readonly transform?: (chunks: TagMatcherResult) => Result,
2326
readonly position = 0,
24-
) {}
27+
) {
28+
this.tagNames = Array.isArray(tagName) ? tagName : [tagName]
29+
}
30+
31+
/**
32+
* For backward compatibility, return the first tag name.
33+
*/
34+
get tagName(): string {
35+
return this.tagNames[0]
36+
}
2537
private collect() {
2638
if (!this.cached.length) {
2739
return
@@ -48,6 +60,47 @@ export class TagMatcher<Result = TagMatcherResult> {
4860
return chunks.map(this.transform)
4961
}
5062

63+
/**
64+
* Check if any remaining candidate tag name has the given length.
65+
*/
66+
private _anyCompletedCandidate(): boolean {
67+
return this.candidates.some((i) => this.tagNames[i].length === this.index)
68+
}
69+
70+
/**
71+
* Get the first completed candidate tag name (fully matched at current index).
72+
*/
73+
private _getCompletedCandidate(): string | undefined {
74+
for (const i of this.candidates) {
75+
if (this.tagNames[i].length === this.index) {
76+
return this.tagNames[i]
77+
}
78+
}
79+
return undefined
80+
}
81+
82+
/**
83+
* Filter candidates to only those matching the given char at the current index.
84+
*/
85+
private _filterCandidates(char: string): boolean {
86+
this.candidates = this.candidates.filter((i) => this.tagNames[i][this.index] === char)
87+
return this.candidates.length > 0
88+
}
89+
90+
/**
91+
* Reset candidates to all tag name indices (for open tags) or
92+
* only the active tag name (for close tags).
93+
*/
94+
private _resetCandidates(closeTag: boolean) {
95+
if (closeTag && this.activeTagName !== undefined) {
96+
// For closing tags, only match the tag that was opened
97+
const idx = this.tagNames.indexOf(this.activeTagName)
98+
this.candidates = idx >= 0 ? [idx] : this.tagNames.map((_, i) => i)
99+
} else {
100+
this.candidates = this.tagNames.map((_, i) => i)
101+
}
102+
}
103+
51104
private _update(chunk: string) {
52105
for (const char of chunk) {
53106
this.cached.push(char)
@@ -57,38 +110,42 @@ export class TagMatcher<Result = TagMatcherResult> {
57110
if (char === "<" && (this.pointer <= this.position + 1 || this.matched)) {
58111
this.state = "TAG_OPEN"
59112
this.index = 0
113+
this._resetCandidates(false)
60114
} else {
61115
this.collect()
62116
}
63117
} else if (this.state === "TAG_OPEN") {
64-
if (char === ">" && this.index === this.tagName.length) {
118+
if (char === ">" && this._anyCompletedCandidate()) {
65119
this.state = "TEXT"
66120
if (!this.matched) {
67121
this.cached = []
68122
}
123+
this.activeTagName = this._getCompletedCandidate()
69124
this.depth++
70125
this.matched = true
71126
} else if (this.index === 0 && char === "/") {
72127
this.state = "TAG_CLOSE"
73-
} else if (char === " " && (this.index === 0 || this.index === this.tagName.length)) {
128+
this._resetCandidates(true)
129+
} else if (char === " " && (this.index === 0 || this._anyCompletedCandidate())) {
74130
continue
75-
} else if (this.tagName[this.index] === char) {
131+
} else if (this._filterCandidates(char)) {
76132
this.index++
77133
} else {
78134
this.state = "TEXT"
79135
this.collect()
80136
}
81137
} else if (this.state === "TAG_CLOSE") {
82-
if (char === ">" && this.index === this.tagName.length) {
138+
if (char === ">" && this._anyCompletedCandidate()) {
83139
this.state = "TEXT"
84140
this.depth--
85141
this.matched = this.depth > 0
86142
if (!this.matched) {
87143
this.cached = []
144+
this.activeTagName = undefined
88145
}
89-
} else if (char === " " && (this.index === 0 || this.index === this.tagName.length)) {
146+
} else if (char === " " && (this.index === 0 || this._anyCompletedCandidate())) {
90147
continue
91-
} else if (this.tagName[this.index] === char) {
148+
} else if (this._filterCandidates(char)) {
92149
this.index++
93150
} else {
94151
this.state = "TEXT"

0 commit comments

Comments
 (0)