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.
This commit is contained in:
Ruslan Andreev 2026-03-02 12:14:15 +03:00
parent 0ead5bb238
commit 2d8842b728
10 changed files with 416 additions and 125 deletions

View file

@ -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<string>()
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<string, string>
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<string, string>)
}
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

View file

@ -430,8 +430,8 @@ export class Task extends EventEmitter<TaskEvents> 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<string, Task>()
constructor({
provider,
@ -1282,19 +1282,13 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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).

View file

@ -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],
})
})
})

View file

@ -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

View file

@ -33,29 +33,37 @@ export class SubagentTool extends BaseTool<"subagent"> {
}
}
async execute(params: SubagentParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
/**
* 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<string | SubagentStructuredResult> {
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<void> {
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<void> {
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))

View file

@ -20,6 +20,7 @@ const mockRunSubagentInBackground =
parentTaskId: string
prompt: string
subagentType: "general" | "explore"
toolCallId: string
}) => Promise<string | SubagentStructuredResult>
>()
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")
})
})
})

View file

@ -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<string | SubagentStructuredResult> {
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<string | SubagentStructuredResult>((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()
})
})
}

View file

@ -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. */

View file

@ -561,6 +561,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}
// Check if any subagent is currently running (has a spinning progress indicator).
// The parent task's API call is already finished, but we still want the stop button.
if (modifiedMessages.some((m) => m.progressStatus?.spin === true)) {
return true
}
return false
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])

View file

@ -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