diff --git a/src/core/tools/MultiApplyDiffTool.ts b/src/core/tools/MultiApplyDiffTool.ts index 7e076d27a9..ede835cc0d 100644 --- a/src/core/tools/MultiApplyDiffTool.ts +++ b/src/core/tools/MultiApplyDiffTool.ts @@ -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("")) // 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 , , or tags 3. Invalid characters or encoding in the XML @@ -188,10 +226,18 @@ Expected structure: 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 diff --git a/src/core/tools/__tests__/MultiApplyDiffTool.truncation.spec.ts b/src/core/tools/__tests__/MultiApplyDiffTool.truncation.spec.ts new file mode 100644 index 0000000000..146738f8b6 --- /dev/null +++ b/src/core/tools/__tests__/MultiApplyDiffTool.truncation.spec.ts @@ -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 + 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: ` + + test.ts + + 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: ` + + test.ts + + some diff ({ + 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: ` + + This is not valid structure + + `, // 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 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")) + }) +})