From 2d8842b7289bb497013aa66d89d904e011213857 Mon Sep 17 00:00:00 2001 From: Ruslan Andreev Date: Mon, 2 Mar 2026 12:14:15 +0300 Subject: [PATCH] feat: enhance subagent functionality for parallel execution - Introduced support for running multiple subagents in parallel by managing child tasks with unique toolCallIds. - Updated the Task class to hold active subagent children in a Map for better tracking and management. - Enhanced the reportSubagentProgress method to update specific subagent messages based on runId, allowing for real-time progress updates. - Modified the ClineProvider to handle cancellation of multiple running subagents. - Improved error handling and messaging for subagent execution. These changes significantly improve the subagent's capabilities, enabling more efficient task management and user feedback during concurrent operations. --- .../presentAssistantMessage.ts | 157 ++++++++++++++---- src/core/task/Task.ts | 47 ++++-- .../flushPendingToolResultsToHistory.spec.ts | 34 ++++ src/core/tools/AttemptCompletionTool.ts | 13 +- src/core/tools/SubagentTool.ts | 156 ++++++++++++----- src/core/tools/__tests__/SubagentTool.spec.ts | 45 +++++ src/core/webview/ClineProvider.ts | 44 +++-- src/shared/subagent.ts | 4 + webview-ui/src/components/chat/ChatView.tsx | 6 + .../src/context/ExtensionStateContext.tsx | 35 +++- 10 files changed, 416 insertions(+), 125 deletions(-) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7d8203d2ed..0e98e77ad9 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -87,6 +87,7 @@ export async function presentAssistantMessage(cline: Task) { } let block: any + let blocksConsumed = 1 try { // Performance optimization: Use shallow copy instead of deep clone. // The block is used read-only throughout this function - we never mutate its properties. @@ -410,8 +411,36 @@ export async function presentAssistantMessage(cline: Task) { break } - // Track if we've already pushed a tool result for this tool call (native tool calling only) - let hasToolResult = false + // Track which tool_use_ids have already received a tool_result (native tool calling only) + const toolResultsPushed = new Set() + const pushToolResultFor = (id: string, content: ToolResponse) => { + if (toolResultsPushed.has(id)) { + console.warn(`[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${id}`) + return + } + let resultContent: string + const imageBlocks: Anthropic.ImageBlockParam[] = [] + if (typeof content === "string") { + resultContent = content || "(tool did not return anything)" + } else { + const textBlocks = content.filter((item) => item.type === "text") + imageBlocks.push( + ...(content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]), + ) + resultContent = + textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || + "(tool did not return anything)" + } + cline.pushToolResultToUserContent({ + type: "tool_result", + tool_use_id: sanitizeToolUseId(id), + content: resultContent, + }) + if (imageBlocks.length > 0) { + cline.userMessageContent.push(...imageBlocks) + } + toolResultsPushed.add(id) + } // If this is a native tool call but the parser couldn't construct nativeArgs // (e.g., malformed/unfinished JSON in a streaming tool call), we must NOT attempt to @@ -452,17 +481,8 @@ export async function presentAssistantMessage(cline: Task) { let approvalFeedback: { text: string; images?: string[] } | undefined const pushToolResult = (content: ToolResponse) => { - // Native tool calling: only allow ONE tool_result per tool call - if (hasToolResult) { - console.warn( - `[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`, - ) - return - } - let resultContent: string let imageBlocks: Anthropic.ImageBlockParam[] = [] - if (typeof content === "string") { resultContent = content || "(tool did not return anything)" } else { @@ -472,7 +492,6 @@ export async function presentAssistantMessage(cline: Task) { textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") || "(tool did not return anything)" } - // Merge approval feedback into tool result (GitHub #10465) if (approvalFeedback) { const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text) @@ -482,18 +501,9 @@ export async function presentAssistantMessage(cline: Task) { imageBlocks = [...feedbackImageBlocks, ...imageBlocks] } } - - cline.pushToolResultToUserContent({ - type: "tool_result", - tool_use_id: sanitizeToolUseId(toolCallId), - content: resultContent, - }) - - if (imageBlocks.length > 0) { - cline.userMessageContent.push(...imageBlocks) - } - - hasToolResult = true + const mergedContent: ToolResponse = + imageBlocks.length > 0 ? [{ type: "text", text: resultContent }, ...imageBlocks] : resultContent + pushToolResultFor(toolCallId, mergedContent) } const askApproval = async ( @@ -818,14 +828,92 @@ export async function presentAssistantMessage(cline: Task) { toolCallId: block.id, }) break - case "subagent": - await subagentTool.handle(cline, block as ToolUse<"subagent">, { - askApproval, - handleError, - pushToolResult, - toolCallId: block.id, - }) + case "subagent": { + if (block.partial) { + break + } + // Collect consecutive subagent tool_use blocks for potential parallel execution + const subagentBlocks: (typeof block)[] = [block] + for ( + let i = cline.currentStreamingContentIndex + 1; + i < cline.assistantMessageContent.length; + i++ + ) { + const b = cline.assistantMessageContent[i] + if (b?.type === "tool_use" && b?.name === "subagent" && !b.partial) { + subagentBlocks.push(b) + } else { + break + } + } + if (subagentBlocks.length === 1) { + await subagentTool.handle(cline, block as ToolUse<"subagent">, { + askApproval, + handleError, + pushToolResult, + toolCallId: block.id, + }) + } else { + // Parallel subagent batch: start all, await all, finish each + const subagentCallbacks = (subagentBlock: typeof block) => ({ + askApproval, + handleError, + pushToolResult: (content: ToolResponse) => pushToolResultFor(subagentBlock.id, content), + toolCallId: subagentBlock.id, + }) + const getParams = ( + b: typeof block, + ): { description: string; prompt: string; subagent_type: "general" | "explore" } => { + if (b.nativeArgs && typeof b.nativeArgs === "object") { + const a = b.nativeArgs as Record + return { + description: a.description ?? "", + prompt: a.prompt ?? "", + subagent_type: (a.subagent_type === "explore" ? "explore" : "general") as + | "general" + | "explore", + } + } + return subagentTool.parseLegacy((b.params ?? {}) as Record) + } + const promises = subagentBlocks.map((subagentBlock) => + subagentTool.startAndReturnPromise( + cline, + getParams(subagentBlock), + subagentCallbacks(subagentBlock), + ), + ) + const results = await Promise.allSettled(promises) + for (let i = 0; i < subagentBlocks.length; i++) { + const subagentBlock = subagentBlocks[i] + const settled = results[i] + if (settled?.status === "fulfilled") { + await subagentTool.finish( + cline, + getParams(subagentBlock), + settled.value, + subagentCallbacks(subagentBlock), + ) + } + if (settled?.status === "rejected") { + cline.finalizeSubagentRunning(subagentBlock.id) + // startAndReturnPromise already pushed error for missing params; other errors need a tool_result + if ( + settled.reason?.message !== "Missing description" && + settled.reason?.message !== "Missing prompt" && + settled.reason?.message !== "Provider reference lost" + ) { + pushToolResultFor( + subagentBlock.id, + formatResponse.toolError("The subagent failed."), + ) + } + } + } + blocksConsumed = subagentBlocks.length + } break + } case "attempt_completion": { const completionCallbacks: AttemptCompletionCallbacks = { askApproval, @@ -953,7 +1041,7 @@ export async function presentAssistantMessage(cline: Task) { // (instead of preemptively doing it in iterator). if (!block.partial || cline.didRejectTool || cline.didAlreadyUseTool) { // Block is finished streaming and executing. - if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) { + if (cline.currentStreamingContentIndex + blocksConsumed >= cline.assistantMessageContent.length) { // It's okay that we increment if !didCompleteReadingStream, it'll // just return because out of bounds and as streaming continues it // will call `presentAssitantMessage` if a new block is ready. If @@ -966,9 +1054,8 @@ export async function presentAssistantMessage(cline: Task) { // Call next block if it exists (if not then read stream will call it // when it's ready). - // Need to increment regardless, so when read stream calls this function - // again it will be streaming the next block. - cline.currentStreamingContentIndex++ + // Need to increment by blocksConsumed (e.g. 1 normally, or N when we processed a parallel subagent batch). + cline.currentStreamingContentIndex += blocksConsumed if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { // There are already more content blocks to stream, so we'll call diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cd8fa171d0..57622d9d78 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -430,8 +430,8 @@ export class Task extends EventEmitter implements TaskLike { public subagentProgressCallback?: (currentTask: string) => void /** When false, saveClineMessages does not call updateTaskHistory (e.g. subagents). */ private readonly needUpdateHistory: boolean - /** When this task is the parent of a running subagent, holds the child task until it completes or is cancelled. */ - public activeSubagentChild?: Task + /** When this task is the parent of running subagents, holds child tasks keyed by toolCallId until they complete or are cancelled. */ + public activeSubagentChildren = new Map() constructor({ provider, @@ -1282,19 +1282,13 @@ export class Task extends EventEmitter implements TaskLike { } /** - * Updates the last "subagentRunning" say message with currentTask so the UI can show it in real time. - * No-op if no such message exists. + * Updates a "subagentRunning" say message with currentTask so the UI can show it in real time. + * When runId is set, updates the last message whose payload has that runId (for parallel subagents). + * When runId is not set, updates the last subagentRunning message (backward compatibility). + * No-op if no matching message exists. */ - public reportSubagentProgress(currentTask: string): void { - const idx = findLastIndex(this.clineMessages, (m) => { - if (m.type !== "say" || m.say !== "tool" || !m.text) return false - try { - const parsed = JSON.parse(m.text) as { tool?: string } - return parsed.tool === SUBAGENT_TOOL_NAMES.running - } catch { - return false - } - }) + public reportSubagentProgress(currentTask: string, runId?: string): void { + const idx = this.findSubagentRunningIndex(runId) if (idx === -1) return const msg = this.clineMessages[idx] try { @@ -1307,6 +1301,31 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Removes a "subagentRunning" message entirely so only the completed message remains. + * Called when the subagent finishes (success or error). + */ + public finalizeSubagentRunning(runId?: string): void { + const idx = this.findSubagentRunningIndex(runId) + if (idx === -1) return + this.clineMessages.splice(idx, 1) + void this.saveClineMessages() + } + + private findSubagentRunningIndex(runId?: string): number { + return findLastIndex(this.clineMessages, (m) => { + if (m.type !== "say" || m.say !== "tool" || !m.text) return false + try { + const parsed = JSON.parse(m.text) as SubagentRunningPayload + if (parsed.tool !== SUBAGENT_TOOL_NAMES.running) return false + if (runId !== undefined) return parsed.runId === runId + return true + } catch { + return false + } + }) + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index cc87b46b1f..cf8bd558a5 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -574,4 +574,38 @@ describe("reportSubagentProgress", () => { clineMessage: lastToolMsg, }) }) + + it("updates only the subagentRunning message with matching runId when runId is provided", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "parent task", + startTask: false, + }) + task.clineMessages = [ + { + type: "say", + say: "tool", + text: JSON.stringify({ tool: "subagentRunning", description: "Sub A", runId: "id-a" }), + ts: 1, + }, + { + type: "say", + say: "tool", + text: JSON.stringify({ tool: "subagentRunning", description: "Sub B", runId: "id-b" }), + ts: 2, + }, + ] + + task.reportSubagentProgress("Progress for B only", "id-b") + + const msgA = JSON.parse(task.clineMessages[0].text!) + const msgB = JSON.parse(task.clineMessages[1].text!) + expect(msgA.currentTask).toBeUndefined() + expect(msgB.currentTask).toBe("Progress for B only") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "messageUpdated", + clineMessage: task.clineMessages[1], + }) + }) }) diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index 2919f2479a..82ba6b10ac 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -80,15 +80,12 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { await task.say("completion_result", result, undefined, false) - // Force final token usage update before emitting TaskCompleted - // This ensures the most recent stats are captured regardless of throttle timer - // and properly updates the snapshot to prevent redundant emissions - task.emitFinalTokenUsageUpdate() - - TelemetryService.instance.captureTaskCompleted(task.taskId) - task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) - if (task.backgroundCompletionResolve) { + // Subagent path: emit TaskCompleted and telemetry once, then resolve and abort. + // Do not run this for normal tasks—emitTaskCompleted() runs on delegation (line 124) or user acceptance (line 151). + task.emitFinalTokenUsageUpdate() + TelemetryService.instance.captureTaskCompleted(task.taskId) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) task.subagentProgressCallback = undefined task.backgroundCompletionResolve(result) task.backgroundCompletionResolve = undefined diff --git a/src/core/tools/SubagentTool.ts b/src/core/tools/SubagentTool.ts index 13092b6094..be7e078635 100644 --- a/src/core/tools/SubagentTool.ts +++ b/src/core/tools/SubagentTool.ts @@ -33,29 +33,37 @@ export class SubagentTool extends BaseTool<"subagent"> { } } - async execute(params: SubagentParams, task: Task, callbacks: ToolCallbacks): Promise { + /** + * Starts a subagent and returns its Promise without awaiting. Used for parallel subagent batches. + * Caller must await the Promise then call finish() with the result. + */ + async startAndReturnPromise( + task: Task, + params: SubagentParams, + callbacks: ToolCallbacks, + ): Promise { const { description, prompt, subagent_type } = params - const { pushToolResult } = callbacks + const toolCallId = callbacks.toolCallId ?? `subagent-${Date.now()}-${Math.random().toString(36).slice(2)}` const provider = task.providerRef.deref() if (!provider || !isSubagentRunner(provider)) { - pushToolResult(formatResponse.toolError("Provider reference lost")) - return + callbacks.pushToolResult(formatResponse.toolError("Provider reference lost")) + return Promise.reject(new Error("Provider reference lost")) } if (!description?.trim()) { task.consecutiveMistakeCount++ task.recordToolError("subagent") task.didToolFailInCurrentTurn = true - pushToolResult(await task.sayAndCreateMissingParamError("subagent", "description")) - return + task.sayAndCreateMissingParamError("subagent", "description").then((msg) => callbacks.pushToolResult(msg)) + return Promise.reject(new Error("Missing description")) } if (!prompt?.trim()) { task.consecutiveMistakeCount++ task.recordToolError("subagent") task.didToolFailInCurrentTurn = true - pushToolResult(await task.sayAndCreateMissingParamError("subagent", "prompt")) - return + task.sayAndCreateMissingParamError("subagent", "prompt").then((msg) => callbacks.pushToolResult(msg)) + return Promise.reject(new Error("Missing prompt")) } task.consecutiveMistakeCount = 0 @@ -64,60 +72,116 @@ export class SubagentTool extends BaseTool<"subagent"> { tool: SUBAGENT_TOOL_NAMES.running, description, currentTask: SUBAGENT_STATUS_STARTING, + runId: toolCallId, } const runningText = JSON.stringify(runningPayload) const progressStatus = { icon: "sync", spin: true } - await task.say("tool", runningText, undefined, true, undefined, progressStatus, { + await task.say("tool", runningText, undefined, undefined, undefined, progressStatus, { isNonInteractive: true, }) + const runParams: RunSubagentInBackgroundParams = { + parentTaskId: task.taskId, + prompt, + subagentType: subagent_type, + onProgress: (currentTask) => task.reportSubagentProgress(currentTask, toolCallId), + toolCallId, + } + return provider.runSubagentInBackground(runParams) + } + + /** + * Emits subagentCompleted and pushes the tool result. Call after startAndReturnPromise's Promise resolves. + */ + async finish( + task: Task, + params: SubagentParams, + result: string | SubagentStructuredResult, + callbacks: ToolCallbacks, + ): Promise { + const { description } = params + const { pushToolResult } = callbacks + const toolCallId = callbacks.toolCallId + + const isStructured = (r: string | SubagentStructuredResult): r is SubagentStructuredResult => + typeof r === "object" && r !== null && "code" in r && "messageKey" in r + + // Stop the spinner on the "Running subagent" row + task.finalizeSubagentRunning(toolCallId) + + let completedPayload: SubagentCompletedPayload + let toolResult: string + if (isStructured(result)) { + completedPayload = { + tool: SUBAGENT_TOOL_NAMES.completed, + description, + result: SUBAGENT_CANCELLED_MODEL_MESSAGE, + resultCode: result.code, + messageKey: result.messageKey, + } + toolResult = SUBAGENT_CANCELLED_MODEL_MESSAGE + } else { + completedPayload = { + tool: SUBAGENT_TOOL_NAMES.completed, + description, + result, + } + toolResult = result + } + const completedText = JSON.stringify(completedPayload) + await task.say("tool", completedText, undefined, undefined, undefined, undefined, { + isNonInteractive: true, + }) + pushToolResult(formatResponse.toolResult(toolResult)) + } + + async execute(params: SubagentParams, task: Task, callbacks: ToolCallbacks): Promise { + const { pushToolResult } = callbacks + + const provider = task.providerRef.deref() + if (!provider || !isSubagentRunner(provider)) { + pushToolResult(formatResponse.toolError("Provider reference lost")) + return + } + + if (!params.description?.trim()) { + task.consecutiveMistakeCount++ + task.recordToolError("subagent") + task.didToolFailInCurrentTurn = true + pushToolResult(await task.sayAndCreateMissingParamError("subagent", "description")) + return + } + if (!params.prompt?.trim()) { + task.consecutiveMistakeCount++ + task.recordToolError("subagent") + task.didToolFailInCurrentTurn = true + pushToolResult(await task.sayAndCreateMissingParamError("subagent", "prompt")) + return + } + try { - const runParams: RunSubagentInBackgroundParams = { - parentTaskId: task.taskId, - prompt, - subagentType: subagent_type, - onProgress: (currentTask) => task.reportSubagentProgress(currentTask), - } - const result = await provider.runSubagentInBackground(runParams) - - const isStructured = (r: string | SubagentStructuredResult): r is SubagentStructuredResult => - typeof r === "object" && r !== null && "code" in r && "messageKey" in r - - let completedPayload: SubagentCompletedPayload - let toolResult: string - if (isStructured(result)) { - completedPayload = { - tool: SUBAGENT_TOOL_NAMES.completed, - description, - result: SUBAGENT_CANCELLED_MODEL_MESSAGE, - resultCode: result.code, - messageKey: result.messageKey, - } - toolResult = SUBAGENT_CANCELLED_MODEL_MESSAGE - } else { - completedPayload = { - tool: SUBAGENT_TOOL_NAMES.completed, - description, - result, - } - toolResult = result - } - const completedText = JSON.stringify(completedPayload) - await task.say("tool", completedText, undefined, false, undefined, undefined, { - isNonInteractive: true, - }) - pushToolResult(formatResponse.toolResult(toolResult)) + const result = await this.startAndReturnPromise(task, params, callbacks) + await this.finish(task, params, result, callbacks) } catch (error) { + if ( + error instanceof Error && + (error.message === "Missing description" || + error.message === "Missing prompt" || + error.message === "Provider reference lost") + ) { + return + } console.error("Subagent failed:", error) + task.finalizeSubagentRunning(callbacks.toolCallId) task.recordToolError("subagent") const errorPayload: SubagentCompletedPayload = { tool: SUBAGENT_TOOL_NAMES.completed, - description, + description: params.description, error: SUBAGENT_FAILED_MODEL_MESSAGE, } const errorPayloadStr = JSON.stringify(errorPayload) - await task.say("tool", errorPayloadStr, undefined, false, undefined, undefined, { + await task.say("tool", errorPayloadStr, undefined, undefined, undefined, undefined, { isNonInteractive: true, }) pushToolResult(formatResponse.toolError(SUBAGENT_FAILED_MODEL_MESSAGE)) diff --git a/src/core/tools/__tests__/SubagentTool.spec.ts b/src/core/tools/__tests__/SubagentTool.spec.ts index dcd6dd4d83..7dacdea6e9 100644 --- a/src/core/tools/__tests__/SubagentTool.spec.ts +++ b/src/core/tools/__tests__/SubagentTool.spec.ts @@ -20,6 +20,7 @@ const mockRunSubagentInBackground = parentTaskId: string prompt: string subagentType: "general" | "explore" + toolCallId: string }) => Promise >() const mockSay = vi.fn() @@ -28,6 +29,7 @@ const mockHandleError = vi.fn() const mockSayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") const mockRecordToolError = vi.fn() const mockReportSubagentProgress = vi.fn() +const mockFinalizeSubagentRunning = vi.fn() const mockTask = { taskId: "parent-1", @@ -37,6 +39,7 @@ const mockTask = { sayAndCreateMissingParamError: mockSayAndCreateMissingParamError, say: mockSay, reportSubagentProgress: mockReportSubagentProgress, + finalizeSubagentRunning: mockFinalizeSubagentRunning, providerRef: { deref: () => ({ runSubagentInBackground: mockRunSubagentInBackground, @@ -193,4 +196,46 @@ describe("SubagentTool", () => { expect(payload.error).toBe(SUBAGENT_FAILED_MODEL_MESSAGE) }) }) + + describe("startAndReturnPromise and finish", () => { + it("startAndReturnPromise returns a Promise that resolves with the result and finish says completed and pushes", async () => { + mockRunSubagentInBackground.mockResolvedValue("Result from subagent") + mockSay.mockResolvedValue(undefined) + const params = { description: "Do X", prompt: "Do it", subagent_type: "general" as const } + const callbacks = { + askApproval: vi.fn(), + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "tool-call-123", + } + + const promise = subagentTool.startAndReturnPromise(mockTask as any, params, callbacks) + const result = await promise + expect(result).toBe("Result from subagent") + expect(mockRunSubagentInBackground).toHaveBeenCalledWith( + expect.objectContaining({ + parentTaskId: "parent-1", + prompt: "Do it", + subagentType: "general", + toolCallId: "tool-call-123", + }), + ) + + mockSay.mockClear() + mockPushToolResult.mockClear() + await subagentTool.finish(mockTask as any, params, result, callbacks) + const sayCalls = mockSay.mock.calls + const completedCall = sayCalls.find((c) => { + try { + const payload = JSON.parse(c[1]) + return payload.tool === "subagentCompleted" + } catch { + return false + } + }) + expect(completedCall).toBeDefined() + expect(JSON.parse(completedCall![1]).result).toBe("Result from subagent") + expect(mockPushToolResult).toHaveBeenCalledWith("Result from subagent") + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 39e461b835..cd5aded78d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2992,19 +2992,21 @@ export class ClineProvider return } - // If the current task has a running subagent, cancel only the subagent and return the cancellation result to the parent. - const subagentChild = task.activeSubagentChild - if (subagentChild) { - task.activeSubagentChild = undefined - subagentChild.subagentProgressCallback = undefined - if (subagentChild.backgroundCompletionResolve) { - subagentChild.backgroundCompletionResolve(SUBAGENT_CANCELLED_STRUCTURED_RESULT) - subagentChild.backgroundCompletionResolve = undefined + // If the current task has running subagents, cancel all of them and return (do not cancel the parent). + if (task.activeSubagentChildren.size > 0) { + const children = Array.from(task.activeSubagentChildren.entries()) + task.activeSubagentChildren.clear() + for (const [_toolCallId, subagentChild] of children) { + subagentChild.subagentProgressCallback = undefined + if (subagentChild.backgroundCompletionResolve) { + subagentChild.backgroundCompletionResolve(SUBAGENT_CANCELLED_STRUCTURED_RESULT) + subagentChild.backgroundCompletionResolve = undefined + } + subagentChild.abandoned = true + subagentChild.cancelCurrentRequest() + subagentChild.abortTask() + this.log(`[cancelTask] cancelled subagent ${subagentChild.taskId}.${subagentChild.instanceId}`) } - subagentChild.abandoned = true - subagentChild.cancelCurrentRequest() - subagentChild.abortTask() - this.log(`[cancelTask] cancelled subagent ${subagentChild.taskId}.${subagentChild.instanceId}`) return } @@ -3388,7 +3390,10 @@ export class ClineProvider public async runSubagentInBackground( params: RunSubagentInBackgroundParams, ): Promise { - const { parentTaskId, prompt, subagentType, onProgress } = params + const { parentTaskId, prompt, subagentType, onProgress, toolCallId } = params + if (!toolCallId) { + throw new Error("[runSubagentInBackground] toolCallId is required") + } const parent = this.getCurrentTask() if (!parent) { throw new Error("[runSubagentInBackground] No current task") @@ -3427,14 +3432,17 @@ export class ClineProvider child.subagentProgressCallback = onProgress } - parent.activeSubagentChild = child + parent.activeSubagentChildren.set(toolCallId, child) return new Promise((resolve, reject) => { + const removeChild = () => { + parent.activeSubagentChildren.delete(toolCallId) + } let settled = false child.backgroundCompletionResolve = (result: string | SubagentStructuredResult) => { if (!settled) { settled = true - parent.activeSubagentChild = undefined + removeChild() resolve(result) } } @@ -3443,19 +3451,19 @@ export class ClineProvider .then(() => { if (!settled) { settled = true - parent.activeSubagentChild = undefined + removeChild() reject(new Error("Subagent ended without attempt_completion")) } }) .catch((err) => { if (!settled) { settled = true - parent.activeSubagentChild = undefined + removeChild() reject(err) } }) .finally(() => { - parent.activeSubagentChild = undefined + removeChild() }) }) } diff --git a/src/shared/subagent.ts b/src/shared/subagent.ts index 674ead5086..dc2374f933 100644 --- a/src/shared/subagent.ts +++ b/src/shared/subagent.ts @@ -36,6 +36,8 @@ export interface SubagentRunningPayload { tool: typeof SUBAGENT_TOOL_NAMES.running description?: string currentTask?: string + /** Identifies this run for progress updates when multiple subagents run in parallel (e.g. block.id). */ + runId?: string } /** Payload for the "subagentCompleted" tool message (result or error). */ @@ -57,6 +59,8 @@ export interface RunSubagentInBackgroundParams { prompt: string subagentType: SubagentType onProgress?: (currentTask: string) => void + /** Required: keys the child in activeSubagentChildren and ties completion to the correct tool_use_id. */ + toolCallId: string } /** Provider interface for running a subagent. Allows SubagentTool to call without type assertion. */ diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fd0aca66cb..82e1535471 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -561,6 +561,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction m.progressStatus?.spin === true)) { + return true + } + return false }, [modifiedMessages, clineAsk, enableButtons, primaryButtonText]) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ce7a607d9a..0bcc91eebc 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -373,11 +373,38 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode case "messageUpdated": { const clineMessage = message.clineMessage! setState((prevState) => { - // worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock - const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts) - if (lastIndex !== -1) { + // For subagentRunning updates, match by runId so parallel subagents (same ts) each update their own row + let index = -1 + if ( + clineMessage.type === "say" && + clineMessage.say === "tool" && + typeof clineMessage.text === "string" + ) { + try { + const payload = JSON.parse(clineMessage.text) as { tool?: string; runId?: string } + if (payload.tool === "subagentRunning" && payload.runId != null) { + index = prevState.clineMessages.findIndex((msg) => { + if (msg.type !== "say" || msg.say !== "tool" || typeof msg.text !== "string") + return false + try { + const p = JSON.parse(msg.text) as { tool?: string; runId?: string } + return p.tool === "subagentRunning" && p.runId === payload.runId + } catch { + return false + } + }) + } + } catch { + // fall through to ts match + } + } + if (index === -1) { + // worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock + index = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts) + } + if (index !== -1) { const newClineMessages = [...prevState.clineMessages] - newClineMessages[lastIndex] = clineMessage + newClineMessages[index] = clineMessage return { ...prevState, clineMessages: newClineMessages } } // Log a warning if messageUpdated arrives for a timestamp not in the