diff --git a/src/core/assistant-message/XmlToolCallParser.ts b/src/core/assistant-message/XmlToolCallParser.ts
new file mode 100644
index 0000000000..d7f5f5e0f4
--- /dev/null
+++ b/src/core/assistant-message/XmlToolCallParser.ts
@@ -0,0 +1,373 @@
+/**
+ * Parser for XML-style tool calls from models like Qwen3-Coder-Next.
+ *
+ * Some models (especially local models running via llama.cpp) output XML-style tool calls
+ * instead of native JSON tool calls. This parser detects and converts them to the same
+ * tool call events (tool_call_start/delta/end) that native tool calling uses.
+ *
+ * Example XML format:
+ * ```
+ *
+ * src/main.ts
+ *
+ * ```
+ *
+ * Or with equals signs inside parameter values:
+ * ```
+ *
+ * Task completed successfully
+ *
+ * ```
+ */
+
+import type {
+ ApiStreamToolCallStartChunk,
+ ApiStreamToolCallDeltaChunk,
+ ApiStreamToolCallEndChunk,
+} from "../../api/transform/stream"
+
+export type XmlToolCallEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk
+
+/**
+ * State for tracking an in-progress XML tool call during streaming.
+ */
+interface XmlToolCallState {
+ id: string
+ name: string
+ parameters: Record
+ hasStarted: boolean
+ buffer: string
+}
+
+/**
+ * Result of processing text through the XML tool call parser.
+ */
+export interface XmlToolCallParseResult {
+ /** Text content that is NOT part of a tool call (to be displayed to user) */
+ textContent: string
+ /** Tool call events to be processed */
+ events: XmlToolCallEvent[]
+ /** Whether we're currently inside an incomplete tool call (for streaming) */
+ isPartialToolCall: boolean
+}
+
+/**
+ * Parser for XML-style tool calls.
+ *
+ * This parser maintains state across multiple text chunks to handle streaming scenarios
+ * where a tool call may be split across multiple chunks.
+ */
+export class XmlToolCallParser {
+ private static toolCallCounter = 0
+
+ /** Buffer for accumulating text that might be part of a tool call */
+ private buffer: string = ""
+
+ /** Current in-progress tool call state */
+ private currentToolCall: XmlToolCallState | null = null
+
+ /** Track if we've detected the start of a potential tool call */
+ private potentialToolCallStart: boolean = false
+
+ /**
+ * Generate a unique ID for XML tool calls.
+ * Uses a prefix to distinguish from native tool call IDs.
+ */
+ private static generateToolCallId(): string {
+ return `xml_tool_${Date.now()}_${++this.toolCallCounter}`
+ }
+
+ /**
+ * Check if text contains an XML tool call pattern.
+ * Returns true if the text contains a complete or partial tool call.
+ */
+ public static containsXmlToolCall(text: string): boolean {
+ // Check for complete function tag
+ if (//.test(text)) {
+ return true
+ }
+ // Check for start of function tag (partial)
+ if (/ 0) {
+ // If we're inside a tool call, look for the end
+ if (this.currentToolCall) {
+ const result = this.processInsideToolCall()
+ events.push(...result.events)
+ if (result.completed) {
+ this.currentToolCall = null
+ } else {
+ // Tool call is incomplete, wait for more data
+ break
+ }
+ } else {
+ // Look for the start of a tool call
+ const functionMatch = this.buffer.match(//)
+
+ if (functionMatch) {
+ const matchIndex = functionMatch.index!
+ const matchEnd = matchIndex + functionMatch[0].length
+
+ // Output any text before the tool call
+ if (matchIndex > 0) {
+ textContent += this.buffer.substring(0, matchIndex)
+ }
+
+ // Start a new tool call
+ const toolName = functionMatch[1]
+ const toolId = XmlToolCallParser.generateToolCallId()
+
+ this.currentToolCall = {
+ id: toolId,
+ name: toolName,
+ parameters: {},
+ hasStarted: false,
+ buffer: "",
+ }
+
+ // Remove processed content from buffer
+ this.buffer = this.buffer.substring(matchEnd)
+
+ // Emit start event
+ events.push({
+ type: "tool_call_start",
+ id: toolId,
+ name: toolName,
+ })
+ this.currentToolCall.hasStarted = true
+ } else if (
+ this.buffer.includes(" 0) {
+ // Check if it looks like it could be a function tag
+ const afterBracket = this.buffer.substring(potentialStart)
+ if (
+ afterBracket === "<" ||
+ afterBracket.startsWith("")) {
+ this.potentialToolCallStart = true
+ break
+ }
+ // Not a tool call pattern, output as text
+ textContent += this.buffer
+ this.buffer = ""
+ } else {
+ // No tool call found, output all text
+ textContent += this.buffer
+ this.buffer = ""
+ }
+ }
+ }
+
+ return {
+ textContent,
+ events,
+ isPartialToolCall: this.currentToolCall !== null || this.potentialToolCallStart,
+ }
+ }
+
+ /**
+ * Process content inside a tool call, looking for parameters and the closing tag.
+ */
+ private processInsideToolCall(): { events: XmlToolCallEvent[]; completed: boolean } {
+ const events: XmlToolCallEvent[] = []
+
+ if (!this.currentToolCall) {
+ return { events, completed: true }
+ }
+
+ // Look for the closing tag
+ const closingMatch = this.buffer.match(/<\/function>/)
+
+ if (closingMatch) {
+ const closingIndex = closingMatch.index!
+
+ // Extract content before closing tag
+ const content = this.buffer.substring(0, closingIndex)
+
+ // Parse parameters from content
+ this.parseParameters(content)
+
+ // Build the arguments JSON
+ const argsJson = JSON.stringify(this.currentToolCall.parameters)
+
+ // Emit delta with the arguments
+ events.push({
+ type: "tool_call_delta",
+ id: this.currentToolCall.id,
+ delta: argsJson,
+ })
+
+ // Emit end event
+ events.push({
+ type: "tool_call_end",
+ id: this.currentToolCall.id,
+ })
+
+ // Remove processed content from buffer (including closing tag)
+ this.buffer = this.buffer.substring(closingIndex + "".length)
+
+ return { events, completed: true }
+ }
+
+ // Check if we have a partial closing tag at the end
+ if (
+ this.buffer.endsWith("<") ||
+ this.buffer.endsWith("") ||
+ this.buffer.endsWith("")
+ ) {
+ // Wait for more data
+ return { events, completed: false }
+ }
+
+ // No closing tag found, keep waiting
+ return { events, completed: false }
+ }
+
+ /**
+ * Parse parameter tags from content.
+ * Format: value
+ */
+ private parseParameters(content: string): void {
+ if (!this.currentToolCall) {
+ return
+ }
+
+ // Match all parameter tags
+ const paramRegex = /([\s\S]*?)<\/parameter>/g
+ let match
+
+ while ((match = paramRegex.exec(content)) !== null) {
+ const paramName = match[1]
+ const paramValue = match[2].trim()
+ this.currentToolCall.parameters[paramName] = paramValue
+ }
+ }
+
+ /**
+ * Finalize parsing and return any remaining content.
+ * Call this at the end of a stream to handle any incomplete tool calls.
+ */
+ public finalize(): XmlToolCallParseResult {
+ const events: XmlToolCallEvent[] = []
+
+ // If we have an incomplete tool call, try to complete it or emit as text
+ if (this.currentToolCall) {
+ // Check if we have a closing tag in the buffer
+ if (this.buffer.includes("")) {
+ const result = this.processInsideToolCall()
+ events.push(...result.events)
+ } else {
+ // Incomplete tool call - emit end event with what we have
+ const argsJson = JSON.stringify(this.currentToolCall.parameters)
+ events.push({
+ type: "tool_call_delta",
+ id: this.currentToolCall.id,
+ delta: argsJson,
+ })
+ events.push({
+ type: "tool_call_end",
+ id: this.currentToolCall.id,
+ })
+ }
+ this.currentToolCall = null
+ }
+
+ // Return any remaining buffer as text
+ const textContent = this.buffer
+ this.buffer = ""
+ this.potentialToolCallStart = false
+
+ return {
+ textContent,
+ events,
+ isPartialToolCall: false,
+ }
+ }
+
+ /**
+ * Reset parser state.
+ */
+ public reset(): void {
+ this.buffer = ""
+ this.currentToolCall = null
+ this.potentialToolCallStart = false
+ }
+
+ /**
+ * Check if parser has pending content.
+ */
+ public hasPendingContent(): boolean {
+ return this.buffer.length > 0 || this.currentToolCall !== null
+ }
+
+ /**
+ * Static utility method to parse a complete text block for XML tool calls.
+ * Use this for non-streaming scenarios.
+ *
+ * @param text - Complete text to parse
+ * @returns Parse result with text content and tool call events
+ */
+ public static parseComplete(text: string): XmlToolCallParseResult {
+ const parser = new XmlToolCallParser()
+ const chunkResult = parser.processChunk(text)
+ const finalResult = parser.finalize()
+
+ return {
+ textContent: chunkResult.textContent + finalResult.textContent,
+ events: [...chunkResult.events, ...finalResult.events],
+ isPartialToolCall: false,
+ }
+ }
+}
diff --git a/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts b/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts
new file mode 100644
index 0000000000..96bc3690fd
--- /dev/null
+++ b/src/core/assistant-message/__tests__/XmlToolCallParser.spec.ts
@@ -0,0 +1,258 @@
+import { XmlToolCallParser } from "../XmlToolCallParser"
+
+describe("XmlToolCallParser", () => {
+ describe("containsXmlToolCall", () => {
+ it("should detect complete function tags", () => {
+ expect(XmlToolCallParser.containsXmlToolCall("")).toBe(true)
+ expect(XmlToolCallParser.containsXmlToolCall("")).toBe(true)
+ expect(XmlToolCallParser.containsXmlToolCall("some text more text")).toBe(true)
+ })
+
+ it("should detect partial function tags", () => {
+ expect(XmlToolCallParser.containsXmlToolCall(" {
+ expect(XmlToolCallParser.containsXmlToolCall("regular text")).toBe(false)
+ expect(XmlToolCallParser.containsXmlToolCall("some tag")).toBe(false)
+ expect(XmlToolCallParser.containsXmlToolCall("let x = 5 < 10")).toBe(false)
+ })
+ })
+
+ describe("parseComplete", () => {
+ it("should parse a simple tool call", () => {
+ const text = `
+src/main.ts
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.textContent.trim()).toBe("")
+ expect(result.events).toHaveLength(3) // start, delta, end
+
+ const startEvent = result.events.find((e) => e.type === "tool_call_start")
+ expect(startEvent).toBeDefined()
+ expect(startEvent!.type).toBe("tool_call_start")
+ expect((startEvent as any).name).toBe("read_file")
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ expect(deltaEvent).toBeDefined()
+ const deltaArgs = JSON.parse((deltaEvent as any).delta)
+ expect(deltaArgs.path).toBe("src/main.ts")
+
+ const endEvent = result.events.find((e) => e.type === "tool_call_end")
+ expect(endEvent).toBeDefined()
+ })
+
+ it("should parse tool call with multiple parameters", () => {
+ const text = `
+src/app.ts
+const x = 1
+const x = 2
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.events).toHaveLength(3)
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(args.path).toBe("src/app.ts")
+ expect(args.old_string).toBe("const x = 1")
+ expect(args.new_string).toBe("const x = 2")
+ })
+
+ it("should extract text before tool call", () => {
+ const text = `Here is my analysis:
+
+
+test.txt
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.textContent.trim()).toBe("Here is my analysis:")
+ expect(result.events).toHaveLength(3)
+ })
+
+ it("should handle attempt_completion correctly", () => {
+ const text = `
+Task completed successfully. I have analyzed the code and found no issues.
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.events).toHaveLength(3)
+
+ const startEvent = result.events.find((e) => e.type === "tool_call_start")
+ expect((startEvent as any).name).toBe("attempt_completion")
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(args.result).toBe("Task completed successfully. I have analyzed the code and found no issues.")
+ })
+
+ it("should handle multiline parameter values", () => {
+ const text = `
+test.ts
+function hello() {
+ console.log("Hello, World!");
+}
+
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(args.path).toBe("test.ts")
+ expect(args.content).toContain('console.log("Hello, World!")')
+ })
+
+ it("should pass through text without tool calls", () => {
+ const text = "This is just regular text without any tool calls."
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.textContent).toBe(text)
+ expect(result.events).toHaveLength(0)
+ })
+ })
+
+ describe("streaming (processChunk)", () => {
+ it("should handle tool call split across chunks", () => {
+ const parser = new XmlToolCallParser()
+
+ // Send chunks progressively - start with a recognizable partial pattern
+ const result1 = parser.processChunk("")
+ expect(result2.events.some((e) => e.type === "tool_call_start")).toBe(true)
+
+ const result3 = parser.processChunk("test.ts")
+ // Should have delta and end events
+ const allEvents = [...result4.events]
+ expect(allEvents.some((e) => e.type === "tool_call_delta")).toBe(true)
+ expect(allEvents.some((e) => e.type === "tool_call_end")).toBe(true)
+ })
+
+ it("should accumulate text before tool call in streaming", () => {
+ const parser = new XmlToolCallParser()
+
+ const result1 = parser.processChunk("Some text ")
+ expect(result1.textContent).toBe("Some text ")
+ expect(result1.events).toHaveLength(0)
+
+ const result2 = parser.processChunk("before ")
+ expect(result2.textContent).toBe("before ")
+ expect(result2.events.some((e) => e.type === "tool_call_start")).toBe(true)
+ })
+
+ it("should finalize incomplete tool calls", () => {
+ const parser = new XmlToolCallParser()
+
+ parser.processChunk("")
+ parser.processChunk("test.ts")
+
+ // Finalize without closing tag
+ const finalResult = parser.finalize()
+
+ // Should still emit delta and end events
+ expect(finalResult.events.some((e) => e.type === "tool_call_delta")).toBe(true)
+ expect(finalResult.events.some((e) => e.type === "tool_call_end")).toBe(true)
+ })
+
+ it("should reset state correctly", () => {
+ const parser = new XmlToolCallParser()
+
+ parser.processChunk("")
+ expect(parser.hasPendingContent()).toBe(true)
+
+ parser.reset()
+ expect(parser.hasPendingContent()).toBe(false)
+ })
+ })
+
+ describe("edge cases", () => {
+ it("should handle empty parameter values", () => {
+ const text = `
+
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(args.path).toBe("")
+ })
+
+ it("should handle parameter values with special characters", () => {
+ const text = `
+echo "Hello "
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(args.command).toBe('echo "Hello "')
+ })
+
+ it("should generate unique tool call IDs", () => {
+ const result1 = XmlToolCallParser.parseComplete(
+ "a.ts",
+ )
+ const result2 = XmlToolCallParser.parseComplete(
+ "b.ts",
+ )
+
+ const id1 = (result1.events.find((e) => e.type === "tool_call_start") as any).id
+ const id2 = (result2.events.find((e) => e.type === "tool_call_start") as any).id
+
+ expect(id1).not.toBe(id2)
+ expect(id1).toMatch(/^xml_tool_/)
+ expect(id2).toMatch(/^xml_tool_/)
+ })
+
+ it("should handle Qwen3-Coder-Next exact format", () => {
+ // This is the exact format from the issue
+ const text = `I am Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. I can analyze code, explain concepts, and access external resources to help you with technical questions.
+
+
+I am Roo, a knowledgeable technical assistant focused on answering questions and providing information about software development, technology, and related topics. I can analyze code, explain concepts, and access external resources to help you with technical questions.
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.textContent.trim()).toContain("I am Roo, a knowledgeable technical assistant")
+ expect(result.events).toHaveLength(3)
+
+ const startEvent = result.events.find((e) => e.type === "tool_call_start")
+ expect((startEvent as any).name).toBe("attempt_completion")
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(args.result).toContain("I am Roo, a knowledgeable technical assistant")
+ })
+
+ it("should handle tool calls with no parameters", () => {
+ const text = `
+`
+
+ const result = XmlToolCallParser.parseComplete(text)
+
+ expect(result.events).toHaveLength(3)
+
+ const deltaEvent = result.events.find((e) => e.type === "tool_call_delta")
+ const args = JSON.parse((deltaEvent as any).delta)
+ expect(Object.keys(args)).toHaveLength(0)
+ })
+ })
+})
diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts
index 107cfdf9e9..33e11bf0a7 100644
--- a/src/core/task/Task.ts
+++ b/src/core/task/Task.ts
@@ -105,6 +105,7 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { RooProtectedController } from "../protect/RooProtectedController"
import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message"
import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser"
+import { XmlToolCallParser } from "../assistant-message/XmlToolCallParser"
import { manageContext, willManageContext } from "../context-management"
import { ClineProvider } from "../webview/ClineProvider"
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
@@ -522,6 +523,9 @@ export class Task extends EventEmitter implements TaskLike {
// Native tool call streaming state (track which index each tool is at)
private streamingToolCallIndices: Map = new Map()
+ // XML-style tool call parser for models like Qwen3-Coder-Next that output XML tool calls
+ private xmlToolCallParser: XmlToolCallParser = new XmlToolCallParser()
+
// Cached model info for current streaming session (set at start of each API request)
// This prevents excessive getModel() calls during tool execution
cachedStreamingModel?: { id: string; info: ModelInfo }
@@ -2881,6 +2885,8 @@ export class Task extends EventEmitter implements TaskLike {
// Clear any leftover streaming tool call state from previous interrupted streams
NativeToolCallParser.clearAllStreamingToolCalls()
NativeToolCallParser.clearRawChunkState()
+ // Reset XML tool call parser for models that output XML-style tool calls
+ this.xmlToolCallParser.reset()
await this.diffViewProvider.reset()
@@ -3020,22 +3026,36 @@ export class Task extends EventEmitter implements TaskLike {
break
}
case "text": {
- assistantMessage += chunk.text
+ // Check for XML-style tool calls in the text (e.g., from Qwen3-Coder-Next)
+ // These models output VALUE
+ // instead of native JSON tool calls
+ const xmlResult = this.xmlToolCallParser.processChunk(chunk.text)
- // Native tool calling: text chunks are plain text.
- // Create or update a text content block directly
- const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1]
- if (lastBlock?.type === "text" && lastBlock.partial) {
- lastBlock.content = assistantMessage
- } else {
- this.assistantMessageContent.push({
- type: "text",
- content: assistantMessage,
- partial: true,
- })
- this.userMessageContentReady = false
+ // Process any XML tool call events
+ for (const event of xmlResult.events) {
+ this.handleToolCallEvent(event)
+ }
+
+ // Only accumulate non-tool-call text
+ if (xmlResult.textContent) {
+ assistantMessage += xmlResult.textContent
+
+ // Native tool calling: text chunks are plain text.
+ // Create or update a text content block directly
+ const lastBlock =
+ this.assistantMessageContent[this.assistantMessageContent.length - 1]
+ if (lastBlock?.type === "text" && lastBlock.partial) {
+ lastBlock.content = assistantMessage
+ } else {
+ this.assistantMessageContent.push({
+ type: "text",
+ content: assistantMessage,
+ partial: true,
+ })
+ this.userMessageContentReady = false
+ }
+ presentAssistantMessage(this)
}
- presentAssistantMessage(this)
break
}
}
@@ -3372,6 +3392,13 @@ export class Task extends EventEmitter implements TaskLike {
}
}
+ // Finalize XML tool call parser for models like Qwen3-Coder-Next
+ // This handles any incomplete tool calls at the end of the stream
+ const xmlFinalResult = this.xmlToolCallParser.finalize()
+ for (const event of xmlFinalResult.events) {
+ this.handleToolCallEvent(event)
+ }
+
// IMPORTANT: Capture partialBlocks AFTER finalizeRawChunks() to avoid double-presentation.
// Tools finalized above are already presented, so we only want blocks still partial after finalization.
const partialBlocks = this.assistantMessageContent.filter((block) => block.partial)