mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 07:10:55 +00:00
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:
parent
52c7a3d0ed
commit
b27cb81bb7
27 changed files with 995 additions and 126 deletions
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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. │
|
||||
│ │
|
||||
|
|
|
|||
|
|
@ -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. │
|
||||
│ │
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
131
packages/cli/src/ui/utils/modelsBySource.test.ts
Normal file
131
packages/cli/src/ui/utils/modelsBySource.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
127
packages/cli/src/ui/utils/modelsBySource.ts
Normal file
127
packages/cli/src/ui/utils/modelsBySource.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue