fix: remove cache logic from read_file deduplication feature

- Removed cache window logic from deduplicateReadFileHistory method
- Removed getRecentFileContent method from Task.ts
- Removed cache-related code from readFileTool.ts
- Removed readFileDeduplicationCacheMinutes setting from all type definitions
- Updated tests to remove cache-related test cases
- Verified all cache-related code has been removed

The deduplication feature now deduplicates all duplicate read_file results regardless of their age.
This commit is contained in:
hannesrudolph 2025-07-28 14:58:41 -06:00
parent 44f4bd5194
commit c98889cebf
9 changed files with 1 additions and 659 deletions

View file

@ -120,7 +120,6 @@ export const globalSettingsSchema = z.object({
diffEnabled: z.boolean().optional(),
fuzzyMatchThreshold: z.number().optional(),
experiments: experimentsSchema.optional(),
readFileDeduplicationCacheMinutes: z.number().optional(),
codebaseIndexModels: codebaseIndexModelsSchema.optional(),
codebaseIndexConfig: codebaseIndexConfigSchema.optional(),

View file

@ -329,79 +329,6 @@ export class Task extends EventEmitter<ClineEvents> {
return readApiMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath })
}
public async getRecentFileContent(filePath: string): Promise<string | null> {
// Check if the experimental feature is enabled
const state = await this.providerRef.deref()?.getState()
if (!state?.experiments || !experiments.isEnabled(state.experiments, EXPERIMENT_IDS.READ_FILE_DEDUPLICATION)) {
return null
}
// Get the cache window from settings
const cacheMinutes = state?.readFileDeduplicationCacheMinutes ?? 5
if (cacheMinutes === 0) {
// Cache is disabled
return null
}
const cacheWindowMs = cacheMinutes * 60 * 1000
const now = Date.now()
// Check recent conversation history for this file
for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) {
const message = this.apiConversationHistory[i]
// Only process user messages
if (message.role !== "user") {
continue
}
// Skip messages outside the cache window
if (message.ts && now - message.ts > cacheWindowMs) {
break
}
// Process content blocks
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text" && typeof block.text === "string") {
// Check for read_file results in text blocks
const readFileMatch = block.text.match(/\[read_file(?:\s+for\s+'([^']+)')?.*?\]\s*Result:/i)
if (readFileMatch) {
// Extract file paths from the result content
const resultContent = block.text.substring(block.text.indexOf("Result:") + 7).trim()
// Handle new XML format
const xmlFileMatches = resultContent.matchAll(
/<file>\s*<path>([^<]+)<\/path>[\s\S]*?<content[^>]*?>([\s\S]*?)<\/content>/g,
)
for (const match of xmlFileMatches) {
const matchedPath = match[1].trim()
const content = match[2].trim()
if (matchedPath === filePath) {
return content
}
}
// Handle legacy format (single file)
if (
readFileMatch[1] &&
readFileMatch[1] === filePath &&
!resultContent.includes("<files>")
) {
// For legacy format, the content is directly after "Result:"
// Remove any leading/trailing whitespace
return resultContent.trim()
}
}
}
}
}
}
return null
}
public async deduplicateReadFileHistory(): Promise<void> {
// Check if the experimental feature is enabled
const state = await this.providerRef.deref()?.getState()
@ -409,10 +336,6 @@ export class Task extends EventEmitter<ClineEvents> {
return
}
// Get the cache window from settings, defaulting to 5 minutes if not set
const cacheMinutes = state?.readFileDeduplicationCacheMinutes ?? 5
const cacheWindowMs = cacheMinutes * 60 * 1000
const now = Date.now()
const seenFiles = new Map<string, { messageIndex: number; blockIndex: number }>()
const blocksToRemove = new Map<number, Set<number>>() // messageIndex -> Set of blockIndexes to remove
@ -425,11 +348,6 @@ export class Task extends EventEmitter<ClineEvents> {
continue
}
// Skip messages within the cache window
if (message.ts && now - message.ts < cacheWindowMs) {
continue
}
// Process content blocks
if (Array.isArray(message.content)) {
for (let j = 0; j < message.content.length; j++) {

View file

@ -1611,37 +1611,6 @@ describe("Cline", () => {
}
})
it("should preserve messages within cache window", async () => {
const now = Date.now()
cline.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 10 * 60 * 1000, // 10 minutes ago
},
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>recent content</content></file></files>",
},
],
ts: now - 2 * 60 * 1000, // 2 minutes ago (within 5 minute cache window)
},
]
await cline.deduplicateReadFileHistory()
// Should keep both messages (recent one is within cache window)
expect(cline.apiConversationHistory).toHaveLength(2)
})
it("should handle multi-file reads", async () => {
const now = Date.now()
cline.apiConversationHistory = [
@ -1944,498 +1913,6 @@ describe("Cline", () => {
expect(content[0].text).toContain("new2")
}
})
it("should use configurable cache time limit", async () => {
// Test with 0 minutes (no cache window)
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
readFileDeduplicationCacheMinutes: 0,
})
const now = Date.now()
cline.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 1000, // 1 second ago
},
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>new content</content></file></files>",
},
],
ts: now - 500, // 0.5 seconds ago
},
]
await cline.deduplicateReadFileHistory()
// With 0 cache window, should deduplicate even very recent reads
expect(cline.apiConversationHistory).toHaveLength(1)
const content = cline.apiConversationHistory[0].content
if (Array.isArray(content) && content[0]?.type === "text") {
expect(content[0].text).toContain("new content")
}
})
it("should use custom cache time limit from settings", async () => {
// Test with 10 minutes cache window
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
readFileDeduplicationCacheMinutes: 10,
})
const now = Date.now()
cline.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 15 * 60 * 1000, // 15 minutes ago
},
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>recent content</content></file></files>",
},
],
ts: now - 8 * 60 * 1000, // 8 minutes ago (within 10 minute window)
},
]
await cline.deduplicateReadFileHistory()
// Should keep both messages (recent one is within 10 minute cache window)
expect(cline.apiConversationHistory).toHaveLength(2)
})
it("should default to 5 minutes when setting is undefined", async () => {
// Test with undefined setting (should default to 5 minutes)
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
// readFileDeduplicationCacheMinutes is undefined
})
const now = Date.now()
cline.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 10 * 60 * 1000, // 10 minutes ago
},
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>recent content</content></file></files>",
},
],
ts: now - 3 * 60 * 1000, // 3 minutes ago (within default 5 minute window)
},
]
await cline.deduplicateReadFileHistory()
// Should keep both messages (recent one is within default 5 minute cache window)
expect(cline.apiConversationHistory).toHaveLength(2)
})
it("should handle large cache time limits", async () => {
// Test with 60 minutes (1 hour) cache window
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
readFileDeduplicationCacheMinutes: 60,
})
const now = Date.now()
cline.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 2 * 60 * 60 * 1000, // 2 hours ago
},
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>recent content</content></file></files>",
},
],
ts: now - 30 * 60 * 1000, // 30 minutes ago (within 60 minute window)
},
]
await cline.deduplicateReadFileHistory()
// Should keep both messages (recent one is within 60 minute cache window)
expect(cline.apiConversationHistory).toHaveLength(2)
})
})
describe("getRecentFileContent", () => {
let mockProvider: any
let mockApiConfig: any
let task: Task
beforeEach(() => {
vi.clearAllMocks()
mockApiConfig = {
apiProvider: "anthropic",
apiKey: "test-key",
}
mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
},
getState: vi.fn().mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
readFileDeduplicationCacheMinutes: 5,
}),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
}
task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
})
it("should return null when feature is disabled", async () => {
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: false,
},
})
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>test content</content></file></files>",
},
],
ts: now - 1000, // 1 second ago
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBeNull()
})
it("should return recent file content within cache window", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>recent content</content></file></files>",
},
],
ts: now - 2 * 60 * 1000, // 2 minutes ago (within 5 minute window)
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBe("recent content")
})
it("should return null for files outside cache window", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 10 * 60 * 1000, // 10 minutes ago (outside 5 minute window)
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBeNull()
})
it("should return most recent content when multiple reads exist", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>old content</content></file></files>",
},
],
ts: now - 4 * 60 * 1000, // 4 minutes ago
},
{
role: "assistant",
content: [{ type: "text" as const, text: "Processing..." }],
ts: now - 3 * 60 * 1000,
},
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>newer content</content></file></files>",
},
],
ts: now - 2 * 60 * 1000, // 2 minutes ago
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBe("newer content")
})
it("should handle multi-file reads", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'file1.ts', 'file2.ts'] Result:\n<files><file><path>file1.ts</path><content>content1</content></file><file><path>file2.ts</path><content>content2</content></file></files>",
},
],
ts: now - 2 * 60 * 1000,
},
]
const result1 = await task.getRecentFileContent("file1.ts")
expect(result1).toBe("content1")
const result2 = await task.getRecentFileContent("file2.ts")
expect(result2).toBe("content2")
})
it("should return null for non-existent files", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>test content</content></file></files>",
},
],
ts: now - 1000,
},
]
const result = await task.getRecentFileContent("other.ts")
expect(result).toBeNull()
})
it("should handle legacy format", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'legacy.ts'] Result:\nFile content without XML wrapper",
},
],
ts: now - 1000,
},
]
const result = await task.getRecentFileContent("legacy.ts")
expect(result).toBe("File content without XML wrapper")
})
it("should ignore assistant messages", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "assistant",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>assistant content</content></file></files>",
},
],
ts: now - 1000,
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBeNull()
})
it("should handle messages without timestamps", async () => {
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>test content</content></file></files>",
},
],
// No ts property - should be treated as recent
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBe("test content")
})
it("should use custom cache time from settings", async () => {
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
readFileDeduplicationCacheMinutes: 10,
})
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>content within 10 min</content></file></files>",
},
],
ts: now - 8 * 60 * 1000, // 8 minutes ago (within 10 minute window)
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBe("content within 10 min")
})
it("should handle 0 cache time (no caching)", async () => {
mockProvider.getState.mockResolvedValue({
experiments: {
[EXPERIMENT_IDS.READ_FILE_DEDUPLICATION]: true,
},
readFileDeduplicationCacheMinutes: 0,
})
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for 'test.ts'] Result:\n<files><file><path>test.ts</path><content>very recent content</content></file></files>",
},
],
ts: now - 100, // 0.1 seconds ago
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBeNull() // With 0 cache time, nothing is cached
})
it("should handle malformed content gracefully", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: "string content instead of array", // Invalid format
ts: now - 1000,
},
{
role: "user",
content: [
{
type: "image" as const,
source: { type: "base64" as const, media_type: "image/png", data: "..." },
},
], // Non-text block
ts: now - 500,
},
]
const result = await task.getRecentFileContent("test.ts")
expect(result).toBeNull()
})
it("should handle empty conversation history", async () => {
task.apiConversationHistory = []
const result = await task.getRecentFileContent("test.ts")
expect(result).toBeNull()
})
it("should handle file paths with special characters", async () => {
const now = Date.now()
task.apiConversationHistory = [
{
role: "user",
content: [
{
type: "text" as const,
text: "[read_file for '@scope/package/file.ts'] Result:\n<files><file><path>@scope/package/file.ts</path><content>scoped content</content></file></files>",
},
],
ts: now - 1000,
},
]
const result = await task.getRecentFileContent("@scope/package/file.ts")
expect(result).toBe("scoped content")
})
})
})
})

