Enable "modify" in write tool (#1044)

This commit is contained in:
Leo 2025-06-14 19:20:04 +01:00 committed by GitHub
parent 93909a2dd3
commit 2c6aae863a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 626 additions and 287 deletions

View File

@ -13,14 +13,17 @@ import {
ToolResult, ToolResult,
ToolRegistry, ToolRegistry,
ApprovalMode, ApprovalMode,
EditTool,
EditToolParams,
EditorType, EditorType,
Config, Config,
logToolCall, logToolCall,
} from '../index.js'; } from '../index.js';
import { Part, PartListUnion } from '@google/genai'; import { Part, PartListUnion } from '@google/genai';
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
import {
isModifiableTool,
ModifyContext,
modifyWithEditor,
} from '../tools/modifiable-tool.js';
export type ValidatingToolCall = { export type ValidatingToolCall = {
status: 'validating'; status: 'validating';
@ -525,8 +528,8 @@ export class CoreToolScheduler {
); );
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
const waitingToolCall = toolCall as WaitingToolCall; const waitingToolCall = toolCall as WaitingToolCall;
if (waitingToolCall?.confirmationDetails?.type === 'edit') { if (isModifiableTool(waitingToolCall.tool)) {
const editTool = waitingToolCall.tool as EditTool; const modifyContext = waitingToolCall.tool.getModifyContext(signal);
const editorType = this.getPreferredEditor(); const editorType = this.getPreferredEditor();
if (!editorType) { if (!editorType) {
return; return;
@ -535,22 +538,22 @@ export class CoreToolScheduler {
this.setStatusInternal(callId, 'awaiting_approval', { this.setStatusInternal(callId, 'awaiting_approval', {
...waitingToolCall.confirmationDetails, ...waitingToolCall.confirmationDetails,
isModifying: true, isModifying: true,
}); } as ToolCallConfirmationDetails);
const modifyResults = await editTool.onModify( const { updatedParams, updatedDiff } = await modifyWithEditor<
waitingToolCall.request.args as unknown as EditToolParams, typeof waitingToolCall.request.args
signal, >(
waitingToolCall.request.args,
modifyContext as ModifyContext<typeof waitingToolCall.request.args>,
editorType, editorType,
signal,
); );
this.setArgsInternal(callId, updatedParams);
if (modifyResults) { this.setStatusInternal(callId, 'awaiting_approval', {
this.setArgsInternal(callId, modifyResults.updatedParams); ...waitingToolCall.confirmationDetails,
this.setStatusInternal(callId, 'awaiting_approval', { fileDiff: updatedDiff,
...waitingToolCall.confirmationDetails, isModifying: false,
fileDiff: modifyResults.updatedDiff, } as ToolCallConfirmationDetails);
isModifying: false,
});
}
} }
} else { } else {
this.setStatusInternal(callId, 'scheduled'); this.setStatusInternal(callId, 'scheduled');

View File

@ -605,141 +605,4 @@ describe('EditTool', () => {
); );
}); });
}); });
describe('onModify', () => {
const testFile = 'some_file.txt';
let filePath: string;
const diffDir = path.join(os.tmpdir(), 'gemini-cli-edit-tool-diffs');
beforeEach(() => {
filePath = path.join(rootDir, testFile);
mockOpenDiff.mockClear();
});
afterEach(() => {
fs.rmSync(diffDir, { recursive: true, force: true });
});
it('should create temporary files, call openDiff, and return updated params with diff', async () => {
const originalContent = 'original content';
const params: EditToolParams = {
file_path: filePath,
old_string: originalContent,
new_string: 'modified content',
};
fs.writeFileSync(filePath, originalContent, 'utf8');
const result = await tool.onModify(
params,
new AbortController().signal,
'vscode',
);
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
const [oldPath, newPath] = mockOpenDiff.mock.calls[0];
expect(oldPath).toMatch(
/gemini-cli-edit-tool-diffs[/\\]gemini-cli-edit-some_file\.txt-old-\d+/,
);
expect(newPath).toMatch(
/gemini-cli-edit-tool-diffs[/\\]gemini-cli-edit-some_file\.txt-new-\d+/,
);
expect(result).toBeDefined();
expect(result!.updatedParams).toEqual({
file_path: filePath,
old_string: originalContent,
new_string: 'modified content',
});
expect(result!.updatedDiff).toEqual(`Index: some_file.txt
===================================================================
--- some_file.txt\tCurrent
+++ some_file.txt\tProposed
@@ -1,1 +1,1 @@
-original content
\\ No newline at end of file
+modified content
\\ No newline at end of file
`);
// Verify temp files are cleaned up
expect(fs.existsSync(oldPath)).toBe(false);
expect(fs.existsSync(newPath)).toBe(false);
});
it('should handle non-existent files and return updated params', async () => {
const params: EditToolParams = {
file_path: filePath,
old_string: '',
new_string: 'new file content',
};
const result = await tool.onModify(
params,
new AbortController().signal,
'vscode',
);
expect(mockOpenDiff).toHaveBeenCalledTimes(1);
const [oldPath, newPath] = mockOpenDiff.mock.calls[0];
expect(result).toBeDefined();
expect(result!.updatedParams).toEqual({
file_path: filePath,
old_string: '',
new_string: 'new file content',
});
expect(result!.updatedDiff).toContain('new file content');
// Verify temp files are cleaned up
expect(fs.existsSync(oldPath)).toBe(false);
expect(fs.existsSync(newPath)).toBe(false);
});
it('should clean up previous temp files before creating new ones', async () => {
const params: EditToolParams = {
file_path: filePath,
old_string: 'old',
new_string: 'new',
};
fs.writeFileSync(filePath, 'some old content', 'utf8');
// Call onModify first time
const result1 = await tool.onModify(
params,
new AbortController().signal,
'vscode',
);
const firstCall = mockOpenDiff.mock.calls[0];
const firstOldPath = firstCall[0];
const firstNewPath = firstCall[1];
expect(result1).toBeDefined();
expect(fs.existsSync(firstOldPath)).toBe(false);
expect(fs.existsSync(firstNewPath)).toBe(false);
// Ensure different timestamps so that the file names are different for testing.
await new Promise((resolve) => setTimeout(resolve, 2));
const result2 = await tool.onModify(
params,
new AbortController().signal,
'vscode',
);
const secondCall = mockOpenDiff.mock.calls[1];
const secondOldPath = secondCall[0];
const secondNewPath = secondCall[1];
// Call onModify second time
expect(result2).toBeDefined();
expect(fs.existsSync(secondOldPath)).toBe(false);
expect(fs.existsSync(secondNewPath)).toBe(false);
// Verify different file names were used
expect(firstOldPath).not.toBe(secondOldPath);
expect(firstNewPath).not.toBe(secondNewPath);
});
});
}); });

