Fix parallel tool calls for subtasks

This commit is contained in:
daniel-lxs 2025-12-02 12:28:08 -05:00
parent edd7cc0986
commit 6df9befc24
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
4 changed files with 631 additions and 73 deletions

View file

@ -161,6 +161,27 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
childTaskId?: string
pendingNewTaskToolCallId?: string
/** Queue of subtasks to execute sequentially when multiple new_task calls are made in parallel. */
pendingSubtasks: Array<{
toolCallId: string
message: string
mode: string
todoItems: TodoItem[]
}> = []
/** Results from completed subtasks, added to conversation only after ALL subtasks finish. */
completedSubtaskResults: Array<{
toolCallId: string
result: string
}> = []
/** Pending tool results from OTHER tools called in the same turn.
* Saved with subtask state so they're combined with subtask results at the end. */
pendingOtherToolResults: Array<Anthropic.ToolResultBlockParam> = []
/** Tool call ID of the currently-executing subtask. */
currentSubtaskToolCallId?: string
readonly instanceId: string
readonly metadata: TaskMetadata
@ -821,6 +842,141 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.userMessageContent = []
}
/** Execute pending subtasks sequentially. Returns empty array since delegation suspends the parent. */
public async executePendingSubtasks(): Promise<Array<{ toolCallId: string; result: string }>> {
if (this.pendingSubtasks.length === 0) {
return []
}
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available for subtask execution")
}
// Save other tool results (like update_todo_list) that were called in the same turn.
// Don't flush them to history yet - they'll be combined with subtask results later.
// Only save non-new_task tool results (filter out new_task tool results which are pending subtasks).
const pendingSubtaskToolIds = new Set(this.pendingSubtasks.map((s) => s.toolCallId))
if (this.currentSubtaskToolCallId) {
pendingSubtaskToolIds.add(this.currentSubtaskToolCallId)
}
// Extract tool_result blocks from userMessageContent that are NOT subtask-related
const otherToolResults = this.userMessageContent.filter((block): block is Anthropic.ToolResultBlockParam => {
if (block.type !== "tool_result") return false
return !pendingSubtaskToolIds.has(block.tool_use_id)
})
// If this is the first subtask, save the other tool results
if (this.pendingOtherToolResults.length === 0 && otherToolResults.length > 0) {
this.pendingOtherToolResults = otherToolResults
}
// Clear userMessageContent since we're about to delegate (don't flush to history)
this.userMessageContent = []
const currentSubtask = this.pendingSubtasks.shift()!
this.currentSubtaskToolCallId = currentSubtask.toolCallId
try {
// State must be saved before delegating since a new parent instance is created on resume
await this.savePendingSubtasks()
await (provider as any).delegateParentAndOpenChild({
parentTaskId: this.taskId,
message: currentSubtask.message,
initialTodos: currentSubtask.todoItems,
mode: currentSubtask.mode,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
this.completedSubtaskResults.push({
toolCallId: currentSubtask.toolCallId,
result: `Failed to execute subtask: ${errorMessage}`,
})
this.currentSubtaskToolCallId = undefined
await this.savePendingSubtasks()
}
return []
}
public hasPendingSubtasks(): boolean {
return this.pendingSubtasks.length > 0
}
/**
* Save subtask state to VSCode's workspaceState via the provider.
* The workspaceState persists across extension restarts, so this state survives
* when the parent is disposed and recreated after a child task completes.
*/
public async savePendingSubtasks(): Promise<void> {
const provider = this.providerRef.deref()
if (!provider) {
console.error(`[Task#savePendingSubtasks] Provider not available`)
return
}
const hasState =
this.pendingSubtasks.length > 0 ||
this.completedSubtaskResults.length > 0 ||
this.currentSubtaskToolCallId ||
this.pendingOtherToolResults.length > 0
if (hasState) {
await provider.setSubtaskState(this.taskId, {
pendingSubtasks: this.pendingSubtasks,
completedSubtaskResults: this.completedSubtaskResults,
currentSubtaskToolCallId: this.currentSubtaskToolCallId,
pendingOtherToolResults: this.pendingOtherToolResults as any,
})
} else {
// Clean up state if nothing to store
await provider.clearSubtaskState(this.taskId)
}
}
/**
* Load subtask state from workspaceState via the provider.
* Called when the parent resumes after a child task completes.
*/
public loadPendingSubtasks(): void {
const provider = this.providerRef.deref()
if (!provider) {
return
}
const state = provider.getSubtaskState(this.taskId)
if (state) {
if (Array.isArray(state.pendingSubtasks)) {
this.pendingSubtasks = state.pendingSubtasks
}
if (Array.isArray(state.completedSubtaskResults)) {
this.completedSubtaskResults = state.completedSubtaskResults
}
if (state.currentSubtaskToolCallId) {
this.currentSubtaskToolCallId = state.currentSubtaskToolCallId
}
if (Array.isArray(state.pendingOtherToolResults)) {
this.pendingOtherToolResults = state.pendingOtherToolResults as Anthropic.ToolResultBlockParam[]
}
}
}
/**
* Clear subtask state from workspaceState after all subtasks are complete.
*/
public async clearSubtaskState(): Promise<void> {
const provider = this.providerRef.deref()
if (provider) {
await provider.clearSubtaskState(this.taskId)
}
this.pendingSubtasks = []
this.completedSubtaskResults = []
this.currentSubtaskToolCallId = undefined
this.pendingOtherToolResults = []
}
private async saveApiConversationHistory() {
try {
await saveApiMessages({
@ -2046,6 +2202,25 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Save the updated history
await this.saveApiConversationHistory()
// Load any pending subtasks from the provider's in-memory storage.
// When multiple new_task tools are called in parallel, remaining subtasks are
// saved before each delegation. We load them here on resume.
this.loadPendingSubtasks()
// Check if there are pending subtasks to execute sequentially.
// This happens when multiple new_task tools were called in parallel -
// they are queued and executed one at a time.
if (this.hasPendingSubtasks()) {
const provider = this.providerRef.deref()
if (provider) {
// Execute the next pending subtask
await this.executePendingSubtasks()
// Don't call initiateTaskLoop - the next subtask will run,
// and when it completes, this method will be called again.
return
}
}
// Continue task loop - pass empty array to signal no new user content needed
// The initiateTaskLoop will handle this by skipping user message addition
await this.initiateTaskLoop([])
@ -3031,6 +3206,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await pWaitFor(() => this.userMessageContentReady)
// Execute pending subtasks after all tool blocks have been processed.
// This ensures the assistant message is already in history before delegating.
if (typeof this.hasPendingSubtasks === "function" && this.hasPendingSubtasks()) {
await this.executePendingSubtasks()
// executePendingSubtasks delegates and suspends this task.
// The parent will resume when all subtasks complete.
return false
}
// If the model did not tool use, then we need to tell it to
// either use a tool or attempt_completion.
const didToolUse = this.assistantMessageContent.some(

View file

@ -1,4 +1,5 @@
import * as vscode from "vscode"
import { Anthropic } from "@anthropic-ai/sdk"
import { TodoItem } from "@roo-code/types"
@ -17,6 +18,32 @@ interface NewTaskParams {
todos?: string
}
/** Counts completed new_task tool blocks in the current assistant message. */
function countNewTaskBlocks(task: Task): number {
if (!task.assistantMessageContent) {
return 0
}
return task.assistantMessageContent.filter(
(block) => block.type === "tool_use" && (block as any).name === "new_task" && !(block as any).partial,
).length
}
/** Checks if there are any tool blocks AFTER the current streaming index that haven't been processed yet. */
function hasRemainingToolBlocks(task: Task): boolean {
if (!task.assistantMessageContent) {
return false
}
// Check all blocks after the current streaming index (which is the new_task we're processing)
// If there are any non-partial tool blocks remaining, we need to queue this new_task
for (let i = task.currentStreamingContentIndex + 1; i < task.assistantMessageContent.length; i++) {
const block = task.assistantMessageContent[i]
if (block.type === "tool_use" && !(block as any).partial) {
return true
}
}
return false
}
export class NewTaskTool extends BaseTool<"new_task"> {
readonly name = "new_task" as const
@ -92,8 +119,7 @@ export class NewTaskTool extends BaseTool<"new_task"> {
task.consecutiveMistakeCount = 0
// Un-escape one level of backslashes before '@' for hierarchical subtasks
// Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks)
// Un-escape \\@ -> \@ for hierarchical subtasks
const unescapedMessage = message.replace(/\\\\@/g, "\\@")
// Verify the mode exists
@ -117,13 +143,45 @@ export class NewTaskTool extends BaseTool<"new_task"> {
return
}
// Provider is guaranteed to be defined here due to earlier check.
if (task.enableCheckpoints) {
task.checkpointSave(true)
}
// Delegate parent and open child as sole active task
// Queue this new_task if there are:
// 1. Multiple new_task blocks (to execute sequentially), OR
// 2. Any remaining tool blocks after this one (so they can execute before delegation)
const newTaskBlockCount = countNewTaskBlocks(task)
const hasRemainingTools = hasRemainingToolBlocks(task)
if (newTaskBlockCount > 1 || hasRemainingTools) {
task.pendingSubtasks.push({
toolCallId: toolCallId ?? "",
message: unescapedMessage,
mode,
todoItems,
})
return
}
// Save other tool results (e.g., update_todo_list) that were called in the same turn.
// This prevents them from being lost or incorrectly ordered when the parent resumes.
const currentToolCallId = toolCallId ?? ""
const otherToolResults = task.userMessageContent.filter(
(block): block is Anthropic.ToolResultBlockParam =>
block.type === "tool_result" && block.tool_use_id !== currentToolCallId,
)
if (otherToolResults.length > 0) {
task.pendingOtherToolResults = otherToolResults
}
// Track this subtask and clear userMessageContent to prevent incorrect flushing
task.currentSubtaskToolCallId = currentToolCallId
task.userMessageContent = []
// Save state before delegation so it survives parent disposal
await task.savePendingSubtasks()
const child = await (provider as any).delegateParentAndOpenChild({
parentTaskId: task.taskId,
message: unescapedMessage,
@ -131,7 +189,6 @@ export class NewTaskTool extends BaseTool<"new_task"> {
mode,
})
// Reflect delegation in tool result (no pause/unpause, no wait)
pushToolResult(`Delegated to child task ${child.taskId}`)
return
} catch (error) {

View file

@ -98,6 +98,10 @@ const mockCline = {
enableCheckpoints: false,
checkpointSave: mockCheckpointSave,
startSubtask: mockStartSubtask,
userMessageContent: [] as any[],
pendingOtherToolResults: [] as any[],
currentSubtaskToolCallId: undefined as string | undefined,
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
providerRef: {
deref: vi.fn(() => ({
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
@ -651,6 +655,10 @@ describe("newTaskTool delegation flow", () => {
enableCheckpoints: false,
checkpointSave: mockCheckpointSave,
startSubtask: localStartSubtask,
userMessageContent: [] as any[],
pendingOtherToolResults: [] as any[],
currentSubtaskToolCallId: undefined as string | undefined,
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
providerRef: {
deref: vi.fn(() => providerSpy),
},
@ -697,3 +705,187 @@ describe("newTaskTool delegation flow", () => {
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1"))
})
})
describe("newTaskTool parallel execution", () => {
it("should queue subtasks when multiple new_task blocks are detected", async () => {
const providerSpy = {
getState: vi.fn().mockResolvedValue({
mode: "ask",
experiments: {},
}),
delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
handleModeSwitch: vi.fn(),
} as any
const pendingSubtasks: any[] = []
const localCline = {
ask: vi.fn(),
sayAndCreateMissingParamError: vi.fn(),
emit: vi.fn(),
recordToolError: vi.fn(),
consecutiveMistakeCount: 0,
isPaused: false,
pausedModeSlug: "ask",
taskId: "mock-parent-task-id",
enableCheckpoints: false,
checkpointSave: vi.fn(),
startSubtask: vi.fn(),
userMessageContent: [] as any[],
pendingOtherToolResults: [] as any[],
currentSubtaskToolCallId: undefined as string | undefined,
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
providerRef: {
deref: vi.fn(() => providerSpy),
},
assistantMessageContent: [
{
type: "tool_use",
name: "new_task",
id: "tool-1",
params: { mode: "code", message: "First task" },
partial: false,
},
{
type: "tool_use",
name: "new_task",
id: "tool-2",
params: { mode: "code", message: "Second task" },
partial: false,
},
],
pendingSubtasks,
}
const mockPushToolResult = vi.fn()
const mockAskApproval = vi.fn().mockResolvedValue(true)
const block1: ToolUse = {
type: "tool_use",
id: "tool-1",
name: "new_task",
params: {
mode: "code",
message: "First task",
},
partial: false,
}
await newTaskTool.handle(localCline as any, block1 as ToolUse<"new_task">, {
askApproval: mockAskApproval,
handleError: vi.fn(),
pushToolResult: mockPushToolResult,
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
toolProtocol: "xml",
toolCallId: "tool-1",
})
expect(pendingSubtasks.length).toBe(1)
expect(pendingSubtasks[0].mode).toBe("code")
expect(pendingSubtasks[0].message).toBe("First task")
expect(pendingSubtasks[0].toolCallId).toBe("tool-1")
expect(mockPushToolResult).not.toHaveBeenCalled()
expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled()
const block2: ToolUse = {
type: "tool_use",
id: "tool-2",
name: "new_task",
params: {
mode: "code",
message: "Second task",
},
partial: false,
}
await newTaskTool.handle(localCline as any, block2 as ToolUse<"new_task">, {
askApproval: mockAskApproval,
handleError: vi.fn(),
pushToolResult: mockPushToolResult,
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
toolProtocol: "xml",
toolCallId: "tool-2",
})
expect(pendingSubtasks.length).toBe(2)
expect(pendingSubtasks[1].mode).toBe("code")
expect(pendingSubtasks[1].message).toBe("Second task")
expect(pendingSubtasks[1].toolCallId).toBe("tool-2")
expect(mockPushToolResult).not.toHaveBeenCalled()
expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled()
})
it("should execute immediately when only one new_task block is present", async () => {
const providerSpy = {
getState: vi.fn().mockResolvedValue({
mode: "ask",
experiments: {},
}),
delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
handleModeSwitch: vi.fn(),
} as any
const pendingSubtasks: any[] = []
const localCline = {
ask: vi.fn(),
sayAndCreateMissingParamError: vi.fn(),
emit: vi.fn(),
recordToolError: vi.fn(),
consecutiveMistakeCount: 0,
isPaused: false,
pausedModeSlug: "ask",
taskId: "mock-parent-task-id",
enableCheckpoints: false,
checkpointSave: vi.fn(),
startSubtask: vi.fn(),
userMessageContent: [] as any[],
pendingOtherToolResults: [] as any[],
currentSubtaskToolCallId: undefined as string | undefined,
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
providerRef: {
deref: vi.fn(() => providerSpy),
},
assistantMessageContent: [
{
type: "tool_use",
name: "new_task",
id: "tool-1",
params: { mode: "code", message: "Single task" },
partial: false,
},
],
pendingSubtasks,
}
const mockPushToolResult = vi.fn()
const mockAskApproval = vi.fn().mockResolvedValue(true)
const block: ToolUse = {
type: "tool_use",
id: "tool-1",
name: "new_task",
params: {
mode: "code",
message: "Single task",
},
partial: false,
}
await newTaskTool.handle(localCline as any, block as ToolUse<"new_task">, {
askApproval: mockAskApproval,
handleError: vi.fn(),
pushToolResult: mockPushToolResult,
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
toolProtocol: "xml",
toolCallId: "tool-1",
})
expect(pendingSubtasks.length).toBe(0)
expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({
parentTaskId: "mock-parent-task-id",
message: "Single task",
initialTodos: [],
mode: "code",
})
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
})

View file

@ -118,6 +118,32 @@ interface PendingEditOperation {
createdAt: number
}
/**
* State for pending subtasks during parallel new_task execution.
* Stored in VSCode's workspaceState for persistence across extension restarts.
*/
export interface SubtaskState {
pendingSubtasks: Array<{
toolCallId: string
message: string
mode: string
todoItems: TodoItem[]
}>
completedSubtaskResults: Array<{
toolCallId: string
result: string
}>
currentSubtaskToolCallId?: string
/** Pending tool results from OTHER tools called in the same turn as new_task.
* These are saved before the first subtask executes so they can be combined
* with subtask results into a single user message when all complete. */
pendingOtherToolResults?: Array<{
type: "tool_result"
tool_use_id: string
content: string | Array<{ type: "text"; text: string } | { type: "image"; source: any }>
}>
}
export class ClineProvider
extends EventEmitter<TaskProviderEvents>
implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike
@ -146,6 +172,9 @@ export class ClineProvider
private pendingOperations: Map<string, PendingEditOperation> = new Map()
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
// Storage key prefix for subtask state in VSCode workspaceState
private static readonly SUBTASK_STATE_KEY_PREFIX = "subtaskState:"
private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
private cloudOrganizationsCacheTimestamp: number | null = null
private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds
@ -550,6 +579,47 @@ export class ClineProvider
this.log(`[clearAllPendingEditOperations] Cleared all pending operations`)
}
// Subtask State Management
// These methods manage state for parallel new_task execution using VSCode's workspaceState.
// workspaceState persists data across extension restarts for the current workspace.
/**
* Gets the subtask state key for a given task ID.
*/
private getSubtaskStateKey(taskId: string): string {
return `${ClineProvider.SUBTASK_STATE_KEY_PREFIX}${taskId}`
}
/**
* Gets the subtask state for a given task ID from workspaceState.
*/
public getSubtaskState(taskId: string): SubtaskState | undefined {
const key = this.getSubtaskStateKey(taskId)
return this.context.workspaceState.get<SubtaskState>(key)
}
/**
* Sets the subtask state for a given task ID in workspaceState.
*/
public async setSubtaskState(taskId: string, state: SubtaskState): Promise<void> {
const key = this.getSubtaskStateKey(taskId)
await this.context.workspaceState.update(key, state)
this.log(`[setSubtaskState] Set subtask state for task ${taskId}`)
}
/**
* Clears the subtask state for a given task ID from workspaceState.
* Should be called after all subtasks complete.
*/
public async clearSubtaskState(taskId: string): Promise<void> {
const key = this.getSubtaskStateKey(taskId)
const exists = this.context.workspaceState.get(key) !== undefined
await this.context.workspaceState.update(key, undefined)
if (exists) {
this.log(`[clearSubtaskState] Cleared subtask state for task ${taskId}`)
}
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
@ -2990,24 +3060,26 @@ export class ClineProvider
`[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`,
)
}
// 2) Flush pending tool results to API history BEFORE disposing the parent.
// This is critical for native tool protocol: when tools are called before new_task,
// their tool_result blocks are in userMessageContent but not yet saved to API history.
// If we don't flush them, the parent's API conversation will be incomplete and
// cause 400 errors when resumed (missing tool_result for tool_use blocks).
// 2) DON'T flush pending tool results to history when we're in subtask execution flow.
// The executePendingSubtasks() method saves other tool results (like update_todo_list)
// to pendingOtherToolResults, which will be combined with subtask results into a
// SINGLE user message when all subtasks complete. If we flush here, we'd create
// a separate user message that breaks the conversation structure (tool results
// MUST follow the assistant message that called them in a single user message).
//
// NOTE: We do NOT pass the assistant message here because the assistant message
// is already added to apiConversationHistory by the normal flow in
// recursivelyMakeClineRequests BEFORE tools start executing. We only need to
// flush the pending user message with tool_results.
try {
await parent.flushPendingToolResultsToHistory()
} catch (error) {
this.log(
`[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${
error instanceof Error ? error.message : String(error)
}`,
)
// Only flush if this is a direct delegation (no pending subtask state), which means
// the parent called new_task without the parallel subtask execution flow.
const hasSubtaskState = this.getSubtaskState(parentTaskId) !== undefined
if (!hasSubtaskState) {
try {
await parent.flushPendingToolResultsToHistory()
} catch (error) {
this.log(
`[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${
error instanceof Error ? error.message : String(error)
}`,
)
}
}
// 3) Enforce single-open invariant by closing/disposing the parent first
@ -3111,13 +3183,22 @@ export class ClineProvider
parentApiMessages = []
}
// 2) Inject synthetic records: UI subtask_result and update API tool_result
const ts = Date.now()
// Load subtask state from workspaceState
const subtaskState = this.getSubtaskState(parentTaskId)
let pendingSubtasks: Array<{ toolCallId: string; message: string; mode: string; todoItems: any[] }> =
subtaskState?.pendingSubtasks ?? []
let completedSubtaskResults: Array<{ toolCallId: string; result: string }> =
subtaskState?.completedSubtaskResults ?? []
let currentSubtaskToolCallId: string | undefined = subtaskState?.currentSubtaskToolCallId
// Other tool results (like update_todo_list) that were called in the same turn as new_task
const pendingOtherToolResults: Array<{ type: string; tool_use_id: string; content: any }> =
(subtaskState?.pendingOtherToolResults as any) ?? []
// Defensive: ensure arrays
const ts = Date.now()
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
// Add the child's result to the UI messages
const subtaskUiMessage: ClineMessage = {
type: "say",
say: "subtask_result",
@ -3127,67 +3208,98 @@ export class ClineProvider
parentClineMessages.push(subtaskUiMessage)
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
// Find the tool_use_id from the last assistant message's new_task tool_use
let toolUseId: string | undefined
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
const msg = parentApiMessages[i]
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name === "new_task") {
toolUseId = block.id
break
// Determine the toolUseId for this subtask result
// Priority: 1) currentSubtaskToolCallId from state, 2) fallback to finding the last one
let toolUseId: string | undefined = currentSubtaskToolCallId
if (!toolUseId) {
// Fallback: find the tool_use_id from the last assistant message's new_task tool_use
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
const msg = parentApiMessages[i]
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name === "new_task") {
toolUseId = block.id
break
}
}
if (toolUseId) break
}
if (toolUseId) break
}
}
// The API expects: user → assistant (with tool_use) → user (with tool_result)
// We need to add a NEW user message with the tool_result AFTER the assistant's tool_use
// NOT add it to an existing user message
// Store this subtask's result
if (toolUseId) {
// Check if the last message is already a user message with a tool_result for this tool_use_id
// (in case this is a retry or the history was already updated)
completedSubtaskResults.push({
toolCallId: toolUseId,
result: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
})
}
// Clear currentSubtaskToolCallId since this subtask is complete
currentSubtaskToolCallId = undefined
// Check if there are more pending subtasks to execute
const hasMoreSubtasks = pendingSubtasks.length > 0
if (!hasMoreSubtasks && completedSubtaskResults.length > 0) {
// All subtasks complete - add ALL tool_results to the conversation now
// The API expects: user → assistant (with tool_use) → user (with tool_result)
// Check if the last message is already a user message we can append to
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
let alreadyHasToolResult = false
let userMessageContent: any[] = []
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
for (const block of lastMsg.content) {
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
// Update the existing tool_result content
block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
alreadyHasToolResult = true
break
}
}
// Filter out any existing tool_results for our subtask IDs (to avoid duplicates)
const allToolResultIds = new Set([
...completedSubtaskResults.map((r) => r.toolCallId),
...pendingOtherToolResults.map((r) => r.tool_use_id),
])
userMessageContent = lastMsg.content.filter(
(block: any) => !(block.type === "tool_result" && allToolResultIds.has(block.tool_use_id)),
)
// Remove the last message so we can replace it with an updated one
parentApiMessages.pop()
}
// If no existing tool_result found, create a NEW user message with the tool_result
if (!alreadyHasToolResult) {
parentApiMessages.push({
role: "user",
content: [
{
type: "tool_result" as const,
tool_use_id: toolUseId,
content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
},
],
ts,
// FIRST: Add other tool results (e.g., update_todo_list) that were called in the same turn
// These should come before subtask results to maintain the original tool call order
for (const toolResult of pendingOtherToolResults) {
userMessageContent.push({
type: "tool_result" as const,
tool_use_id: toolResult.tool_use_id,
content: toolResult.content,
})
}
} else {
// Fallback for XML protocol or when toolUseId couldn't be found:
// Add a text block (not ideal but maintains backward compatibility)
// THEN: Add all completed subtask results as tool_result blocks
for (const result of completedSubtaskResults) {
userMessageContent.push({
type: "tool_result" as const,
tool_use_id: result.toolCallId,
content: result.result,
})
}
parentApiMessages.push({
role: "user",
content: [
{
type: "text",
text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
},
],
content: userMessageContent,
ts,
})
// Clear completed results since they're now in the conversation
completedSubtaskResults = []
// Clean up the subtask state from workspaceState
await this.clearSubtaskState(parentTaskId)
} else {
// More subtasks remain - save state for the next resumption
await this.setSubtaskState(parentTaskId, {
pendingSubtasks,
completedSubtaskResults,
currentSubtaskToolCallId,
})
}
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
@ -3249,8 +3361,21 @@ export class ClineProvider
// non-fatal
}
// Auto-resume parent without ask("resume_task")
await parentInstance.resumeAfterDelegation()
// Load subtask state into the parent instance (synchronous - reads from provider's in-memory state)
parentInstance.loadPendingSubtasks()
// Check if there are more pending subtasks to execute
if (parentInstance.hasPendingSubtasks()) {
// Execute the next pending subtask
// This will cause the parent to be "paused" again and a new child will run
this.log(
`[reopenParentFromDelegation] Parent ${parentTaskId} has ${parentInstance.pendingSubtasks.length} more subtasks, executing next one`,
)
await parentInstance.executePendingSubtasks()
} else {
// No more pending subtasks - resume the parent with the API
await parentInstance.resumeAfterDelegation()
}
}
// 9) Emit TaskDelegationResumed (provider-level)