fix(web-shell): count node-face agents from the same sources the rows render

R8-2: agentCount read only transcript subTools, while renderExecution also
renders live child tasks from the task index, so a live child with no
transcript entry rendered a row the tally never counted. Count the same
deduped union the rows come from.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
jinjing.zzjandQwen-Coder committed 2026-09-13 14:23:48 +08:00
1 parent 5c4f1de9e8
commit 5f70a13866
2 files changed
+84 -9

No files matched your search

@@ -586,10 +586,14 @@ describe('PlanExecutionView', () => {
// the dependency chips, and the agent count and elapsed carry the "is
// this alive" signal onto the face.
expect(buildNode?.textContent).toContain('Build');
expect(buildNode?.textContent).toContain('1 agent');
// Root agent plus the live child task below (parentAgentId set, no
// transcript entry): the node renders two agent rows, so the face tally
// must read 2 — counting only transcript subTools is the R8-2 divergence.
expect(buildNode?.textContent).toContain('2 agents');
expect(buildNode?.textContent).toContain('1m 5s');
// The singular branch above and the plural branch here are separate
// formatter paths; `1 agents` would ship green with only one of them.
// The plural branch is pinned above; `1 agents` would ship green without
// the negative assertion here. The singular branch is pinned in
// 'the node-face agent tally matches the rows it renders'.
const researchNode = container
.querySelector('[data-plan-node-id="research"]')
?.closest('article');
@@ -1839,6 +1843,62 @@ describe('PlanExecutionView', () => {
container.remove();
});
it('the node-face agent tally matches the rows it renders', () => {
// R8-2 witness: a live child task whose toolUseId matches no subTools
// callId renders a row (via toolForNestedTask) while the transcript-only
// tally never saw it — the node read "1 agent" beside two agent rows.
const rootTool: ACPToolCall = {
...agentTool('build'),
subTools: [
{
callId: 'call-nested',
toolName: 'Agent',
title: 'Nested agent',
status: 'in_progress',
},
],
};
const rootTask = task('running');
const liveChild = task('running', {
id: 'agent-live-child',
label: 'Live child',
toolUseId: 'call-live-child',
parentAgentId: rootTask.id,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<PlanExecutionView
todos={todos}
tools={[rootTool, agentTool('research')]}
tasks={[rootTask, liveChild]}
/>
</I18nProvider>,
);
});
const buildNode = container
.querySelector('[data-plan-node-id="build"]')
?.closest('article');
// Three rows render: root, the transcript nested agent, and the live
// child — and the tally counts the same three.
expect(buildNode?.textContent).toContain('3 agents');
expect(buildNode?.textContent).toContain('Nested agent');
expect(buildNode?.textContent).toContain('Live child');
// The lone root on the research node keeps the singular path pinned.
const researchNode = container
.querySelector('[data-plan-node-id="research"]')
?.closest('article');
expect(researchNode?.textContent).toContain('1 agent');
expect(researchNode?.textContent).not.toContain('1 agents');
act(() => root.unmount());
container.remove();
});
it('renders no agent tally for a non-agent tool tagged to the step', () => {
// Declaring todo_id does not make a tool an agent: the tally hides
// rather than printing "0 agents" beside the tool's row.
@@ -1293,14 +1293,29 @@ export function PlanExecutionView({
// Agents, not bare executions: a nested subagent counts too,
// matching the rows this node renders and the inspector's
// Subagents list for the same step. The runtime above stays
// on roots, so nested time is not summed twice.
const agentCount = executions.reduce(
(count, tool) =>
// on roots, so nested time is not summed twice. Count from
// the same two sources renderExecution draws the rows from —
// live child tasks from the task index plus transcript
// subTools, deduped by toolUseId exactly as the rows are —
// otherwise a live child task with no transcript entry
// renders a row the count never sees.
const agentCount = executions.reduce((count, tool) => {
const liveNested = nestedTasksFromIndex(tool, taskIndex);
const liveCallIds = new Set(
liveNested.flatMap(({ task }) =>
task.toolUseId ? [task.toolUseId] : [],
),
);
const transcriptOnly = nestedAgentToolsForTool(tool).filter(
({ tool: nested }) => !liveCallIds.has(nested.callId),
);
return (
count +
(isSubAgentToolCall(tool) ? 1 : 0) +
nestedAgentToolsForTool(tool).length,
0,
);
liveNested.length +
transcriptOnly.length
);
}, 0);
// blockedBy is model-authored and can repeat an id — or name
// the todo itself. Dedup and drop self-references like the
// topology builder; ghost ids stay, because above the edge