mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 03:54:43 -07:00
fix(core): refresh file contents in smart edit given newer edits from user/external process (#10084)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user