fix: enable XML tool parsing when switching from Native to XML protocol mid-task

- Dynamically check tool protocol on each streaming chunk instead of caching at stream start
- Ensures XML parser is activated immediately when switching profiles from Native to XML
- Fixes issue where XML tool calls were treated as plain text after protocol switch

Fixes #9509
This commit is contained in:
Roo Code 2025-11-22 21:41:36 +00:00
parent cdc72750e4
commit fa80b4f000
2 changed files with 307 additions and 6 deletions

View file

@ -2240,8 +2240,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.cachedStreamingModel = this.api.getModel()
const streamModelInfo = this.cachedStreamingModel.info
const cachedModelId = this.cachedStreamingModel.id
const streamProtocol = resolveToolProtocol(this.apiConfiguration, streamModelInfo)
const shouldUseXmlParser = streamProtocol === "xml"
// Yields only if the first chunk is successful, otherwise will
// allow the user to retry the request (most likely due to rate
@ -2349,8 +2347,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
case "text": {
assistantMessage += chunk.text
// Use the protocol determined at the start of streaming
// Don't rely solely on parser existence - parser might exist from previous state
// Dynamically check the current protocol on each chunk to handle mid-task profile switches
// This ensures XML parsing activates immediately when switching from Native to XML
const currentModelInfo = this.api.getModel().info
const currentProtocol = resolveToolProtocol(this.apiConfiguration, currentModelInfo)
const shouldUseXmlParser = currentProtocol === "xml"
if (shouldUseXmlParser && this.assistantMessageParser) {
// XML protocol: Parse raw assistant message chunk into content blocks
const prevLength = this.assistantMessageContent.length
@ -2679,8 +2681,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// this.assistantMessageContent.forEach((e) => (e.partial = false))
// Now that the stream is complete, finalize any remaining partial content blocks (XML protocol only)
// Use the protocol determined at the start of streaming
if (shouldUseXmlParser && this.assistantMessageParser) {
// Check the current protocol (not cached) to handle mid-stream profile switches
const finalModelInfo = this.api.getModel().info
const finalProtocol = resolveToolProtocol(this.apiConfiguration, finalModelInfo)
if (finalProtocol === "xml" && this.assistantMessageParser) {
this.assistantMessageParser.finalizeContentBlocks()
const parsedBlocks = this.assistantMessageParser.getContentBlocks()
// For XML protocol: Use only parsed blocks (includes both text and tool_use parsed from XML)

View file

@ -0,0 +1,297 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { Task } from "../Task"
import { AssistantMessageParser } from "../../assistant-message/AssistantMessageParser"
import { resolveToolProtocol } from "../../../utils/resolveToolProtocol"
import type { ClineProvider } from "../../webview/ClineProvider"
import type { ApiHandler } from "../../../api"
import { TOOL_PROTOCOL } from "@roo-code/types"
// Mock vscode module
vi.mock("vscode", () => ({
default: {},
workspace: {
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(),
onDidChange: vi.fn(),
onDidDelete: vi.fn(),
dispose: vi.fn(),
})),
getConfiguration: vi.fn(() => ({
get: vi.fn(),
})),
},
RelativePattern: class RelativePattern {
constructor(
public base: string,
public pattern: string,
) {}
},
EventEmitter: class EventEmitter {
fire = vi.fn()
event = vi.fn()
dispose = vi.fn()
},
}))
// Mock other dependencies
vi.mock("../../../utils/resolveToolProtocol")
vi.mock("../../assistant-message/AssistantMessageParser", () => ({
AssistantMessageParser: vi.fn(() => ({
processChunk: vi.fn(),
finalizeContentBlocks: vi.fn(),
getContentBlocks: vi.fn(),
reset: vi.fn(),
})),
}))
vi.mock("../../ignore/RooIgnoreController", () => ({
RooIgnoreController: class RooIgnoreController {
initialize = vi.fn().mockResolvedValue(undefined)
dispose = vi.fn()
getInstructions = vi.fn()
},
}))
vi.mock("../../protect/RooProtectedController", () => ({
RooProtectedController: class RooProtectedController {
dispose = vi.fn()
},
}))
vi.mock("../../context-tracking/FileContextTracker", () => ({
FileContextTracker: class FileContextTracker {
dispose = vi.fn()
},
}))
vi.mock("../../../services/browser/UrlContentFetcher", () => ({
UrlContentFetcher: class UrlContentFetcher {
closeBrowser = vi.fn()
},
}))
vi.mock("../../../services/browser/BrowserSession", () => ({
BrowserSession: class BrowserSession {
closeBrowser = vi.fn()
isSessionActive = vi.fn().mockReturnValue(false)
},
}))
vi.mock("../../../integrations/editor/DiffViewProvider", () => ({
DiffViewProvider: class DiffViewProvider {
reset = vi.fn()
revertChanges = vi.fn()
isEditing = false
},
}))
vi.mock("../../../api", () => ({
buildApiHandler: vi.fn(() => mockApi),
}))
vi.mock("../../tools/ToolRepetitionDetector", () => ({
ToolRepetitionDetector: class ToolRepetitionDetector {},
}))
vi.mock("../../message-queue/MessageQueueService", () => ({
MessageQueueService: class MessageQueueService {
on = vi.fn()
removeListener = vi.fn()
dispose = vi.fn()
isEmpty = vi.fn().mockReturnValue(true)
messages = []
},
}))
vi.mock("../../auto-approval", () => ({
AutoApprovalHandler: class AutoApprovalHandler {
checkAutoApprovalLimits = vi.fn()
},
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureTaskCreated: vi.fn(),
captureTaskRestarted: vi.fn(),
captureConversationMessage: vi.fn(),
captureLlmCompletion: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
},
},
}))
// Need to define mockApi outside beforeEach so it's available to the mock
let mockApi: any
describe("Task Protocol Switching", () => {
let mockProvider: any
beforeEach(() => {
// Reset mockApi before each test
mockApi = {
getModel: vi.fn(() => ({
id: "test-model",
info: {
supportsNativeTools: true,
contextWindow: 100000,
},
})),
}
// Setup mock provider
mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
extensionUri: { fsPath: "/test/extension" },
},
getState: vi.fn(),
postStateToWebview: vi.fn(),
postMessageToWebview: vi.fn(),
on: vi.fn(),
off: vi.fn(),
log: vi.fn(),
updateTaskHistory: vi.fn(),
providerSettingsManager: {
getProfile: vi.fn(),
},
}
// Setup default return values
mockProvider.getState.mockResolvedValue({
apiConfiguration: {
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.NATIVE,
},
})
})
it("should dynamically check protocol during streaming when switching from Native to XML", async () => {
// Start with Native protocol
const mockResolveToolProtocol = vi.mocked(resolveToolProtocol)
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE)
// Create task with Native protocol
const task = new Task({
provider: mockProvider as any,
apiConfiguration: {
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.NATIVE,
},
task: "Test task",
})
// Verify parser is not created for native protocol
expect(task.assistantMessageParser).toBeUndefined()
// Simulate profile switch to XML protocol
await task.updateApiConfiguration({
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.XML,
})
// Mock the protocol resolution to return XML after switch
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.XML)
// Verify parser is created after switch to XML
expect(task.assistantMessageParser).toBeDefined()
expect(task.assistantMessageParser).toBeInstanceOf(AssistantMessageParser)
})
it("should handle XML tool calls correctly after switching from Native to XML mid-task", async () => {
const mockResolveToolProtocol = vi.mocked(resolveToolProtocol)
// Start with Native protocol
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE)
const task = new Task({
provider: mockProvider as any,
apiConfiguration: {
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.NATIVE,
},
task: "Test task",
})
// Switch to XML protocol mid-task
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.XML)
await task.updateApiConfiguration({
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.XML,
})
// Mock parser to simulate parsing XML tool calls
const mockProcessChunk = vi.fn().mockReturnValue([
{
type: "tool_use",
name: "read_file",
params: { path: "test.txt" },
partial: false,
},
])
if (task.assistantMessageParser) {
task.assistantMessageParser.processChunk = mockProcessChunk
}
// Simulate receiving XML text chunk that should be parsed as a tool call
const xmlChunk = "<read_file><path>test.txt</path></read_file>"
// The protocol should be checked dynamically and use XML parser
const result = task.assistantMessageParser?.processChunk(xmlChunk)
expect(mockProcessChunk).toHaveBeenCalledWith(xmlChunk)
expect(result).toBeDefined()
expect(result?.[0]).toMatchObject({
type: "tool_use",
name: "read_file",
params: { path: "test.txt" },
})
})
it("should handle switching from XML to Native protocol", async () => {
const mockResolveToolProtocol = vi.mocked(resolveToolProtocol)
// Start with XML protocol
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.XML)
const task = new Task({
provider: mockProvider as any,
apiConfiguration: {
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.XML,
},
task: "Test task",
})
// Verify parser is created for XML protocol
expect(task.assistantMessageParser).toBeDefined()
// Switch to Native protocol
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.NATIVE)
await task.updateApiConfiguration({
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.NATIVE,
})
// Verify parser is removed after switch to Native
expect(task.assistantMessageParser).toBeUndefined()
})
it("should not recreate parser if protocol doesn't change", async () => {
const mockResolveToolProtocol = vi.mocked(resolveToolProtocol)
mockResolveToolProtocol.mockReturnValue(TOOL_PROTOCOL.XML)
const task = new Task({
provider: mockProvider as any,
apiConfiguration: {
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.XML,
},
task: "Test task",
})
const originalParser = task.assistantMessageParser
// Update configuration but keep same protocol
await task.updateApiConfiguration({
apiProvider: "anthropic",
toolProtocol: TOOL_PROTOCOL.XML,
apiKey: "new-key", // Different property
})
// Parser should remain the same instance
expect(task.assistantMessageParser).toBe(originalParser)
})
})