diff --git a/src/agent/subagents/manager.ts b/src/agent/subagents/manager.ts index e0be8d5..d698da9 100644 --- a/src/agent/subagents/manager.ts +++ b/src/agent/subagents/manager.ts @@ -680,6 +680,12 @@ async function executeSubagent( }, }); + // Consider execution "running" once the child process has successfully spawned. + // This avoids waiting on subagent init events (e.g. agentURL) to reflect progress. + proc.once("spawn", () => { + updateSubagent(subagentId, { status: "running" }); + }); + // Set up abort handler to kill the child process let wasAborted = false; const abortHandler = () => { @@ -708,6 +714,14 @@ async function executeSubagent( crlfDelay: Number.POSITIVE_INFINITY, }); + let rlClosed = false; + const rlClosedPromise = new Promise((resolve) => { + rl.once("close", () => { + rlClosed = true; + resolve(); + }); + }); + rl.on("line", (line: string) => { stdoutChunks.push(Buffer.from(`${line}\n`)); processStreamEvent(line, state, subagentId); @@ -723,6 +737,13 @@ async function executeSubagent( proc.on("error", () => resolve(null)); }); + // Ensure all stdout lines have been processed before completing. + // Without this, late tool events can be dropped before Task marks completion. + if (!rlClosed) { + rl.close(); + } + await rlClosedPromise; + // Clean up abort listener signal?.removeEventListener("abort", abortHandler); diff --git a/src/cli/components/SubagentGroupDisplay.tsx b/src/cli/components/SubagentGroupDisplay.tsx index 24a36c7..f515119 100644 --- a/src/cli/components/SubagentGroupDisplay.tsx +++ b/src/cli/components/SubagentGroupDisplay.tsx @@ -25,6 +25,7 @@ import { } from "../helpers/subagentDisplay.js"; import { getSnapshot, + getSubagentToolCount, type SubagentState, subscribe, toggleExpanded, @@ -71,20 +72,22 @@ interface AgentRowProps { const AgentRow = memo( ({ agent, isLast, expanded, condensed = false }: AgentRowProps) => { const { treeChar, continueChar } = getTreeChars(isLast); + const rowIndent = " "; + const statusIndent = " "; + const expandedToolIndent = " "; const columns = useTerminalWidth(); - const gutterWidth = 8; // indent (3) + continueChar (2) + status indent (3) + const gutterWidth = + rowIndent.length + continueChar.length + statusIndent.length; const contentWidth = Math.max(0, columns - gutterWidth); const isRunning = agent.status === "pending" || agent.status === "running"; + const toolCount = getSubagentToolCount(agent); const shouldDim = isRunning && !agent.isBackground; - const showStats = !(agent.isBackground && isRunning); + const showStats = + !(agent.isBackground && isRunning) && !(isRunning && toolCount === 0); const hideBackgroundStatusLine = agent.isBackground && isRunning && !agent.agentURL; - const stats = formatStats( - agent.toolCalls.length, - agent.totalTokens, - isRunning, - ); + const stats = formatStats(toolCount, agent.totalTokens); const modelDisplay = getSubagentModelDisplay(agent.model); const lastTool = agent.toolCalls[agent.toolCalls.length - 1]; @@ -98,9 +101,9 @@ const AgentRow = memo( {/* Main row: tree char + description + type + model (no stats) */} - + - {" "} + {rowIndent} {treeChar}{" "} @@ -131,19 +134,37 @@ const AgentRow = memo( {/* Simple status line */} {!hideBackgroundStatusLine && ( - - {" "} - {continueChar} - - {" "} - {agent.status === "error" ? ( - Error - ) : isComplete ? ( - Done - ) : agent.isBackground ? ( - Running in the background + {!agent.agentURL && + !lastTool && + !isComplete && + agent.status !== "error" && + !agent.isBackground ? ( + <> + + {rowIndent} + {continueChar} ⎿{" "} + + Launching... + ) : ( - Running... + <> + + {rowIndent} + {continueChar} + + {statusIndent} + {agent.status === "error" ? ( + Error + ) : isComplete ? ( + Done + ) : agent.isBackground ? ( + Running in the background + ) : lastTool ? ( + Running... + ) : ( + Thinking + )} + )} )} @@ -156,9 +177,9 @@ const AgentRow = memo( {/* Main row: tree char + description + type + model + stats */} - + - {" "} + {rowIndent} {treeChar}{" "} @@ -195,7 +216,7 @@ const AgentRow = memo( {agent.agentURL && ( - {" "} + {rowIndent} {continueChar} ⎿{" "} {"Subagent: "} @@ -210,11 +231,11 @@ const AgentRow = memo( return ( - {" "} + {rowIndent} {continueChar} - {" "} + {expandedToolIndent} {tc.name}({formattedArgs}) @@ -224,23 +245,15 @@ const AgentRow = memo( {/* Status line */} {!hideBackgroundStatusLine && ( - {agent.status === "completed" ? ( - <> - - {" "} - {continueChar} - - {" Done"} - - ) : agent.status === "error" ? ( + {agent.status === "error" ? ( <> - {" "} + {rowIndent} {continueChar} - {" "} + {statusIndent} @@ -249,32 +262,33 @@ const AgentRow = memo( - ) : agent.isBackground ? ( - - - {" "} - {continueChar} - - {" Running in the background"} - - ) : lastTool ? ( + ) : !agent.agentURL && + !lastTool && + agent.status !== "completed" && + !agent.isBackground ? ( <> - {" "} - {continueChar} - - - {" "} - {lastTool.name} + {rowIndent} + {continueChar} ⎿{" "} + Launching... ) : ( <> - {" "} + {rowIndent} {continueChar} - {" Starting..."} + + {statusIndent} + {agent.status === "completed" + ? "Done" + : agent.isBackground + ? "Running in the background" + : lastTool + ? lastTool.name + : "Thinking"} + )} diff --git a/src/cli/components/SubagentGroupStatic.tsx b/src/cli/components/SubagentGroupStatic.tsx index 78f2baf..34fb90b 100644 --- a/src/cli/components/SubagentGroupStatic.tsx +++ b/src/cli/components/SubagentGroupStatic.tsx @@ -56,8 +56,11 @@ interface AgentRowProps { const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { const { treeChar, continueChar } = getTreeChars(isLast); + const rowIndent = " "; + const statusIndent = " "; const columns = useTerminalWidth(); - const gutterWidth = 8; // indent (3) + continueChar (2) + status indent (3) + const gutterWidth = + rowIndent.length + continueChar.length + statusIndent.length; const contentWidth = Math.max(0, columns - gutterWidth); const isRunning = agent.status === "running"; @@ -65,16 +68,16 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { const showStats = !(agent.isBackground && isRunning); const hideBackgroundStatusLine = agent.isBackground && isRunning && !agent.agentURL; - const stats = formatStats(agent.toolCount, agent.totalTokens, isRunning); + const stats = formatStats(agent.toolCount, agent.totalTokens); const modelDisplay = getSubagentModelDisplay(agent.model); return ( {/* Main row: tree char + description + type + model + stats */} - + - {" "} + {rowIndent} {treeChar}{" "} @@ -111,7 +114,7 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { {agent.agentURL && ( - {" "} + {rowIndent} {continueChar} ⎿{" "} {"Subagent: "} @@ -122,23 +125,15 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { {/* Status line */} {!hideBackgroundStatusLine && ( - {agent.status === "completed" && !agent.isBackground ? ( - <> - - {" "} - {continueChar} - - {" Done"} - - ) : agent.status === "error" ? ( + {agent.status === "error" ? ( <> - {" "} + {rowIndent} {continueChar} - {" "} + {statusIndent} @@ -150,10 +145,15 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => { ) : ( <> - {" "} + {rowIndent} {continueChar} - {" Running in the background"} + + {statusIndent} + {agent.status === "completed" && !agent.isBackground + ? "Done" + : "Running in the background"} + )} diff --git a/src/cli/helpers/subagentAggregation.ts b/src/cli/helpers/subagentAggregation.ts index 8a98e79..6b3c132 100644 --- a/src/cli/helpers/subagentAggregation.ts +++ b/src/cli/helpers/subagentAggregation.ts @@ -5,7 +5,11 @@ import type { StaticSubagent } from "../components/SubagentGroupStatic.js"; import type { Line } from "./accumulator.js"; -import { getSubagentByToolCallId, getSubagents } from "./subagentState.js"; +import { + getSubagentByToolCallId, + getSubagents, + getSubagentToolCount, +} from "./subagentState.js"; import { isTaskTool } from "./toolNameMapping.js"; /** @@ -133,7 +137,7 @@ export function createSubagentGroupItem( status: subagent.isBackground ? "running" : (subagent.status as "completed" | "error"), - toolCount: subagent.toolCalls.length, + toolCount: getSubagentToolCount(subagent), totalTokens: subagent.totalTokens, agentURL: subagent.agentURL, error: subagent.error, diff --git a/src/cli/helpers/subagentDisplay.ts b/src/cli/helpers/subagentDisplay.ts index 13e419b..06a6fd5 100644 --- a/src/cli/helpers/subagentDisplay.ts +++ b/src/cli/helpers/subagentDisplay.ts @@ -12,22 +12,15 @@ import { formatCompact } from "./format"; * * @param toolCount - Number of tool calls * @param totalTokens - Total tokens used (0 or undefined means no data available) - * @param isRunning - If true, shows "—" for tokens (since usage is only available at end) */ -export function formatStats( - toolCount: number, - totalTokens: number, - isRunning = false, -): string { +export function formatStats(toolCount: number, totalTokens: number): string { const toolStr = `${toolCount} tool use${toolCount !== 1 ? "s" : ""}`; - // Only show token count if we have actual data (not running and totalTokens > 0) - const hasTokenData = !isRunning && totalTokens > 0; - if (!hasTokenData) { - return toolStr; + if (totalTokens > 0) { + return `${toolStr} · ${formatCompact(totalTokens)} tokens`; } - return `${toolStr} · ${formatCompact(totalTokens)} tokens`; + return toolStr; } /** diff --git a/src/cli/helpers/subagentState.ts b/src/cli/helpers/subagentState.ts index f877726..6922c17 100644 --- a/src/cli/helpers/subagentState.ts +++ b/src/cli/helpers/subagentState.ts @@ -23,6 +23,8 @@ export interface SubagentState { status: "pending" | "running" | "completed" | "error"; agentURL: string | null; toolCalls: ToolCall[]; + // Monotonic counter to avoid transient regressions in rendered tool usage. + maxToolCallsSeen: number; totalTokens: number; durationMs: number; error?: string; @@ -121,6 +123,7 @@ export function registerSubagent( status: "pending", agentURL: null, toolCalls: [], + maxToolCallsSeen: 0, totalTokens: 0, durationMs: 0, startTime: Date.now(), @@ -148,8 +151,22 @@ export function updateSubagent( updates.status = "running"; } + const nextToolCalls = updates.toolCalls ?? agent.toolCalls; + const nextMax = Math.max( + agent.maxToolCallsSeen, + nextToolCalls.length, + updates.maxToolCallsSeen ?? 0, + ); + + // Skip no-op updates to avoid unnecessary re-renders + const keys = Object.keys(updates) as (keyof typeof updates)[]; + const isNoop = + keys.every((k) => agent[k] === updates[k]) && + nextMax === agent.maxToolCallsSeen; + if (isNoop) return; + // Create a new object to ensure React.memo detects the change - const updatedAgent = { ...agent, ...updates }; + const updatedAgent = { ...agent, ...updates, maxToolCallsSeen: nextMax }; store.agents.set(id, updatedAgent); notifyListeners(); } @@ -176,6 +193,10 @@ export function addToolCall( ...agent.toolCalls, { id: toolCallId, name: toolName, args: toolArgs }, ], + maxToolCallsSeen: Math.max( + agent.maxToolCallsSeen, + agent.toolCalls.length + 1, + ), }; store.agents.set(subagentId, updatedAgent); notifyListeners(); @@ -198,11 +219,18 @@ export function completeSubagent( error: result.error, durationMs: Date.now() - agent.startTime, totalTokens: result.totalTokens ?? agent.totalTokens, + maxToolCallsSeen: Math.max(agent.maxToolCallsSeen, agent.toolCalls.length), } as SubagentState; store.agents.set(id, updatedAgent); notifyListeners(); } +export function getSubagentToolCount( + agent: Pick, +): number { + return Math.max(agent.toolCalls.length, agent.maxToolCallsSeen); +} + /** * Toggle expanded/collapsed state */ diff --git a/src/tests/cli/subagent-tool-count.test.ts b/src/tests/cli/subagent-tool-count.test.ts new file mode 100644 index 0000000..db88805 --- /dev/null +++ b/src/tests/cli/subagent-tool-count.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { Line } from "../../cli/helpers/accumulator"; +import { + collectFinishedTaskToolCalls, + createSubagentGroupItem, +} from "../../cli/helpers/subagentAggregation"; +import { + addToolCall, + clearAllSubagents, + completeSubagent, + getSubagentByToolCallId, + getSubagentToolCount, + registerSubagent, + updateSubagent, +} from "../../cli/helpers/subagentState"; + +describe("subagent tool count stability", () => { + beforeEach(() => { + clearAllSubagents(); + }); + + test("tool count remains monotonic even if toolCalls array is overwritten with fewer entries", () => { + registerSubagent("sub-1", "explore", "Find symbols", "tc-task", false); + addToolCall("sub-1", "tc-read", "Read", "{}"); + addToolCall("sub-1", "tc-grep", "Grep", "{}"); + + const before = getSubagentByToolCallId("tc-task"); + if (!before) { + throw new Error("Expected subagent for tc-task"); + } + expect(getSubagentToolCount(before)).toBe(2); + + // Simulate a stale overwrite (should not reduce displayed count). + updateSubagent("sub-1", { + toolCalls: before.toolCalls.slice(0, 1), + }); + + const after = getSubagentByToolCallId("tc-task"); + if (!after) { + throw new Error("Expected updated subagent for tc-task"); + } + expect(after.toolCalls.length).toBe(1); + expect(getSubagentToolCount(after)).toBe(2); + + completeSubagent("sub-1", { success: true }); + const completed = getSubagentByToolCallId("tc-task"); + if (!completed) { + throw new Error("Expected completed subagent for tc-task"); + } + expect(getSubagentToolCount(completed)).toBe(2); + }); + + test("static subagent grouping uses monotonic tool count", () => { + registerSubagent("sub-1", "explore", "Find symbols", "tc-task", false); + addToolCall("sub-1", "tc-read", "Read", "{}"); + addToolCall("sub-1", "tc-grep", "Grep", "{}"); + completeSubagent("sub-1", { success: true, totalTokens: 42 }); + + const subagent = getSubagentByToolCallId("tc-task"); + if (!subagent) { + throw new Error("Expected subagent for tc-task before grouping"); + } + + // Simulate stale reduction right before grouping. + updateSubagent("sub-1", { + toolCalls: subagent.toolCalls.slice(0, 1), + }); + + const order = ["line-task"]; + const byId = new Map([ + [ + "line-task", + { + kind: "tool_call", + id: "line-task", + name: "Task", + phase: "finished", + toolCallId: "tc-task", + resultOk: true, + }, + ], + ]); + + const finished = collectFinishedTaskToolCalls( + order, + byId, + new Set(), + false, + ); + expect(finished.length).toBe(1); + + const group = createSubagentGroupItem(finished); + expect(group.agents.length).toBe(1); + expect(group.agents[0]?.toolCount).toBe(2); + }); +}); diff --git a/src/tests/tools/task-background-helper.test.ts b/src/tests/tools/task-background-helper.test.ts index c15e73c..857b3cd 100644 --- a/src/tests/tools/task-background-helper.test.ts +++ b/src/tests/tools/task-background-helper.test.ts @@ -46,6 +46,7 @@ describe("spawnBackgroundSubagentTask", () => { { id: "tc-1", name: "Read", args: "{}" }, { id: "tc-2", name: "Edit", args: "{}" }, ], + maxToolCallsSeen: 2, totalTokens: 0, durationMs: 0, startTime: Date.now(), diff --git a/src/tools/impl/Task.ts b/src/tools/impl/Task.ts index b56ade2..d7756eb 100644 --- a/src/tools/impl/Task.ts +++ b/src/tools/impl/Task.ts @@ -16,6 +16,7 @@ import { completeSubagent, generateSubagentId, getSnapshot as getSubagentSnapshot, + getSubagentToolCount, registerSubagent, } from "../../cli/helpers/subagentState.js"; import { formatTaskNotification } from "../../cli/helpers/taskNotifications.js"; @@ -293,9 +294,9 @@ export function spawnBackgroundSubagentTask( if (!silentCompletion) { const subagentSnapshot = getSubagentSnapshotFn(); - const toolUses = subagentSnapshot.agents.find( + const subagentEntry = subagentSnapshot.agents.find( (agent) => agent.id === subagentId, - )?.toolCalls.length; + ); const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); const fullResult = result.success @@ -317,7 +318,10 @@ export function spawnBackgroundSubagentTask( outputFile, usage: { totalTokens: result.totalTokens, - toolUses, + toolUses: + subagentEntry === undefined + ? undefined + : getSubagentToolCount(subagentEntry), durationMs, }, }); @@ -361,9 +365,9 @@ export function spawnBackgroundSubagentTask( if (!silentCompletion) { const subagentSnapshot = getSubagentSnapshotFn(); - const toolUses = subagentSnapshot.agents.find( + const subagentEntry = subagentSnapshot.agents.find( (agent) => agent.id === subagentId, - )?.toolCalls.length; + ); const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime()); const notificationXml = formatTaskNotificationFn({ taskId, @@ -372,7 +376,10 @@ export function spawnBackgroundSubagentTask( result: errorMessage, outputFile, usage: { - toolUses, + toolUses: + subagentEntry === undefined + ? undefined + : getSubagentToolCount(subagentEntry), durationMs, }, });