Skip to content

Commit b8c112e

Browse files
committed
feat(tools): add run in background to execute command
This new parameter allows commands like development servers or monitoring processes to run in the background without requiring user interaction after initiation. This improves the user experience for long-running tasks.
1 parent 3a47c55 commit b8c112e

File tree

5 files changed

+111
-6
lines changed

5 files changed

+111
-6
lines changed

src/core/prompts/tools/execute-command.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@ Description: Request to execute a CLI command on the system. Use this when you n
66
Parameters:
77
- 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.
88
- cwd: (optional) The working directory to execute the command in (default: ${args.cwd})
9+
- runInBackground: (optional) If true, auto run the command in the background without user interaction. Use this for long-running commands like dev servers or monitoring commands (default: false)
910
Usage:
1011
<execute_command>
1112
<command>Your command here</command>
1213
<cwd>Working directory path (optional)</cwd>
1314
</execute_command>
1415
15-
Example: Requesting to execute npm run dev
16+
Example: Requesting to execute npm run dev in background
1617
<execute_command>
1718
<command>npm run dev</command>
19+
<runInBackground>true</runInBackground>
1820
</execute_command>
1921
2022
Example: Requesting to execute ls in a specific directory if directed

src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vitest.mock("../../prompts/responses", () => ({
2121
formatResponse: {
2222
toolError: vitest.fn((msg) => `Tool Error: ${msg}`),
2323
rooIgnoreError: vitest.fn((msg) => `RooIgnore Error: ${msg}`),
24+
toolResult: vitest.fn((content, images) => `Tool Result: ${content}`),
2425
},
2526
}))
2627
vitest.mock("../../../utils/text-normalization", () => ({
@@ -52,6 +53,7 @@ describe("Command Execution Timeout Integration", () => {
5253
postMessageToWebview: vitest.fn(),
5354
}),
5455
},
56+
ask: vitest.fn(),
5557
say: vitest.fn().mockResolvedValue(undefined),
5658
}
5759

@@ -409,4 +411,60 @@ describe("Command Execution Timeout Integration", () => {
409411
expect(result2).toContain("terminated after exceeding")
410412
}, 5000)
411413
})
414+
415+
describe("runInBackground Integration", () => {
416+
it("should automatically continue command execution when runInBackground is true", async () => {
417+
// Setup a command that produces output
418+
mockProcess = {
419+
continue: vitest.fn(),
420+
abort: vitest.fn(),
421+
}
422+
423+
mockTerminal = {
424+
runCommand: vitest.fn().mockImplementation((command, callbacks) => {
425+
// Simulate command producing output
426+
setTimeout(() => {
427+
callbacks.onLine("Starting development server...\n", mockProcess)
428+
}, 10)
429+
430+
// Simulate command completion
431+
setTimeout(() => {
432+
callbacks.onCompleted("Development server started successfully")
433+
callbacks.onShellExecutionComplete({ exitCode: 0 })
434+
}, 50)
435+
436+
return Promise.resolve()
437+
}),
438+
getCurrentWorkingDirectory: vitest.fn().mockReturnValue({
439+
toPosix: () => "/test/directory",
440+
}),
441+
}
442+
;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal)
443+
444+
const options: ExecuteCommandOptions = {
445+
executionId: "test-execution-id",
446+
command: "npm run dev",
447+
runInBackground: true,
448+
terminalShellIntegrationDisabled: true,
449+
terminalOutputLineLimit: 500,
450+
terminalOutputCharacterLimit: 10000,
451+
commandExecutionTimeout: 0,
452+
}
453+
454+
// Execute the command
455+
const [rejected] = await executeCommand(mockTask, options)
456+
457+
// Wait for async operations to complete
458+
await new Promise((resolve) => setTimeout(resolve, 100))
459+
460+
expect(rejected).toBe(false)
461+
expect(TerminalRegistry.getOrCreateTerminal).toHaveBeenCalledWith("/test/directory", undefined, "execa")
462+
expect(mockTerminal.runCommand).toHaveBeenCalledWith("npm run dev", expect.any(Object))
463+
expect(mockProcess.continue).toHaveBeenCalled()
464+
465+
// The task.ask method should not be called since runInBackground skips user interaction
466+
// Note: We can't verify this directly since the callback is async and may not be called
467+
// in this test scenario, but the important thing is that the command completes successfully
468+
})
469+
})
412470
})

