diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts
index c31f63df76..4f6085a03f 100644
--- a/packages/types/src/tool.ts
+++ b/packages/types/src/tool.ts
@@ -35,6 +35,7 @@ export const toolNames = [
"codebase_search",
"update_todo_list",
"generate_image",
+ "edit_and_execute",
] as const
export const toolNamesSchema = z.enum(toolNames)
diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts
index af1c57a5ee..26cc58e14f 100644
--- a/src/core/assistant-message/presentAssistantMessage.ts
+++ b/src/core/assistant-message/presentAssistantMessage.ts
@@ -29,6 +29,7 @@ import { newTaskTool } from "../tools/newTaskTool"
import { updateTodoListTool } from "../tools/updateTodoListTool"
import { generateImageTool } from "../tools/generateImageTool"
+import { editAndExecuteTool } from "../tools/editAndExecuteTool"
import { formatResponse } from "../prompts/responses"
import { validateToolUse } from "../tools/validateToolUse"
@@ -224,6 +225,8 @@ export async function presentAssistantMessage(cline: Task) {
}
case "generate_image":
return `[${block.name} for '${block.params.path}']`
+ case "edit_and_execute":
+ return `[${block.name}]`
}
}
@@ -552,6 +555,9 @@ export async function presentAssistantMessage(cline: Task) {
case "generate_image":
await generateImageTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
+ case "edit_and_execute":
+ await editAndExecuteTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
+ break
}
break
diff --git a/src/core/tools/__tests__/editAndExecuteTool.spec.ts b/src/core/tools/__tests__/editAndExecuteTool.spec.ts
new file mode 100644
index 0000000000..e6024b1694
--- /dev/null
+++ b/src/core/tools/__tests__/editAndExecuteTool.spec.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { editAndExecuteTool } from "../editAndExecuteTool"
+import { Task } from "../../task/Task"
+import { ToolUse } from "../../../shared/tools"
+import * as writeToFileToolModule from "../writeToFileTool"
+import * as executeCommandToolModule from "../executeCommandTool"
+
+// Mock the imported tool modules
+vi.mock("../writeToFileTool")
+vi.mock("../executeCommandTool")
+vi.mock("../applyDiffTool")
+vi.mock("../insertContentTool")
+vi.mock("../searchAndReplaceTool")
+
+describe("editAndExecuteTool", () => {
+ let mockCline: Task
+ let mockAskApproval: any
+ let mockHandleError: any
+ let mockPushToolResult: any
+ let mockRemoveClosingTag: any
+
+ beforeEach(() => {
+ // Reset all mocks
+ vi.clearAllMocks()
+
+ // Create mock Cline instance
+ mockCline = {
+ consecutiveMistakeCount: 0,
+ recordToolError: vi.fn(),
+ recordToolUsage: vi.fn(),
+ sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
+ ask: vi.fn().mockResolvedValue({}),
+ cwd: "/test/workspace",
+ providerRef: {
+ deref: vi.fn().mockReturnValue({
+ getState: vi.fn().mockResolvedValue({ experiments: {} }),
+ }),
+ },
+ } as any
+
+ // Create mock functions
+ mockAskApproval = vi.fn().mockResolvedValue(true)
+ mockHandleError = vi.fn()
+ mockPushToolResult = vi.fn()
+ mockRemoveClosingTag = vi.fn((tag, content) => content)
+ })
+
+ it("should handle missing args parameter", async () => {
+ const block: ToolUse = {
+ type: "tool_use",
+ name: "edit_and_execute",
+ params: {},
+ partial: false,
+ }
+
+ await editAndExecuteTool(
+ mockCline,
+ block,
+ mockAskApproval,
+ mockHandleError,
+ mockPushToolResult,
+ mockRemoveClosingTag,
+ )
+
+ expect(mockCline.consecutiveMistakeCount).toBe(1)
+ expect(mockCline.recordToolError).toHaveBeenCalledWith("edit_and_execute")
+ expect(mockPushToolResult).toHaveBeenCalledWith("Missing parameter error")
+ })
+
+ it("should handle missing edit block", async () => {
+ const block: ToolUse = {
+ type: "tool_use",
+ name: "edit_and_execute",
+ params: {
+ args: "ls",
+ },
+ partial: false,
+ }
+
+ await editAndExecuteTool(
+ mockCline,
+ block,
+ mockAskApproval,
+ mockHandleError,
+ mockPushToolResult,
+ mockRemoveClosingTag,
+ )
+
+ expect(mockCline.consecutiveMistakeCount).toBe(1)
+ expect(mockCline.recordToolError).toHaveBeenCalledWith("edit_and_execute")
+ expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Missing or invalid block"))
+ })
+
+ it("should handle missing execute block", async () => {
+ const block: ToolUse = {
+ type: "tool_use",
+ name: "edit_and_execute",
+ params: {
+ args: "test.txthello",
+ },
+ partial: false,
+ }
+
+ await editAndExecuteTool(
+ mockCline,
+ block,
+ mockAskApproval,
+ mockHandleError,
+ mockPushToolResult,
+ mockRemoveClosingTag,
+ )
+
+ expect(mockCline.consecutiveMistakeCount).toBe(1)
+ expect(mockCline.recordToolError).toHaveBeenCalledWith("edit_and_execute")
+ expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Missing or invalid block"))
+ })
+
+ it("should handle partial blocks", async () => {
+ const block: ToolUse = {
+ type: "tool_use",
+ name: "edit_and_execute",
+ params: {
+ args: "partial content",
+ },
+ partial: true,
+ }
+
+ await editAndExecuteTool(
+ mockCline,
+ block,
+ mockAskApproval,
+ mockHandleError,
+ mockPushToolResult,
+ mockRemoveClosingTag,
+ )
+
+ expect(mockCline.ask).toHaveBeenCalledWith(
+ "tool",
+ expect.stringContaining("Processing edit and execute operation"),
+ true,
+ )
+ expect(mockPushToolResult).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/core/tools/editAndExecuteTool.ts b/src/core/tools/editAndExecuteTool.ts
new file mode 100644
index 0000000000..0d36302688
--- /dev/null
+++ b/src/core/tools/editAndExecuteTool.ts
@@ -0,0 +1,304 @@
+import { Task } from "../task/Task"
+import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools"
+import { formatResponse } from "../prompts/responses"
+import { ClineSayTool } from "../../shared/ExtensionMessage"
+import { getReadablePath } from "../../utils/path"
+
+// Import the existing tools that we'll delegate to
+import { writeToFileTool } from "./writeToFileTool"
+import { applyDiffToolLegacy } from "./applyDiffTool"
+import { applyDiffTool } from "./multiApplyDiffTool"
+import { insertContentTool } from "./insertContentTool"
+import { searchAndReplaceTool } from "./searchAndReplaceTool"
+import { executeCommandTool } from "./executeCommandTool"
+import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
+
+/**
+ * Tool for performing an edit operation followed by a command execution.
+ * This is a composite tool that delegates to existing edit tools and then executes a command.
+ *
+ * The tool accepts nested XML structure with an block containing any edit tool,
+ * and an block containing the execute_command tool.
+ *
+ * If the edit operation fails, the command execution is not attempted.
+ */
+export async function editAndExecuteTool(
+ cline: Task,
+ block: ToolUse,
+ askApproval: AskApproval,
+ handleError: HandleError,
+ pushToolResult: PushToolResult,
+ removeClosingTag: RemoveClosingTag,
+) {
+ // Parse the nested XML structure from the params
+ // We'll use a generic 'args' parameter that contains the full XML structure
+ const argsContent = block.params.args
+
+ if (block.partial) {
+ // For partial blocks, show progress
+ const partialMessage = {
+ tool: "editAndExecute" as const,
+ path: getReadablePath(cline.cwd, ""),
+ content: "Processing edit and execute operation...",
+ }
+ await cline.ask("tool", JSON.stringify(partialMessage), block.partial).catch(() => {})
+ return
+ }
+
+ // Validate required parameters
+ if (!argsContent) {
+ cline.consecutiveMistakeCount++
+ cline.recordToolError("edit_and_execute")
+ pushToolResult(await cline.sayAndCreateMissingParamError("edit_and_execute", "args"))
+ return
+ }
+
+ // Parse the edit and execute blocks from the args
+ const editMatch = argsContent.match(/([\s\S]*?)<\/edit>/i)
+ const executeMatch = argsContent.match(/([\s\S]*?)<\/execute>/i)
+
+ if (!editMatch || !editMatch[1]) {
+ cline.consecutiveMistakeCount++
+ cline.recordToolError("edit_and_execute")
+ pushToolResult(formatResponse.toolError("Missing or invalid block in edit_and_execute tool"))
+ return
+ }
+
+ if (!executeMatch || !executeMatch[1]) {
+ cline.consecutiveMistakeCount++
+ cline.recordToolError("edit_and_execute")
+ pushToolResult(formatResponse.toolError("Missing or invalid block in edit_and_execute tool"))
+ return
+ }
+
+ const editContent = editMatch[1].trim()
+ const executeContent = executeMatch[1].trim()
+
+ // Parse the edit tool from the edit content
+ const editToolNameMatch = editContent.match(/^<(\w+)>/)
+ if (!editToolNameMatch) {
+ cline.consecutiveMistakeCount++
+ cline.recordToolError("edit_and_execute")
+ pushToolResult(formatResponse.toolError("Invalid edit tool format in block"))
+ return
+ }
+
+ const editToolName = editToolNameMatch[1]
+
+ // Create a ToolUse object for the edit operation
+ const editToolUse: ToolUse = {
+ type: "tool_use",
+ name: editToolName as any, // We'll validate this below
+ params: {},
+ partial: false,
+ }
+
+ // Parse parameters for the edit tool based on its type
+ let editSucceeded = false
+ let editResult: string = ""
+
+ // Custom result collector for the edit operation
+ const collectEditResult = (content: any) => {
+ if (typeof content === "string") {
+ editResult = content
+ } else if (Array.isArray(content)) {
+ editResult = content
+ .map((item) => (typeof item === "object" && item.type === "text" ? item.text : ""))
+ .join("\n")
+ }
+ }
+
+ try {
+ switch (editToolName) {
+ case "write_to_file": {
+ const pathMatch = editContent.match(/([\s\S]*?)<\/path>/)
+ const contentMatch = editContent.match(/([\s\S]*?)<\/content>/)
+ const lineCountMatch = editContent.match(/([\s\S]*?)<\/line_count>/)
+
+ if (pathMatch) editToolUse.params.path = pathMatch[1].trim()
+ if (contentMatch) editToolUse.params.content = contentMatch[1]
+ if (lineCountMatch) editToolUse.params.line_count = lineCountMatch[1].trim()
+
+ await writeToFileTool(cline, editToolUse, askApproval, handleError, collectEditResult, removeClosingTag)
+ editSucceeded = true
+ break
+ }
+ case "apply_diff": {
+ // Check if multi-file apply diff is enabled
+ const provider = cline.providerRef.deref()
+ let isMultiFileApplyDiffEnabled = false
+
+ if (provider) {
+ const state = await provider.getState()
+ isMultiFileApplyDiffEnabled = experiments.isEnabled(
+ state.experiments ?? {},
+ EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
+ )
+ }
+
+ if (isMultiFileApplyDiffEnabled) {
+ // For multi-file, use args parameter
+ editToolUse.params.args = editContent.replace(/^/, "").replace(/<\/apply_diff>$/, "")
+ await applyDiffTool(
+ cline,
+ editToolUse,
+ askApproval,
+ handleError,
+ collectEditResult,
+ removeClosingTag,
+ )
+ } else {
+ // For single file, parse path and diff
+ const pathMatch = editContent.match(/([\s\S]*?)<\/path>/)
+ const diffMatch = editContent.match(/([\s\S]*?)<\/diff>/)
+
+ if (pathMatch) editToolUse.params.path = pathMatch[1].trim()
+ if (diffMatch) editToolUse.params.diff = diffMatch[1]
+
+ await applyDiffToolLegacy(
+ cline,
+ editToolUse,
+ askApproval,
+ handleError,
+ collectEditResult,
+ removeClosingTag,
+ )
+ }
+ editSucceeded = true
+ break
+ }
+ case "insert_content": {
+ const pathMatch = editContent.match(/([\s\S]*?)<\/path>/)
+ const lineMatch = editContent.match(/([\s\S]*?)<\/line>/)
+ const contentMatch = editContent.match(/([\s\S]*?)<\/content>/)
+
+ if (pathMatch) editToolUse.params.path = pathMatch[1].trim()
+ if (lineMatch) editToolUse.params.line = lineMatch[1].trim()
+ if (contentMatch) editToolUse.params.content = contentMatch[1]
+
+ await insertContentTool(
+ cline,
+ editToolUse,
+ askApproval,
+ handleError,
+ collectEditResult,
+ removeClosingTag,
+ )
+ editSucceeded = true
+ break
+ }
+ case "search_and_replace": {
+ const pathMatch = editContent.match(/([\s\S]*?)<\/path>/)
+ const searchMatch = editContent.match(/([\s\S]*?)<\/search>/)
+ const replaceMatch = editContent.match(/([\s\S]*?)<\/replace>/)
+ const useRegexMatch = editContent.match(/([\s\S]*?)<\/use_regex>/)
+ const ignoreCaseMatch = editContent.match(/([\s\S]*?)<\/ignore_case>/)
+ const startLineMatch = editContent.match(/([\s\S]*?)<\/start_line>/)
+ const endLineMatch = editContent.match(/([\s\S]*?)<\/end_line>/)
+
+ if (pathMatch) editToolUse.params.path = pathMatch[1].trim()
+ if (searchMatch) editToolUse.params.search = searchMatch[1]
+ if (replaceMatch) editToolUse.params.replace = replaceMatch[1]
+ if (useRegexMatch) editToolUse.params.use_regex = useRegexMatch[1].trim()
+ if (ignoreCaseMatch) editToolUse.params.ignore_case = ignoreCaseMatch[1].trim()
+ if (startLineMatch) editToolUse.params.start_line = startLineMatch[1].trim()
+ if (endLineMatch) editToolUse.params.end_line = endLineMatch[1].trim()
+
+ await searchAndReplaceTool(
+ cline,
+ editToolUse,
+ askApproval,
+ handleError,
+ collectEditResult,
+ removeClosingTag,
+ )
+ editSucceeded = true
+ break
+ }
+ default:
+ pushToolResult(
+ formatResponse.toolError(
+ `Unsupported edit tool: ${editToolName}. Supported tools are: write_to_file, apply_diff, insert_content, search_and_replace`,
+ ),
+ )
+ return
+ }
+ } catch (error) {
+ await handleError(`executing edit operation (${editToolName})`, error as Error)
+ return
+ }
+
+ // If edit operation failed, don't proceed with execute
+ if (!editSucceeded) {
+ pushToolResult(
+ formatResponse.toolError(
+ `Edit operation failed. Command execution was not attempted.\nEdit result: ${editResult}`,
+ ),
+ )
+ return
+ }
+
+ // Now parse and execute the command
+ const commandMatch = executeContent.match(/([\s\S]*?)<\/command>/)
+ const cwdMatch = executeContent.match(/([\s\S]*?)<\/cwd>/)
+
+ if (!commandMatch || !commandMatch[1]) {
+ cline.consecutiveMistakeCount++
+ cline.recordToolError("edit_and_execute")
+ pushToolResult(formatResponse.toolError("Missing in block"))
+ return
+ }
+
+ // Create a ToolUse object for the execute operation
+ const executeToolUse: ToolUse = {
+ type: "tool_use",
+ name: "execute_command",
+ params: {
+ command: commandMatch[1].trim(),
+ },
+ partial: false,
+ }
+
+ if (cwdMatch && cwdMatch[1]) {
+ executeToolUse.params.cwd = cwdMatch[1].trim()
+ }
+
+ // Collect the execute result
+ let executeResult: string = ""
+ const collectExecuteResult = (content: any) => {
+ if (typeof content === "string") {
+ executeResult = content
+ } else if (Array.isArray(content)) {
+ executeResult = content
+ .map((item) => (typeof item === "object" && item.type === "text" ? item.text : ""))
+ .join("\n")
+ }
+ }
+
+ try {
+ await executeCommandTool(
+ cline,
+ executeToolUse,
+ askApproval,
+ handleError,
+ collectExecuteResult,
+ removeClosingTag,
+ )
+
+ // Combine results from both operations
+ const combinedResult = [
+ "Edit operation completed successfully:",
+ editResult,
+ "",
+ "Command execution result:",
+ executeResult,
+ ].join("\n")
+
+ pushToolResult(combinedResult)
+
+ // Record successful tool usage
+ cline.recordToolUsage("edit_and_execute")
+ } catch (error) {
+ await handleError("executing command after edit", error as Error)
+ }
+}
diff --git a/src/shared/tools.ts b/src/shared/tools.ts
index 8a8776764e..d2a01d41cf 100644
--- a/src/shared/tools.ts
+++ b/src/shared/tools.ts
@@ -63,10 +63,11 @@ export const toolParamNames = [
"start_line",
"end_line",
"query",
- "args",
"todos",
"prompt",
"image",
+ "edit",
+ "execute",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -171,6 +172,11 @@ export interface GenerateImageToolUse extends ToolUse {
params: Partial, "prompt" | "path" | "image">>
}
+export interface EditAndExecuteToolUse extends ToolUse {
+ name: "edit_and_execute"
+ params: Partial, "args">>
+}
+
// Define tool group configuration
export type ToolGroupConfig = {
tools: readonly string[]
@@ -198,6 +204,7 @@ export const TOOL_DISPLAY_NAMES: Record = {
codebase_search: "codebase search",
update_todo_list: "update todo list",
generate_image: "generate images",
+ edit_and_execute: "edit and execute",
} as const
// Define available tool groups.
@@ -213,7 +220,14 @@ export const TOOL_GROUPS: Record = {
],
},
edit: {
- tools: ["apply_diff", "write_to_file", "insert_content", "search_and_replace", "generate_image"],
+ tools: [
+ "apply_diff",
+ "write_to_file",
+ "insert_content",
+ "search_and_replace",
+ "generate_image",
+ "edit_and_execute",
+ ],
},
browser: {
tools: ["browser_action"],