Просмотр исходного кода

feat: rename search_and_replace tool to edit and unify edit-family UI (#11296)

Hannes Rudolph 1 день назад
Родитель
Сommit
8a69e9e04d

+ 1 - 1
packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts

@@ -281,7 +281,7 @@ describe("CustomToolRegistry", () => {
 			const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
 
 			expect(result.loaded).toContain("cached")
-		}, 30000)
+		}, 120_000)
 	})
 
 	describe.sequential("loadFromDirectories", () => {

+ 1 - 0
packages/types/src/tool.ts

@@ -20,6 +20,7 @@ export const toolNames = [
 	"read_command_output",
 	"write_to_file",
 	"apply_diff",
+	"edit",
 	"search_and_replace",
 	"search_replace",
 	"edit_file",

+ 20 - 6
src/core/assistant-message/NativeToolCallParser.ts

@@ -599,11 +599,18 @@ export class NativeToolCallParser {
 				}
 				break
 
+			case "edit":
 			case "search_and_replace":
-				if (partialArgs.path !== undefined || partialArgs.operations !== undefined) {
+				if (
+					partialArgs.file_path !== undefined ||
+					partialArgs.old_string !== undefined ||
+					partialArgs.new_string !== undefined
+				) {
 					nativeArgs = {
-						path: partialArgs.path,
-						operations: partialArgs.operations,
+						file_path: partialArgs.file_path,
+						old_string: partialArgs.old_string,
+						new_string: partialArgs.new_string,
+						replace_all: this.coerceOptionalBoolean(partialArgs.replace_all),
 					}
 				}
 				break
@@ -806,11 +813,18 @@ export class NativeToolCallParser {
 					}
 					break
 
+				case "edit":
 				case "search_and_replace":
-					if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) {
+					if (
+						args.file_path !== undefined &&
+						args.old_string !== undefined &&
+						args.new_string !== undefined
+					) {
 						nativeArgs = {
-							path: args.path,
-							operations: args.operations,
+							file_path: args.file_path,
+							old_string: args.old_string,
+							new_string: args.new_string,
+							replace_all: this.coerceOptionalBoolean(args.replace_all),
 						} as NativeArgsFor<TName>
 					}
 					break

+ 39 - 0
src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts

@@ -2,6 +2,7 @@
 
 import { describe, it, expect, beforeEach, vi } from "vitest"
 import { presentAssistantMessage } from "../presentAssistantMessage"
+import { validateToolUse } from "../../tools/validateToolUse"
 
 // Mock dependencies
 vi.mock("../../task/Task")
@@ -301,6 +302,44 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
 		})
 	})
 
