diff --git a/src/core/context-tracking/FileContextStatusChecker.ts b/src/core/context-tracking/FileContextStatusChecker.ts new file mode 100644 index 0000000000..ef72499689 --- /dev/null +++ b/src/core/context-tracking/FileContextStatusChecker.ts @@ -0,0 +1,147 @@ +import * as fs from "fs/promises" +import type { ApiMessage } from "../task-persistence/apiMessages" +import type { TaskMetadata, FileMetadataEntry } from "./FileContextTrackerTypes" + +/** + * Reasons why a file may or may not need to be re-read + */ +export type FileContextStatusReason = + | "never_read" // No prior read record exists + | "file_modified" // File has been modified on disk since last read + | "message_deleted" // The API message containing file content no longer exists + | "content_condensed" // The content was summarized during context condensation + | "content_truncated" // The content was removed during sliding window truncation + | "content_current" // File unchanged AND content still in effective context + +/** + * Result of checking whether a file needs to be re-read + */ +export type FileContextStatus = { + shouldReRead: boolean + reason: FileContextStatusReason + lastReadDate?: number +} + +/** + * Checks whether a file needs to be re-read based on: + * 1. File modification time vs last read time + * 2. Whether the message containing the file content is still in effective context + * + * @param filePath - Relative path to the file + * @param fullPath - Full absolute path to the file + * @param metadata - Task metadata containing file context tracking info + * @param messages - Current API message history + * @returns Status indicating whether the file should be re-read and why + */ +export async function checkFileContextStatus( + filePath: string, + fullPath: string, + metadata: TaskMetadata, + messages: ApiMessage[], +): Promise { + // Find the latest active entry for this file + const latestEntry = getLatestActiveEntry(metadata, filePath) + + if (!latestEntry || !latestEntry.roo_read_date) { + return { shouldReRead: true, reason: "never_read" } + } + + // 1. Check if file changed on disk using mtime + try { + const stats = await fs.stat(fullPath) + if (stats.mtimeMs > latestEntry.roo_read_date) { + return { + shouldReRead: true, + reason: "file_modified", + lastReadDate: latestEntry.roo_read_date, + } + } + } catch { + // File might not exist, let the read_file tool handle this error + return { shouldReRead: true, reason: "file_modified" } + } + + // 2. Check if the message containing file content is still in effective context + if (latestEntry.containingMessageTs) { + const containingMsg = messages.find((m) => m.ts === latestEntry.containingMessageTs) + + if (!containingMsg) { + return { + shouldReRead: true, + reason: "message_deleted", + lastReadDate: latestEntry.roo_read_date, + } + } + + // Check if message was condensed (content replaced with summary) + if (containingMsg.condenseParent) { + const summaryExists = messages.some((m) => m.isSummary && m.condenseId === containingMsg.condenseParent) + if (summaryExists) { + return { + shouldReRead: true, + reason: "content_condensed", + lastReadDate: latestEntry.roo_read_date, + } + } + } + + // Check if message was truncated (hidden from context) + if (containingMsg.truncationParent) { + const truncationMarkerExists = messages.some( + (m) => m.isTruncationMarker && m.truncationId === containingMsg.truncationParent, + ) + if (truncationMarkerExists) { + return { + shouldReRead: true, + reason: "content_truncated", + lastReadDate: latestEntry.roo_read_date, + } + } + } + } + + // File unchanged AND content still in effective context + return { + shouldReRead: false, + reason: "content_current", + lastReadDate: latestEntry.roo_read_date, + } +} + +/** + * Gets the latest active entry for a file from the task metadata + * + * @param metadata - Task metadata containing file context tracking info + * @param filePath - Relative path to the file + * @returns The most recent active entry for the file, or null if none exists + */ +function getLatestActiveEntry(metadata: TaskMetadata, filePath: string): FileMetadataEntry | null { + const entries = metadata.files_in_context + .filter((e) => e.path === filePath && e.record_state === "active" && e.roo_read_date) + .sort((a, b) => (b.roo_read_date ?? 0) - (a.roo_read_date ?? 0)) + + return entries[0] ?? null +} + +/** + * Generates a human-readable notice explaining why a file is being re-read + * + * @param reason - The reason the file needs to be re-read + * @returns A descriptive string explaining the re-read reason + */ +export function getReReadNotice(reason: FileContextStatusReason): string | undefined { + switch (reason) { + case "content_condensed": + return "Previous content was summarized during context condensation." + case "content_truncated": + return "Previous content was removed during sliding window truncation." + case "file_modified": + return "File has been modified since last read." + case "message_deleted": + return "Previous message containing file content was deleted." + case "never_read": + case "content_current": + default: + return undefined + } +} diff --git a/src/core/context-tracking/FileContextTracker.ts b/src/core/context-tracking/FileContextTracker.ts index 5741b62cfc..7753e93567 100644 --- a/src/core/context-tracking/FileContextTracker.ts +++ b/src/core/context-tracking/FileContextTracker.ts @@ -30,11 +30,37 @@ export class FileContextTracker { private recentlyEditedByRoo = new Set() private checkpointPossibleFiles = new Set() + // Message context tracking - tracks which API message the current tool results will be part of + private currentMessageTs: number | null = null + constructor(provider: ClineProvider, taskId: string) { this.providerRef = new WeakRef(provider) this.taskId = taskId } + /** + * Sets the timestamp of the current API message being built. + * Should be called before tool execution starts for each message. + */ + setCurrentMessageContext(messageTs: number): void { + this.currentMessageTs = messageTs + } + + /** + * Clears the current message context. + * Should be called after tool execution completes. + */ + clearCurrentMessageContext(): void { + this.currentMessageTs = null + } + + /** + * Gets the current message timestamp context. + */ + getCurrentMessageContext(): number | null { + return this.currentMessageTs + } + // Gets the current working directory or returns undefined if it cannot be determined private getCwd(): string | undefined { const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) @@ -168,6 +194,8 @@ export class FileContextTracker { roo_read_date: getLatestDateForField(filePath, "roo_read_date"), roo_edit_date: getLatestDateForField(filePath, "roo_edit_date"), user_edit_date: getLatestDateForField(filePath, "user_edit_date"), + // Track the API message containing this file's content (for context status checking) + containingMessageTs: this.currentMessageTs, } switch (source) { @@ -181,6 +209,7 @@ export class FileContextTracker { case "roo_edited": newEntry.roo_read_date = now newEntry.roo_edit_date = now + newEntry.containingMessageTs = this.currentMessageTs this.checkpointPossibleFiles.add(filePath) this.markFileAsEditedByRoo(filePath) break @@ -189,6 +218,7 @@ export class FileContextTracker { case "read_tool": case "file_mentioned": newEntry.roo_read_date = now + newEntry.containingMessageTs = this.currentMessageTs break } diff --git a/src/core/context-tracking/FileContextTrackerTypes.ts b/src/core/context-tracking/FileContextTrackerTypes.ts index 7a761a1d39..a5f823b8e4 100644 --- a/src/core/context-tracking/FileContextTrackerTypes.ts +++ b/src/core/context-tracking/FileContextTrackerTypes.ts @@ -14,6 +14,8 @@ export const fileMetadataEntrySchema = z.object({ roo_read_date: z.number().nullable(), roo_edit_date: z.number().nullable(), user_edit_date: z.number().nullable().optional(), + // Timestamp of the API message containing this file's content (for context tracking) + containingMessageTs: z.number().nullable().optional(), }) // TypeScript type derived from the Zod schema diff --git a/src/core/context-tracking/__tests__/FileContextStatusChecker.test.ts b/src/core/context-tracking/__tests__/FileContextStatusChecker.test.ts new file mode 100644 index 0000000000..4767e53a8c --- /dev/null +++ b/src/core/context-tracking/__tests__/FileContextStatusChecker.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { checkFileContextStatus, getReReadNotice } from "../FileContextStatusChecker" +import type { TaskMetadata } from "../FileContextTrackerTypes" +import type { ApiMessage } from "../../task-persistence/apiMessages" + +// Mock fs/promises with factory +const mockStat = vi.fn() +vi.mock("fs/promises", () => ({ + stat: (...args: unknown[]) => mockStat(...args), +})) + +describe("FileContextStatusChecker", () => { + const testFilePath = "test/file.ts" + const testFullPath = "/workspace/test/file.ts" + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("checkFileContextStatus", () => { + describe("never_read scenario", () => { + it("should return never_read when no entry exists for the file", async () => { + const metadata: TaskMetadata = { + files_in_context: [], + } + const messages: ApiMessage[] = [] + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "never_read", + }) + }) + + it("should return never_read when entry exists but has no roo_read_date", async () => { + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: null, + roo_edit_date: null, + user_edit_date: null, + }, + ], + } + const messages: ApiMessage[] = [] + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "never_read", + }) + }) + + it("should return never_read when entry exists but is stale", async () => { + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "stale", + record_source: "read_tool", + roo_read_date: Date.now() - 1000, + roo_edit_date: null, + user_edit_date: null, + }, + ], + } + const messages: ApiMessage[] = [] + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "never_read", + }) + }) + }) + + describe("file_modified scenario", () => { + it("should return file_modified when file mtime is newer than last read", async () => { + const readDate = Date.now() - 5000 + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + }, + ], + } + const messages: ApiMessage[] = [] + + // Mock file stat to return mtime newer than read date + mockStat.mockResolvedValue({ + mtimeMs: Date.now(), + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "file_modified", + lastReadDate: readDate, + }) + }) + + it("should return file_modified when file stat throws error (file may not exist)", async () => { + const readDate = Date.now() - 5000 + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + }, + ], + } + const messages: ApiMessage[] = [] + + // Mock file stat to throw error (file doesn't exist) + mockStat.mockRejectedValue(new Error("ENOENT")) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "file_modified", + }) + }) + }) + + describe("message_deleted scenario", () => { + it("should return message_deleted when containing message no longer exists", async () => { + const readDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + // No messages exist + const messages: ApiMessage[] = [] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "message_deleted", + lastReadDate: readDate, + }) + }) + }) + + describe("content_condensed scenario", () => { + it("should return content_condensed when message was condensed with summary", async () => { + const readDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + const condenseId = "condense-123" + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + const messages: ApiMessage[] = [ + { + role: "user", + content: "original content", + ts: messageTs, + condenseParent: condenseId, + }, + { + role: "assistant", + content: "Summary of conversation", + ts: Date.now() - 3000, + isSummary: true, + condenseId: condenseId, + }, + ] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "content_condensed", + lastReadDate: readDate, + }) + }) + + it("should not return content_condensed when message has condenseParent but no summary exists", async () => { + const readDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + const condenseId = "condense-123" + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + const messages: ApiMessage[] = [ + { + role: "user", + content: "original content", + ts: messageTs, + condenseParent: condenseId, + }, + // No summary message with matching condenseId + ] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + // Should be content_current since no actual summary exists + expect(result).toEqual({ + shouldReRead: false, + reason: "content_current", + lastReadDate: readDate, + }) + }) + }) + + describe("content_truncated scenario", () => { + it("should return content_truncated when message was truncated", async () => { + const readDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + const truncationId = "truncation-456" + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + const messages: ApiMessage[] = [ + { + role: "user", + content: "original content", + ts: messageTs, + truncationParent: truncationId, + }, + { + role: "assistant", + content: "[Context truncation marker]", + ts: Date.now() - 3000, + isTruncationMarker: true, + truncationId: truncationId, + }, + ] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: true, + reason: "content_truncated", + lastReadDate: readDate, + }) + }) + + it("should not return content_truncated when message has truncationParent but no marker exists", async () => { + const readDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + const truncationId = "truncation-456" + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + const messages: ApiMessage[] = [ + { + role: "user", + content: "original content", + ts: messageTs, + truncationParent: truncationId, + }, + // No truncation marker with matching truncationId + ] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + // Should be content_current since no actual truncation marker exists + expect(result).toEqual({ + shouldReRead: false, + reason: "content_current", + lastReadDate: readDate, + }) + }) + }) + + describe("content_current scenario", () => { + it("should return content_current when file unchanged and content still in context", async () => { + const readDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + const messages: ApiMessage[] = [ + { + role: "user", + content: "original content with file", + ts: messageTs, + // No condenseParent or truncationParent + }, + ] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: false, + reason: "content_current", + lastReadDate: readDate, + }) + }) + + it("should return content_current when file unchanged and no containingMessageTs (legacy entry)", async () => { + const readDate = Date.now() - 5000 + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: readDate, + roo_edit_date: null, + user_edit_date: null, + // No containingMessageTs - legacy entry + }, + ], + } + + const messages: ApiMessage[] = [] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: readDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: false, + reason: "content_current", + lastReadDate: readDate, + }) + }) + }) + + describe("multiple entries for same file", () => { + it("should use the latest active entry for the file", async () => { + const oldReadDate = Date.now() - 10000 + const newReadDate = Date.now() - 5000 + const messageTs = Date.now() - 4000 + + const metadata: TaskMetadata = { + files_in_context: [ + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: oldReadDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: Date.now() - 9000, + }, + { + path: testFilePath, + record_state: "active", + record_source: "read_tool", + roo_read_date: newReadDate, + roo_edit_date: null, + user_edit_date: null, + containingMessageTs: messageTs, + }, + ], + } + + const messages: ApiMessage[] = [ + { + role: "user", + content: "latest read", + ts: messageTs, + }, + ] + + // Mock file stat to show file hasn't changed + mockStat.mockResolvedValue({ + mtimeMs: newReadDate - 1000, + }) + + const result = await checkFileContextStatus(testFilePath, testFullPath, metadata, messages) + + expect(result).toEqual({ + shouldReRead: false, + reason: "content_current", + lastReadDate: newReadDate, + }) + }) + }) + }) + + describe("getReReadNotice", () => { + it("should return appropriate notice for content_condensed", () => { + expect(getReReadNotice("content_condensed")).toBe( + "Previous content was summarized during context condensation.", + ) + }) + + it("should return appropriate notice for content_truncated", () => { + expect(getReReadNotice("content_truncated")).toBe( + "Previous content was removed during sliding window truncation.", + ) + }) + + it("should return appropriate notice for file_modified", () => { + expect(getReReadNotice("file_modified")).toBe("File has been modified since last read.") + }) + + it("should return appropriate notice for message_deleted", () => { + expect(getReReadNotice("message_deleted")).toBe("Previous message containing file content was deleted.") + }) + + it("should return undefined for never_read", () => { + expect(getReReadNotice("never_read")).toBeUndefined() + }) + + it("should return undefined for content_current", () => { + expect(getReReadNotice("content_current")).toBeUndefined() + }) + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b515e60855..e7104d1832 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -309,6 +309,8 @@ export class Task extends EventEmitter implements TaskLike { presentAssistantMessageHasPendingUpdates = false userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false + // Timestamp for the pending user message (for file context tracking coordination) + pendingUserMessageTs: number | null = null didRejectTool = false didAlreadyUseTool = false didToolFailInCurrentTurn = false @@ -815,8 +817,16 @@ export class Task extends EventEmitter implements TaskLike { } else { // For user messages, validate and fix tool_result IDs against the previous assistant message const validatedMessage = validateAndFixToolResultIds(message, this.apiConversationHistory) - const messageWithTs = { ...validatedMessage, ts: Date.now() } + // Use pendingUserMessageTs if set (for file context tracking coordination) + // This ensures the message timestamp matches what was set during tool execution + const messageTs = this.pendingUserMessageTs ?? Date.now() + const messageWithTs = { ...validatedMessage, ts: messageTs } this.apiConversationHistory.push(messageWithTs) + // Clear the pending timestamp and message context after use + if (this.pendingUserMessageTs) { + this.pendingUserMessageTs = null + this.fileContextTracker.clearCurrentMessageContext() + } } await this.saveApiConversationHistory() @@ -2406,6 +2416,13 @@ export class Task extends EventEmitter implements TaskLike { NativeToolCallParser.clearAllStreamingToolCalls() NativeToolCallParser.clearRawChunkState() + // Set up message context for file context tracking. + // This timestamp will be used for the user message containing tool results, + // allowing us to track which message contains each file's content. + const userMessageTs = Date.now() + this.pendingUserMessageTs = userMessageTs + this.fileContextTracker.setCurrentMessageContext(userMessageTs) + await this.diffViewProvider.reset() // Cache model info once per API request to avoid repeated calls during streaming diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 17282df774..ca0b73f12d 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -9,6 +9,7 @@ import { formatResponse } from "../prompts/responses" import { getModelMaxOutputTokens } from "../../shared/api" import { t } from "../../i18n" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { checkFileContextStatus, getReReadNotice } from "../context-tracking/FileContextStatusChecker" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" import { countFileLines } from "../../integrations/misc/line-counter" @@ -32,7 +33,7 @@ import type { ToolUse } from "../../shared/tools" interface FileResult { path: string - status: "approved" | "denied" | "blocked" | "error" | "pending" + status: "approved" | "denied" | "blocked" | "error" | "pending" | "unchanged" content?: string error?: string notice?: string @@ -42,6 +43,7 @@ interface FileResult { imageDataUrl?: string feedbackText?: string feedbackImages?: any[] + reReadNotice?: string // Notice explaining why file is being re-read (condensation/truncation) } export class ReadFileTool extends BaseTool<"read_file"> { @@ -189,6 +191,36 @@ export class ReadFileTool extends BaseTool<"read_file"> { continue } + // Check if file needs to be re-read based on context status + // Skip this check for line range requests as those always need fresh content + if (!fileResult.lineRanges || fileResult.lineRanges.length === 0) { + const metadata = await task.fileContextTracker.getTaskMetadata(task.taskId) + const contextStatus = await checkFileContextStatus( + relPath, + fullPath, + metadata, + task.apiConversationHistory, + ) + + if (!contextStatus.shouldReRead) { + // File unchanged - store info for later, but still show in approval UI + const lastReadTime = contextStatus.lastReadDate + ? new Date(contextStatus.lastReadDate).toISOString() + : "unknown" + const notice = `File content unchanged since last read (${lastReadTime}). Content is already in your current context.` + updateFileResult(relPath, { + notice, // Store the notice for use after approval + }) + // Continue to filesToApprove - we'll handle unchanged after approval + } else { + // Store the re-read notice for later use + const reReadNotice = getReReadNotice(contextStatus.reason) + if (reReadNotice) { + updateFileResult(relPath, { reReadNotice }) + } + } + } + filesToApprove.push(fileResult) } } @@ -336,6 +368,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { const relPath = fileResult.path const fullPath = path.resolve(task.cwd, relPath) + // Handle unchanged files - return short response after approval was shown in UI + if (fileResult.notice && fileResult.notice.includes("unchanged")) { + updateFileResult(relPath, { + status: "unchanged", + xmlContent: `${relPath}unchanged${fileResult.notice}`, + nativeContent: `File: ${relPath}\nStatus: unchanged\nNote: ${fileResult.notice}`, + }) + continue + } + try { const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) @@ -574,6 +616,12 @@ export class ReadFileTool extends BaseTool<"read_file"> { } } + // Add re-read notice if content was condensed/truncated + if (fileResult.reReadNotice) { + xmlInfo += `${fileResult.reReadNotice}\n` + nativeInfo += `\n\nNote: ${fileResult.reReadNotice}` + } + await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index d22c163636..f180cd6a0d 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -225,7 +225,9 @@ function createMockCline(): any { removeClosingTag: vi.fn((tag, content) => content), fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), }, + apiConversationHistory: [], recordToolUsage: vi.fn().mockReturnValue(undefined), recordToolError: vi.fn().mockReturnValue(undefined), didRejectTool: false, @@ -846,7 +848,9 @@ describe("read_file tool XML output structure", () => { mockCline.removeClosingTag = vi.fn((tag, content) => content) mockCline.fileContextTracker = { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), } + mockCline.apiConversationHistory = [] mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) @@ -919,7 +923,9 @@ describe("read_file tool XML output structure", () => { mockCline.removeClosingTag = vi.fn((tag, content) => content) mockCline.fileContextTracker = { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), } + mockCline.apiConversationHistory = [] mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) @@ -1005,7 +1011,9 @@ describe("read_file tool XML output structure", () => { mockCline.removeClosingTag = vi.fn((tag, content) => content) mockCline.fileContextTracker = { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), } + mockCline.apiConversationHistory = [] mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) @@ -1078,7 +1086,9 @@ describe("read_file tool XML output structure", () => { mockCline.removeClosingTag = vi.fn((tag, content) => content) mockCline.fileContextTracker = { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), } + mockCline.apiConversationHistory = [] mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) @@ -1203,7 +1213,9 @@ describe("read_file tool XML output structure", () => { mockCline.removeClosingTag = vi.fn((tag, content) => content) mockCline.fileContextTracker = { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), } + mockCline.apiConversationHistory = [] mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) @@ -1250,7 +1262,9 @@ describe("read_file tool XML output structure", () => { mockCline.removeClosingTag = vi.fn((tag, content) => content) mockCline.fileContextTracker = { trackFileContext: vi.fn().mockResolvedValue(undefined), + getTaskMetadata: vi.fn().mockResolvedValue({ files_in_context: [] }), } + mockCline.apiConversationHistory = [] mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) diff --git a/src/core/tools/simpleReadFileTool.ts b/src/core/tools/simpleReadFileTool.ts index 1b41e9e9d6..9999d6cd08 100644 --- a/src/core/tools/simpleReadFileTool.ts +++ b/src/core/tools/simpleReadFileTool.ts @@ -7,6 +7,7 @@ import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { checkFileContextStatus, getReReadNotice } from "../context-tracking/FileContextStatusChecker" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" import { countFileLines } from "../../integrations/misc/line-counter" @@ -85,6 +86,13 @@ export async function simpleReadFileTool( return } + // Check if file needs to be re-read based on context status + const metadata = await cline.fileContextTracker.getTaskMetadata(cline.taskId) + const contextStatus = await checkFileContextStatus(relPath, fullPath, metadata, cline.apiConversationHistory) + + // If we need to re-read, get the notice explaining why (for later use) + const reReadNotice = getReReadNotice(contextStatus.reason) + // Get max read file line setting const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} @@ -125,6 +133,25 @@ export async function simpleReadFileTool( await cline.say("user_feedback", text, images) } + // If file is unchanged and content is still in context, return short response + if (!contextStatus.shouldReRead) { + const lastReadTime = contextStatus.lastReadDate + ? new Date(contextStatus.lastReadDate).toISOString() + : "unknown" + const notice = `File content unchanged since last read (${lastReadTime}). Content is already in your current context.` + if (text) { + const statusMessage = formatResponse.toolApprovedWithFeedback(text) + pushToolResult( + `${statusMessage}\n${relPath}unchanged${notice}`, + ) + } else { + pushToolResult( + `${relPath}unchanged${notice}`, + ) + } + return + } + // Process the file const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) @@ -257,6 +284,11 @@ export async function simpleReadFileTool( xmlInfo += `File is empty\n` } + // Add re-read notice if content was condensed/truncated + if (reReadNotice) { + xmlInfo += `${reReadNotice}\n` + } + // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)