diff --git a/cli/src/components/SubagentMessage.tsx b/cli/src/components/SubagentMessage.tsx index 46b0158f12..b4a01cd81e 100644 --- a/cli/src/components/SubagentMessage.tsx +++ b/cli/src/components/SubagentMessage.tsx @@ -1,4 +1,4 @@ -import type { ClineAskUseSubagents, ClineMessage, ClineSaySubagentStatus, SubagentStatusItem } from "@shared/ExtensionMessage" +import type { ClineAskUseSubagents, ClineMessage, ClineSaySubagentStatus } from "@shared/ExtensionMessage" import { Box, Text } from "ink" import Spinner from "ink-spinner" import React from "react" @@ -55,11 +55,16 @@ function formatCompactCost(cost: number | undefined): string { }).format(value) } -function formatSubagentStats(entry: SubagentStatusItem): string { - const toolUses = entry.toolCalls === 1 ? "tool use" : "tool uses" - const tokensUsed = formatCompactTokens(entry.contextTokens) - const totalCost = formatCompactCost(entry.totalCost) - return `${entry.toolCalls} ${toolUses} · ${tokensUsed} tokens · ${totalCost}` +function formatSubagentStatsValues( + toolCalls: number | undefined, + contextTokens: number | undefined, + totalCost: number | undefined, +) { + const safeToolCalls = Number.isFinite(toolCalls) ? Math.max(0, toolCalls || 0) : 0 + const toolUses = safeToolCalls === 1 ? "tool use" : "tool uses" + const tokensUsed = formatCompactTokens(contextTokens || 0) + const formattedCost = formatCompactCost(totalCost || 0) + return `${safeToolCalls} ${toolUses} · ${tokensUsed} tokens · ${formattedCost}` } function wrapPrompt(text: string, width: number): string[] { @@ -177,7 +182,7 @@ export const SubagentMessage: React.FC = ({ message, mode, return ( - Cline wants to run subagents + Cline wants to run subagents: ) @@ -187,22 +192,30 @@ export const SubagentMessage: React.FC = ({ message, mode, return ( - {singular ? "Cline wants to run a subagent" : "Cline wants to run subagents"} + {singular ? "Cline wants to run a subagent:" : "Cline wants to run subagents:"} {prompts.map((prompt, index) => { const isLastPrompt = index === prompts.length - 1 const branch = isLastPrompt ? "└─" : "├─" const continuationPrefix = isLastPrompt ? " " : "│ " + const shouldShowPromptStats = partial !== true || !isLastPrompt return ( - {`${branch} `}} - prompt={prompt} - promptWidth={promptWidth} - /> + + {`${branch} `}} + prompt={prompt} + promptWidth={promptWidth} + /> + {shouldShowPromptStats && ( + + )} + ) })} @@ -235,7 +248,7 @@ export const SubagentMessage: React.FC = ({ message, mode, - {items.length === 1 ? "Cline is running a subagent" : "Cline is running subagents"} + {items.length === 1 ? "Cline is running a subagent:" : "Cline is running subagents:"} @@ -244,6 +257,7 @@ export const SubagentMessage: React.FC = ({ message, mode, const branch = isLastEntry ? "└─" : "├─" const continuationPrefix = isLastEntry ? " " : "│ " const key = `${entry.index}-${index}` + const shouldShowStats = true if (entry.status === "completed") { return ( @@ -260,7 +274,10 @@ export const SubagentMessage: React.FC = ({ message, mode, prompt={entry.prompt} promptWidth={promptWidth} /> - + ) } @@ -280,7 +297,10 @@ export const SubagentMessage: React.FC = ({ message, mode, prompt={entry.prompt} promptWidth={promptWidth} /> - + ) } @@ -305,7 +325,12 @@ export const SubagentMessage: React.FC = ({ message, mode, prompt={entry.prompt} promptWidth={promptWidth} /> - + {shouldShowStats && ( + + )} ) })} diff --git a/src/core/task/tools/handlers/SubagentToolHandler.ts b/src/core/task/tools/handlers/SubagentToolHandler.ts index 3e7f70ea03..937bbec7ea 100644 --- a/src/core/task/tools/handlers/SubagentToolHandler.ts +++ b/src/core/task/tools/handlers/SubagentToolHandler.ts @@ -39,8 +39,26 @@ export class UseSubagentsToolHandler implements IFullyManagedTool { return "[subagents]" } - async handlePartialBlock(_block: ToolUse, _uiHelpers: StronglyTypedUIHelpers): Promise { - return + async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise { + const prompts = PROMPT_KEYS.map((key) => uiHelpers.removeClosingTag(block, key, block.params[key]?.trim())) + .map((prompt) => prompt?.trim()) + .filter((prompt): prompt is string => !!prompt) + + if (prompts.length === 0) { + return + } + + const partialMessage = JSON.stringify({ prompts } satisfies ClineAskUseSubagents) + const autoApproveResult = uiHelpers.shouldAutoApproveTool(this.name) + const [shouldAutoApprove] = Array.isArray(autoApproveResult) ? autoApproveResult : [autoApproveResult, false] + + if (shouldAutoApprove) { + await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "use_subagents") + await uiHelpers.say("use_subagents", partialMessage, undefined, undefined, block.partial) + } else { + await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "use_subagents") + await uiHelpers.ask("use_subagents", partialMessage, block.partial).catch(() => {}) + } } async execute(config: TaskConfig, block: ToolUse): Promise { diff --git a/src/core/task/tools/handlers/__tests__/SubagentToolHandler.test.ts b/src/core/task/tools/handlers/__tests__/SubagentToolHandler.test.ts index e79488140a..48c74fb2b8 100644 --- a/src/core/task/tools/handlers/__tests__/SubagentToolHandler.test.ts +++ b/src/core/task/tools/handlers/__tests__/SubagentToolHandler.test.ts @@ -1,10 +1,13 @@ import { strict as assert } from "node:assert" import { setTimeout as delay } from "node:timers/promises" import { ClineSubagentUsageInfo } from "@shared/ExtensionMessage" +import { ClineDefaultTool } from "@shared/tools" import { afterEach, describe, it } from "mocha" import sinon from "sinon" import { TaskState } from "../../../TaskState" import { SubagentRunner } from "../../subagent/SubagentRunner" +import type { TaskConfig } from "../../types/TaskConfig" +import { createUIHelpers } from "../../types/UIHelpers" import { UseSubagentsToolHandler } from "../SubagentToolHandler" function createConfig(options?: { @@ -39,7 +42,7 @@ function createConfig(options?: { runUserPromptSubmitHook: sinon.stub().resolves({}), } - const config: any = { + const config = { taskId: "task-1", ulid: "ulid-1", cwd: "/tmp", @@ -89,7 +92,7 @@ function createConfig(options?: { coordinator: { getHandler: sinon.stub(), }, - } + } as unknown as TaskConfig return { config, callbacks, taskState } } @@ -105,7 +108,7 @@ describe("SubagentToolHandler", () => { const result = await handler.execute(config, { type: "tool_use", - name: "use_subagents" as any, + name: ClineDefaultTool.USE_SUBAGENTS, params: {}, partial: false, }) @@ -115,6 +118,62 @@ describe("SubagentToolHandler", () => { sinon.assert.calledOnce(callbacks.sayAndCreateMissingParamError) }) + it("streams partial use_subagents approval as ask when not auto-approved", async () => { + const { config, callbacks } = createConfig({ autoApproveSafe: false, autoApproveAll: false }) + const handler = new UseSubagentsToolHandler() + const uiHelpers = createUIHelpers(config) + + await handler.handlePartialBlock( + { + type: "tool_use", + name: ClineDefaultTool.USE_SUBAGENTS, + params: { + prompt_1: "first prompt", + prompt_2: "second prompt", + }, + partial: true, + }, + uiHelpers, + ) + + sinon.assert.calledOnce(callbacks.removeLastPartialMessageIfExistsWithType) + sinon.assert.calledWithExactly(callbacks.removeLastPartialMessageIfExistsWithType, "say", "use_subagents") + sinon.assert.calledOnce(callbacks.ask) + sinon.assert.calledWithMatch(callbacks.ask, "use_subagents", sinon.match.string, true) + + const payload = JSON.parse(callbacks.ask.firstCall.args[1]) + assert.deepEqual(payload.prompts, ["first prompt", "second prompt"]) + sinon.assert.notCalled(callbacks.say) + }) + + it("streams partial use_subagents approval as say when auto-approved", async () => { + const { config, callbacks } = createConfig({ autoApproveSafe: true, autoApproveAll: false }) + const handler = new UseSubagentsToolHandler() + const uiHelpers = createUIHelpers(config) + + await handler.handlePartialBlock( + { + type: "tool_use", + name: ClineDefaultTool.USE_SUBAGENTS, + params: { + prompt_1: "first prompt", + prompt_2: "second prompt", + }, + partial: true, + }, + uiHelpers, + ) + + sinon.assert.calledOnce(callbacks.removeLastPartialMessageIfExistsWithType) + sinon.assert.calledWithExactly(callbacks.removeLastPartialMessageIfExistsWithType, "ask", "use_subagents") + sinon.assert.calledOnce(callbacks.say) + sinon.assert.calledWithMatch(callbacks.say, "use_subagents", sinon.match.string, undefined, undefined, true) + + const payload = JSON.parse(callbacks.say.firstCall.args[1]) + assert.deepEqual(payload.prompts, ["first prompt", "second prompt"]) + sinon.assert.notCalled(callbacks.ask) + }) + it("uses one approval for the full batch and stops on denial", async () => { const { config, callbacks, taskState } = createConfig({ taskAskResponse: "noButtonClicked" }) const runStub = sinon.stub(SubagentRunner.prototype, "run") @@ -122,7 +181,7 @@ describe("SubagentToolHandler", () => { const result = await handler.execute(config, { type: "tool_use", - name: "use_subagents" as any, + name: ClineDefaultTool.USE_SUBAGENTS, params: { prompt_1: "one", prompt_2: "two", @@ -158,7 +217,7 @@ describe("SubagentToolHandler", () => { const handler = new UseSubagentsToolHandler() await handler.execute(config, { type: "tool_use", - name: "use_subagents" as any, + name: ClineDefaultTool.USE_SUBAGENTS, params: { prompt_1: "one", }, @@ -166,7 +225,7 @@ describe("SubagentToolHandler", () => { }) sinon.assert.notCalled(callbacks.ask) - const subagentStatusCalls = callbacks.say.getCalls().filter((call: any) => call.args[0] === "subagent") + const subagentStatusCalls = callbacks.say.getCalls().filter((call) => call.args[0] === "subagent") assert.ok(subagentStatusCalls.length >= 1) }) @@ -175,7 +234,7 @@ describe("SubagentToolHandler", () => { let activeRuns = 0 let maxActiveRuns = 0 - sinon.stub(SubagentRunner.prototype, "run").callsFake(async (_prompt: string, onProgress: any) => { + sinon.stub(SubagentRunner.prototype, "run").callsFake(async (_prompt: string, onProgress) => { activeRuns++ maxActiveRuns = Math.max(maxActiveRuns, activeRuns) onProgress({ @@ -214,7 +273,7 @@ describe("SubagentToolHandler", () => { const handler = new UseSubagentsToolHandler() const result = await handler.execute(config, { type: "tool_use", - name: "use_subagents" as any, + name: ClineDefaultTool.USE_SUBAGENTS, params: { prompt_1: "one", prompt_2: "two", @@ -227,12 +286,12 @@ describe("SubagentToolHandler", () => { assert.ok((result as string).includes("Total: 3")) assert.ok(maxActiveRuns > 1) - const subagentStatusCalls = callbacks.say.getCalls().filter((call: any) => call.args[0] === "subagent") + const subagentStatusCalls = callbacks.say.getCalls().filter((call) => call.args[0] === "subagent") assert.ok(subagentStatusCalls.length >= 2) const finalCall = subagentStatusCalls[subagentStatusCalls.length - 1] assert.equal(finalCall.args[4], false) - const usageCalls = callbacks.say.getCalls().filter((call: any) => call.args[0] === "subagent_usage") + const usageCalls = callbacks.say.getCalls().filter((call) => call.args[0] === "subagent_usage") assert.equal(usageCalls.length, 1) const usagePayload = JSON.parse(usageCalls[0].args[1]) as ClineSubagentUsageInfo assert.equal(usagePayload.source, "subagents") @@ -284,7 +343,7 @@ describe("SubagentToolHandler", () => { const handler = new UseSubagentsToolHandler() const result = await handler.execute(config, { type: "tool_use", - name: "use_subagents" as any, + name: ClineDefaultTool.USE_SUBAGENTS, params: { prompt_1: "succeed", prompt_2: "fail", diff --git a/webview-ui/src/components/chat/SubagentStatusRow.tsx b/webview-ui/src/components/chat/SubagentStatusRow.tsx index b98bade74c..10aecf14a4 100644 --- a/webview-ui/src/components/chat/SubagentStatusRow.tsx +++ b/webview-ui/src/components/chat/SubagentStatusRow.tsx @@ -146,13 +146,14 @@ export default function SubagentStatusRow({ message, isLast, lastModifiedMessage {title}
- {data.items.map((entry) => { + {data.items.map((entry, _index) => { const displayStatus: DisplayStatus = wasCancelled && (entry.status === "running" || entry.status === "pending") ? "cancelled" : entry.status const hasDetails = Boolean( (entry.result && entry.status === "completed") || (entry.error && entry.status === "failed"), ) const isExpanded = expandedItems[entry.index] === true + const shouldShowStats = entry.status !== "pending" return (
-
- {formatCount(entry.toolCalls)} tools called | {formatCount(entry.contextTokens)} tokens used |{" "} - {formatCost(entry.totalCost)} -
+ {shouldShowStats && ( +
+ {formatCount(entry.toolCalls)} tools called | {formatCount(entry.contextTokens)} tokens used |{" "} + {formatCost(entry.totalCost)} +
+ )} {hasDetails && (