feat: add file context tracking to skip redundant re-reads

- Add containingMessageTs field to FileMetadataEntry schema
- Create FileContextStatusChecker to detect unchanged files in context
- Modify read_file tools to check context status before re-reading
- Return short response when file content is already in effective context
- Still show unchanged files in UI approval like normal reads
- Add 19 unit tests for FileContextStatusChecker
- Update test mocks with getTaskMetadata and apiConversationHistory
This commit is contained in:
Hannes Rudolph 2025-12-15 12:20:04 -07:00
parent 596783d365
commit 7b82c91090
8 changed files with 819 additions and 2 deletions

View file

@ -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<FileContextStatus> {
// 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
}
}

View file

@ -30,11 +30,37 @@ export class FileContextTracker {
private recentlyEditedByRoo = new Set<string>()
private checkpointPossibleFiles = new Set<string>()
// 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
}

View file

@ -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

View file

@ -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()
})
})
})

View file

@ -309,6 +309,8 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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

View file

@ -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: `<file><path>${relPath}</path><status>unchanged</status><notice>${fileResult.notice}</notice></file>`,
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 += `<notice>${fileResult.reReadNotice}</notice>\n`
nativeInfo += `\n\nNote: ${fileResult.reReadNotice}`
}
await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)
updateFileResult(relPath, {

View file

@ -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)

View file

@ -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<file><path>${relPath}</path><status>unchanged</status><notice>${notice}</notice></file>`,
)
} else {
pushToolResult(
`<file><path>${relPath}</path><status>unchanged</status><notice>${notice}</notice></file>`,
)
}
return
}
// Process the file
const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)])
@ -257,6 +284,11 @@ export async function simpleReadFileTool(
xmlInfo += `<notice>File is empty</notice>\n`
}
// Add re-read notice if content was condensed/truncated
if (reReadNotice) {
xmlInfo += `<notice>${reReadNotice}</notice>\n`
}
// Track file read
await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource)