diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
index 847aa6e4dc..cbb2a91333 100644
--- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
+++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
@@ -97,6 +97,75 @@ describe("BaseOpenAiCompatibleProvider", () => {
])
})
+ it("should handle reasoning tags () from stream", async () => {
+ mockCreate.mockImplementationOnce(() => {
+ return {
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Deep thought" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " here" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Result: 42" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }
+ })
+ const stream = handler.createMessage("system prompt", [])
+ const chunks = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Deep thought" },
+ { type: "reasoning", text: " here" },
+ { type: "text", text: "Result: 42" },
+ ])
+ })
+
+ it("should not close tag with tag", async () => {
+ mockCreate.mockImplementationOnce(() => {
+ return {
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "Thinking" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " but closing with wrong tag" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " still thinking" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }
+ })
+ const stream = handler.createMessage("system prompt", [])
+ const chunks = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+ // The tag should be treated as text since it doesn't match the active tag
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "Thinking" },
+ { type: "reasoning", text: " but closing with wrong tag" },
+ { type: "reasoning", text: " still thinking" },
+ ])
+ })
+
it("should handle complete tag in a single chunk", async () => {
mockCreate.mockImplementationOnce(() => {
return {
@@ -153,13 +222,8 @@ describe("BaseOpenAiCompatibleProvider", () => {
chunks.push(chunk)
}
- // TagMatcher should handle incomplete tags and flush remaining content
- expect(chunks.length).toBeGreaterThan(0)
- expect(
- chunks.some(
- (c) => (c.type === "text" || c.type === "reasoning") && c.text.includes("Incomplete thought"),
- ),
- ).toBe(true)
+ // TagMatcher should flush incomplete reasoning content on stream end
+ expect(chunks).toContainEqual({ type: "reasoning", text: "Incomplete thought" })
})
it("should handle text without any tags", async () => {
diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts
index 3c006f8318..708a131957 100644
--- a/src/api/providers/__tests__/openai.spec.ts
+++ b/src/api/providers/__tests__/openai.spec.ts
@@ -636,6 +636,175 @@ describe("OpenAiHandler", () => {
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBe(4096)
})
+
+ describe("TagMatcher reasoning tags", () => {
+ it("should treat stray closing tag as plain text when no tag is open", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "finaltext" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([{ type: "text", text: "finaltext" }])
+ })
+
+ it("should treat extra closing tag after a closed block as plain text", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: {
+ choices: [{ delta: { content: "thinkingfinaltext" } }],
+ },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "thinking" },
+ { type: "text", text: "finaltext" },
+ ])
+ })
+
+ it("should handle nested mixed tags with correct closure matching", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "outer" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "inner" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " middle" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "final text" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // With the tag stack fix, closes inner tag,
+ // and correctly closes the outer tag.
+ // inner content inside is reasoning, middle is still reasoning under
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "outer" },
+ { type: "reasoning", text: "inner" },
+ { type: "reasoning", text: " middle" },
+ { type: "text", text: "final text" },
+ ])
+ })
+
+ it("should handle nested tags with correct stack unwinding", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "outer" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "inner" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " middle" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "final text" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ // With the tag stack fix, closes inner tag,
+ // and correctly closes the outer tag.
+ // inner content inside is reasoning, middle is still reasoning under
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "outer" },
+ { type: "reasoning", text: "inner" },
+ { type: "reasoning", text: " middle" },
+ { type: "text", text: "final text" },
+ ])
+ })
+
+ it("should handle reasoning_content alongside tag matching", async () => {
+ mockCreate.mockImplementationOnce(() => ({
+ [Symbol.asyncIterator]: () => ({
+ next: vi
+ .fn()
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { reasoning_content: "native reasoning" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: "tag based" } }] },
+ })
+ .mockResolvedValueOnce({
+ done: false,
+ value: { choices: [{ delta: { content: " final output" } }] },
+ })
+ .mockResolvedValueOnce({ done: true }),
+ }),
+ }))
+
+ const stream = handler.createMessage(systemPrompt, messages)
+ const chunks: any[] = []
+ for await (const chunk of stream) {
+ chunks.push(chunk)
+ }
+
+ expect(chunks).toEqual([
+ { type: "reasoning", text: "native reasoning" },
+ { type: "reasoning", text: "tag based" },
+ { type: "text", text: " final output" },
+ ])
+ })
+ })
})
describe("error handling", () => {
diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts
index 28c812660f..b6094f9cc4 100644
--- a/src/api/providers/base-openai-compatible-provider.ts
+++ b/src/api/providers/base-openai-compatible-provider.ts
@@ -118,7 +118,7 @@ export abstract class BaseOpenAiCompatibleProvider
const stream = await this.createStream(systemPrompt, messages, metadata)
const matcher = new TagMatcher(
- "think",
+ ["think", "thought"],
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts
index d04bd157c7..b109518557 100644
--- a/src/api/providers/lm-studio.ts
+++ b/src/api/providers/lm-studio.ts
@@ -104,7 +104,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
}
const matcher = new TagMatcher(
- "think",
+ ["think", "thought"],
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts
index 99c1dc03cf..7ee91282a4 100644
--- a/src/api/providers/native-ollama.ts
+++ b/src/api/providers/native-ollama.ts
@@ -215,7 +215,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
]
const matcher = new TagMatcher(
- "think",
+ ["think", "thought"],
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts
index abef612d88..c8f17dac3e 100644
--- a/src/api/providers/openai.ts
+++ b/src/api/providers/openai.ts
@@ -184,7 +184,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
const matcher = new TagMatcher(
- "think",
+ ["think", "thought"],
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
diff --git a/src/utils/__tests__/tag-matcher.spec.ts b/src/utils/__tests__/tag-matcher.spec.ts
new file mode 100644
index 0000000000..114eaccce7
--- /dev/null
+++ b/src/utils/__tests__/tag-matcher.spec.ts
@@ -0,0 +1,154 @@
+// npx vitest utils/__tests__/tag-matcher.spec.ts
+
+import { TagMatcher } from "../tag-matcher"
+
+describe("TagMatcher", () => {
+ describe("collect() chunk merging (line 52)", () => {
+ it("merges consecutive same-type chars into one chunk within a single call", () => {
+ // Two text chars in one update() → both hit collect() with matched=false
+ // second char finds last chunk same type → last.data += char (line 52)
+ const matcher = new TagMatcher("think")
+ const result = matcher.update("ab")
+ expect(result).toEqual([{ matched: false, data: "ab" }])
+ })
+
+ it("merges consecutive reasoning chars within a single call", () => {
+ const matcher = new TagMatcher("think")
+ matcher.update("")
+ const result = matcher.update("ab")
+ expect(result).toEqual([{ matched: true, data: "ab" }])
+ })
+ })
+
+ describe("final() with a chunk argument (line 131)", () => {
+ it("processes a chunk passed directly to final()", () => {
+ // Call final() with a chunk instead of update() — exercises line 131
+ const matcher = new TagMatcher("think")
+ const result = matcher.final("hello")
+ expect(result).toEqual([{ matched: false, data: "hello" }])
+ })
+
+ it("processes a closing tag passed to final()", () => {
+ const matcher = new TagMatcher("think")
+ // Don't use update() — keeps reasoning in the buffer so final() flushes it
+ const result = matcher.final("reasoning")
+ expect(result.some((r) => r.matched && r.data === "reasoning")).toBe(true)
+ })
+ })
+
+ describe("space handling in TAG_OPEN (lines 93-97)", () => {
+ it("tolerates a space before tag name has started (line 95: all candidates at index 0)", () => {
+ // "< think>" — space arrives when all candidates are at index 0
+ // hits line 95 (continue), candidates survive, 't' then matches normally
+ const matcher = new TagMatcher("think")
+ const result = matcher.final("< think>content")
+ expect(result.some((r) => r.matched && r.data === "content")).toBe(true)
+ })
+
+ it("drops mid-match candidates on a space (line 97)", () => {
+ // "| " — space arrives mid-match (index > 0, index < name.length)
+ // those candidates are dropped, tag is not opened
+ const matcher = new TagMatcher("think")
+ const result = matcher.final(" | content")
+ expect(result.every((r) => !r.matched)).toBe(true)
+ })
+ })
+
+ describe("multi-tag constructor (string[])", () => {
+ it("opens and closes when constructed with array", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("deep reasoningdone")
+ expect(result.some((r) => r.matched && r.data === "deep reasoning")).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "done")).toBe(true)
+ })
+
+ it("opens and closes when constructed with array", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("thinkingdone")
+ expect(result.some((r) => r.matched && r.data === "thinking")).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "done")).toBe(true)
+ })
+
+ it(" open is not closed by (cross-tag isolation)", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("reasoningstill reasoningdone")
+ // must be treated as text since active tag is
+ expect(result.some((r) => r.matched && r.data.includes(""))).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "done")).toBe(true)
+ })
+
+ it(" open is not closed by (inverse cross-tag isolation)", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("reasoningstill reasoningdone")
+ // must be treated as text since active tag is
+ expect(result.some((r) => r.matched && r.data.includes(""))).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "done")).toBe(true)
+ })
+ })
+
+ describe("chunk split at mid-tag-name boundary", () => {
+ it("correctly opens tag split across two update() calls", () => {
+ const matcher = new TagMatcher("think")
+ const first = matcher.update("content")
+ expect(second.some((r) => r.matched && r.data === "content")).toBe(true)
+ })
+ })
+
+ describe("unmatched > in TAG_OPEN falls back to TEXT", () => {
+ it("treats as plain text when xyz is not a configured tag name", () => {
+ const matcher = new TagMatcher("think")
+ const result = matcher.final("content")
+ expect(result.every((r) => !r.matched)).toBe(true)
+ })
+
+ it("treats stray closing tag as plain text when no tag is open", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("finaltext")
+ expect(result).toEqual([{ matched: false, data: "finaltext" }])
+ })
+
+ it("treats extra closing tag after a closed block as plain text", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("thinkingfinaltext")
+ expect(result.some((r) => r.matched && r.data === "thinking")).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "finaltext")).toBe(true)
+ })
+ })
+
+ describe("nested tags", () => {
+ it("treats inner as text when outer is active", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("outerinner middlefinal")
+ expect(result.some((r) => r.matched && r.data.includes("inner"))).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "final")).toBe(true)
+ })
+
+ it("correctly unwinds nested same-name tags", () => {
+ const matcher = new TagMatcher(["think", "thought"])
+ const result = matcher.final("outerinner middlefinal")
+ expect(result.some((r) => r.matched && r.data.includes("inner"))).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "final")).toBe(true)
+ })
+ })
+
+ describe("space handling in TAG_CLOSE (line 119)", () => {
+ it("tolerates a trailing space before > in closing tag ()", () => {
+ // space at index === tagName.length hits line 119 (continue)
+ const matcher = new TagMatcher("think")
+ const result = matcher.final("reasoningafter")
+ expect(result.some((r) => r.matched && r.data === "reasoning")).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "after")).toBe(true)
+ })
+
+ it("tolerates a leading space after in closing tag ( think>)", () => {
+ // space at index === 0 hits line 119 (continue)
+ const matcher = new TagMatcher("think")
+ const result = matcher.final("reasoning think>after")
+ expect(result.some((r) => r.matched && r.data === "reasoning")).toBe(true)
+ expect(result.some((r) => !r.matched && r.data === "after")).toBe(true)
+ })
+ })
+})
diff --git a/src/utils/tag-matcher.ts b/src/utils/tag-matcher.ts
index 38d99a2904..f10bf2f1ee 100644
--- a/src/utils/tag-matcher.ts
+++ b/src/utils/tag-matcher.ts
@@ -17,11 +17,17 @@ export class TagMatcher {
state: "TEXT" | "TAG_OPEN" | "TAG_CLOSE" = "TEXT"
depth = 0
pointer = 0
+ private readonly tagNames: string[]
+ private activeTagNames: string[] = []
+ private candidates: { name: string; index: number }[] = []
+
constructor(
- readonly tagName: string,
+ tagName: string | [string, ...string[]],
readonly transform?: (chunks: TagMatcherResult) => Result,
readonly position = 0,
- ) {}
+ ) {
+ this.tagNames = Array.isArray(tagName) ? tagName : [tagName]
+ }
private collect() {
if (!this.cached.length) {
return
@@ -56,39 +62,64 @@ export class TagMatcher {
if (this.state === "TEXT") {
if (char === "<" && (this.pointer <= this.position + 1 || this.matched)) {
this.state = "TAG_OPEN"
- this.index = 0
+ if (this.depth === 0) {
+ this.candidates = this.tagNames.map((name) => ({ name, index: 0 }))
+ } else {
+ const active = this.activeTagNames.at(-1)
+ this.candidates = active ? [{ name: active, index: 0 }] : []
+ }
} else {
this.collect()
}
} else if (this.state === "TAG_OPEN") {
- if (char === ">" && this.index === this.tagName.length) {
- this.state = "TEXT"
- if (!this.matched) {
- this.cached = []
+ if (char === ">") {
+ const matched = this.candidates.find((c) => c.index === c.name.length)
+ if (matched) {
+ this.state = "TEXT"
+ this.activeTagNames.push(matched.name)
+ if (!this.matched) {
+ this.cached = []
+ }
+ this.depth++
+ this.matched = true
+ continue
+ } else {
+ this.state = "TEXT"
+ this.collect()
}
- this.depth++
- this.matched = true
- } else if (this.index === 0 && char === "/") {
+ } else if (this.candidates.every((c) => c.index === 0) && char === "/") {
this.state = "TAG_CLOSE"
- } else if (char === " " && (this.index === 0 || this.index === this.tagName.length)) {
+ this.index = 0
continue
- } else if (this.tagName[this.index] === char) {
- this.index++
+ } else if (char === " ") {
+ const remaining = this.candidates.filter((c) => c.index === 0 || c.index === c.name.length)
+ if (remaining.length === this.candidates.length) {
+ continue
+ }
+ this.candidates = remaining
} else {
- this.state = "TEXT"
- this.collect()
+ this.candidates = this.candidates.filter((c) => c.name[c.index] === char)
+ for (const c of this.candidates) {
+ c.index++
+ }
+ if (this.candidates.length === 0) {
+ this.state = "TEXT"
+ this.collect()
+ }
}
} else if (this.state === "TAG_CLOSE") {
- if (char === ">" && this.index === this.tagName.length) {
+ const tagName = this.activeTagNames.at(-1) ?? this.tagNames[0]
+ if (char === ">" && this.index === tagName.length) {
this.state = "TEXT"
this.depth--
+ this.activeTagNames.pop()
this.matched = this.depth > 0
if (!this.matched) {
this.cached = []
}
- } else if (char === " " && (this.index === 0 || this.index === this.tagName.length)) {
+ } else if (char === " " && (this.index === 0 || this.index === tagName.length)) {
continue
- } else if (this.tagName[this.index] === char) {
+ } else if (tagName[this.index] === char) {
this.index++
} else {
this.state = "TEXT"
@@ -102,10 +133,15 @@ export class TagMatcher {
this._update(chunk)
}
this.collect()
+ this.candidates = []
+ this.activeTagNames = []
return this.pop()
}
update(chunk: string) {
this._update(chunk)
+ if (this.state === "TEXT") {
+ this.collect()
+ }
return this.pop()
}
}
|