Skip to content

Commit 7d7c040

Browse files
daniel-lxsq1600822305
authored andcommitted
fix: prevent time-travel bug in parallel tool calling (RooCodeInc#11046)
1 parent a6e5e74 commit 7d7c040

2 files changed

Lines changed: 146 additions & 1 deletion

File tree

src/core/task/Task.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,20 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
384384
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = []
385385
userMessageContentReady = false
386386

387+
/**
388+
* Flag indicating whether the assistant message for the current streaming session
389+
* has been saved to API conversation history.
390+
*
391+
* This is critical for parallel tool calling: tools should NOT execute until
392+
* the assistant message is saved. Otherwise, if a tool like `new_task` triggers
393+
* `flushPendingToolResultsToHistory()`, the user message with tool_results would
394+
* appear BEFORE the assistant message with tool_uses, causing API errors.
395+
*
396+
* Reset to `false` at the start of each API request.
397+
* Set to `true` after the assistant message is saved in `recursivelyMakeClineRequests`.
398+
*/
399+
assistantMessageSavedToHistory = false
400+
387401
/**
388402
* Push a tool_result block to userMessageContent, preventing duplicates.
389403
* This is critical for native tool protocol where duplicate tool_use_ids cause API errors.
@@ -1107,6 +1121,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11071121
return
11081122
}
11091123

1124+
// CRITICAL: Wait for the assistant message to be saved to API history first.
1125+
// Without this, tool_result blocks would appear BEFORE tool_use blocks in the
1126+
// conversation history, causing API errors like:
1127+
// "unexpected `tool_use_id` found in `tool_result` blocks"
1128+
//
1129+
// This can happen when parallel tools are called (e.g., update_todo_list + new_task).
1130+
// Tools execute during streaming via presentAssistantMessage, BEFORE the assistant
1131+
// message is saved. When new_task triggers delegation, it calls this method to
1132+
// flush pending results - but the assistant message hasn't been saved yet.
1133+
//
1134+
// The assistantMessageSavedToHistory flag is:
1135+
// - Reset to false at the start of each API request
1136+
// - Set to true after the assistant message is saved in recursivelyMakeClineRequests
1137+
if (!this.assistantMessageSavedToHistory) {
1138+
await pWaitFor(() => this.assistantMessageSavedToHistory || this.abort, {
1139+
interval: 50,
1140+
timeout: 30_000, // 30 second timeout as safety net
1141+
}).catch(() => {
1142+
// If timeout or abort, log and proceed anyway to avoid hanging
1143+
console.warn(
1144+
`[Task#${this.taskId}] flushPendingToolResultsToHistory: timed out waiting for assistant message to be saved`,
1145+
)
1146+
})
1147+
}
1148+
1149+
// If task was aborted while waiting, don't flush
1150+
if (this.abort) {
1151+
return
1152+
}
1153+
11101154
// Save the user message with tool_result blocks
11111155
const userMessage: Anthropic.MessageParam = {
11121156
role: "user",
@@ -2830,6 +2874,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
28302874
this.userMessageContentReady = false
28312875
this.didRejectTool = false
28322876
this.didAlreadyUseTool = false
2877+
this.assistantMessageSavedToHistory = false
28332878
// Reset tool failure flag for each new assistant turn - this ensures that tool failures
28342879
// only prevent attempt_completion within the same assistant message, not across turns
28352880
// (e.g., if a tool fails, then user sends a message saying "just complete anyway")
@@ -3617,6 +3662,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
36173662
{ role: "assistant", content: assistantContent },
36183663
reasoningMessage || undefined,
36193664
)
3665+
this.assistantMessageSavedToHistory = true
36203666

36213667
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
36223668

src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ vi.mock("fs/promises", async (importOriginal) => {
3838
}
3939
})
4040

41+
const { mockPWaitFor } = vi.hoisted(() => {
42+
return { mockPWaitFor: vi.fn().mockImplementation(async () => Promise.resolve()) }
43+
})
44+
4145
vi.mock("p-wait-for", () => ({
42-
default: vi.fn().mockImplementation(async () => Promise.resolve()),
46+
default: mockPWaitFor,
4347
}))
4448

4549
vi.mock("vscode", () => {
@@ -344,4 +348,99 @@ describe("flushPendingToolResultsToHistory", () => {
344348
expect((task.apiConversationHistory[0] as any).ts).toBeGreaterThanOrEqual(beforeTs)
345349
expect((task.apiConversationHistory[0] as any).ts).toBeLessThanOrEqual(afterTs)
346350
})
351+
352+
it("should skip waiting for assistantMessageSavedToHistory when flag is already true", async () => {
353+
const task = new Task({
354+
provider: mockProvider,
355+
apiConfiguration: mockApiConfig,
356+
task: "test task",
357+
startTask: false,
358+
})
359+
360+
// Set flag to true (assistant message already saved)
361+
task.assistantMessageSavedToHistory = true
362+
363+
// Set up pending tool result
364+
task.userMessageContent = [
365+
{
366+
type: "tool_result",
367+
tool_use_id: "tool-skip-wait",
368+
content: "Result when flag is true",
369+
},
370+
]
371+
372+
// Clear mock call history
373+
mockPWaitFor.mockClear()
374+
375+
await task.flushPendingToolResultsToHistory()
376+
377+
// Should not have called pWaitFor since flag was already true
378+
expect(mockPWaitFor).not.toHaveBeenCalled()
379+
380+
// Should still save the message
381+
expect(task.apiConversationHistory.length).toBe(1)
382+
expect((task.apiConversationHistory[0].content as any[])[0].tool_use_id).toBe("tool-skip-wait")
383+
})
384+
385+
it("should wait for assistantMessageSavedToHistory when flag is false", async () => {
386+
const task = new Task({
387+
provider: mockProvider,
388+
apiConfiguration: mockApiConfig,
389+
task: "test task",
390+
startTask: false,
391+
})
392+
393+
// Flag is false by default - assistant message not yet saved
394+
expect(task.assistantMessageSavedToHistory).toBe(false)
395+
396+
// Set up pending tool result
397+
task.userMessageContent = [
398+
{
399+
type: "tool_result",
400+
tool_use_id: "tool-wait",
401+
content: "Result when flag is false",
402+
},
403+
]
404+
405+
// Clear mock call history
406+
mockPWaitFor.mockClear()
407+
408+
await task.flushPendingToolResultsToHistory()
409+
410+
// Should have called pWaitFor since flag was false
411+
expect(mockPWaitFor).toHaveBeenCalled()
412+
413+
// Should still save the message (mock resolves immediately)
414+
expect(task.apiConversationHistory.length).toBe(1)
415+
})
416+
417+
it("should not flush when task is aborted during wait", async () => {
418+
const task = new Task({
419+
provider: mockProvider,
420+
apiConfiguration: mockApiConfig,
421+
task: "test task",
422+
startTask: false,
423+
})
424+
425+
// Flag is false - will need to wait
426+
task.assistantMessageSavedToHistory = false
427+
428+
// Set up pending tool result
429+
task.userMessageContent = [
430+
{
431+
type: "tool_result",
432+
tool_use_id: "tool-aborted",
433+
content: "Should not be saved",
434+
},
435+
]
436+
437+
// Set abort flag - this will cause the condition in pWaitFor to return true
438+
// AND will cause early return after the wait
439+
task.abort = true
440+
441+
await task.flushPendingToolResultsToHistory()
442+
443+
// Should not have saved anything since task was aborted
444+
expect(task.apiConversationHistory.length).toBe(0)
445+
})
347446
})

0 commit comments

Comments
 (0)