feat(cli): attribute /stats rows to the originating subagent (#3229)

* feat(cli): attribute /stats rows to the originating subagent

Thread subagent identity through telemetry via an AsyncLocalStorage
context so each API response knows which subagent (or main) emitted
it. Aggregate a per-source breakdown alongside the existing per-model
totals and render one row per (model, source) in /stats and /stats
model. Main-only sessions collapse to the existing single-row display.

Resolves #3215

* fix(cli): reserve `main` subagent name and stabilize /stats React keys

Two latent correctness issues found during self-review of PR #3229:

- A subagent named `main` would silently collide with the `MAIN_SOURCE`
  sentinel and be merged into the main bucket with no attribution. Add
  `main` to the reserved-names list so validation rejects it.
- `flattenModelsBySource` used the normalized display label (with `-001`
  stripped) as the React key, which could collapse distinct models
  `foo` and `foo-001` into duplicate keys. Split `ModelSourceEntry` into
  `{ key, label, metrics }` with `key` built from the raw model name
  (plus `::source` in the split case), and update both `StatsDisplay`
  and `ModelStatsDisplay` to key rows/columns off it.

Also surface invalid-subagent-file parse errors through the debug
logger instead of swallowing them entirely, so users running with
debug logging enabled can tell why a subagent failed to load.

Add a dedicated unit test file for `flattenModelsBySource` covering
the collapse rule, session-wide split, source order, the
`foo`/`foo-001` key-collision regression, and the empty-bySource
fallback. Extend the reserved-name test to include `main`.
This commit is contained in:
tanzhenxin 2026-04-21 11:44:10 +08:00 committed by GitHub
parent 52c7a3d0ed
commit b27cb81bb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 995 additions and 126 deletions

View file

@ -8,7 +8,17 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type {
ModelMetrics,
ModelMetricsCore,
SessionMetrics,
} from '../contexts/SessionContext.js';
import { MAIN_SOURCE } from '@qwen-code/qwen-code-core';
const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({
...core,
bySource: { [MAIN_SOURCE]: core },
});
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@ -73,7 +83,7 @@ describe('<ModelStatsDisplay />', () => {
it('should not display conditional rows if no model has data for them', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
@ -83,7 +93,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
},
}),
},
tools: {
totalCalls: 0,
@ -105,7 +115,7 @@ describe('<ModelStatsDisplay />', () => {
it('should display conditional rows if at least one model has data', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
@ -115,8 +125,8 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 0,
},
},
'gemini-2.5-flash': {
}),
'gemini-2.5-flash': mainOnly({
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
tokens: {
prompt: 5,
@ -126,7 +136,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 3,
},
},
}),
},
tools: {
totalCalls: 0,
@ -148,7 +158,7 @@ describe('<ModelStatsDisplay />', () => {
it('should display stats for multiple models correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
tokens: {
prompt: 100,
@ -158,8 +168,8 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 10,
tool: 5,
},
},
'gemini-2.5-flash': {
}),
'gemini-2.5-flash': mainOnly({
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
tokens: {
prompt: 200,
@ -169,7 +179,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 20,
tool: 10,
},
},
}),
},
tools: {
totalCalls: 0,
@ -190,7 +200,7 @@ describe('<ModelStatsDisplay />', () => {
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: {
totalRequests: 999999999,
totalErrors: 123456789,
@ -204,7 +214,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 111111111,
tool: 222222222,
},
},
}),
},
tools: {
totalCalls: 0,
@ -222,7 +232,7 @@ describe('<ModelStatsDisplay />', () => {
it('should display a single model correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
@ -232,7 +242,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 1,
},
},
}),
},
tools: {
totalCalls: 0,
@ -249,4 +259,70 @@ describe('<ModelStatsDisplay />', () => {
expect(output).not.toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
describe('Subagent source attribution', () => {
const baseTools: SessionMetrics['tools'] = {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
};
const baseFiles: SessionMetrics['files'] = {
totalLinesAdded: 0,
totalLinesRemoved: 0,
};
const makeCore = (reqs: number): ModelMetricsCore => ({
api: { totalRequests: reqs, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
});
it('collapses the column header when only main is a source', () => {
const { lastFrame } = renderWithMockedStats({
models: { 'glm-5': mainOnly(makeCore(1)) },
tools: baseTools,
files: baseFiles,
});
const output = lastFrame();
expect(output).toContain('glm-5');
expect(output).not.toContain('glm-5 (main)');
});
it('renders distinct columns for main and subagent when same model has multiple sources', () => {
const mainCore = makeCore(1);
const echoerCore = makeCore(1);
const { lastFrame } = renderWithMockedStats({
models: {
'glm-5': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
tokens: {
prompt: 20,
candidates: 40,
total: 60,
cached: 0,
thoughts: 0,
tool: 0,
},
bySource: {
[MAIN_SOURCE]: mainCore,
echoer: echoerCore,
},
},
},
tools: baseTools,
files: baseFiles,
});
const output = lastFrame();
expect(output).toContain('glm-5 (main)');
expect(output).toContain('glm-5 (echoer)');
});
});
});

View file

@ -13,12 +13,17 @@ import {
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import type { ModelMetricsCore } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { flattenModelsBySource } from '../utils/modelsBySource.js';
import { t } from '../../i18n/index.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;
// 28 + 2*24 = 76, fitting the 76-column panel at 80-column terminal width
// when the session has a single (model, source) pair split into two columns.
// Sessions with three or more sources will exceed the panel — acceptable per
// the design doc, which accepts the crowded layout for many-subagent cases.
const MODEL_COL_WIDTH = 24;
interface StatRowProps {
title: string;
@ -59,11 +64,9 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
}) => {
const { stats } = useSessionStats();
const { models } = stats.metrics;
const activeModels = Object.entries(models).filter(
([, metrics]) => metrics.api.totalRequests > 0,
);
const entries = flattenModelsBySource(models);
if (activeModels.length === 0) {
if (entries.length === 0) {
return (
<Box
borderStyle="round"
@ -79,19 +82,15 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
);
}
const modelNames = activeModels.map(([name]) => name);
const getModelValues = (
getter: (metrics: ModelMetrics) => string | React.ReactElement,
) => activeModels.map(([, metrics]) => getter(metrics));
getter: (metrics: ModelMetricsCore) => string | React.ReactElement,
) => entries.map(({ metrics }) => getter(metrics));
const hasThoughts = activeModels.some(
([, metrics]) => metrics.tokens.thoughts > 0,
);
const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0);
const hasCached = activeModels.some(
([, metrics]) => metrics.tokens.cached > 0,
const hasThoughts = entries.some(
({ metrics }) => metrics.tokens.thoughts > 0,
);
const hasTool = entries.some(({ metrics }) => metrics.tokens.tool > 0);
const hasCached = entries.some(({ metrics }) => metrics.tokens.cached > 0);
return (
<Box
@ -114,10 +113,10 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
{t('Metric')}
</Text>
</Box>
{modelNames.map((name) => (
<Box width={MODEL_COL_WIDTH} key={name}>
{entries.map(({ key, label }) => (
<Box width={MODEL_COL_WIDTH} key={key}>
<Text bold color={theme.text.primary}>
{name}
{label}
</Text>
</Box>
))}

View file

@ -8,9 +8,19 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type {
ModelMetrics,
ModelMetricsCore,
SessionMetrics,
} from '../contexts/SessionContext.js';
import { MAIN_SOURCE } from '@qwen-code/qwen-code-core';
import { ConfigContext } from '../contexts/ConfigContext.js';
const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({
...core,
bySource: { [MAIN_SOURCE]: core },
});
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
@ -57,7 +67,7 @@ describe('<SessionSummaryDisplay />', () => {
it('renders the summary display with a title', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
tokens: {
prompt: 1000,
@ -67,7 +77,7 @@ describe('<SessionSummaryDisplay />', () => {
thoughts: 300,
tool: 200,
},
},
}),
},
tools: {
totalCalls: 0,

View file

@ -8,7 +8,20 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type {
ModelMetrics,
ModelMetricsCore,
SessionMetrics,
} from '../contexts/SessionContext.js';
import { MAIN_SOURCE } from '@qwen-code/qwen-code-core';
// Wraps a core metrics object as a ModelMetrics with a single `main` source
// bucket, matching the shape produced by processing an API call with no
// subagent attribution. Used to keep fixtures terse.
const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({
...core,
bySource: { [MAIN_SOURCE]: core },
});
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@ -69,7 +82,7 @@ describe('<StatsDisplay />', () => {
it('renders a table with two models correctly', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
tokens: {
prompt: 1000,
@ -79,8 +92,8 @@ describe('<StatsDisplay />', () => {
thoughts: 100,
tool: 50,
},
},
'gemini-2.5-flash': {
}),
'gemini-2.5-flash': mainOnly({
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
tokens: {
prompt: 25000,
@ -90,7 +103,7 @@ describe('<StatsDisplay />', () => {
thoughts: 2000,
tool: 1000,
},
},
}),
},
tools: {
totalCalls: 0,
@ -119,7 +132,7 @@ describe('<StatsDisplay />', () => {
it('renders all sections when all data is present', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
@ -129,7 +142,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
},
}),
},
tools: {
totalCalls: 2,
@ -202,7 +215,7 @@ describe('<StatsDisplay />', () => {
it('hides Efficiency section when cache is not used', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
'gemini-2.5-pro': mainOnly({
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
@ -212,7 +225,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
},
}),
},
tools: {
totalCalls: 0,
@ -350,6 +363,154 @@ describe('<StatsDisplay />', () => {
});
});
describe('Subagent source attribution', () => {
const baseTools: SessionMetrics['tools'] = {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
};
const baseFiles: SessionMetrics['files'] = {
totalLinesAdded: 0,
totalLinesRemoved: 0,
};
const coreMetrics = (reqs: number, tokens: number): ModelMetricsCore => ({
api: { totalRequests: reqs, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: tokens,
candidates: tokens,
total: tokens * 2,
cached: 0,
thoughts: 0,
tool: 0,
},
});
it('renders a plain model name when only main is a source', () => {
const metrics: SessionMetrics = {
models: { 'glm-5': mainOnly(coreMetrics(1, 100)) },
tools: baseTools,
files: baseFiles,
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('glm-5');
expect(output).not.toContain('glm-5 (main)');
expect(output).not.toContain('(main)');
});
it('shows main and subagent suffixes when the same model has multiple sources', () => {
const mainCore = coreMetrics(2, 200);
const echoerCore = coreMetrics(1, 40);
const metrics: SessionMetrics = {
models: {
'glm-5': {
api: {
totalRequests:
mainCore.api.totalRequests + echoerCore.api.totalRequests,
totalErrors: 0,
totalLatencyMs: 200,
},
tokens: {
prompt: mainCore.tokens.prompt + echoerCore.tokens.prompt,
candidates:
mainCore.tokens.candidates + echoerCore.tokens.candidates,
total: mainCore.tokens.total + echoerCore.tokens.total,
cached: 0,
thoughts: 0,
tool: 0,
},
bySource: {
[MAIN_SOURCE]: mainCore,
echoer: echoerCore,
},
},
},
tools: baseTools,
files: baseFiles,
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('glm-5 (main)');
expect(output).toContain('glm-5 (echoer)');
});
it('labels main rows session-wide when a subagent uses a different model', () => {
// Session has two models: glm-5 used only by main, qwen-plus used only by
// a subagent. Even though glm-5 has a single main source, it must still
// render with `(main)` because the session-wide rule triggers on qwen-plus.
const metrics: SessionMetrics = {
models: {
'glm-5': mainOnly(coreMetrics(2, 200)),
'qwen-plus': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 40,
candidates: 40,
total: 80,
cached: 0,
thoughts: 0,
tool: 0,
},
bySource: {
researcher: coreMetrics(1, 40),
},
},
},
tools: baseTools,
files: baseFiles,
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('glm-5 (main)');
expect(output).toContain('qwen-plus (researcher)');
// The bare `glm-5` label (not followed by a space + `(`) must not appear
// as a row label in this session.
expect(output).not.toMatch(/glm-5\s{2,}/);
});
it('shows distinct rows when two subagents share a model', () => {
const alphaCore = coreMetrics(1, 10);
const bravoCore = coreMetrics(1, 20);
const metrics: SessionMetrics = {
models: {
'glm-5': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 30,
candidates: 30,
total: 60,
cached: 0,
thoughts: 0,
tool: 0,
},
bySource: {
alpha: alphaCore,
bravo: bravoCore,
},
},
},
tools: baseTools,
files: baseFiles,
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('glm-5 (alpha)');
expect(output).toContain('glm-5 (bravo)');
expect(output).not.toContain('glm-5 (main)');
});
});
describe('Title Rendering', () => {
const zeroMetrics: SessionMetrics = {
models: {},

View file

@ -19,6 +19,7 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
import { flattenModelsBySource } from '../utils/modelsBySource.js';
import { t } from '../../i18n/index.js';
// A more flexible and powerful StatRow component
@ -75,11 +76,17 @@ const ModelUsageTable: React.FC<{
totalCachedTokens: number;
cacheEfficiency: number;
}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
const nameWidth = 25;
// 35 + 8 + 15 + 15 = 73, fitting within the 76-column panel allocated
// when the terminal is at the default 80-column width. Subagent labels
// longer than 35 characters will wrap — acceptable cosmetic trade-off
// given the alternative is overflowing the panel border.
const nameWidth = 35;
const requestsWidth = 8;
const inputTokensWidth = 15;
const outputTokensWidth = 15;
const entries = flattenModelsBySource(models);
return (
<Box flexDirection="column" marginTop={1}>
{/* Header */}
@ -117,24 +124,22 @@ const ModelUsageTable: React.FC<{
></Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
{entries.map(({ key, label, metrics }) => (
<Box key={key}>
<Box width={nameWidth}>
<Text color={theme.text.primary}>{name.replace('-001', '')}</Text>
<Text color={theme.text.primary}>{label}</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.api.totalRequests}
</Text>
<Text color={theme.text.primary}>{metrics.api.totalRequests}</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={theme.status.warning}>
{modelMetrics.tokens.prompt.toLocaleString()}
{metrics.tokens.prompt.toLocaleString()}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={theme.status.warning}>
{modelMetrics.tokens.candidates.toLocaleString()}
{metrics.tokens.candidates.toLocaleString()}
</Text>
</Box>
</Box>

View file

@ -28,20 +28,20 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 1
│ Errors 0 (0.0%) 0 (0.0%)
│ Avg Latency 100ms 50ms
│ Requests 1 1
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 100ms 50ms │
│ │
│ Tokens │
│ Total 30 15
│ ↳ Prompt 10 5
│ ↳ Cached 5 (50.0%) 0 (0.0%)
│ ↳ Thoughts 2 0
│ ↳ Tool 0 3
│ ↳ Output 20 10
│ Total 30 15
│ ↳ Prompt 10 5
│ ↳ Cached 5 (50.0%) 0 (0.0%) │
│ ↳ Thoughts 2 0
│ ↳ Tool 0 3
│ ↳ Output 20 10
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -51,20 +51,20 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20
│ Errors 1 (10.0%) 2 (10.0%)
│ Avg Latency 100ms 25ms
│ Requests 10 20
│ Errors 1 (10.0%) 2 (10.0%) │
│ Avg Latency 100ms 25ms │
│ │
│ Tokens │
│ Total 300 600
│ ↳ Prompt 100 200
│ ↳ Cached 50 (50.0%) 100 (50.0%)
│ ↳ Thoughts 10 20
│ ↳ Tool 5 10
│ ↳ Output 200 400
│ Total 300 600 │
│ ↳ Prompt 100 200 │
│ ↳ Cached 50 (50.0%) 100 (50.0%) │
│ ↳ Thoughts 10 20
│ ↳ Tool 5 10
│ ↳ Output 200 400 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -18,9 +18,9 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 10 1,000 2,000
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 10 1,000 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │

View file

@ -118,9 +118,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@ -202,10 +202,10 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 1,000 2,000
│ gemini-2.5-flash 5 25,000 15,000
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 3 1,000 2,000 │
│ gemini-2.5-flash 5 25,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
@ -232,9 +232,9 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens
│ ───────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────────────────
│ gemini-2.5-pro 1 100 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │

View file

@ -73,6 +73,7 @@ describe('SessionStatsContext', () => {
thoughts: 20,
tool: 10,
},
bySource: {},
},
},
tools: {
@ -151,6 +152,7 @@ describe('SessionStatsContext', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
tools: {
@ -192,6 +194,7 @@ describe('SessionStatsContext', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
};

View file

@ -17,6 +17,7 @@ import {
import type {
SessionMetrics,
ModelMetrics,
ModelMetricsCore,
ToolCallStats,
} from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
@ -28,7 +29,10 @@ export enum ToolCallDecision {
AUTO_ACCEPT = 'auto_accept',
}
function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
function areModelMetricsCoreEqual(
a: ModelMetricsCore,
b: ModelMetricsCore,
): boolean {
if (
a.api.totalRequests !== b.api.totalRequests ||
a.api.totalErrors !== b.api.totalErrors ||
@ -49,6 +53,23 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
return true;
}
function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean {
if (!areModelMetricsCoreEqual(a, b)) return false;
const aKeys = Object.keys(a.bySource);
const bKeys = Object.keys(b.bySource);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
const aSource = a.bySource[key];
const bSource = b.bySource[key];
if (!bSource || !areModelMetricsCoreEqual(aSource, bSource)) {
return false;
}
}
return true;
}
function areToolCallStatsEqual(a: ToolCallStats, b: ToolCallStats): boolean {
if (
a.count !== b.count ||
@ -138,7 +159,7 @@ function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean {
return true;
}
export type { SessionMetrics, ModelMetrics };
export type { SessionMetrics, ModelMetrics, ModelMetricsCore };
export interface SessionStatsState {
sessionId: string;

View file

@ -12,13 +12,13 @@ import {
computeSessionStats,
} from './computeStats.js';
import type {
ModelMetrics,
ModelMetricsCore,
SessionMetrics,
} from '../contexts/SessionContext.js';
describe('calculateErrorRate', () => {
it('should return 0 if totalRequests is 0', () => {
const metrics: ModelMetrics = {
const metrics: ModelMetricsCore = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 0,
@ -33,7 +33,7 @@ describe('calculateErrorRate', () => {
});
it('should calculate the error rate correctly', () => {
const metrics: ModelMetrics = {
const metrics: ModelMetricsCore = {
api: { totalRequests: 10, totalErrors: 2, totalLatencyMs: 0 },
tokens: {
prompt: 0,
@ -50,7 +50,7 @@ describe('calculateErrorRate', () => {
describe('calculateAverageLatency', () => {
it('should return 0 if totalRequests is 0', () => {
const metrics: ModelMetrics = {
const metrics: ModelMetricsCore = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 1000 },
tokens: {
prompt: 0,
@ -65,7 +65,7 @@ describe('calculateAverageLatency', () => {
});
it('should calculate the average latency correctly', () => {
const metrics: ModelMetrics = {
const metrics: ModelMetricsCore = {
api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 1500 },
tokens: {
prompt: 0,
@ -82,7 +82,7 @@ describe('calculateAverageLatency', () => {
describe('calculateCacheHitRate', () => {
it('should return 0 if prompt tokens is 0', () => {
const metrics: ModelMetrics = {
const metrics: ModelMetricsCore = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 0,
@ -97,7 +97,7 @@ describe('calculateCacheHitRate', () => {
});
it('should calculate the cache hit rate correctly', () => {
const metrics: ModelMetrics = {
const metrics: ModelMetricsCore = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 200,
@ -162,6 +162,7 @@ describe('computeSessionStats', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
tools: {
@ -200,6 +201,7 @@ describe('computeSessionStats', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
tools: {

View file

@ -7,24 +7,24 @@
import type {
SessionMetrics,
ComputedSessionStats,
ModelMetrics,
ModelMetricsCore,
} from '../contexts/SessionContext.js';
export function calculateErrorRate(metrics: ModelMetrics): number {
export function calculateErrorRate(metrics: ModelMetricsCore): number {
if (metrics.api.totalRequests === 0) {
return 0;
}
return (metrics.api.totalErrors / metrics.api.totalRequests) * 100;
}
export function calculateAverageLatency(metrics: ModelMetrics): number {
export function calculateAverageLatency(metrics: ModelMetricsCore): number {
if (metrics.api.totalRequests === 0) {
return 0;
}
return metrics.api.totalLatencyMs / metrics.api.totalRequests;
}
export function calculateCacheHitRate(metrics: ModelMetrics): number {
export function calculateCacheHitRate(metrics: ModelMetricsCore): number {
if (metrics.tokens.prompt === 0) {
return 0;
}

View file

@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
MAIN_SOURCE,
type ModelMetrics,
type ModelMetricsCore,
} from '@qwen-code/qwen-code-core';
import { flattenModelsBySource } from './modelsBySource.js';
const emptyCore = (): ModelMetricsCore => ({
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
});
const coreWithRequests = (requests: number): ModelMetricsCore => ({
...emptyCore(),
api: { totalRequests: requests, totalErrors: 0, totalLatencyMs: 0 },
});
const makeModel = (
bySource: Record<string, ModelMetricsCore>,
): ModelMetrics => {
const totalRequests = Object.values(bySource).reduce(
(sum, m) => sum + m.api.totalRequests,
0,
);
return {
...emptyCore(),
api: { totalRequests, totalErrors: 0, totalLatencyMs: 0 },
bySource,
};
};
describe('flattenModelsBySource', () => {
it('omits models with zero requests', () => {
const entries = flattenModelsBySource({
'idle-model': makeModel({}),
});
expect(entries).toEqual([]);
});
it('collapses to plain label when no non-main source exists in the session', () => {
const entries = flattenModelsBySource({
'glm-5': makeModel({ [MAIN_SOURCE]: coreWithRequests(3) }),
'qwen-max': makeModel({ [MAIN_SOURCE]: coreWithRequests(2) }),
});
expect(entries.map((e) => e.label)).toEqual(['glm-5', 'qwen-max']);
expect(entries.map((e) => e.key)).toEqual(['glm-5', 'qwen-max']);
});
it('splits every row when any model has a non-main source (session-wide rule)', () => {
const entries = flattenModelsBySource({
'glm-5': makeModel({ [MAIN_SOURCE]: coreWithRequests(2) }),
'qwen-plus': makeModel({ researcher: coreWithRequests(1) }),
});
expect(entries.map((e) => e.label)).toEqual([
'glm-5 (main)',
'qwen-plus (researcher)',
]);
});
it('orders sources with MAIN_SOURCE first then alphabetically', () => {
const entries = flattenModelsBySource({
'glm-5': makeModel({
bravo: coreWithRequests(1),
[MAIN_SOURCE]: coreWithRequests(2),
alpha: coreWithRequests(1),
}),
});
expect(entries.map((e) => e.label)).toEqual([
'glm-5 (main)',
'glm-5 (alpha)',
'glm-5 (bravo)',
]);
});
it('produces distinct keys when two raw model names normalize to the same label', () => {
// `normalizeModelName` strips `-001`, so `foo` and `foo-001` both render
// as the label `foo`. The React key must still be unique across entries.
const entries = flattenModelsBySource({
foo: makeModel({ [MAIN_SOURCE]: coreWithRequests(1) }),
'foo-001': makeModel({ [MAIN_SOURCE]: coreWithRequests(1) }),
});
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.label)).toEqual(['foo', 'foo']);
const keys = entries.map((e) => e.key);
expect(new Set(keys).size).toBe(keys.length);
expect(keys).toEqual(['foo', 'foo-001']);
});
it('produces distinct keys across (model, source) pairs in the split case', () => {
const entries = flattenModelsBySource({
'glm-5': makeModel({
[MAIN_SOURCE]: coreWithRequests(1),
alpha: coreWithRequests(1),
}),
'qwen-plus': makeModel({
alpha: coreWithRequests(1),
}),
});
const keys = entries.map((e) => e.key);
expect(new Set(keys).size).toBe(keys.length);
expect(keys).toEqual(['glm-5::main', 'glm-5::alpha', 'qwen-plus::alpha']);
});
it('falls back to the aggregate when bySource is empty (defensive)', () => {
// Callers shouldn't hit this, but the helper should still produce a
// usable row rather than dropping the model.
const entries = flattenModelsBySource({
'glm-5': {
...coreWithRequests(1),
bySource: {},
},
});
expect(entries).toHaveLength(1);
expect(entries[0]?.label).toBe('glm-5');
expect(entries[0]?.key).toBe('glm-5');
});
});

View file

@ -0,0 +1,127 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
MAIN_SOURCE,
type ModelMetrics,
type ModelMetricsCore,
} from '@qwen-code/qwen-code-core';
/**
* One entry in the flattened view of the `models` metric map. Each entry
* corresponds to a single row (in `StatsDisplay`) or column (in
* `ModelStatsDisplay`).
*/
export interface ModelSourceEntry {
/**
* Stable React key built from the raw model name + source. Guaranteed
* unique across the returned array, even when two raw model names
* normalize to the same display label (e.g. `foo` and `foo-001`).
*/
key: string;
/**
* Display label. Either the bare (possibly normalized) model name for
* the single-source collapse case, or `${modelName} (${source})` when
* the model has any non-main source.
*/
label: string;
/** Backing metrics — either the model aggregate or one source bucket. */
metrics: ModelMetricsCore;
}
/**
* Flattens `SessionMetrics.models` into a list of `(label, metrics)` entries
* suitable for rendering one per row/column.
*
* Rules (matching the design doc `3215-subagent-stats-attribution.md`):
* - Collapse is decided **session-wide**: if NO model in the entire session
* has any non-main source, every row renders with the plain model name
* (existing UX preserved).
* - If ANY model in the session has a non-main source, EVERY row across
* ALL models renders with a `${model} (${source})` label including the
* `(main)` rows so the user can directly compare attribution across the
* whole stats panel. This matches the issue mockup, which shows
* `qwen-max (main)` alongside `qwen-plus (researcher)`.
* - Within the split case, sources under a given model are sorted with
* `MAIN_SOURCE` first (if present), then the rest alphabetically.
* - Models with zero requests (aggregate) are omitted.
* - If `bySource` is somehow empty (defensive callers shouldn't hit this),
* fall back to the aggregate row with the plain model name.
*/
export function flattenModelsBySource(
models: Record<string, ModelMetrics>,
): ModelSourceEntry[] {
const sessionHasNonMainSource = Object.values(models).some((modelMetrics) =>
Object.keys(modelMetrics.bySource).some((source) => source !== MAIN_SOURCE),
);
const result: ModelSourceEntry[] = [];
for (const [modelName, modelMetrics] of Object.entries(models)) {
if (modelMetrics.api.totalRequests <= 0) continue;
const displayName = normalizeModelName(modelName);
const sourceNames = Object.keys(modelMetrics.bySource);
if (sourceNames.length === 0) {
result.push({
key: modelName,
label: displayName,
metrics: modelMetrics,
});
continue;
}
if (!sessionHasNonMainSource) {
// Collapse session-wide: only main sources exist, render aggregate
// with plain model names so the existing UX is preserved.
result.push({
key: modelName,
label: displayName,
metrics: modelMetrics.bySource[MAIN_SOURCE] ?? modelMetrics,
});
continue;
}
const sortedSources = sortSources(sourceNames);
for (const source of sortedSources) {
result.push({
key: `${modelName}::${source}`,
label: `${displayName} (${source})`,
metrics: modelMetrics.bySource[source],
});
}
}
return result;
}
/**
* Strips the Gemini `-001` version suffix from model names for display.
* Historically the StatsDisplay summary table normalized model names this
* way; keep the behavior but apply it to the model portion only so subagent
* names that happen to contain `-001` are not mangled.
*/
function normalizeModelName(modelName: string): string {
return modelName.replace('-001', '');
}
/**
* `MAIN_SOURCE` first (if present), then the rest alphabetically.
*/
function sortSources(sources: string[]): string[] {
const main: string[] = [];
const rest: string[] = [];
for (const source of sources) {
if (source === MAIN_SOURCE) {
main.push(source);
} else {
rest.push(source);
}
}
rest.sort((a, b) => a.localeCompare(b));
return [...main, ...rest];
}

View file

@ -358,6 +358,7 @@ describe('computeUsageFromMetrics', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
tools: {
@ -400,6 +401,7 @@ describe('computeUsageFromMetrics', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
'model-2': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@ -411,6 +413,7 @@ describe('computeUsageFromMetrics', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
tools: {
@ -453,6 +456,7 @@ describe('computeUsageFromMetrics', () => {
thoughts: 0,
tool: 0,
},
bySource: {},
},
},
tools: {