Revert "Add batch editing capabilities to Edit Tool (#648)" (#857)

This commit is contained in:
N. Taylor Mullen 2025-06-08 16:20:43 -07:00 committed by GitHub
parent 152af28a34
commit d62dad5575
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1544 additions and 3159 deletions

View File

@ -70,7 +70,7 @@ When you create a `.gemini/settings.json` file for project-specific settings, or
- **Default:** `false` (users will be prompted for most tool calls).
- **Behavior:**
- If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. An indicator may be shown in the UI when auto-accept is active.
- Potentially destructive or system-modifying tools (like `execute_bash_command` or `edit_file`) will likely still require confirmation regardless of this setting.
- Potentially destructive or system-modifying tools (like `execute_bash_command` or `write_file`) will likely still require confirmation regardless of this setting.
- **Example:** `"autoAccept": true`
- **`theme`** (string):

View File

@ -35,6 +35,7 @@ The core comes with a suite of pre-defined tools, typically found in `packages/c
- **File System Tools:**
- `LSTool` (`ls.ts`): Lists directory contents.
- `ReadFileTool` (`read-file.ts`): Reads the content of a single file.
- `WriteFileTool` (`write-file.ts`): Writes content to a file.
- `GrepTool` (`grep.ts`): Searches for patterns in files.
- `GlobTool` (`glob.ts`): Finds files matching glob patterns.
- `EditTool` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).

View File

@ -25,7 +25,7 @@ This documentation is organized into the following sections:
- **[Tools API](./core/tools-api.md):** Information on how the core manages and exposes tools.
- **Tools:**
- **[Tools Overview](./tools/index.md):** A general look at the available tools.
- **[File System Tools](./tools/file-system.md):** Documentation for tools like `read_file`, `edit_file`, etc.
- **[File System Tools](./tools/file-system.md):** Documentation for tools like `read_file`, `write_file`, etc.
- **[Shell Tool](./tools/shell.md):** Using the `execute_bash_command` tool.
- **[Web Fetch Tool](./tools/web-fetch.md):** Using the `web_fetch` tool.
- **[Web Search Tool](./tools/web-search.md):** Using the `google_web_search` tool.

View File

@ -53,7 +53,6 @@ All file system tools operate within a `rootDirectory` (usually the current work
- **Behavior:**
- Writes the provided `content` to the `file_path`.
- Creates parent directories if they don't exist.
- **Self-correction:** Before writing, the tool may use the Gemini model to correct the provided content to ensure it is valid and well-formed.
- **Output (`llmContent`):** A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` or `Successfully created and wrote to new file: /path/to/new/file.txt`.
- **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing.
@ -102,24 +101,33 @@ All file system tools operate within a `rootDirectory` (usually the current work
```
- **Confirmation:** No.
## 6. `edit_file` (EditFile)
## 6. `replace` (Edit)
- **Tool Name:** `edit_file`
- **Display Name:** EditFile
- **Tool Name:** `replace`
- **Display Name:** Edit
- **File:** `edit.ts`
- **Description:** Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when `expected_replacements` is specified. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location. It can also be used to create new files if `old_string` is empty and the `file_path` does not exist.
- **Description:** Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when `expected_replacements` is specified. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
- **Parameters:**
- `file_path` (string, required): The absolute path to the file to modify.
- `old_string` (string, required): The exact literal text to replace. **CRITICAL:** This string must uniquely identify the single instance to change. It should include at least 3 lines of context _before_ and _after_ the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content.
- `new_string` (string, required): The exact literal text to replace `old_string` with.
- `expected_replacements` (number, optional): The number of occurrences to replace. Defaults to 1.
- **Behavior:**
- **Modifying existing files**: Replaces exact text matches. File must exist unless the first edit has an empty `old_string` (indicating file creation).
- **Creating new files**: Use an empty `old_string` in the first edit to create a new file with `new_string` as the content.
- **Batch editing**: Applies multiple changes in sequence to the same file.
- **Enhanced Reliability**: Incorporates multi-stage edit correction to improve success rates when initial text matches aren't perfect.
- **Context Requirements**: Each `old_string` must uniquely identify the target location with sufficient context (typically 3+ lines before and after).
- **Output (`llmContent`):** Reports number of edits applied, attempted, and any failures with specific error details for troubleshooting.
- If `old_string` is empty and `file_path` does not exist, creates a new file with `new_string` as content.
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence of `old_string`.
- If one occurrence is found, it replaces it with `new_string`.
- **Enhanced Reliability (Multi-Stage Edit Correction):** To significantly improve the success rate of edits, especially when the model-provided `old_string` might not be perfectly precise, the tool incorporates a multi-stage edit correction mechanism.
- If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Gemini model to iteratively refine `old_string` (and potentially `new_string`).
- This self-correction process attempts to identify the unique segment the model intended to modify, making the `replace` operation more robust even with slightly imperfect initial context from the AI.
- **Failure Conditions:** Despite the correction mechanism, the tool will fail if:
- `file_path` is not absolute or is outside the root directory.
- `old_string` is not empty, but the `file_path` does not exist.
- `old_string` is empty, but the `file_path` already exists.
- `old_string` is not found in the file after attempts to correct it.
- `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
- **Output (`llmContent`):**
- On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.`
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit, expected 1 occurrences but found 2...`).
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file.
These file system tools provide a robust foundation for the Gemini CLI to understand and interact with your local project context.

View File

@ -30,7 +30,7 @@ You will typically see messages in the CLI indicating when a tool is being calle
## Security and Confirmation
Many tools, especially those that can modify your file system or execute commands (`edit_file`, `execute_bash_command`), are designed with safety in mind. The Gemini CLI will typically:
Many tools, especially those that can modify your file system or execute commands (`write_file`, `edit`, `execute_bash_command`), are designed with safety in mind. The Gemini CLI will typically:
- **Require Confirmation:** Prompt you before executing potentially sensitive operations, showing you what action is about to be taken.
- **Utilize Sandboxing:** All tools are subject to restrictions enforced by sandboxing (see [README](../../README.md#sandboxing)).

View File

@ -28,6 +28,7 @@ import {
ShellTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
} from '@gemini-cli/core';
export async function main() {
@ -155,13 +156,14 @@ async function loadNonInteractiveConfig(
GrepTool.Name,
GlobTool.Name,
EditTool.Name,
WriteFileTool.Name,
WebFetchTool.Name,
WebSearchTool.Name,
ReadManyFilesTool.Name,
ShellTool.Name,
MemoryTool.Name,
];
const interactiveTools = [ShellTool.Name, EditTool.Name];
const interactiveTools = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
const nonInteractiveTools = existingCoreTools.filter(
(tool) => !interactiveTools.includes(tool),
);

View File

@ -27,6 +27,7 @@ vi.mock('../tools/grep');
vi.mock('../tools/glob');
vi.mock('../tools/edit');
vi.mock('../tools/shell');
vi.mock('../tools/write-file');
vi.mock('../tools/web-fetch');
vi.mock('../tools/read-many-files');
vi.mock('../tools/memoryTool', () => ({

View File

@ -18,6 +18,7 @@ import { GrepTool } from '../tools/grep.js';
import { GlobTool } from '../tools/glob.js';
import { EditTool } from '../tools/edit.js';
import { ShellTool } from '../tools/shell.js';
import { WriteFileTool } from '../tools/write-file.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
@ -343,6 +344,7 @@ export function createToolRegistry(config: Config): Promise<ToolRegistry> {
registerCoreTool(GrepTool, targetDir);
registerCoreTool(GlobTool, targetDir, config);
registerCoreTool(EditTool, config);
registerCoreTool(WriteFileTool, config);
registerCoreTool(WebFetchTool, config);
registerCoreTool(ReadManyFilesTool, targetDir, config);
registerCoreTool(ShellTool, config);

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ import * as process from 'node:process';
// Mock tool names if they are dynamically generated or complex
vi.mock('../tools/ls', () => ({ LSTool: { Name: 'list_directory' } }));
vi.mock('../tools/edit', () => ({ EditTool: { Name: 'edit_file' } }));
vi.mock('../tools/edit', () => ({ EditTool: { Name: 'replace' } }));
vi.mock('../tools/glob', () => ({ GlobTool: { Name: 'glob' } }));
vi.mock('../tools/grep', () => ({ GrepTool: { Name: 'search_file_content' } }));
vi.mock('../tools/read-file', () => ({ ReadFileTool: { Name: 'read_file' } }));
@ -20,6 +20,9 @@ vi.mock('../tools/read-many-files', () => ({
vi.mock('../tools/shell', () => ({
ShellTool: { Name: 'execute_bash_command' },
}));
vi.mock('../tools/write-file', () => ({
WriteFileTool: { Name: 'write_file' },
}));
describe('Core System Prompt (prompts.ts)', () => {
// Store original env vars that we modify

View File

@ -13,7 +13,7 @@ import { GrepTool } from '../tools/grep.js';
import { ReadFileTool } from '../tools/read-file.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { ShellTool } from '../tools/shell.js';
import { WriteFileTool } from '../tools/write-file.js';
import process from 'node:process';
import { execSync } from 'node:child_process';
import { MemoryTool, GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
@ -50,183 +50,19 @@ You are an interactive CLI agent specializing in software engineering tasks. You
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
# Edit Tool Best Practices
## Batch Editing
- **Group Related Changes:** Use the edit tool's batch editing capability (via 'edits' array) when making multiple related changes to the same file. This is more efficient than individual edit calls.
- **Context for Each Edit:** Even in batch operations, each edit still requires significant context (3+ lines before/after) to ensure unique identification.
- **Partial Success Handling:** Batch edits may partially succeed. Review the 'editsApplied', 'editsFailed', and 'failedEdits' in results to understand what worked and retry failed edits with better context if needed.
## Examples
### Batch Editing for Multiple Related Changes
When making several related changes to the same file, use the 'edits' array:
{
"file_path": "/absolute/path/to/component.js",
"edits": [
{
"old_string": "const handleClick = () => {\n console.log('old handler');\n return false;\n}",
"new_string": "const handleClick = () => {\n console.log('updated handler');\n return true;\n}"
},
{
"old_string": "// TODO: implement validation\nconst isValid = false;",
"new_string": "// Validation implemented\nconst isValid = validateInput(input);"
}
]
}
### Context Requirements
Always provide sufficient context (3+ lines before/after target) to ensure unique identification:
{
"file_path": "/absolute/path/to/app.js",
"edits": [{
"old_string": " // Initialize app\n const app = express();\n app.use(middleware);\n \n // Start server\n app.listen(3000);",
"new_string": " // Initialize app\n const app = express();\n app.use(middleware);\n app.use(newMiddleware);\n \n // Start server\n app.listen(3000);"
}]
}
### API v2 Style Edit Examples
// Single edit operation
{
"file_path": "/absolute/path/to/file.txt",
"edits": [
{
"old_string": "text to be replaced",
"new_string": "new text"
}
]
}
// Multiple edit operations in a single call
{
"file_path": "/absolute/path/to/anotherFile.js",
"edits": [
{
"old_string": "const oldVariable = 123;",
"new_string": "const newVariable = 456;"
},
{
"old_string": "function oldFunction() {\n // ...\n}",
"new_string": "function newFunction() {\n // updated logic...\n}"
}
]
}
// Creating a new file using the edits array
{
"file_path": "/absolute/path/to/newly_created_file.md",
"edits": [
{
"old_string": "",
"new_string": "# New File Content\nThis is a new file."
}
]
}
// Multiple edit operations in a single call (alternative example)
{
"file_path": "/absolute/path/to/anotherFile.js",
"edits": [
{
"old_string": "const oldVariable = 123;",
"new_string": "const newVariable = 456;"
},
{
"old_string": "function oldFunction() {\n // ...\n}",
"new_string": "function newFunction() {\n // updated logic...\n}"
}
]
}
## JSON Escaping for Edit Operations
**CRITICAL:** When using the edit_file tool, strings in old_string and new_string must use standard JSON escaping - one level only. The content must match exactly what appears in the actual file.
### Escaping Rules
- **Newlines:** Use \\n (not \\\\n or \\\\\\\\n)
- **Double quotes:** Use \\" (not \\\\" or \\\\\\\\")
- **Single quotes:** Use ' as-is (not \\' unless the file actually contains \\')
- **Backslashes:** Use \\\\ (not \\\\\\\\)
- **Never double-escape or over-escape content**
- **Don't treat JSON examples within your edit operations as requiring additional escaping**
### Examples
#### CORRECT - Adding JSON content to a file
{
"file_path": "/path/to/config.js",
"edits": [
{
"old_string": "const config = {};\\n\\nmodule.exports = config;",
"new_string": "const config = {\\n "apiUrl": "https://api.example.com",\\n "timeout": 5000\\n};\\n\\nmodule.exports = config;"
}
]
}
#### INCORRECT - Over-escaped version (will fail to match)
{
"file_path": "/path/to/config.js",
"edits": [
{
"old_string": "const config = {};\\\\n\\\\nmodule.exports = config;",
"new_string": "const config = {\\\\n \\\\\\"apiUrl\\\\\\": \\\\\\"https://api.example.com\\\\\\",\\\\n \\\\\\"timeout\\\\\\": 5000\\\\n};\\\\n\\\\nmodule.exports = config;"
}
]
}
#### CORRECT - Adding a multi-line function
{
"file_path": "/path/to/utils.js",
"edits": [
{
"old_string": "// TODO: Add helper functions",
"new_string": "function formatDate(date) {\\n return date.toISOString().split('T')[0];\\n}\\n\\n// TODO: Add more helper functions"
}
]
}
#### INCORRECT - Over-escaped newlines (will fail to match)
{
"file_path": "/path/to/utils.js",
"edits": [
{
"old_string": "// TODO: Add helper functions",
"new_string": "function formatDate(date) {\\\\n return date.toISOString().split('T')[0];\\\\n}\\\\n\\\\n// TODO: Add more helper functions"
}
]
}
### Validation Check
**Before submitting an edit:** Verify that your old_string would literally match the text in the target file. If you're editing a file that contains JSON, your old_string should match the JSON as it appears in the file, not as a JSON-escaped version.
### When to Use Different Approaches
- **Single targeted change:** Use single edit with old_string/new_string
- **Multiple related changes:** Use batch edits array for efficiency and consistency
- **File creation:** Use create mode with content parameter
- **Complete file replacement:** Use overwrite mode when rewriting substantial portions
- **Adding to existing file:** Use edit mode with empty old_string only for simple appends
## Error Recovery
- **Failed Edits:** When edits fail, examine the specific error messages in 'failedEdits' array. Common issues: insufficient context, multiple matches, or missing target text.
- **Retry Strategy:** For failed batch edits, retry individually with more specific context rather than re-running the entire batch.
- **Context Improvement:** Add more surrounding lines or unique identifiers to old_string when facing multiple matches.
# Primary Workflows
## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
1. **Understand:** Think about the user's request and the relevant codebase context. Use '${GrepTool.Name}' and '${GlobTool.Name}' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use '${ReadFileTool.Name}' and '${ReadManyFilesTool.Name}' to understand context and validate any assumptions you may have.
2. **Plan:** Build a coherent and grounded (based off of the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
3. **Implement:** Use the available tools (e.g., '${EditTool.Name}', '${ShellTool.Name}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). For file modifications, prefer batch edits when making multiple related changes to the same file. Use appropriate edit modes: 'create' for new files, 'edit' for modifications, 'overwrite' for complete replacements.
3. **Implement:** Use the available tools (e.g., '${EditTool.Name}', '${WriteFileTool.Name}' '${ShellTool.Name}' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${EditTool.Name}' and '${ShellTool.Name}'.
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${WriteFileTool.Name}', '${EditTool.Name}' and '${ShellTool.Name}'.
1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2d or 3d game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions.
2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner.
@ -239,7 +75,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
- **3d Games:** HTML/CSS/JavaScript with Three.js.
- **2d Games:** HTML/CSS/JavaScript.
3. **User Approval:** Obtain user approval for the proposed plan.
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using '${ShellTool.Name}' for commands like 'npm init', 'npx create-react-app'. Use 'create' mode when generating new files and batch editing for related changes within files. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using '${ShellTool.Name}' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors.
6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype.
@ -263,10 +99,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
- **Command Execution:** Use the '${ShellTool.Name}' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user.
- **Enhanced Editing:** The '${EditTool.Name}' tool supports both single edits and batch operations. Use batch editing ('edits' array) for multiple related changes to the same file. Each edit in a batch still requires significant context for precise targeting. Choose appropriate modes: 'create' for new files, 'edit' for modifications, 'overwrite' for complete file replacement. Handle partial success - review 'editsApplied', 'editsFailed', and 'failedEdits' results and retry failed edits with improved context when needed.
- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until cancelled by the user.
- **Remembering Facts:** Use the '${MemoryTool.Name}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific 'GEMINI.md' files. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Remembering Facts:** Use the '${MemoryTool.Name}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information that belongs in project-specific \`GEMINI.md\` files. If unsure whether to save something, you can ask the user, "Should I remember that for you?"
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
## Interaction Details
@ -305,15 +138,15 @@ ${(function () {
# Git Repository
- The current working (project) directory is being managed by a git repository.
- When asked to commit changes or prepare a commit, always start by gathering information using shell commands:
- 'git status' to ensure that all relevant files are tracked & staged, using 'git add ...' as needed.
- 'git diff HEAD' to review all changes (including unstaged changes) to tracked files in work tree since last commit.
- 'git diff --staged' to review only staged changes when a partial commit makes sense or was requested by user.
- 'git log -n 3' to review recent commit messages and match their style (verbosity, formatting, signature line, etc.)
- Combine shell commands whenever possible to save time/steps, e.g. 'git status && git diff HEAD && git log -n 3'.
- \`git status\` to ensure that all relevant files are tracked & staged, using \`git add ...\` as needed.
- \`git diff HEAD\` to review all changes (including unstaged changes) to tracked files in work tree since last commit.
- \`git diff --staged\` to review only staged changes when a partial commit makes sense or was requested by user.
- \`git log -n 3\` to review recent commit messages and match their style (verbosity, formatting, signature line, etc.)
- Combine shell commands whenever possible to save time/steps, e.g. \`git status && git diff HEAD && git log -n 3\`.
- Always propose a draft commit message. Never just ask the user to give you the full commit message.
- Prefer commit messages that are clear, concise, and focused more on "why" and less on "what".
- Keep the user informed and ask for clarification or confirmation where needed.
- After each commit, confirm that it was successful by running 'git status'.
- After each commit, confirm that it was successful by running \`git status\`.
- If a commit fails, never attempt to work around the issues without being asked to do so.
- Never push changes to a remote repository without being asked explicitly by the user.
`;
@ -327,11 +160,21 @@ user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: list files here.
model: [tool_call: ${LSTool.Name} for path '.']
</example>
<example>
user: start the server implemented in server.js
model: [tool_call: ${ShellTool.Name} for 'node server.js &' because it must run in the background]
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the 'requests' library.
model:
@ -340,27 +183,27 @@ Okay, I see src/auth.py currently uses 'urllib'. Before changing it, I need to c
[tool_call: ${ReadFileTool.Name} 'requirements.txt']
(After confirming dependency)
Okay, 'requests' is available. Let me double check how it's used across the codebase:
[tool_call: ${GrepTool.Name} 'requests.(get|post)']
[tool_call: ${GrepTool.Name} 'requests\\.(get|post)']
I will now refactor src/auth.py.
[tool_call: Uses ${EditTool.Name}]
[tool_call: Uses ${EditTool.Name} or ${WriteFileTool.Name} edit tools following conventions]
(After editing)
[tool_call: Runs project-specific lint/typecheck commands found previously, e.g., ${ShellTool.Name} for 'npm run build', 'ruff', 'check', 'src/auth.py']
</example>
<example>
user: Delete the temp directory.
model: I can run 'rm -rf ./temp'. This will permanently delete the directory and all its contents.
model: I can run \`rm -rf ./temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read 'someFile.ts' to understand its functionality.
[tool_call: ${ReadFileTool.Name} to read /path/to/someFile.ts or use ${GlobTool.Name} to find 'someFile.ts' if its location is unknown]
Okay, I can write those tests. First, I'll read \`someFile.ts\` to understand its functionality.
[tool_call: ${ReadFileTool.Name} to read /path/to/someFile.ts or use ${GlobTool.Name} to find \`someFile.ts\` if its location is unknown]
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
[tool_call: ${ReadManyFilesTool.Name} for paths ['**/*.test.ts', 'src/**/*.spec.ts'] assuming someFile.ts is in the src directory]
(After reviewing existing tests and the file content)
[tool_call: ${EditTool.Name} to create /path/to/someFile.test.ts with the test code]
[tool_call: ${WriteFileTool.Name} to create /path/to/someFile.test.ts with the test code]
I've written the tests. Now I'll run the project's test command to verify them.
[tool_call: ${ShellTool.Name} for 'npm run test']
</example>
@ -371,10 +214,10 @@ model:
I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
[tool_call: ${GrepTool.Name} for pattern 'UserProfile|updateProfile|editUser']
(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
Okay, '/path/to/UserProfileService.java' seems like the most relevant file. I'll read its content to understand how updates are handled.
Okay, \`/path/to/UserProfileService.java\` seems like the most relevant file. I'll read its content to understand how updates are handled.
[tool_call: ${ReadFileTool.Name} to read /path/to/UserProfileService.java]
(After reading the file)
It appears the 'updateUserProfile' method in 'UserProfileService.java' is responsible for this. It expects a user ID and a 'UserProfileDTO' object...
It appears the \`updateUserProfile\` method in \`UserProfileService.java\` is responsible for this. It expects a user ID and a \`UserProfileDTO\` object...
</example>
<example>
@ -388,17 +231,6 @@ I found the following 'app.config' files:
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
<example>
user: Create a new config file for the database settings.
model:
[tool_call: ${EditTool.Name} with mode 'create' to create config/database.yml]
</example>
<example>
user: Delete the temp directory.
model: I can run 'rm -rf ./temp'. This will permanently delete the directory and all its contents.
</example>
# Final Reminder
Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions on the contents of files; instead use '${ReadFileTool.Name}' or '${ReadManyFilesTool.Name}' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
`.trim();

View File

@ -40,7 +40,7 @@ export * from './tools/ls.js';
export * from './tools/grep.js';
export * from './tools/glob.js';
export * from './tools/edit.js';
export * from './tools/write-file.js';
export * from './tools/web-fetch.js';
export * from './tools/memoryTool.js';
export * from './tools/shell.js';

View File

@ -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(
params,
new AbortController().signal,
// 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,
};
},
);
// Should return false because edit will fail (no exact match)
expect(confirmation).toBe(false);
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}`);
// 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');

View File

@ -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:
'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, 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:
'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 result = {
newContent: currentContent || '',
editsApplied: 0,
editsAttempted: params.edits.length,
editsFailed: 0,
failedEdits: [] as FailedEdit[],
const newContent = this._applyReplacement(
currentContent,
finalOldString,
finalNewString,
isNewFile,
);
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,
);
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;
}
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 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 {
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}`,
};
}
if (editData.error) {
return {
llmContent: editData.error.raw,
returnDisplay: `Error: ${editData.error.display}`,
};
}
try {
const editResult = await this.applyMultipleEdits(params, abortSignal);
// Apply the changes to the file
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');

View File

@ -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) {

View File

@ -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);
});
});
});

View File

@ -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 };
}
}

View File

@ -214,9 +214,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{ old_string: 'find me', new_string: 'replace with \\"this\\"' },
],
old_string: 'find me',
new_string: 'replace with \\"this\\"',
};
@ -238,7 +235,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [{ old_string: 'find me', new_string: 'replace with this' }],
old_string: 'find me',
new_string: 'replace with this',
};
@ -257,9 +253,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find\\me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{ old_string: 'find\\me', new_string: 'replace with \\"this\\"' },
],
old_string: 'find\\me',
new_string: 'replace with \\"this\\"',
};
@ -281,7 +274,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find\\me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [{ old_string: 'find\\me', new_string: 'replace with this' }],
old_string: 'find\\me',
new_string: 'replace with this',
};
@ -303,12 +295,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find "me".';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{
old_string: 'find \\"me\\"',
new_string: 'replace with \\"this\\"',
},
],
old_string: 'find \\"me\\"',
new_string: 'replace with \\"this\\"',
};
@ -328,9 +314,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find "me".';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{ old_string: 'find \\"me\\"', new_string: 'replace with this' },
],
old_string: 'find \\"me\\"',
new_string: 'replace with this',
};
@ -349,9 +332,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to find \\me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{ old_string: 'find \\\\me', new_string: 'replace with foobar' },
],
old_string: 'find \\\\me',
new_string: 'replace with foobar',
};
@ -376,12 +356,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to corrected find me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{
old_string: 'find me',
new_string: 'replace with \\\\"this\\\\"',
},
],
old_string: 'find me',
new_string: 'replace with \\\\"this\\\\"',
};
@ -402,12 +376,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to corrected find me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{
old_string: 'find\\me',
new_string: 'replace with \\\\"this\\\\"',
},
],
old_string: 'find\\me',
new_string: 'replace with \\\\"this\\\\"',
};
@ -430,9 +398,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to be corrected.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{ old_string: 'fiiind me', new_string: 'replace with "this"' },
],
old_string: 'fiiind me',
new_string: 'replace with "this"',
};
@ -453,12 +418,6 @@ describe('editCorrector', () => {
const currentContent = 'This is a test string to corrected find me.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{
old_string: 'find me',
new_string: 'replace with \\\\"this\\\\"',
},
],
old_string: 'find me',
new_string: 'replace with \\\\"this\\\\"',
};
@ -483,9 +442,6 @@ describe('editCorrector', () => {
const currentContent = 'This content has nothing to find.';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{ old_string: 'nonexistent string', new_string: 'some new string' },
],
old_string: 'nonexistent string',
new_string: 'some new string',
};
@ -497,11 +453,7 @@ describe('editCorrector', () => {
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params).toEqual({
file_path: originalParams.file_path,
old_string: originalParams.old_string,
new_string: originalParams.new_string,
});
expect(result.params).toEqual(originalParams);
expect(result.occurrences).toBe(0);
});
it('Test 4.2: unescapedOldStringAttempt results in >1 occurrences -> returns original params, count occurrences', async () => {
@ -509,7 +461,6 @@ describe('editCorrector', () => {
'This content has find "me" and also find "me" again.';
const originalParams = {
file_path: '/test/file.txt',
edits: [{ old_string: 'find "me"', new_string: 'some new string' }],
old_string: 'find "me"',
new_string: 'some new string',
};
@ -520,11 +471,7 @@ describe('editCorrector', () => {
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(0);
expect(result.params).toEqual({
file_path: originalParams.file_path,
old_string: originalParams.old_string,
new_string: originalParams.new_string,
});
expect(result.params).toEqual(originalParams);
expect(result.occurrences).toBe(2);
});
});
@ -534,12 +481,6 @@ describe('editCorrector', () => {
const currentContent = 'const x = "a\\nbc\\\\"def\\\\"';
const originalParams = {
file_path: '/test/file.txt',
edits: [
{
old_string: 'const x = \\\\"a\\\\nbc\\\\\\\\"def\\\\\\\\"',
new_string: 'const y = \\\\"new\\\\nval\\\\\\\\"content\\\\\\\\"',
},
],
old_string: 'const x = \\\\"a\\\\nbc\\\\\\\\"def\\\\\\\\"',
new_string: 'const y = \\\\"new\\\\nval\\\\\\\\"content\\\\\\\\"',
};

View File

@ -1,503 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest';
// MOCKS
let callCount = 0;
const mockResponses: any[] = [];
let mockGenerateJson: any;
let mockStartChat: any;
let mockSendMessageStream: any;
vi.mock('../core/client.js', () => ({
GeminiClient: vi.fn().mockImplementation(function (
this: any,
_config: Config,
) {
this.generateJson = (...params: any[]) => mockGenerateJson(...params); // Corrected: use mockGenerateJson
this.startChat = (...params: any[]) => mockStartChat(...params); // Corrected: use mockStartChat
this.sendMessageStream = (...params: any[]) =>
mockSendMessageStream(...params); // Corrected: use mockSendMessageStream
return this;
}),
}));
// END MOCKS
import {
countOccurrences,
ensureCorrectEdit,
unescapeStringForGeminiBug,
resetEditCorrectorCaches_TEST_ONLY,
} from './editCorrector.js';
import { GeminiClient } from '../core/client.js';
import type { Config } from '../config/config.js';
import { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('../tools/tool-registry.js');
describe('editCorrector', () => {
describe('countOccurrences', () => {
it('should return 0 for empty string', () => {
expect(countOccurrences('', 'a')).toBe(0);
});
it('should return 0 for empty substring', () => {
expect(countOccurrences('abc', '')).toBe(0);
});
it('should return 0 if substring is not found', () => {
expect(countOccurrences('abc', 'd')).toBe(0);
});
it('should return 1 if substring is found once', () => {
expect(countOccurrences('abc', 'b')).toBe(1);
});
it('should return correct count for multiple occurrences', () => {
expect(countOccurrences('ababa', 'a')).toBe(3);
expect(countOccurrences('ababab', 'ab')).toBe(3);
});
it('should count non-overlapping occurrences', () => {
expect(countOccurrences('aaaaa', 'aa')).toBe(2);
expect(countOccurrences('ababab', 'aba')).toBe(1);
});
it('should correctly count occurrences when substring is longer', () => {
expect(countOccurrences('abc', 'abcdef')).toBe(0);
});
it('should be case sensitive', () => {
expect(countOccurrences('abcABC', 'a')).toBe(1);
expect(countOccurrences('abcABC', 'A')).toBe(1);
});
});
describe('unescapeStringForGeminiBug', () => {
it('should unescape common sequences', () => {
expect(unescapeStringForGeminiBug('\\n')).toBe('\n');
expect(unescapeStringForGeminiBug('\\t')).toBe('\t');
expect(unescapeStringForGeminiBug("\\'")).toBe("'");
expect(unescapeStringForGeminiBug('\\"')).toBe('"');
expect(unescapeStringForGeminiBug('\\`')).toBe('`');
});
it('should handle multiple escaped sequences', () => {
expect(unescapeStringForGeminiBug('Hello\\nWorld\\tTest')).toBe(
'Hello\nWorld\tTest',
);
});
it('should not alter already correct sequences', () => {
expect(unescapeStringForGeminiBug('\n')).toBe('\n');
expect(unescapeStringForGeminiBug('Correct string')).toBe(
'Correct string',
);
});
it('should handle mixed correct and incorrect sequences', () => {
expect(unescapeStringForGeminiBug('\\nCorrect\t\\`')).toBe(
'\nCorrect\t`',
);
});
it('should handle backslash followed by actual newline character', () => {
expect(unescapeStringForGeminiBug('\\\n')).toBe('\n');
expect(unescapeStringForGeminiBug('First line\\\nSecond line')).toBe(
'First line\nSecond line',
);
});
it('should handle multiple backslashes before an escapable character', () => {
expect(unescapeStringForGeminiBug('\\\\n')).toBe('\n');
expect(unescapeStringForGeminiBug('\\\\\\t')).toBe('\t');
expect(unescapeStringForGeminiBug('\\\\\\\\`')).toBe('`');
});
it('should return empty string for empty input', () => {
expect(unescapeStringForGeminiBug('')).toBe('');
});
it('should not alter strings with no targeted escape sequences', () => {
expect(unescapeStringForGeminiBug('abc def')).toBe('abc def');
expect(unescapeStringForGeminiBug('C:\\Folder\\File')).toBe(
'C:\\Folder\\File',
);
});
it('should correctly process strings with some targeted escapes', () => {
expect(unescapeStringForGeminiBug('C:\\Users\\name')).toBe(
'C:\\Users\name',
);
});
it('should handle complex cases with mixed slashes and characters', () => {
expect(
unescapeStringForGeminiBug('\\\\\\\nLine1\\\nLine2\\tTab\\\\`Tick\\"'),
).toBe('\nLine1\nLine2\tTab`Tick"');
});
});
describe('ensureCorrectEdit', () => {
let mockGeminiClientInstance: Mocked<GeminiClient>;
let mockToolRegistry: Mocked<ToolRegistry>;
let mockConfigInstance: Config;
const abortSignal = new AbortController().signal;
beforeEach(() => {
mockToolRegistry = new ToolRegistry({} as Config) as Mocked<ToolRegistry>;
const configParams = {
apiKey: 'test-api-key',
model: 'test-model',
sandbox: false as boolean | string,
targetDir: '/test',
debugMode: false,
question: undefined as string | undefined,
fullContext: false,
coreTools: undefined as string[] | undefined,
toolDiscoveryCommand: undefined as string | undefined,
toolCallCommand: undefined as string | undefined,
mcpServerCommand: undefined as string | undefined,
mcpServers: undefined as Record<string, any> | undefined,
userAgent: 'test-agent',
userMemory: '',
geminiMdFileCount: 0,
alwaysSkipModificationConfirmation: false,
};
mockConfigInstance = {
...configParams,
getApiKey: vi.fn(() => configParams.apiKey),
getModel: vi.fn(() => configParams.model),
getSandbox: vi.fn(() => configParams.sandbox),
getTargetDir: vi.fn(() => configParams.targetDir),
getToolRegistry: vi.fn(() => mockToolRegistry),
getDebugMode: vi.fn(() => configParams.debugMode),
getQuestion: vi.fn(() => configParams.question),
getFullContext: vi.fn(() => configParams.fullContext),
getCoreTools: vi.fn(() => configParams.coreTools),
getToolDiscoveryCommand: vi.fn(() => configParams.toolDiscoveryCommand),
getToolCallCommand: vi.fn(() => configParams.toolCallCommand),
getMcpServerCommand: vi.fn(() => configParams.mcpServerCommand),
getMcpServers: vi.fn(() => configParams.mcpServers),
getUserAgent: vi.fn(() => configParams.userAgent),
getUserMemory: vi.fn(() => configParams.userMemory),
setUserMemory: vi.fn((mem: string) => {
configParams.userMemory = mem;
}),
getGeminiMdFileCount: vi.fn(() => configParams.geminiMdFileCount),
setGeminiMdFileCount: vi.fn((count: number) => {
configParams.geminiMdFileCount = count;
}),
getAlwaysSkipModificationConfirmation: vi.fn(
() => configParams.alwaysSkipModificationConfirmation,
),
setAlwaysSkipModificationConfirmation: vi.fn((skip: boolean) => {
configParams.alwaysSkipModificationConfirmation = skip;
}),
} as unknown as Config;
callCount = 0;
mockResponses.length = 0;
mockGenerateJson = vi
.fn()
.mockImplementation((_contents, _schema, signal) => {
// Check if the signal is aborted. If so, throw an error or return a specific response.
if (signal && signal.aborted) {
return Promise.reject(new Error('Aborted')); // Or some other specific error/response
}
const response = mockResponses[callCount];
callCount++;
if (response === undefined) return Promise.resolve({});
return Promise.resolve(response);
});
mockStartChat = vi.fn();
mockSendMessageStream = vi.fn();
mockGeminiClientInstance = new GeminiClient(
mockConfigInstance,
) as Mocked<GeminiClient>;
resetEditCorrectorCaches_TEST_ONLY();
});
describe('Scenario Group 1: originalParams.old_string matches currentContent directly', () => {
it('Test 1.1: old_string (no literal \\), new_string (escaped by Gemini) -> new_string unescaped', async () => {
const currentContent = 'This is a test string to find me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find me',
new_string: 'replace with \\"this\\"',
};
mockResponses.push({
corrected_new_string_escaping: 'replace with "this"',
});
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe('replace with "this"');
expect(result.params.old_string).toBe('find me');
expect(result.occurrences).toBe(1);
});
it('Test 1.2: old_string (no literal \\), new_string (correctly formatted) -> new_string unchanged', async () => {
const currentContent = 'This is a test string to find me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find me',
new_string: 'replace with this',
};
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(0);
expect(result.params.new_string).toBe('replace with this');
expect(result.params.old_string).toBe('find me');
expect(result.occurrences).toBe(1);
});
it('Test 1.3: old_string (with literal \\), new_string (escaped by Gemini) -> new_string unchanged (still escaped)', async () => {
const currentContent = 'This is a test string to find\\me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find\\me',
new_string: 'replace with \\"this\\"',
};
mockResponses.push({
corrected_new_string_escaping: 'replace with "this"',
});
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe('replace with "this"');
expect(result.params.old_string).toBe('find\\me');
expect(result.occurrences).toBe(1);
});
it('Test 1.4: old_string (with literal \\), new_string (correctly formatted) -> new_string unchanged', async () => {
const currentContent = 'This is a test string to find\\me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find\\me',
new_string: 'replace with this',
};
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(0);
expect(result.params.new_string).toBe('replace with this');
expect(result.params.old_string).toBe('find\\me');
expect(result.occurrences).toBe(1);
});
});
describe('Scenario Group 2: originalParams.old_string does NOT match, but unescapeStringForGeminiBug(originalParams.old_string) DOES match', () => {
it('Test 2.1: old_string (over-escaped, no intended literal \\), new_string (escaped by Gemini) -> new_string unescaped', async () => {
const currentContent = 'This is a test string to find "me".';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find \\"me\\"',
new_string: 'replace with \\"this\\"',
};
mockResponses.push({ corrected_new_string: 'replace with "this"' });
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe('replace with "this"');
expect(result.params.old_string).toBe('find "me"');
expect(result.occurrences).toBe(1);
});
it('Test 2.2: old_string (over-escaped, no intended literal \\), new_string (correctly formatted) -> new_string unescaped (harmlessly)', async () => {
const currentContent = 'This is a test string to find "me".';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find \\"me\\"',
new_string: 'replace with this',
};
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(0);
expect(result.params.new_string).toBe('replace with this');
expect(result.params.old_string).toBe('find "me"');
expect(result.occurrences).toBe(1);
});
it('Test 2.3: old_string (over-escaped, with intended literal \\), new_string (simple) -> new_string corrected', async () => {
const currentContent = 'This is a test string to find \\me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find \\\\me',
new_string: 'replace with foobar',
};
mockResponses.push({
corrected_target_snippet: 'find \\me',
});
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe('replace with foobar');
expect(result.params.old_string).toBe('find \\me');
expect(result.occurrences).toBe(1);
});
});
describe('Scenario Group 3: LLM Correction Path', () => {
it('Test 3.1: old_string (no literal \\), new_string (escaped by Gemini), LLM re-escapes new_string -> final new_string is double unescaped', async () => {
const currentContent = 'This is a test string to corrected find me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find me',
new_string: 'replace with \\\\"this\\\\"',
};
const llmNewString = 'LLM says replace with "that"';
mockResponses.push({ corrected_new_string_escaping: llmNewString });
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe(llmNewString);
expect(result.params.old_string).toBe('find me');
expect(result.occurrences).toBe(1);
});
it('Test 3.2: old_string (with literal \\), new_string (escaped by Gemini), LLM re-escapes new_string -> final new_string is unescaped once', async () => {
const currentContent = 'This is a test string to corrected find me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find\\me',
new_string: 'replace with \\\\"this\\\\"',
};
const llmCorrectedOldString = 'corrected find me';
const llmNewString = 'LLM says replace with "that"';
mockResponses.push({ corrected_target_snippet: llmCorrectedOldString });
mockResponses.push({ corrected_new_string: llmNewString });
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(2);
expect(result.params.new_string).toBe(llmNewString);
expect(result.params.old_string).toBe(llmCorrectedOldString);
expect(result.occurrences).toBe(1);
});
it('Test 3.3: old_string needs LLM, new_string is fine -> old_string corrected, new_string original', async () => {
const currentContent = 'This is a test string to be corrected.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'fiiind me',
new_string: 'replace with "this"',
};
const llmCorrectedOldString = 'to be corrected';
mockResponses.push({ corrected_target_snippet: llmCorrectedOldString });
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe('replace with "this"');
expect(result.params.old_string).toBe(llmCorrectedOldString);
expect(result.occurrences).toBe(1);
});
it('Test 3.4: LLM correction path, correctNewString returns the originalNewString it was passed (which was unescaped) -> final new_string is unescaped', async () => {
const currentContent = 'This is a test string to corrected find me.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find me',
new_string: 'replace with \\\\"this\\\\"',
};
const newStringForLLMAndReturnedByLLM = 'replace with "this"';
mockResponses.push({
corrected_new_string_escaping: newStringForLLMAndReturnedByLLM,
});
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params.new_string).toBe(newStringForLLMAndReturnedByLLM);
expect(result.occurrences).toBe(1);
});
});
describe('Scenario Group 4: No Match Found / Multiple Matches', () => {
it('Test 4.1: No version of old_string (original, unescaped, LLM-corrected) matches -> returns original params, 0 occurrences', async () => {
const currentContent = 'This content has nothing to find.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'nonexistent string',
new_string: 'some new string',
};
mockResponses.push({ corrected_target_snippet: 'still nonexistent' });
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(1);
expect(result.params).toEqual(originalParams);
expect(result.occurrences).toBe(0);
});
it('Test 4.2: unescapedOldStringAttempt results in >1 occurrences -> returns original params, count occurrences', async () => {
const currentContent =
'This content has find "me" and also find "me" again.';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'find "me"',
new_string: 'some new string',
};
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(0);
expect(result.params).toEqual(originalParams);
expect(result.occurrences).toBe(2);
});
});
describe('Scenario Group 5: Specific unescapeStringForGeminiBug checks (integrated into ensureCorrectEdit)', () => {
it('Test 5.1: old_string needs LLM to become currentContent, new_string also needs correction', async () => {
const currentContent = 'const x = "a\\nbc\\\\"def\\\\"';
const originalParams = {
file_path: '/test/file.txt',
old_string: 'const x = \\\\"a\\\\nbc\\\\\\\\"def\\\\\\\\"',
new_string: 'const y = \\\\"new\\\\nval\\\\\\\\"content\\\\\\\\"',
};
const expectedFinalNewString = 'const y = "new\\nval\\\\"content\\\\"';
mockResponses.push({ corrected_target_snippet: currentContent });
mockResponses.push({ corrected_new_string: expectedFinalNewString });
const result = await ensureCorrectEdit(
currentContent,
originalParams,
mockGeminiClientInstance,
abortSignal,
);
expect(mockGenerateJson).toHaveBeenCalledTimes(2);
expect(result.params.old_string).toBe(currentContent);
expect(result.params.new_string).toBe(expectedFinalNewString);
expect(result.occurrences).toBe(1);
});
});
});
});

