summaryrefslogtreecommitdiff
path: root/packages/core/src/tools
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/tools')
-rw-r--r--packages/core/src/tools/edit.test.ts420
-rw-r--r--packages/core/src/tools/edit.ts473
-rw-r--r--packages/core/src/tools/shell.ts2
-rw-r--r--packages/core/src/tools/write-file.test.ts571
-rw-r--r--packages/core/src/tools/write-file.ts339
5 files changed, 1270 insertions, 535 deletions
diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts
index 2f6a6642..8c351929 100644
--- a/packages/core/src/tools/edit.test.ts
+++ b/packages/core/src/tools/edit.test.ts
@@ -194,7 +194,8 @@ describe('EditTool', () => {
it('should return null for valid params', () => {
const params: EditToolParams = {
file_path: path.join(rootDir, 'test.txt'),
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
expect(tool.validateToolParams(params)).toBeNull();
});
@@ -202,7 +203,8 @@ describe('EditTool', () => {
it('should return error for relative path', () => {
const params: EditToolParams = {
file_path: 'test.txt',
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
expect(tool.validateToolParams(params)).toMatch(
/File path must be absolute/,
@@ -212,7 +214,8 @@ describe('EditTool', () => {
it('should return error for path outside root', () => {
const params: EditToolParams = {
file_path: path.join(tempDir, 'outside-root.txt'),
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
expect(tool.validateToolParams(params)).toMatch(
/File path must be within the root directory/,
@@ -231,7 +234,8 @@ describe('EditTool', () => {
it('should return false if params are invalid', async () => {
const params: EditToolParams = {
file_path: 'relative.txt',
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
expect(
await tool.shouldConfirmExecute(params, new AbortController().signal),
@@ -242,7 +246,8 @@ describe('EditTool', () => {
fs.writeFileSync(filePath, 'some old content here');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
// ensureCorrectEdit will be called by shouldConfirmExecute
mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 1 });
@@ -263,48 +268,26 @@ describe('EditTool', () => {
fs.writeFileSync(filePath, 'some content here');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'not_found', new_string: 'new' }],
+ old_string: 'not_found',
+ new_string: 'new',
};
- mockEnsureCorrectEdit.mockResolvedValueOnce({
- params: {
- file_path: filePath,
- old_string: 'not_found',
- new_string: 'new',
- },
- occurrences: 0,
- });
-
- // Our new implementation shows confirmation but with no changes,
- // which should still return false due to no edits applied
- const result = await tool.shouldConfirmExecute(
- params,
- new AbortController().signal,
- );
- // If no edits would be applied, confirmation should be false
- expect(result).toBe(false);
+ mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 0 });
+ expect(
+ await tool.shouldConfirmExecute(params, new AbortController().signal),
+ ).toBe(false);
});
it('should return false if multiple occurrences of old_string are found (ensureCorrectEdit returns > 1)', async () => {
fs.writeFileSync(filePath, 'old old content here');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
- mockEnsureCorrectEdit.mockResolvedValueOnce({
- params: {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- },
- occurrences: 2,
- });
-
- // Multiple occurrences should result in failed edit, no confirmation
- const result = await tool.shouldConfirmExecute(
- params,
- new AbortController().signal,
- );
- expect(result).toBe(false);
+ mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 2 });
+ expect(
+ await tool.shouldConfirmExecute(params, new AbortController().signal),
+ ).toBe(false);
});
it('should request confirmation for creating a new file (empty old_string)', async () => {
@@ -312,41 +295,87 @@ describe('EditTool', () => {
const newFilePath = path.join(rootDir, newFileName);
const params: EditToolParams = {
file_path: newFilePath,
- edits: [{ old_string: '', new_string: 'new file content' }],
+ old_string: '',
+ new_string: 'new file content',
};
+ // ensureCorrectEdit might not be called if old_string is empty,
+ // as shouldConfirmExecute handles this for diff generation.
+ // If it is called, it should return 0 occurrences for a new file.
+ mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 0 });
const confirmation = await tool.shouldConfirmExecute(
params,
new AbortController().signal,
);
expect(confirmation).toEqual(
expect.objectContaining({
- title: expect.stringContaining(newFileName),
+ title: `Confirm Edit: ${newFileName}`,
fileName: newFileName,
fileDiff: expect.any(String),
}),
);
});
- it('should not use AI correction and provide clear feedback for non-matching text', async () => {
+ it('should use corrected params from ensureCorrectEdit for diff generation', async () => {
const originalContent = 'This is the original string to be replaced.';
- const nonMatchingOldString = 'completely different text'; // This won't match at all
- const newString = 'new string';
+ const originalOldString = 'original string';
+ const originalNewString = 'new string';
+
+ const correctedOldString = 'original string to be replaced'; // More specific
+ const correctedNewString = 'completely new string'; // Different replacement
+ const expectedFinalContent = 'This is the completely new string.';
fs.writeFileSync(filePath, originalContent);
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: nonMatchingOldString, new_string: newString }],
+ old_string: originalOldString,
+ new_string: originalNewString,
};
- // With deterministic approach, this should return false (no confirmation)
- // because the old_string doesn't match exactly
- const confirmation = await tool.shouldConfirmExecute(
+ // The main beforeEach already calls mockEnsureCorrectEdit.mockReset()
+ // Set a specific mock for this test case
+ let mockCalled = false;
+ mockEnsureCorrectEdit.mockImplementationOnce(
+ async (content, p, client) => {
+ console.log('mockEnsureCorrectEdit CALLED IN TEST');
+ mockCalled = true;
+ expect(content).toBe(originalContent);
+ expect(p).toBe(params);
+ expect(client).toBe((tool as any).client);
+ return {
+ params: {
+ file_path: filePath,
+ old_string: correctedOldString,
+ new_string: correctedNewString,
+ },
+ occurrences: 1,
+ };
+ },
+ );
+
+ const confirmation = (await tool.shouldConfirmExecute(
params,
new AbortController().signal,
+ )) as FileDiff;
+
+ expect(mockCalled).toBe(true); // Check if the mock implementation was run
+ // expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(originalContent, params, expect.anything()); // Keep this commented for now
+ expect(confirmation).toEqual(
+ expect.objectContaining({
+ title: `Confirm Edit: ${testFile}`,
+ fileName: testFile,
+ }),
);
+ // Check that the diff is based on the corrected strings leading to the new state
+ expect(confirmation.fileDiff).toContain(`-${originalContent}`);
+ expect(confirmation.fileDiff).toContain(`+${expectedFinalContent}`);
- // Should return false because edit will fail (no exact match)
- expect(confirmation).toBe(false);
+ // Verify that applying the correctedOldString and correctedNewString to originalContent
+ // indeed produces the expectedFinalContent, which is what the diff should reflect.
+ const patchedContent = originalContent.replace(
+ correctedOldString, // This was the string identified by ensureCorrectEdit for replacement
+ correctedNewString, // This was the string identified by ensureCorrectEdit as the replacement
+ );
+ expect(patchedContent).toBe(expectedFinalContent);
});
});
@@ -375,7 +404,8 @@ describe('EditTool', () => {
it('should return error if params are invalid', async () => {
const params: EditToolParams = {
file_path: 'relative.txt',
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
const result = await tool.execute(params, new AbortController().signal);
expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
@@ -388,29 +418,26 @@ describe('EditTool', () => {
fs.writeFileSync(filePath, initialContent, 'utf8');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
- // Mock ensureCorrectEdit to return the expected params and occurrences
- mockEnsureCorrectEdit.mockResolvedValueOnce({
- params: {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- },
- occurrences: 1,
- });
+ // Specific mock for this test's execution path in calculateEdit
+ // ensureCorrectEdit is NOT called by calculateEdit, only by shouldConfirmExecute
+ // So, the default mockEnsureCorrectEdit should correctly return 1 occurrence for 'old' in initialContent
+
+ // Simulate confirmation by setting shouldAlwaysEdit
+ (tool as any).shouldAlwaysEdit = true;
const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(/Successfully applied 1\/1 edits/);
- expect(result.editsApplied).toBe(1);
- expect(result.editsAttempted).toBe(1);
- expect(result.editsFailed).toBe(0);
+ (tool as any).shouldAlwaysEdit = false; // Reset for other tests
+
+ expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent);
const display = result.returnDisplay as FileDiff;
- expect(display.fileDiff).toContain('-This is some old text.');
- expect(display.fileDiff).toContain('+This is some new text.');
+ expect(display.fileDiff).toMatch(initialContent);
+ expect(display.fileDiff).toMatch(newContent);
expect(display.fileName).toBe(testFile);
});
@@ -420,7 +447,8 @@ describe('EditTool', () => {
const fileContent = 'Content for the new file.';
const params: EditToolParams = {
file_path: newFilePath,
- edits: [{ old_string: '', new_string: fileContent }],
+ old_string: '',
+ new_string: fileContent,
};
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
@@ -429,65 +457,42 @@ describe('EditTool', () => {
const result = await tool.execute(params, new AbortController().signal);
expect(result.llmContent).toMatch(/Created new file/);
- expect(result.editsApplied).toBe(1);
- expect(result.editsAttempted).toBe(1);
- expect(result.editsFailed).toBe(0);
+ expect(fs.existsSync(newFilePath)).toBe(true);
expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent);
- expect(result.returnDisplay).toContain('Created');
+ expect(result.returnDisplay).toBe(`Created ${newFileName}`);
});
it('should return error if old_string is not found in file', async () => {
fs.writeFileSync(filePath, 'Some content.', 'utf8');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'nonexistent', new_string: 'replacement' }],
+ old_string: 'nonexistent',
+ new_string: 'replacement',
};
- // Mock ensureCorrectEdit to return 0 occurrences
- mockEnsureCorrectEdit.mockResolvedValueOnce({
- params: {
- file_path: filePath,
- old_string: 'not_found',
- new_string: 'replacement',
- },
- occurrences: 0,
- });
-
+ // The default mockEnsureCorrectEdit will return 0 occurrences for 'nonexistent'
const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(/Failed to apply any edits/);
- expect(result.editsApplied).toBe(0);
- expect(result.editsAttempted).toBe(1);
- expect(result.editsFailed).toBe(1);
- expect(result.failedEdits).toHaveLength(1);
- expect(result.failedEdits![0].error).toMatch(/String not found/);
+ expect(result.llmContent).toMatch(
+ /0 occurrences found for old_string in/,
+ );
+ expect(result.returnDisplay).toMatch(
+ /Failed to edit, could not find the string to replace./,
+ );
});
it('should return error if multiple occurrences of old_string are found', async () => {
- const initialContent = 'old old content here';
- fs.writeFileSync(filePath, initialContent, 'utf8');
+ fs.writeFileSync(filePath, 'multiple old old strings', 'utf8');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
-
- // Mock ensureCorrectEdit to return multiple occurrences
- mockEnsureCorrectEdit.mockResolvedValueOnce({
- params: {
- file_path: filePath,
- old_string: 'old',
- new_string: 'new',
- },
- occurrences: 2,
- });
-
+ // The default mockEnsureCorrectEdit will return 2 occurrences for 'old'
const result = await tool.execute(params, new AbortController().signal);
-
- expect(result.llmContent).toMatch(/Failed to apply any edits/);
- expect(result.editsApplied).toBe(0);
- expect(result.editsAttempted).toBe(1);
- expect(result.editsFailed).toBe(1);
- expect(result.failedEdits).toHaveLength(1);
- expect(result.failedEdits![0].error).toMatch(
- /Expected 1 occurrences but found 2/,
+ expect(result.llmContent).toMatch(
+ /Expected 1 occurrences but found 2 for old_string in file/,
+ );
+ expect(result.returnDisplay).toMatch(
+ /Failed to edit, expected 1 occurrence\(s\) but found 2/,
);
});
@@ -495,7 +500,8 @@ describe('EditTool', () => {
fs.writeFileSync(filePath, 'old text old text old text', 'utf8');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
expected_replacements: 3,
};
@@ -506,7 +512,7 @@ describe('EditTool', () => {
(tool as any).shouldAlwaysEdit = false; // Reset for other tests
- expect(result.llmContent).toMatch(/Successfully applied 1\/1 edits/);
+ expect(result.llmContent).toMatch(/Successfully modified file/);
expect(fs.readFileSync(filePath, 'utf8')).toBe(
'new text new text new text',
);
@@ -520,159 +526,45 @@ describe('EditTool', () => {
fs.writeFileSync(filePath, 'old text old text', 'utf8');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
expected_replacements: 3, // Expecting 3 but only 2 exist
};
const result = await tool.execute(params, new AbortController().signal);
expect(result.llmContent).toMatch(
- /Failed to apply any edits.*Expected 3 occurrences but found 2/,
+ /Expected 3 occurrences but found 2 for old_string in file/,
+ );
+ expect(result.returnDisplay).toMatch(
+ /Failed to edit, expected 3 occurrence\(s\) but found 2/,
);
- expect(result.returnDisplay).toMatch(/No edits applied/);
});
it('should return error if trying to create a file that already exists (empty old_string)', async () => {
- const existingContent = 'File already exists.';
- fs.writeFileSync(filePath, existingContent, 'utf8');
+ fs.writeFileSync(filePath, 'Existing content', 'utf8');
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: '', new_string: 'new content' }],
+ old_string: '',
+ new_string: 'new content',
};
-
const result = await tool.execute(params, new AbortController().signal);
-
- expect(result.llmContent).toMatch(/File already exists/);
- expect(result.editsApplied).toBe(0);
- expect(result.editsAttempted).toBe(1);
- expect(result.editsFailed).toBe(1);
- });
-
- it('should reject multiple edits with mixed file creation and editing on non-existent file', async () => {
- // Ensure file doesn't exist
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- }
-
- const params: EditToolParams = {
- file_path: filePath,
- edits: [
- { old_string: '', new_string: 'new content' },
- { old_string: 'some text', new_string: 'replacement' },
- ],
- };
-
- const result = await tool.execute(params, new AbortController().signal);
-
- // File should be created with first edit, but second edit should fail
- expect(result.llmContent).toMatch(/Created new file.*Failed edits/);
- expect(result.editsApplied).toBe(1);
- expect(result.editsFailed).toBe(1);
- expect(result.failedEdits![0].error).toMatch(/String not found/);
-
- // File should now exist with content from first edit
- expect(fs.existsSync(filePath)).toBe(true);
- expect(fs.readFileSync(filePath, 'utf8')).toBe('new content');
- });
-
- it('should demonstrate deterministic position-based edit behavior', async () => {
- // Demonstrates that position-based processor is strict about exact matches
- const originalContent = `function processUser(userData) {
- const userName = userData.name;
- console.log('Processing user:', userName);
- return { user: userName, processed: true };
-}`;
-
- fs.writeFileSync(filePath, originalContent);
-
- const params: EditToolParams = {
- file_path: filePath,
- edits: [
- // This edit will succeed - userData appears exactly once
- { old_string: 'userData', new_string: 'userInfo' },
- // This edit will fail - after first edit, this exact string no longer exists
- {
- old_string: 'const userName = userData.name;',
- new_string: 'const displayName = userInfo.name;',
- },
- // These demonstrate that dependent edits fail when context changes
- {
- old_string: "console.log('Processing user:', userName);",
- new_string: "console.log('Processing user:', displayName);",
- },
- ],
- };
-
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(/Successfully applied 2\/3 edits/);
- expect(result.llmContent).toMatch(
- /Failed edits.*Expected 1 occurrences but found 2/,
+ expect(result.llmContent).toMatch(/File already exists, cannot create/);
+ expect(result.returnDisplay).toMatch(
+ /Attempted to create a file that already exists/,
);
-
- // Verify what edits were actually applied (based on position-based processing)
- const finalContent = fs.readFileSync(filePath, 'utf8');
- // Check that the content changed in some way (deterministic behavior test)
- expect(finalContent).not.toBe(originalContent);
- // The exact result depends on position-based processing order
- expect(finalContent).toContain('userInfo');
- });
-
- it('should handle non-conflicting edits efficiently', async () => {
- // Demonstrates successful position-based processing with non-conflicting edits
- const originalContent = `const config = {
- apiUrl: 'https://api.old.com',
- timeout: 5000,
- retries: 3
-};
-
-function makeRequest() {
- return fetch(config.apiUrl);
-}`;
-
- fs.writeFileSync(filePath, originalContent);
-
- const params: EditToolParams = {
- file_path: filePath,
- edits: [
- // These edits don't interfere with each other
- {
- old_string: "apiUrl: 'https://api.old.com'",
- new_string: "apiUrl: 'https://api.new.com'",
- },
- { old_string: 'timeout: 5000', new_string: 'timeout: 10000' },
- { old_string: 'retries: 3', new_string: 'retries: 5' },
- ],
- };
-
- const result = await tool.execute(params, new AbortController().signal);
- expect(result.llmContent).toMatch(/Successfully applied 3\/3 edits/);
-
- // All edits should succeed because they don't conflict
- const finalContent = fs.readFileSync(filePath, 'utf8');
- const expectedContent = `const config = {
- apiUrl: 'https://api.new.com',
- timeout: 10000,
- retries: 5
-};
-
-function makeRequest() {
- return fetch(config.apiUrl);
-}`;
-
- expect(finalContent).toBe(expectedContent);
});
});
describe('getDescription', () => {
- it('should return consistent format even if old_string and new_string are the same', () => {
+ it('should return "No file changes to..." if old_string and new_string are the same', () => {
const testFileName = 'test.txt';
const params: EditToolParams = {
file_path: path.join(rootDir, testFileName),
- edits: [
- { old_string: 'identical_string', new_string: 'identical_string' },
- ],
+ old_string: 'identical_string',
+ new_string: 'identical_string',
};
// shortenPath will be called internally, resulting in just the file name
expect(tool.getDescription(params)).toBe(
- `${testFileName}: identical_string => identical_string`,
+ `No file changes to ${testFileName}`,
);
});
@@ -680,12 +572,8 @@ function makeRequest() {
const testFileName = 'test.txt';
const params: EditToolParams = {
file_path: path.join(rootDir, testFileName),
- edits: [
- {
- old_string: 'this is the old string value',
- new_string: 'this is the new string value',
- },
- ],
+ old_string: 'this is the old string value',
+ new_string: 'this is the new string value',
};
// shortenPath will be called internally, resulting in just the file name
// The snippets are truncated at 30 chars + '...'
@@ -698,7 +586,8 @@ function makeRequest() {
const testFileName = 'short.txt';
const params: EditToolParams = {
file_path: path.join(rootDir, testFileName),
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
expect(tool.getDescription(params)).toBe(`${testFileName}: old => new`);
});
@@ -707,14 +596,10 @@ function makeRequest() {
const testFileName = 'long.txt';
const params: EditToolParams = {
file_path: path.join(rootDir, testFileName),
- edits: [
- {
- old_string:
- 'this is a very long old string that will definitely be truncated',
- new_string:
- 'this is a very long new string that will also be truncated',
- },
- ],
+ old_string:
+ 'this is a very long old string that will definitely be truncated',
+ new_string:
+ 'this is a very long new string that will also be truncated',
};
expect(tool.getDescription(params)).toBe(
`${testFileName}: this is a very long old string... => this is a very long new string...`,
@@ -740,9 +625,8 @@ function makeRequest() {
const originalContent = 'original content';
const params: EditToolParams = {
file_path: filePath,
- edits: [
- { old_string: originalContent, new_string: 'modified content' },
- ],
+ old_string: originalContent,
+ new_string: 'modified content',
};
fs.writeFileSync(filePath, originalContent, 'utf8');
@@ -765,9 +649,8 @@ function makeRequest() {
expect(result).toBeDefined();
expect(result!.updatedParams).toEqual({
file_path: filePath,
- edits: [
- { old_string: originalContent, new_string: 'modified content' },
- ],
+ old_string: originalContent,
+ new_string: 'modified content',
});
expect(result!.updatedDiff).toEqual(`Index: some_file.txt
===================================================================
@@ -788,7 +671,8 @@ function makeRequest() {
it('should handle non-existent files and return updated params', async () => {
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: '', new_string: 'new file content' }],
+ old_string: '',
+ new_string: 'new file content',
};
const result = await tool.onModify(
@@ -804,7 +688,8 @@ function makeRequest() {
expect(result).toBeDefined();
expect(result!.updatedParams).toEqual({
file_path: filePath,
- edits: [{ old_string: '', new_string: 'new file content' }],
+ old_string: '',
+ new_string: 'new file content',
});
expect(result!.updatedDiff).toContain('new file content');
@@ -816,7 +701,8 @@ function makeRequest() {
it('should clean up previous temp files before creating new ones', async () => {
const params: EditToolParams = {
file_path: filePath,
- edits: [{ old_string: 'old', new_string: 'new' }],
+ old_string: 'old',
+ new_string: 'new',
};
fs.writeFileSync(filePath, 'some old content', 'utf8');
diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts
index f6a05ac1..3240fa30 100644
--- a/packages/core/src/tools/edit.ts
+++ b/packages/core/src/tools/edit.ts
@@ -36,12 +36,14 @@ export interface EditToolParams {
file_path: string;
/**
- * Array of edits to apply
+ * The text to replace
*/
- edits: Array<{
- old_string: string;
- new_string: string;
- }>;
+ old_string: string;
+
+ /**
+ * The text to replace it with
+ */
+ new_string: string;
/**
* Number of replacements expected. Defaults to 1 if not specified.
@@ -50,29 +52,18 @@ export interface EditToolParams {
expected_replacements?: number;
}
-interface EditResult extends ToolResult {
- editsApplied: number;
- editsAttempted: number;
- editsFailed: number;
- failedEdits?: Array<{
- index: number;
- oldString: string;
- newString: string;
- error: string;
- }>;
-}
-
-interface FailedEdit {
- index: number;
- oldString: string;
- newString: string;
- error: string;
+interface CalculatedEdit {
+ currentContent: string | null;
+ newContent: string;
+ occurrences: number;
+ error?: { display: string; raw: string };
+ isNewFile: boolean;
}
/**
* Implementation of the Edit tool logic
*/
-export class EditTool extends BaseTool<EditToolParams, EditResult> {
+export class EditTool extends BaseTool<EditToolParams, ToolResult> {
static readonly Name = 'replace';
private readonly config: Config;
private readonly rootDirectory: string;
@@ -87,8 +78,8 @@ export class EditTool extends BaseTool<EditToolParams, EditResult> {
constructor(config: Config) {
super(
EditTool.Name,
- 'EditFile',
- `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool also supports batch editing with multiple edits in a single operation. Requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement.
+ 'Edit',
+ `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement.
Expectation for required parameters:
1. \`file_path\` MUST be an absolute path; otherwise an error will be thrown.
@@ -104,26 +95,15 @@ Expectation for required parameters:
"The absolute path to the file to modify. Must start with '/'.",
type: 'string',
},
- edits: {
+ old_string: {
+ description:
+ 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.',
+ type: 'string',
+ },
+ new_string: {
description:
- 'Array of edit operations to apply. Each edit should have old_string and new_string properties.',
- type: 'array',
- items: {
- type: 'object',
- properties: {
- old_string: {
- description:
- 'The exact literal text to replace, preferably unescaped. CRITICAL: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely.',
- type: 'string',
- },
- new_string: {
- description:
- 'The exact literal text to replace old_string with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
- type: 'string',
- },
- },
- required: ['old_string', 'new_string'],
- },
+ 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.',
+ type: 'string',
},
expected_replacements: {
type: 'number',
@@ -132,7 +112,7 @@ Expectation for required parameters:
minimum: 1,
},
},
- required: ['file_path', 'edits'],
+ required: ['file_path', 'old_string', 'new_string'],
type: 'object',
},
);
@@ -182,11 +162,6 @@ Expectation for required parameters:
return `File path must be within the root directory (${this.rootDirectory}): ${params.file_path}`;
}
- // Validate that edits array is provided and not empty
- if (!params.edits || params.edits.length === 0) {
- return 'Must provide "edits" array with at least one edit.';
- }
-
return null;
}
@@ -211,124 +186,95 @@ Expectation for required parameters:
}
/**
- * Applies multiple edits to file content in sequence
- * @param params Edit parameters
- * @param abortSignal Abort signal for cancellation
- * @returns Result with detailed edit metrics
+ * Calculates the potential outcome of an edit operation.
+ * @param params Parameters for the edit operation
+ * @returns An object describing the potential edit outcome
+ * @throws File system errors if reading the file fails unexpectedly (e.g., permissions)
*/
- private async applyMultipleEdits(
+ private async calculateEdit(
params: EditToolParams,
abortSignal: AbortSignal,
- ): Promise<{
- newContent: string;
- editsApplied: number;
- editsAttempted: number;
- editsFailed: number;
- failedEdits: FailedEdit[];
- isNewFile: boolean;
- originalContent: string | null;
- }> {
- // Read current file content or determine if this is a new file
+ ): Promise<CalculatedEdit> {
+ const expectedReplacements = params.expected_replacements ?? 1;
let currentContent: string | null = null;
let fileExists = false;
let isNewFile = false;
+ let finalNewString = params.new_string;
+ let finalOldString = params.old_string;
+ let occurrences = 0;
+ let error: { display: string; raw: string } | undefined = undefined;
try {
currentContent = fs.readFileSync(params.file_path, 'utf8');
fileExists = true;
} catch (err: unknown) {
if (!isNodeError(err) || err.code !== 'ENOENT') {
+ // Rethrow unexpected FS errors (permissions, etc.)
throw err;
}
+ fileExists = false;
}
- // If file doesn't exist and first edit has empty old_string, it's file creation
- if (!fileExists && params.edits[0].old_string === '') {
+ if (params.old_string === '' && !fileExists) {
+ // Creating a new file
isNewFile = true;
- currentContent = '';
} else if (!fileExists) {
- throw new Error(`File does not exist: ${params.file_path}`);
- } else if (fileExists && params.edits[0].old_string === '') {
- // Protect against accidentally creating a file that already exists
- throw new Error(`File already exists: ${params.file_path}`);
+ // Trying to edit a non-existent file (and old_string is not empty)
+ error = {
+ display: `File not found. Cannot apply edit. Use an empty old_string to create a new file.`,
+ raw: `File not found: ${params.file_path}`,
+ };
+ } else if (currentContent !== null) {
+ // Editing an existing file
+ const correctedEdit = await ensureCorrectEdit(
+ currentContent,
+ params,
+ this.client,
+ abortSignal,
+ );
+ finalOldString = correctedEdit.params.old_string;
+ finalNewString = correctedEdit.params.new_string;
+ occurrences = correctedEdit.occurrences;
+
+ if (params.old_string === '') {
+ // Error: Trying to create a file that already exists
+ error = {
+ display: `Failed to edit. Attempted to create a file that already exists.`,
+ raw: `File already exists, cannot create: ${params.file_path}`,
+ };
+ } else if (occurrences === 0) {
+ error = {
+ display: `Failed to edit, could not find the string to replace.`,
+ raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`,
+ };
+ } else if (occurrences !== expectedReplacements) {
+ error = {
+ display: `Failed to edit, expected ${expectedReplacements} occurrence(s) but found ${occurrences}.`,
+ raw: `Failed to edit, Expected ${expectedReplacements} occurrences but found ${occurrences} for old_string in file: ${params.file_path}`,
+ };
+ }
+ } else {
+ // Should not happen if fileExists and no exception was thrown, but defensively:
+ error = {
+ display: `Failed to read content of file.`,
+ raw: `Failed to read content of existing file: ${params.file_path}`,
+ };
}
- const expectedReplacements = params.expected_replacements ?? 1;
+ const newContent = this._applyReplacement(
+ currentContent,
+ finalOldString,
+ finalNewString,
+ isNewFile,
+ );
- const result = {
- newContent: currentContent || '',
- editsApplied: 0,
- editsAttempted: params.edits.length,
- editsFailed: 0,
- failedEdits: [] as FailedEdit[],
+ return {
+ currentContent,
+ newContent,
+ occurrences,
+ error,
isNewFile,
- originalContent: currentContent,
};
-
- // Apply each edit
- for (let i = 0; i < params.edits.length; i++) {
- const edit = params.edits[i];
-
- // Handle new file creation with empty old_string
- if (isNewFile && edit.old_string === '') {
- result.newContent = edit.new_string;
- result.editsApplied++;
- continue;
- }
-
- // Use edit corrector for better matching
- try {
- const correctedEdit = await ensureCorrectEdit(
- result.newContent,
- {
- ...params,
- old_string: edit.old_string,
- new_string: edit.new_string,
- },
- this.client,
- abortSignal,
- );
-
- // Handle both single and multiple replacements based on expected_replacements
- if (expectedReplacements === 1 && correctedEdit.occurrences === 1) {
- result.newContent = result.newContent.replace(
- correctedEdit.params.old_string,
- correctedEdit.params.new_string,
- );
- result.editsApplied++;
- } else if (
- expectedReplacements > 1 &&
- correctedEdit.occurrences === expectedReplacements
- ) {
- result.newContent = result.newContent.replaceAll(
- correctedEdit.params.old_string,
- correctedEdit.params.new_string,
- );
- result.editsApplied++;
- } else {
- result.editsFailed++;
- result.failedEdits.push({
- index: i,
- oldString: edit.old_string,
- newString: edit.new_string,
- error:
- correctedEdit.occurrences === 0
- ? 'String not found'
- : `Expected ${expectedReplacements} occurrences but found ${correctedEdit.occurrences}`,
- });
- }
- } catch (error) {
- result.editsFailed++;
- result.failedEdits.push({
- index: i,
- oldString: edit.old_string,
- newString: edit.new_string,
- error: error instanceof Error ? error.message : String(error),
- });
- }
- }
-
- return result;
}
/**
@@ -349,89 +295,98 @@ Expectation for required parameters:
);
return false;
}
+ let currentContent: string | null = null;
+ let fileExists = false;
+ let finalNewString = params.new_string;
+ let finalOldString = params.old_string;
+ let occurrences = 0;
try {
- // Calculate what the edits would produce
- const editResult = await this.applyMultipleEdits(params, abortSignal);
-
- // Don't show confirmation if no edits would be applied
- if (editResult.editsApplied === 0 && !editResult.isNewFile) {
+ currentContent = fs.readFileSync(params.file_path, 'utf8');
+ fileExists = true;
+ } catch (err: unknown) {
+ if (isNodeError(err) && err.code === 'ENOENT') {
+ fileExists = false;
+ } else {
+ console.error(`Error reading file for confirmation diff: ${err}`);
return false;
}
+ }
- // Read current content for diff comparison
- let currentContent: string | null = null;
- try {
- currentContent = fs.readFileSync(params.file_path, 'utf8');
- } catch (err: unknown) {
- if (isNodeError(err) && err.code === 'ENOENT') {
- currentContent = '';
- } else {
- console.error(`Error reading file for confirmation diff: ${err}`);
- return false;
- }
- }
-
- // Generate diff for confirmation
- const fileName = path.basename(params.file_path);
- const fileDiff = Diff.createPatch(
- fileName,
- currentContent || '',
- editResult.newContent,
- 'Current',
- 'Proposed',
- DEFAULT_DIFF_OPTIONS,
+ if (params.old_string === '' && !fileExists) {
+ // Creating new file, newContent is just params.new_string
+ } else if (!fileExists) {
+ return false; // Cannot edit non-existent file if old_string is not empty
+ } else if (currentContent !== null) {
+ const correctedEdit = await ensureCorrectEdit(
+ currentContent,
+ params,
+ this.client,
+ abortSignal,
);
+ finalOldString = correctedEdit.params.old_string;
+ finalNewString = correctedEdit.params.new_string;
+ occurrences = correctedEdit.occurrences;
- const editsCount = params.edits.length;
- const title =
- editsCount > 1
- ? `Confirm ${editsCount} Edits: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`
- : `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
-
- const confirmationDetails: ToolEditConfirmationDetails = {
- type: 'edit',
- title,
- fileName,
- fileDiff,
- onConfirm: async (outcome: ToolConfirmationOutcome) => {
- if (outcome === ToolConfirmationOutcome.ProceedAlways) {
- this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
- }
- },
- };
- return confirmationDetails;
- } catch (error) {
- console.error(`Error generating confirmation diff: ${error}`);
- return false;
+ const expectedReplacements = params.expected_replacements ?? 1;
+ if (occurrences === 0 || occurrences !== expectedReplacements) {
+ return false;
+ }
+ } else {
+ return false; // Should not happen
}
+
+ const isNewFileScenario = params.old_string === '' && !fileExists;
+ const newContent = this._applyReplacement(
+ currentContent,
+ finalOldString,
+ finalNewString,
+ isNewFileScenario,
+ );
+
+ const fileName = path.basename(params.file_path);
+ const fileDiff = Diff.createPatch(
+ fileName,
+ currentContent ?? '',
+ newContent,
+ 'Current',
+ 'Proposed',
+ DEFAULT_DIFF_OPTIONS,
+ );
+ const confirmationDetails: ToolEditConfirmationDetails = {
+ type: 'edit',
+ title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`,
+ fileName,
+ fileDiff,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ if (outcome === ToolConfirmationOutcome.ProceedAlways) {
+ this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
+ }
+ },
+ };
+ return confirmationDetails;
}
getDescription(params: EditToolParams): string {
- if (!params.file_path) {
+ if (!params.file_path || !params.old_string || !params.new_string) {
return `Model did not provide valid parameters for edit tool`;
}
const relativePath = makeRelative(params.file_path, this.rootDirectory);
-
- if (!params.edits || params.edits.length === 0) {
- return `Edit ${shortenPath(relativePath)}`;
+ if (params.old_string === '') {
+ return `Create ${shortenPath(relativePath)}`;
}
- if (params.edits.length === 1) {
- const edit = params.edits[0];
- if (edit.old_string === '') {
- return `Create ${shortenPath(relativePath)}`;
- }
- const oldSnippet =
- edit.old_string.split('\n')[0].substring(0, 30) +
- (edit.old_string.length > 30 ? '...' : '');
- const newSnippet =
- edit.new_string.split('\n')[0].substring(0, 30) +
- (edit.new_string.length > 30 ? '...' : '');
- return `${shortenPath(relativePath)}: ${oldSnippet} => ${newSnippet}`;
- } else {
- return `Edit ${shortenPath(relativePath)} (${params.edits.length} edits)`;
+ const oldStringSnippet =
+ params.old_string.split('\n')[0].substring(0, 30) +
+ (params.old_string.length > 30 ? '...' : '');
+ const newStringSnippet =
+ params.new_string.split('\n')[0].substring(0, 30) +
+ (params.new_string.length > 30 ? '...' : '');
+
+ if (params.old_string === params.new_string) {
+ return `No file changes to ${shortenPath(relativePath)}`;
}
+ return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`;
}
/**
@@ -441,79 +396,69 @@ Expectation for required parameters:
*/
async execute(
params: EditToolParams,
- abortSignal: AbortSignal,
- ): Promise<EditResult> {
+ signal: AbortSignal,
+ ): Promise<ToolResult> {
const validationError = this.validateToolParams(params);
if (validationError) {
return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: `Error: ${validationError}`,
- editsApplied: 0,
- editsAttempted: 0,
- editsFailed: 1,
};
}
+ let editData: CalculatedEdit;
try {
- const editResult = await this.applyMultipleEdits(params, abortSignal);
+ editData = await this.calculateEdit(params, signal);
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : String(error);
+ return {
+ llmContent: `Error preparing edit: ${errorMsg}`,
+ returnDisplay: `Error preparing edit: ${errorMsg}`,
+ };
+ }
- // Apply the changes to the file
+ if (editData.error) {
+ return {
+ llmContent: editData.error.raw,
+ returnDisplay: `Error: ${editData.error.display}`,
+ };
+ }
+
+ try {
this.ensureParentDirectoriesExist(params.file_path);
- fs.writeFileSync(params.file_path, editResult.newContent, 'utf8');
+ fs.writeFileSync(params.file_path, editData.newContent, 'utf8');
- // Generate appropriate response messages
let displayResult: ToolResultDisplay;
- let llmContent: string;
-
- if (editResult.isNewFile) {
+ if (editData.isNewFile) {
displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
- llmContent = `Created new file: ${params.file_path}`;
- } else if (editResult.editsApplied > 0) {
- // Generate diff for display using original content before writing
+ } else {
+ // Generate diff for display, even though core logic doesn't technically need it
+ // The CLI wrapper will use this part of the ToolResult
const fileName = path.basename(params.file_path);
- // Use the original content from before the edit was applied
- const originalContent = editResult.originalContent || '';
const fileDiff = Diff.createPatch(
fileName,
- originalContent,
- editResult.newContent,
+ editData.currentContent ?? '', // Should not be null here if not isNewFile
+ editData.newContent,
'Current',
'Proposed',
DEFAULT_DIFF_OPTIONS,
);
displayResult = { fileDiff, fileName };
- llmContent = `Successfully applied ${editResult.editsApplied}/${editResult.editsAttempted} edits to ${params.file_path}`;
- } else {
- displayResult = `No edits applied to ${shortenPath(makeRelative(params.file_path, this.rootDirectory))}`;
- llmContent = `Failed to apply any edits to ${params.file_path}`;
}
- // Add details about failed edits
- if (editResult.editsFailed > 0) {
- const failureDetails = editResult.failedEdits
- .map((f) => `Edit ${f.index + 1}: ${f.error}`)
- .join('; ');
- llmContent += `. Failed edits: ${failureDetails}`;
- }
+ const llmSuccessMessage = editData.isNewFile
+ ? `Created new file: ${params.file_path} with provided content.`
+ : `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`;
return {
- llmContent,
+ llmContent: llmSuccessMessage,
returnDisplay: displayResult,
- editsApplied: editResult.editsApplied,
- editsAttempted: editResult.editsAttempted,
- editsFailed: editResult.editsFailed,
- failedEdits: editResult.failedEdits,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
- const editsAttempted = params.edits.length;
-
return {
- llmContent: `Error executing edits: ${errorMsg}`,
- returnDisplay: `Error: ${errorMsg}`,
- editsApplied: 0,
- editsAttempted,
- editsFailed: editsAttempted,
+ llmContent: `Error executing edit: ${errorMsg}`,
+ returnDisplay: `Error writing file: ${errorMsg}`,
};
}
}
@@ -567,12 +512,8 @@ Expectation for required parameters:
// Combine the edits into a single edit
const updatedParams: EditToolParams = {
...params,
- edits: [
- {
- old_string: oldContent,
- new_string: newContent,
- },
- ],
+ old_string: oldContent,
+ new_string: newContent,
};
const updatedDiff = Diff.createPatch(
@@ -618,14 +559,12 @@ Expectation for required parameters:
}
let proposedContent = currentContent;
- for (const edit of params.edits) {
- proposedContent = this._applyReplacement(
- proposedContent,
- edit.old_string,
- edit.new_string,
- edit.old_string === '' && 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');
diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts
index caef67b9..fea276ad 100644
--- a/packages/core/src/tools/shell.ts
+++ b/packages/core/src/tools/shell.ts
@@ -30,7 +30,7 @@ import { spawn } from 'child_process';
const OUTPUT_UPDATE_INTERVAL_MS = 1000;
export class ShellTool extends BaseTool<ShellToolParams, ToolResult> {
- static readonly Name: string = 'execute_bash_command';
+ static Name: string = 'execute_bash_command';
private whitelist: Set<string> = new Set();
constructor(private readonly config: Config) {
diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts
new file mode 100644
index 00000000..4646f30a
--- /dev/null
+++ b/packages/core/src/tools/write-file.test.ts
@@ -0,0 +1,571 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ beforeEach,
+ afterEach,
+ vi,
+ type Mocked,
+} from 'vitest';
+import { WriteFileTool } from './write-file.js';
+import {
+ FileDiff,
+ ToolConfirmationOutcome,
+ ToolEditConfirmationDetails,
+} from './tools.js';
+import { type EditToolParams } from './edit.js';
+import { ApprovalMode, Config } from '../config/config.js';
+import { ToolRegistry } from './tool-registry.js';
+import path from 'path';
+import fs from 'fs';
+import os from 'os';
+import { GeminiClient } from '../core/client.js';
+import {
+ ensureCorrectEdit,
+ ensureCorrectFileContent,
+ CorrectedEditResult,
+} from '../utils/editCorrector.js';
+
+const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root');
+
+// --- MOCKS ---
+vi.mock('../core/client.js');
+vi.mock('../utils/editCorrector.js');
+
+let mockGeminiClientInstance: Mocked<GeminiClient>;
+const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
+const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
+
+// Wire up the mocked functions to be used by the actual module imports
+vi.mocked(ensureCorrectEdit).mockImplementation(mockEnsureCorrectEdit);
+vi.mocked(ensureCorrectFileContent).mockImplementation(
+ mockEnsureCorrectFileContent,
+);
+
+// Mock Config
+const mockConfigInternal = {
+ getTargetDir: () => rootDir,
+ getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
+ setApprovalMode: vi.fn(),
+ getGeminiClient: vi.fn(), // Initialize as a plain mock function
+ getApiKey: () => 'test-key',
+ getModel: () => 'test-model',
+ getSandbox: () => false,
+ getDebugMode: () => false,
+ getQuestion: () => undefined,
+ getFullContext: () => false,
+ getToolDiscoveryCommand: () => undefined,
+ getToolCallCommand: () => undefined,
+ getMcpServerCommand: () => undefined,
+ getMcpServers: () => undefined,
+ getUserAgent: () => 'test-agent',
+ getUserMemory: () => '',
+ setUserMemory: vi.fn(),
+ getGeminiMdFileCount: () => 0,
+ setGeminiMdFileCount: vi.fn(),
+ getToolRegistry: () =>
+ ({
+ registerTool: vi.fn(),
+ discoverTools: vi.fn(),
+ }) as unknown as ToolRegistry,
+};
+const mockConfig = mockConfigInternal as unknown as Config;
+// --- END MOCKS ---
+
+describe('WriteFileTool', () => {
+ let tool: WriteFileTool;
+ let tempDir: string;
+
+ beforeEach(() => {
+ // Create a unique temporary directory for files created outside the root
+ tempDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'write-file-test-external-'),
+ );
+ // Ensure the rootDir for the tool exists
+ if (!fs.existsSync(rootDir)) {
+ fs.mkdirSync(rootDir, { recursive: true });
+ }
+
+ // Setup GeminiClient mock
+ mockGeminiClientInstance = new (vi.mocked(GeminiClient))(
+ mockConfig,
+ ) as Mocked<GeminiClient>;
+ vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);
+
+ // Now that mockGeminiClientInstance is initialized, set the mock implementation for getGeminiClient
+ mockConfigInternal.getGeminiClient.mockReturnValue(
+ mockGeminiClientInstance,
+ );
+
+ tool = new WriteFileTool(mockConfig);
+
+ // Reset mocks before each test
+ mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
+ mockConfigInternal.setApprovalMode.mockClear();
+ mockEnsureCorrectEdit.mockReset();
+ mockEnsureCorrectFileContent.mockReset();
+
+ // Default mock implementations that return valid structures
+ mockEnsureCorrectEdit.mockImplementation(
+ async (
+ _currentContent: string,
+ params: EditToolParams,
+ _client: GeminiClient,
+ signal?: AbortSignal, // Make AbortSignal optional to match usage
+ ): Promise<CorrectedEditResult> => {
+ if (signal?.aborted) {
+ return Promise.reject(new Error('Aborted'));
+ }
+ return Promise.resolve({
+ params: { ...params, new_string: params.new_string ?? '' },
+ occurrences: 1,
+ });
+ },
+ );
+ mockEnsureCorrectFileContent.mockImplementation(
+ async (
+ content: string,
+ _client: GeminiClient,
+ signal?: AbortSignal,
+ ): Promise<string> => {
+ // Make AbortSignal optional
+ if (signal?.aborted) {
+ return Promise.reject(new Error('Aborted'));
+ }
+ return Promise.resolve(content ?? '');
+ },
+ );
+ });
+
+ afterEach(() => {
+ // Clean up the temporary directories
+ if (fs.existsSync(tempDir)) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ if (fs.existsSync(rootDir)) {
+ fs.rmSync(rootDir, { recursive: true, force: true });
+ }
+ vi.clearAllMocks();
+ });
+
+ describe('validateToolParams', () => {
+ it('should return null for valid absolute path within root', () => {
+ const params = {
+ file_path: path.join(rootDir, 'test.txt'),
+ content: 'hello',
+ };
+ expect(tool.validateToolParams(params)).toBeNull();
+ });
+
+ it('should return error for relative path', () => {
+ const params = { file_path: 'test.txt', content: 'hello' };
+ expect(tool.validateToolParams(params)).toMatch(
+ /File path must be absolute/,
+ );
+ });
+
+ it('should return error for path outside root', () => {
+ const outsidePath = path.resolve(tempDir, 'outside-root.txt');
+ const params = {
+ file_path: outsidePath,
+ content: 'hello',
+ };
+ expect(tool.validateToolParams(params)).toMatch(
+ /File path must be within the root directory/,
+ );
+ });
+
+ it('should return error if path is a directory', () => {
+ const dirAsFilePath = path.join(rootDir, 'a_directory');
+ fs.mkdirSync(dirAsFilePath);
+ const params = {
+ file_path: dirAsFilePath,
+ content: 'hello',
+ };
+ expect(tool.validateToolParams(params)).toMatch(
+ `Path is a directory, not a file: ${dirAsFilePath}`,
+ );
+ });
+ });
+
+ describe('_getCorrectedFileContent', () => {
+ it('should call ensureCorrectFileContent for a new file', async () => {
+ const filePath = path.join(rootDir, 'new_corrected_file.txt');
+ const proposedContent = 'Proposed new content.';
+ const correctedContent = 'Corrected new content.';
+ const abortSignal = new AbortController().signal;
+ // Ensure the mock is set for this specific test case if needed, or rely on beforeEach
+ mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
+
+ // @ts-expect-error _getCorrectedFileContent is private
+ const result = await tool._getCorrectedFileContent(
+ filePath,
+ proposedContent,
+ abortSignal,
+ );
+
+ expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
+ proposedContent,
+ mockGeminiClientInstance,
+ abortSignal,
+ );
+ expect(mockEnsureCorrectEdit).not.toHaveBeenCalled();
+ expect(result.correctedContent).toBe(correctedContent);
+ expect(result.originalContent).toBe('');
+ expect(result.fileExists).toBe(false);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should call ensureCorrectEdit for an existing file', async () => {
+ const filePath = path.join(rootDir, 'existing_corrected_file.txt');
+ const originalContent = 'Original existing content.';
+ const proposedContent = 'Proposed replacement content.';
+ const correctedProposedContent = 'Corrected replacement content.';
+ const abortSignal = new AbortController().signal;
+ fs.writeFileSync(filePath, originalContent, 'utf8');
+
+ // Ensure this mock is active and returns the correct structure
+ mockEnsureCorrectEdit.mockResolvedValue({
+ params: {
+ file_path: filePath,
+ old_string: originalContent,
+ new_string: correctedProposedContent,
+ },
+ occurrences: 1,
+ } as CorrectedEditResult);
+
+ // @ts-expect-error _getCorrectedFileContent is private
+ const result = await tool._getCorrectedFileContent(
+ filePath,
+ proposedContent,
+ abortSignal,
+ );
+
+ expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
+ originalContent,
+ {
+ old_string: originalContent,
+ new_string: proposedContent,
+ file_path: filePath,
+ },
+ mockGeminiClientInstance,
+ abortSignal,
+ );
+ expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
+ expect(result.correctedContent).toBe(correctedProposedContent);
+ expect(result.originalContent).toBe(originalContent);
+ expect(result.fileExists).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+
+ it('should return error if reading an existing file fails (e.g. permissions)', async () => {
+ const filePath = path.join(rootDir, 'unreadable_file.txt');
+ const proposedContent = 'some content';
+ const abortSignal = new AbortController().signal;
+ fs.writeFileSync(filePath, 'content', { mode: 0o000 });
+
+ const readError = new Error('Permission denied');
+ const originalReadFileSync = fs.readFileSync;
+ vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
+ throw readError;
+ });
+
+ // @ts-expect-error _getCorrectedFileContent is private
+ const result = await tool._getCorrectedFileContent(
+ filePath,
+ proposedContent,
+ abortSignal,
+ );
+
+ expect(fs.readFileSync).toHaveBeenCalledWith(filePath, 'utf8');
+ expect(mockEnsureCorrectEdit).not.toHaveBeenCalled();
+ expect(mockEnsureCorrectFileContent).not.toHaveBeenCalled();
+ expect(result.correctedContent).toBe(proposedContent);
+ expect(result.originalContent).toBe('');
+ expect(result.fileExists).toBe(true);
+ expect(result.error).toEqual({
+ message: 'Permission denied',
+ code: undefined,
+ });
+
+ vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
+ fs.chmodSync(filePath, 0o600);
+ });
+ });
+
+ describe('shouldConfirmExecute', () => {
+ const abortSignal = new AbortController().signal;
+ it('should return false if params are invalid (relative path)', async () => {
+ const params = { file_path: 'relative.txt', content: 'test' };
+ const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
+ expect(confirmation).toBe(false);
+ });
+
+ it('should return false if params are invalid (outside root)', async () => {
+ const outsidePath = path.resolve(tempDir, 'outside-root.txt');
+ const params = { file_path: outsidePath, content: 'test' };
+ const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
+ expect(confirmation).toBe(false);
+ });
+
+ it('should return false if _getCorrectedFileContent returns an error', async () => {
+ const filePath = path.join(rootDir, 'confirm_error_file.txt');
+ const params = { file_path: filePath, content: 'test content' };
+ fs.writeFileSync(filePath, 'original', { mode: 0o000 });
+
+ const readError = new Error('Simulated read error for confirmation');
+ const originalReadFileSync = fs.readFileSync;
+ vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
+ throw readError;
+ });
+
+ const confirmation = await tool.shouldConfirmExecute(params, abortSignal);
+ expect(confirmation).toBe(false);
+
+ vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
+ fs.chmodSync(filePath, 0o600);
+ });
+
+ it('should request confirmation with diff for a new file (with corrected content)', async () => {
+ const filePath = path.join(rootDir, 'confirm_new_file.txt');
+ const proposedContent = 'Proposed new content for confirmation.';
+ const correctedContent = 'Corrected new content for confirmation.';
+ mockEnsureCorrectFileContent.mockResolvedValue(correctedContent); // Ensure this mock is active
+
+ const params = { file_path: filePath, content: proposedContent };
+ const confirmation = (await tool.shouldConfirmExecute(
+ params,
+ abortSignal,
+ )) as ToolEditConfirmationDetails;
+
+ expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
+ proposedContent,
+ mockGeminiClientInstance,
+ abortSignal,
+ );
+ expect(confirmation).toEqual(
+ expect.objectContaining({
+ title: `Confirm Write: ${path.basename(filePath)}`,
+ fileName: 'confirm_new_file.txt',
+ fileDiff: expect.stringContaining(correctedContent),
+ }),
+ );
+ expect(confirmation.fileDiff).toMatch(
+ /--- confirm_new_file.txt\tCurrent/,
+ );
+ expect(confirmation.fileDiff).toMatch(
+ /\+\+\+ confirm_new_file.txt\tProposed/,
+ );
+ });
+
+ it('should request confirmation with diff for an existing file (with corrected content)', async () => {
+ const filePath = path.join(rootDir, 'confirm_existing_file.txt');
+ const originalContent = 'Original content for confirmation.';
+ const proposedContent = 'Proposed replacement for confirmation.';
+ const correctedProposedContent =
+ 'Corrected replacement for confirmation.';
+ fs.writeFileSync(filePath, originalContent, 'utf8');
+
+ mockEnsureCorrectEdit.mockResolvedValue({
+ params: {
+ file_path: filePath,
+ old_string: originalContent,
+ new_string: correctedProposedContent,
+ },
+ occurrences: 1,
+ });
+
+ const params = { file_path: filePath, content: proposedContent };
+ const confirmation = (await tool.shouldConfirmExecute(
+ params,
+ abortSignal,
+ )) as ToolEditConfirmationDetails;
+
+ expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
+ originalContent,
+ {
+ old_string: originalContent,
+ new_string: proposedContent,
+ file_path: filePath,
+ },
+ mockGeminiClientInstance,
+ abortSignal,
+ );
+ expect(confirmation).toEqual(
+ expect.objectContaining({
+ title: `Confirm Write: ${path.basename(filePath)}`,
+ fileName: 'confirm_existing_file.txt',
+ fileDiff: expect.stringContaining(correctedProposedContent),
+ }),
+ );
+ expect(confirmation.fileDiff).toMatch(
+ originalContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
+ );
+ });
+ });
+
+ describe('execute', () => {
+ const abortSignal = new AbortController().signal;
+ it('should return error if params are invalid (relative path)', async () => {
+ const params = { file_path: 'relative.txt', content: 'test' };
+ const result = await tool.execute(params, abortSignal);
+ expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
+ expect(result.returnDisplay).toMatch(/Error: File path must be absolute/);
+ });
+
+ it('should return error if params are invalid (path outside root)', async () => {
+ const outsidePath = path.resolve(tempDir, 'outside-root.txt');
+ const params = { file_path: outsidePath, content: 'test' };
+ const result = await tool.execute(params, abortSignal);
+ expect(result.llmContent).toMatch(/Error: Invalid parameters provided/);
+ expect(result.returnDisplay).toMatch(
+ /Error: File path must be within the root directory/,
+ );
+ });
+
+ it('should return error if _getCorrectedFileContent returns an error during execute', async () => {
+ const filePath = path.join(rootDir, 'execute_error_file.txt');
+ const params = { file_path: filePath, content: 'test content' };
+ fs.writeFileSync(filePath, 'original', { mode: 0o000 });
+
+ const readError = new Error('Simulated read error for execute');
+ const originalReadFileSync = fs.readFileSync;
+ vi.spyOn(fs, 'readFileSync').mockImplementationOnce(() => {
+ throw readError;
+ });
+
+ const result = await tool.execute(params, abortSignal);
+ expect(result.llmContent).toMatch(/Error checking existing file/);
+ expect(result.returnDisplay).toMatch(
+ /Error checking existing file: Simulated read error for execute/,
+ );
+
+ vi.spyOn(fs, 'readFileSync').mockImplementation(originalReadFileSync);
+ fs.chmodSync(filePath, 0o600);
+ });
+
+ it('should write a new file with corrected content and return diff', async () => {
+ const filePath = path.join(rootDir, 'execute_new_corrected_file.txt');
+ const proposedContent = 'Proposed new content for execute.';
+ const correctedContent = 'Corrected new content for execute.';
+ mockEnsureCorrectFileContent.mockResolvedValue(correctedContent);
+
+ const params = { file_path: filePath, content: proposedContent };
+
+ const confirmDetails = await tool.shouldConfirmExecute(
+ params,
+ abortSignal,
+ );
+ if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) {
+ await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
+ }
+
+ const result = await tool.execute(params, abortSignal);
+
+ expect(mockEnsureCorrectFileContent).toHaveBeenCalledWith(
+ proposedContent,
+ mockGeminiClientInstance,
+ abortSignal,
+ );
+ expect(result.llmContent).toMatch(
+ /Successfully created and wrote to new file/,
+ );
+ expect(fs.existsSync(filePath)).toBe(true);
+ expect(fs.readFileSync(filePath, 'utf8')).toBe(correctedContent);
+ const display = result.returnDisplay as FileDiff;
+ expect(display.fileName).toBe('execute_new_corrected_file.txt');
+ expect(display.fileDiff).toMatch(
+ /--- execute_new_corrected_file.txt\tOriginal/,
+ );
+ expect(display.fileDiff).toMatch(
+ /\+\+\+ execute_new_corrected_file.txt\tWritten/,
+ );
+ expect(display.fileDiff).toMatch(
+ correctedContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
+ );
+ });
+
+ it('should overwrite an existing file with corrected content and return diff', async () => {
+ const filePath = path.join(
+ rootDir,
+ 'execute_existing_corrected_file.txt',
+ );
+ const initialContent = 'Initial content for execute.';
+ const proposedContent = 'Proposed overwrite for execute.';
+ const correctedProposedContent = 'Corrected overwrite for execute.';
+ fs.writeFileSync(filePath, initialContent, 'utf8');
+
+ mockEnsureCorrectEdit.mockResolvedValue({
+ params: {
+ file_path: filePath,
+ old_string: initialContent,
+ new_string: correctedProposedContent,
+ },
+ occurrences: 1,
+ });
+
+ const params = { file_path: filePath, content: proposedContent };
+
+ const confirmDetails = await tool.shouldConfirmExecute(
+ params,
+ abortSignal,
+ );
+ if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) {
+ await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
+ }
+
+ const result = await tool.execute(params, abortSignal);
+
+ expect(mockEnsureCorrectEdit).toHaveBeenCalledWith(
+ initialContent,
+ {
+ old_string: initialContent,
+ new_string: proposedContent,
+ file_path: filePath,
+ },
+ mockGeminiClientInstance,
+ abortSignal,
+ );
+ expect(result.llmContent).toMatch(/Successfully overwrote file/);
+ expect(fs.readFileSync(filePath, 'utf8')).toBe(correctedProposedContent);
+ const display = result.returnDisplay as FileDiff;
+ expect(display.fileName).toBe('execute_existing_corrected_file.txt');
+ expect(display.fileDiff).toMatch(
+ initialContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
+ );
+ expect(display.fileDiff).toMatch(
+ correctedProposedContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),
+ );
+ });
+
+ it('should create directory if it does not exist', async () => {
+ const dirPath = path.join(rootDir, 'new_dir_for_write');
+ const filePath = path.join(dirPath, 'file_in_new_dir.txt');
+ const content = 'Content in new directory';
+ mockEnsureCorrectFileContent.mockResolvedValue(content); // Ensure this mock is active
+
+ const params = { file_path: filePath, content };
+ // Simulate confirmation if your logic requires it before execute, or remove if not needed for this path
+ const confirmDetails = await tool.shouldConfirmExecute(
+ params,
+ abortSignal,
+ );
+ if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) {
+ await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
+ }
+
+ await tool.execute(params, abortSignal);
+
+ expect(fs.existsSync(dirPath)).toBe(true);
+ expect(fs.statSync(dirPath).isDirectory()).toBe(true);
+ expect(fs.existsSync(filePath)).toBe(true);
+ expect(fs.readFileSync(filePath, 'utf8')).toBe(content);
+ });
+ });
+});
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
new file mode 100644
index 00000000..dc634cc8
--- /dev/null
+++ b/packages/core/src/tools/write-file.ts
@@ -0,0 +1,339 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import fs from 'fs';
+import path from 'path';
+import * as Diff from 'diff';
+import { Config, ApprovalMode } from '../config/config.js';
+import {
+ BaseTool,
+ ToolResult,
+ FileDiff,
+ ToolEditConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolCallConfirmationDetails,
+} from './tools.js';
+import { SchemaValidator } from '../utils/schemaValidator.js';
+import { makeRelative, shortenPath } from '../utils/paths.js';
+import { getErrorMessage, isNodeError } from '../utils/errors.js';
+import {
+ ensureCorrectEdit,
+ ensureCorrectFileContent,
+} from '../utils/editCorrector.js';
+import { GeminiClient } from '../core/client.js';
+import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js';
+
+/**
+ * Parameters for the WriteFile tool
+ */
+export interface WriteFileToolParams {
+ /**
+ * The absolute path to the file to write to
+ */
+ file_path: string;
+
+ /**
+ * The content to write to the file
+ */
+ content: string;
+}
+
+interface GetCorrectedFileContentResult {
+ originalContent: string;
+ correctedContent: string;
+ fileExists: boolean;
+ error?: { message: string; code?: string };
+}
+
+/**
+ * Implementation of the WriteFile tool logic
+ */
+export class WriteFileTool extends BaseTool<WriteFileToolParams, ToolResult> {
+ static readonly Name: string = 'write_file';
+ private readonly client: GeminiClient;
+
+ constructor(private readonly config: Config) {
+ super(
+ WriteFileTool.Name,
+ 'WriteFile',
+ 'Writes content to a specified file in the local filesystem.',
+ {
+ properties: {
+ file_path: {
+ description:
+ "The absolute path to the file to write to (e.g., '/home/user/project/file.txt'). Relative paths are not supported.",
+ type: 'string',
+ },
+ content: {
+ description: 'The content to write to the file.',
+ type: 'string',
+ },
+ },
+ required: ['file_path', 'content'],
+ type: 'object',
+ },
+ );
+
+ this.client = this.config.getGeminiClient();
+ }
+
+ private isWithinRoot(pathToCheck: string): boolean {
+ const normalizedPath = path.normalize(pathToCheck);
+ const normalizedRoot = path.normalize(this.config.getTargetDir());
+ const rootWithSep = normalizedRoot.endsWith(path.sep)
+ ? normalizedRoot
+ : normalizedRoot + path.sep;
+ return (
+ normalizedPath === normalizedRoot ||
+ normalizedPath.startsWith(rootWithSep)
+ );
+ }
+
+ validateToolParams(params: WriteFileToolParams): string | null {
+ if (
+ this.schema.parameters &&
+ !SchemaValidator.validate(
+ this.schema.parameters as Record<string, unknown>,
+ params,
+ )
+ ) {
+ return 'Parameters failed schema validation.';
+ }
+ const filePath = params.file_path;
+ if (!path.isAbsolute(filePath)) {
+ return `File path must be absolute: ${filePath}`;
+ }
+ if (!this.isWithinRoot(filePath)) {
+ return `File path must be within the root directory (${this.config.getTargetDir()}): ${filePath}`;
+ }
+
+ try {
+ // This check should be performed only if the path exists.
+ // If it doesn't exist, it's a new file, which is valid for writing.
+ if (fs.existsSync(filePath)) {
+ const stats = fs.lstatSync(filePath);
+ if (stats.isDirectory()) {
+ return `Path is a directory, not a file: ${filePath}`;
+ }
+ }
+ } catch (statError: unknown) {
+ // If fs.existsSync is true but lstatSync fails (e.g., permissions, race condition where file is deleted)
+ // this indicates an issue with accessing the path that should be reported.
+ return `Error accessing path properties for validation: ${filePath}. Reason: ${statError instanceof Error ? statError.message : String(statError)}`;
+ }
+
+ return null;
+ }
+
+ getDescription(params: WriteFileToolParams): string {
+ if (!params.file_path || !params.content) {
+ return `Model did not provide valid parameters for write file tool`;
+ }
+ const relativePath = makeRelative(
+ params.file_path,
+ this.config.getTargetDir(),
+ );
+ return `Writing to ${shortenPath(relativePath)}`;
+ }
+
+ /**
+ * Handles the confirmation prompt for the WriteFile tool.
+ */
+ async shouldConfirmExecute(
+ params: WriteFileToolParams,
+ abortSignal: AbortSignal,
+ ): Promise<ToolCallConfirmationDetails | false> {
+ if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
+ return false;
+ }
+
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ return false;
+ }
+
+ const correctedContentResult = await this._getCorrectedFileContent(
+ params.file_path,
+ params.content,
+ abortSignal,
+ );
+
+ if (correctedContentResult.error) {
+ // If file exists but couldn't be read, we can't show a diff for confirmation.
+ return false;
+ }
+
+ const { originalContent, correctedContent } = correctedContentResult;
+ const relativePath = makeRelative(
+ params.file_path,
+ this.config.getTargetDir(),
+ );
+ const fileName = path.basename(params.file_path);
+
+ const fileDiff = Diff.createPatch(
+ fileName,
+ originalContent, // Original content (empty if new file or unreadable)
+ correctedContent, // Content after potential correction
+ 'Current',
+ 'Proposed',
+ DEFAULT_DIFF_OPTIONS,
+ );
+
+ const confirmationDetails: ToolEditConfirmationDetails = {
+ type: 'edit',
+ title: `Confirm Write: ${shortenPath(relativePath)}`,
+ fileName,
+ fileDiff,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ if (outcome === ToolConfirmationOutcome.ProceedAlways) {
+ this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
+ }
+ },
+ };
+ return confirmationDetails;
+ }
+
+ async execute(
+ params: WriteFileToolParams,
+ abortSignal: AbortSignal,
+ ): Promise<ToolResult> {
+ const validationError = this.validateToolParams(params);
+ if (validationError) {
+ return {
+ llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
+ returnDisplay: `Error: ${validationError}`,
+ };
+ }
+
+ const correctedContentResult = await this._getCorrectedFileContent(
+ params.file_path,
+ params.content,
+ abortSignal,
+ );
+
+ if (correctedContentResult.error) {
+ const errDetails = correctedContentResult.error;
+ const errorMsg = `Error checking existing file: ${errDetails.message}`;
+ return {
+ llmContent: `Error checking existing file ${params.file_path}: ${errDetails.message}`,
+ returnDisplay: errorMsg,
+ };
+ }
+
+ const {
+ originalContent,
+ correctedContent: fileContent,
+ fileExists,
+ } = correctedContentResult;
+ // fileExists is true if the file existed (and was readable or unreadable but caught by readError).
+ // fileExists is false if the file did not exist (ENOENT).
+ const isNewFile =
+ !fileExists ||
+ (correctedContentResult.error !== undefined &&
+ !correctedContentResult.fileExists);
+
+ try {
+ const dirName = path.dirname(params.file_path);
+ if (!fs.existsSync(dirName)) {
+ fs.mkdirSync(dirName, { recursive: true });
+ }
+
+ fs.writeFileSync(params.file_path, fileContent, 'utf8');
+
+ // Generate diff for display result
+ const fileName = path.basename(params.file_path);
+ // If there was a readError, originalContent in correctedContentResult is '',
+ // but for the diff, we want to show the original content as it was before the write if possible.
+ // However, if it was unreadable, currentContentForDiff will be empty.
+ const currentContentForDiff = correctedContentResult.error
+ ? '' // Or some indicator of unreadable content
+ : originalContent;
+
+ const fileDiff = Diff.createPatch(
+ fileName,
+ currentContentForDiff,
+ fileContent,
+ 'Original',
+ 'Written',
+ DEFAULT_DIFF_OPTIONS,
+ );
+
+ const llmSuccessMessage = isNewFile
+ ? `Successfully created and wrote to new file: ${params.file_path}`
+ : `Successfully overwrote file: ${params.file_path}`;
+
+ const displayResult: FileDiff = { fileDiff, fileName };
+
+ return {
+ llmContent: llmSuccessMessage,
+ returnDisplay: displayResult,
+ };
+ } catch (error) {
+ const errorMsg = `Error writing to file: ${error instanceof Error ? error.message : String(error)}`;
+ return {
+ llmContent: `Error writing to file ${params.file_path}: ${errorMsg}`,
+ returnDisplay: `Error: ${errorMsg}`,
+ };
+ }
+ }
+
+ private async _getCorrectedFileContent(
+ filePath: string,
+ proposedContent: string,
+ abortSignal: AbortSignal,
+ ): Promise<GetCorrectedFileContentResult> {
+ let originalContent = '';
+ let fileExists = false;
+ let correctedContent = proposedContent;
+
+ try {
+ originalContent = fs.readFileSync(filePath, 'utf8');
+ fileExists = true; // File exists and was read
+ } catch (err) {
+ if (isNodeError(err) && err.code === 'ENOENT') {
+ fileExists = false;
+ originalContent = '';
+ } else {
+ // File exists but could not be read (permissions, etc.)
+ fileExists = true; // Mark as existing but problematic
+ originalContent = ''; // Can't use its content
+ const error = {
+ message: getErrorMessage(err),
+ code: isNodeError(err) ? err.code : undefined,
+ };
+ // Return early as we can't proceed with content correction meaningfully
+ return { originalContent, correctedContent, fileExists, error };
+ }
+ }
+
+ // If readError is set, we have returned.
+ // So, file was either read successfully (fileExists=true, originalContent set)
+ // or it was ENOENT (fileExists=false, originalContent='').
+
+ if (fileExists) {
+ // This implies originalContent is available
+ const { params: correctedParams } = await ensureCorrectEdit(
+ originalContent,
+ {
+ old_string: originalContent, // Treat entire current content as old_string
+ new_string: proposedContent,
+ file_path: filePath,
+ },
+ this.client,
+ abortSignal,
+ );
+ correctedContent = correctedParams.new_string;
+ } else {
+ // This implies new file (ENOENT)
+ correctedContent = await ensureCorrectFileContent(
+ proposedContent,
+ this.client,
+ abortSignal,
+ );
+ }
+ return { originalContent, correctedContent, fileExists };
+ }
+}