feat: add role-specific statistics to telemetry and UI (cont. #15234) (#18824)

Co-authored-by: Yuna Seol <yunaseol@google.com>
This commit is contained in:
Yuna Seol
2026-02-17 12:32:30 -05:00
committed by GitHub
parent 14aabbbe8b
commit 8aca3068cf
51 changed files with 826 additions and 20 deletions
@@ -11,7 +11,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
import * as SettingsContext from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -118,6 +118,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -160,6 +161,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 0,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
@@ -172,6 +174,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 3,
},
roles: {},
},
},
tools: {
@@ -214,6 +217,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 10,
tool: 5,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
@@ -226,6 +230,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 20,
tool: 10,
},
roles: {},
},
},
tools: {
@@ -271,6 +276,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 111111111,
tool: 222222222,
},
roles: {},
},
},
tools: {
@@ -309,6 +315,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 2,
tool: 1,
},
roles: {},
},
},
tools: {
@@ -351,6 +358,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 100,
tool: 50,
},
roles: {},
},
'gemini-3-flash-preview': {
api: { totalRequests: 20, totalErrors: 0, totalLatencyMs: 1000 },
@@ -363,6 +371,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 200,
tool: 100,
},
roles: {},
},
},
tools: {
@@ -390,6 +399,64 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
});
it('should display role breakdown correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 },
tokens: {
input: 20,
prompt: 30,
candidates: 40,
total: 70,
cached: 10,
thoughts: 0,
tool: 0,
},
roles: {
[LlmRole.MAIN]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 15,
candidates: 20,
total: 35,
cached: 5,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).toContain('main');
expect(output).toContain('Input');
expect(output).toContain('Output');
expect(output).toContain('Cache Reads');
expect(output).toMatchSnapshot();
});
@@ -427,6 +494,7 @@ describe('<ModelStatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -462,4 +530,121 @@ describe('<ModelStatsDisplay />', () => {
expect(output).toContain('Tier:');
expect(output).toContain('Pro');
});
it('should handle long role name layout', () => {
// Use the longest valid role name to test layout
const longRoleName = LlmRole.UTILITY_LOOP_DETECTOR;
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {
[longRoleName]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).toContain(longRoleName);
expect(output).toMatchSnapshot();
});
it('should filter out invalid role names', () => {
const invalidRoleName =
'this_is_a_very_long_role_name_that_should_be_wrapped' as LlmRole;
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
roles: {
[invalidRoleName]: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 100,
tokens: {
input: 10,
prompt: 10,
candidates: 20,
total: 30,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).not.toContain(invalidRoleName);
expect(output).toMatchSnapshot();
});
});
@@ -13,10 +13,17 @@ import {
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
useSessionStats,
type ModelMetrics,
} from '../contexts/SessionContext.js';
import { Table, type Column } from './Table.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getDisplayString, isAutoModel } from '@google/gemini-cli-core';
import {
getDisplayString,
isAutoModel,
LlmRole,
} from '@google/gemini-cli-core';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
@@ -25,9 +32,11 @@ interface StatRowData {
isSection?: boolean;
isSubtle?: boolean;
// Dynamic keys for model values
[key: string]: string | React.ReactNode | boolean | undefined;
[key: string]: string | React.ReactNode | boolean | undefined | number;
}
type RoleMetrics = NonNullable<NonNullable<ModelMetrics['roles']>[LlmRole]>;
interface ModelStatsDisplayProps {
selectedAuthType?: string;
userEmail?: string;
@@ -81,6 +90,22 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
([, metrics]) => metrics.tokens.cached > 0,
);
const allRoles = [
...new Set(
activeModels.flatMap(([, metrics]) => Object.keys(metrics.roles ?? {})),
),
]
.filter((role): role is LlmRole => {
const validRoles: string[] = Object.values(LlmRole);
return validRoles.includes(role);
})
.sort((a, b) => {
if (a === b) return 0;
if (a === LlmRole.MAIN) return -1;
if (b === LlmRole.MAIN) return 1;
return a.localeCompare(b);
});
// Helper to create a row with values for each model
const createRow = (
metric: string,
@@ -204,6 +229,60 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
),
);
// Roles Section
if (allRoles.length > 0) {
// Spacer
rows.push({ metric: '' });
rows.push({ metric: 'Roles', isSection: true });
allRoles.forEach((role) => {
// Role Header Row
const roleHeaderRow: StatRowData = {
metric: role,
isSection: true,
color: theme.text.primary,
};
// We don't populate model values for the role header row
rows.push(roleHeaderRow);
const addRoleMetric = (
metric: string,
getValue: (r: RoleMetrics) => string | React.ReactNode,
) => {
const row: StatRowData = {
metric,
isSubtle: true,
};
activeModels.forEach(([name, metrics]) => {
const roleMetrics = metrics.roles?.[role];
if (roleMetrics) {
row[name] = getValue(roleMetrics);
} else {
row[name] = <Text color={theme.text.secondary}>-</Text>;
}
});
rows.push(row);
};
addRoleMetric('Requests', (r) => r.totalRequests.toLocaleString());
addRoleMetric('Input', (r) => (
<Text color={theme.text.primary}>
{r.tokens.input.toLocaleString()}
</Text>
));
addRoleMetric('Output', (r) => (
<Text color={theme.text.primary}>
{r.tokens.candidates.toLocaleString()}
</Text>
));
addRoleMetric('Cache Reads', (r) => (
<Text color={theme.text.secondary}>
{r.tokens.cached.toLocaleString()}
</Text>
));
});
}
const columns: Array<Column<StatRowData>> = [
{
key: 'metric',
@@ -55,6 +55,7 @@ describe('<SessionSummaryDisplay />', () => {
thoughts: 300,
tool: 200,
},
roles: {},
},
},
tools: {
@@ -93,6 +93,7 @@ describe('<StatsDisplay />', () => {
thoughts: 100,
tool: 50,
},
roles: {},
},
'gemini-2.5-flash': {
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
@@ -105,6 +106,7 @@ describe('<StatsDisplay />', () => {
thoughts: 2000,
tool: 1000,
},
roles: {},
},
},
});
@@ -133,6 +135,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
tools: {
@@ -227,6 +230,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
});
@@ -411,6 +415,7 @@ describe('<StatsDisplay />', () => {
thoughts: 0,
tool: 0,
},
roles: {},
},
},
});
@@ -44,6 +44,32 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display role breakdown correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 2 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 70 │
│ ↳ Input 20 │
│ ↳ Cache Reads 10 (33.3%) │
│ ↳ Output 40 │
│ Roles │
│ main │
│ ↳ Requests 1 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ ↳ Cache Reads 5 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -66,6 +92,25 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should filter out invalid role names 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 30 │
│ ↳ Input 10 │
│ ↳ Output 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -88,6 +133,31 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 30 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ Roles │
│ utility_loop_detector │
│ ↳ Requests 1 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ ↳ Cache Reads 0 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -8,7 +8,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
@@ -279,6 +279,7 @@ Return a JSON object with:
},
abortSignal: new AbortController().signal,
promptId: 'triage-duplicates',
role: LlmRole.UTILITY_TOOL,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -8,7 +8,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
@@ -223,6 +223,7 @@ Return a JSON object with:
},
abortSignal: abortControllerRef.current.signal,
promptId: 'triage-issues',
role: LlmRole.UTILITY_TOOL,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion