mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
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:
parent
596783d365
commit
7b82c91090
8 changed files with 819 additions and 2 deletions
147
src/core/context-tracking/FileContextStatusChecker.ts
Normal file
147
src/core/context-tracking/FileContextStatusChecker.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue