fix: improve XML truncation error handling for Grok-4.1-Fast model

- Add detection for XML response truncation errors
- Provide context-specific error messages for truncation issues
- Include Grok-specific guidance when detected (150k+ context issue)
- Add suggestions for resolution (condense context, start fresh, etc)
- Add unit tests for truncation detection logic

Fixes #9463
This commit is contained in:
Roo Code 2025-11-21 06:32:21 +00:00
parent 038f830bf6
commit 1ca13345b9
2 changed files with 244 additions and 2 deletions

View file

@ -171,7 +171,45 @@ export async function applyDiffTool(
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to parse apply_diff XML. This usually means:
// Check for truncation-specific errors
const isTruncationError =
errorMessage.includes("StopNode is not closed") ||
errorMessage.includes("unclosed") ||
errorMessage.includes("Unexpected end") ||
argsXmlTag?.endsWith("<") ||
argsXmlTag?.endsWith("</") ||
(argsXmlTag && !argsXmlTag.includes("</args>")) // Missing closing args tag indicates truncation
// Get model info for context-specific advice (outside the if block for scope)
const modelId = cline.api.getModel().id
const isGrokModel = modelId?.includes("grok")
let detailedError: string
if (isTruncationError) {
detailedError = `XML response was truncated (incomplete). This typically occurs when:
1. The model's context window is too full (150k+ tokens)
2. The response exceeds the model's output token limit
${isGrokModel ? "Note: Grok-4.1-Fast is particularly susceptible to this issue with large contexts.\n" : ""}
Suggested solutions:
Start a new conversation with less context
Use the "Condense Context" feature to reduce conversation size
Break large operations into smaller chunks
Consider switching to a model with larger output limits
Technical details:
- Error: ${errorMessage}
- XML appears to be cut off mid-tag
- Last characters received: ${argsXmlTag ? argsXmlTag.slice(-100) : "N/A"}
To continue:
1. Try condensing the context using the UI button
2. Copy essential context and start fresh
3. Retry with smaller file edits`
} else {
detailedError = `Failed to parse apply_diff XML. This usually means:
1. The XML structure is malformed or incomplete
2. Missing required <file>, <path>, or <diff> tags
3. Invalid characters or encoding in the XML
@ -188,10 +226,18 @@ Expected structure:
</args>
Original error: ${errorMessage}`
}
cline.consecutiveMistakeCount++
cline.recordToolError("apply_diff")
TelemetryService.instance.captureDiffApplicationError(cline.taskId, cline.consecutiveMistakeCount)
await cline.say("diff_error", `Failed to parse apply_diff XML: ${errorMessage}`)
// Use a more descriptive error message for truncation issues
const userFacingMessage = isTruncationError
? `XML response truncated due to context/output limits. ${isGrokModel ? "(Known issue with Grok-4.1-Fast at 150k+ tokens) " : ""}Try condensing context or starting fresh.`
: `Failed to parse apply_diff XML: ${errorMessage}`
await cline.say("diff_error", userFacingMessage)
pushToolResult(detailedError)
cline.processQueuedMessages()
return

View file

@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { applyDiffTool } from "../MultiApplyDiffTool"
import { Task } from "../../task/Task"
import { TelemetryService } from "@roo-code/telemetry"
import { EXPERIMENT_IDS } from "../../../shared/experiments"
// Mock dependencies
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureDiffApplicationError: vi.fn(),
},
},
}))
vi.mock("../../../utils/resolveToolProtocol", () => ({
resolveToolProtocol: vi.fn().mockReturnValue("xml"),
}))
vi.mock("../../../shared/experiments", () => ({
EXPERIMENT_IDS: {
MULTI_FILE_APPLY_DIFF: "multi_file_apply_diff",
PREVENT_FOCUS_DISRUPTION: "prevent_focus_disruption",
},
experiments: {
isEnabled: vi.fn().mockReturnValue(true), // Enable multi-file experiment
},
}))
describe("MultiApplyDiffTool - XML Truncation Detection", () => {
let mockCline: Partial<Task>
let mockAskApproval: any
let mockHandleError: any
let mockPushToolResult: any
let mockRemoveClosingTag: any
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks()
// Set up mock Task instance (cline)
mockCline = {
api: {
getModel: vi.fn().mockReturnValue({
id: "openrouter/grok-4.1-fast",
info: { id: "openrouter/grok-4.1-fast" },
}),
},
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "", text: "", images: [] }),
recordToolError: vi.fn(),
processQueuedMessages: vi.fn(),
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
consecutiveMistakeCount: 0,
taskId: "test-task-id",
cwd: "/workspace",
diffViewProvider: {
reset: vi.fn().mockResolvedValue(undefined),
},
providerRef: {
deref: vi.fn().mockReturnValue({
getState: vi.fn().mockResolvedValue({
experiments: {},
}),
}),
},
} as any
// Set up mock callbacks
mockAskApproval = vi.fn().mockResolvedValue(true)
mockHandleError = vi.fn()
mockPushToolResult = vi.fn()
mockRemoveClosingTag = vi.fn()
})
it("should detect StopNode is not closed error as truncation", async () => {
const block = {
params: {
args: `<args>
<file>
<path>test.ts</path>
<diff>
<content>some diff<`, // Truncated XML
},
}
// Mock parseXmlForDiff to throw the typical truncation error
vi.doMock("../../../utils/xml", () => ({
parseXmlForDiff: vi.fn().mockImplementation(() => {
throw new Error("Failed to parse XML: StopNode is not closed")
}),
}))
await applyDiffTool(
mockCline as Task,
block as any,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify truncation-specific error was shown
expect(mockCline.say).toHaveBeenCalledWith(
"diff_error",
expect.stringContaining("XML response truncated due to context/output limits"),
)
// Verify Grok-specific message was included
expect(mockCline.say).toHaveBeenCalledWith(
"diff_error",
expect.stringContaining("Known issue with Grok-4.1-Fast at 150k+ tokens"),
)
// Verify detailed error includes truncation-specific guidance
expect(mockPushToolResult).toHaveBeenCalledWith(
expect.stringContaining("XML response was truncated (incomplete)"),
)
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining('Use the "Condense Context" feature'))
})
it("should detect XML ending with incomplete tag as truncation", async () => {
const block = {
params: {
args: `<args>
<file>
<path>test.ts</path>
<diff>
<content>some diff</`, // Ends with opening tag
},
}
// Mock parseXmlForDiff to throw a generic error
vi.doMock("../../../utils/xml", () => ({
parseXmlForDiff: vi.fn().mockImplementation(() => {
throw new Error("Failed to parse XML: Unexpected end")
}),
}))
await applyDiffTool(
mockCline as Task,
block as any,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify truncation was detected
expect(mockCline.say).toHaveBeenCalledWith(
"diff_error",
expect.stringContaining("XML response truncated due to context/output limits"),
)
})
it("should handle non-truncation XML errors normally", async () => {
const block = {
params: {
args: `<args>
<file>
<malformed>This is not valid structure</malformed>
</file>
</args>`, // Complete but malformed XML
},
}
// Mock parseXmlForDiff to throw a non-truncation error
vi.doMock("../../../utils/xml", () => ({
parseXmlForDiff: vi.fn().mockImplementation(() => {
throw new Error("Invalid XML structure: missing <path> tag")
}),
}))
await applyDiffTool(
mockCline as Task,
block as any,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify standard error message was shown (not truncation-specific)
expect(mockCline.say).toHaveBeenCalledWith(
"diff_error",
expect.stringContaining("Failed to parse apply_diff XML"),
)
// Verify NO truncation-specific messages were shown
expect(mockCline.say).not.toHaveBeenCalledWith("diff_error", expect.stringContaining("truncated"))
// Verify detailed error shows standard format guidance
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Expected structure:"))
// Verify NO condensing context messages
expect(mockPushToolResult).not.toHaveBeenCalledWith(expect.stringContaining("Condense Context"))
})
})