View File

@ -6,7 +6,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import * as Diff from 'diff'; import * as Diff from 'diff';
import { import {
BaseTool, BaseTool,
@ -23,9 +22,8 @@ import { GeminiClient } from '../core/client.js';
import { Config, ApprovalMode } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js';
import { ensureCorrectEdit } from '../utils/editCorrector.js'; import { ensureCorrectEdit } from '../utils/editCorrector.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { openDiff } from '../utils/editor.js';
import { ReadFileTool } from './read-file.js'; import { ReadFileTool } from './read-file.js';
import { EditorType } from '../utils/editor.js'; import { ModifiableTool, ModifyContext } from './modifiable-tool.js';
/** /**
* Parameters for the Edit tool * Parameters for the Edit tool
@ -64,13 +62,14 @@ interface CalculatedEdit {
/** /**
* Implementation of the Edit tool logic * Implementation of the Edit tool logic
*/ */
export class EditTool extends BaseTool<EditToolParams, ToolResult> { export class EditTool
extends BaseTool<EditToolParams, ToolResult>
implements ModifiableTool<EditToolParams>
{
static readonly Name = 'replace'; static readonly Name = 'replace';
private readonly config: Config; private readonly config: Config;
private readonly rootDirectory: string; private readonly rootDirectory: string;
private readonly client: GeminiClient; private readonly client: GeminiClient;
private tempOldDiffPath?: string;
private tempNewDiffPath?: string;
/** /**
* Creates a new instance of the EditLogic * Creates a new instance of the EditLogic
@ -432,132 +431,6 @@ Expectation for required parameters:
} }
} }
/**
* Creates temp files for the current and proposed file contents and opens a diff tool.
* When the diff tool is closed, the tool will check if the file has been modified and provide the updated params.
* @returns Updated params and diff if the file has been modified, undefined otherwise.
*/
async onModify(
params: EditToolParams,
_abortSignal: AbortSignal,
editorType: EditorType,
): Promise<
{ updatedParams: EditToolParams; updatedDiff: string } | undefined
> {
const { oldPath, newPath } = this.createTempFiles(params);
this.tempOldDiffPath = oldPath;
this.tempNewDiffPath = newPath;
await openDiff(this.tempOldDiffPath, this.tempNewDiffPath, editorType);
return await this.getUpdatedParamsIfModified(params, _abortSignal);
}
private async getUpdatedParamsIfModified(
params: EditToolParams,
_abortSignal: AbortSignal,
): Promise<
{ updatedParams: EditToolParams; updatedDiff: string } | undefined
> {
if (!this.tempOldDiffPath || !this.tempNewDiffPath) return undefined;
let oldContent = '';
let newContent = '';
try {
oldContent = fs.readFileSync(this.tempOldDiffPath, 'utf8');
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
oldContent = '';
}
try {
newContent = fs.readFileSync(this.tempNewDiffPath, 'utf8');
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
newContent = '';
}
// Combine the edits into a single edit
const updatedParams: EditToolParams = {
...params,
old_string: oldContent,
new_string: newContent,
};
const updatedDiff = Diff.createPatch(
path.basename(params.file_path),
oldContent,
newContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
this.deleteTempFiles();
return { updatedParams, updatedDiff };
}
private createTempFiles(params: EditToolParams): Record<string, string> {
this.deleteTempFiles();
const tempDir = os.tmpdir();
const diffDir = path.join(tempDir, 'gemini-cli-edit-tool-diffs');
if (!fs.existsSync(diffDir)) {
fs.mkdirSync(diffDir, { recursive: true });
}
const fileName = path.basename(params.file_path);
const timestamp = Date.now();
const tempOldPath = path.join(
diffDir,
`gemini-cli-edit-${fileName}-old-${timestamp}`,
);
const tempNewPath = path.join(
diffDir,
`gemini-cli-edit-${fileName}-new-${timestamp}`,
);
let currentContent = '';
try {
currentContent = fs.readFileSync(params.file_path, 'utf8');
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
currentContent = '';
}
let proposedContent = currentContent;
proposedContent = this._applyReplacement(
proposedContent,
params.old_string,
params.new_string,
params.old_string === '' && currentContent === '',
);
fs.writeFileSync(tempOldPath, currentContent, 'utf8');
fs.writeFileSync(tempNewPath, proposedContent, 'utf8');
return {
oldPath: tempOldPath,
newPath: tempNewPath,
};
}
private deleteTempFiles(): void {
try {
if (this.tempOldDiffPath) {
fs.unlinkSync(this.tempOldDiffPath);
this.tempOldDiffPath = undefined;
}
} catch {
console.error(`Error deleting temp diff file: `, this.tempOldDiffPath);
}
try {
if (this.tempNewDiffPath) {
fs.unlinkSync(this.tempNewDiffPath);
this.tempNewDiffPath = undefined;
}
} catch {
console.error(`Error deleting temp diff file: `, this.tempNewDiffPath);
}
}
/** /**
* Creates parent directories if they don't exist * Creates parent directories if they don't exist
*/ */
@ -567,4 +440,39 @@ Expectation for required parameters:
fs.mkdirSync(dirName, { recursive: true }); fs.mkdirSync(dirName, { recursive: true });
} }
} }
getModifyContext(_: AbortSignal): ModifyContext<EditToolParams> {
return {
getFilePath: (params: EditToolParams) => params.file_path,
getCurrentContent: async (params: EditToolParams): Promise<string> => {
try {
return fs.readFileSync(params.file_path, 'utf8');
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
return '';
}
},
getProposedContent: async (params: EditToolParams): Promise<string> => {
try {
const currentContent = fs.readFileSync(params.file_path, 'utf8');
return this._applyReplacement(
currentContent,
params.old_string,
params.new_string,
params.old_string === '' && currentContent === '',
);
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
return '';
}
},
createUpdatedParams: (
modifiedProposedContent: string,
originalParams: EditToolParams,
): EditToolParams => ({
...originalParams,
new_string: modifiedProposedContent,
}),
};
}
} }

