diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 6e61c3950f..4e8b1cc46f 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -148,6 +148,7 @@ export const globalSettingsSchema = z.object({ maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), maxTotalImageSize: z.number().optional(), + preReadFileCheckpoint: z.boolean().optional(), terminalOutputLineLimit: z.number().optional(), terminalOutputCharacterLimit: z.number().optional(), diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 8827232c7b..2cd83ba0b2 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -139,6 +139,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { try { const filesToApprove: FileResult[] = [] + // Check if preReadFileCheckpoint is enabled + const globalState = await task.providerRef.deref()?.getState() + const preReadFileCheckpoint = (globalState as any)?.preReadFileCheckpoint ?? false + for (const fileResult of fileResults) { const relPath = fileResult.path const fullPath = path.resolve(task.cwd, relPath) @@ -188,6 +192,44 @@ export class ReadFileTool extends BaseTool<"read_file"> { continue } + // Pre-read checkpoint: validate file size against context limits + if (preReadFileCheckpoint) { + const { contextTokens } = task.getTokenUsage() + const contextWindow = modelInfo.contextWindow + + const budgetResult = await validateFileTokenBudget(fullPath, contextWindow, contextTokens || 0) + + // If file exceeds context budget, ask user if they want to proceed + if (budgetResult.shouldTruncate && budgetResult.reason) { + const warningMessage = `⚠️ File Size Warning for ${relPath}:\n${budgetResult.reason}\n\nThe file may cause context limit issues. Do you want to proceed with reading this file?` + + const completeMessage = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, relPath), + content: fullPath, + reason: warningMessage, + } satisfies ClineSayTool) + + const { response, text, images } = await task.ask("tool", completeMessage, false) + + if (response !== "yesButtonClicked") { + if (text) await task.say("user_feedback", text, images) + task.didRejectTool = true + updateFileResult(relPath, { + status: "denied", + xmlContent: `${relPath}Denied by user due to file size exceeding context limits`, + nativeContent: `File: ${relPath}\nStatus: Denied by user due to file size exceeding context limits`, + feedbackText: text, + feedbackImages: images, + }) + continue + } + + // User approved despite warning + if (text) await task.say("user_feedback", text, images) + } + } + filesToApprove.push(fileResult) } } diff --git a/src/core/tools/__tests__/ReadFileTool.preCheckpoint.spec.ts b/src/core/tools/__tests__/ReadFileTool.preCheckpoint.spec.ts new file mode 100644 index 0000000000..c1f4a2ec17 --- /dev/null +++ b/src/core/tools/__tests__/ReadFileTool.preCheckpoint.spec.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import path from "path" +import * as fs from "fs/promises" +import { ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import type { ToolCallbacks } from "../BaseTool" +import { validateFileTokenBudget } from "../helpers/fileTokenBudget" + +// Mock the fileTokenBudget helper +vi.mock("../helpers/fileTokenBudget", () => ({ + validateFileTokenBudget: vi.fn(), + truncateFileContent: vi.fn(), +})) + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + stat: vi.fn(), + readFile: vi.fn(), + open: vi.fn(), +})) + +// Mock isBinaryFile +vi.mock("isbinaryfile", () => ({ + isBinaryFile: vi.fn().mockResolvedValue(false), +})) + +// Mock extractTextFromFile +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("file content"), + addLineNumbers: vi.fn((content: string) => content), + getSupportedBinaryFormats: vi.fn().mockReturnValue([]), +})) + +// Mock countFileLines +vi.mock("../../../integrations/misc/line-counter", () => ({ + countFileLines: vi.fn().mockResolvedValue(10), +})) + +describe("ReadFileTool - Pre-read Checkpoint", () => { + let readFileTool: ReadFileTool + let mockTask: Partial + let mockCallbacks: ToolCallbacks + + beforeEach(() => { + vi.clearAllMocks() + readFileTool = new ReadFileTool() + + // Setup mock task + mockTask = { + cwd: "/test/workspace", + api: { + getModel: () => ({ + info: { + contextWindow: 100000, + supportsImages: false, + }, + }), + }, + apiConfiguration: { + apiProvider: "anthropic", + }, + getTokenUsage: () => ({ contextTokens: 50000 }), + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + preReadFileCheckpoint: true, + maxReadFileLine: -1, + }), + }), + }, + say: vi.fn(), + ask: vi.fn(), + sayAndCreateMissingParamError: vi.fn(), + fileContextTracker: { + trackFileContext: vi.fn(), + }, + rooIgnoreController: { + validateAccess: () => true, + }, + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didRejectTool: false, + didToolFailInCurrentTurn: false, + } as any + + mockCallbacks = { + handleError: vi.fn(), + pushToolResult: vi.fn(), + toolProtocol: "xml", + askApproval: vi.fn(), + removeClosingTag: vi.fn(), + } + + // Setup default fs mock responses + vi.mocked(fs.stat).mockResolvedValue({ size: 500000 } as any) // 500KB file + vi.mocked(fs.readFile).mockResolvedValue("file content") + }) + + it("should check file size against context limits when preReadFileCheckpoint is enabled", async () => { + // Mock validateFileTokenBudget to indicate file exceeds budget + vi.mocked(validateFileTokenBudget).mockResolvedValue({ + shouldTruncate: true, + maxChars: 1000, + reason: "File requires 60000 tokens but only 30000 tokens available in context budget", + }) + + // Mock user approval + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + }) + + const params = { + files: [{ path: "test.ts", lineRanges: [] }], + } + + await readFileTool.execute(params, mockTask as Task, mockCallbacks) + + // Verify that validateFileTokenBudget was called + expect(validateFileTokenBudget).toHaveBeenCalledWith(path.resolve("/test/workspace", "test.ts"), 100000, 50000) + + // Verify that ask was called with warning message + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("File Size Warning"), false) + + // Verify that tool result was pushed + expect(mockCallbacks.pushToolResult).toHaveBeenCalled() + }) + + it("should skip file when user denies reading file that exceeds context limits", async () => { + // Mock validateFileTokenBudget to indicate file exceeds budget + vi.mocked(validateFileTokenBudget).mockResolvedValue({ + shouldTruncate: true, + maxChars: 1000, + reason: "File requires 60000 tokens but only 30000 tokens available in context budget", + }) + + // Mock user denial + mockTask.ask = vi.fn().mockResolvedValue({ + response: "noButtonClicked", + text: "File is too large", + }) + + const params = { + files: [{ path: "large-file.ts", lineRanges: [] }], + } + + await readFileTool.execute(params, mockTask as Task, mockCallbacks) + + // Verify that validateFileTokenBudget was called + expect(validateFileTokenBudget).toHaveBeenCalled() + + // Verify that ask was called with warning message + expect(mockTask.ask).toHaveBeenCalled() + + // Verify that the tool marked rejection + expect(mockTask.didRejectTool).toBe(true) + + // Verify that result includes denial message + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Denied by user due to file size exceeding context limits"), + ) + }) + + it("should not check file size when preReadFileCheckpoint is disabled", async () => { + // Disable preReadFileCheckpoint + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + preReadFileCheckpoint: false, // Disabled + maxReadFileLine: -1, + }), + }), + } as any + + const params = { + files: [{ path: "test.ts", lineRanges: [] }], + } + + // Mock user approval for regular file read + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + }) + + await readFileTool.execute(params, mockTask as Task, mockCallbacks) + + // Verify that validateFileTokenBudget was NOT called for pre-check + // (it may still be called later in the normal flow) + const askCalls = vi.mocked(mockTask.ask).mock.calls + const warningCalls = askCalls.filter( + (call) => call[1] && typeof call[1] === "string" && call[1].includes("File Size Warning"), + ) + expect(warningCalls).toHaveLength(0) + }) + + it("should proceed without warning when file fits within context budget", async () => { + // Mock validateFileTokenBudget to indicate file fits + vi.mocked(validateFileTokenBudget).mockResolvedValue({ + shouldTruncate: false, + }) + + // Mock user approval for regular file read + mockTask.ask = vi.fn().mockResolvedValue({ + response: "yesButtonClicked", + }) + + const params = { + files: [{ path: "small-file.ts", lineRanges: [] }], + } + + await readFileTool.execute(params, mockTask as Task, mockCallbacks) + + // Verify that validateFileTokenBudget was called + expect(validateFileTokenBudget).toHaveBeenCalled() + + // Verify that no warning was shown (only regular approval) + const askCalls = vi.mocked(mockTask.ask).mock.calls + const warningCalls = askCalls.filter( + (call) => call[1] && typeof call[1] === "string" && call[1].includes("File Size Warning"), + ) + expect(warningCalls).toHaveLength(0) + + // Verify that tool result was pushed + expect(mockCallbacks.pushToolResult).toHaveBeenCalled() + }) +}) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 9342cf2127..e4805cfb5f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -290,6 +290,7 @@ export type ExtensionState = Pick< | "includeCurrentTime" | "includeCurrentCost" | "maxGitStatusFiles" + | "preReadFileCheckpoint" > & { version: string clineMessages: ClineMessage[] diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index fb2ca08b1b..bc1fd38921 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -22,6 +22,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxReadFileLine?: number maxImageFileSize?: number maxTotalImageSize?: number + preReadFileCheckpoint?: boolean maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean @@ -39,6 +40,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxReadFileLine" | "maxImageFileSize" | "maxTotalImageSize" + | "preReadFileCheckpoint" | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" @@ -61,6 +63,7 @@ export const ContextManagementSettings = ({ maxReadFileLine, maxImageFileSize, maxTotalImageSize, + preReadFileCheckpoint, maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index dc1f1ae5ae..56178eb291 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -187,6 +187,7 @@ const SettingsView = forwardRef(({ onDone, t maxReadFileLine, maxImageFileSize, maxTotalImageSize, + preReadFileCheckpoint, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, @@ -395,6 +396,7 @@ const SettingsView = forwardRef(({ onDone, t maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, + preReadFileCheckpoint: preReadFileCheckpoint ?? false, maxConcurrentFileReads: cachedState.maxConcurrentFileReads ?? 5, includeDiagnosticMessages: includeDiagnosticMessages !== undefined ? includeDiagnosticMessages : true, @@ -772,6 +774,7 @@ const SettingsView = forwardRef(({ onDone, t maxReadFileLine={maxReadFileLine} maxImageFileSize={maxImageFileSize} maxTotalImageSize={maxTotalImageSize} + preReadFileCheckpoint={preReadFileCheckpoint} maxConcurrentFileReads={maxConcurrentFileReads} profileThresholds={profileThresholds} includeDiagnosticMessages={includeDiagnosticMessages} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 4bc03e259c..b252cdaaf4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -140,6 +140,8 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxImageFileSize: (value: number) => void maxTotalImageSize: number setMaxTotalImageSize: (value: number) => void + preReadFileCheckpoint: boolean + setPreReadFileCheckpoint: (value: boolean) => void machineId?: string pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void @@ -243,6 +245,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode maxReadFileLine: -1, // Default max read file line limit maxImageFileSize: 5, // Default max image file size in MB maxTotalImageSize: 20, // Default max total image size in MB + preReadFileCheckpoint: false, // Default pre-read file checkpoint disabled pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting maxConcurrentFileReads: 5, // Default concurrent file reads @@ -452,6 +455,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const contextValue: ExtensionStateContextType = { ...state, reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true, + preReadFileCheckpoint: state.preReadFileCheckpoint ?? false, didHydrateState, showWelcome, theme, @@ -549,6 +553,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })), + setPreReadFileCheckpoint: (value) => setState((prevState) => ({ ...prevState, preReadFileCheckpoint: value })), setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })), setTerminalCompressProgressBar: (value) => setState((prevState) => ({ ...prevState, terminalCompressProgressBar: value })),