diff --git a/src/core/prompts/tools/execute-command.ts b/src/core/prompts/tools/execute-command.ts index c1fc1ea3f19c..099e9ca6c3b2 100644 --- a/src/core/prompts/tools/execute-command.ts +++ b/src/core/prompts/tools/execute-command.ts @@ -6,10 +6,12 @@ Description: Request to execute a CLI command on the system. Use this when you n Parameters: - command: (required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions. - cwd: (optional) The working directory to execute the command in (default: ${args.cwd}) +- background: (optional) Set to "true" to run the command in the background without requiring user interaction. The command will run without blocking, and its output will still be streamed to the terminal UI. This is useful for long-running processes like development servers. Usage: Your command here Working directory path (optional) +true or false (optional) Example: Requesting to execute npm run dev @@ -21,5 +23,11 @@ Example: Requesting to execute ls in a specific directory if directed ls -la /home/user/projects + + +Example: Running a development server in the background + +npm run dev +true ` } diff --git a/src/core/tools/__tests__/executeCommand.spec.ts b/src/core/tools/__tests__/executeCommand.spec.ts index 2e973a24cb84..66b002e6a6fa 100644 --- a/src/core/tools/__tests__/executeCommand.spec.ts +++ b/src/core/tools/__tests__/executeCommand.spec.ts @@ -451,4 +451,186 @@ describe("executeCommand", () => { expect(mockTerminalInstance.getCurrentWorkingDirectory).toHaveBeenCalled() }) }) + + describe("Background Command Execution", () => { + it("should run command in background when background=true", async () => { + // Mock the terminal process that doesn't require user interaction + const mockBackgroundProcess: any = Promise.resolve() + mockBackgroundProcess.continue = vitest.fn() + + mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { + // Simulate normal command execution + setTimeout(() => { + // First output triggers the background behavior + callbacks.onLine("Command running in background...", mockBackgroundProcess) + }, 50) + + // Simulate completion after a delay (but we won't wait for it) + setTimeout(() => { + callbacks.onCompleted("Background command completed", mockBackgroundProcess) + callbacks.onShellExecutionComplete({ exitCode: 0 }, mockBackgroundProcess) + }, 200) + + return mockBackgroundProcess + }) + + // Mock ask method to verify it's NOT called when background=true + mockTask.ask = vitest.fn() + + const options: ExecuteCommandOptions = { + executionId: "test-123", + command: "npm run dev", + background: true, + terminalShellIntegrationDisabled: false, + terminalOutputLineLimit: 500, + } + + // Execute + const [rejected, result] = await executeCommand(mockTask, options) + + // Verify - when background=true, command returns immediately + expect(rejected).toBe(false) + // The output when background is true shows it's running in background + expect(result).toContain("Command is running in the background") + expect(result).toContain("will continue running without blocking") + // Verify that the ask method was NOT called (no user interaction) + expect(mockTask.ask).not.toHaveBeenCalled() + // Continue is called automatically when onLine is triggered + expect(mockBackgroundProcess.continue).toHaveBeenCalled() + }) + + it("should require user interaction when background=false (default)", async () => { + // Mock process that requires user interaction + const mockInteractiveProcess: any = Promise.resolve() + mockInteractiveProcess.continue = vitest.fn() + + let hasCalledOnLine = false + let storedCallbacks: RooTerminalCallbacks + + mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { + storedCallbacks = callbacks + + // Simulate output after a delay + setTimeout(() => { + if (!hasCalledOnLine) { + hasCalledOnLine = true + callbacks.onLine("Command output...", mockInteractiveProcess) + } + }, 0) + + return mockInteractiveProcess + }) + + // Mock ask method to simulate user interaction + mockTask.ask = vitest.fn().mockImplementation(async () => { + // Complete the command after user provides feedback + setTimeout(() => { + if (storedCallbacks) { + storedCallbacks.onCompleted("Interactive command completed", mockInteractiveProcess) + storedCallbacks.onShellExecutionComplete({ exitCode: 0 }, mockInteractiveProcess) + } + }, 0) + + // Return user feedback + return { + response: "messageResponse", + text: "continue", + images: undefined, + } + }) + + const options: ExecuteCommandOptions = { + executionId: "test-123", + command: "npm run dev", + background: false, // explicitly set to false + terminalShellIntegrationDisabled: false, + terminalOutputLineLimit: 500, + } + + // Execute + const [rejected, result] = await executeCommand(mockTask, options) + + // Verify + expect(rejected).toBe(true) // User provided feedback + expect(mockTask.ask).toHaveBeenCalledWith("command_output", "") + expect(mockInteractiveProcess.continue).toHaveBeenCalled() + expect(result).toContain("continue") // User feedback should be in result + }) + + it("should handle background=true with command timeout (should not timeout as it returns immediately)", async () => { + // Mock a long-running background process + let processResolve: any + const mockLongRunningProcess: any = new Promise((resolve) => { + processResolve = resolve + }) + mockLongRunningProcess.continue = vitest.fn() + mockLongRunningProcess.abort = vitest.fn() + + mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { + // Simulate output after a delay + setTimeout(() => { + callbacks.onLine("Starting long-running process...", mockLongRunningProcess) + }, 50) + // Don't complete - simulate a long-running process + return mockLongRunningProcess + }) + + const options: ExecuteCommandOptions = { + executionId: "test-123", + command: "npm run dev", + background: true, + commandExecutionTimeout: 50, // 50ms timeout - but should not apply to background commands + terminalShellIntegrationDisabled: false, + terminalOutputLineLimit: 500, + } + + // Execute + const [rejected, result] = await executeCommand(mockTask, options) + + // Verify command returned immediately without timeout + expect(rejected).toBe(false) + expect(result).toContain("Command is running in the background") + // Should NOT be aborted as background commands don't wait for completion + expect(mockLongRunningProcess.abort).not.toHaveBeenCalled() + + // Clean up + if (processResolve) processResolve() + }) + + it("should parse background parameter from string 'true'", async () => { + // This test verifies the string parsing in executeCommandTool.ts + // The actual parsing happens in executeCommandTool, not executeCommand + // So we just verify that background boolean is handled correctly + + mockTerminal.runCommand.mockImplementation((command: string, callbacks: RooTerminalCallbacks) => { + setTimeout(() => { + callbacks.onLine("Background task output", mockProcess) + }, 50) + // The completion handlers won't be called before we return + setTimeout(() => { + callbacks.onCompleted("Done", mockProcess) + callbacks.onShellExecutionComplete({ exitCode: 0 }, mockProcess) + }, 200) + return mockProcess + }) + + const options: ExecuteCommandOptions = { + executionId: "test-123", + command: "echo test", + background: true, // This would be parsed from "true" string in executeCommandTool + terminalShellIntegrationDisabled: false, + terminalOutputLineLimit: 500, + } + + // Execute + const [rejected, result] = await executeCommand(mockTask, options) + + // Verify - background=true means command returns immediately + expect(rejected).toBe(false) + expect(result).toContain("Command is running in the background") + expect(result).toContain("will continue running without blocking") + // Process.continue is called when onLine is triggered + expect(mockProcess.continue).toHaveBeenCalled() + }) + }) }) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index b0ae07bf86a2..b3a8e7284afc 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -30,6 +30,7 @@ export async function executeCommandTool( ) { let command: string | undefined = block.params.command const customCwd: string | undefined = block.params.cwd + const background: string | undefined = block.params.background try { if (block.partial) { @@ -86,10 +87,14 @@ export async function executeCommandTool( // Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000 + // Parse background parameter as boolean + const runInBackground = background?.toLowerCase() === "true" + const options: ExecuteCommandOptions = { executionId, command, customCwd, + background: runInBackground, terminalShellIntegrationDisabled, terminalOutputLineLimit, terminalOutputCharacterLimit, @@ -137,6 +142,7 @@ export type ExecuteCommandOptions = { executionId: string command: string customCwd?: string + background?: boolean terminalShellIntegrationDisabled?: boolean terminalOutputLineLimit?: number terminalOutputCharacterLimit?: number @@ -149,6 +155,7 @@ export async function executeCommand( executionId, command, customCwd, + background = false, terminalShellIntegrationDisabled = true, terminalOutputLineLimit = 500, terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, @@ -174,7 +181,7 @@ export async function executeCommand( } let message: { text?: string; images?: string[] } | undefined - let runInBackground = false + let runInBackground = background let completed = false let result: string = "" let exitDetails: ExitCodeDetails | undefined @@ -196,6 +203,8 @@ export async function executeCommand( provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) if (runInBackground) { + // When running in background, automatically continue without user interaction + process.continue() return } @@ -252,6 +261,21 @@ export async function executeCommand( const process = terminal.runCommand(command, callbacks) task.terminalProcess = process + // If background=true, return immediately without waiting for process to complete + if (background) { + // Give a brief moment for initial output to be captured + await delay(100) + + return [ + false, + [ + `Command is running in the background in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`, + result && result.length > 0 ? `Initial output:\n${result}\n` : "", + "The command will continue running without blocking. You will be updated on the terminal status and new output in the future.", + ].join("\n"), + ] + } + // Implement command execution timeout (skip if timeout is 0). if (commandExecutionTimeout > 0) { let timeoutId: NodeJS.Timeout | undefined @@ -316,7 +340,7 @@ export async function executeCommand( formatResponse.toolResult( [ `Command is still running in terminal from '${terminal.getCurrentWorkingDirectory().toPosix()}'.`, - result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", + result && result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", `The user provided the following feedback:`, `\n${text}\n`, ].join("\n"), @@ -356,7 +380,7 @@ export async function executeCommand( false, [ `Command is still running in terminal ${workingDir ? ` from '${workingDir.toPosix()}'` : ""}.`, - result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", + result && result.length > 0 ? `Here's the output so far:\n${result}\n` : "\n", "You will be updated on the terminal status and new output in the future.", ].join("\n"), ] diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 61e840272726..28908162998f 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -52,6 +52,7 @@ export const toolParamNames = [ "mode", "message", "cwd", + "background", "follow_up", "task", "size", @@ -77,7 +78,7 @@ export interface ToolUse { export interface ExecuteCommandToolUse extends ToolUse { name: "execute_command" // Pick, "command"> makes "command" required, but Partial<> makes it optional - params: Partial, "command" | "cwd">> + params: Partial, "command" | "cwd" | "background">> } export interface ReadFileToolUse extends ToolUse {