fix(core): refresh file contents in smart edit given newer edits from user/external process (#10084)

This commit is contained in:
anthony bushong
2025-10-08 13:58:52 -07:00
committed by GitHub
parent f2852056a1
commit 76b1deec25
3 changed files with 121 additions and 39 deletions

View File

@@ -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', () => {

View File

@@ -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<EditToolParams, ToolResult> {
abortSignal: AbortSignal,
originalLineEnding: '\r\n' | '\n',
): Promise<CalculatedEdit> {
// 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<EditToolParams, ToolResult> {
old_string: fixedEdit.search,
new_string: fixedEdit.replace,
},
currentContent,
currentContent: contentForLlmEditFixer,
abortSignal,
});
@@ -423,7 +451,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
logSmartEditCorrectionEvent(this.config, event);
return {
currentContent,
currentContent: contentForLlmEditFixer,
newContent: currentContent,
occurrences: 0,
isNewFile: false,
@@ -436,7 +464,7 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
logSmartEditCorrectionEvent(this.config, event);
return {
currentContent,
currentContent: contentForLlmEditFixer,
newContent: secondAttemptResult.newContent,
occurrences: secondAttemptResult.occurrences,
isNewFile: false,

View File

@@ -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.
`;