feat: add background parameter to execute_command tool

- Add optional background boolean parameter to execute_command tool
- When background=true, commands run without requiring user interaction
- Commands return immediately without blocking when background is enabled
- Output continues to stream to terminal UI in background
- Add comprehensive test coverage for background execution

Fixes #9022
This commit is contained in:
Roo Code 2025-11-04 15:03:17 +00:00
parent 8e4b145681
commit 23011af014
4 changed files with 219 additions and 4 deletions

View file

@ -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:
<execute_command>
<command>Your command here</command>
<cwd>Working directory path (optional)</cwd>
<background>true or false (optional)</background>
</execute_command>
Example: Requesting to execute npm run dev
@ -21,5 +23,11 @@ Example: Requesting to execute ls in a specific directory if directed
<execute_command>
<command>ls -la</command>
<cwd>/home/user/projects</cwd>
</execute_command>
Example: Running a development server in the background
<execute_command>
<command>npm run dev</command>
<background>true</background>
</execute_command>`
}

View file

@ -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()
})
})
})

View file

@ -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:`,
`<feedback>\n${text}\n</feedback>`,
].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"),
]

View file

@ -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<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd">>
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd" | "background">>
}
export interface ReadFileToolUse extends ToolUse {