Skip to content

Commit 37b1f8d

Browse files
committed
feat(core): parallelize safe commands
1 parent 243e184 commit 37b1f8d

3 files changed

Lines changed: 598 additions & 1 deletion

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest"
2+
3+
const { executeCommandHandleMock } = vi.hoisted(() => ({
4+
executeCommandHandleMock: vi.fn(),
5+
}))
6+
7+
vi.mock("../../task/Task")
8+
vi.mock("@roo-code/core", () => ({
9+
customToolRegistry: {
10+
has: vi.fn(() => false),
11+
get: vi.fn(),
12+
},
13+
}))
14+
vi.mock("../../tools/ExecuteCommandTool", () => ({
15+
executeCommandTool: {
16+
handle: executeCommandHandleMock,
17+
},
18+
}))
19+
vi.mock("../../tools/validateToolUse", () => ({
20+
validateToolUse: vi.fn(),
21+
isValidToolName: vi.fn((toolName: string) => toolName === "execute_command"),
22+
}))
23+
24+
import { presentAssistantMessage } from "../presentAssistantMessage"
25+
26+
const findCommand = "find src -maxdepth 1 -type f"
27+
const rgCommand = "rg TODO src"
28+
29+
describe("presentAssistantMessage - parallel command batches", () => {
30+
let mockTask: any
31+
32+
beforeEach(() => {
33+
vi.clearAllMocks()
34+
35+
mockTask = {
36+
taskId: "test-task-id",
37+
instanceId: "test-instance",
38+
abort: false,
39+
presentAssistantMessageLocked: false,
40+
presentAssistantMessageHasPendingUpdates: false,
41+
currentStreamingContentIndex: 0,
42+
assistantMessageContent: [],
43+
userMessageContent: [],
44+
didCompleteReadingStream: true,
45+
didRejectTool: false,
46+
didAlreadyUseTool: false,
47+
consecutiveMistakeCount: 0,
48+
clineMessages: [],
49+
api: {
50+
getModel: () => ({ id: "test-model", info: {} }),
51+
},
52+
recordToolUsage: vi.fn(),
53+
recordToolError: vi.fn(),
54+
toolRepetitionDetector: {
55+
check: vi.fn().mockReturnValue({ allowExecution: true }),
56+
},
57+
providerRef: {
58+
deref: () => ({
59+
getState: vi.fn().mockResolvedValue({
60+
mode: "code",
61+
customModes: [],
62+
experiments: {},
63+
disabledTools: [],
64+
}),
65+
}),
66+
},
67+
say: vi.fn().mockResolvedValue(undefined),
68+
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
69+
maybeInterruptForPendingSteerAtToolBoundary: vi.fn().mockResolvedValue(false),
70+
checkpointSave: vi.fn().mockResolvedValue(undefined),
71+
}
72+
73+
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
74+
const existingResult = mockTask.userMessageContent.find(
75+
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
76+
)
77+
if (existingResult) {
78+
return false
79+
}
80+
mockTask.userMessageContent.push(toolResult)
81+
return true
82+
})
83+
})
84+
85+
it("runs consecutive read-only execute_command tool calls concurrently and preserves result order", async () => {
86+
const finished: string[] = []
87+
mockTask.assistantMessageContent = [commandBlock("cmd-1", findCommand), commandBlock("cmd-2", rgCommand)]
88+
executeCommandHandleMock.mockImplementation(async (_task: any, toolUse: any, callbacks: any) => {
89+
const command = toolUse.nativeArgs.command
90+
const approved = await callbacks.askApproval("command", command)
91+
if (!approved) {
92+
return
93+
}
94+
if (command === findCommand) {
95+
await delay(25)
96+
}
97+
finished.push(command)
98+
callbacks.pushToolResult(`result:${command}`)
99+
})
100+
101+
await presentAssistantMessage(mockTask)
102+
103+
expect(finished[0]).toBe(rgCommand)
104+
expect(executeCommandHandleMock).toHaveBeenCalledTimes(2)
105+
expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(2)
106+
expect(toolResultIds(mockTask)).toEqual(["cmd-1", "cmd-2"])
107+
expect(toolResultContents(mockTask)).toEqual([`result:${findCommand}`, `result:${rgCommand}`])
108+
})
109+
110+
it("waits for the stream to complete before executing a read-only command", async () => {
111+
mockTask.didCompleteReadingStream = false
112+
mockTask.assistantMessageContent = [commandBlock("cmd-1", findCommand)]
113+
executeCommandHandleMock.mockImplementation(async (_task: any, toolUse: any, callbacks: any) => {
114+
await callbacks.askApproval("command", toolUse.nativeArgs.command)
115+
callbacks.pushToolResult("result")
116+
})
117+
118+
await presentAssistantMessage(mockTask)
119+
120+
expect(executeCommandHandleMock).not.toHaveBeenCalled()
121+
expect(mockTask.currentStreamingContentIndex).toBe(0)
122+
expect(mockTask.presentAssistantMessageLocked).toBe(false)
123+
124+
mockTask.didCompleteReadingStream = true
125+
await presentAssistantMessage(mockTask)
126+
127+
expect(executeCommandHandleMock).toHaveBeenCalledTimes(1)
128+
expect(toolResultIds(mockTask)).toEqual(["cmd-1"])
129+
})
130+
131+
it("keeps unsafe command forms on the serial execution path", async () => {
132+
let active = 0
133+
let maxActive = 0
134+
const unsafeCommand = "git diff --output=/tmp/roo-diff.txt"
135+
mockTask.assistantMessageContent = [commandBlock("cmd-1", unsafeCommand), commandBlock("cmd-2", rgCommand)]
136+
executeCommandHandleMock.mockImplementation(async (_task: any, toolUse: any, callbacks: any) => {
137+
const command = toolUse.nativeArgs.command
138+
const approved = await callbacks.askApproval("command", command)
139+
if (!approved) {
140+
return
141+
}
142+
active++
143+
maxActive = Math.max(maxActive, active)
144+
if (command === unsafeCommand) {
145+
await delay(25)
146+
}
147+
callbacks.pushToolResult(`result:${command}`)
148+
active--
149+
})
150+
151+
await presentAssistantMessage(mockTask)
152+
await waitFor(() => expect(executeCommandHandleMock).toHaveBeenCalledTimes(2))
153+
154+
expect(maxActive).toBe(1)
155+
expect(toolResultIds(mockTask)).toEqual(["cmd-1", "cmd-2"])
156+
})
157+
})
158+
159+
function commandBlock(id: string, command: string) {
160+
return {
161+
type: "tool_use",
162+
id,
163+
name: "execute_command",
164+
params: { command },
165+
nativeArgs: { command },
166+
partial: false,
167+
}
168+
}
169+
170+
function toolResultIds(task: any): string[] {
171+
return task.userMessageContent
172+
.filter((item: any) => item.type === "tool_result")
173+
.map((item: any) => item.tool_use_id)
174+
}
175+
176+
function toolResultContents(task: any): string[] {
177+
return task.userMessageContent.filter((item: any) => item.type === "tool_result").map((item: any) => item.content)
178+
}
179+
180+
async function waitFor(assertion: () => void): Promise<void> {
181+
const started = Date.now()
182+
let lastError: unknown
183+
184+
while (Date.now() - started < 1000) {
185+
try {
186+
assertion()
187+
return
188+
} catch (error) {
189+
lastError = error
190+
await delay(5)
191+
}
192+
}
193+
194+
throw lastError
195+
}
196+
197+
function delay(ms: number): Promise<void> {
198+
return new Promise((resolve) => setTimeout(resolve, ms))
199+
}

0 commit comments

Comments
 (0)