fix: Stabilize subagent lifecycle and polish subagent live display [LET-7764] (#1391)
This commit is contained in:
@@ -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
|
// Set up abort handler to kill the child process
|
||||||
let wasAborted = false;
|
let wasAborted = false;
|
||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
@@ -708,6 +714,14 @@ async function executeSubagent(
|
|||||||
crlfDelay: Number.POSITIVE_INFINITY,
|
crlfDelay: Number.POSITIVE_INFINITY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let rlClosed = false;
|
||||||
|
const rlClosedPromise = new Promise<void>((resolve) => {
|
||||||
|
rl.once("close", () => {
|
||||||
|
rlClosed = true;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
rl.on("line", (line: string) => {
|
rl.on("line", (line: string) => {
|
||||||
stdoutChunks.push(Buffer.from(`${line}\n`));
|
stdoutChunks.push(Buffer.from(`${line}\n`));
|
||||||
processStreamEvent(line, state, subagentId);
|
processStreamEvent(line, state, subagentId);
|
||||||
@@ -723,6 +737,13 @@ async function executeSubagent(
|
|||||||
proc.on("error", () => resolve(null));
|
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
|
// Clean up abort listener
|
||||||
signal?.removeEventListener("abort", abortHandler);
|
signal?.removeEventListener("abort", abortHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "../helpers/subagentDisplay.js";
|
} from "../helpers/subagentDisplay.js";
|
||||||
import {
|
import {
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
|
getSubagentToolCount,
|
||||||
type SubagentState,
|
type SubagentState,
|
||||||
subscribe,
|
subscribe,
|
||||||
toggleExpanded,
|
toggleExpanded,
|
||||||
@@ -71,20 +72,22 @@ interface AgentRowProps {
|
|||||||
const AgentRow = memo(
|
const AgentRow = memo(
|
||||||
({ agent, isLast, expanded, condensed = false }: AgentRowProps) => {
|
({ agent, isLast, expanded, condensed = false }: AgentRowProps) => {
|
||||||
const { treeChar, continueChar } = getTreeChars(isLast);
|
const { treeChar, continueChar } = getTreeChars(isLast);
|
||||||
|
const rowIndent = " ";
|
||||||
|
const statusIndent = " ";
|
||||||
|
const expandedToolIndent = " ";
|
||||||
const columns = useTerminalWidth();
|
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 contentWidth = Math.max(0, columns - gutterWidth);
|
||||||
|
|
||||||
const isRunning = agent.status === "pending" || agent.status === "running";
|
const isRunning = agent.status === "pending" || agent.status === "running";
|
||||||
|
const toolCount = getSubagentToolCount(agent);
|
||||||
const shouldDim = isRunning && !agent.isBackground;
|
const shouldDim = isRunning && !agent.isBackground;
|
||||||
const showStats = !(agent.isBackground && isRunning);
|
const showStats =
|
||||||
|
!(agent.isBackground && isRunning) && !(isRunning && toolCount === 0);
|
||||||
const hideBackgroundStatusLine =
|
const hideBackgroundStatusLine =
|
||||||
agent.isBackground && isRunning && !agent.agentURL;
|
agent.isBackground && isRunning && !agent.agentURL;
|
||||||
const stats = formatStats(
|
const stats = formatStats(toolCount, agent.totalTokens);
|
||||||
agent.toolCalls.length,
|
|
||||||
agent.totalTokens,
|
|
||||||
isRunning,
|
|
||||||
);
|
|
||||||
const modelDisplay = getSubagentModelDisplay(agent.model);
|
const modelDisplay = getSubagentModelDisplay(agent.model);
|
||||||
const lastTool = agent.toolCalls[agent.toolCalls.length - 1];
|
const lastTool = agent.toolCalls[agent.toolCalls.length - 1];
|
||||||
|
|
||||||
@@ -98,9 +101,9 @@ const AgentRow = memo(
|
|||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Main row: tree char + description + type + model (no stats) */}
|
{/* Main row: tree char + description + type + model (no stats) */}
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text>
|
<Text wrap="truncate-end">
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{treeChar}{" "}
|
{treeChar}{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text bold={!shouldDim} dimColor={shouldDim}>
|
<Text bold={!shouldDim} dimColor={shouldDim}>
|
||||||
@@ -131,19 +134,37 @@ const AgentRow = memo(
|
|||||||
{/* Simple status line */}
|
{/* Simple status line */}
|
||||||
{!hideBackgroundStatusLine && (
|
{!hideBackgroundStatusLine && (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={colors.subagent.treeChar}>
|
{!agent.agentURL &&
|
||||||
{" "}
|
!lastTool &&
|
||||||
{continueChar}
|
!isComplete &&
|
||||||
</Text>
|
agent.status !== "error" &&
|
||||||
<Text dimColor>{" "}</Text>
|
!agent.isBackground ? (
|
||||||
{agent.status === "error" ? (
|
<>
|
||||||
<Text color={colors.subagent.error}>Error</Text>
|
<Text color={colors.subagent.treeChar}>
|
||||||
) : isComplete ? (
|
{rowIndent}
|
||||||
<Text dimColor>Done</Text>
|
{continueChar} ⎿{" "}
|
||||||
) : agent.isBackground ? (
|
</Text>
|
||||||
<Text dimColor>Running in the background</Text>
|
<Text dimColor>Launching...</Text>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text dimColor>Running...</Text>
|
<>
|
||||||
|
<Text color={colors.subagent.treeChar}>
|
||||||
|
{rowIndent}
|
||||||
|
{continueChar}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>{statusIndent}</Text>
|
||||||
|
{agent.status === "error" ? (
|
||||||
|
<Text color={colors.subagent.error}>Error</Text>
|
||||||
|
) : isComplete ? (
|
||||||
|
<Text dimColor>Done</Text>
|
||||||
|
) : agent.isBackground ? (
|
||||||
|
<Text dimColor>Running in the background</Text>
|
||||||
|
) : lastTool ? (
|
||||||
|
<Text dimColor>Running...</Text>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>Thinking</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -156,9 +177,9 @@ const AgentRow = memo(
|
|||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Main row: tree char + description + type + model + stats */}
|
{/* Main row: tree char + description + type + model + stats */}
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text>
|
<Text wrap="truncate-end">
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{treeChar}{" "}
|
{treeChar}{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text bold={!shouldDim} dimColor={shouldDim}>
|
<Text bold={!shouldDim} dimColor={shouldDim}>
|
||||||
@@ -195,7 +216,7 @@ const AgentRow = memo(
|
|||||||
{agent.agentURL && (
|
{agent.agentURL && (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar} ⎿{" "}
|
{continueChar} ⎿{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{"Subagent: "}</Text>
|
<Text dimColor>{"Subagent: "}</Text>
|
||||||
@@ -210,11 +231,11 @@ const AgentRow = memo(
|
|||||||
return (
|
return (
|
||||||
<Box key={tc.id} flexDirection="row">
|
<Box key={tc.id} flexDirection="row">
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar}
|
{continueChar}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
{" "}
|
{expandedToolIndent}
|
||||||
{tc.name}({formattedArgs})
|
{tc.name}({formattedArgs})
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -224,23 +245,15 @@ const AgentRow = memo(
|
|||||||
{/* Status line */}
|
{/* Status line */}
|
||||||
{!hideBackgroundStatusLine && (
|
{!hideBackgroundStatusLine && (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
{agent.status === "completed" ? (
|
{agent.status === "error" ? (
|
||||||
<>
|
|
||||||
<Text color={colors.subagent.treeChar}>
|
|
||||||
{" "}
|
|
||||||
{continueChar}
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>{" Done"}</Text>
|
|
||||||
</>
|
|
||||||
) : agent.status === "error" ? (
|
|
||||||
<>
|
<>
|
||||||
<Box width={gutterWidth} flexShrink={0}>
|
<Box width={gutterWidth} flexShrink={0}>
|
||||||
<Text>
|
<Text>
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar}
|
{continueChar}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{" "}</Text>
|
<Text dimColor>{statusIndent}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1} width={contentWidth}>
|
<Box flexGrow={1} width={contentWidth}>
|
||||||
@@ -249,32 +262,33 @@ const AgentRow = memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : agent.isBackground ? (
|
) : !agent.agentURL &&
|
||||||
<Text>
|
!lastTool &&
|
||||||
<Text color={colors.subagent.treeChar}>
|
agent.status !== "completed" &&
|
||||||
{" "}
|
!agent.isBackground ? (
|
||||||
{continueChar}
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>{" Running in the background"}</Text>
|
|
||||||
</Text>
|
|
||||||
) : lastTool ? (
|
|
||||||
<>
|
<>
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar}
|
{continueChar} ⎿{" "}
|
||||||
</Text>
|
|
||||||
<Text dimColor>
|
|
||||||
{" "}
|
|
||||||
{lastTool.name}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text dimColor>Launching...</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar}
|
{continueChar}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{" Starting..."}</Text>
|
<Text dimColor>
|
||||||
|
{statusIndent}
|
||||||
|
{agent.status === "completed"
|
||||||
|
? "Done"
|
||||||
|
: agent.isBackground
|
||||||
|
? "Running in the background"
|
||||||
|
: lastTool
|
||||||
|
? lastTool.name
|
||||||
|
: "Thinking"}
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ interface AgentRowProps {
|
|||||||
|
|
||||||
const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
||||||
const { treeChar, continueChar } = getTreeChars(isLast);
|
const { treeChar, continueChar } = getTreeChars(isLast);
|
||||||
|
const rowIndent = " ";
|
||||||
|
const statusIndent = " ";
|
||||||
const columns = useTerminalWidth();
|
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 contentWidth = Math.max(0, columns - gutterWidth);
|
||||||
|
|
||||||
const isRunning = agent.status === "running";
|
const isRunning = agent.status === "running";
|
||||||
@@ -65,16 +68,16 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
|||||||
const showStats = !(agent.isBackground && isRunning);
|
const showStats = !(agent.isBackground && isRunning);
|
||||||
const hideBackgroundStatusLine =
|
const hideBackgroundStatusLine =
|
||||||
agent.isBackground && isRunning && !agent.agentURL;
|
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);
|
const modelDisplay = getSubagentModelDisplay(agent.model);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Main row: tree char + description + type + model + stats */}
|
{/* Main row: tree char + description + type + model + stats */}
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text>
|
<Text wrap="truncate-end">
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{treeChar}{" "}
|
{treeChar}{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text bold={!shouldDim} dimColor={shouldDim}>
|
<Text bold={!shouldDim} dimColor={shouldDim}>
|
||||||
@@ -111,7 +114,7 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
|||||||
{agent.agentURL && (
|
{agent.agentURL && (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar} ⎿{" "}
|
{continueChar} ⎿{" "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{"Subagent: "}</Text>
|
<Text dimColor>{"Subagent: "}</Text>
|
||||||
@@ -122,23 +125,15 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
|||||||
{/* Status line */}
|
{/* Status line */}
|
||||||
{!hideBackgroundStatusLine && (
|
{!hideBackgroundStatusLine && (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
{agent.status === "completed" && !agent.isBackground ? (
|
{agent.status === "error" ? (
|
||||||
<>
|
|
||||||
<Text color={colors.subagent.treeChar}>
|
|
||||||
{" "}
|
|
||||||
{continueChar}
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>{" Done"}</Text>
|
|
||||||
</>
|
|
||||||
) : agent.status === "error" ? (
|
|
||||||
<>
|
<>
|
||||||
<Box width={gutterWidth} flexShrink={0}>
|
<Box width={gutterWidth} flexShrink={0}>
|
||||||
<Text>
|
<Text>
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar}
|
{continueChar}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{" "}</Text>
|
<Text dimColor>{statusIndent}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1} width={contentWidth}>
|
<Box flexGrow={1} width={contentWidth}>
|
||||||
@@ -150,10 +145,15 @@ const AgentRow = memo(({ agent, isLast }: AgentRowProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.subagent.treeChar}>
|
<Text color={colors.subagent.treeChar}>
|
||||||
{" "}
|
{rowIndent}
|
||||||
{continueChar}
|
{continueChar}
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{" Running in the background"}</Text>
|
<Text dimColor>
|
||||||
|
{statusIndent}
|
||||||
|
{agent.status === "completed" && !agent.isBackground
|
||||||
|
? "Done"
|
||||||
|
: "Running in the background"}
|
||||||
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
|
|
||||||
import type { StaticSubagent } from "../components/SubagentGroupStatic.js";
|
import type { StaticSubagent } from "../components/SubagentGroupStatic.js";
|
||||||
import type { Line } from "./accumulator.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";
|
import { isTaskTool } from "./toolNameMapping.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,7 +137,7 @@ export function createSubagentGroupItem(
|
|||||||
status: subagent.isBackground
|
status: subagent.isBackground
|
||||||
? "running"
|
? "running"
|
||||||
: (subagent.status as "completed" | "error"),
|
: (subagent.status as "completed" | "error"),
|
||||||
toolCount: subagent.toolCalls.length,
|
toolCount: getSubagentToolCount(subagent),
|
||||||
totalTokens: subagent.totalTokens,
|
totalTokens: subagent.totalTokens,
|
||||||
agentURL: subagent.agentURL,
|
agentURL: subagent.agentURL,
|
||||||
error: subagent.error,
|
error: subagent.error,
|
||||||
|
|||||||
@@ -12,22 +12,15 @@ import { formatCompact } from "./format";
|
|||||||
*
|
*
|
||||||
* @param toolCount - Number of tool calls
|
* @param toolCount - Number of tool calls
|
||||||
* @param totalTokens - Total tokens used (0 or undefined means no data available)
|
* @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(
|
export function formatStats(toolCount: number, totalTokens: number): string {
|
||||||
toolCount: number,
|
|
||||||
totalTokens: number,
|
|
||||||
isRunning = false,
|
|
||||||
): string {
|
|
||||||
const toolStr = `${toolCount} tool use${toolCount !== 1 ? "s" : ""}`;
|
const toolStr = `${toolCount} tool use${toolCount !== 1 ? "s" : ""}`;
|
||||||
|
|
||||||
// Only show token count if we have actual data (not running and totalTokens > 0)
|
if (totalTokens > 0) {
|
||||||
const hasTokenData = !isRunning && totalTokens > 0;
|
return `${toolStr} · ${formatCompact(totalTokens)} tokens`;
|
||||||
if (!hasTokenData) {
|
|
||||||
return toolStr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${toolStr} · ${formatCompact(totalTokens)} tokens`;
|
return toolStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface SubagentState {
|
|||||||
status: "pending" | "running" | "completed" | "error";
|
status: "pending" | "running" | "completed" | "error";
|
||||||
agentURL: string | null;
|
agentURL: string | null;
|
||||||
toolCalls: ToolCall[];
|
toolCalls: ToolCall[];
|
||||||
|
// Monotonic counter to avoid transient regressions in rendered tool usage.
|
||||||
|
maxToolCallsSeen: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -121,6 +123,7 @@ export function registerSubagent(
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
agentURL: null,
|
agentURL: null,
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
|
maxToolCallsSeen: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
@@ -148,8 +151,22 @@ export function updateSubagent(
|
|||||||
updates.status = "running";
|
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
|
// 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);
|
store.agents.set(id, updatedAgent);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -176,6 +193,10 @@ export function addToolCall(
|
|||||||
...agent.toolCalls,
|
...agent.toolCalls,
|
||||||
{ id: toolCallId, name: toolName, args: toolArgs },
|
{ id: toolCallId, name: toolName, args: toolArgs },
|
||||||
],
|
],
|
||||||
|
maxToolCallsSeen: Math.max(
|
||||||
|
agent.maxToolCallsSeen,
|
||||||
|
agent.toolCalls.length + 1,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
store.agents.set(subagentId, updatedAgent);
|
store.agents.set(subagentId, updatedAgent);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -198,11 +219,18 @@ export function completeSubagent(
|
|||||||
error: result.error,
|
error: result.error,
|
||||||
durationMs: Date.now() - agent.startTime,
|
durationMs: Date.now() - agent.startTime,
|
||||||
totalTokens: result.totalTokens ?? agent.totalTokens,
|
totalTokens: result.totalTokens ?? agent.totalTokens,
|
||||||
|
maxToolCallsSeen: Math.max(agent.maxToolCallsSeen, agent.toolCalls.length),
|
||||||
} as SubagentState;
|
} as SubagentState;
|
||||||
store.agents.set(id, updatedAgent);
|
store.agents.set(id, updatedAgent);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSubagentToolCount(
|
||||||
|
agent: Pick<SubagentState, "toolCalls" | "maxToolCallsSeen">,
|
||||||
|
): number {
|
||||||
|
return Math.max(agent.toolCalls.length, agent.maxToolCallsSeen);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle expanded/collapsed state
|
* Toggle expanded/collapsed state
|
||||||
*/
|
*/
|
||||||
|
|||||||
96
src/tests/cli/subagent-tool-count.test.ts
Normal file
96
src/tests/cli/subagent-tool-count.test.ts
Normal file
@@ -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<string, Line>([
|
||||||
|
[
|
||||||
|
"line-task",
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "line-task",
|
||||||
|
name: "Task",
|
||||||
|
phase: "finished",
|
||||||
|
toolCallId: "tc-task",
|
||||||
|
resultOk: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const finished = collectFinishedTaskToolCalls(
|
||||||
|
order,
|
||||||
|
byId,
|
||||||
|
new Set<string>(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(finished.length).toBe(1);
|
||||||
|
|
||||||
|
const group = createSubagentGroupItem(finished);
|
||||||
|
expect(group.agents.length).toBe(1);
|
||||||
|
expect(group.agents[0]?.toolCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@ describe("spawnBackgroundSubagentTask", () => {
|
|||||||
{ id: "tc-1", name: "Read", args: "{}" },
|
{ id: "tc-1", name: "Read", args: "{}" },
|
||||||
{ id: "tc-2", name: "Edit", args: "{}" },
|
{ id: "tc-2", name: "Edit", args: "{}" },
|
||||||
],
|
],
|
||||||
|
maxToolCallsSeen: 2,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
completeSubagent,
|
completeSubagent,
|
||||||
generateSubagentId,
|
generateSubagentId,
|
||||||
getSnapshot as getSubagentSnapshot,
|
getSnapshot as getSubagentSnapshot,
|
||||||
|
getSubagentToolCount,
|
||||||
registerSubagent,
|
registerSubagent,
|
||||||
} from "../../cli/helpers/subagentState.js";
|
} from "../../cli/helpers/subagentState.js";
|
||||||
import { formatTaskNotification } from "../../cli/helpers/taskNotifications.js";
|
import { formatTaskNotification } from "../../cli/helpers/taskNotifications.js";
|
||||||
@@ -293,9 +294,9 @@ export function spawnBackgroundSubagentTask(
|
|||||||
|
|
||||||
if (!silentCompletion) {
|
if (!silentCompletion) {
|
||||||
const subagentSnapshot = getSubagentSnapshotFn();
|
const subagentSnapshot = getSubagentSnapshotFn();
|
||||||
const toolUses = subagentSnapshot.agents.find(
|
const subagentEntry = subagentSnapshot.agents.find(
|
||||||
(agent) => agent.id === subagentId,
|
(agent) => agent.id === subagentId,
|
||||||
)?.toolCalls.length;
|
);
|
||||||
const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime());
|
const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime());
|
||||||
|
|
||||||
const fullResult = result.success
|
const fullResult = result.success
|
||||||
@@ -317,7 +318,10 @@ export function spawnBackgroundSubagentTask(
|
|||||||
outputFile,
|
outputFile,
|
||||||
usage: {
|
usage: {
|
||||||
totalTokens: result.totalTokens,
|
totalTokens: result.totalTokens,
|
||||||
toolUses,
|
toolUses:
|
||||||
|
subagentEntry === undefined
|
||||||
|
? undefined
|
||||||
|
: getSubagentToolCount(subagentEntry),
|
||||||
durationMs,
|
durationMs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -361,9 +365,9 @@ export function spawnBackgroundSubagentTask(
|
|||||||
|
|
||||||
if (!silentCompletion) {
|
if (!silentCompletion) {
|
||||||
const subagentSnapshot = getSubagentSnapshotFn();
|
const subagentSnapshot = getSubagentSnapshotFn();
|
||||||
const toolUses = subagentSnapshot.agents.find(
|
const subagentEntry = subagentSnapshot.agents.find(
|
||||||
(agent) => agent.id === subagentId,
|
(agent) => agent.id === subagentId,
|
||||||
)?.toolCalls.length;
|
);
|
||||||
const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime());
|
const durationMs = Math.max(0, Date.now() - bgTask.startTime.getTime());
|
||||||
const notificationXml = formatTaskNotificationFn({
|
const notificationXml = formatTaskNotificationFn({
|
||||||
taskId,
|
taskId,
|
||||||
@@ -372,7 +376,10 @@ export function spawnBackgroundSubagentTask(
|
|||||||
result: errorMessage,
|
result: errorMessage,
|
||||||
outputFile,
|
outputFile,
|
||||||
usage: {
|
usage: {
|
||||||
toolUses,
|
toolUses:
|
||||||
|
subagentEntry === undefined
|
||||||
|
? undefined
|
||||||
|
: getSubagentToolCount(subagentEntry),
|
||||||
durationMs,
|
durationMs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user