From 2297c7a0d5d8816b8754b8b8f76a9dcfa1809b35 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 3 Sep 2025 02:23:15 +0000 Subject: [PATCH] feat: add edit_and_execute composite tool - Add new tool that combines file editing operations with command execution - Supports write_to_file, apply_diff, insert_content, and search_and_replace as edit operations - Executes command only if edit operation succeeds - Add comprehensive tests for the new tool - Update type definitions and tool registry Implements #7607 --- packages/types/src/tool.ts | 1 + .../presentAssistantMessage.ts | 6 + .../__tests__/editAndExecuteTool.spec.ts | 144 +++++++++ src/core/tools/editAndExecuteTool.ts | 304 ++++++++++++++++++ src/shared/tools.ts | 18 +- 5 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 src/core/tools/__tests__/editAndExecuteTool.spec.ts create mode 100644 src/core/tools/editAndExecuteTool.ts 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"],