feat: Replace edit_file tool alias with edit_file tool (#9983)

This commit is contained in:
Hannes Rudolph 2025-12-18 12:48:30 -07:00 • committed by GitHub
parent 157a097032
commit 8251170b84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 993 additions and 8 deletions

View file

@ -16,6 +16,7 @@ export const geminiModels = {
supportsReasoningEffort: ["low", "high"],
reasoningEffort: "low",
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
supportsTemperature: true,
defaultTemperature: 1,
inputPrice: 4.0,
@ -43,6 +44,7 @@ export const geminiModels = {
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
supportsTemperature: true,
defaultTemperature: 1,
inputPrice: 0.3,
@ -59,6 +61,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
outputPrice: 15,
cacheReadsPrice: 0.625,
@ -89,6 +92,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
outputPrice: 15,
cacheReadsPrice: 0.625,
@ -118,6 +122,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
outputPrice: 15,
cacheReadsPrice: 0.625,
@ -145,6 +150,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5, // This is the pricing for prompts above 200k tokens.
outputPrice: 15,
cacheReadsPrice: 0.625,
@ -176,6 +182,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.3,
outputPrice: 2.5,
cacheReadsPrice: 0.075,
@ -191,6 +198,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.3,
outputPrice: 2.5,
cacheReadsPrice: 0.075,
@ -206,6 +214,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.3,
outputPrice: 2.5,
cacheReadsPrice: 0.075,
@ -223,6 +232,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,
@ -238,6 +248,7 @@ export const geminiModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,

View file

@ -16,6 +16,7 @@ export const vertexModels = {
supportsReasoningEffort: ["low", "high"],
reasoningEffort: "low",
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
supportsTemperature: true,
defaultTemperature: 1,
inputPrice: 4.0,
@ -43,6 +44,7 @@ export const vertexModels = {
supportsReasoningEffort: ["minimal", "low", "medium", "high"],
reasoningEffort: "medium",
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
supportsTemperature: true,
defaultTemperature: 1,
inputPrice: 0.3,
@ -58,6 +60,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.15,
outputPrice: 3.5,
maxThinkingTokens: 24_576,
@ -72,6 +75,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.15,
outputPrice: 0.6,
},
@ -83,6 +87,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.3,
outputPrice: 2.5,
cacheReadsPrice: 0.075,
@ -98,6 +103,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.15,
outputPrice: 3.5,
maxThinkingTokens: 24_576,
@ -112,6 +118,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.15,
outputPrice: 0.6,
},
@ -123,6 +130,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5,
outputPrice: 15,
},
@ -134,6 +142,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5,
outputPrice: 15,
},
@ -145,6 +154,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5,
outputPrice: 15,
maxThinkingTokens: 32_768,
@ -158,6 +168,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 2.5,
outputPrice: 15,
maxThinkingTokens: 32_768,
@ -186,6 +197,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0,
outputPrice: 0,
},
@ -197,6 +209,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0,
outputPrice: 0,
},
@ -208,6 +221,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.15,
outputPrice: 0.6,
},
@ -219,6 +233,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.075,
outputPrice: 0.3,
},
@ -230,6 +245,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0,
outputPrice: 0,
},
@ -241,6 +257,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.075,
outputPrice: 0.3,
},
@ -252,6 +269,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: false,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 1.25,
outputPrice: 5,
},
@ -400,6 +418,7 @@ export const vertexModels = {
defaultToolProtocol: "native",
supportsPromptCache: true,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,

View file

@ -21,6 +21,7 @@ export const toolNames = [
"apply_diff",
"search_and_replace",
"search_replace",
"edit_file",
"apply_patch",
"search_files",
"list_files",

View file

@ -32,6 +32,7 @@ export function applyRouterToolPreferences(modelId: string, info: ModelInfo): Mo
if (modelId.includes("gemini")) {
result = {
...result,
excludedTools: [...new Set([...(result.excludedTools || []), "apply_diff"])],
includedTools: [...new Set([...(result.includedTools || []), "write_file", "edit_file"])],
}
}

View file

@ -525,6 +525,21 @@ export class NativeToolCallParser {
}
break
case "edit_file":
if (
partialArgs.file_path !== undefined ||
partialArgs.old_string !== undefined ||
partialArgs.new_string !== undefined
) {
nativeArgs = {
file_path: partialArgs.file_path,
old_string: partialArgs.old_string,
new_string: partialArgs.new_string,
expected_replacements: partialArgs.expected_replacements,
}
}
break
default:
break
}
@ -562,7 +577,7 @@ export class NativeToolCallParser {
return this.parseDynamicMcpTool(toolCall)
}
// Resolve tool alias to canonical name (e.g., "edit_file" -> "apply_diff", "temp_edit_file" -> "search_and_replace")
// Resolve tool alias to canonical name
const resolvedName = resolveToolAlias(toolCall.name as string) as TName
// Validate tool name (after alias resolution)
@ -785,6 +800,21 @@ export class NativeToolCallParser {
}
break
case "edit_file":
if (
args.file_path !== undefined &&
args.old_string !== undefined &&
args.new_string !== undefined
) {
nativeArgs = {
file_path: args.file_path,
old_string: args.old_string,
new_string: args.new_string,
expected_replacements: args.expected_replacements,
} as NativeArgsFor<TName>
}
break
default:
break
}