View File

@ -0,0 +1,367 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import {
modifyWithEditor,
ModifyContext,
ModifiableTool,
isModifiableTool,
} from './modifiable-tool.js';
import { EditorType } from '../utils/editor.js';
import fs from 'fs';
import os from 'os';
import * as path from 'path';
// Mock dependencies
const mockOpenDiff = vi.hoisted(() => vi.fn());
const mockCreatePatch = vi.hoisted(() => vi.fn());
vi.mock('../utils/editor.js', () => ({
openDiff: mockOpenDiff,
}));
vi.mock('diff', () => ({
createPatch: mockCreatePatch,
}));
vi.mock('fs');
vi.mock('os');
interface TestParams {
filePath: string;
someOtherParam: string;
modifiedContent?: string;
}
describe('modifyWithEditor', () => {
let tempDir: string;
let mockModifyContext: ModifyContext<TestParams>;
let mockParams: TestParams;
let currentContent: string;
let proposedContent: string;
let modifiedContent: string;
let abortSignal: AbortSignal;
beforeEach(() => {
vi.resetAllMocks();
tempDir = '/tmp/test-dir';
abortSignal = new AbortController().signal;
currentContent = 'original content\nline 2\nline 3';
proposedContent = 'modified content\nline 2\nline 3';
modifiedContent = 'user modified content\nline 2\nline 3\nnew line';
mockParams = {
filePath: path.join(tempDir, 'test.txt'),
someOtherParam: 'value',
};
mockModifyContext = {
getFilePath: vi.fn().mockReturnValue(mockParams.filePath),
getCurrentContent: vi.fn().mockResolvedValue(currentContent),
getProposedContent: vi.fn().mockResolvedValue(proposedContent),
createUpdatedParams: vi
.fn()
.mockImplementation((modifiedContent, originalParams) => ({
...originalParams,
modifiedContent,
})),
};
(os.tmpdir as Mock).mockReturnValue(tempDir);
(fs.existsSync as Mock).mockReturnValue(true);
(fs.mkdirSync as Mock).mockImplementation(() => undefined);
(fs.writeFileSync as Mock).mockImplementation(() => {});
(fs.unlinkSync as Mock).mockImplementation(() => {});
(fs.readFileSync as Mock).mockImplementation((filePath: string) => {
if (filePath.includes('-new-')) {
return modifiedContent;
}
return currentContent;
});
mockCreatePatch.mockReturnValue('mock diff content');
mockOpenDiff.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('successful modification', () => {
it('should successfully modify content with VSCode editor', async () => {
const result = await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
mockParams,
);
expect(mockModifyContext.getProposedContent).toHaveBeenCalledWith(
mockParams,
);
expect(mockModifyContext.getFilePath).toHaveBeenCalledWith(mockParams);
expect(fs.writeFileSync).toHaveBeenCalledTimes(2);
expect(fs.writeFileSync).toHaveBeenNthCalledWith(
1,
expect.stringContaining(
path.join(tempDir, 'gemini-cli-tool-modify-diffs'),
),
currentContent,
'utf8',
);
expect(fs.writeFileSync).toHaveBeenNthCalledWith(
2,
expect.stringContaining(
path.join(tempDir, 'gemini-cli-tool-modify-diffs'),
),
proposedContent,
'utf8',
);
expect(mockOpenDiff).toHaveBeenCalledWith(
expect.stringContaining('-old-'),
expect.stringContaining('-new-'),
'vscode',
);
expect(fs.readFileSync).toHaveBeenCalledWith(
expect.stringContaining('-old-'),
'utf8',
);
expect(fs.readFileSync).toHaveBeenCalledWith(
expect.stringContaining('-new-'),
'utf8',
);
expect(mockModifyContext.createUpdatedParams).toHaveBeenCalledWith(
modifiedContent,
mockParams,
);
expect(mockCreatePatch).toHaveBeenCalledWith(
path.basename(mockParams.filePath),
currentContent,
modifiedContent,
'Current',
'Proposed',
expect.objectContaining({
context: 3,
ignoreWhitespace: true,
}),
);
expect(fs.unlinkSync).toHaveBeenCalledTimes(2);
expect(fs.unlinkSync).toHaveBeenNthCalledWith(
1,
expect.stringContaining('-old-'),
);
expect(fs.unlinkSync).toHaveBeenNthCalledWith(
2,
expect.stringContaining('-new-'),
);
expect(result).toEqual({
updatedParams: {
...mockParams,
modifiedContent,
},
updatedDiff: 'mock diff content',
});
});
it('should create temp directory if it does not exist', async () => {
(fs.existsSync as Mock).mockReturnValue(false);
await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
expect(fs.mkdirSync).toHaveBeenCalledWith(
path.join(tempDir, 'gemini-cli-tool-modify-diffs'),
{ recursive: true },
);
});
it('should not create temp directory if it already exists', async () => {
(fs.existsSync as Mock).mockReturnValue(true);
await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
expect(fs.mkdirSync).not.toHaveBeenCalled();
});
});
it('should handle missing old temp file gracefully', async () => {
(fs.readFileSync as Mock).mockImplementation((filePath: string) => {
if (filePath.includes('-old-')) {
const error = new Error('ENOENT: no such file or directory');
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return modifiedContent;
});
const result = await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
expect(mockCreatePatch).toHaveBeenCalledWith(
path.basename(mockParams.filePath),
'',
modifiedContent,
'Current',
'Proposed',
expect.objectContaining({
context: 3,
ignoreWhitespace: true,
}),
);
expect(result.updatedParams).toBeDefined();
expect(result.updatedDiff).toBe('mock diff content');
});
it('should handle missing new temp file gracefully', async () => {
(fs.readFileSync as Mock).mockImplementation((filePath: string) => {
if (filePath.includes('-new-')) {
const error = new Error('ENOENT: no such file or directory');
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return currentContent;
});
const result = await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
expect(mockCreatePatch).toHaveBeenCalledWith(
path.basename(mockParams.filePath),
currentContent,
'',
'Current',
'Proposed',
expect.objectContaining({
context: 3,
ignoreWhitespace: true,
}),
);
expect(result.updatedParams).toBeDefined();
expect(result.updatedDiff).toBe('mock diff content');
});
it('should clean up temp files even if editor fails', async () => {
const editorError = new Error('Editor failed to open');
mockOpenDiff.mockRejectedValue(editorError);
await expect(
modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
),
).rejects.toThrow('Editor failed to open');
expect(fs.unlinkSync).toHaveBeenCalledTimes(2);
});
it('should handle temp file cleanup errors gracefully', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
(fs.unlinkSync as Mock).mockImplementation((_filePath: string) => {
throw new Error('Failed to delete file');
});
await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Error deleting temp diff file:'),
);
consoleErrorSpy.mockRestore();
});
it('should create temp files with correct naming', async () => {
const testFilePath = path.join(tempDir, 'subfolder', 'test-file.txt');
mockModifyContext.getFilePath = vi.fn().mockReturnValue(testFilePath);
await modifyWithEditor(
mockParams,
mockModifyContext,
'vscode' as EditorType,
abortSignal,
);
const writeFileCalls = (fs.writeFileSync as Mock).mock.calls;
expect(writeFileCalls).toHaveLength(2);
const oldFilePath = writeFileCalls[0][0];
const newFilePath = writeFileCalls[1][0];
expect(oldFilePath).toMatch(/gemini-cli-modify-test-file\.txt-old-\d+$/);
expect(newFilePath).toMatch(/gemini-cli-modify-test-file\.txt-new-\d+$/);
expect(oldFilePath).toContain(`${tempDir}/gemini-cli-tool-modify-diffs/`);
expect(newFilePath).toContain(`${tempDir}/gemini-cli-tool-modify-diffs/`);
});
});
describe('isModifiableTool', () => {
it('should return true for objects with getModifyContext method', () => {
const mockTool = {
name: 'test-tool',
getModifyContext: vi.fn(),
} as unknown as ModifiableTool<TestParams>;
expect(isModifiableTool(mockTool)).toBe(true);
});
it('should return false for objects without getModifyContext method', () => {
const mockTool = {
name: 'test-tool',
} as unknown as ModifiableTool<TestParams>;
expect(isModifiableTool(mockTool)).toBe(false);
});
});

