Skip to content

Commit 620c3cd

Browse files
committed
feat: add consistent handling for all reasoning tag variants
- Updated presentAssistantMessage.ts to strip all reasoning tags (<think>, <thinking>, <reasoning>, <thought>) - Created ReasoningXmlMatcher utility to handle multiple reasoning tag variants - Updated all provider parsers to use ReasoningXmlMatcher - Ensures all four tag variants render identically as collapsible grey reasoning blocks Fixes #8785
1 parent 8187a8e commit 620c3cd

File tree

10 files changed

+276
-36
lines changed

10 files changed

+276
-36
lines changed

src/api/providers/cerebras.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
66
import { calculateApiCostOpenAI } from "../../shared/cost"
77
import { ApiStream } from "../transform/stream"
88
import { convertToOpenAiMessages } from "../transform/openai-format"
9-
import { XmlMatcher } from "../../utils/xml-matcher"
9+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
1010

1111
import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
1212
import { BaseProvider } from "./base-provider"
@@ -187,9 +187,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
187187
throw new Error(t("common:errors.cerebras.noResponseBody"))
188188
}
189189

190-
// Initialize XmlMatcher to parse <think>...</think> tags
191-
const matcher = new XmlMatcher(
192-
"think",
190+
// Initialize ReasoningXmlMatcher to parse reasoning tags
191+
const matcher = new ReasoningXmlMatcher(
193192
(chunk) =>
194193
({
195194
type: chunk.matched ? "reasoning" : "text",
@@ -228,7 +227,7 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
228227
if (parsed.choices?.[0]?.delta?.content) {
229228
const content = parsed.choices[0].delta.content
230229

231-
// Use XmlMatcher to parse <think>...</think> tags
230+
// Use ReasoningXmlMatcher to parse reasoning tags
232231
for (const chunk of matcher.update(content)) {
233232
yield chunk
234233
}

src/api/providers/chutes.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
33
import OpenAI from "openai"
44

55
import type { ApiHandlerOptions } from "../../shared/api"
6-
import { XmlMatcher } from "../../utils/xml-matcher"
6+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
77
import { convertToR1Format } from "../transform/r1-format"
88
import { convertToOpenAiMessages } from "../transform/openai-format"
99
import { ApiStream } from "../transform/stream"
@@ -53,8 +53,7 @@ export class ChutesHandler extends BaseOpenAiCompatibleProvider<ChutesModelId> {
5353
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
5454
})
5555

56-
const matcher = new XmlMatcher(
57-
"think",
56+
const matcher = new ReasoningXmlMatcher(
5857
(chunk) =>
5958
({
6059
type: chunk.matched ? "reasoning" : "text",

src/api/providers/featherless.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { DEEP_SEEK_DEFAULT_TEMPERATURE, type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types"
1+
import {
2+
DEEP_SEEK_DEFAULT_TEMPERATURE,
3+
type FeatherlessModelId,
4+
featherlessDefaultModelId,
5+
featherlessModels,
6+
} from "@roo-code/types"
27
import { Anthropic } from "@anthropic-ai/sdk"
38
import OpenAI from "openai"
49

510
import type { ApiHandlerOptions } from "../../shared/api"
6-
import { XmlMatcher } from "../../utils/xml-matcher"
11+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
712
import { convertToR1Format } from "../transform/r1-format"
813
import { convertToOpenAiMessages } from "../transform/openai-format"
914
import { ApiStream } from "../transform/stream"
@@ -53,8 +58,7 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider<Featherless
5358
messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]),
5459
})
5560

56-
const matcher = new XmlMatcher(
57-
"think",
61+
const matcher = new ReasoningXmlMatcher(
5862
(chunk) =>
5963
({
6064
type: chunk.matched ? "reasoning" : "text",

src/api/providers/lm-studio.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU
66

77
import type { ApiHandlerOptions } from "../../shared/api"
88

9-
import { XmlMatcher } from "../../utils/xml-matcher"
9+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
1010

1111
import { convertToOpenAiMessages } from "../transform/openai-format"
1212
import { ApiStream } from "../transform/stream"
@@ -100,8 +100,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
100100
throw handleOpenAIError(error, this.providerName)
101101
}
102102

103-
const matcher = new XmlMatcher(
104-
"think",
103+
const matcher = new ReasoningXmlMatcher(
105104
(chunk) =>
106105
({
107106
type: chunk.matched ? "reasoning" : "text",

src/api/providers/native-ollama.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ApiStream } from "../transform/stream"
55
import { BaseProvider } from "./base-provider"
66
import type { ApiHandlerOptions } from "../../shared/api"
77
import { getOllamaModels } from "./fetchers/ollama"
8-
import { XmlMatcher } from "../../utils/xml-matcher"
8+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
99
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1010

1111
interface OllamaChatOptions {
@@ -179,8 +179,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
179179
...convertToOllamaMessages(messages),
180180
]
181181

182-
const matcher = new XmlMatcher(
183-
"think",
182+
const matcher = new ReasoningXmlMatcher(
184183
(chunk) =>
185184
({
186185
type: chunk.matched ? "reasoning" : "text",

src/api/providers/ollama.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERAT
55

66
import type { ApiHandlerOptions } from "../../shared/api"
77

8-
import { XmlMatcher } from "../../utils/xml-matcher"
8+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
99

1010
import { convertToOpenAiMessages } from "../transform/openai-format"
1111
import { convertToR1Format } from "../transform/r1-format"
@@ -68,8 +68,7 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl
6868
} catch (error) {
6969
throw handleOpenAIError(error, this.providerName)
7070
}
71-
const matcher = new XmlMatcher(
72-
"think",
71+
const matcher = new ReasoningXmlMatcher(
7372
(chunk) =>
7473
({
7574
type: chunk.matched ? "reasoning" : "text",

src/api/providers/openai.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212

1313
import type { ApiHandlerOptions } from "../../shared/api"
1414

15-
import { XmlMatcher } from "../../utils/xml-matcher"
15+
import { ReasoningXmlMatcher } from "../../utils/reasoning-xml-matcher"
1616

1717
import { convertToOpenAiMessages } from "../transform/openai-format"
1818
import { convertToR1Format } from "../transform/r1-format"
@@ -179,8 +179,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
179179
throw handleOpenAIError(error, this.providerName)
180180
}
181181

182-
const matcher = new XmlMatcher(
183-
"think",
182+
const matcher = new ReasoningXmlMatcher(
184183
(chunk) =>
185184
({
186185
type: chunk.matched ? "reasoning" : "text",

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,21 +93,24 @@ export async function presentAssistantMessage(cline: Task) {
9393

9494
if (content) {
9595
// Have to do this for partial and complete since sending
96-
// content in thinking tags to markdown renderer will
96+
// content in reasoning tags to markdown renderer will
9797
// automatically be removed.
98-
// Remove end substrings of <thinking or </thinking (below xml
99-
// parsing is only for opening tags).
100-
// Tthis is done with the xml parsing below now, but keeping
101-
// here for reference.
102-
// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
10398
//
104-
// Remove all instances of <thinking> (with optional line break
105-
// after) and </thinking> (with optional line break before).
99+
// Remove all instances of reasoning tags: <think>, <thinking>, <reasoning>, <thought>
100+
// (with optional line break after opening tags) and their closing tags
101+
// (with optional line break before closing tags).
106102
// - Needs to be separate since we dont want to remove the line
107103
// break before the first tag.
108104
// - Needs to happen before the xml parsing below.
109-
content = content.replace(/<thinking>\s?/g, "")
110-
content = content.replace(/\s?<\/thinking>/g, "")
105+
const reasoningTags = ["think", "thinking", "reasoning", "thought"]
106+
reasoningTags.forEach((tag) => {
107+
// Remove opening tags with optional line break after
108+
const openingRegex = new RegExp(`<${tag}>\\s?`, "g")
109+
content = content.replace(openingRegex, "")
110+
// Remove closing tags with optional line break before
111+
const closingRegex = new RegExp(`\\s?<\\/${tag}>`, "g")
112+
content = content.replace(closingRegex, "")
113+
})
111114

112115
// Remove partial XML tag at the very end of the content (for
113116
// tool use and thinking tags), Prevents scrollview from
@@ -136,14 +139,20 @@ export async function presentAssistantMessage(cline: Task) {
136139
// (letters and underscores only).
137140
const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
138141

142+
// Check if it's a partial reasoning tag
143+
const reasoningTags = ["think", "thinking", "reasoning", "thought"]
144+
const isPartialReasoningTag = reasoningTags.some(
145+
(tag) => tag.startsWith(tagContent) || tagContent.startsWith(tag),
146+
)
147+
139148
// Preemptively remove < or </ to keep from these
140149
// artifacts showing up in chat (also handles closing
141-
// thinking tags).
150+
// reasoning tags).
142151
const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"
143152

144153
// If the tag is incomplete and at the end, remove it
145154
// from the content.
146-
if (isOpeningOrClosing || isLikelyTagName) {
155+
if (isOpeningOrClosing || isLikelyTagName || isPartialReasoningTag) {
147156
content = content.slice(0, lastOpenBracketIndex).trim()
148157
}
149158
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, it, expect } from "vitest"
2+
import { ReasoningXmlMatcher } from "../reasoning-xml-matcher"
3+
4+
describe("ReasoningXmlMatcher", () => {
5+
it("should match <think> tags", () => {
6+
const matcher = new ReasoningXmlMatcher()
7+
const input = "Some text <think>This is reasoning content</think> more text"
8+
const results = matcher.final(input)
9+
10+
expect(results).toHaveLength(3)
11+
expect(results[0]).toEqual({ matched: false, data: "Some text " })
12+
expect(results[1]).toEqual({ matched: true, data: "<think>This is reasoning content</think>" })
13+
expect(results[2]).toEqual({ matched: false, data: " more text" })
14+
})
15+
16+
it("should match <thinking> tags", () => {
17+
const matcher = new ReasoningXmlMatcher()
18+
const input = "Some text <thinking>This is reasoning content</thinking> more text"
19+
const results = matcher.final(input)
20+
21+
expect(results).toHaveLength(3)
22+
expect(results[0]).toEqual({ matched: false, data: "Some text " })
23+
expect(results[1]).toEqual({ matched: true, data: "<thinking>This is reasoning content</thinking>" })
24+
expect(results[2]).toEqual({ matched: false, data: " more text" })
25+
})
26+
27+
it("should match <reasoning> tags", () => {
28+
const matcher = new ReasoningXmlMatcher()
29+
const input = "Some text <reasoning>This is reasoning content</reasoning> more text"
30+
const results = matcher.final(input)
31+
32+
expect(results).toHaveLength(3)
33+
expect(results[0]).toEqual({ matched: false, data: "Some text " })
34+
expect(results[1]).toEqual({ matched: true, data: "<reasoning>This is reasoning content</reasoning>" })
35+
expect(results[2]).toEqual({ matched: false, data: " more text" })
36+
})
37+
38+
it("should match <thought> tags", () => {
39+
const matcher = new ReasoningXmlMatcher()
40+
const input = "Some text <thought>This is reasoning content</thought> more text"
41+
const results = matcher.final(input)
42+
43+
expect(results).toHaveLength(3)
44+
expect(results[0]).toEqual({ matched: false, data: "Some text " })
45+
expect(results[1]).toEqual({ matched: true, data: "<thought>This is reasoning content</thought>" })
46+
expect(results[2]).toEqual({ matched: false, data: " more text" })
47+
})
48+
49+
it("should handle streaming updates for all tag variants", () => {
50+
const testCases = [
51+
{ tag: "think", content: "Thinking about the problem" },
52+
{ tag: "thinking", content: "Processing the request" },
53+
{ tag: "reasoning", content: "Analyzing the situation" },
54+
{ tag: "thought", content: "Considering options" },
55+
]
56+
57+
testCases.forEach(({ tag, content }) => {
58+
const matcher = new ReasoningXmlMatcher()
59+
60+
// Simulate streaming
61+
const chunks = [
62+
"Initial text ",
63+
`<${tag}>`,
64+
content.slice(0, 10),
65+
content.slice(10),
66+
`</${tag}>`,
67+
" final text",
68+
]
69+
70+
let allResults: any[] = []
71+
chunks.forEach((chunk) => {
72+
const results = matcher.update(chunk)
73+
allResults.push(...results)
74+
})
75+
76+
// Get final results
77+
const finalResults = matcher.final()
78+
allResults.push(...finalResults)
79+
80+
// Verify we got the expected matched content
81+
const matchedResults = allResults.filter((r) => r.matched)
82+
const unmatchedResults = allResults.filter((r) => !r.matched)
83+
84+
expect(matchedResults.length).toBeGreaterThan(0)
85+
const fullMatchedContent = matchedResults.map((r) => r.data).join("")
86+
expect(fullMatchedContent).toContain(content)
87+
88+
const fullUnmatchedContent = unmatchedResults.map((r) => r.data).join("")
89+
expect(fullUnmatchedContent).toContain("Initial text")
90+
expect(fullUnmatchedContent).toContain("final text")
91+
})
92+
})
93+
94+
it("should handle nested tags correctly", () => {
95+
const matcher = new ReasoningXmlMatcher()
96+
const input = "<think>Outer <think>Inner</think> content</think>"
97+
const results = matcher.final(input)
98+
99+
// Should match the entire nested structure
100+
expect(results).toHaveLength(1)
101+
expect(results[0]).toEqual({
102+
matched: true,
103+
data: "<think>Outer <think>Inner</think> content</think>",
104+
})
105+
})
106+
107+
it("should handle multiple different reasoning tags in sequence", () => {
108+
const matcher = new ReasoningXmlMatcher()
109+
const input = "Text <think>Think content</think> middle <thinking>Thinking content</thinking> end"
110+
const results = matcher.final(input)
111+
112+
// Should match only the first tag type encountered
113+
expect(results.filter((r) => r.matched).length).toBeGreaterThan(0)
114+
expect(results.some((r) => r.data.includes("Think content"))).toBe(true)
115+
})
116+
117+
it("should apply custom transform function", () => {
118+
const transform = (chunk: { matched: boolean; data: string }) => ({
119+
type: chunk.matched ? "reasoning" : "text",
120+
text: chunk.data,
121+
})
122+
123+
const matcher = new ReasoningXmlMatcher(transform)
124+
const input = "Normal text <think>Reasoning here</think> more text"
125+
const results = matcher.final(input)
126+
127+
expect(results[0]).toEqual({ type: "text", text: "Normal text " })
128+
expect(results[1]).toEqual({ type: "reasoning", text: "<think>Reasoning here</think>" })
129+
expect(results[2]).toEqual({ type: "text", text: " more text" })
130+
})
131+
})

0 commit comments

Comments
 (0)