View file

@ -23,6 +23,7 @@ import { writeToFileTool } from "../tools/WriteToFileTool"
import { applyDiffTool } from "../tools/MultiApplyDiffTool"
import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
import { searchReplaceTool } from "../tools/SearchReplaceTool"
import { editFileTool } from "../tools/EditFileTool"
import { applyPatchTool } from "../tools/ApplyPatchTool"
import { searchFilesTool } from "../tools/SearchFilesTool"
import { browserActionTool } from "../tools/BrowserActionTool"
@ -403,6 +404,8 @@ export async function presentAssistantMessage(cline: Task) {
return `[${block.name} for '${block.params.path}']`
case "search_replace":
return `[${block.name} for '${block.params.file_path}']`
case "edit_file":
return `[${block.name} for '${block.params.file_path}']`
case "apply_patch":
return `[${block.name}]`
case "list_files":
@ -872,6 +875,16 @@ export async function presentAssistantMessage(cline: Task) {
toolProtocol,
})
break
case "edit_file":
await checkpointSaveAndMark(cline)
await editFileTool.handle(cline, block as ToolUse<"edit_file">, {
askApproval,
handleError,
pushToolResult,
removeClosingTag,
toolProtocol,
})
break
case "apply_patch":
await checkpointSaveAndMark(cline)
await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, {

View file

@ -722,6 +722,14 @@ describe("filterMcpToolsForMode", () => {
parameters: {},
},
},
{
type: "function",
function: {
name: "edit_file",
description: "Edit file",
parameters: {},
},
},
]
it("should exclude tools when model specifies excludedTools", () => {
@ -823,7 +831,7 @@ describe("filterMcpToolsForMode", () => {
expect(toolNames).not.toContain("apply_diff") // Excluded
})
it("should rename tools to alias names when model includes aliases", () => {
it("should honor included aliases while respecting exclusions", () => {
const codeMode: ModeConfig = {
slug: "code",
name: "Code",
@ -834,6 +842,7 @@ describe("filterMcpToolsForMode", () => {
const modelInfo: ModelInfo = {
contextWindow: 100000,
supportsPromptCache: false,
excludedTools: ["apply_diff"],
includedTools: ["edit_file", "write_file"],
}

View file

@ -0,0 +1,70 @@
import type OpenAI from "openai"
const EDIT_FILE_DESCRIPTION = `Use this tool to replace text in an existing file, or create a new file.
This tool performs literal string replacement with support for multiple occurrences.
USAGE PATTERNS:
1. MODIFY EXISTING FILE (default):
- Provide file_path, old_string (text to find), and new_string (replacement)
- By default, expects exactly 1 occurrence of old_string
- Use expected_replacements to replace multiple occurrences
2. CREATE NEW FILE:
- Set old_string to empty string ""
- new_string becomes the entire file content
- File must not already exist
CRITICAL REQUIREMENTS:
1. EXACT MATCHING: The old_string must match the file contents EXACTLY, including:
- All whitespace (spaces, tabs, newlines)
- All indentation
- All punctuation and special characters
2. CONTEXT FOR UNIQUENESS: For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text to ensure uniqueness.
3. MULTIPLE REPLACEMENTS: If you need to replace multiple identical occurrences:
- Set expected_replacements to the exact count you expect to replace
- ALL occurrences will be replaced
4. NO ESCAPING: Provide the literal text - do not escape special characters.`
const edit_file = {
type: "function",
function: {
name: "edit_file",
description: EDIT_FILE_DESCRIPTION,
parameters: {
type: "object",
properties: {
file_path: {
type: "string",
description:
"The path to the file to modify or create. You can use either a relative path in the workspace or an absolute path. If an absolute path is provided, it will be preserved as is.",
},
old_string: {
type: "string",
description:
"The exact literal text to replace (must match the file contents exactly, including all whitespace and indentation). For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text. Use empty string to create a new file.",
},
new_string: {
type: "string",
description:
"The exact literal text to replace old_string with. When creating a new file (old_string is empty), this becomes the file content.",
},
expected_replacements: {
type: "number",
description:
"Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences of the same text.",
minimum: 1,
},
},
required: ["file_path", "old_string", "new_string"],
additionalProperties: false,
},
},
} satisfies OpenAI.Chat.ChatCompletionTool
export default edit_file

View file

@ -15,6 +15,7 @@ import { createReadFileTool } from "./read_file"
import runSlashCommand from "./run_slash_command"
import searchAndReplace from "./search_and_replace"
import searchReplace from "./search_replace"
import edit_file from "./edit_file"
import searchFiles from "./search_files"
import switchMode from "./switch_mode"
import updateTodoList from "./update_todo_list"
@ -47,6 +48,7 @@ export function getNativeTools(partialReadsEnabled: boolean = true): OpenAI.Chat
runSlashCommand,
searchAndReplace,
searchReplace,
edit_file,
searchFiles,
switchMode,
updateTodoList,

View file

@ -0,0 +1,373 @@
import fs from "fs/promises"
import path from "path"
import { getReadablePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { ClineSayTool } from "../../shared/ExtensionMessage"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { fileExistsAtPath } from "../../utils/fs"
import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
import { BaseTool, ToolCallbacks } from "./BaseTool"
import type { ToolUse } from "../../shared/tools"
interface EditFileParams {
file_path: string
old_string: string
new_string: string
expected_replacements?: number
}
/**
* Count occurrences of a substring in a string.
* @param str The string to search in
* @param substr The substring to count
* @returns Number of non-overlapping occurrences
*/
function countOccurrences(str: string, substr: string): number {
if (substr === "") return 0
let count = 0
let pos = str.indexOf(substr)
while (pos !== -1) {
count++
pos = str.indexOf(substr, pos + substr.length)
}
return count
}
/**
* Safely replace all occurrences of a literal string, handling $ escape sequences.
* Standard String.replaceAll treats $ specially in the replacement string.
* This function ensures literal replacement.
*
* @param str The original string
* @param oldString The string to replace
* @param newString The replacement string
* @returns The string with all occurrences replaced
*/
function safeLiteralReplace(str: string, oldString: string, newString: string): string {
if (oldString === "" || !str.includes(oldString)) {
return str
}
// If newString doesn't contain $, we can use replaceAll directly
if (!newString.includes("$")) {
return str.replaceAll(oldString, newString)
}
// Escape $ to prevent ECMAScript GetSubstitution issues
// $$ becomes a single $ in the output, so we double-escape
const escapedNewString = newString.replaceAll("$", "$$$$")
return str.replaceAll(oldString, escapedNewString)
}
/**
* Apply a replacement operation.
*
* @param currentContent The current file content (null if file doesn't exist)
* @param oldString The string to replace
* @param newString The replacement string
* @param isNewFile Whether this is creating a new file
* @returns The resulting content
*/
function applyReplacement(
currentContent: string | null,
oldString: string,
newString: string,
isNewFile: boolean,
): string {
if (isNewFile) {
return newString
}
// If oldString is empty and it's not a new file, do not modify the content
if (oldString === "" || currentContent === null) {
return currentContent ?? ""
}
return safeLiteralReplace(currentContent, oldString, newString)
}
export class EditFileTool extends BaseTool<"edit_file"> {
readonly name = "edit_file" as const
parseLegacy(params: Partial<Record<string, string>>): EditFileParams {
return {
file_path: params.file_path || "",
old_string: params.old_string || "",
new_string: params.new_string || "",
expected_replacements: params.expected_replacements
? parseInt(params.expected_replacements, 10)
: undefined,
}
}
async execute(params: EditFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { file_path, old_string, new_string, expected_replacements = 1 } = params
const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks
try {
// Validate required parameters
if (!file_path) {
task.consecutiveMistakeCount++
task.recordToolError("edit_file")
pushToolResult(await task.sayAndCreateMissingParamError("edit_file", "file_path"))
return
}
// Determine relative path - file_path can be absolute or relative
let relPath: string
if (path.isAbsolute(file_path)) {
relPath = path.relative(task.cwd, file_path)
} else {
relPath = file_path
}
const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await task.say("rooignore_error", relPath)
pushToolResult(formatResponse.rooIgnoreError(relPath, toolProtocol))
return
}
// Check if file is write-protected
const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath) || false
const absolutePath = path.resolve(task.cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
let currentContent: string | null = null
let isNewFile = false
// Read file or determine if creating new
if (fileExists) {
try {
currentContent = await fs.readFile(absolutePath, "utf8")
// Normalize line endings to LF
currentContent = currentContent.replace(/\r\n/g, "\n")
} catch (error) {
task.consecutiveMistakeCount++
task.recordToolError("edit_file")
const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.`
await task.say("error", errorMessage)
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
return
}
// Check if trying to create a file that already exists
if (old_string === "") {
task.consecutiveMistakeCount++
task.recordToolError("edit_file")
const errorMessage = `File '${relPath}' already exists. Cannot create a new file with empty old_string when file exists.`
await task.say("error", errorMessage)
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
return
}
} else {
// File doesn't exist
if (old_string === "") {
// Creating a new file
isNewFile = true
} else {
// Trying to replace in non-existent file
task.consecutiveMistakeCount++
task.recordToolError("edit_file")
const errorMessage = `File not found: ${relPath}. Cannot perform replacement on a non-existent file. Use an empty old_string to create a new file.`
await task.say("error", errorMessage)
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
return
}
}
// Validate replacement operation
if (!isNewFile && currentContent !== null) {
// Check occurrence count
const occurrences = countOccurrences(currentContent, old_string)
if (occurrences === 0) {
task.consecutiveMistakeCount++
task.recordToolError("edit_file", "no_match")
pushToolResult(
formatResponse.toolError(
`No match found for the specified 'old_string'. Please ensure it matches the file contents exactly, including all whitespace and indentation.`,
toolProtocol,
),
)
return
}
if (occurrences !== expected_replacements) {
task.consecutiveMistakeCount++
task.recordToolError("edit_file", "occurrence_mismatch")
pushToolResult(
formatResponse.toolError(
`Expected ${expected_replacements} occurrence(s) but found ${occurrences}. Please adjust your old_string to match exactly ${expected_replacements} occurrence(s), or set expected_replacements to ${occurrences}.`,
toolProtocol,
),
)
return
}
// Validate that old_string and new_string are different
if (old_string === new_string) {
task.consecutiveMistakeCount++
task.recordToolError("edit_file")
pushToolResult(
formatResponse.toolError(
"No changes to apply. The old_string and new_string are identical.",
toolProtocol,
),
)
return
}
}
// Apply the replacement
const newContent = applyReplacement(currentContent, old_string, new_string, isNewFile)
// Check if any changes were made
if (!isNewFile && newContent === currentContent) {
pushToolResult(`No changes needed for '${relPath}'`)
return
}
task.consecutiveMistakeCount = 0
// Initialize diff view
task.diffViewProvider.editType = isNewFile ? "create" : "modify"
task.diffViewProvider.originalContent = currentContent || ""
// Generate and validate diff
const diff = formatResponse.createPrettyPatch(relPath, currentContent || "", newContent)
if (!diff && !isNewFile) {
pushToolResult(`No changes needed for '${relPath}'`)
await task.diffViewProvider.reset()
return
}
// Check if preventFocusDisruption experiment is enabled
const provider = task.providerRef.deref()
const state = await provider?.getState()
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
state?.experiments ?? {},
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
)
const sanitizedDiff = sanitizeUnifiedDiff(diff || "")
const diffStats = computeDiffStats(sanitizedDiff) || undefined
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
const sharedMessageProps: ClineSayTool = {
tool: isNewFile ? "newFileCreated" : "appliedDiff",
path: getReadablePath(task.cwd, relPath),
diff: sanitizedDiff,
isOutsideWorkspace,
}
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: sanitizedDiff,
isProtected: isWriteProtected,
diffStats,
} satisfies ClineSayTool)
// Show diff view if focus disruption prevention is disabled
if (!isPreventFocusDisruptionEnabled) {
await task.diffViewProvider.open(relPath)
await task.diffViewProvider.update(newContent, true)
task.diffViewProvider.scrollToFirstDiff()
}
const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected)
if (!didApprove) {
// Revert changes if diff view was shown
if (!isPreventFocusDisruptionEnabled) {
await task.diffViewProvider.revertChanges()
}
pushToolResult("Changes were rejected by the user.")
await task.diffViewProvider.reset()
return
}
// Save the changes
if (isPreventFocusDisruptionEnabled) {
// Direct file write without diff view or opening the file
await task.diffViewProvider.saveDirectly(
relPath,
newContent,
isNewFile,
diagnosticsEnabled,
writeDelayMs,
)
} else {
// Call saveChanges to update the DiffViewProvider properties
await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
}
// Track file edit operation
if (relPath) {
await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
}
task.didEditFile = true
// Get the formatted response message
const replacementInfo =
!isNewFile && expected_replacements > 1 ? ` (${expected_replacements} replacements)` : ""
const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, isNewFile)
pushToolResult(message + replacementInfo)
// Record successful tool usage and cleanup
task.recordToolUsage("edit_file")
await task.diffViewProvider.reset()
// Process any queued messages after file edit completes
task.processQueuedMessages()
} catch (error) {
await handleError("edit_file", error as Error)
await task.diffViewProvider.reset()
}
}
override async handlePartial(task: Task, block: ToolUse<"edit_file">): Promise<void> {
const filePath: string | undefined = block.params.file_path
const oldString: string | undefined = block.params.old_string
let operationPreview: string | undefined
if (oldString !== undefined) {
if (oldString === "") {
operationPreview = "creating new file"
} else {
const preview = oldString.length > 50 ? oldString.substring(0, 50) + "..." : oldString
operationPreview = `replacing: "${preview}"`
}
}
// Determine relative path for display
let relPath = filePath || ""
if (filePath && path.isAbsolute(filePath)) {
relPath = path.relative(task.cwd, filePath)
}
const absolutePath = relPath ? path.resolve(task.cwd, relPath) : ""
const isOutsideWorkspace = absolutePath ? isPathOutsideWorkspace(absolutePath) : false
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(task.cwd, relPath),
diff: operationPreview,
isOutsideWorkspace,
}
await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
}
}
export const editFileTool = new EditFileTool()

View file

@ -0,0 +1,455 @@
import * as path from "path"
import fs from "fs/promises"
import type { MockedFunction } from "vitest"
import { fileExistsAtPath } from "../../../utils/fs"
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
import { getReadablePath } from "../../../utils/path"
import { ToolUse, ToolResponse } from "../../../shared/tools"
import { editFileTool } from "../EditFileTool"
vi.mock("fs/promises", () => ({
default: {
readFile: vi.fn().mockResolvedValue(""),
},
}))
vi.mock("path", async () => {
const originalPath = await vi.importActual("path")
return {
...originalPath,
resolve: vi.fn().mockImplementation((...args) => {
const separator = process.platform === "win32" ? "\\" : "/"
return args.join(separator)
}),
isAbsolute: vi.fn().mockReturnValue(false),
relative: vi.fn().mockImplementation((from, to) => to),
}
})
vi.mock("delay", () => ({
default: vi.fn(),
}))
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockResolvedValue(true),
}))
vi.mock("../../prompts/responses", () => ({
formatResponse: {
toolError: vi.fn((msg) => `Error: ${msg}`),
rooIgnoreError: vi.fn((path) => `Access denied: ${path}`),
createPrettyPatch: vi.fn(() => "mock-diff"),
},
}))
vi.mock("../../../utils/pathUtils", () => ({
isPathOutsideWorkspace: vi.fn().mockReturnValue(false),
}))
vi.mock("../../../utils/path", () => ({
getReadablePath: vi.fn().mockReturnValue("test/path.txt"),
}))
vi.mock("../../diff/stats", () => ({
sanitizeUnifiedDiff: vi.fn((diff) => diff),
computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })),
}))
vi.mock("vscode", () => ({
window: {
showWarningMessage: vi.fn().mockResolvedValue(undefined),
},
env: {
openExternal: vi.fn(),
},
Uri: {
parse: vi.fn(),
},
}))
describe("editFileTool", () => {
// Test data
const testFilePath = "test/file.txt"
const absoluteFilePath = process.platform === "win32" ? "C:\\test\\file.txt" : "/test/file.txt"
const testFileContent = "Line 1\nLine 2\nLine 3\nLine 4"
const testOldString = "Line 2"
const testNewString = "Modified Line 2"
// Mocked functions
const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction<typeof fileExistsAtPath>
const mockedFsReadFile = fs.readFile as unknown as MockedFunction<
(path: string, encoding: string) => Promise<string>
>
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
const mockedGetReadablePath = getReadablePath as MockedFunction<typeof getReadablePath>
const mockedPathResolve = path.resolve as MockedFunction<typeof path.resolve>
const mockedPathIsAbsolute = path.isAbsolute as MockedFunction<typeof path.isAbsolute>
const mockTask: any = {}
let mockAskApproval: ReturnType<typeof vi.fn>
let mockHandleError: ReturnType<typeof vi.fn>
let mockPushToolResult: ReturnType<typeof vi.fn>
let mockRemoveClosingTag: ReturnType<typeof vi.fn>
let toolResult: ToolResponse | undefined
beforeEach(() => {
vi.clearAllMocks()
mockedPathResolve.mockReturnValue(absoluteFilePath)
mockedPathIsAbsolute.mockReturnValue(false)
mockedFileExistsAtPath.mockResolvedValue(true)
mockedFsReadFile.mockResolvedValue(testFileContent)
mockedIsPathOutsideWorkspace.mockReturnValue(false)
mockedGetReadablePath.mockReturnValue("test/path.txt")
mockTask.cwd = "/"
mockTask.consecutiveMistakeCount = 0
mockTask.didEditFile = false
mockTask.providerRef = {
deref: vi.fn().mockReturnValue({
getState: vi.fn().mockResolvedValue({
diagnosticsEnabled: true,
writeDelayMs: 1000,
experiments: {},
}),
}),
}
mockTask.rooIgnoreController = {
validateAccess: vi.fn().mockReturnValue(true),
}
mockTask.rooProtectedController = {
isWriteProtected: vi.fn().mockReturnValue(false),
}
mockTask.diffViewProvider = {
editType: undefined,
isEditing: false,
originalContent: "",
open: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
reset: vi.fn().mockResolvedValue(undefined),
revertChanges: vi.fn().mockResolvedValue(undefined),
saveChanges: vi.fn().mockResolvedValue({
newProblemsMessage: "",
userEdits: null,
finalContent: "final content",
}),
saveDirectly: vi.fn().mockResolvedValue(undefined),
scrollToFirstDiff: vi.fn(),
pushToolWriteResult: vi.fn().mockResolvedValue("Tool result message"),
}
mockTask.fileContextTracker = {
trackFileContext: vi.fn().mockResolvedValue(undefined),
}
mockTask.say = vi.fn().mockResolvedValue(undefined)
mockTask.ask = vi.fn().mockResolvedValue(undefined)
mockTask.recordToolError = vi.fn()
mockTask.recordToolUsage = vi.fn()
mockTask.processQueuedMessages = vi.fn()
mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error")
mockAskApproval = vi.fn().mockResolvedValue(true)
mockHandleError = vi.fn().mockResolvedValue(undefined)
mockRemoveClosingTag = vi.fn((tag, content) => content)
toolResult = undefined
})
/**
* Helper function to execute the edit_file tool with different parameters
*/
async function executeEditFileTool(
params: Partial<ToolUse["params"]> = {},
options: {
fileExists?: boolean
fileContent?: string
isPartial?: boolean
accessAllowed?: boolean
} = {},
): Promise<ToolResponse | undefined> {
const fileExists = options.fileExists ?? true
const fileContent = options.fileContent ?? testFileContent
const isPartial = options.isPartial ?? false
const accessAllowed = options.accessAllowed ?? true
mockedFileExistsAtPath.mockResolvedValue(fileExists)
mockedFsReadFile.mockResolvedValue(fileContent)
mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed)
const toolUse: ToolUse = {
type: "tool_use",
name: "edit_file",
params: {
file_path: testFilePath,
old_string: testOldString,
new_string: testNewString,
...params,
},
partial: isPartial,
}
mockPushToolResult = vi.fn((result: ToolResponse) => {
toolResult = result
})
await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "native",
})
return toolResult
}
describe("parameter validation", () => {
it("returns error when file_path is missing", async () => {
const result = await executeEditFileTool({ file_path: undefined })
expect(result).toBe("Missing param error")
expect(mockTask.consecutiveMistakeCount).toBe(1)
expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file")
})
it("treats undefined new_string as empty string (deletion)", async () => {
await executeEditFileTool(
{ old_string: "Line 2", new_string: undefined },
{ fileContent: "Line 1\nLine 2\nLine 3" },
)
expect(mockAskApproval).toHaveBeenCalled()
})
it("allows empty new_string for deletion", async () => {
await executeEditFileTool(
{ old_string: "Line 2", new_string: "" },
{ fileContent: "Line 1\nLine 2\nLine 3" },
)
expect(mockAskApproval).toHaveBeenCalled()
})
it("returns error when old_string equals new_string", async () => {
const result = await executeEditFileTool({
old_string: "same",
new_string: "same",
})
expect(result).toContain("Error:")
expect(mockTask.consecutiveMistakeCount).toBe(1)
})
})
describe("file access", () => {
it("returns error when file does not exist and old_string is not empty", async () => {
const result = await executeEditFileTool({}, { fileExists: false })
expect(result).toContain("Error:")
expect(result).toContain("File not found")
expect(mockTask.consecutiveMistakeCount).toBe(1)
})
it("returns error when access is denied", async () => {
const result = await executeEditFileTool({}, { accessAllowed: false })
expect(result).toContain("Access denied")
})
})
describe("edit_file logic", () => {
it("returns error when no match is found", async () => {
const result = await executeEditFileTool(
{ old_string: "NonExistent" },
{ fileContent: "Line 1\nLine 2\nLine 3" },
)
expect(result).toContain("Error:")
expect(result).toContain("No match found")
expect(mockTask.consecutiveMistakeCount).toBe(1)
expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file", "no_match")
})
it("returns error when occurrence count does not match expected_replacements", async () => {
const result = await executeEditFileTool(
{ old_string: "Line", expected_replacements: "1" },
{ fileContent: "Line 1\nLine 2\nLine 3" },
)
expect(result).toContain("Error:")
expect(result).toContain("Expected 1 occurrence(s) but found 3")
expect(mockTask.consecutiveMistakeCount).toBe(1)
expect(mockTask.recordToolError).toHaveBeenCalledWith("edit_file", "occurrence_mismatch")
})
it("succeeds when occurrence count matches expected_replacements", async () => {
await executeEditFileTool(
{ old_string: "Line", new_string: "Row", expected_replacements: "4" },
{ fileContent: "Line 1\nLine 2\nLine 3\nLine 4" },
)
expect(mockTask.consecutiveMistakeCount).toBe(0)
expect(mockTask.diffViewProvider.editType).toBe("modify")
expect(mockAskApproval).toHaveBeenCalled()
})
it("successfully replaces single unique match", async () => {
await executeEditFileTool(
{
old_string: "Line 2",
new_string: "Modified Line 2",
},
{ fileContent: "Line 1\nLine 2\nLine 3" },
)
expect(mockTask.consecutiveMistakeCount).toBe(0)
expect(mockTask.diffViewProvider.editType).toBe("modify")
expect(mockAskApproval).toHaveBeenCalled()
})
it("defaults expected_replacements to 1", async () => {
const result = await executeEditFileTool(
{ old_string: "Line" },
{ fileContent: "Line 1\nLine 2\nLine 3\nLine 4" },
)
expect(result).toContain("Error:")
expect(result).toContain("Expected 1 occurrence(s) but found 4")
})
})
describe("file creation", () => {
it("creates new file when old_string is empty and file does not exist", async () => {
await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false })
expect(mockTask.consecutiveMistakeCount).toBe(0)
expect(mockTask.diffViewProvider.editType).toBe("create")
expect(mockAskApproval).toHaveBeenCalled()
})
it("returns error when trying to create file that already exists", async () => {
const result = await executeEditFileTool(
{ old_string: "", new_string: "Content" },
{ fileExists: true, fileContent: "Existing content" },
)
expect(result).toContain("Error:")
expect(result).toContain("already exists")
expect(mockTask.consecutiveMistakeCount).toBe(1)
})
})
describe("approval workflow", () => {
it("saves changes when user approves", async () => {
mockAskApproval.mockResolvedValue(true)
await executeEditFileTool()
expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled()
expect(mockTask.didEditFile).toBe(true)
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit_file")
})
it("reverts changes when user rejects", async () => {
mockAskApproval.mockResolvedValue(false)
const result = await executeEditFileTool()
expect(mockTask.diffViewProvider.revertChanges).toHaveBeenCalled()
expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled()
expect(result).toContain("rejected")
})
})
describe("partial block handling", () => {
it("handles partial block without errors", async () => {
await executeEditFileTool({}, { isPartial: true })
expect(mockTask.ask).toHaveBeenCalled()
})
it("shows creating new file preview when old_string is empty", async () => {
await executeEditFileTool({ old_string: "" }, { isPartial: true })
expect(mockTask.ask).toHaveBeenCalled()
})
})
describe("error handling", () => {
it("handles file read errors gracefully", async () => {
mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed"))
const toolUse: ToolUse = {
type: "tool_use",
name: "edit_file",
params: {
file_path: testFilePath,
old_string: testOldString,
new_string: testNewString,
},
partial: false,
}
let capturedResult: ToolResponse | undefined
const localPushToolResult = vi.fn((result: ToolResponse) => {
capturedResult = result
})
await editFileTool.handle(mockTask, toolUse as ToolUse<"edit_file">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: localPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "native",
})
expect(capturedResult).toContain("Error:")
expect(capturedResult).toContain("Failed to read file")
expect(mockTask.consecutiveMistakeCount).toBe(1)
})
it("handles general errors and resets diff view", async () => {
mockTask.diffViewProvider.open.mockRejectedValueOnce(new Error("General error"))
await executeEditFileTool()
expect(mockHandleError).toHaveBeenCalledWith("edit_file", expect.any(Error))
expect(mockTask.diffViewProvider.reset).toHaveBeenCalled()
})
})
describe("file tracking", () => {
it("tracks file context after successful edit", async () => {
await executeEditFileTool()
expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited")
})
})
describe("CRLF normalization", () => {
it("normalizes CRLF to LF when reading file", async () => {
const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3"
await executeEditFileTool(
{ old_string: "Line 2", new_string: "Modified Line 2" },
{ fileContent: contentWithCRLF },
)
expect(mockTask.consecutiveMistakeCount).toBe(0)
expect(mockAskApproval).toHaveBeenCalled()
})
})
describe("dollar sign handling", () => {
it("handles $ in new_string correctly", async () => {
await executeEditFileTool(
{ old_string: "Line 2", new_string: "Cost: $100" },
{ fileContent: "Line 1\nLine 2\nLine 3" },
)
expect(mockTask.consecutiveMistakeCount).toBe(0)
expect(mockAskApproval).toHaveBeenCalled()
})
})
})

View file

@ -72,9 +72,10 @@ export const toolParamNames = [
"files", // Native protocol parameter for read_file
"operations", // search_and_replace parameter for multiple operations
"patch", // apply_patch parameter
"file_path", // search_replace parameter
"old_string", // search_replace parameter
"new_string", // search_replace parameter
"file_path", // search_replace and edit_file parameter
"old_string", // search_replace and edit_file parameter
"new_string", // search_replace and edit_file parameter
"expected_replacements", // edit_file parameter for multiple occurrences
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@ -93,6 +94,7 @@ export type NativeToolArgs = {
apply_diff: { path: string; diff: string }
search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> }
search_replace: { file_path: string; old_string: string; new_string: string }
edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number }
apply_patch: { patch: string }
ask_followup_question: {
question: string
@ -249,6 +251,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
apply_diff: "apply changes",
search_and_replace: "apply changes using search and replace",
search_replace: "apply single search and replace",
edit_file: "edit files using search and replace",
apply_patch: "apply patches using codex format",
search_files: "search files",
list_files: "list files",
@ -272,7 +275,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
},
edit: {
tools: ["apply_diff", "write_to_file", "generate_image"],
customTools: ["search_and_replace", "search_replace", "apply_patch"],
customTools: ["search_and_replace", "search_replace", "edit_file", "apply_patch"],
},
browser: {
tools: ["browser_action"],
@ -310,9 +313,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [
* To add a new alias, simply add an entry here. No other files need to be modified.
*/
export const TOOL_ALIASES: Record<string, ToolName> = {
edit_file: "apply_diff",
write_file: "write_to_file",
temp_edit_file: "search_and_replace",
} as const
export type DiffResult =