View file

@ -130,9 +130,6 @@ describe("read_file tool with maxReadFileLine setting", () => {
// Add the deduplicateReadFileHistory method to the mock
mockCline.deduplicateReadFileHistory = vi.fn().mockReturnValue(undefined)
// Add the getRecentFileContent method to the mock
mockCline.getRecentFileContent = vi.fn().mockResolvedValue(null)
toolResult = undefined
})
@ -392,9 +389,6 @@ describe("read_file tool XML output structure", () => {
// Add the deduplicateReadFileHistory method to the mock
mockCline.deduplicateReadFileHistory = vi.fn().mockReturnValue(undefined)
// Add the getRecentFileContent method to the mock
mockCline.getRecentFileContent = vi.fn().mockResolvedValue(null)
toolResult = undefined
})

View file

@ -431,45 +431,7 @@ export async function readFileTool(
const fullPath = path.resolve(cline.cwd, relPath)
const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {}
// Check if we have recent content for this file (deduplication)
const recentContent = await cline.getRecentFileContent(relPath)
if (recentContent !== null) {
// We have recent content, use it instead of reading the file again
const lines = recentContent.split("\n")
const totalLines = lines.length
// Handle range reads
if (fileResult.lineRanges && fileResult.lineRanges.length > 0) {
const rangeResults: string[] = []
for (const range of fileResult.lineRanges) {
const selectedLines = lines.slice(range.start - 1, range.end).join("\n")
const content = addLineNumbers(selectedLines, range.start)
const lineRangeAttr = ` lines="${range.start}-${range.end}"`
rangeResults.push(`<content${lineRangeAttr}>\n${content}</content>`)
}
updateFileResult(relPath, {
xmlContent: `<file><path>${relPath}</path>\n${rangeResults.join("\n")}\n<notice>Using cached content from recent read</notice>\n</file>`,
})
continue
}
// Handle normal file read with cached content
const lineRangeAttr = ` lines="1-${totalLines}"`
let xmlInfo = totalLines > 0 ? `<content${lineRangeAttr}>\n${recentContent}</content>\n` : `<content/>`
if (totalLines === 0) {
xmlInfo += `<notice>File is empty</notice>\n`
} else {
xmlInfo += `<notice>Using cached content from recent read</notice>\n`
}
updateFileResult(relPath, {
xmlContent: `<file><path>${relPath}</path>\n${xmlInfo}</file>`,
})
continue
}
// Process approved files (no cached content available)
// Process approved files
try {
const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)])

