Skip to content

Commit edd7cc0

Browse files
authored
fix: flush pending tool results before task delegation (#9726)
When tools are called in parallel (e.g., update_todo_list + new_task), the tool results accumulate in userMessageContent but aren't saved to API history until all tools complete. When new_task triggers delegation, the parent is disposed before these pending results are saved, causing 400 errors when the parent resumes (missing tool_result for tool_use). This fix: - Adds flushPendingToolResultsToHistory() method in Task.ts that saves pending userMessageContent to API history - Calls this method in delegateParentAndOpenChild() before disposing the parent task - Safe for both native/XML protocols and sequential/parallel execution (returns early if there's nothing to flush)
1 parent 9b18014 commit edd7cc0

File tree

3 files changed

+401
-1
lines changed

3 files changed

+401
-1
lines changed

src/core/task/Task.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,41 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
786786
await this.saveApiConversationHistory()
787787
}
788788

789+
/**
790+
* Flush any pending tool results to the API conversation history.
791+
*
792+
* This is critical for native tool protocol when the task is about to be
793+
* delegated (e.g., via new_task). Before delegation, if other tools were
794+
* called in the same turn before new_task, their tool_result blocks are
795+
* accumulated in `userMessageContent` but haven't been saved to the API
796+
* history yet. If we don't flush them before the parent is disposed,
797+
* the API conversation will be incomplete and cause 400 errors when
798+
* the parent resumes (missing tool_result for tool_use blocks).
799+
*
800+
* NOTE: The assistant message is typically already in history by the time
801+
* tools execute (added in recursivelyMakeClineRequests after streaming completes).
802+
* So we usually only need to flush the pending user message with tool_results.
803+
*/
804+
public async flushPendingToolResultsToHistory(): Promise<void> {
805+
// Only flush if there's actually pending content to save
806+
if (this.userMessageContent.length === 0) {
807+
return
808+
}
809+
810+
// Save the user message with tool_result blocks
811+
const userMessage: Anthropic.MessageParam = {
812+
role: "user",
813+
content: this.userMessageContent,
814+
}
815+
const userMessageWithTs = { ...userMessage, ts: Date.now() }
816+
this.apiConversationHistory.push(userMessageWithTs as ApiMessage)
817+
818+
await this.saveApiConversationHistory()
819+
820+
// Clear the pending content since it's now saved
821+
this.userMessageContent = []
822+
}
823+
789824
private async saveApiConversationHistory() {
790825
try {
791826
await saveApiMessages({
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
// npx vitest run core/task/__tests__/flushPendingToolResultsToHistory.spec.ts
2+
3+
import * as os from "os"
4+
import * as path from "path"
5+
import * as vscode from "vscode"
6+
7+
import type { GlobalState, ProviderSettings } from "@roo-code/types"
8+
import { TelemetryService } from "@roo-code/telemetry"
9+
10+
import { Task } from "../Task"
11+
import { ClineProvider } from "../../webview/ClineProvider"
12+
import { ContextProxy } from "../../config/ContextProxy"
13+
14+
// Mock delay before any imports that might use it
15+
vi.mock("delay", () => ({
16+
__esModule: true,
17+
default: vi.fn().mockResolvedValue(undefined),
18+
}))
19+
20+
vi.mock("execa", () => ({
21+
execa: vi.fn(),
22+
}))
23+
24+
vi.mock("fs/promises", async (importOriginal) => {
25+
const actual = (await importOriginal()) as Record<string, any>
26+
const mockFunctions = {
27+
mkdir: vi.fn().mockResolvedValue(undefined),
28+
writeFile: vi.fn().mockResolvedValue(undefined),
29+
readFile: vi.fn().mockResolvedValue("[]"),
30+
unlink: vi.fn().mockResolvedValue(undefined),
31+
rmdir: vi.fn().mockResolvedValue(undefined),
32+
}
33+
34+
return {
35+
...actual,
36+
...mockFunctions,
37+
default: mockFunctions,
38+
}
39+
})
40+
41+
vi.mock("p-wait-for", () => ({
42+
default: vi.fn().mockImplementation(async () => Promise.resolve()),
43+
}))
44+
45+
vi.mock("vscode", () => {
46+
const mockDisposable = { dispose: vi.fn() }
47+
const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
48+
const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
49+
const mockTextEditor = { document: mockTextDocument }
50+
const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
51+
const mockTabGroup = { tabs: [mockTab] }
52+
53+
return {
54+
TabInputTextDiff: vi.fn(),
55+
CodeActionKind: {
56+
QuickFix: { value: "quickfix" },
57+
RefactorRewrite: { value: "refactor.rewrite" },
58+
},
59+
window: {
60+
createTextEditorDecorationType: vi.fn().mockReturnValue({
61+
dispose: vi.fn(),
62+
}),
63+
visibleTextEditors: [mockTextEditor],
64+
tabGroups: {
65+
all: [mockTabGroup],
66+
close: vi.fn(),
67+
onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
68+
},
69+
showErrorMessage: vi.fn(),
70+
},
71+
workspace: {
72+
workspaceFolders: [
73+
{
74+
uri: { fsPath: "/mock/workspace/path" },
75+
name: "mock-workspace",
76+
index: 0,
77+
},
78+
],
79+
createFileSystemWatcher: vi.fn(() => ({
80+
onDidCreate: vi.fn(() => mockDisposable),
81+
onDidDelete: vi.fn(() => mockDisposable),
82+
onDidChange: vi.fn(() => mockDisposable),
83+
dispose: vi.fn(),
84+
})),
85+
fs: {
86+
stat: vi.fn().mockResolvedValue({ type: 1 }),
87+
},
88+
onDidSaveTextDocument: vi.fn(() => mockDisposable),
89+
getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
90+
},
91+
env: {
92+
uriScheme: "vscode",
93+
language: "en",
94+
},
95+
EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
96+
Disposable: {
97+
from: vi.fn(),
98+
},
99+
TabInputText: vi.fn(),
100+
}
101+
})
102+
103+
vi.mock("../../mentions", () => ({
104+
parseMentions: vi.fn().mockImplementation((text) => {
105+
return Promise.resolve(`processed: ${text}`)
106+
}),
107+
openMention: vi.fn(),
108+
getLatestTerminalOutput: vi.fn(),
109+
}))
110+
111+
vi.mock("../../../integrations/misc/extract-text", () => ({
112+
extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
113+
}))
114+
115+
vi.mock("../../environment/getEnvironmentDetails", () => ({
116+
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
117+
}))
118+
119+
vi.mock("../../ignore/RooIgnoreController")
120+
121+
vi.mock("../../condense", async (importOriginal) => {
122+
const actual = (await importOriginal()) as any
123+
return {
124+
...actual,
125+
summarizeConversation: vi.fn().mockResolvedValue({
126+
messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }],
127+
summary: "summary",
128+
cost: 0,
129+
newContextTokens: 1,
130+
}),
131+
}
132+
})
133+
134+
vi.mock("../../../utils/storage", () => ({
135+
getTaskDirectoryPath: vi
136+
.fn()
137+
.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
138+
getSettingsDirectoryPath: vi
139+
.fn()
140+
.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
141+
}))
142+
143+
vi.mock("../../../utils/fs", () => ({
144+
fileExistsAtPath: vi.fn().mockReturnValue(false),
145+
}))
146+
147+
describe("flushPendingToolResultsToHistory", () => {
148+
let mockProvider: any
149+
let mockApiConfig: ProviderSettings
150+
let mockOutputChannel: any
151+
let mockExtensionContext: vscode.ExtensionContext
152+
153+
beforeEach(() => {
154+
if (!TelemetryService.hasInstance()) {
155+
TelemetryService.createInstance([])
156+
}
157+
158+
const storageUri = {
159+
fsPath: path.join(os.tmpdir(), "test-storage"),
160+
}
161+
162+
mockExtensionContext = {
163+
globalState: {
164+
get: vi.fn().mockImplementation((key: keyof GlobalState) => undefined),
165+
update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
166+
keys: vi.fn().mockReturnValue([]),
167+
},
168+
globalStorageUri: storageUri,
169+
workspaceState: {
170+
get: vi.fn().mockImplementation((_key) => undefined),
171+
update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
172+
keys: vi.fn().mockReturnValue([]),
173+
},
174+
secrets: {
175+
get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)),
176+
store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
177+
delete: vi.fn().mockImplementation((_key) => Promise.resolve()),
178+
},
179+
extensionUri: {
180+
fsPath: "/mock/extension/path",
181+
},
182+
extension: {
183+
packageJSON: {
184+
version: "1.0.0",
185+
},
186+
},
187+
} as unknown as vscode.ExtensionContext
188+
189+
mockOutputChannel = {
190+
appendLine: vi.fn(),
191+
append: vi.fn(),
192+
clear: vi.fn(),
193+
show: vi.fn(),
194+
hide: vi.fn(),
195+
dispose: vi.fn(),
196+
}
197+
198+
mockProvider = new ClineProvider(
199+
mockExtensionContext,
200+
mockOutputChannel,
201+
"sidebar",
202+
new ContextProxy(mockExtensionContext),
203+
) as any
204+
205+
mockApiConfig = {
206+
apiProvider: "anthropic",
207+
apiModelId: "claude-3-5-sonnet-20241022",
208+
apiKey: "test-api-key",
209+
}
210+
211+
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
212+
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
213+
mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined)
214+
})
215+
216+
it("should not save anything when userMessageContent is empty", async () => {
217+
const task = new Task({
218+
provider: mockProvider,
219+
apiConfiguration: mockApiConfig,
220+
task: "test task",
221+
startTask: false,
222+
})
223+
224+
// Ensure userMessageContent is empty
225+
task.userMessageContent = []
226+
const initialHistoryLength = task.apiConversationHistory.length
227+
228+
// Call flush
229+
await task.flushPendingToolResultsToHistory()
230+
231+
// History should not have changed since userMessageContent was empty
232+
expect(task.apiConversationHistory.length).toBe(initialHistoryLength)
233+
})
234+
235+
it("should save user message when userMessageContent has pending tool results", async () => {
236+
const task = new Task({
237+
provider: mockProvider,
238+
apiConfiguration: mockApiConfig,
239+
task: "test task",
240+
startTask: false,
241+
})
242+
243+
// Set up pending tool result in userMessageContent
244+
task.userMessageContent = [
245+
{
246+
type: "tool_result",
247+
tool_use_id: "tool-123",
248+
content: "File written successfully",
249+
},
250+
]
251+
252+
await task.flushPendingToolResultsToHistory()
253+
254+
// Should have saved 1 user message
255+
expect(task.apiConversationHistory.length).toBe(1)
256+
257+
// Check user message with tool result
258+
const userMessage = task.apiConversationHistory[0]
259+
expect(userMessage.role).toBe("user")
260+
expect(Array.isArray(userMessage.content)).toBe(true)
261+
expect((userMessage.content as any[])[0].type).toBe("tool_result")
262+
expect((userMessage.content as any[])[0].tool_use_id).toBe("tool-123")
263+
})
264+
265+
it("should clear userMessageContent after flushing", async () => {
266+
const task = new Task({
267+
provider: mockProvider,
268+
apiConfiguration: mockApiConfig,
269+
task: "test task",
270+
startTask: false,
271+
})
272+
273+
// Set up pending tool result
274+
task.userMessageContent = [
275+
{
276+
type: "tool_result",
277+
tool_use_id: "tool-456",
278+
content: "Command executed",
279+
},
280+
]
281+
282+
await task.flushPendingToolResultsToHistory()
283+
284+
// userMessageContent should be cleared
285+
expect(task.userMessageContent.length).toBe(0)
286+
})
287+
288+
it("should handle multiple tool results in a single flush", async () => {
289+
const task = new Task({
290+
provider: mockProvider,
291+
apiConfiguration: mockApiConfig,
292+
task: "test task",
293+
startTask: false,
294+
})
295+
296+
// Set up multiple pending tool results
297+
task.userMessageContent = [
298+
{
299+
type: "tool_result",
300+
tool_use_id: "tool-1",
301+
content: "First result",
302+
},
303+
{
304+
type: "tool_result",
305+
tool_use_id: "tool-2",
306+
content: "Second result",
307+
},
308+
]
309+
310+
await task.flushPendingToolResultsToHistory()
311+
312+
// Check user message has both tool results
313+
const userMessage = task.apiConversationHistory[0]
314+
expect(Array.isArray(userMessage.content)).toBe(true)
315+
expect((userMessage.content as any[]).length).toBe(2)
316+
expect((userMessage.content as any[])[0].tool_use_id).toBe("tool-1")
317+
expect((userMessage.content as any[])[1].tool_use_id).toBe("tool-2")
318+
})
319+
320+
it("should add timestamp to saved messages", async () => {
321+
const task = new Task({
322+
provider: mockProvider,
323+
apiConfiguration: mockApiConfig,
324+
task: "test task",
325+
startTask: false,
326+
})
327+
328+
const beforeTs = Date.now()
329+
330+
task.userMessageContent = [
331+
{
332+
type: "tool_result",
333+
tool_use_id: "tool-ts",
334+
content: "Result",
335+
},
336+
]
337+
338+
await task.flushPendingToolResultsToHistory()
339+
340+
const afterTs = Date.now()
341+
342+
// Message should have timestamp
343+
expect((task.apiConversationHistory[0] as any).ts).toBeGreaterThanOrEqual(beforeTs)
344+
expect((task.apiConversationHistory[0] as any).ts).toBeLessThanOrEqual(afterTs)
345+
})
346+
})

0 commit comments

Comments
 (0)