fix(ui): display output tokens instead of cumulative API throughput for subagents (#5972)

Multiple UI components displayed executionSummary.totalTokens (cumulative
sum of total_tokens across all API rounds) as the subagent token count.
For a subagent making 87 requests with ~51K prompt each, this inflated
to 4.4M — misleading users into thinking it was context window usage.

Switch all subagent token displays to use outputTokens (what the model
actually generated), aligning with the loading indicator which already
correctly shows output tokens only.

Closes #5683
This commit is contained in:
jinye 2026-06-29 15:27:41 +08:00 committed by GitHub
parent 68348e236a
commit 9601d90b78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 108 additions and 81 deletions

View file

@ -82,9 +82,9 @@ describe('ArenaSessionCard', () => {
expect(output).toContain('Approach Summary:');
expect(output).toContain('Refactored with JWT strategy pattern.');
expect(output).toContain('Token Efficiency:');
expect(output).toContain('45,000 tokens');
expect(output).toContain('45,000 tokens · runtime 12.0s');
expect(output).not.toContain('45,000 tokens · runtime 12.0s · 12 tools');
expect(output).toContain('15,000 tokens');
expect(output).toContain('15,000 tokens · runtime 12.0s');
expect(output).not.toContain('15,000 tokens · runtime 12.0s · 12 tools');
expect(output).not.toContain('Quick Preview:');
expect(output).not.toContain('[View Detailed Diff]');
expect(output).not.toContain('[Select Winner →]');

View file

@ -297,7 +297,7 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
:{' '}
</Text>
<Text color={theme.text.primary}>
{agent.totalTokens.toLocaleString()} tokens · runtime{' '}
{agent.outputTokens.toLocaleString()} tokens · runtime{' '}
{formatDuration(agent.durationMs)}
</Text>
</Box>

View file

@ -153,7 +153,7 @@ export function ArenaSelectDialog({
const label = agent.model.modelId;
const statusInfo = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.stats.durationMs);
const tokens = agent.stats.totalTokens.toLocaleString();
const tokens = agent.stats.outputTokens.toLocaleString();
// Build diff summary from cached result if available
let diffAdditions = 0;
@ -327,7 +327,7 @@ function ArenaAgentPreview({
<Box marginLeft={2}>
<Text color={theme.text.secondary}>Metrics: </Text>
<Text color={theme.text.primary}>
{result.stats.totalTokens.toLocaleString()} tokens ·{' '}
{result.stats.outputTokens.toLocaleString()} tokens ·{' '}
{formatDuration(result.stats.durationMs)} · {result.stats.toolCalls}{' '}
tools
</Text>

View file

@ -220,7 +220,7 @@ export function ArenaStatusDialog({
// Use live stats from AgentInteractive when in-process, otherwise
// fall back to the cached ArenaAgentState.stats (file-polled).
const live = liveStats?.get(agent.agentId);
const totalTokens = live?.totalTokens ?? agent.stats.totalTokens;
const outputTokens = live?.outputTokens ?? agent.stats.outputTokens;
const rounds = live?.rounds ?? agent.stats.rounds;
const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls;
const successfulToolCalls =
@ -246,7 +246,7 @@ export function ArenaStatusDialog({
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(totalTokens.toLocaleString(), colTokens - 1, 'right')}
{pad(outputTokens.toLocaleString(), colTokens - 1, 'right')}
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">

View file

@ -594,10 +594,10 @@ const AgentDetailBody: React.FC<{
const terminal = terminalStatusPresentation(entry.status);
const dimSubtitleParts: string[] = [elapsedFor(entry)];
if (entry.stats?.totalTokens) {
if (entry.stats?.outputTokens) {
dimSubtitleParts.push(
t('{{count}} tokens', {
count: formatTokenCount(entry.stats.totalTokens),
count: formatTokenCount(entry.stats.outputTokens),
}),
);
}

View file

@ -292,13 +292,18 @@ describe('<LiveAgentPanel />', () => {
status: 'completed',
startTime: -12_000,
endTime: 0,
stats: { totalTokens: 2400, toolUses: 5, durationMs: 12_000 },
stats: {
totalTokens: 2400,
outputTokens: 800,
toolUses: 5,
durationMs: 12_000,
},
}),
],
});
const frame = lastFrame() ?? '';
expect(frame).toContain('12s');
expect(frame).toContain('2.4k tokens');
expect(frame).toContain('800 tokens');
});
it('renders paused agents with the paused glyph', () => {

View file

@ -473,8 +473,8 @@ const AgentRow: React.FC<{
? escapeAnsiCtrlCodes(entry.subagentType ?? '')
: '';
const tokenSuffix =
entry.stats?.totalTokens && entry.stats.totalTokens > 0
? ` · ${formatTokenCount(entry.stats.totalTokens)} tokens`
entry.stats?.outputTokens && entry.stats.outputTokens > 0
? ` · ${formatTokenCount(entry.stats.outputTokens)} tokens`
: '';
// Layout (Claude Code's CoordinatorTaskPanel visual + our

View file

@ -219,7 +219,7 @@ describe('<InlineParallelAgentsDisplay />', () => {
failedToolCalls: 0,
successRate: 1,
inputTokens: 0,
outputTokens: 0,
outputTokens: 800,
thoughtTokens: 0,
cachedTokens: 0,
totalTokens: 2400,
@ -234,8 +234,8 @@ describe('<InlineParallelAgentsDisplay />', () => {
const { lastFrame } = renderInline({ toolCalls: [toolCall] });
const frame = lastFrame() ?? '';
expect(frame).toContain('12s');
// 2400 tokens → "2.4k" per formatTokenCount.
expect(frame).toContain('2.4k tok');
// outputTokens: 800 → "800" per formatTokenCount (not totalTokens: 2400).
expect(frame).toContain('800 tok');
});
it('ignores non task_execution tool calls in the same group', () => {

View file

@ -246,8 +246,8 @@ export const InlineParallelAgentsDisplay: React.FC<
: undefined,
tokenCount:
result.tokenCount ??
live?.stats?.totalTokens ??
result.executionSummary?.totalTokens,
live?.stats?.outputTokens ??
result.executionSummary?.outputTokens,
};
});
}, [agentEntries, config, now]);

View file

@ -401,8 +401,8 @@ const SubagentScrollbackSummary: React.FC<{
formatDuration(stats.totalDurationMs, { hideTrailingZeros: true }),
);
}
if (stats?.totalTokens && stats.totalTokens > 0) {
parts.push(`${formatTokenCount(stats.totalTokens)} tokens`);
if (stats?.outputTokens && stats.outputTokens > 0) {
parts.push(`${formatTokenCount(stats.outputTokens)} tokens`);
}
// Sanitize every user/LLM-controlled string before it reaches Ink.
// `subagentName` is subagent config (user-authored or model-chosen),

View file

@ -433,6 +433,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -524,6 +525,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -662,6 +664,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -739,6 +742,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -829,6 +833,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -911,6 +916,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -995,6 +1001,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -1021,8 +1028,7 @@ describe('BackgroundAgentResumeService', () => {
expect(registry.get(agentId)?.status).toBe('completed');
});
const provider = subagent.setExternalMessageProvider.mock.calls[0]?.[0] as
| (() => string[])
| undefined;
(() => string[]) | undefined;
expect(provider).toBeDefined();
expect(provider?.()).toEqual(['second message']);
});
@ -1081,7 +1087,11 @@ describe('BackgroundAgentResumeService', () => {
setExternalMessageWaiter: vi.fn(),
setExternalMessageWaitPredicate: vi.fn(),
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({ totalTokens: 0, totalDurationMs: 0 }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
getFinalText: () => 'done',
};
@ -1193,7 +1203,11 @@ describe('BackgroundAgentResumeService', () => {
getCore: vi.fn(() => {
throw new Error('setup failed');
}),
getExecutionSummary: () => ({ totalTokens: 0, totalDurationMs: 0 }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
getFinalText: () => 'done',
};
@ -1324,6 +1338,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -1363,8 +1378,7 @@ describe('BackgroundAgentResumeService', () => {
const executeCall = execute.mock.calls[0];
expect(executeCall).toBeDefined();
const contextArg = executeCall?.[0] as
| { get(key: string): unknown }
| undefined;
{ get(key: string): unknown } | undefined;
expect(contextArg).toBeDefined();
if (!contextArg) {
throw new Error('Expected resume execute context');
@ -1601,6 +1615,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.CANCELLED,
@ -1679,6 +1694,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.CANCELLED,
@ -1765,8 +1781,7 @@ describe('BackgroundAgentResumeService', () => {
const execute = vi.fn(
async (context: { get: (key: string) => unknown }) => {
const override = context.get('initial_messages_override') as
| Array<{ parts?: Array<{ text?: string }> }>
| undefined;
Array<{ parts?: Array<{ text?: string }> }> | undefined;
expect(override).toBeUndefined();
expect(context.get('task_prompt')).toBe('continue work');
},
@ -1777,6 +1792,7 @@ describe('BackgroundAgentResumeService', () => {
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
@ -1872,7 +1888,11 @@ describe('BackgroundAgentResumeService', () => {
execute,
setExternalMessageProvider: vi.fn(),
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({ totalTokens: 0, totalDurationMs: 0 }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
getFinalText: () => 'iterated',
};
@ -2196,7 +2216,11 @@ describe('BackgroundAgentResumeService', () => {
execute: vi.fn(async () => undefined),
setExternalMessageProvider: vi.fn(),
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({ totalTokens: 0, totalDurationMs: 0 }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
getFinalText: () => 'iterated',
};

View file

@ -325,8 +325,7 @@ function recoverTranscript(records: ChatRecord[]): TranscriptRecovery {
'fork' &&
typeof (
launchPromptRecord?.systemPayload as
| NotificationRecordPayload
| undefined
NotificationRecordPayload | undefined
)?.displayText === 'string'
? {
history: structuredClone(
@ -362,6 +361,7 @@ function getCompletionStats(
const summary = subagent.getExecutionSummary();
return {
totalTokens: summary.totalTokens,
outputTokens: summary.outputTokens,
toolUses: liveToolCallCount,
durationMs: summary.totalDurationMs,
};
@ -699,7 +699,7 @@ export class BackgroundAgentResumeService {
// definition would silently auto-deny calls the fresh launch bubbles.
const shouldBubble = Boolean(
target.subagentConfig?.approvalMode === BUBBLE_APPROVAL_MODE &&
this.config.isInteractive(),
this.config.isInteractive(),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bgConfig = Object.create(agentConfig) as any;

View file

@ -162,6 +162,7 @@ export type BackgroundTaskStatus = TaskStatus;
export interface AgentCompletionStats {
totalTokens: number;
outputTokens: number;
toolUses: number;
durationMs: number;
}
@ -859,8 +860,7 @@ export class BackgroundTaskRegistry {
*/
reset(): void {
const firstEntry = this.agents.values().next().value as
| AgentTask
| undefined;
AgentTask | undefined;
if (!firstEntry) return;
for (const entry of this.agents.values()) {
// Defensive: callers (session switch via /resume, /clear) gate on

View file

@ -1277,8 +1277,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
promptConfig = {
renderedSystemPrompt: generationConfig.systemInstruction as
| string
| Content,
string | Content,
initialMessages,
};
toolConfig = {
@ -2225,7 +2224,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
// already requires confirmation — this only flips deny → surface.)
const shouldBubble = Boolean(
subagentConfig.approvalMode === BUBBLE_APPROVAL_MODE &&
this.config.isInteractive(),
this.config.isInteractive(),
);
// Use Object.create so the resolved approval mode override (e.g.
// subagent-level `approvalMode: auto-edit`) is preserved.
@ -2422,6 +2421,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
const summary = bgSubagent.getExecutionSummary();
entry.stats = {
totalTokens: summary.totalTokens,
outputTokens: summary.outputTokens,
toolUses: liveToolCallCount,
durationMs: summary.totalDurationMs,
};
@ -2473,6 +2473,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
const summary = bgSubagent.getExecutionSummary();
return {
totalTokens: summary.totalTokens,
outputTokens: summary.outputTokens,
toolUses: liveToolCallCount,
durationMs: summary.totalDurationMs,
};
@ -2879,6 +2880,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
const summary = subagent.getExecutionSummary();
entry.stats = {
totalTokens: summary.totalTokens,
outputTokens: summary.outputTokens,
toolUses: fgLiveToolCallCount,
durationMs: summary.totalDurationMs,
};

View file

@ -677,15 +677,21 @@ function TaskDetail({
},
];
if (task.kind === 'agent' && task.stats?.totalTokens) {
const agentOutputTokens =
task.kind === 'agent'
? (((task.stats as Record<string, unknown> | undefined)?.[
'outputTokens'
] as number | undefined) ?? task.stats?.totalTokens)
: undefined;
if (agentOutputTokens) {
subtitleParts.push(
t('tasks.detail.tokens', {
count: formatTokenCount(task.stats.totalTokens),
count: formatTokenCount(agentOutputTokens),
}),
);
compactFields.push({
label: t('tasks.detail.tokenCount'),
value: formatTokenCount(task.stats.totalTokens),
value: formatTokenCount(agentOutputTokens),
});
}

View file

@ -392,8 +392,7 @@ function getAgentDisplayInfo(
0;
const stats = taskExec?.['executionSummary'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const elapsed =
stats && typeof stats['totalDurationMs'] === 'number'
? formatDurationMs(stats['totalDurationMs'])
@ -403,17 +402,17 @@ function getAgentDisplayInfo(
(tool.status === 'in_progress' && now ? now : undefined),
);
const totalTokens =
const outputTokens =
taskExec &&
typeof taskExec['tokenCount'] === 'number' &&
taskExec['tokenCount'] > 0
? (taskExec['tokenCount'] as number)
: stats &&
typeof stats['totalTokens'] === 'number' &&
stats['totalTokens'] > 0
? (stats['totalTokens'] as number)
typeof stats['outputTokens'] === 'number' &&
stats['outputTokens'] > 0
? (stats['outputTokens'] as number)
: 0;
const tokens = totalTokens > 0 ? formatTokenCount(totalTokens) : '';
const tokens = outputTokens > 0 ? formatTokenCount(outputTokens) : '';
return {
agentType,

View file

@ -37,8 +37,7 @@ function getAgentStats(agent: ACPToolCall, now: number): string {
const parts: string[] = [];
const taskExec = getTaskExecutionRecord(agent.rawOutput);
const stats = taskExec?.['executionSummary'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const elapsed =
stats && typeof stats['totalDurationMs'] === 'number'
? formatDuration(stats['totalDurationMs'])
@ -53,9 +52,9 @@ function getAgentStats(agent: ACPToolCall, now: number): string {
taskExec['tokenCount'] > 0
? (taskExec['tokenCount'] as number)
: stats &&
typeof stats['totalTokens'] === 'number' &&
stats['totalTokens'] > 0
? (stats['totalTokens'] as number)
typeof stats['outputTokens'] === 'number' &&
stats['outputTokens'] > 0
? (stats['outputTokens'] as number)
: 0;
if (tokens > 0) {
parts.push(formatTokenCount(tokens));

View file

@ -291,7 +291,7 @@ export function SubAgentPanel({
const tokenCount =
taskExec?.tokenCount && taskExec.tokenCount > 0
? taskExec.tokenCount
: taskExec?.executionSummary?.totalTokens;
: taskExec?.executionSummary?.outputTokens;
const tokens = tokenCount ? formatTokenCount(tokenCount) : '';
const resultText = isComplete ? getAgentResultText(tool) : '';

View file

@ -178,8 +178,9 @@ function getTaskExecutionTokenCount(rawOutput: unknown): number | undefined {
if (typeof tokenCount === 'number' && tokenCount > 0) return tokenCount;
const summary = obj['executionSummary'];
if (typeof summary === 'object' && summary !== null) {
const totalTokens = (summary as Record<string, unknown>)['totalTokens'];
if (typeof totalTokens === 'number' && totalTokens > 0) return totalTokens;
const outputTokens = (summary as Record<string, unknown>)['outputTokens'];
if (typeof outputTokens === 'number' && outputTokens > 0)
return outputTokens;
}
return undefined;
}

View file

@ -21,11 +21,11 @@ export const isAgentExecutionRawOutput = (
): value is AgentExecutionRawOutput =>
Boolean(
value &&
typeof value === 'object' &&
'type' in value &&
(value as { type?: unknown }).type === 'task_execution' &&
'taskDescription' in value &&
'status' in value,
typeof value === 'object' &&
'type' in value &&
(value as { type?: unknown }).type === 'task_execution' &&
'taskDescription' in value &&
'status' in value,
);
export const isAgentExecutionToolCall = (
@ -136,7 +136,11 @@ export const AgentToolCall: FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="flex flex-wrap gap-x-4 gap-y-1">
<span>{data.executionSummary.totalToolCalls} tool calls</span>
<span>
{data.executionSummary.totalTokens.toLocaleString()} tokens
{(
data.executionSummary.outputTokens ??
data.executionSummary.totalTokens
).toLocaleString()}{' '}
tokens
</span>
<span>{formatDuration(data.executionSummary.totalDurationMs)}</span>
</div>

View file

@ -36,27 +36,18 @@ export interface ToolCallLocation {
* Tool call status type
*/
export type ToolCallStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'cancelled';
'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
export type AgentExecutionStatus =
| 'running'
| 'completed'
| 'failed'
| 'cancelled';
'running' | 'completed' | 'failed' | 'cancelled';
export type AgentToolCallStatus =
| 'executing'
| 'awaiting_approval'
| 'success'
| 'failed';
'executing' | 'awaiting_approval' | 'success' | 'failed';
export interface AgentExecutionSummary {
totalToolCalls: number;
totalTokens: number;
outputTokens?: number;
totalDurationMs: number;
successfulToolCalls?: number;
failedToolCalls?: number;
@ -132,11 +123,7 @@ export interface GroupedContent {
* Container status type for styling
*/
export type ContainerStatus =
| 'success'
| 'error'
| 'warning'
| 'loading'
| 'default';
'success' | 'error' | 'warning' | 'loading' | 'default';
/**
* Plan entry status type