From 76b1deec25c7fa528c42c42a0e1b47c1e0d9f2ec Mon Sep 17 00:00:00 2001 From: anthony bushong Date: Wed, 8 Oct 2025 13:58:52 -0700 Subject: [PATCH] fix(core): refresh file contents in smart edit given newer edits from user/external process (#10084) --- packages/core/src/tools/smart-edit.test.ts | 116 +++++++++++++++------ packages/core/src/tools/smart-edit.ts | 38 ++++++- packages/core/src/utils/llm-edit-fixer.ts | 6 +- 3 files changed, 121 insertions(+), 39 deletions(-) diff --git a/packages/core/src/tools/smart-edit.test.ts b/packages/core/src/tools/smart-edit.test.ts index d3cad90ad2..f6c4c107c8 100644 --- a/packages/core/src/tools/smart-edit.test.ts +++ b/packages/core/src/tools/smart-edit.test.ts @@ -25,6 +25,7 @@ vi.mock('../utils/llm-edit-fixer.js', () => ({ vi.mock('../core/client.js', () => ({ GeminiClient: vi.fn().mockImplementation(() => ({ generateJson: mockGenerateJson, + getHistory: vi.fn().mockResolvedValue([]), })), })); @@ -64,6 +65,7 @@ describe('SmartEditTool', () => { let rootDir: string; let mockConfig: Config; let geminiClient: any; + let fileSystemService: StandardFileSystemService; let baseLlmClient: BaseLlmClient; beforeEach(() => { @@ -74,12 +76,15 @@ describe('SmartEditTool', () => { geminiClient = { generateJson: mockGenerateJson, + getHistory: vi.fn().mockResolvedValue([]), }; baseLlmClient = { generateJson: mockGenerateJson, } as unknown as BaseLlmClient; + fileSystemService = new StandardFileSystemService(); + mockConfig = { getUsageStatisticsEnabled: vi.fn(() => true), getSessionId: vi.fn(() => 'mock-session-id'), @@ -93,7 +98,7 @@ describe('SmartEditTool', () => { getApprovalMode: vi.fn(), setApprovalMode: vi.fn(), getWorkspaceContext: () => createMockWorkspaceContext(rootDir), - getFileSystemService: () => new StandardFileSystemService(), + getFileSystemService: () => fileSystemService, getIdeMode: () => false, getApiKey: () => 'test-api-key', getModel: () => 'test-model', @@ -449,7 +454,7 @@ describe('SmartEditTool', () => { const params: EditToolParams = { file_path: filePath, instruction: 'Replace original with brand new', - old_string: 'original text that is slightly wrong', // This will fail first + old_string: 'wrong text', // This will fail first new_string: 'brand new text', }; @@ -469,35 +474,6 @@ describe('SmartEditTool', () => { expect(mockFixLLMEditWithInstruction).toHaveBeenCalledTimes(1); }); - it('should return NO_CHANGE if FixLLMEditWithInstruction determines no changes are needed', async () => { - const initialContent = 'The price is $100.'; - fs.writeFileSync(filePath, initialContent, 'utf8'); - const params: EditToolParams = { - file_path: filePath, - instruction: 'Ensure the price is $100', - old_string: 'price is $50', // Incorrect old string - new_string: 'price is $100', - }; - - mockFixLLMEditWithInstruction.mockResolvedValueOnce({ - noChangesRequired: true, - search: '', - replace: '', - explanation: 'The price is already correctly set to $100.', - }); - - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - - expect(result.error?.type).toBe( - ToolErrorType.EDIT_NO_CHANGE_LLM_JUDGEMENT, - ); - expect(result.llmContent).toMatch( - /A secondary check by an LLM determined/, - ); - expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent); // File is unchanged - }); - it('should preserve CRLF line endings when editing a file', async () => { const initialContent = 'line one\r\nline two\r\n'; const newContent = 'line one\r\nline three\r\n'; @@ -531,6 +507,84 @@ describe('SmartEditTool', () => { const finalContent = fs.readFileSync(filePath, 'utf8'); expect(finalContent).toBe(newContentWithCRLF); }); + + it('should return NO_CHANGE if FixLLMEditWithInstruction determines no changes are needed', async () => { + const initialContent = 'The price is $100.'; + fs.writeFileSync(filePath, initialContent, 'utf8'); + const params: EditToolParams = { + file_path: filePath, + instruction: 'Ensure the price is $100', + old_string: 'price is $50', // Incorrect old string + new_string: 'price is $100', + }; + + mockFixLLMEditWithInstruction.mockResolvedValueOnce({ + noChangesRequired: true, + search: '', + replace: '', + explanation: 'The price is already correctly set to $100.', + }); + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error?.type).toBe( + ToolErrorType.EDIT_NO_CHANGE_LLM_JUDGEMENT, + ); + expect(result.llmContent).toMatch( + /A secondary check by an LLM determined/, + ); + expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent); // File is unchanged + }); + }); + + describe('self-correction with content refresh to pull in external edits', () => { + const testFile = 'test.txt'; + let filePath: string; + + beforeEach(() => { + filePath = path.join(rootDir, testFile); + }); + + it('should use refreshed file content for self-correction if file was modified externally', async () => { + const initialContent = 'This is the original content.'; + const externallyModifiedContent = + 'This is the externally modified content.'; + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + instruction: + 'Replace "externally modified content" with "externally modified string"', + old_string: 'externally modified content', // This will fail the first attempt, triggering self-correction. + new_string: 'externally modified string', + }; + + // Spy on `readTextFile` to simulate an external file change between reads. + const readTextFileSpy = vi + .spyOn(fileSystemService, 'readTextFile') + .mockResolvedValueOnce(initialContent) // First call in `calculateEdit` + .mockResolvedValueOnce(externallyModifiedContent); // Second call in `attemptSelfCorrection` + + const invocation = tool.build(params); + await invocation.execute(new AbortController().signal); + + // Assert that the file was read twice (initial read, then re-read for hash comparison). + expect(readTextFileSpy).toHaveBeenCalledTimes(2); + + // Assert that the self-correction LLM was called with the updated content and a specific message. + expect(mockFixLLMEditWithInstruction).toHaveBeenCalledWith( + expect.any(String), // instruction + params.old_string, + params.new_string, + expect.stringContaining( + 'However, the file has been modified by either the user or an external process', + ), // errorForLlmEditFixer + externallyModifiedContent, // The new content for correction + expect.any(Object), // baseLlmClient + expect.any(Object), // abortSignal + ); + }); }); describe('Error Scenarios', () => { diff --git a/packages/core/src/tools/smart-edit.ts b/packages/core/src/tools/smart-edit.ts index 81a0ef609d..11c5c45d8d 100644 --- a/packages/core/src/tools/smart-edit.ts +++ b/packages/core/src/tools/smart-edit.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as crypto from 'node:crypto'; import * as Diff from 'diff'; import { BaseDeclarativeTool, @@ -50,6 +51,15 @@ interface ReplacementResult { finalNewString: string; } +/** + * Creates a SHA256 hash of the given content. + * @param content The string content to hash. + * @returns A hex-encoded hash string. + */ +function hashContent(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex'); +} + function restoreTrailingNewline( originalContent: string, modifiedContent: string, @@ -374,12 +384,30 @@ class EditToolInvocation implements ToolInvocation { abortSignal: AbortSignal, originalLineEnding: '\r\n' | '\n', ): Promise { + // In order to keep from clobbering edits made outside our system, + // check if the file has been modified since we first read it. + let errorForLlmEditFixer = initialError.raw; + let contentForLlmEditFixer = currentContent; + + const initialContentHash = hashContent(currentContent); + const onDiskContent = await this.config + .getFileSystemService() + .readTextFile(params.file_path); + const onDiskContentHash = hashContent(onDiskContent.replace(/\r\n/g, '\n')); + + if (initialContentHash !== onDiskContentHash) { + // The file has changed on disk since we first read it. + // Use the latest content for the correction attempt. + contentForLlmEditFixer = onDiskContent.replace(/\r\n/g, '\n'); + errorForLlmEditFixer = `The initial edit attempt failed with the following error: "${initialError.raw}". However, the file has been modified by either the user or an external process since that edit attempt. The file content provided to you is the latest version. Please base your correction on this new content.`; + } + const fixedEdit = await FixLLMEditWithInstruction( params.instruction, params.old_string, params.new_string, - initialError.raw, - currentContent, + errorForLlmEditFixer, + contentForLlmEditFixer, this.config.getBaseLlmClient(), abortSignal, ); @@ -405,7 +433,7 @@ class EditToolInvocation implements ToolInvocation { old_string: fixedEdit.search, new_string: fixedEdit.replace, }, - currentContent, + currentContent: contentForLlmEditFixer, abortSignal, }); @@ -423,7 +451,7 @@ class EditToolInvocation implements ToolInvocation { logSmartEditCorrectionEvent(this.config, event); return { - currentContent, + currentContent: contentForLlmEditFixer, newContent: currentContent, occurrences: 0, isNewFile: false, @@ -436,7 +464,7 @@ class EditToolInvocation implements ToolInvocation { logSmartEditCorrectionEvent(this.config, event); return { - currentContent, + currentContent: contentForLlmEditFixer, newContent: secondAttemptResult.newContent, occurrences: secondAttemptResult.occurrences, isNewFile: false, diff --git a/packages/core/src/utils/llm-edit-fixer.ts b/packages/core/src/utils/llm-edit-fixer.ts index 467fdbdb9a..40c66fa5e9 100644 --- a/packages/core/src/utils/llm-edit-fixer.ts +++ b/packages/core/src/utils/llm-edit-fixer.ts @@ -26,13 +26,13 @@ You will be given: 1. The high-level instruction for the original edit. 2. The exact \`search\` and \`replace\` strings that failed. 3. The error message that was produced. -4. The full content of the source file. +4. The full content of the latest version of the source file. # Rules for Correction 1. **Minimal Correction:** Your new \`search\` string must be a close variation of the original. Focus on fixing issues like whitespace, indentation, line endings, or small contextual differences. 2. **Explain the Fix:** Your \`explanation\` MUST state exactly why the original \`search\` failed and how your new \`search\` string resolves that specific failure. (e.g., "The original search failed due to incorrect indentation; the new search corrects the indentation to match the source file."). -3. **Preserve the \`replace\` String:** Do NOT modify the \`replace\` string unless the instruction explicitly requires it and it was the source of the error. Your primary focus is fixing the \`search\` string. -4. **No Changes Case:** CRUCIAL: if the change is already present in the file, set \`noChangesRequired\` to True and explain why in the \`explanation\`. It is crucial that you only do this if the changes outline in \`replace\` are alredy in the file and suits the instruction!! +3. **Preserve the \`replace\` String:** Do NOT modify the \`replace\` string unless the instruction explicitly requires it and it was the source of the error. Do not escape any characters in \`replace\`. Your primary focus is fixing the \`search\` string. +4. **No Changes Case:** CRUCIAL: if the change is already present in the file, set \`noChangesRequired\` to True and explain why in the \`explanation\`. It is crucial that you only do this if the changes outline in \`replace\` are already in the file and suits the instruction. 5. **Exactness:** The final \`search\` field must be the EXACT literal text from the file. Do not escape characters. `;