fix: EditTool can clobber human edits to the same file. (#3043)

Co-authored-by: Colt McAnlis <colton@google.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Colt McAnlis
2025-07-07 10:28:56 -07:00
committed by GitHub
parent 229ae03631
commit 8f4046c71a
7 changed files with 249 additions and 21 deletions

View File

@@ -84,7 +84,8 @@ describe('EditTool', () => {
// Reset mocks and set default implementation for ensureCorrectEdit // Reset mocks and set default implementation for ensureCorrectEdit
mockEnsureCorrectEdit.mockReset(); mockEnsureCorrectEdit.mockReset();
mockEnsureCorrectEdit.mockImplementation(async (currentContent, params) => { mockEnsureCorrectEdit.mockImplementation(
async (_, currentContent, params) => {
let occurrences = 0; let occurrences = 0;
if (params.old_string && currentContent) { if (params.old_string && currentContent) {
// Simple string counting for the mock // Simple string counting for the mock
@@ -97,7 +98,8 @@ describe('EditTool', () => {
occurrences = 0; // Creating a new file occurrences = 0; // Creating a new file
} }
return Promise.resolve({ params, occurrences }); return Promise.resolve({ params, occurrences });
}); },
);
// Default mock for generateJson to return the snippet unchanged // Default mock for generateJson to return the snippet unchanged
mockGenerateJson.mockReset(); mockGenerateJson.mockReset();
@@ -333,7 +335,7 @@ describe('EditTool', () => {
// Set a specific mock for this test case // Set a specific mock for this test case
let mockCalled = false; let mockCalled = false;
mockEnsureCorrectEdit.mockImplementationOnce( mockEnsureCorrectEdit.mockImplementationOnce(
async (content, p, client) => { async (_, content, p, client) => {
mockCalled = true; mockCalled = true;
expect(content).toBe(originalContent); expect(content).toBe(originalContent);
expect(p).toBe(params); expect(p).toBe(params);
@@ -383,7 +385,7 @@ describe('EditTool', () => {
beforeEach(() => { beforeEach(() => {
filePath = path.join(rootDir, testFile); filePath = path.join(rootDir, testFile);
// Default for execute tests, can be overridden // Default for execute tests, can be overridden
mockEnsureCorrectEdit.mockImplementation(async (content, params) => { mockEnsureCorrectEdit.mockImplementation(async (_, content, params) => {
let occurrences = 0; let occurrences = 0;
if (params.old_string && content) { if (params.old_string && content) {
let index = content.indexOf(params.old_string); let index = content.indexOf(params.old_string);

View File

@@ -236,6 +236,7 @@ Expectation for required parameters:
} else if (currentContent !== null) { } else if (currentContent !== null) {
// Editing an existing file // Editing an existing file
const correctedEdit = await ensureCorrectEdit( const correctedEdit = await ensureCorrectEdit(
params.file_path,
currentContent, currentContent,
params, params,
this.client, this.client,

View File

@@ -114,6 +114,7 @@ describe('WriteFileTool', () => {
// Default mock implementations that return valid structures // Default mock implementations that return valid structures
mockEnsureCorrectEdit.mockImplementation( mockEnsureCorrectEdit.mockImplementation(
async ( async (
filePath: string,
_currentContent: string, _currentContent: string,
params: EditToolParams, params: EditToolParams,
_client: GeminiClient, _client: GeminiClient,
@@ -248,6 +249,7 @@ describe('WriteFileTool', () => {
); );
expect(mockEnsureCorrectEdit).toHaveBeenCalledWith( expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
filePath,
originalContent, originalContent,
{ {
old_string: originalContent, old_string: originalContent,
@@ -388,6 +390,7 @@ describe('WriteFileTool', () => {
)) as ToolEditConfirmationDetails; )) as ToolEditConfirmationDetails;
expect(mockEnsureCorrectEdit).toHaveBeenCalledWith( expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
filePath,
originalContent, originalContent,
{ {
old_string: originalContent, old_string: originalContent,
@@ -523,6 +526,7 @@ describe('WriteFileTool', () => {
const result = await tool.execute(params, abortSignal); const result = await tool.execute(params, abortSignal);
expect(mockEnsureCorrectEdit).toHaveBeenCalledWith( expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
filePath,
initialContent, initialContent,
{ {
old_string: initialContent, old_string: initialContent,

View File

@@ -367,6 +367,7 @@ export class WriteFileTool
if (fileExists) { if (fileExists) {
// This implies originalContent is available // This implies originalContent is available
const { params: correctedParams } = await ensureCorrectEdit( const { params: correctedParams } = await ensureCorrectEdit(
filePath,
originalContent, originalContent,
{ {
old_string: originalContent, // Treat entire current content as old_string old_string: originalContent, // Treat entire current content as old_string

View File

@@ -5,7 +5,17 @@
*/ */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest'; import {
vi,
describe,
it,
expect,
beforeEach,
Mock,
type Mocked,
} from 'vitest';
import * as fs from 'fs';
import { EditTool } from '../tools/edit.js';
// MOCKS // MOCKS
let callCount = 0; let callCount = 0;
@@ -15,6 +25,10 @@ let mockGenerateJson: any;
let mockStartChat: any; let mockStartChat: any;
let mockSendMessageStream: any; let mockSendMessageStream: any;
vi.mock('fs', () => ({
statSync: vi.fn(),
}));
vi.mock('../core/client.js', () => ({ vi.mock('../core/client.js', () => ({
GeminiClient: vi.fn().mockImplementation(function ( GeminiClient: vi.fn().mockImplementation(function (
this: any, this: any,
@@ -222,6 +236,7 @@ describe('editCorrector', () => {
mockGeminiClientInstance = new GeminiClient( mockGeminiClientInstance = new GeminiClient(
mockConfigInstance, mockConfigInstance,
) as Mocked<GeminiClient>; ) as Mocked<GeminiClient>;
mockGeminiClientInstance.getHistory = vi.fn().mockResolvedValue([]);
resetEditCorrectorCaches_TEST_ONLY(); resetEditCorrectorCaches_TEST_ONLY();
}); });
@@ -237,6 +252,7 @@ describe('editCorrector', () => {
corrected_new_string_escaping: 'replace with "this"', corrected_new_string_escaping: 'replace with "this"',
}); });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -255,6 +271,7 @@ describe('editCorrector', () => {
new_string: 'replace with this', new_string: 'replace with this',
}; };
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -276,6 +293,7 @@ describe('editCorrector', () => {
corrected_new_string_escaping: 'replace with "this"', corrected_new_string_escaping: 'replace with "this"',
}); });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -294,6 +312,7 @@ describe('editCorrector', () => {
new_string: 'replace with this', new_string: 'replace with this',
}; };
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -316,6 +335,7 @@ describe('editCorrector', () => {
}; };
mockResponses.push({ corrected_new_string: 'replace with "this"' }); mockResponses.push({ corrected_new_string: 'replace with "this"' });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -334,6 +354,7 @@ describe('editCorrector', () => {
new_string: 'replace with this', new_string: 'replace with this',
}; };
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -352,6 +373,7 @@ describe('editCorrector', () => {
new_string: 'replace with foobar', new_string: 'replace with foobar',
}; };
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -375,6 +397,7 @@ describe('editCorrector', () => {
const llmNewString = 'LLM says replace with "that"'; const llmNewString = 'LLM says replace with "that"';
mockResponses.push({ corrected_new_string_escaping: llmNewString }); mockResponses.push({ corrected_new_string_escaping: llmNewString });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -397,6 +420,7 @@ describe('editCorrector', () => {
mockResponses.push({ corrected_target_snippet: llmCorrectedOldString }); mockResponses.push({ corrected_target_snippet: llmCorrectedOldString });
mockResponses.push({ corrected_new_string: llmNewString }); mockResponses.push({ corrected_new_string: llmNewString });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -417,6 +441,7 @@ describe('editCorrector', () => {
const llmCorrectedOldString = 'to be corrected'; const llmCorrectedOldString = 'to be corrected';
mockResponses.push({ corrected_target_snippet: llmCorrectedOldString }); mockResponses.push({ corrected_target_snippet: llmCorrectedOldString });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -439,6 +464,7 @@ describe('editCorrector', () => {
corrected_new_string_escaping: newStringForLLMAndReturnedByLLM, corrected_new_string_escaping: newStringForLLMAndReturnedByLLM,
}); });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -460,6 +486,7 @@ describe('editCorrector', () => {
}; };
mockResponses.push({ corrected_target_snippet: 'still nonexistent' }); mockResponses.push({ corrected_target_snippet: 'still nonexistent' });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -478,6 +505,7 @@ describe('editCorrector', () => {
new_string: 'some new string', new_string: 'some new string',
}; };
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -491,16 +519,17 @@ describe('editCorrector', () => {
describe('Scenario Group 5: Specific unescapeStringForGeminiBug checks (integrated into ensureCorrectEdit)', () => { describe('Scenario Group 5: Specific unescapeStringForGeminiBug checks (integrated into ensureCorrectEdit)', () => {
it('Test 5.1: old_string needs LLM to become currentContent, new_string also needs correction', async () => { it('Test 5.1: old_string needs LLM to become currentContent, new_string also needs correction', async () => {
const currentContent = 'const x = "a\\nbc\\\\"def\\\\"'; const currentContent = 'const x = "a\nbc\\"def\\"';
const originalParams = { const originalParams = {
file_path: '/test/file.txt', file_path: '/test/file.txt',
old_string: 'const x = \\\\"a\\\\nbc\\\\\\\\"def\\\\\\\\"', old_string: 'const x = \\"a\\nbc\\\\"def\\\\"',
new_string: 'const y = \\\\"new\\\\nval\\\\\\\\"content\\\\\\\\"', new_string: 'const y = \\"new\\nval\\\\"content\\\\"',
}; };
const expectedFinalNewString = 'const y = "new\\nval\\\\"content\\\\"'; const expectedFinalNewString = 'const y = "new\nval\\"content\\"';
mockResponses.push({ corrected_target_snippet: currentContent }); mockResponses.push({ corrected_target_snippet: currentContent });
mockResponses.push({ corrected_new_string: expectedFinalNewString }); mockResponses.push({ corrected_new_string: expectedFinalNewString });
const result = await ensureCorrectEdit( const result = await ensureCorrectEdit(
'/test/file.txt',
currentContent, currentContent,
originalParams, originalParams,
mockGeminiClientInstance, mockGeminiClientInstance,
@@ -512,6 +541,61 @@ describe('editCorrector', () => {
expect(result.occurrences).toBe(1); expect(result.occurrences).toBe(1);
}); });
}); });
describe('Scenario Group 6: Concurrent Edits', () => {
it('Test 6.1: should return early if file was modified by another process', async () => {
const filePath = '/test/file.txt';
const currentContent =
'This content has been modified by someone else.';
const originalParams = {
file_path: filePath,
old_string: 'nonexistent string',
new_string: 'some new string',
};
const now = Date.now();
const lastEditTime = now - 5000; // 5 seconds ago
// Mock the file's modification time to be recent
vi.spyOn(fs, 'statSync').mockReturnValue({
mtimeMs: now,
} as fs.Stats);
// Mock the last edit timestamp from our history to be in the past
const history = [
{
role: 'model',
parts: [
{
functionResponse: {
name: EditTool.Name,
id: `${EditTool.Name}-${lastEditTime}-123`,
response: {
output: {
llmContent: `Successfully modified file: ${filePath}`,
},
},
},
},
],
},
];
(mockGeminiClientInstance.getHistory as Mock).mockResolvedValue(
history,
);
const result = await ensureCorrectEdit(
filePath,
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(result.occurrences).toBe(0);
expect(result.params).toEqual(originalParams);
});
});
}); });
describe('ensureCorrectFileContent', () => { describe('ensureCorrectFileContent', () => {

View File

@@ -11,9 +11,18 @@ import {
Type, Type,
} from '@google/genai'; } from '@google/genai';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { EditToolParams } from '../tools/edit.js'; import { EditToolParams, EditTool } from '../tools/edit.js';
import { WriteFileTool } from '../tools/write-file.js';
import { ReadFileTool } from '../tools/read-file.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { GrepTool } from '../tools/grep.js';
import { LruCache } from './LruCache.js'; import { LruCache } from './LruCache.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import {
isFunctionResponse,
isFunctionCall,
} from '../utils/messageInspectors.js';
import * as fs from 'fs';
const EditModel = DEFAULT_GEMINI_FLASH_MODEL; const EditModel = DEFAULT_GEMINI_FLASH_MODEL;
const EditConfig: GenerateContentConfig = { const EditConfig: GenerateContentConfig = {
@@ -49,6 +58,96 @@ export interface CorrectedEditResult {
occurrences: number; occurrences: number;
} }
/**
* Extracts the timestamp from the .id value, which is in format
* <tool.name>-<timestamp>-<uuid>
* @param fcnId the ID value of a functionCall or functionResponse object
* @returns -1 if the timestamp could not be extracted, else the timestamp (as a number)
*/
function getTimestampFromFunctionId(fcnId: string): number {
const idParts = fcnId.split('-');
if (idParts.length > 2) {
const timestamp = parseInt(idParts[1], 10);
if (!isNaN(timestamp)) {
return timestamp;
}
}
return -1;
}
/**
* Will look through the gemini client history and determine when the most recent
* edit to a target file occured. If no edit happened, it will return -1
* @param filePath the path to the file
* @param client the geminiClient, so that we can get the history
* @returns a DateTime (as a number) of when the last edit occured, or -1 if no edit was found.
*/
async function findLastEditTimestamp(
filePath: string,
client: GeminiClient,
): Promise<number> {
const history = (await client.getHistory()) ?? [];
// Tools that may reference the file path in their FunctionResponse `output`.
const toolsInResp = new Set([
WriteFileTool.Name,
EditTool.Name,
ReadManyFilesTool.Name,
GrepTool.Name,
]);
// Tools that may reference the file path in their FunctionCall `args`.
const toolsInCall = new Set([...toolsInResp, ReadFileTool.Name]);
// Iterate backwards to find the most recent relevant action.
for (const entry of history.slice().reverse()) {
if (!entry.parts) continue;
for (const part of entry.parts) {
let id: string | undefined;
let content: unknown;
// Check for a relevant FunctionCall with the file path in its arguments.
if (
isFunctionCall(entry) &&
part.functionCall?.name &&
toolsInCall.has(part.functionCall.name)
) {
id = part.functionCall.id;
content = part.functionCall.args;
}
// Check for a relevant FunctionResponse with the file path in its output.
else if (
isFunctionResponse(entry) &&
part.functionResponse?.name &&
toolsInResp.has(part.functionResponse.name)
) {
const { response } = part.functionResponse;
if (response && !('error' in response) && 'output' in response) {
id = part.functionResponse.id;
content = response.output;
}
}
if (!id || content === undefined) continue;
// Use the "blunt hammer" approach to find the file path in the content.
// Note that the tool response data is inconsistent in their formatting
// with successes and errors - so, we just check for the existance
// as the best guess to if error/failed occured with the response.
const stringified = JSON.stringify(content);
if (
!stringified.includes('Error') && // only applicable for functionResponse
!stringified.includes('Failed') && // only applicable for functionResponse
stringified.includes(filePath)
) {
return getTimestampFromFunctionId(id);
}
}
}
return -1;
}
/** /**
* Attempts to correct edit parameters if the original old_string is not found. * Attempts to correct edit parameters if the original old_string is not found.
* It tries unescaping, and then LLM-based correction. * It tries unescaping, and then LLM-based correction.
@@ -61,6 +160,7 @@ export interface CorrectedEditResult {
* EditToolParams (as CorrectedEditParams) and the final occurrences count. * EditToolParams (as CorrectedEditParams) and the final occurrences count.
*/ */
export async function ensureCorrectEdit( export async function ensureCorrectEdit(
filePath: string,
currentContent: string, currentContent: string,
originalParams: EditToolParams, // This is the EditToolParams from edit.ts, without \'corrected\' originalParams: EditToolParams, // This is the EditToolParams from edit.ts, without \'corrected\'
client: GeminiClient, client: GeminiClient,
@@ -140,6 +240,34 @@ export async function ensureCorrectEdit(
); );
} }
} else if (occurrences === 0) { } else if (occurrences === 0) {
if (filePath) {
// In order to keep from clobbering edits made outside our system,
// let's check if there was a more recent edit to the file than what
// our system has done
const lastEditedByUsTime = await findLastEditTimestamp(
filePath,
client,
);
// Add a 1-second buffer to account for timing inaccuracies. If the file
// was modified more than a second after the last edit tool was run, we
// can assume it was modified by something else.
if (lastEditedByUsTime > 0) {
const stats = fs.statSync(filePath);
const diff = stats.mtimeMs - lastEditedByUsTime;
if (diff > 2000) {
// Hard coded for 2 seconds
// This file was edited sooner
const result: CorrectedEditResult = {
params: { ...originalParams },
occurrences: 0, // Explicitly 0 as LLM failed
};
editCorrectionCache.set(cacheKey, result);
return result;
}
}
}
const llmCorrectedOldString = await correctOldStringMismatch( const llmCorrectedOldString = await correctOldStringMismatch(
client, client,
currentContent, currentContent,

View File

@@ -13,3 +13,11 @@ export function isFunctionResponse(content: Content): boolean {
content.parts.every((part) => !!part.functionResponse) content.parts.every((part) => !!part.functionResponse)
); );
} }
export function isFunctionCall(content: Content): boolean {
return (
content.role === 'model' &&
!!content.parts &&
content.parts.every((part) => !!part.functionCall)
);
}