View file

@ -1444,9 +1444,6 @@ export class ClineProvider
maxDiagnosticMessages,
} = state
// Get readFileDeduplicationCacheMinutes with default value
const readFileDeduplicationCacheMinutes = state.readFileDeduplicationCacheMinutes ?? 5
const telemetryKey = process.env.POSTHOG_API_KEY
const machineId = vscode.env.machineId
const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
@ -1536,7 +1533,6 @@ export class ClineProvider
language: language ?? formatLanguage(vscode.env.language),
renderContext: this.renderContext,
maxReadFileLine: maxReadFileLine ?? -1,
readFileDeduplicationCacheMinutes: readFileDeduplicationCacheMinutes ?? 5,
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
settingsImportedAt: this.settingsImportedAt,
terminalCompressProgressBar: terminalCompressProgressBar ?? true,
@ -1707,7 +1703,6 @@ export class ClineProvider
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
maxReadFileLine: stateValues.maxReadFileLine ?? -1,
readFileDeduplicationCacheMinutes: stateValues.readFileDeduplicationCacheMinutes ?? 5,
maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5,
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
cloudUserInfo,

View file

@ -542,7 +542,6 @@ describe("ClineProvider", () => {
profileThresholds: {},
hasOpenedModeSelector: false,
diagnosticsEnabled: true,
readFileDeduplicationCacheMinutes: 5,
}
const message: ExtensionMessage = {

View file

@ -281,7 +281,6 @@ export type ExtensionState = Pick<
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
readFileDeduplicationCacheMinutes: number // Cache window in minutes for read_file deduplication (0 = no cache)
experiments: Experiments // Map of experiment IDs to their enabled state

View file

@ -202,7 +202,6 @@ export interface WebviewMessage {
| "saveCodeIndexSettingsAtomic"
| "requestCodeIndexSecretStatus"
| "requestCommands"
| "readFileDeduplicationCacheMinutes"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"