src/core/tools/__tests__/executeCommandTool.spec.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ beforeEach(() => {
6666

6767
// Get the custom working directory if provided
6868
const customCwd = block.params.cwd
69-
70-
const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd)
69+
const runInBackground = block.params.runInBackground === "true"
70+
const [userRejected, result] = await mockExecuteCommand(cline, block.params.command, customCwd, runInBackground)
7171

7272
if (userRejected) {
7373
cline.didRejectTool = true
@@ -309,4 +309,41 @@ describe("executeCommandTool", () => {
309309
expect(mockOptions.commandExecutionTimeout).toBeDefined()
310310
})
311311
})
312+
313+
describe("runInBackground parameter", () => {
314+
it("should extract runInBackground parameter when set to 'true'", async () => {
315+
mockToolUse.params.command = "npm run dev"
316+
mockToolUse.params.runInBackground = "true"
317+
318+
await executeCommandTool(
319+
mockCline as unknown as Task,
320+
mockToolUse,
321+
mockAskApproval as unknown as AskApproval,
322+
mockHandleError as unknown as HandleError,
323+
mockPushToolResult as unknown as PushToolResult,
324+
mockRemoveClosingTag as unknown as RemoveClosingTag,
325+
)
326+
327+
expect(mockExecuteCommand).toHaveBeenCalled()
328+
const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1]
329+
expect(lastCall[3]).toBe(true) // runInBackground parameter should be true
330+
})
331+
332+
it("should default runInBackground to false when parameter is missing", async () => {
333+
mockToolUse.params.command = "echo test"
334+
335+
await executeCommandTool(
336+
mockCline as unknown as Task,
337+
mockToolUse,
338+
mockAskApproval as unknown as AskApproval,
339+
mockHandleError as unknown as HandleError,
340+
mockPushToolResult as unknown as PushToolResult,
341+
mockRemoveClosingTag as unknown as RemoveClosingTag,
342+
)
343+
344+
expect(mockExecuteCommand).toHaveBeenCalled()
345+
const lastCall = mockExecuteCommand.mock.calls[mockExecuteCommand.mock.calls.length - 1]
346+
expect(lastCall[3]).toBe(false) // runInBackground parameter should be false
347+
})
348+
})
312349
})

src/core/tools/executeCommandTool.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export async function executeCommandTool(
3030
) {
3131
let command: string | undefined = block.params.command
3232
const customCwd: string | undefined = block.params.cwd
33+
const runInBackground: boolean = block.params.runInBackground === "true"
3334

3435
try {
3536
if (block.partial) {
@@ -94,6 +95,7 @@ export async function executeCommandTool(
9495
terminalOutputLineLimit,
9596
terminalOutputCharacterLimit,
9697
commandExecutionTimeout,
98+
runInBackground,
9799
}
98100

99101
try {
@@ -141,6 +143,7 @@ export type ExecuteCommandOptions = {
141143
terminalOutputLineLimit?: number
142144
terminalOutputCharacterLimit?: number
143145
commandExecutionTimeout?: number
146+
runInBackground?: boolean
144147
}
145148

146149
export async function executeCommand(
@@ -153,6 +156,7 @@ export async function executeCommand(
153156
terminalOutputLineLimit = 500,
154157
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
155158
commandExecutionTimeout = 0,
159+
runInBackground: runInBackgroundRequested = false,
156160
}: ExecuteCommandOptions,
157161
): Promise<[boolean, ToolResponse]> {
158162
// Convert milliseconds back to seconds for display purposes.
@@ -174,7 +178,7 @@ export async function executeCommand(
174178
}
175179

176180
let message: { text?: string; images?: string[] } | undefined
177-
let runInBackground = false
181+
let runInBackground = runInBackgroundRequested
178182
let completed = false
179183
let result: string = ""
180184
let exitDetails: ExitCodeDetails | undefined
@@ -195,7 +199,10 @@ export async function executeCommand(
195199
const status: CommandExecutionStatus = { executionId, status: "output", output: compressedOutput }
196200
provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
197201

198-
if (runInBackground) {
202+
// If runInBackgroundRequested, automatically continue the process
203+
if (runInBackgroundRequested && !completed) {
204+
completed = true
205+
process.continue()
199206
return
200207
}
201208

src/shared/tools.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const toolParamNames = [
6767
"todos",
6868
"prompt",
6969
"image",
70+
"runInBackground",
7071
] as const
7172

7273
export type ToolParamName = (typeof toolParamNames)[number]
@@ -82,7 +83,7 @@ export interface ToolUse {
8283
export interface ExecuteCommandToolUse extends ToolUse {
8384
name: "execute_command"
8485
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
85-
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd">>
86+
params: Partial<Pick<Record<ToolParamName, string>, "command" | "cwd" | "runInBackground">>
8687
}
8788

8889
export interface ReadFileToolUse extends ToolUse {

0 commit comments

Comments
 (0)