View File

@ -61,16 +61,10 @@ export interface CorrectedEditResult {
*/
export async function ensureCorrectEdit(
currentContent: string,
originalParams: EditToolParams & { old_string: string; new_string: string },
originalParams: EditToolParams, // This is the EditToolParams from edit.ts, without \'corrected\'
client: GeminiClient,
abortSignal: AbortSignal,
): Promise<CorrectedEditResult> {
// Ensure we have required old_string and new_string
if (!originalParams.old_string || !originalParams.new_string) {
throw new Error(
'old_string and new_string are required for edit correction',
);
}
const cacheKey = `${currentContent}---${originalParams.old_string}---${originalParams.new_string}`;
const cachedResult = editCorrectionCache.get(cacheKey);
if (cachedResult) {
@ -102,11 +96,7 @@ export async function ensureCorrectEdit(
// If user expects multiple replacements, return as-is
if (occurrences === expectedReplacements) {
const result: CorrectedEditResult = {
params: {
file_path: originalParams.file_path,
old_string: originalParams.old_string!,
new_string: originalParams.new_string!,
},
params: { ...originalParams },
occurrences,
};
editCorrectionCache.set(cacheKey, result);
@ -116,11 +106,7 @@ export async function ensureCorrectEdit(
// If user expects 1 but found multiple, try to correct (existing behavior)
if (expectedReplacements === 1) {
const result: CorrectedEditResult = {
params: {
file_path: originalParams.file_path,
old_string: originalParams.old_string!,
new_string: originalParams.new_string!,
},
params: { ...originalParams },
occurrences,
};
editCorrectionCache.set(cacheKey, result);
@ -129,11 +115,7 @@ export async function ensureCorrectEdit(
// If occurrences don't match expected, return as-is (will fail validation later)
const result: CorrectedEditResult = {
params: {
file_path: originalParams.file_path,
old_string: finalOldString,
new_string: finalNewString,
},
params: { ...originalParams },
occurrences,
};
editCorrectionCache.set(cacheKey, result);
@ -187,11 +169,7 @@ export async function ensureCorrectEdit(
} else {
// LLM correction also failed for old_string
const result: CorrectedEditResult = {
params: {
file_path: originalParams.file_path,
old_string: finalOldString,
new_string: finalNewString,
},
params: { ...originalParams },
occurrences: 0, // Explicitly 0 as LLM failed
};
editCorrectionCache.set(cacheKey, result);
@ -200,11 +178,7 @@ export async function ensureCorrectEdit(
} else {
// Unescaping old_string resulted in > 1 occurrences
const result: CorrectedEditResult = {
params: {
file_path: originalParams.file_path,
old_string: finalOldString,
new_string: finalNewString,
},
params: { ...originalParams },
occurrences, // This will be > 1
};
editCorrectionCache.set(cacheKey, result);

View File

@ -1,239 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Single edit operation with position information
*/
export interface PositionedEdit {
/**
* Original edit operation
*/
original: EditOperation;
/**
* Index in the original edits array
*/
index: number;
/**
* Start position in the file content
*/
startPos: number;
/**
* End position in the file content
*/
endPos: number;
/**
* The replacement text
*/
newString: string;
}
/**
* Result of failed edit with reason
*/
export interface FailedEdit {
index: number;
edit: EditOperation;
reason: 'not_found' | 'multiple_matches' | 'empty_old_string';
}
/**
* Result of position-based edit processing
*/
export interface PositionBasedEditResult {
finalContent: string;
appliedEdits: PositionedEdit[];
failedEdits: FailedEdit[];
}
/**
* Edit operation interface (matches existing EditOperation)
*/
export interface EditOperation {
old_string: string;
new_string: string;
}
/**
* Efficient position-based edit processor that minimizes memory usage
* and applies edits in optimal order to prevent position conflicts.
*/
export class PositionBasedEditProcessor {
/**
* Process edits with minimal memory usage and optimal ordering
*/
processEdits(
content: string,
edits: EditOperation[],
): PositionBasedEditResult {
// Phase 1: Find positions for all edits
const { validEdits, failedEdits } = this.analyzeEdits(content, edits);
// Phase 2: Sort by position (highest first) to prevent position drift
const sortedEdits = validEdits.sort((a, b) => b.startPos - a.startPos);
// Phase 3: Build final content in single pass
const finalContent = this.buildFinalContent(content, sortedEdits);
return {
finalContent,
appliedEdits: sortedEdits,
failedEdits,
};
}
/**
* Analyze all edits and categorize into valid/failed
*/
private analyzeEdits(
content: string,
edits: EditOperation[],
): {
validEdits: PositionedEdit[];
failedEdits: FailedEdit[];
} {
const validEdits: PositionedEdit[] = [];
const failedEdits: FailedEdit[] = [];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
// Handle empty old_string (file creation case)
if (edit.old_string === '') {
failedEdits.push({
index: i,
edit,
reason: 'empty_old_string',
});
continue;
}
// Find all positions of old_string
const positions = this.findAllPositions(content, edit.old_string);
if (positions.length === 0) {
failedEdits.push({
index: i,
edit,
reason: 'not_found',
});
} else if (positions.length > 1) {
failedEdits.push({
index: i,
edit,
reason: 'multiple_matches',
});
} else {
// Exactly one match - valid edit
const startPos = positions[0];
validEdits.push({
original: edit,
index: i,
startPos,
endPos: startPos + edit.old_string.length,
newString: edit.new_string,
});
}
}
return { validEdits, failedEdits };
}
/**
* Find all positions where searchString occurs in content
*/
private findAllPositions(content: string, searchString: string): number[] {
const positions: number[] = [];
let index = content.indexOf(searchString);
while (index !== -1) {
positions.push(index);
index = content.indexOf(searchString, index + 1);
}
return positions;
}
/**
* Build final content in single pass with minimal memory allocation
*/
private buildFinalContent(original: string, edits: PositionedEdit[]): string {
if (edits.length === 0) {
return original;
}
// Check for overlapping edits (should not happen with our validation, but safety check)
if (this.hasOverlappingEdits(edits)) {
throw new Error('Overlapping edits detected - this should not happen');
}
// Build segments array working backwards through the string
const segments: string[] = [];
let currentPos = original.length;
// Process edits from end to beginning (edits are already sorted by startPos desc)
for (const edit of edits) {
// Add text after this edit position
if (currentPos > edit.endPos) {
segments.unshift(original.slice(edit.endPos, currentPos));
}
// Add the replacement text
segments.unshift(edit.newString);
// Update current position
currentPos = edit.startPos;
}
// Add remaining text from beginning
if (currentPos > 0) {
segments.unshift(original.slice(0, currentPos));
}
// Single join operation creates final string
return segments.join('');
}
/**
* Check if any edits overlap (safety validation)
*/
private hasOverlappingEdits(edits: PositionedEdit[]): boolean {
for (let i = 0; i < edits.length - 1; i++) {
for (let j = i + 1; j < edits.length; j++) {
const edit1 = edits[i];
const edit2 = edits[j];
// Check if ranges overlap
if (
!(edit1.endPos <= edit2.startPos || edit2.endPos <= edit1.startPos)
) {
return true;
}
}
}
return false;
}
/**
* Get human-readable error message for failed edit reason
*/
static getErrorMessage(reason: FailedEdit['reason']): string {
switch (reason) {
case 'not_found':
return 'Old string not found in current content';
case 'multiple_matches':
return 'Old string found multiple times - please be more specific';
case 'empty_old_string':
return 'Cannot use empty old_string on existing file';
default:
return 'Unknown error';
}
}
}