View File

@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EditorType } from '../utils/editor.js';
import os from 'os';
import path from 'path';
import fs from 'fs';
import * as Diff from 'diff';
import { openDiff } from '../utils/editor.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { isNodeError } from '../utils/errors.js';
import { Tool } from './tools.js';
/**
* A tool that supports a modify operation.
*/
export interface ModifiableTool<ToolParams> extends Tool<ToolParams> {
getModifyContext(abortSignal: AbortSignal): ModifyContext<ToolParams>;
}
export interface ModifyContext<ToolParams> {
getFilePath: (params: ToolParams) => string;
getCurrentContent: (params: ToolParams) => Promise<string>;
getProposedContent: (params: ToolParams) => Promise<string>;
createUpdatedParams: (
modifiedProposedContent: string,
originalParams: ToolParams,
) => ToolParams;
}
export interface ModifyResult<ToolParams> {
updatedParams: ToolParams;
updatedDiff: string;
}
export function isModifiableTool<TParams>(
tool: Tool<TParams>,
): tool is ModifiableTool<TParams> {
return 'getModifyContext' in tool;
}
function createTempFilesForModify(
currentContent: string,
proposedContent: string,
file_path: string,
): { oldPath: string; newPath: string } {
const tempDir = os.tmpdir();
const diffDir = path.join(tempDir, 'gemini-cli-tool-modify-diffs');
if (!fs.existsSync(diffDir)) {
fs.mkdirSync(diffDir, { recursive: true });
}
const fileName = path.basename(file_path);
const timestamp = Date.now();
const tempOldPath = path.join(
diffDir,
`gemini-cli-modify-${fileName}-old-${timestamp}`,
);
const tempNewPath = path.join(
diffDir,
`gemini-cli-modify-${fileName}-new-${timestamp}`,
);
fs.writeFileSync(tempOldPath, currentContent, 'utf8');
fs.writeFileSync(tempNewPath, proposedContent, 'utf8');
return { oldPath: tempOldPath, newPath: tempNewPath };
}
function getUpdatedParams<ToolParams>(
tmpOldPath: string,
tempNewPath: string,
originalParams: ToolParams,
modifyContext: ModifyContext<ToolParams>,
): { updatedParams: ToolParams; updatedDiff: string } {
let oldContent = '';
let newContent = '';
try {
oldContent = fs.readFileSync(tmpOldPath, 'utf8');
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
oldContent = '';
}
try {
newContent = fs.readFileSync(tempNewPath, 'utf8');
} catch (err) {
if (!isNodeError(err) || err.code !== 'ENOENT') throw err;
newContent = '';
}
const updatedParams = modifyContext.createUpdatedParams(
newContent,
originalParams,
);
const updatedDiff = Diff.createPatch(
path.basename(modifyContext.getFilePath(originalParams)),
oldContent,
newContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
return { updatedParams, updatedDiff };
}
function deleteTempFiles(oldPath: string, newPath: string): void {
try {
fs.unlinkSync(oldPath);
} catch {
console.error(`Error deleting temp diff file: ${oldPath}`);
}
try {
fs.unlinkSync(newPath);
} catch {
console.error(`Error deleting temp diff file: ${newPath}`);
}
}
/**
* Triggers an external editor for the user to modify the proposed content,
* and returns the updated tool parameters and the diff after the user has modified the proposed content.
*/
export async function modifyWithEditor<ToolParams>(
originalParams: ToolParams,
modifyContext: ModifyContext<ToolParams>,
editorType: EditorType,
_abortSignal: AbortSignal,
): Promise<ModifyResult<ToolParams>> {
const currentContent = await modifyContext.getCurrentContent(originalParams);
const proposedContent =
await modifyContext.getProposedContent(originalParams);
const { oldPath, newPath } = createTempFilesForModify(
currentContent,
proposedContent,
modifyContext.getFilePath(originalParams),
);
try {
await openDiff(oldPath, newPath, editorType);
const result = getUpdatedParams(
oldPath,
newPath,
originalParams,
modifyContext,
);
return result;
} finally {
deleteTempFiles(oldPath, newPath);
}
}

View File

@ -25,6 +25,7 @@ import {
} from '../utils/editCorrector.js'; } from '../utils/editCorrector.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
import { ModifiableTool, ModifyContext } from './modifiable-tool.js';
/** /**
* Parameters for the WriteFile tool * Parameters for the WriteFile tool
@ -51,7 +52,10 @@ interface GetCorrectedFileContentResult {
/** /**
* Implementation of the WriteFile tool logic * Implementation of the WriteFile tool logic
*/ */
export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> { export class WriteFileTool
extends BaseTool<WriteFileToolParams, ToolResult>
implements ModifiableTool<WriteFileToolParams>
{
static readonly Name: string = 'write_file'; static readonly Name: string = 'write_file';
private readonly client: GeminiClient; private readonly client: GeminiClient;
@ -336,4 +340,35 @@ export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
} }
return { originalContent, correctedContent, fileExists }; return { originalContent, correctedContent, fileExists };
} }
getModifyContext(
abortSignal: AbortSignal,
): ModifyContext<WriteFileToolParams> {
return {
getFilePath: (params: WriteFileToolParams) => params.file_path,
getCurrentContent: async (params: WriteFileToolParams) => {
const correctedContentResult = await this._getCorrectedFileContent(
params.file_path,
params.content,
abortSignal,
);
return correctedContentResult.originalContent;
},
getProposedContent: async (params: WriteFileToolParams) => {
const correctedContentResult = await this._getCorrectedFileContent(
params.file_path,
params.content,
abortSignal,
);
return correctedContentResult.correctedContent;
},
createUpdatedParams: (
modifiedProposedContent: string,
originalParams: WriteFileToolParams,
) => ({
...originalParams,
content: modifiedProposedContent,
}),
};
}
} }