+	describe("Validation requirements", () => {
+		it("normalizes disabledTools aliases before validateToolUse", async () => {
+			const toolCallId = "tool_call_validation_alias_123"
+			mockTask.assistantMessageContent = [
+				{
+					type: "tool_use",
+					id: toolCallId,
+					name: "some_unknown_tool",
+					params: {},
+					partial: false,
+				},
+			]
+
+			mockTask.providerRef = {
+				deref: () => ({
+					getState: vi.fn().mockResolvedValue({
+						mode: "code",
+						customModes: [],
+						experiments: {
+							customTools: false,
+						},
+						disabledTools: ["search_and_replace"],
+					}),
+				}),
+			}
+
+			await presentAssistantMessage(mockTask)
+
+			const validateToolUseMock = vi.mocked(validateToolUse)
+			expect(validateToolUseMock).toHaveBeenCalled()
+			const toolRequirements = validateToolUseMock.mock.calls[0][3]
+			expect(toolRequirements).toMatchObject({
+				search_and_replace: false,
+				edit: false,
+			})
+		})
+	})
+
 	describe("Partial blocks", () => {
 		it("should not record usage for partial custom tool blocks", async () => {
 			mockTask.assistantMessageContent = [

+ 7 - 59
src/core/assistant-message/presentAssistantMessage.ts

@@ -18,7 +18,7 @@ import { listFilesTool } from "../tools/ListFilesTool"
 import { readFileTool } from "../tools/ReadFileTool"
 import { readCommandOutputTool } from "../tools/ReadCommandOutputTool"
 import { writeToFileTool } from "../tools/WriteToFileTool"
-import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
+import { editTool } from "../tools/EditTool"
 import { searchReplaceTool } from "../tools/SearchReplaceTool"
 import { editFileTool } from "../tools/EditFileTool"
 import { applyPatchTool } from "../tools/ApplyPatchTool"
@@ -291,18 +291,6 @@ export async function presentAssistantMessage(cline: Task) {
 				// Strip any streamed <thinking> tags from text output.
 				content = content.replace(/<thinking>\s?/g, "")
 				content = content.replace(/\s?<\/thinking>/g, "")
-
-				// Tool calling is native-only. If the model emits XML-style tool tags in a text block,
-				// fail fast with a clear error.
-				if (containsXmlToolMarkup(content)) {
-					const errorMessage =
-						"XML tool calls are no longer supported. Remove any XML tool markup (e.g. <read_file>...</read_file>) and use native tool calling instead."
-					cline.consecutiveMistakeCount++
-					await cline.say("error", errorMessage)
-					cline.userMessageContent.push({ type: "text", text: errorMessage })
-					cline.didAlreadyUseTool = true
-					break
-				}
 			}
 
 			await cline.say("text", content, undefined, block.partial)
@@ -357,8 +345,9 @@ export async function presentAssistantMessage(cline: Task) {
 						return `[${block.name} for '${block.params.regex}'${
 							block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
 						}]`
+					case "edit":
 					case "search_and_replace":
-						return `[${block.name} for '${block.params.path}']`
+						return `[${block.name} for '${block.params.file_path}']`
 					case "search_replace":
 						return `[${block.name} for '${block.params.file_path}']`
 					case "edit_file":
@@ -629,6 +618,8 @@ export async function presentAssistantMessage(cline: Task) {
 						disabledTools?.reduce(
 							(acc: Record<string, boolean>, tool: string) => {
 								acc[tool] = false
+								const resolvedToolName = resolveToolAlias(tool)
+								acc[resolvedToolName] = false
 								return acc
 							},
 							{} as Record<string, boolean>,
@@ -739,9 +730,10 @@ export async function presentAssistantMessage(cline: Task) {
 						pushToolResult,
 					})
 					break
+				case "edit":
 				case "search_and_replace":
 					await checkpointSaveAndMark(cline)
-					await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
+					await editTool.handle(cline, block as ToolUse<"edit">, {
 						askApproval,
 						handleError,
 						pushToolResult,
@@ -1040,47 +1032,3 @@ async function checkpointSaveAndMark(task: Task) {
 		console.error(`[Task#presentAssistantMessage] Error saving checkpoint: ${error.message}`, error)
 	}
 }
-
-function containsXmlToolMarkup(text: string): boolean {
-	// Keep this intentionally narrow: only reject XML-style tool tags matching our tool names.
-	// Avoid regex so we don't keep legacy XML parsing artifacts around.
-	// Note: This is a best-effort safeguard; tool_use blocks without an id are rejected elsewhere.
-
-	// First, strip out content inside markdown code fences to avoid false positives
-	// when users paste documentation or examples containing tool tag references.
-	// This handles both fenced code blocks (```) and inline code (`).
-	const textWithoutCodeBlocks = text
-		.replace(/```[\s\S]*?```/g, "") // Remove fenced code blocks
-		.replace(/`[^`]+`/g, "") // Remove inline code
-
-	const lower = textWithoutCodeBlocks.toLowerCase()
-	if (!lower.includes("<") || !lower.includes(">")) {
-		return false
-	}
-
-	const toolNames = [
-		"access_mcp_resource",
-		"apply_diff",
-		"apply_patch",
-		"ask_followup_question",
-		"attempt_completion",
-		"browser_action",
-		"codebase_search",
-		"edit_file",
-		"execute_command",
-		"generate_image",
-		"list_files",
-		"new_task",
-		"read_command_output",
-		"read_file",
-		"search_and_replace",
-		"search_files",
-		"search_replace",
-		"switch_mode",
-		"update_todo_list",
-		"use_mcp_tool",
-		"write_to_file",
-	] as const
-
-	return toolNames.some((name) => lower.includes(`<${name}`) || lower.includes(`</${name}`))
-}

+ 16 - 0
src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts

@@ -22,6 +22,7 @@ describe("filterNativeToolsForMode - disabledTools", () => {
 		makeTool("write_to_file"),
 		makeTool("browser_action"),
 		makeTool("apply_diff"),
+		makeTool("edit"),
 	]
 
 	it("removes tools listed in settings.disabledTools", () => {
@@ -77,4 +78,19 @@ describe("filterNativeToolsForMode - disabledTools", () => {
 		expect(resultNames).not.toContain("browser_action")
 		expect(resultNames).toContain("read_file")
 	})
+
+	it("disables canonical tool when disabledTools contains alias name", () => {
+		const settings = {
+			disabledTools: ["search_and_replace"],
+			modelInfo: {
+				includedTools: ["search_and_replace"],
+			},
+		}
+
+		const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
+
+		const resultNames = result.map((t) => (t as any).function.name)
+		expect(resultNames).not.toContain("search_and_replace")
+		expect(resultNames).not.toContain("edit")
+	})
 })

+ 4 - 1
src/core/prompts/tools/filter-tools-for-mode.ts

@@ -299,7 +299,10 @@ export function filterNativeToolsForMode(
 	// Remove tools that are explicitly disabled via the disabledTools setting
 	if (settings?.disabledTools?.length) {
 		for (const toolName of settings.disabledTools) {
-			allowedToolNames.delete(toolName)
+			// Normalize aliases so disabling a legacy alias (e.g. "search_and_replace")
+			// also disables the canonical tool (e.g. "edit").
+			const resolvedToolName = resolveToolAlias(toolName)
+			allowedToolNames.delete(resolvedToolName)
 		}
 	}
 

+ 48 - 0
src/core/prompts/tools/native-tools/edit.ts

@@ -0,0 +1,48 @@
+import type OpenAI from "openai"
+
+const EDIT_DESCRIPTION = `Performs exact string replacements in files.
+
+Usage:
+- You must use your \`Read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
+- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
+- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
+- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
+- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
+- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`
+
+const edit = {
+	type: "function",
+	function: {
+		name: "edit",
+		description: EDIT_DESCRIPTION,
+		parameters: {
+			type: "object",
+			properties: {
+				file_path: {
+					type: "string",
+					description: "The path of the file to edit (relative to the working directory)",
+				},
+				old_string: {
+					type: "string",
+					description:
+						"The exact text to find in the file. Must match exactly, including all whitespace, indentation, and line endings.",
+				},
+				new_string: {
+					type: "string",
+					description:
+						"The replacement text that will replace old_string. Must include all necessary whitespace and indentation.",
+				},
+				replace_all: {
+					type: "boolean",
+					description:
+						"When true, replaces ALL occurrences of old_string in the file. When false (default), only replaces the first occurrence and errors if multiple matches exist.",
+					default: false,
+				},
+			},
+			required: ["file_path", "old_string", "new_string"],
+			additionalProperties: false,
+		},
+	},
+} satisfies OpenAI.Chat.ChatCompletionTool
+
+export default edit

+ 2 - 2
src/core/prompts/tools/native-tools/index.ts

@@ -6,6 +6,7 @@ import askFollowupQuestion from "./ask_followup_question"
 import attemptCompletion from "./attempt_completion"
 import browserAction from "./browser_action"
 import codebaseSearch from "./codebase_search"
+import editTool from "./edit"
 import executeCommand from "./execute_command"
 import generateImage from "./generate_image"
 import listFiles from "./list_files"
@@ -14,7 +15,6 @@ import readCommandOutput from "./read_command_output"
 import { createReadFileTool, type ReadFileToolOptions } from "./read_file"
 import runSlashCommand from "./run_slash_command"
 import skill from "./skill"
-import searchAndReplace from "./search_and_replace"
 import searchReplace from "./search_replace"
 import edit_file from "./edit_file"
 import searchFiles from "./search_files"
@@ -63,9 +63,9 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch
 		createReadFileTool(readFileOptions),
 		runSlashCommand,
 		skill,
-		searchAndReplace,
 		searchReplace,
 		edit_file,
+		editTool,
 		searchFiles,
 		switchMode,
 		updateTodoList,

+ 0 - 44
src/core/prompts/tools/native-tools/search_and_replace.ts

@@ -1,44 +0,0 @@
-import type OpenAI from "openai"
-
-const SEARCH_AND_REPLACE_DESCRIPTION = `Apply precise, targeted modifications to an existing file using search and replace operations. This tool is for surgical edits only; provide an array of operations where each operation specifies the exact text to search for and what to replace it with. The search text must exactly match the existing content, including whitespace and indentation.`
-
-const search_and_replace = {
-	type: "function",
-	function: {
-		name: "search_and_replace",
-		description: SEARCH_AND_REPLACE_DESCRIPTION,
-		parameters: {
-			type: "object",
-			properties: {
-				path: {
-					type: "string",
-					description: "The path of the file to modify, relative to the current workspace directory.",
-				},
-				operations: {
-					type: "array",
-					description: "Array of search and replace operations to perform on the file.",
-					items: {
-						type: "object",
-						properties: {
-							search: {
-								type: "string",
-								description:
-									"The exact text to find in the file. Must match exactly, including whitespace.",
-							},
-							replace: {
-								type: "string",
-								description: "The text to replace the search text with.",
-							},
-						},
-						required: ["search", "replace"],
-					},
-					minItems: 1,
-				},
-			},
-			required: ["path", "operations"],
-			additionalProperties: false,
-		},
-	},
-} satisfies OpenAI.Chat.ChatCompletionTool
-
-export default search_and_replace

+ 1 - 1
src/core/task/Task.ts

@@ -3587,7 +3587,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 								const input = toolUse.nativeArgs || toolUse.params
 
 								// Use originalName (alias) if present for API history consistency.
-								// When tool aliases are used (e.g., "edit_file" -> "search_and_replace"),
+								// When tool aliases are used (e.g., "edit_file" -> "search_and_replace" -> "edit" (current canonical name)),
 								// we want the alias name in the conversation history to match what the model
 								// was told the tool was named, preventing confusion in multi-turn conversations.
 								const toolNameForHistory = toolUse.originalName ?? toolUse.name

+ 36 - 2
src/core/tools/ApplyPatchTool.ts

@@ -23,6 +23,35 @@ interface ApplyPatchParams {
 export class ApplyPatchTool extends BaseTool<"apply_patch"> {
 	readonly name = "apply_patch" as const
 
+	private static readonly FILE_HEADER_MARKERS = ["*** Add File: ", "*** Delete File: ", "*** Update File: "] as const
+
+	private extractFirstPathFromPatch(patch: string | undefined): string | undefined {
+		if (!patch) {
+			return undefined
+		}
+
+		const lines = patch.split("\n")
+		const hasTrailingNewline = patch.endsWith("\n")
+		const completeLines = hasTrailingNewline ? lines : lines.slice(0, -1)
+
+		for (const rawLine of completeLines) {
+			const line = rawLine.trim()
+
+			for (const marker of ApplyPatchTool.FILE_HEADER_MARKERS) {
+				if (!line.startsWith(marker)) {
+					continue
+				}
+
+				const candidatePath = line.substring(marker.length).trim()
+				if (candidatePath.length > 0) {
+					return candidatePath
+				}
+			}
+		}
+
+		return undefined
+	}
+
 	async execute(params: ApplyPatchParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
 		const { patch } = params
 		const { askApproval, handleError, pushToolResult } = callbacks
@@ -422,6 +451,11 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
 
 	override async handlePartial(task: Task, block: ToolUse<"apply_patch">): Promise<void> {
 		const patch: string | undefined = block.params.patch
+		const candidateRelPath = this.extractFirstPathFromPatch(patch)
+		const fallbackDisplayPath = path.basename(task.cwd) || "workspace"
+		const resolvedRelPath = candidateRelPath ?? ""
+		const absolutePath = path.resolve(task.cwd, resolvedRelPath)
+		const displayPath = candidateRelPath ? getReadablePath(task.cwd, candidateRelPath) : fallbackDisplayPath
 
 		let patchPreview: string | undefined
 		if (patch) {
@@ -432,9 +466,9 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> {
 
 		const sharedMessageProps: ClineSayTool = {
 			tool: "appliedDiff",
-			path: "",
+			path: displayPath || path.basename(task.cwd) || "workspace",
 			diff: patchPreview || "Parsing patch...",
-			isOutsideWorkspace: false,
+			isOutsideWorkspace: isPathOutsideWorkspace(absolutePath),
 		}
 
 		await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})

+ 279 - 0
src/core/tools/EditTool.ts

@@ -0,0 +1,279 @@
+import fs from "fs/promises"
+import path from "path"
+
+import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
+
+import { getReadablePath } from "../../utils/path"
+import { isPathOutsideWorkspace } from "../../utils/pathUtils"
+import { Task } from "../task/Task"
+import { formatResponse } from "../prompts/responses"
+import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
+import { fileExistsAtPath } from "../../utils/fs"
+import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
+import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
+import type { ToolUse } from "../../shared/tools"
+
+import { BaseTool, ToolCallbacks } from "./BaseTool"
+
+interface EditParams {
+	file_path: string
+	old_string: string
+	new_string: string
+	replace_all?: boolean
+}
+
+export class EditTool extends BaseTool<"edit"> {
+	readonly name = "edit" as const
+
+	async execute(params: EditParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
+		const { file_path: relPath, old_string: oldString, new_string: newString, replace_all: replaceAll } = params
+		const { askApproval, handleError, pushToolResult } = callbacks
+
+		try {
+			// Validate required parameters
+			if (!relPath) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				pushToolResult(await task.sayAndCreateMissingParamError("edit", "file_path"))
+				return
+			}
+
+			if (!oldString) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				pushToolResult(await task.sayAndCreateMissingParamError("edit", "old_string"))
+				return
+			}
+
+			if (newString === undefined) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				pushToolResult(await task.sayAndCreateMissingParamError("edit", "new_string"))
+				return
+			}
+
+			// Check old_string !== new_string
+			if (oldString === newString) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				pushToolResult(
+					formatResponse.toolError(
+						"'old_string' and 'new_string' are identical. No changes needed. If you want to make a change, ensure 'old_string' and 'new_string' are different.",
+					),
+				)
+				return
+			}
+
+			const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
+
+			if (!accessAllowed) {
+				await task.say("rooignore_error", relPath)
+				pushToolResult(formatResponse.rooIgnoreError(relPath))
+				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)
+			if (!fileExists) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				const errorMessage = `File not found: ${relPath}. Cannot perform edit on a non-existent file.`
+				await task.say("error", errorMessage)
+				pushToolResult(formatResponse.toolError(errorMessage))
+				return
+			}
+
+			let fileContent: string
+			try {
+				fileContent = await fs.readFile(absolutePath, "utf8")
+				// Normalize line endings to LF for consistent matching
+				fileContent = fileContent.replace(/\r\n/g, "\n")
+			} catch (error) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.`
+				await task.say("error", errorMessage)
+				pushToolResult(formatResponse.toolError(errorMessage))
+				return
+			}
+
+			// Normalize line endings in old_string/new_string to match file content
+			const normalizedOld = oldString.replace(/\r\n/g, "\n")
+			const normalizedNew = newString.replace(/\r\n/g, "\n")
+
+			// Count occurrences of old_string in file content
+			const matchCount = fileContent.split(normalizedOld).length - 1
+
+			if (matchCount === 0) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit", "no_match")
+				pushToolResult(
+					formatResponse.toolError(
+						`No match found for 'old_string' in ${relPath}. Make sure the text to find appears exactly in the file, including whitespace and indentation.`,
+					),
+				)
+				return
+			}
+
+			// Uniqueness check when replace_all is not enabled
+			if (!replaceAll && matchCount > 1) {
+				task.consecutiveMistakeCount++
+				task.recordToolError("edit")
+				pushToolResult(
+					formatResponse.toolError(
+						`Found ${matchCount} matches of 'old_string' in the file. Use 'replace_all: true' to replace all occurrences, or provide more context in 'old_string' to make it unique.`,
+					),
+				)
+				return
+			}
+
+			// Apply the replacement
+			let newContent: string
+			if (replaceAll) {
+				// Replace all occurrences
+				const searchPattern = new RegExp(escapeRegExp(normalizedOld), "g")
+				newContent = fileContent.replace(searchPattern, () => normalizedNew)
+			} else {
+				// Replace single occurrence (already verified uniqueness above)
+				newContent = fileContent.replace(normalizedOld, () => normalizedNew)
+			}
+
+			// Check if any changes were made
+			if (newContent === fileContent) {
+				pushToolResult(`No changes needed for '${relPath}'`)
+				return
+			}
+
+			task.consecutiveMistakeCount = 0
+
+			// Initialize diff view
+			task.diffViewProvider.editType = "modify"
+			task.diffViewProvider.originalContent = fileContent
+
+			// Generate and validate diff
+			const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent)
+			if (!diff) {
+				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: "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, false, 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 message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false)
+			pushToolResult(message)
+
+			// Record successful tool usage and cleanup
+			task.recordToolUsage("edit")
+			await task.diffViewProvider.reset()
+			this.resetPartialState()
+
+			// Process any queued messages after file edit completes
+			task.processQueuedMessages()
+		} catch (error) {
+			await handleError("edit", error as Error)
+			await task.diffViewProvider.reset()
+			this.resetPartialState()
+		}
+	}
+
+	override async handlePartial(task: Task, block: ToolUse<"edit">): Promise<void> {
+		const relPath: string | undefined = block.params.file_path
+
+		// Wait for path to stabilize before showing UI (prevents truncated paths)
+		if (!this.hasPathStabilized(relPath)) {
+			return
+		}
+
+		// relPath is guaranteed non-null after hasPathStabilized
+		const absolutePath = path.resolve(task.cwd, relPath!)
+		const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
+
+		const sharedMessageProps: ClineSayTool = {
+			tool: "appliedDiff",
+			path: getReadablePath(task.cwd, relPath!),
+			diff: block.params.old_string ? "1 edit operation" : undefined,
+			isOutsideWorkspace,
+		}
+
+		await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
+	}
+}
+
+/**
+ * Escapes special regex characters in a string
+ * @param input String to escape regex characters in
+ * @returns Escaped string safe for regex pattern matching
+ */
+function escapeRegExp(input: string): string {
+	return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+export const editTool = new EditTool()
+export const searchAndReplaceTool = editTool // alias for backward compat

+ 2 - 303
src/core/tools/SearchAndReplaceTool.ts

@@ -1,303 +1,2 @@
-import fs from "fs/promises"
-import path from "path"
-
-import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
-
-import { getReadablePath } from "../../utils/path"
-import { isPathOutsideWorkspace } from "../../utils/pathUtils"
-import { Task } from "../task/Task"
-import { formatResponse } from "../prompts/responses"
-import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
-import { fileExistsAtPath } from "../../utils/fs"
-import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
-import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats"
-import type { ToolUse } from "../../shared/tools"
-
-import { BaseTool, ToolCallbacks } from "./BaseTool"
-
-interface SearchReplaceOperation {
-	search: string
-	replace: string
-}
-
-interface SearchAndReplaceParams {
-	path: string
-	operations: SearchReplaceOperation[]
-}
-
-export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> {
-	readonly name = "search_and_replace" as const
-
-	async execute(params: SearchAndReplaceParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
-		const { path: relPath, operations } = params
-		const { askApproval, handleError, pushToolResult } = callbacks
-
-		try {
-			// Validate required parameters
-			if (!relPath) {
-				task.consecutiveMistakeCount++
-				task.recordToolError("search_and_replace")
-				pushToolResult(await task.sayAndCreateMissingParamError("search_and_replace", "path"))
-				return
-			}
-
-			if (!operations || !Array.isArray(operations) || operations.length === 0) {
-				task.consecutiveMistakeCount++
-				task.recordToolError("search_and_replace")
-				pushToolResult(
-					formatResponse.toolError(
-						"Missing or empty 'operations' parameter. At least one search/replace operation is required.",
-					),
-				)
-				return
-			}
-
-			// Validate each operation has search and replace fields
-			for (let i = 0; i < operations.length; i++) {
-				const op = operations[i]
-				if (!op.search) {
-					task.consecutiveMistakeCount++
-					task.recordToolError("search_and_replace")
-					pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'search' field.`))
-					return
-				}
-				if (op.replace === undefined) {
-					task.consecutiveMistakeCount++
-					task.recordToolError("search_and_replace")
-					pushToolResult(formatResponse.toolError(`Operation ${i + 1} is missing the 'replace' field.`))
-					return
-				}
-			}
-
-			const accessAllowed = task.rooIgnoreController?.validateAccess(relPath)
-
-			if (!accessAllowed) {
-				await task.say("rooignore_error", relPath)
-				pushToolResult(formatResponse.rooIgnoreError(relPath))
-				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)
-			if (!fileExists) {
-				task.consecutiveMistakeCount++
-				task.recordToolError("search_and_replace")
-				const errorMessage = `File not found: ${relPath}. Cannot perform search and replace on a non-existent file.`
-				await task.say("error", errorMessage)
-				pushToolResult(formatResponse.toolError(errorMessage))
-				return
-			}
-
-			let fileContent: string
-			try {
-				fileContent = await fs.readFile(absolutePath, "utf8")
-				// Normalize line endings to LF for consistent matching
-				fileContent = fileContent.replace(/\r\n/g, "\n")
-			} catch (error) {
-				task.consecutiveMistakeCount++
-				task.recordToolError("search_and_replace")
-				const errorMessage = `Failed to read file '${relPath}'. Please verify file permissions and try again.`
-				await task.say("error", errorMessage)
-				pushToolResult(formatResponse.toolError(errorMessage))
-				return
-			}
-
-			// Apply all operations sequentially
-			let newContent = fileContent
-			const errors: string[] = []
-
-			for (let i = 0; i < operations.length; i++) {
-				// Normalize line endings in search/replace strings to match file content
-				const search = operations[i].search.replace(/\r\n/g, "\n")
-				const replace = operations[i].replace.replace(/\r\n/g, "\n")
-				const searchPattern = new RegExp(escapeRegExp(search), "g")
-
-				const matchCount = newContent.match(searchPattern)?.length ?? 0
-				if (matchCount === 0) {
-					errors.push(`Operation ${i + 1}: No match found for search text.`)
-					continue
-				}
-
-				if (matchCount > 1) {
-					errors.push(
-						`Operation ${i + 1}: Found ${matchCount} matches. Please provide more context to make a unique match.`,
-					)
-					continue
-				}
-
-				// Apply the replacement
-				newContent = newContent.replace(searchPattern, replace)
-			}
-
-			// If all operations failed, return error
-			if (errors.length === operations.length) {
-				task.consecutiveMistakeCount++
-				task.recordToolError("search_and_replace", "no_match")
-				pushToolResult(formatResponse.toolError(`All operations failed:\n${errors.join("\n")}`))
-				return
-			}
-
-			// Check if any changes were made
-			if (newContent === fileContent) {
-				pushToolResult(`No changes needed for '${relPath}'`)
-				return
-			}
-
-			task.consecutiveMistakeCount = 0
-
-			// Initialize diff view
-			task.diffViewProvider.editType = "modify"
-			task.diffViewProvider.originalContent = fileContent
-
-			// Generate and validate diff
-			const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent)
-			if (!diff) {
-				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: "appliedDiff",
-				path: getReadablePath(task.cwd, relPath),
-				diff: sanitizedDiff,
-				isOutsideWorkspace,
-			}
-
-			// Include any partial errors in the message
-			let resultMessage = ""
-			if (errors.length > 0) {
-				resultMessage = `Some operations failed:\n${errors.join("\n")}\n\n`
-			}
-
-			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, false, 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 message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false)
-
-			// Add error info if some operations failed
-			if (errors.length > 0) {
-				pushToolResult(`${resultMessage}${message}`)
-			} else {
-				pushToolResult(message)
-			}
-
-			// Record successful tool usage and cleanup
-			task.recordToolUsage("search_and_replace")
-			await task.diffViewProvider.reset()
-			this.resetPartialState()
-
-			// Process any queued messages after file edit completes
-			task.processQueuedMessages()
-		} catch (error) {
-			await handleError("search and replace", error as Error)
-			await task.diffViewProvider.reset()
-			this.resetPartialState()
-		}
-	}
-
-	override async handlePartial(task: Task, block: ToolUse<"search_and_replace">): Promise<void> {
-		const relPath: string | undefined = block.params.path
-
-		// Wait for path to stabilize before showing UI (prevents truncated paths)
-		if (!this.hasPathStabilized(relPath)) {
-			return
-		}
-
-		const operationsStr: string | undefined = block.params.operations
-
-		let operationsPreview: string | undefined
-		if (operationsStr) {
-			try {
-				const ops = JSON.parse(operationsStr)
-				if (Array.isArray(ops) && ops.length > 0) {
-					operationsPreview = `${ops.length} operation(s)`
-				}
-			} catch {
-				operationsPreview = "parsing..."
-			}
-		}
-
-		// relPath is guaranteed non-null after hasPathStabilized
-		const absolutePath = path.resolve(task.cwd, relPath!)
-		const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)
-
-		const sharedMessageProps: ClineSayTool = {
-			tool: "appliedDiff",
-			path: getReadablePath(task.cwd, relPath!),
-			diff: operationsPreview,
-			isOutsideWorkspace,
-		}
-
-		await task.ask("tool", JSON.stringify(sharedMessageProps), block.partial).catch(() => {})
-	}
-}
-
-/**
- * Escapes special regex characters in a string
- * @param input String to escape regex characters in
- * @returns Escaped string safe for regex pattern matching
- */
-function escapeRegExp(input: string): string {
-	return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
-}
-
-export const searchAndReplaceTool = new SearchAndReplaceTool()
+// Deprecated: Use EditTool instead. This file exists only for backward compatibility.
+export { EditTool as SearchAndReplaceTool, searchAndReplaceTool } from "./EditTool"

+ 190 - 0
src/core/tools/__tests__/applyPatchTool.partial.spec.ts

@@ -0,0 +1,190 @@
+import path from "path"
+
+import type { MockedFunction } from "vitest"
+
+import type { ToolUse } from "../../../shared/tools"
+import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
+import type { Task } from "../../task/Task"
+import { ApplyPatchTool } from "../ApplyPatchTool"
+
+vi.mock("../../../utils/pathUtils", () => ({
+	isPathOutsideWorkspace: vi.fn(),
+}))
+
+interface PartialApplyPatchPayload {
+	tool: string
+	path: string
+	diff: string
+	isOutsideWorkspace: boolean
+}
+
+function parsePartialApplyPatchPayload(payloadText: string): PartialApplyPatchPayload {
+	const parsed: unknown = JSON.parse(payloadText)
+
+	if (!parsed || typeof parsed !== "object") {
+		throw new Error("Expected partial apply_patch payload to be a JSON object")
+	}
+
+	const payload = parsed as Record<string, unknown>
+
+	return {
+		tool: typeof payload.tool === "string" ? payload.tool : "",
+		path: typeof payload.path === "string" ? payload.path : "",
+		diff: typeof payload.diff === "string" ? payload.diff : "",
+		isOutsideWorkspace: typeof payload.isOutsideWorkspace === "boolean" ? payload.isOutsideWorkspace : false,
+	}
+}
+
+describe("ApplyPatchTool.handlePartial", () => {
+	const cwd = path.join(path.sep, "workspace", "project")
+	const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
+
+	let askSpy: MockedFunction<Task["ask"]>
+	let mockTask: Pick<Task, "cwd" | "ask">
+	let tool: ApplyPatchTool
+
+	beforeEach(() => {
+		vi.clearAllMocks()
+
+		askSpy = vi.fn().mockRejectedValue(new Error("ask() rejection is ignored for partial rows")) as MockedFunction<
+			Task["ask"]
+		>
+		mockTask = {
+			cwd,
+			ask: askSpy,
+		}
+
+		mockedIsPathOutsideWorkspace.mockImplementation((absolutePath) =>
+			absolutePath.replace(/\\/g, "/").includes("/outside/"),
+		)
+		tool = new ApplyPatchTool()
+	})
+
+	afterEach(() => {
+		tool.resetPartialState()
+	})
+
+	function createPartialBlock(patchText?: string): ToolUse<"apply_patch"> {
+		const params: ToolUse<"apply_patch">["params"] = {}
+		if (patchText !== undefined) {
+			params.patch = patchText
+		}
+
+		return {
+			type: "tool_use",
+			name: "apply_patch",
+			params,
+			partial: true,
+		}
+	}
+
+	async function executePartial(patchText?: string): Promise<PartialApplyPatchPayload> {
+		await tool.handlePartial(mockTask as Task, createPartialBlock(patchText))
+
+		const call = askSpy.mock.calls.at(-1)
+		expect(call).toBeDefined()
+
+		if (!call) {
+			throw new Error("Expected task.ask() to be called")
+		}
+
+		expect(call[0]).toBe("tool")
+		expect(call[2]).toBe(true)
+
+		const payloadText = call[1]
+		expect(typeof payloadText).toBe("string")
+
+		if (typeof payloadText !== "string") {
+			throw new Error("Expected partial payload text to be a string")
+		}
+
+		return parsePartialApplyPatchPayload(payloadText)
+	}
+
+	it("emits non-empty path from the first complete file header", async () => {
+		const patchText = `*** Begin Patch
+*** Update File: src/first.ts
+@@
+-old
++new
+*** End Patch`
+
+		const payload = await executePartial(patchText)
+
+		expect(payload.path).toBe("src/first.ts")
+		expect(payload.path.length).toBeGreaterThan(0)
+	})
+
+	it("uses first header path deterministically for multi-file patches", async () => {
+		const patchText = `*** Begin Patch
+*** Add File: docs/first.md
++content
+*** Update File: src/second.ts
+@@
+-a
++b
+*** End Patch`
+
+		const payload = await executePartial(patchText)
+
+		expect(payload.path).toBe("docs/first.md")
+	})
+
+	it("keeps stable first path when trailing second header is truncated", async () => {
+		/**
+		 * The final line has no trailing newline on purpose, simulating streaming truncation.
+		 * `extractFirstPathFromPatch()` should ignore this incomplete line and keep the first path.
+		 */
+		const patchText = `*** Begin Patch
+*** Update File: src/stable-first.ts
+@@
+-old
++new
+*** Update File: src/truncated-second`
+
+		const payload = await executePartial(patchText)
+
+		expect(payload.path).toBe("src/stable-first.ts")
+		expect(payload.path).not.toBe("")
+	})
+
+	it("falls back to deterministic non-blank path when no header is present", async () => {
+		const patchText = "*** Begin Patch\n@@\n-old\n+new"
+
+		const firstPayload = await executePartial(patchText)
+		const secondPayload = await executePartial(patchText)
+
+		const expectedFallbackPath = path.basename(cwd)
+		expect(firstPayload.path).toBe(expectedFallbackPath)
+		expect(secondPayload.path).toBe(expectedFallbackPath)
+		expect(firstPayload.path.length).toBeGreaterThan(0)
+	})
+
+	it("reflects isOutsideWorkspace for both derived and fallback paths", async () => {
+		const derivedPatch = `*** Begin Patch
+*** Update File: outside/derived.ts
+@@
+-old
++new
+*** End Patch`
+		const fallbackPatch = "*** Begin Patch\n@@\n-old\n+new"
+
+		const derivedPayload = await executePartial(derivedPatch)
+		const fallbackPayload = await executePartial(fallbackPatch)
+
+		expect(derivedPayload.path).toBe("outside/derived.ts")
+		expect(derivedPayload.isOutsideWorkspace).toBe(true)
+
+		expect(fallbackPayload.path).toBe(path.basename(cwd))
+		expect(fallbackPayload.isOutsideWorkspace).toBe(false)
+	})
+
+	it("preserves appliedDiff partial payload contract", async () => {
+		const payload = await executePartial(undefined)
+
+		expect(payload.tool).toBe("appliedDiff")
+		expect(payload.diff).toBe("Parsing patch...")
+		expect(payload.path).toBe(path.basename(cwd))
+		expect(typeof payload.isOutsideWorkspace).toBe("boolean")
+	})
+})

+ 423 - 0
src/core/tools/__tests__/editTool.spec.ts

@@ -0,0 +1,423 @@
+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 { editTool } from "../EditTool"
+
+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: string) => `Error: ${msg}`),
+		rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`),
+		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: string) => 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("editTool", () => {
+	// 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"
+
+	// 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 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)
+
+		toolResult = undefined
+	})
+
+	/**
+	 * Helper function to execute the edit tool with different parameters
+	 */
+	async function executeEditTool(
+		params: {
+			file_path?: string
+			old_string?: string
+			new_string?: string
+			replace_all?: string
+		} = {},
+		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 defaultParams = {
+			file_path: testFilePath,
+			old_string: "Line 2",
+			new_string: "Modified Line 2",
+		}
+		const fullParams: Record<string, string | undefined> = { ...defaultParams, ...params }
+
+		// Build nativeArgs from params (only include defined values)
+		const nativeArgs: Record<string, unknown> = {}
+		if (fullParams.file_path !== undefined) {
+			nativeArgs.file_path = fullParams.file_path
+		}
+		if (fullParams.old_string !== undefined) {
+			nativeArgs.old_string = fullParams.old_string
+		}
+		if (fullParams.new_string !== undefined) {
+			nativeArgs.new_string = fullParams.new_string
+		}
+		if (fullParams.replace_all !== undefined) {
+			nativeArgs.replace_all = fullParams.replace_all === "true"
+		}
+
+		const toolUse: ToolUse = {
+			type: "tool_use",
+			name: "edit",
+			params: fullParams as Partial<Record<string, string>>,
+			nativeArgs: nativeArgs as ToolUse<"edit">["nativeArgs"],
+			partial: isPartial,
+		}
+
+		mockPushToolResult = vi.fn((result: ToolResponse) => {
+			toolResult = result
+		})
+
+		await editTool.handle(mockTask, toolUse as ToolUse<"edit">, {
+			askApproval: mockAskApproval,
+			handleError: mockHandleError,
+			pushToolResult: mockPushToolResult,
+		})
+
+		return toolResult
+	}
+
+	describe("basic replacement", () => {
+		it("replaces a single unique occurrence of old_string with new_string", async () => {
+			await executeEditTool(
+				{ 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()
+		})
+	})
+
+	describe("replace_all", () => {
+		it("replaces all occurrences when replace_all is true", async () => {
+			await executeEditTool(
+				{ old_string: "Line", new_string: "Row", replace_all: "true" },
+				{ fileContent: "Line 1\nLine 2\nLine 3" },
+			)
+
+			expect(mockTask.consecutiveMistakeCount).toBe(0)
+			expect(mockTask.diffViewProvider.editType).toBe("modify")
+			expect(mockAskApproval).toHaveBeenCalled()
+		})
+	})
+
+	describe("uniqueness check", () => {
+		it("returns error when old_string appears multiple times without replace_all", async () => {
+			const result = await executeEditTool(
+				{ old_string: "Line", new_string: "Row" },
+				{ fileContent: "Line 1\nLine 2\nLine 3" },
+			)
+
+			expect(result).toContain("Error:")
+			expect(result).toContain("3 matches")
+			expect(result).toContain("replace_all")
+			expect(mockTask.consecutiveMistakeCount).toBe(1)
+			expect(mockTask.recordToolError).toHaveBeenCalledWith("edit")
+		})
+	})
+
+	describe("no match error", () => {
+		it("returns error when old_string is not found in the file", async () => {
+			const result = await executeEditTool(
+				{ old_string: "NonExistent", new_string: "New" },
+				{ 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", "no_match")
+		})
+	})
+
+	describe("old_string equals new_string", () => {
+		it("returns error when old_string and new_string are identical", async () => {
+			const result = await executeEditTool(
+				{ old_string: "Line 2", new_string: "Line 2" },
+				{ fileContent: "Line 1\nLine 2\nLine 3" },
+			)
+
+			expect(result).toContain("Error:")
+			expect(result).toContain("identical")
+			expect(mockTask.consecutiveMistakeCount).toBe(1)
+			expect(mockTask.recordToolError).toHaveBeenCalledWith("edit")
+		})
+	})
+
+	describe("missing required params", () => {
+		it("returns error when file_path is missing", async () => {
+			const result = await executeEditTool({ file_path: undefined })
+
+			expect(result).toBe("Missing param error")
+			expect(mockTask.consecutiveMistakeCount).toBe(1)
+			expect(mockTask.recordToolError).toHaveBeenCalledWith("edit")
+			expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("edit", "file_path")
+		})
+
+		it("returns error when old_string is missing", async () => {
+			const result = await executeEditTool({ old_string: undefined })
+
+			expect(result).toBe("Missing param error")
+			expect(mockTask.consecutiveMistakeCount).toBe(1)
+			expect(mockTask.recordToolError).toHaveBeenCalledWith("edit")
+			expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("edit", "old_string")
+		})
+
+		it("returns error when new_string is missing", async () => {
+			const result = await executeEditTool({ new_string: undefined })
+
+			expect(result).toBe("Missing param error")
+			expect(mockTask.consecutiveMistakeCount).toBe(1)
+			expect(mockTask.recordToolError).toHaveBeenCalledWith("edit")
+			expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("edit", "new_string")
+		})
+	})
+
+	describe("file access", () => {
+		it("returns error when file does not exist", async () => {
+			const result = await executeEditTool({}, { 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 executeEditTool({}, { accessAllowed: false })
+
+			expect(result).toContain("Access denied")
+		})
+	})
+
+	describe("approval workflow", () => {
+		it("saves changes when user approves", async () => {
+			mockAskApproval.mockResolvedValue(true)
+
+			await executeEditTool()
+
+			expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled()
+			expect(mockTask.didEditFile).toBe(true)
+			expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit")
+		})
+
+		it("reverts changes when user rejects", async () => {
+			mockAskApproval.mockResolvedValue(false)
+
+			const result = await executeEditTool()
+
+			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 after path stabilizes", async () => {
+			// Path stabilization requires two consecutive calls with the same path
+			await executeEditTool({}, { isPartial: true })
+			await executeEditTool({}, { 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",
+				params: {
+					file_path: testFilePath,
+					old_string: "Line 2",
+					new_string: "Modified",
+				},
+				nativeArgs: {
+					file_path: testFilePath,
+					old_string: "Line 2",
+					new_string: "Modified",
+				} as ToolUse<"edit">["nativeArgs"],
+				partial: false,
+			}
+
+			let capturedResult: ToolResponse | undefined
+			const localPushToolResult = vi.fn((result: ToolResponse) => {
+				capturedResult = result
+			})
+
+			await editTool.handle(mockTask, toolUse as ToolUse<"edit">, {
+				askApproval: mockAskApproval,
+				handleError: mockHandleError,
+				pushToolResult: localPushToolResult,
+			})
+
+			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 executeEditTool()
+
+			expect(mockHandleError).toHaveBeenCalledWith("edit", expect.any(Error))
+			expect(mockTask.diffViewProvider.reset).toHaveBeenCalled()
+		})
+	})
+
+	describe("file tracking", () => {
+		it("tracks file context after successful edit", async () => {
+			await executeEditTool()
+
+			expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited")
+		})
+	})
+})

+ 8 - 409
src/core/tools/__tests__/searchAndReplaceTool.spec.ts

@@ -1,414 +1,13 @@
-import * as path from "path"
-import fs from "fs/promises"
+// Deprecated: Tests for the old SearchAndReplaceTool.
+// Full edit tool tests are in editTool.spec.ts.
+// This file only verifies the backward-compatible re-export.
 
-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 { searchAndReplaceTool } from "../SearchAndReplaceTool"
+import { editTool } from "../EditTool"
 
-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("searchAndReplaceTool", () => {
-	// 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"
-
-	// 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 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)
-
-		toolResult = undefined
-	})
-
-	/**
-	 * Helper function to execute the search and replace tool with different parameters
-	 */
-	async function executeSearchAndReplaceTool(
-		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 baseParams: Record<string, unknown> = {
-			path: testFilePath,
-			operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]),
-		}
-		const fullParams: Record<string, unknown> = { ...baseParams, ...params }
-		const nativeArgs: Record<string, unknown> = {
-			path: fullParams.path,
-			operations:
-				typeof fullParams.operations === "string" ? JSON.parse(fullParams.operations) : fullParams.operations,
-		}
-
-		const toolUse: ToolUse = {
-			type: "tool_use",
-			name: "search_and_replace",
-			params: fullParams as any,
-			nativeArgs: nativeArgs as any,
-			partial: isPartial,
-		}
-
-		mockPushToolResult = vi.fn((result: ToolResponse) => {
-			toolResult = result
-		})
-
-		await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, {
-			askApproval: mockAskApproval,
-			handleError: mockHandleError,
-			pushToolResult: mockPushToolResult,
-		})
-
-		return toolResult
-	}
-
-	describe("parameter validation", () => {
-		it("returns error when path is missing", async () => {
-			const result = await executeSearchAndReplaceTool({ path: undefined })
-
-			expect(result).toBe("Missing param error")
-			expect(mockTask.consecutiveMistakeCount).toBe(1)
-			expect(mockTask.recordToolError).toHaveBeenCalledWith("search_and_replace")
-		})
-
-		it("returns error when operations is missing", async () => {
-			const result = await executeSearchAndReplaceTool({ operations: undefined })
-
-			expect(result).toContain("Error:")
-			expect(result).toContain("Missing or empty 'operations' parameter")
-			expect(mockTask.consecutiveMistakeCount).toBe(1)
-		})
-
-		it("returns error when operations is empty array", async () => {
-			const result = await executeSearchAndReplaceTool({ operations: JSON.stringify([]) })
-
-			expect(result).toContain("Error:")
-			expect(result).toContain("Missing or empty 'operations' parameter")
-			expect(mockTask.consecutiveMistakeCount).toBe(1)
-		})
-	})
-
-	describe("file access", () => {
-		it("returns error when file does not exist", async () => {
-			const result = await executeSearchAndReplaceTool({}, { 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 executeSearchAndReplaceTool({}, { accessAllowed: false })
-
-			expect(result).toContain("Access denied")
-		})
-	})
-
-	describe("search and replace logic", () => {
-		it("returns error when no match is found", async () => {
-			const result = await executeSearchAndReplaceTool(
-				{ operations: JSON.stringify([{ search: "NonExistent", replace: "New" }]) },
-				{ 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("search_and_replace", "no_match")
-		})
-
-		it("returns error when multiple matches are found", async () => {
-			const result = await executeSearchAndReplaceTool(
-				{ operations: JSON.stringify([{ search: "Line", replace: "Row" }]) },
-				{ fileContent: "Line 1\nLine 2\nLine 3" },
-			)
-
-			expect(result).toContain("Error:")
-			expect(result).toContain("3 matches")
-			expect(mockTask.consecutiveMistakeCount).toBe(1)
-		})
-
-		it("successfully replaces single unique match", async () => {
-			await executeSearchAndReplaceTool(
-				{ operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]) },
-				{ fileContent: "Line 1\nLine 2\nLine 3" },
-			)
-
-			expect(mockTask.consecutiveMistakeCount).toBe(0)
-			expect(mockTask.diffViewProvider.editType).toBe("modify")
-			expect(mockAskApproval).toHaveBeenCalled()
-		})
-	})
-
-	describe("CRLF normalization", () => {
-		it("normalizes CRLF to LF when reading file", async () => {
-			const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3"
-
-			await executeSearchAndReplaceTool(
-				{ operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]) },
-				{ fileContent: contentWithCRLF },
-			)
-
-			expect(mockTask.consecutiveMistakeCount).toBe(0)
-			expect(mockAskApproval).toHaveBeenCalled()
-		})
-
-		it("normalizes CRLF in search string to match LF-normalized file content", async () => {
-			// File has CRLF line endings
-			const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3"
-			// Search string also has CRLF (simulating what the model might send)
-			const searchWithCRLF = "Line 1\r\nLine 2"
-
-			await executeSearchAndReplaceTool(
-				{ operations: JSON.stringify([{ search: searchWithCRLF, replace: "Modified Lines" }]) },
-				{ fileContent: contentWithCRLF },
-			)
-
-			expect(mockTask.consecutiveMistakeCount).toBe(0)
-			expect(mockAskApproval).toHaveBeenCalled()
-		})
-
-		it("matches LF search string against CRLF file content after normalization", async () => {
-			// File has CRLF line endings
-			const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3"
-			// Search string has LF (typical model output)
-			const searchWithLF = "Line 1\nLine 2"
-
-			await executeSearchAndReplaceTool(
-				{ operations: JSON.stringify([{ search: searchWithLF, replace: "Modified Lines" }]) },
-				{ fileContent: contentWithCRLF },
-			)
-
-			expect(mockTask.consecutiveMistakeCount).toBe(0)
-			expect(mockAskApproval).toHaveBeenCalled()
-		})
-	})
-
-	describe("approval workflow", () => {
-		it("saves changes when user approves", async () => {
-			mockAskApproval.mockResolvedValue(true)
-
-			await executeSearchAndReplaceTool()
-
-			expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled()
-			expect(mockTask.didEditFile).toBe(true)
-			expect(mockTask.recordToolUsage).toHaveBeenCalledWith("search_and_replace")
-		})
-
-		it("reverts changes when user rejects", async () => {
-			mockAskApproval.mockResolvedValue(false)
-
-			const result = await executeSearchAndReplaceTool()
-
-			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 after path stabilizes", async () => {
-			// Path stabilization requires two consecutive calls with the same path
-			// First call sets lastSeenPartialPath, second call sees it has stabilized
-			await executeSearchAndReplaceTool({}, { isPartial: true })
-			await executeSearchAndReplaceTool({}, { 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: "search_and_replace",
-				params: {
-					path: testFilePath,
-					operations: JSON.stringify([{ search: "Line 2", replace: "Modified" }]),
-				},
-				nativeArgs: {
-					path: testFilePath,
-					operations: [{ search: "Line 2", replace: "Modified" }],
-				},
-				partial: false,
-			}
-
-			let capturedResult: ToolResponse | undefined
-			const localPushToolResult = vi.fn((result: ToolResponse) => {
-				capturedResult = result
-			})
-
-			await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, {
-				askApproval: mockAskApproval,
-				handleError: mockHandleError,
-				pushToolResult: localPushToolResult,
-			})
-
-			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 executeSearchAndReplaceTool()
-
-			expect(mockHandleError).toHaveBeenCalledWith("search and replace", expect.any(Error))
-			expect(mockTask.diffViewProvider.reset).toHaveBeenCalled()
-		})
-	})
-
-	describe("file tracking", () => {
-		it("tracks file context after successful edit", async () => {
-			await executeSearchAndReplaceTool()
-
-			expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited")
-		})
+describe("SearchAndReplaceTool re-export", () => {
+	it("exports searchAndReplaceTool as an alias for editTool", () => {
+		expect(searchAndReplaceTool).toBeDefined()
+		expect(searchAndReplaceTool).toBe(editTool)
 	})
 })

+ 12 - 4
src/core/tools/validateToolUse.ts

@@ -4,7 +4,7 @@ import { customToolRegistry } from "@roo-code/core"
 
 import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes"
 import { EXPERIMENT_IDS } from "../../shared/experiments"
-import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../shared/tools"
+import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS, TOOL_ALIASES } from "../../shared/tools"
 
 /**
  * Checks if a tool name is a valid, known tool.
@@ -126,11 +126,18 @@ export function isToolAllowedForMode(
 	experiments?: Record<string, boolean>,
 	includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo)
 ): boolean {
+	// Resolve alias to canonical name (e.g., "search_and_replace" → "edit")
+	const resolvedTool = TOOL_ALIASES[tool] ?? tool
+	const resolvedIncludedTools = includedTools?.map((t) => TOOL_ALIASES[t] ?? t)
+
 	// Check tool requirements first — explicit disabling takes priority over everything,
 	// including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently
 	// at both the filtering layer and the execution-time validation layer.
 	if (toolRequirements && typeof toolRequirements === "object") {
-		if (tool in toolRequirements && !toolRequirements[tool]) {
+		if (
+			(tool in toolRequirements && !toolRequirements[tool]) ||
+			(resolvedTool in toolRequirements && !toolRequirements[resolvedTool])
+		) {
 			return false
 		}
 	} else if (toolRequirements === false) {
@@ -179,10 +186,11 @@ export function isToolAllowedForMode(
 		}
 
 		// Check if the tool is in the group's regular tools
-		const isRegularTool = groupConfig.tools.includes(tool)
+		const isRegularTool = groupConfig.tools.includes(resolvedTool)
 
 		// Check if the tool is a custom tool that has been explicitly included
-		const isCustomTool = groupConfig.customTools?.includes(tool) && includedTools?.includes(tool)
+		const isCustomTool =
+			groupConfig.customTools?.includes(resolvedTool) && resolvedIncludedTools?.includes(resolvedTool)
 
 		// If the tool isn't in regular tools and isn't an included custom tool, continue to next group
 		if (!isRegularTool && !isCustomTool) {

+ 6 - 2
src/shared/tools.ts

@@ -71,6 +71,7 @@ export const toolParamNames = [
 	"file_path", // search_replace and edit_file parameter
 	"old_string", // search_replace and edit_file parameter
 	"new_string", // search_replace and edit_file parameter
+	"replace_all", // edit tool parameter for replacing all occurrences
 	"expected_replacements", // edit_file parameter for multiple occurrences
 	"artifact_id", // read_command_output parameter
 	"search", // read_command_output parameter for grep-like search
@@ -101,7 +102,8 @@ export type NativeToolArgs = {
 	attempt_completion: { result: string }
 	execute_command: { command: string; cwd?: string }
 	apply_diff: { path: string; diff: string }
-	search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> }
+	edit: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }
+	search_and_replace: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }
 	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 }
@@ -281,6 +283,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
 	read_command_output: "read command output",
 	write_to_file: "write files",
 	apply_diff: "apply changes",
+	edit: "edit files",
 	search_and_replace: "apply changes using search and replace",
 	search_replace: "apply single search and replace",
 	edit_file: "edit files using search and replace",
@@ -309,7 +312,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
 	},
 	edit: {
 		tools: ["apply_diff", "write_to_file", "generate_image"],
-		customTools: ["search_and_replace", "search_replace", "edit_file", "apply_patch"],
+		customTools: ["edit", "search_replace", "edit_file", "apply_patch"],
 	},
 	browser: {
 		tools: ["browser_action"],
@@ -349,6 +352,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [
  */
 export const TOOL_ALIASES: Record<string, ToolName> = {
 	write_file: "write_to_file",
+	search_and_replace: "edit",
 } as const
 
 export type DiffResult =

+ 34 - 68
webview-ui/src/components/chat/ChatRow.tsx

@@ -406,6 +406,14 @@ export const ChatRowContent = ({
 		return (tool.content ?? tool.diff) as string | undefined
 	}, [tool])
 
+	const onJumpToCreatedFile = useMemo(() => {
+		if (!tool || tool.tool !== "newFileCreated" || !tool.path) {
+			return undefined
+		}
+
+		return () => vscode.postMessage({ type: "openFile", text: "./" + tool.path })
+	}, [tool])
+
 	const followUpData = useMemo(() => {
 		if (message.type === "ask" && message.ask === "followup" && !message.partial) {
 			return safeJsonParse<FollowUpData>(message.text)
@@ -436,6 +444,29 @@ export const ChatRowContent = ({
 		switch (tool.tool as string) {
 			case "editedExistingFile":
 			case "appliedDiff":
+			case "newFileCreated":
+			case "searchAndReplace":
+			case "search_and_replace":
+			case "search_replace":
+			case "edit":
+			case "edit_file":
+			case "apply_patch":
+			case "apply_diff":
+				// Check if this is a batch diff request
+				if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) {
+					return (
+						<>
+							<div style={headerStyle}>
+								<FileDiff className="w-4 shrink-0" aria-label="Batch diff icon" />
+								<span style={{ fontWeight: "bold" }}>
+									{t("chat:fileOperations.wantsToApplyBatchChanges")}
+								</span>
+							</div>
+							<BatchDiffApproval files={tool.batchDiffs} ts={message.ts} />
+						</>
+					)
+				}
+
 				// Regular single file diff
 				return (
 					<>
@@ -446,7 +477,7 @@ export const ChatRowContent = ({
 									style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
 								/>
 							) : (
-								toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit")
+								toolIcon("diff")
 							)}
 							<span style={{ fontWeight: "bold" }}>
 								{tool.isProtected
@@ -459,12 +490,13 @@ export const ChatRowContent = ({
 						<div className="pl-6">
 							<CodeAccordian
 								path={tool.path}
-								code={unifiedDiff ?? tool.content ?? tool.diff}
+								code={unifiedDiff ?? tool.content ?? tool.diff ?? ""}
 								language="diff"
 								progressStatus={message.progressStatus}
 								isLoading={message.partial}
 								isExpanded={isExpanded}
 								onToggleExpand={handleToggleExpand}
+								onJumpToFile={onJumpToCreatedFile}
 								diffStats={tool.diffStats}
 							/>
 						</div>
@@ -508,40 +540,6 @@ export const ChatRowContent = ({
 						</div>
 					</>
 				)
-			case "searchAndReplace":
-				return (
-					<>
-						<div style={headerStyle}>
-							{tool.isProtected ? (
-								<span
-									className="codicon codicon-lock"
-									style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
-								/>
-							) : (
-								toolIcon("replace")
-							)}
-							<span style={{ fontWeight: "bold" }}>
-								{tool.isProtected && message.type === "ask"
-									? t("chat:fileOperations.wantsToEditProtected")
-									: message.type === "ask"
-										? t("chat:fileOperations.wantsToSearchReplace")
-										: t("chat:fileOperations.didSearchReplace")}
-							</span>
-						</div>
-						<div className="pl-6">
-							<CodeAccordian
-								path={tool.path}
-								code={unifiedDiff ?? tool.diff}
-								language="diff"
-								progressStatus={message.progressStatus}
-								isLoading={message.partial}
-								isExpanded={isExpanded}
-								onToggleExpand={handleToggleExpand}
-								diffStats={tool.diffStats}
-							/>
-						</div>
-					</>
-				)
 			case "codebaseSearch": {
 				return (
 					<div style={headerStyle}>
@@ -571,38 +569,6 @@ export const ChatRowContent = ({
 
 				return <TodoChangeDisplay previousTodos={previousTodos} newTodos={todos} />
 			}
-			case "newFileCreated":
-				return (
-					<>
-						<div style={headerStyle}>
-							{tool.isProtected ? (
-								<span
-									className="codicon codicon-lock"
-									style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }}
-								/>
-							) : (
-								toolIcon("new-file")
-							)}
-							<span style={{ fontWeight: "bold" }}>
-								{tool.isProtected
-									? t("chat:fileOperations.wantsToEditProtected")
-									: t("chat:fileOperations.wantsToCreate")}
-							</span>
-						</div>
-						<div className="pl-6">
-							<CodeAccordian
-								path={tool.path}
-								code={unifiedDiff ?? ""}
-								language="diff"
-								isLoading={message.partial}
-								isExpanded={isExpanded}
-								onToggleExpand={handleToggleExpand}
-								onJumpToFile={() => vscode.postMessage({ type: "openFile", text: "./" + tool.path })}
-								diffStats={tool.diffStats}
-							/>
-						</div>
-					</>
-				)
 			case "readFile":
 				// Check if this is a batch file permission request
 				const isBatchRequest = message.type === "ask" && tool.batchFiles && Array.isArray(tool.batchFiles)

+ 133 - 62
webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx

@@ -1,15 +1,27 @@
 import React from "react"
-import { render, screen } from "@/utils/test-utils"
+import { fireEvent, render, screen } from "@/utils/test-utils"
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import type { ClineMessage } from "@roo-code/types"
 import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
 import { ChatRowContent } from "../ChatRow"
 
+const mockPostMessage = vi.fn()
+
+vi.mock("@src/utils/vscode", () => ({
+	vscode: {
+		postMessage: (...args: unknown[]) => mockPostMessage(...args),
+	},
+}))
+
 // Mock i18n
 vi.mock("react-i18next", () => ({
 	useTranslation: () => ({
 		t: (key: string) => {
 			const map: Record<string, string> = {
 				"chat:fileOperations.wantsToEdit": "Roo wants to edit this file",
+				"chat:fileOperations.wantsToEditProtected": "Roo wants to edit a protected file",
+				"chat:fileOperations.wantsToEditOutsideWorkspace": "Roo wants to edit outside workspace",
+				"chat:fileOperations.wantsToApplyBatchChanges": "Roo wants to apply batch changes",
 			}
 			return map[key] || key
 		},
@@ -25,7 +37,17 @@ vi.mock("@src/components/common/CodeBlock", () => ({
 
 const queryClient = new QueryClient()
 
-function renderChatRow(message: any, isExpanded = false) {
+function createToolAskMessage(toolPayload: Record<string, unknown>): ClineMessage {
+	return {
+		type: "ask",
+		ask: "tool",
+		ts: Date.now(),
+		partial: false,
+		text: JSON.stringify(toolPayload),
+	}
+}
+
+function renderChatRow(message: ClineMessage, isExpanded = false) {
 	return render(
 		<ExtensionStateContextProvider>
 			<QueryClientProvider client={queryClient}>
@@ -48,92 +70,141 @@ function renderChatRow(message: any, isExpanded = false) {
 describe("ChatRow - inline diff stats and actions", () => {
 	beforeEach(() => {
 		vi.clearAllMocks()
+		mockPostMessage.mockClear()
 	})
 
-	it("shows + and - counts for editedExistingFile ask", () => {
+	it("uses appliedDiff edit treatment (header/icon/diff stats)", () => {
 		const diff = "@@ -1,1 +1,1 @@\n-old\n+new\n"
-		const message: any = {
-			type: "ask",
-			ask: "tool",
-			ts: Date.now(),
-			partial: false,
-			text: JSON.stringify({
-				tool: "editedExistingFile",
-				path: "src/file.ts",
-				diff,
-				diffStats: { added: 1, removed: 1 },
-			}),
-		}
+		const message = createToolAskMessage({
+			tool: "appliedDiff",
+			path: "src/file.ts",
+			diff,
+			diffStats: { added: 1, removed: 1 },
+		})
+
+		const { container } = renderChatRow(message, false)
 
-		renderChatRow(message, false)
+		expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument()
+		expect(container.querySelector(".codicon-diff")).toBeInTheDocument()
+		expect(screen.getByText("+1")).toBeInTheDocument()
+		expect(screen.getByText("-1")).toBeInTheDocument()
+	})
 
-		// Plus/minus counts
+	it("uses same edit treatment for editedExistingFile", () => {
+		const diff = "@@ -1,1 +1,1 @@\n-old\n+new\n"
+		const message = createToolAskMessage({
+			tool: "editedExistingFile",
+			path: "src/file.ts",
+			diff,
+			diffStats: { added: 1, removed: 1 },
+		})
+
+		const { container } = renderChatRow(message)
+
+		expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument()
+		expect(container.querySelector(".codicon-diff")).toBeInTheDocument()
 		expect(screen.getByText("+1")).toBeInTheDocument()
 		expect(screen.getByText("-1")).toBeInTheDocument()
 	})
 
-	it("derives counts from searchAndReplace diff", () => {
+	it("uses same edit treatment for searchAndReplace", () => {
 		const diff = "-a\n-b\n+c\n"
-		const message: any = {
-			type: "ask",
-			ask: "tool",
-			ts: Date.now(),
-			partial: false,
-			text: JSON.stringify({
-				tool: "searchAndReplace",
-				path: "src/file.ts",
-				diff,
-				diffStats: { added: 1, removed: 2 },
-			}),
-		}
+		const message = createToolAskMessage({
+			tool: "searchAndReplace",
+			path: "src/file.ts",
+			diff,
+			diffStats: { added: 1, removed: 2 },
+		})
 
-		renderChatRow(message)
+		const { container } = renderChatRow(message)
 
+		expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument()
+		expect(container.querySelector(".codicon-diff")).toBeInTheDocument()
 		expect(screen.getByText("+1")).toBeInTheDocument()
 		expect(screen.getByText("-2")).toBeInTheDocument()
 	})
 
-	it("counts only added lines for newFileCreated (ignores diff headers)", () => {
+	it("uses same edit treatment for newFileCreated", () => {
 		const content = "a\nb\nc"
-		const message: any = {
-			type: "ask",
-			ask: "tool",
-			ts: Date.now(),
-			partial: false,
-			text: JSON.stringify({
-				tool: "newFileCreated",
-				path: "src/new-file.ts",
-				content,
-				diffStats: { added: 3, removed: 0 },
-			}),
-		}
+		const message = createToolAskMessage({
+			tool: "newFileCreated",
+			path: "src/new-file.ts",
+			content,
+			diffStats: { added: 3, removed: 0 },
+		})
 
-		renderChatRow(message)
+		const { container } = renderChatRow(message)
 
-		// Should only count the three content lines as additions
+		expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument()
+		expect(container.querySelector(".codicon-diff")).toBeInTheDocument()
 		expect(screen.getByText("+3")).toBeInTheDocument()
 		expect(screen.getByText("-0")).toBeInTheDocument()
 	})
 
-	it("counts only added lines for newFileCreated with trailing newline", () => {
-		const content = "a\nb\nc\n"
-		const message: any = {
-			type: "ask",
-			ask: "tool",
-			ts: Date.now(),
-			partial: false,
-			text: JSON.stringify({
-				tool: "newFileCreated",
-				path: "src/new-file.ts",
-				content,
-				diffStats: { added: 3, removed: 0 },
-			}),
+	it("preserves jump-to-file affordance for newFileCreated", () => {
+		const message = createToolAskMessage({
+			tool: "newFileCreated",
+			path: "src/new-file.ts",
+			content: "+new file",
+			diffStats: { added: 1, removed: 0 },
+		})
+
+		const { container } = renderChatRow(message)
+		const openFileIcon = container.querySelector(".codicon-link-external") as HTMLElement | null
+
+		expect(openFileIcon).toBeInTheDocument()
+		if (!openFileIcon) {
+			throw new Error("Expected external link icon for newFileCreated")
 		}
 
+		fireEvent.click(openFileIcon)
+
+		expect(mockPostMessage).toHaveBeenCalledWith({
+			type: "openFile",
+			text: "./src/new-file.ts",
+		})
+	})
+
+	it("preserves protected and outside-workspace messaging in unified branch", () => {
+		const outsideWorkspaceMessage = createToolAskMessage({
+			tool: "searchAndReplace",
+			path: "../outside/file.ts",
+			diff: "-a\n+b\n",
+			isOutsideWorkspace: true,
+			diffStats: { added: 1, removed: 1 },
+		})
+		renderChatRow(outsideWorkspaceMessage)
+		expect(screen.getByText("Roo wants to edit outside workspace")).toBeInTheDocument()
+
+		const protectedMessage = createToolAskMessage({
+			tool: "appliedDiff",
+			path: "src/protected.ts",
+			diff: "-a\n+b\n",
+			isProtected: true,
+			diffStats: { added: 1, removed: 1 },
+		})
+		const { container } = renderChatRow(protectedMessage)
+		expect(screen.getByText("Roo wants to edit a protected file")).toBeInTheDocument()
+		expect(container.querySelector(".codicon-lock")).toBeInTheDocument()
+	})
+
+	it("keeps batch diff handling for unified edit tools", () => {
+		const message = createToolAskMessage({
+			tool: "searchAndReplace",
+			batchDiffs: [
+				{
+					path: "src/a.ts",
+					changeCount: 1,
+					key: "a",
+					content: "@@ -1,1 +1,1 @@\n-a\n+b\n",
+					diffStats: { added: 1, removed: 1 },
+				},
+			],
+		})
+
 		renderChatRow(message)
 
-		// Trailing newline should not increase the added count
-		expect(screen.getByText("+3")).toBeInTheDocument()
-		expect(screen.getByText("-0")).toBeInTheDocument()
+		expect(screen.getByText("Roo wants to apply batch changes")).toBeInTheDocument()
+		expect(screen.getByText((text) => text.includes("src/a.ts"))).toBeInTheDocument()
 	})
 })

+ 6 - 6
webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx

@@ -27,7 +27,7 @@ const mockTasks: HistoryItem[] = [
 		id: "task-1",
 		number: 1,
 		task: "First task",
-		ts: Date.now(),
+		ts: 600,
 		tokensIn: 100,
 		tokensOut: 50,
 		totalCost: 0.01,
@@ -36,7 +36,7 @@ const mockTasks: HistoryItem[] = [
 		id: "task-2",
 		number: 2,
 		task: "Second task",
-		ts: Date.now(),
+		ts: 500,
 		tokensIn: 200,
 		tokensOut: 100,
 		totalCost: 0.02,
@@ -45,7 +45,7 @@ const mockTasks: HistoryItem[] = [
 		id: "task-3",
 		number: 3,
 		task: "Third task",
-		ts: Date.now(),
+		ts: 400,
 		tokensIn: 150,
 		tokensOut: 75,
 		totalCost: 0.015,
@@ -54,7 +54,7 @@ const mockTasks: HistoryItem[] = [
 		id: "task-4",
 		number: 4,
 		task: "Fourth task",
-		ts: Date.now(),
+		ts: 300,
 		tokensIn: 300,
 		tokensOut: 150,
 		totalCost: 0.03,
@@ -63,7 +63,7 @@ const mockTasks: HistoryItem[] = [
 		id: "task-5",
 		number: 5,
 		task: "Fifth task",
-		ts: Date.now(),
+		ts: 200,
 		tokensIn: 250,
 		tokensOut: 125,
 		totalCost: 0.025,
@@ -72,7 +72,7 @@ const mockTasks: HistoryItem[] = [
 		id: "task-6",
 		number: 6,
 		task: "Sixth task",
-		ts: Date.now(),
+		ts: 100,
 		tokensIn: 400,
 		tokensOut: 200,
 		totalCost: 0.04,