feat: Change /stats to include more detailed breakdowns (#2615)

This commit is contained in:
Abhi 2025-06-29 20:44:33 -04:00 committed by GitHub
parent 0fd602eb43
commit 770f862832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3218 additions and 758 deletions

View File

@ -823,11 +823,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
showMemoryUsage={ showMemoryUsage={
config.getDebugMode() || config.getShowMemoryUsage() config.getDebugMode() || config.getShowMemoryUsage()
} }
promptTokenCount={sessionStats.currentResponse.promptTokenCount} promptTokenCount={sessionStats.lastPromptTokenCount}
candidatesTokenCount={
sessionStats.currentResponse.candidatesTokenCount
}
totalTokenCount={sessionStats.currentResponse.totalTokenCount}
/> />
</Box> </Box>
</Box> </Box>

View File

@ -23,8 +23,6 @@ interface FooterProps {
showErrorDetails: boolean; showErrorDetails: boolean;
showMemoryUsage?: boolean; showMemoryUsage?: boolean;
promptTokenCount: number; promptTokenCount: number;
candidatesTokenCount: number;
totalTokenCount: number;
} }
export const Footer: React.FC<FooterProps> = ({ export const Footer: React.FC<FooterProps> = ({
@ -37,10 +35,10 @@ export const Footer: React.FC<FooterProps> = ({
errorCount, errorCount,
showErrorDetails, showErrorDetails,
showMemoryUsage, showMemoryUsage,
totalTokenCount, promptTokenCount,
}) => { }) => {
const limit = tokenLimit(model); const limit = tokenLimit(model);
const percentage = totalTokenCount / limit; const percentage = promptTokenCount / limit;
return ( return (
<Box marginTop={1} justifyContent="space-between" width="100%"> <Box marginTop={1} justifyContent="space-between" width="100%">

View File

@ -8,7 +8,7 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { HistoryItem, MessageType } from '../types.js'; import { HistoryItem, MessageType } from '../types.js';
import { CumulativeStats } from '../contexts/SessionContext.js'; import { SessionStatsProvider } from '../contexts/SessionContext.js';
// Mock child components // Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({ vi.mock('./messages/ToolGroupMessage.js', () => ({
@ -36,25 +36,15 @@ describe('<HistoryItemDisplay />', () => {
}); });
it('renders StatsDisplay for "stats" type', () => { it('renders StatsDisplay for "stats" type', () => {
const stats: CumulativeStats = {
turnCount: 1,
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
cachedContentTokenCount: 5,
toolUsePromptTokenCount: 2,
thoughtsTokenCount: 3,
apiTimeMs: 123,
};
const item: HistoryItem = { const item: HistoryItem = {
...baseItem, ...baseItem,
type: MessageType.STATS, type: MessageType.STATS,
stats,
lastTurnStats: stats,
duration: '1s', duration: '1s',
}; };
const { lastFrame } = render( const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />, <SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
); );
expect(lastFrame()).toContain('Stats'); expect(lastFrame()).toContain('Stats');
}); });
@ -76,25 +66,46 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('About Gemini CLI'); expect(lastFrame()).toContain('About Gemini CLI');
}); });
it('renders SessionSummaryDisplay for "quit" type', () => { it('renders ModelStatsDisplay for "model_stats" type', () => {
const stats: CumulativeStats = { const item: HistoryItem = {
turnCount: 1, ...baseItem,
promptTokenCount: 10, type: 'model_stats',
candidatesTokenCount: 20,
totalTokenCount: 30,
cachedContentTokenCount: 5,
toolUsePromptTokenCount: 2,
thoughtsTokenCount: 3,
apiTimeMs: 123,
}; };
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
});
it('renders ToolStatsDisplay for "tool_stats" type', () => {
const item: HistoryItem = {
...baseItem,
type: 'tool_stats',
};
const { lastFrame } = render(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
});
it('renders SessionSummaryDisplay for "quit" type', () => {
const item: HistoryItem = { const item: HistoryItem = {
...baseItem, ...baseItem,
type: 'quit', type: 'quit',
stats,
duration: '1s', duration: '1s',
}; };
const { lastFrame } = render( const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />, <SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
); );
expect(lastFrame()).toContain('Agent powering down. Goodbye!'); expect(lastFrame()).toContain('Agent powering down. Goodbye!');
}); });

View File

@ -17,6 +17,8 @@ import { CompressionMessage } from './messages/CompressionMessage.js';
import { Box } from 'ink'; import { Box } from 'ink';
import { AboutBox } from './AboutBox.js'; import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js'; import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@google/gemini-cli-core'; import { Config } from '@google/gemini-cli-core';
@ -69,16 +71,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
gcpProject={item.gcpProject} gcpProject={item.gcpProject}
/> />
)} )}
{item.type === 'stats' && ( {item.type === 'stats' && <StatsDisplay duration={item.duration} />}
<StatsDisplay {item.type === 'model_stats' && <ModelStatsDisplay />}
stats={item.stats} {item.type === 'tool_stats' && <ToolStatsDisplay />}
lastTurnStats={item.lastTurnStats} {item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
duration={item.duration}
/>
)}
{item.type === 'quit' && (
<SessionSummaryDisplay stats={item.stats} duration={item.duration} />
)}
{item.type === 'tool_group' && ( {item.type === 'tool_group' && (
<ToolGroupMessage <ToolGroupMessage
toolCalls={item.tools} toolCalls={item.tools}

View File

@ -0,0 +1,235 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
},
});
return render(<ModelStatsDisplay />);
};
describe('<ModelStatsDisplay />', () => {
it('should render "no API calls" message when there are no active models', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
});
it('should not display conditional rows if no model has data for them', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
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 },
byName: {},
},
});
const output = lastFrame();
expect(output).not.toContain('Cached');
expect(output).not.toContain('Thoughts');
expect(output).not.toContain('Tool');
expect(output).toMatchSnapshot();
});
it('should display conditional rows if at least one model has data', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 0,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
tokens: {
prompt: 5,
candidates: 10,
total: 15,
cached: 0,
thoughts: 0,
tool: 3,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('Cached');
expect(output).toContain('Thoughts');
expect(output).toContain('Tool');
expect(output).toMatchSnapshot();
});
it('should display stats for multiple models correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
tokens: {
prompt: 100,
candidates: 200,
total: 300,
cached: 50,
thoughts: 10,
tool: 5,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
tokens: {
prompt: 200,
candidates: 400,
total: 600,
cached: 100,
thoughts: 20,
tool: 10,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: {
totalRequests: 999999999,
totalErrors: 123456789,
totalLatencyMs: 9876,
},
tokens: {
prompt: 987654321,
candidates: 123456789,
total: 999999999,
cached: 123456789,
thoughts: 111111111,
tool: 222222222,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('should display a single model correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 1,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
const output = lastFrame();
expect(output).toContain('gemini-2.5-pro');
expect(output).not.toContain('gemini-2.5-flash');
expect(output).toMatchSnapshot();
});
});

View File

@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
calculateAverageLatency,
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;
interface StatRowProps {
title: string;
values: Array<string | React.ReactElement>;
isSubtle?: boolean;
isSection?: boolean;
}
const StatRow: React.FC<StatRowProps> = ({
title,
values,
isSubtle = false,
isSection = false,
}) => (
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
{isSubtle ? `${title}` : title}
</Text>
</Box>
{values.map((value, index) => (
<Box width={MODEL_COL_WIDTH} key={index}>
<Text>{value}</Text>
</Box>
))}
</Box>
);
export const ModelStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { models } = stats.metrics;
const activeModels = Object.entries(models).filter(
([, metrics]) => metrics.api.totalRequests > 0,
);
if (activeModels.length === 0) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
paddingY={1}
paddingX={2}
>
<Text>No API calls have been made in this session.</Text>
</Box>
);
}
const modelNames = activeModels.map(([name]) => name);
const getModelValues = (
getter: (metrics: ModelMetrics) => string | React.ReactElement,
) => activeModels.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,
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
>
<Text bold color={Colors.AccentPurple}>
Model Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold>Metric</Text>
</Box>
{modelNames.map((name) => (
<Box width={MODEL_COL_WIDTH} key={name}>
<Text bold>{name}</Text>
</Box>
))}
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
/>
{/* API Section */}
<StatRow title="API" values={[]} isSection />
<StatRow
title="Requests"
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
/>
<StatRow
title="Errors"
values={getModelValues((m) => {
const errorRate = calculateErrorRate(m);
return (
<Text
color={
m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground
}
>
{m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
</Text>
);
})}
/>
<StatRow
title="Avg Latency"
values={getModelValues((m) => {
const avgLatency = calculateAverageLatency(m);
return formatDuration(avgLatency);
})}
/>
<Box height={1} />
{/* Tokens Section */}
<StatRow title="Tokens" values={[]} isSection />
<StatRow
title="Total"
values={getModelValues((m) => (
<Text color={Colors.AccentYellow}>
{m.tokens.total.toLocaleString()}
</Text>
))}
/>
<StatRow
title="Prompt"
isSubtle
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
/>
{hasCached && (
<StatRow
title="Cached"
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
return (
<Text color={Colors.AccentGreen}>
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
</Text>
);
})}
/>
)}
{hasThoughts && (
<StatRow
title="Thoughts"
isSubtle
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
/>
)}
{hasTool && (
<StatRow
title="Tool"
isSubtle
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
/>
)}
<StatRow
title="Output"
isSubtle
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
/>
</Box>
);
};

View File

@ -5,48 +5,92 @@
*/ */
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { type CumulativeStats } from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
},
});
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
};
describe('<SessionSummaryDisplay />', () => { describe('<SessionSummaryDisplay />', () => {
const mockStats: CumulativeStats = { it('correctly sums and displays stats from multiple models', () => {
turnCount: 10, const metrics: SessionMetrics = {
promptTokenCount: 1000, models: {
candidatesTokenCount: 2000, 'gemini-2.5-pro': {
totalTokenCount: 3500, api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
cachedContentTokenCount: 500, tokens: {
toolUsePromptTokenCount: 200, prompt: 1000,
thoughtsTokenCount: 300, candidates: 2000,
apiTimeMs: 50234, total: 3500,
cached: 500,
thoughts: 300,
tool: 200,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 12345 },
tokens: {
prompt: 500,
candidates: 1000,
total: 1500,
cached: 100,
thoughts: 50,
tool: 20,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
}; };
const mockDuration = '1h 23m 45s'; const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
it('renders correctly with given stats and duration', () => { // Verify totals are summed correctly
const { lastFrame } = render( expect(output).toContain('Cumulative Stats (15 API calls)');
<SessionSummaryDisplay stats={mockStats} duration={mockDuration} />, expect(output).toMatchSnapshot();
);
expect(lastFrame()).toMatchSnapshot();
}); });
it('renders zero state correctly', () => { it('renders zero state correctly', () => {
const zeroStats: CumulativeStats = { const zeroMetrics: SessionMetrics = {
turnCount: 0, models: {},
promptTokenCount: 0, tools: {
candidatesTokenCount: 0, totalCalls: 0,
totalTokenCount: 0, totalSuccess: 0,
cachedContentTokenCount: 0, totalFail: 0,
toolUsePromptTokenCount: 0, totalDurationMs: 0,
thoughtsTokenCount: 0, totalDecisions: { accept: 0, reject: 0, modify: 0 },
apiTimeMs: 0, byName: {},
},
}; };
const { lastFrame } = render( const { lastFrame } = renderWithMockedStats(zeroMetrics);
<SessionSummaryDisplay stats={zeroStats} duration="0s" />,
);
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
}); });
}); });

View File

@ -9,31 +9,57 @@ import { Box, Text } from 'ink';
import Gradient from 'ink-gradient'; import Gradient from 'ink-gradient';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { CumulativeStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { computeSessionStats } from '../utils/computeStats.js';
import { FormattedStats, StatRow, StatsColumn } from './Stats.js'; import { FormattedStats, StatRow, StatsColumn } from './Stats.js';
// --- Prop and Data Structures --- // --- Prop and Data Structures ---
interface SessionSummaryDisplayProps { interface SessionSummaryDisplayProps {
stats: CumulativeStats;
duration: string; duration: string;
} }
// --- Main Component --- // --- Main Component ---
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
stats,
duration, duration,
}) => { }) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const computed = computeSessionStats(metrics);
const cumulativeFormatted: FormattedStats = { const cumulativeFormatted: FormattedStats = {
inputTokens: stats.promptTokenCount, inputTokens: Object.values(metrics.models).reduce(
outputTokens: stats.candidatesTokenCount, (acc, model) => acc + model.tokens.prompt,
toolUseTokens: stats.toolUsePromptTokenCount, 0,
thoughtsTokens: stats.thoughtsTokenCount, ),
cachedTokens: stats.cachedContentTokenCount, outputTokens: Object.values(metrics.models).reduce(
totalTokens: stats.totalTokenCount, (acc, model) => acc + model.tokens.candidates,
0,
),
toolUseTokens: Object.values(metrics.models).reduce(
(acc, model) => acc + model.tokens.tool,
0,
),
thoughtsTokens: Object.values(metrics.models).reduce(
(acc, model) => acc + model.tokens.thoughts,
0,
),
cachedTokens: Object.values(metrics.models).reduce(
(acc, model) => acc + model.tokens.cached,
0,
),
totalTokens: Object.values(metrics.models).reduce(
(acc, model) => acc + model.tokens.total,
0,
),
}; };
const totalRequests = Object.values(metrics.models).reduce(
(acc, model) => acc + model.api.totalRequests,
0,
);
const title = 'Agent powering down. Goodbye!'; const title = 'Agent powering down. Goodbye!';
return ( return (
@ -57,14 +83,18 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
<Box marginTop={1}> <Box marginTop={1}>
<StatsColumn <StatsColumn
title={`Cumulative Stats (${stats.turnCount} Turns)`} title={`Cumulative Stats (${totalRequests} API calls)`}
stats={cumulativeFormatted} stats={cumulativeFormatted}
isCumulative={true} isCumulative={true}
> >
<Box marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
<StatRow <StatRow
label="Total duration (API)" label="Total duration (API)"
value={formatDuration(stats.apiTimeMs)} value={formatDuration(computed.totalApiTime)}
/>
<StatRow
label="Total duration (Tools)"
value={formatDuration(computed.totalToolTime)}
/> />
<StatRow label="Total duration (wall)" value={duration} /> <StatRow label="Total duration (wall)" value={duration} />
</Box> </Box>

View File

@ -5,67 +5,259 @@
*/ */
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js'; import { StatsDisplay } from './StatsDisplay.js';
import { type CumulativeStats } from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
},
});
return render(<StatsDisplay duration="1s" />);
};
describe('<StatsDisplay />', () => { describe('<StatsDisplay />', () => {
const mockStats: CumulativeStats = { it('renders only the Performance section in its zero state', () => {
turnCount: 10, const zeroMetrics: SessionMetrics = {
promptTokenCount: 1000, models: {},
candidatesTokenCount: 2000, tools: {
totalTokenCount: 3500, totalCalls: 0,
cachedContentTokenCount: 500, totalSuccess: 0,
toolUsePromptTokenCount: 200, totalFail: 0,
thoughtsTokenCount: 300, totalDurationMs: 0,
apiTimeMs: 50234, totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
}; };
const mockLastTurnStats: CumulativeStats = { const { lastFrame } = renderWithMockedStats(zeroMetrics);
turnCount: 1, const output = lastFrame();
promptTokenCount: 100,
candidatesTokenCount: 200, expect(output).toContain('Performance');
totalTokenCount: 350, expect(output).not.toContain('Interaction Summary');
cachedContentTokenCount: 50, expect(output).not.toContain('Efficiency & Optimizations');
toolUsePromptTokenCount: 20, expect(output).not.toContain('Model'); // The table header
thoughtsTokenCount: 30, expect(output).toMatchSnapshot();
apiTimeMs: 1234, });
it('renders a table with two models correctly', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
tokens: {
prompt: 1000,
candidates: 2000,
total: 43234,
cached: 500,
thoughts: 100,
tool: 50,
},
},
'gemini-2.5-flash': {
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
tokens: {
prompt: 25000,
candidates: 15000,
total: 150000000,
cached: 10000,
thoughts: 2000,
tool: 1000,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
}; };
const mockDuration = '1h 23m 45s'; const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
it('renders correctly with given stats and duration', () => { expect(output).toContain('gemini-2.5-pro');
const { lastFrame } = render( expect(output).toContain('gemini-2.5-flash');
<StatsDisplay expect(output).toContain('1,000');
stats={mockStats} expect(output).toContain('25,000');
lastTurnStats={mockLastTurnStats} expect(output).toMatchSnapshot();
duration={mockDuration} });
/>,
);
it('renders all sections when all data is present', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
candidates: 100,
total: 250,
cached: 50,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 1, reject: 0, modify: 0 },
},
},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Performance');
expect(output).toContain('Interaction Summary');
expect(output).toContain('User Agreement');
expect(output).toContain('Savings Highlight');
expect(output).toContain('gemini-2.5-pro');
expect(output).toMatchSnapshot();
});
describe('Conditional Rendering Tests', () => {
it('hides User Agreement when no decisions are made', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 0, reject: 0, modify: 0 },
},
},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('Interaction Summary');
expect(output).toContain('Success Rate');
expect(output).not.toContain('User Agreement');
expect(output).toMatchSnapshot();
});
it('hides Efficiency section when cache is not used', () => {
const metrics: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
prompt: 100,
candidates: 100,
total: 200,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).not.toContain('Efficiency & Optimizations');
expect(output).toMatchSnapshot();
});
});
describe('Conditional Color Tests', () => {
it('renders success rate in green for high values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 10,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
}); });
it('renders zero state correctly', () => { it('renders success rate in yellow for medium values', () => {
const zeroStats: CumulativeStats = { const metrics: SessionMetrics = {
turnCount: 0, models: {},
promptTokenCount: 0, tools: {
candidatesTokenCount: 0, totalCalls: 10,
totalTokenCount: 0, totalSuccess: 9,
cachedContentTokenCount: 0, totalFail: 1,
toolUsePromptTokenCount: 0, totalDurationMs: 0,
thoughtsTokenCount: 0, totalDecisions: { accept: 0, reject: 0, modify: 0 },
apiTimeMs: 0, byName: {},
},
}; };
const { lastFrame } = renderWithMockedStats(metrics);
const { lastFrame } = render(
<StatsDisplay
stats={zeroStats}
lastTurnStats={zeroStats}
duration="0s"
/>,
);
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
}); });
it('renders success rate in red for low values', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 5,
totalFail: 5,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
});
}); });

View File

@ -8,90 +8,230 @@ import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { Colors } from '../colors.js'; import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { CumulativeStats } from '../contexts/SessionContext.js'; import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
import { FormattedStats, StatRow, StatsColumn } from './Stats.js'; import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { computeSessionStats } from '../utils/computeStats.js';
// --- Constants --- // A more flexible and powerful StatRow component
interface StatRowProps {
title: string;
children: React.ReactNode; // Use children to allow for complex, colored values
}
const COLUMN_WIDTH = '48%'; const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
<Box>
{/* Fixed width for the label creates a clean "gutter" for alignment */}
<Box width={28}>
<Text color={Colors.LightBlue}>{title}</Text>
</Box>
{children}
</Box>
);
// --- Prop and Data Structures --- // A SubStatRow for indented, secondary information
interface SubStatRowProps {
title: string;
children: React.ReactNode;
}
const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
<Box paddingLeft={2}>
{/* Adjust width for the "» " prefix */}
<Box width={26}>
<Text>» {title}</Text>
</Box>
{children}
</Box>
);
// A Section component to group related stats
interface SectionProps {
title: string;
children: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({ title, children }) => (
<Box flexDirection="column" width="100%" marginBottom={1}>
<Text bold>{title}</Text>
{children}
</Box>
);
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
totalCachedTokens: number;
cacheEfficiency: number;
}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
const nameWidth = 25;
const requestsWidth = 8;
const inputTokensWidth = 15;
const outputTokensWidth = 15;
return (
<Box flexDirection="column" marginTop={1}>
{/* Header */}
<Box>
<Box width={nameWidth}>
<Text bold>Model Usage</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text bold>Reqs</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text bold>Input Tokens</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text bold>Output Tokens</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="round"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth}
></Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
<Box width={nameWidth}>
<Text>{name.replace('-001', '')}</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text>{modelMetrics.api.totalRequests}</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
{modelMetrics.tokens.prompt.toLocaleString()}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>
{modelMetrics.tokens.candidates.toLocaleString()}
</Text>
</Box>
</Box>
))}
{cacheEfficiency > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text>
<Text color={Colors.AccentGreen}>Savings Highlight:</Text>{' '}
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
%) of input tokens were served from the cache, reducing costs.
</Text>
<Box height={1} />
<Text color={Colors.Gray}>
» Tip: For a full token breakdown, run `/stats model`.
</Text>
</Box>
)}
</Box>
);
};
interface StatsDisplayProps { interface StatsDisplayProps {
stats: CumulativeStats;
lastTurnStats: CumulativeStats;
duration: string; duration: string;
} }
// --- Main Component --- export const StatsDisplay: React.FC<StatsDisplayProps> = ({ duration }) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const { models, tools } = metrics;
const computed = computeSessionStats(metrics);
export const StatsDisplay: React.FC<StatsDisplayProps> = ({ const successThresholds = {
stats, green: TOOL_SUCCESS_RATE_HIGH,
lastTurnStats, yellow: TOOL_SUCCESS_RATE_MEDIUM,
duration,
}) => {
const lastTurnFormatted: FormattedStats = {
inputTokens: lastTurnStats.promptTokenCount,
outputTokens: lastTurnStats.candidatesTokenCount,
toolUseTokens: lastTurnStats.toolUsePromptTokenCount,
thoughtsTokens: lastTurnStats.thoughtsTokenCount,
cachedTokens: lastTurnStats.cachedContentTokenCount,
totalTokens: lastTurnStats.totalTokenCount,
}; };
const agreementThresholds = {
const cumulativeFormatted: FormattedStats = { green: USER_AGREEMENT_RATE_HIGH,
inputTokens: stats.promptTokenCount, yellow: USER_AGREEMENT_RATE_MEDIUM,
outputTokens: stats.candidatesTokenCount,
toolUseTokens: stats.toolUsePromptTokenCount,
thoughtsTokens: stats.thoughtsTokenCount,
cachedTokens: stats.cachedContentTokenCount,
totalTokens: stats.totalTokenCount,
}; };
const successColor = getStatusColor(computed.successRate, successThresholds);
const agreementColor = getStatusColor(
computed.agreementRate,
agreementThresholds,
);
return ( return (
<Box <Box
borderStyle="round" borderStyle="round"
borderColor="gray" borderColor={Colors.Gray}
flexDirection="column" flexDirection="column"
paddingY={1} paddingY={1}
paddingX={2} paddingX={2}
> >
<Text bold color={Colors.AccentPurple}> <Text bold color={Colors.AccentPurple}>
Stats Session Stats
</Text> </Text>
<Box height={1} />
<Box flexDirection="row" justifyContent="space-between" marginTop={1}> {tools.totalCalls > 0 && (
<StatsColumn <Section title="Interaction Summary">
title="Last Turn" <StatRow title="Tool Calls:">
stats={lastTurnFormatted} <Text>
width={COLUMN_WIDTH} {tools.totalCalls} ({' '}
/> <Text color={Colors.AccentGreen}> {tools.totalSuccess}</Text>{' '}
<StatsColumn <Text color={Colors.AccentRed}> {tools.totalFail}</Text> )
title={`Cumulative (${stats.turnCount} Turns)`} </Text>
stats={cumulativeFormatted} </StatRow>
isCumulative={true} <StatRow title="Success Rate:">
width={COLUMN_WIDTH} <Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
/> </StatRow>
</Box> {computed.totalDecisions > 0 && (
<StatRow title="User Agreement:">
<Text color={agreementColor}>
{computed.agreementRate.toFixed(1)}%{' '}
<Text color={Colors.Gray}>
({computed.totalDecisions} reviewed)
</Text>
</Text>
</StatRow>
)}
</Section>
)}
<Box flexDirection="row" justifyContent="space-between" marginTop={1}> <Section title="Performance">
{/* Left column for "Last Turn" duration */} <StatRow title="Wall Time:">
<Box width={COLUMN_WIDTH} flexDirection="column"> <Text>{duration}</Text>
<StatRow </StatRow>
label="Turn Duration (API)" <StatRow title="Agent Active:">
value={formatDuration(lastTurnStats.apiTimeMs)} <Text>{formatDuration(computed.agentActiveTime)}</Text>
/> </StatRow>
</Box> <SubStatRow title="API Time:">
<Text>
{formatDuration(computed.totalApiTime)}{' '}
<Text color={Colors.Gray}>
({computed.apiTimePercent.toFixed(1)}%)
</Text>
</Text>
</SubStatRow>
<SubStatRow title="Tool Time:">
<Text>
{formatDuration(computed.totalToolTime)}{' '}
<Text color={Colors.Gray}>
({computed.toolTimePercent.toFixed(1)}%)
</Text>
</Text>
</SubStatRow>
</Section>
{/* Right column for "Cumulative" durations */} {Object.keys(models).length > 0 && (
<Box width={COLUMN_WIDTH} flexDirection="column"> <ModelUsageTable
<StatRow models={models}
label="Total duration (API)" totalCachedTokens={computed.totalCachedTokens}
value={formatDuration(stats.apiTimeMs)} cacheEfficiency={computed.cacheEfficiency}
/> />
<StatRow label="Total duration (wall)" value={duration} /> )}
</Box>
</Box>
</Box> </Box>
); );
}; };

View File

@ -0,0 +1,176 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
return {
...actual,
useSessionStats: vi.fn(),
};
});
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
},
});
return render(<ToolStatsDisplay />);
};
describe('<ToolStatsDisplay />', () => {
it('should render "no tool calls" message when there are no active tools', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
});
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
expect(lastFrame()).toMatchSnapshot();
});
it('should display stats for a single tool correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 1, reject: 0, modify: 0 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toMatchSnapshot();
});
it('should display stats for multiple tools correctly', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 3,
totalSuccess: 2,
totalFail: 1,
totalDurationMs: 300,
totalDecisions: { accept: 1, reject: 1, modify: 1 },
byName: {
'tool-a': {
count: 2,
success: 1,
fail: 1,
durationMs: 200,
decisions: { accept: 1, reject: 1, modify: 0 },
},
'tool-b': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 1 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('tool-a');
expect(output).toContain('tool-b');
expect(output).toMatchSnapshot();
});
it('should handle large values without wrapping or overlapping', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 999999999,
totalSuccess: 888888888,
totalFail: 111111111,
totalDurationMs: 987654321,
totalDecisions: {
accept: 123456789,
reject: 98765432,
modify: 12345,
},
byName: {
'long-named-tool-for-testing-wrapping-and-such': {
count: 999999999,
success: 888888888,
fail: 111111111,
durationMs: 987654321,
decisions: {
accept: 123456789,
reject: 98765432,
modify: 12345,
},
},
},
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('should handle zero decisions gracefully', () => {
const { lastFrame } = renderWithMockedStats({
models: {},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 0 },
},
},
},
});
const output = lastFrame();
expect(output).toContain('Total Reviewed Suggestions:');
expect(output).toContain('0');
expect(output).toContain('Overall Agreement Rate:');
expect(output).toContain('--');
expect(output).toMatchSnapshot();
});
});

View File

@ -0,0 +1,208 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { ToolCallStats } from '@google/gemini-cli-core';
const TOOL_NAME_COL_WIDTH = 25;
const CALLS_COL_WIDTH = 8;
const SUCCESS_RATE_COL_WIDTH = 15;
const AVG_DURATION_COL_WIDTH = 15;
const StatRow: React.FC<{
name: string;
stats: ToolCallStats;
}> = ({ name, stats }) => {
const successRate = stats.count > 0 ? (stats.success / stats.count) * 100 : 0;
const avgDuration = stats.count > 0 ? stats.durationMs / stats.count : 0;
const successColor = getStatusColor(successRate, {
green: TOOL_SUCCESS_RATE_HIGH,
yellow: TOOL_SUCCESS_RATE_MEDIUM,
});
return (
<Box>
<Box width={TOOL_NAME_COL_WIDTH}>
<Text color={Colors.LightBlue}>{name}</Text>
</Box>
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
<Text>{stats.count}</Text>
</Box>
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
<Text color={successColor}>{successRate.toFixed(1)}%</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text>{formatDuration(avgDuration)}</Text>
</Box>
</Box>
);
};
export const ToolStatsDisplay: React.FC = () => {
const { stats } = useSessionStats();
const { tools } = stats.metrics;
const activeTools = Object.entries(tools.byName).filter(
([, metrics]) => metrics.count > 0,
);
if (activeTools.length === 0) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
paddingY={1}
paddingX={2}
>
<Text>No tool calls have been made in this session.</Text>
</Box>
);
}
const totalDecisions = Object.values(tools.byName).reduce(
(acc, tool) => {
acc.accept += tool.decisions.accept;
acc.reject += tool.decisions.reject;
acc.modify += tool.decisions.modify;
return acc;
},
{ accept: 0, reject: 0, modify: 0 },
);
const totalReviewed =
totalDecisions.accept + totalDecisions.reject + totalDecisions.modify;
const agreementRate =
totalReviewed > 0 ? (totalDecisions.accept / totalReviewed) * 100 : 0;
const agreementColor = getStatusColor(agreementRate, {
green: USER_AGREEMENT_RATE_HIGH,
yellow: USER_AGREEMENT_RATE_MEDIUM,
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
paddingY={1}
paddingX={2}
width={70}
>
<Text bold color={Colors.AccentPurple}>
Tool Stats For Nerds
</Text>
<Box height={1} />
{/* Header */}
<Box>
<Box width={TOOL_NAME_COL_WIDTH}>
<Text bold>Tool Name</Text>
</Box>
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
<Text bold>Calls</Text>
</Box>
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
<Text bold>Success Rate</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text bold>Avg Duration</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width="100%"
/>
{/* Tool Rows */}
{activeTools.map(([name, stats]) => (
<StatRow key={name} name={name} stats={stats as ToolCallStats} />
))}
<Box height={1} />
{/* User Decision Summary */}
<Text bold>User Decision Summary</Text>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text color={Colors.LightBlue}>Total Reviewed Suggestions:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text>{totalReviewed}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Accepted:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentGreen}>{totalDecisions.accept}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Rejected:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentRed}>{totalDecisions.reject}</Text>
</Box>
</Box>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> » Modified:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text color={Colors.AccentYellow}>{totalDecisions.modify}</Text>
</Box>
</Box>
{/* Divider */}
<Box
borderStyle="single"
borderBottom={true}
borderTop={false}
borderLeft={false}
borderRight={false}
width="100%"
/>
<Box>
<Box
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
>
<Text> Overall Agreement Rate:</Text>
</Box>
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
<Text bold color={totalReviewed > 0 ? agreementColor : undefined}>
{totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'}
</Text>
</Box>
</Box>
</Box>
);
};

View File

@ -0,0 +1,121 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Cached 5 (50.0%) │
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display conditional rows if at least one model has data 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ 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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ 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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 999,999,999 │
│ Errors 123,456,789 (12.3%) │
│ Avg Latency 0ms │
│ │
│ Tokens │
│ Total 999,999,999 │
│ ↳ Prompt 987,654,321 │
│ ↳ Cached 123,456,789 (12.5%) │
│ ↳ Thoughts 111,111,111 │
│ ↳ Tool 222,222,222 │
│ ↳ Output 123,456,789 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ │
│ Tokens │
│ Total 30 │
│ ↳ Prompt 10 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should render "no API calls" message when there are no active models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No API calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@ -1,43 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SessionSummaryDisplay /> > renders correctly with given stats and duration 1`] = ` exports[`<SessionSummaryDisplay /> > correctly sums and displays stats from multiple models 1`] = `
"╭─────────────────────────────────────╮ "╭─────────────────────────────────────╮
│ │ │ │
│ Agent powering down. Goodbye! │ │ Agent powering down. Goodbye! │
│ │ │ │
│ │ │ │
│ Cumulative Stats (10 Turns) │ Cumulative Stats (15 API calls)
│ │ │ │
│ Input Tokens 1,000 │ │ Input Tokens 1,500 │
│ Output Tokens 2,000 │ │ Output Tokens 3,000 │
│ Tool Use Tokens 200 │ │ Tool Use Tokens 220 │
│ Thoughts Tokens 300 │ │ Thoughts Tokens 350 │
│ Cached Tokens 500 (14.3%) │ │ Cached Tokens 600 (12.0%) │
│ ───────────────────────────────── │ │ ───────────────────────────────── │
│ Total Tokens 3,500 │ │ Total Tokens 5,000 │
│ │ │ │
│ Total duration (API) 50.2s │ │ Total duration (API) 1m 2s │
│ Total duration (Tools) 0s │
│ Total duration (wall) 1h 23m 45s │ │ Total duration (wall) 1h 23m 45s │
│ │ │ │
╰─────────────────────────────────────╯" ╰─────────────────────────────────────╯"
`; `;
exports[`<SessionSummaryDisplay /> > renders zero state correctly 1`] = ` exports[`<SessionSummaryDisplay /> > renders zero state correctly 1`] = `
"╭─────────────────────────────────╮ "╭─────────────────────────────────────
│ │ │ │
│ Agent powering down. Goodbye! │ │ Agent powering down. Goodbye! │
│ │ │ │
│ │ │ │
│ Cumulative Stats (0 Turns) │ │ Cumulative Stats (0 API calls) │
│ │ │ │
│ Input Tokens 0 │ │ Input Tokens 0 │
│ Output Tokens 0 │ │ Output Tokens 0 │
│ Thoughts Tokens 0 │ │ Thoughts Tokens 0 │
│ ────────────────────────── │ ─────────────────────────────────
│ Total Tokens 0 │ │ Total Tokens 0 │
│ │ │ │
│ Total duration (API) 0s │ │ Total duration (API) 0s │
│ Total duration (wall) 0s │ │ Total duration (Tools) 0s │
│ Total duration (wall) 1h 23m 45s │
│ │ │ │
╰─────────────────────────────────╯" ╰─────────────────────────────────────╯"
`; `;

View File

@ -1,41 +1,163 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<StatsDisplay /> > renders correctly with given stats and duration 1`] = ` exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in green for high values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │ │ │
│ Stats │ Session Stats │
│ │ │ │
│ Last Turn Cumulative (10 Turns) │ │ Interaction Summary │
│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
│ Success Rate: 100.0% │
│ │ │ │
│ Input Tokens 100 Input Tokens 1,000 │ │ Performance │
│ Output Tokens 200 Output Tokens 2,000 │ │ Wall Time: 1s │
│ Tool Use Tokens 20 Tool Use Tokens 200 │ │ Agent Active: 0s │
│ Thoughts Tokens 30 Thoughts Tokens 300 │ │ » API Time: 0s (0.0%) │
│ Cached Tokens 50 Cached Tokens 500 (14.3%) │ │ » Tool Time: 0s (0.0%) │
│ ───────────────────────────────────────────── ───────────────────────────────────────────── │
│ Total Tokens 350 Total Tokens 3,500 │
│ │ │ │
│ Turn Duration (API) 1.2s Total duration (API) 50.2s │
│ Total duration (wall) 1h 23m 45s │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<StatsDisplay /> > renders zero state correctly 1`] = ` exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in red for low values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │ │ │
│ Stats │ Session Stats │
│ │ │ │
│ Last Turn Cumulative (0 Turns) │ │ Interaction Summary │
│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
│ Success Rate: 50.0% │
│ │ │ │
│ Input Tokens 0 Input Tokens 0 │ │ Performance │
│ Output Tokens 0 Output Tokens 0 │ │ Wall Time: 1s │
│ Thoughts Tokens 0 Thoughts Tokens 0 │ │ Agent Active: 0s │
│ ───────────────────────────────────────────── ───────────────────────────────────────────── │ │ » API Time: 0s (0.0%) │
│ Total Tokens 0 Total Tokens 0 │ │ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in yellow for medium values 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
│ Success Rate: 90.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 100ms │
│ » API Time: 100ms (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Output Tokens │
│ ─────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 123ms │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 123ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 19.5s │
│ » API Time: 19.5s (100.0%) │
│ » 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 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders all sections when all data is present 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
│ Success Rate: 50.0% │
│ User Agreement: 100.0% (1 reviewed) │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 223ms │
│ » API Time: 100ms (44.8%) │
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ 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. │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > renders only the Performance section in its zero state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │ │ │
│ Turn Duration (API) 0s Total duration (API) 0s │
│ Total duration (wall) 0s │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`; `;

View File

@ -0,0 +1,91 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 1 │
│ » Accepted: 1 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 100.0% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ tool-a 2 50.0% 100ms │
│ tool-b 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 3 │
│ » Accepted: 1 │
│ » Rejected: 1 │
│ » Modified: 1 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 33.3% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ long-named-tool-for-testi99999999 88.9% 1ms │
│ ng-wrapping-and-such 9 │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 222234566 │
│ » Accepted: 123456789 │
│ » Rejected: 98765432 │
│ » Modified: 12345 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 55.6% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
"╭────────────────────────────────────────────────────────────────────╮
│ │
│ Tool Stats For Nerds │
│ │
│ Tool Name Calls Success Rate Avg Duration │
│ ──────────────────────────────────────────────────────────────── │
│ test-tool 1 100.0% 100ms │
│ │
│ User Decision Summary │
│ Total Reviewed Suggestions: 0 │
│ » Accepted: 0 │
│ » Rejected: 0 │
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: -- │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No tool calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@ -8,28 +8,13 @@ import { type MutableRefObject } from 'react';
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { SessionStatsProvider, useSessionStats } from './SessionContext.js'; import {
SessionStatsProvider,
useSessionStats,
SessionMetrics,
} from './SessionContext.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { GenerateContentResponseUsageMetadata } from '@google/genai'; import { uiTelemetryService } from '@google/gemini-cli-core';
// Mock data that simulates what the Gemini API would return.
const mockMetadata1: GenerateContentResponseUsageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 200,
totalTokenCount: 300,
cachedContentTokenCount: 50,
toolUsePromptTokenCount: 10,
thoughtsTokenCount: 20,
};
const mockMetadata2: GenerateContentResponseUsageMetadata = {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
cachedContentTokenCount: 5,
toolUsePromptTokenCount: 1,
thoughtsTokenCount: 2,
};
/** /**
* A test harness component that uses the hook and exposes the context value * A test harness component that uses the hook and exposes the context value
@ -60,13 +45,11 @@ describe('SessionStatsContext', () => {
const stats = contextRef.current?.stats; const stats = contextRef.current?.stats;
expect(stats?.sessionStartTime).toBeInstanceOf(Date); expect(stats?.sessionStartTime).toBeInstanceOf(Date);
expect(stats?.currentTurn).toBeDefined(); expect(stats?.metrics).toBeDefined();
expect(stats?.cumulative.turnCount).toBe(0); expect(stats?.metrics.models).toEqual({});
expect(stats?.cumulative.totalTokenCount).toBe(0);
expect(stats?.cumulative.promptTokenCount).toBe(0);
}); });
it('should increment turnCount when startNewTurn is called', () => { it('should update metrics when the uiTelemetryService emits an update', () => {
const contextRef: MutableRefObject< const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined ReturnType<typeof useSessionStats> | undefined
> = { current: undefined }; > = { current: undefined };
@ -77,150 +60,60 @@ describe('SessionStatsContext', () => {
</SessionStatsProvider>, </SessionStatsProvider>,
); );
const newMetrics: SessionMetrics = {
models: {
'gemini-pro': {
api: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 123,
},
tokens: {
prompt: 100,
candidates: 200,
total: 300,
cached: 50,
thoughts: 20,
tool: 10,
},
},
},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 456,
totalDecisions: {
accept: 1,
reject: 0,
modify: 0,
},
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 456,
decisions: {
accept: 1,
reject: 0,
modify: 0,
},
},
},
},
};
act(() => { act(() => {
contextRef.current?.startNewTurn(); uiTelemetryService.emit('update', {
metrics: newMetrics,
lastPromptTokenCount: 100,
});
}); });
const stats = contextRef.current?.stats; const stats = contextRef.current?.stats;
expect(stats?.currentTurn.totalTokenCount).toBe(0); expect(stats?.metrics).toEqual(newMetrics);
expect(stats?.cumulative.turnCount).toBe(1); expect(stats?.lastPromptTokenCount).toBe(100);
// Ensure token counts are unaffected
expect(stats?.cumulative.totalTokenCount).toBe(0);
});
it('should aggregate token usage correctly when addUsage is called', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
act(() => {
contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 123 });
});
const stats = contextRef.current?.stats;
// Check that token counts are updated
expect(stats?.cumulative.totalTokenCount).toBe(
mockMetadata1.totalTokenCount ?? 0,
);
expect(stats?.cumulative.promptTokenCount).toBe(
mockMetadata1.promptTokenCount ?? 0,
);
expect(stats?.cumulative.apiTimeMs).toBe(123);
// Check that turn count is NOT incremented
expect(stats?.cumulative.turnCount).toBe(0);
// Check that currentTurn is updated
expect(stats?.currentTurn?.totalTokenCount).toEqual(
mockMetadata1.totalTokenCount,
);
expect(stats?.currentTurn?.apiTimeMs).toBe(123);
});
it('should correctly track a full logical turn with multiple API calls', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
// 1. User starts a new turn
act(() => {
contextRef.current?.startNewTurn();
});
// 2. First API call (e.g., prompt with a tool request)
act(() => {
contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 100 });
});
// 3. Second API call (e.g., sending tool response back)
act(() => {
contextRef.current?.addUsage({ ...mockMetadata2, apiTimeMs: 50 });
});
const stats = contextRef.current?.stats;
// Turn count should only be 1
expect(stats?.cumulative.turnCount).toBe(1);
// --- Check Cumulative Stats ---
// These fields should be the SUM of both calls
expect(stats?.cumulative.totalTokenCount).toBe(300 + 30);
expect(stats?.cumulative.candidatesTokenCount).toBe(200 + 20);
expect(stats?.cumulative.thoughtsTokenCount).toBe(20 + 2);
expect(stats?.cumulative.apiTimeMs).toBe(100 + 50);
// These fields should be the SUM of both calls
expect(stats?.cumulative.promptTokenCount).toBe(100 + 10);
expect(stats?.cumulative.cachedContentTokenCount).toBe(50 + 5);
expect(stats?.cumulative.toolUsePromptTokenCount).toBe(10 + 1);
// --- Check Current Turn Stats ---
// All fields should be the SUM of both calls for the turn
expect(stats?.currentTurn.totalTokenCount).toBe(300 + 30);
expect(stats?.currentTurn.candidatesTokenCount).toBe(200 + 20);
expect(stats?.currentTurn.thoughtsTokenCount).toBe(20 + 2);
expect(stats?.currentTurn.promptTokenCount).toBe(100 + 10);
expect(stats?.currentTurn.cachedContentTokenCount).toBe(50 + 5);
expect(stats?.currentTurn.toolUsePromptTokenCount).toBe(10 + 1);
expect(stats?.currentTurn.apiTimeMs).toBe(100 + 50);
});
it('should overwrite currentResponse with each API call', () => {
const contextRef: MutableRefObject<
ReturnType<typeof useSessionStats> | undefined
> = { current: undefined };
render(
<SessionStatsProvider>
<TestHarness contextRef={contextRef} />
</SessionStatsProvider>,
);
// 1. First API call
act(() => {
contextRef.current?.addUsage({ ...mockMetadata1, apiTimeMs: 100 });
});
let stats = contextRef.current?.stats;
// currentResponse should match the first call
expect(stats?.currentResponse.totalTokenCount).toBe(300);
expect(stats?.currentResponse.apiTimeMs).toBe(100);
// 2. Second API call
act(() => {
contextRef.current?.addUsage({ ...mockMetadata2, apiTimeMs: 50 });
});
stats = contextRef.current?.stats;
// currentResponse should now match the second call
expect(stats?.currentResponse.totalTokenCount).toBe(30);
expect(stats?.currentResponse.apiTimeMs).toBe(50);
// 3. Start a new turn
act(() => {
contextRef.current?.startNewTurn();
});
stats = contextRef.current?.stats;
// currentResponse should be reset
expect(stats?.currentResponse.totalTokenCount).toBe(0);
expect(stats?.currentResponse.apiTimeMs).toBe(0);
}); });
it('should throw an error when useSessionStats is used outside of a provider', () => { it('should throw an error when useSessionStats is used outside of a provider', () => {

View File

@ -9,39 +9,43 @@ import React, {
useContext, useContext,
useState, useState,
useMemo, useMemo,
useCallback, useEffect,
} from 'react'; } from 'react';
import { type GenerateContentResponseUsageMetadata } from '@google/genai'; import {
uiTelemetryService,
SessionMetrics,
ModelMetrics,
} from '@google/gemini-cli-core';
// --- Interface Definitions --- // --- Interface Definitions ---
export interface CumulativeStats { export type { SessionMetrics, ModelMetrics };
turnCount: number;
promptTokenCount: number;
candidatesTokenCount: number;
totalTokenCount: number;
cachedContentTokenCount: number;
toolUsePromptTokenCount: number;
thoughtsTokenCount: number;
apiTimeMs: number;
}
interface SessionStatsState { interface SessionStatsState {
sessionStartTime: Date; sessionStartTime: Date;
cumulative: CumulativeStats; metrics: SessionMetrics;
currentTurn: CumulativeStats; lastPromptTokenCount: number;
currentResponse: CumulativeStats; }
export interface ComputedSessionStats {
totalApiTime: number;
totalToolTime: number;
agentActiveTime: number;
apiTimePercent: number;
toolTimePercent: number;
cacheEfficiency: number;
totalDecisions: number;
successRate: number;
agreementRate: number;
totalCachedTokens: number;
totalPromptTokens: number;
} }
// Defines the final "value" of our context, including the state // Defines the final "value" of our context, including the state
// and the functions to update it. // and the functions to update it.
interface SessionStatsContextValue { interface SessionStatsContextValue {
stats: SessionStatsState; stats: SessionStatsState;
startNewTurn: () => void;
addUsage: (
metadata: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
) => void;
} }
// --- Context Definition --- // --- Context Definition ---
@ -50,27 +54,6 @@ const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
undefined, undefined,
); );
// --- Helper Functions ---
/**
* A small, reusable helper function to sum token counts.
* It unconditionally adds all token values from the source to the target.
* @param target The object to add the tokens to (e.g., cumulative, currentTurn).
* @param source The metadata object from the API response.
*/
const addTokens = (
target: CumulativeStats,
source: GenerateContentResponseUsageMetadata & { apiTimeMs?: number },
) => {
target.candidatesTokenCount += source.candidatesTokenCount ?? 0;
target.thoughtsTokenCount += source.thoughtsTokenCount ?? 0;
target.totalTokenCount += source.totalTokenCount ?? 0;
target.apiTimeMs += source.apiTimeMs ?? 0;
target.promptTokenCount += source.promptTokenCount ?? 0;
target.cachedContentTokenCount += source.cachedContentTokenCount ?? 0;
target.toolUsePromptTokenCount += source.toolUsePromptTokenCount ?? 0;
};
// --- Provider Component --- // --- Provider Component ---
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
@ -78,110 +61,42 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
}) => { }) => {
const [stats, setStats] = useState<SessionStatsState>({ const [stats, setStats] = useState<SessionStatsState>({
sessionStartTime: new Date(), sessionStartTime: new Date(),
cumulative: { metrics: uiTelemetryService.getMetrics(),
turnCount: 0, lastPromptTokenCount: 0,
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
apiTimeMs: 0,
},
currentTurn: {
turnCount: 0,
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
apiTimeMs: 0,
},
currentResponse: {
turnCount: 0,
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
apiTimeMs: 0,
},
}); });
// A single, internal worker function to handle all metadata aggregation. useEffect(() => {
const aggregateTokens = useCallback( const handleUpdate = ({
( metrics,
metadata: GenerateContentResponseUsageMetadata & { apiTimeMs?: number }, lastPromptTokenCount,
) => { }: {
setStats((prevState) => { metrics: SessionMetrics;
const newCumulative = { ...prevState.cumulative }; lastPromptTokenCount: number;
const newCurrentTurn = { ...prevState.currentTurn }; }) => {
const newCurrentResponse = {
turnCount: 0,
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
apiTimeMs: 0,
};
// Add all tokens to the current turn's stats as well as cumulative stats.
addTokens(newCurrentTurn, metadata);
addTokens(newCumulative, metadata);
addTokens(newCurrentResponse, metadata);
return {
...prevState,
cumulative: newCumulative,
currentTurn: newCurrentTurn,
currentResponse: newCurrentResponse,
};
});
},
[],
);
const startNewTurn = useCallback(() => {
setStats((prevState) => ({ setStats((prevState) => ({
...prevState, ...prevState,
cumulative: { metrics,
...prevState.cumulative, lastPromptTokenCount,
turnCount: prevState.cumulative.turnCount + 1,
},
currentTurn: {
turnCount: 0, // Reset for the new turn's accumulation.
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
apiTimeMs: 0,
},
currentResponse: {
turnCount: 0,
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
apiTimeMs: 0,
},
})); }));
};
uiTelemetryService.on('update', handleUpdate);
// Set initial state
handleUpdate({
metrics: uiTelemetryService.getMetrics(),
lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
return () => {
uiTelemetryService.off('update', handleUpdate);
};
}, []); }, []);
const value = useMemo( const value = useMemo(
() => ({ () => ({
stats, stats,
startNewTurn,
addUsage: aggregateTokens,
}), }),
[stats, startNewTurn, aggregateTokens], [stats],
); );
return ( return (

View File

@ -296,19 +296,9 @@ describe('useSlashCommandProcessor', () => {
describe('/stats command', () => { describe('/stats command', () => {
it('should show detailed session statistics', async () => { it('should show detailed session statistics', async () => {
// Arrange // Arrange
const cumulativeStats = {
totalTokenCount: 900,
promptTokenCount: 200,
candidatesTokenCount: 400,
cachedContentTokenCount: 100,
turnCount: 1,
toolUsePromptTokenCount: 50,
thoughtsTokenCount: 150,
};
mockUseSessionStats.mockReturnValue({ mockUseSessionStats.mockReturnValue({
stats: { stats: {
sessionStartTime: new Date('2025-01-01T00:00:00.000Z'), sessionStartTime: new Date('2025-01-01T00:00:00.000Z'),
cumulative: cumulativeStats,
}, },
}); });
@ -326,7 +316,6 @@ describe('useSlashCommandProcessor', () => {
2, // Called after the user message 2, // Called after the user message
expect.objectContaining({ expect.objectContaining({
type: MessageType.STATS, type: MessageType.STATS,
stats: cumulativeStats,
duration: '1h 2m 3s', duration: '1h 2m 3s',
}), }),
expect.any(Number), expect.any(Number),
@ -334,6 +323,44 @@ describe('useSlashCommandProcessor', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('should show model-specific statistics when using /stats model', async () => {
// Arrange
const { handleSlashCommand } = getProcessor();
// Act
await act(async () => {
handleSlashCommand('/stats model');
});
// Assert
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Called after the user message
expect.objectContaining({
type: MessageType.MODEL_STATS,
}),
expect.any(Number),
);
});
it('should show tool-specific statistics when using /stats tools', async () => {
// Arrange
const { handleSlashCommand } = getProcessor();
// Act
await act(async () => {
handleSlashCommand('/stats tools');
});
// Assert
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Called after the user message
expect.objectContaining({
type: MessageType.TOOL_STATS,
}),
expect.any(Number),
);
});
}); });
describe('/about command', () => { describe('/about command', () => {
@ -598,7 +625,6 @@ describe('useSlashCommandProcessor', () => {
}, },
{ {
type: 'quit', type: 'quit',
stats: expect.any(Object),
duration: '1h 2m 3s', duration: '1h 2m 3s',
id: expect.any(Number), id: expect.any(Number),
}, },

View File

@ -110,14 +110,19 @@ export const useSlashCommandProcessor = (
} else if (message.type === MessageType.STATS) { } else if (message.type === MessageType.STATS) {
historyItemContent = { historyItemContent = {
type: 'stats', type: 'stats',
stats: message.stats,
lastTurnStats: message.lastTurnStats,
duration: message.duration, duration: message.duration,
}; };
} else if (message.type === MessageType.MODEL_STATS) {
historyItemContent = {
type: 'model_stats',
};
} else if (message.type === MessageType.TOOL_STATS) {
historyItemContent = {
type: 'tool_stats',
};
} else if (message.type === MessageType.QUIT) { } else if (message.type === MessageType.QUIT) {
historyItemContent = { historyItemContent = {
type: 'quit', type: 'quit',
stats: message.stats,
duration: message.duration, duration: message.duration,
}; };
} else if (message.type === MessageType.COMPRESSION) { } else if (message.type === MessageType.COMPRESSION) {
@ -262,16 +267,28 @@ export const useSlashCommandProcessor = (
{ {
name: 'stats', name: 'stats',
altName: 'usage', altName: 'usage',
description: 'check session stats', description: 'check session stats. Usage: /stats [model|tools]',
action: (_mainCommand, _subCommand, _args) => { action: (_mainCommand, subCommand, _args) => {
if (subCommand === 'model') {
addMessage({
type: MessageType.MODEL_STATS,
timestamp: new Date(),
});
return;
} else if (subCommand === 'tools') {
addMessage({
type: MessageType.TOOL_STATS,
timestamp: new Date(),
});
return;
}
const now = new Date(); const now = new Date();
const { sessionStartTime, cumulative, currentTurn } = session.stats; const { sessionStartTime } = session.stats;
const wallDuration = now.getTime() - sessionStartTime.getTime(); const wallDuration = now.getTime() - sessionStartTime.getTime();
addMessage({ addMessage({
type: MessageType.STATS, type: MessageType.STATS,
stats: cumulative,
lastTurnStats: currentTurn,
duration: formatDuration(wallDuration), duration: formatDuration(wallDuration),
timestamp: new Date(), timestamp: new Date(),
}); });
@ -805,7 +822,7 @@ export const useSlashCommandProcessor = (
description: 'exit the cli', description: 'exit the cli',
action: async (mainCommand, _subCommand, _args) => { action: async (mainCommand, _subCommand, _args) => {
const now = new Date(); const now = new Date();
const { sessionStartTime, cumulative } = session.stats; const { sessionStartTime } = session.stats;
const wallDuration = now.getTime() - sessionStartTime.getTime(); const wallDuration = now.getTime() - sessionStartTime.getTime();
setQuittingMessages([ setQuittingMessages([
@ -816,7 +833,6 @@ export const useSlashCommandProcessor = (
}, },
{ {
type: 'quit', type: 'quit',
stats: cumulative,
duration: formatDuration(wallDuration), duration: formatDuration(wallDuration),
id: now.getTime(), id: now.getTime(),
}, },

View File

@ -604,78 +604,6 @@ describe('useGeminiStream', () => {
}); });
}); });
describe('Session Stats Integration', () => {
it('should call startNewTurn and addUsage for a simple prompt', async () => {
const mockMetadata = { totalTokenCount: 123 };
const mockStream = (async function* () {
yield { type: 'content', value: 'Response' };
yield { type: 'usage_metadata', value: mockMetadata };
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('Hello, world!');
});
expect(mockStartNewTurn).toHaveBeenCalledTimes(1);
expect(mockAddUsage).toHaveBeenCalledTimes(1);
expect(mockAddUsage).toHaveBeenCalledWith(mockMetadata);
});
it('should only call addUsage for a tool continuation prompt', async () => {
const mockMetadata = { totalTokenCount: 456 };
const mockStream = (async function* () {
yield { type: 'content', value: 'Final Answer' };
yield { type: 'usage_metadata', value: mockMetadata };
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery([{ text: 'tool response' }], {
isContinuation: true,
});
});
expect(mockStartNewTurn).not.toHaveBeenCalled();
expect(mockAddUsage).toHaveBeenCalledTimes(1);
expect(mockAddUsage).toHaveBeenCalledWith(mockMetadata);
});
it('should not call addUsage if the stream contains no usage metadata', async () => {
// Arrange: A stream that yields content but never a usage_metadata event
const mockStream = (async function* () {
yield { type: 'content', value: 'Some response text' };
})();
mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('Query with no usage data');
});
expect(mockStartNewTurn).toHaveBeenCalledTimes(1);
expect(mockAddUsage).not.toHaveBeenCalled();
});
it('should not call startNewTurn for a slash command', async () => {
mockHandleSlashCommand.mockReturnValue(true);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('/stats');
});
expect(mockStartNewTurn).not.toHaveBeenCalled();
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
});
it('should not flicker streaming state to Idle between tool completion and submission', async () => { it('should not flicker streaming state to Idle between tool completion and submission', async () => {
const toolCallResponseParts: PartListUnion = [ const toolCallResponseParts: PartListUnion = [
{ text: 'tool 1 final response' }, { text: 'tool 1 final response' },

View File

@ -51,7 +51,6 @@ import {
TrackedCompletedToolCall, TrackedCompletedToolCall,
TrackedCancelledToolCall, TrackedCancelledToolCall,
} from './useReactToolScheduler.js'; } from './useReactToolScheduler.js';
import { useSessionStats } from '../contexts/SessionContext.js';
export function mergePartListUnions(list: PartListUnion[]): PartListUnion { export function mergePartListUnions(list: PartListUnion[]): PartListUnion {
const resultParts: PartListUnion = []; const resultParts: PartListUnion = [];
@ -101,7 +100,6 @@ export const useGeminiStream = (
useStateAndRef<HistoryItemWithoutId | null>(null); useStateAndRef<HistoryItemWithoutId | null>(null);
const processedMemoryToolsRef = useRef<Set<string>>(new Set()); const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const logger = useLogger(); const logger = useLogger();
const { startNewTurn, addUsage } = useSessionStats();
const gitService = useMemo(() => { const gitService = useMemo(() => {
if (!config.getProjectRoot()) { if (!config.getProjectRoot()) {
return; return;
@ -461,9 +459,6 @@ export const useGeminiStream = (
case ServerGeminiEventType.ChatCompressed: case ServerGeminiEventType.ChatCompressed:
handleChatCompressionEvent(event.value); handleChatCompressionEvent(event.value);
break; break;
case ServerGeminiEventType.UsageMetadata:
addUsage(event.value);
break;
case ServerGeminiEventType.ToolCallConfirmation: case ServerGeminiEventType.ToolCallConfirmation:
case ServerGeminiEventType.ToolCallResponse: case ServerGeminiEventType.ToolCallResponse:
// do nothing // do nothing
@ -486,7 +481,6 @@ export const useGeminiStream = (
handleErrorEvent, handleErrorEvent,
scheduleToolCalls, scheduleToolCalls,
handleChatCompressionEvent, handleChatCompressionEvent,
addUsage,
], ],
); );
@ -516,10 +510,6 @@ export const useGeminiStream = (
return; return;
} }
if (!options?.isContinuation) {
startNewTurn();
}
setIsResponding(true); setIsResponding(true);
setInitError(null); setInitError(null);
@ -568,7 +558,6 @@ export const useGeminiStream = (
setPendingHistoryItem, setPendingHistoryItem,
setInitError, setInitError,
geminiClient, geminiClient,
startNewTurn,
onAuthError, onAuthError,
config, config,
], ],

View File

@ -8,7 +8,6 @@ import {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolResultDisplay, ToolResultDisplay,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { CumulativeStats } from './contexts/SessionContext.js';
// Only defining the state enum needed by the UI // Only defining the state enum needed by the UI
export enum StreamingState { export enum StreamingState {
@ -100,14 +99,19 @@ export type HistoryItemAbout = HistoryItemBase & {
export type HistoryItemStats = HistoryItemBase & { export type HistoryItemStats = HistoryItemBase & {
type: 'stats'; type: 'stats';
stats: CumulativeStats;
lastTurnStats: CumulativeStats;
duration: string; duration: string;
}; };
export type HistoryItemModelStats = HistoryItemBase & {
type: 'model_stats';
};
export type HistoryItemToolStats = HistoryItemBase & {
type: 'tool_stats';
};
export type HistoryItemQuit = HistoryItemBase & { export type HistoryItemQuit = HistoryItemBase & {
type: 'quit'; type: 'quit';
stats: CumulativeStats;
duration: string; duration: string;
}; };
@ -140,6 +144,8 @@ export type HistoryItemWithoutId =
| HistoryItemAbout | HistoryItemAbout
| HistoryItemToolGroup | HistoryItemToolGroup
| HistoryItemStats | HistoryItemStats
| HistoryItemModelStats
| HistoryItemToolStats
| HistoryItemQuit | HistoryItemQuit
| HistoryItemCompression; | HistoryItemCompression;
@ -152,6 +158,8 @@ export enum MessageType {
USER = 'user', USER = 'user',
ABOUT = 'about', ABOUT = 'about',
STATS = 'stats', STATS = 'stats',
MODEL_STATS = 'model_stats',
TOOL_STATS = 'tool_stats',
QUIT = 'quit', QUIT = 'quit',
GEMINI = 'gemini', GEMINI = 'gemini',
COMPRESSION = 'compression', COMPRESSION = 'compression',
@ -178,15 +186,22 @@ export type Message =
| { | {
type: MessageType.STATS; type: MessageType.STATS;
timestamp: Date; timestamp: Date;
stats: CumulativeStats;
lastTurnStats: CumulativeStats;
duration: string; duration: string;
content?: string; content?: string;
} }
| {
type: MessageType.MODEL_STATS;
timestamp: Date;
content?: string;
}
| {
type: MessageType.TOOL_STATS;
timestamp: Date;
content?: string;
}
| { | {
type: MessageType.QUIT; type: MessageType.QUIT;
timestamp: Date; timestamp: Date;
stats: CumulativeStats;
duration: string; duration: string;
content?: string; content?: string;
} }

View File

@ -0,0 +1,247 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
calculateAverageLatency,
calculateCacheHitRate,
calculateErrorRate,
computeSessionStats,
} from './computeStats.js';
import { ModelMetrics, SessionMetrics } from '../contexts/SessionContext.js';
describe('calculateErrorRate', () => {
it('should return 0 if totalRequests is 0', () => {
const metrics: ModelMetrics = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
};
expect(calculateErrorRate(metrics)).toBe(0);
});
it('should calculate the error rate correctly', () => {
const metrics: ModelMetrics = {
api: { totalRequests: 10, totalErrors: 2, totalLatencyMs: 0 },
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
};
expect(calculateErrorRate(metrics)).toBe(20);
});
});
describe('calculateAverageLatency', () => {
it('should return 0 if totalRequests is 0', () => {
const metrics: ModelMetrics = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 1000 },
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
};
expect(calculateAverageLatency(metrics)).toBe(0);
});
it('should calculate the average latency correctly', () => {
const metrics: ModelMetrics = {
api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 1500 },
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
};
expect(calculateAverageLatency(metrics)).toBe(150);
});
});
describe('calculateCacheHitRate', () => {
it('should return 0 if prompt tokens is 0', () => {
const metrics: ModelMetrics = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 100,
thoughts: 0,
tool: 0,
},
};
expect(calculateCacheHitRate(metrics)).toBe(0);
});
it('should calculate the cache hit rate correctly', () => {
const metrics: ModelMetrics = {
api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 },
tokens: {
prompt: 200,
candidates: 0,
total: 0,
cached: 50,
thoughts: 0,
tool: 0,
},
};
expect(calculateCacheHitRate(metrics)).toBe(25);
});
});
describe('computeSessionStats', () => {
it('should return all zeros for initial empty metrics', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const result = computeSessionStats(metrics);
expect(result).toEqual({
totalApiTime: 0,
totalToolTime: 0,
agentActiveTime: 0,
apiTimePercent: 0,
toolTimePercent: 0,
cacheEfficiency: 0,
totalDecisions: 0,
successRate: 0,
agreementRate: 0,
totalPromptTokens: 0,
totalCachedTokens: 0,
});
});
it('should correctly calculate API and tool time percentages', () => {
const metrics: SessionMetrics = {
models: {
'gemini-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 750 },
tokens: {
prompt: 10,
candidates: 10,
total: 20,
cached: 0,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 250,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const result = computeSessionStats(metrics);
expect(result.totalApiTime).toBe(750);
expect(result.totalToolTime).toBe(250);
expect(result.agentActiveTime).toBe(1000);
expect(result.apiTimePercent).toBe(75);
expect(result.toolTimePercent).toBe(25);
});
it('should correctly calculate cache efficiency', () => {
const metrics: SessionMetrics = {
models: {
'gemini-pro': {
api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 1000 },
tokens: {
prompt: 150,
candidates: 10,
total: 160,
cached: 50,
thoughts: 0,
tool: 0,
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const result = computeSessionStats(metrics);
expect(result.cacheEfficiency).toBeCloseTo(33.33); // 50 / 150
});
it('should correctly calculate success and agreement rates', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 10,
totalSuccess: 8,
totalFail: 2,
totalDurationMs: 1000,
totalDecisions: { accept: 6, reject: 2, modify: 2 },
byName: {},
},
};
const result = computeSessionStats(metrics);
expect(result.successRate).toBe(80); // 8 / 10
expect(result.agreementRate).toBe(60); // 6 / 10
});
it('should handle division by zero gracefully', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
};
const result = computeSessionStats(metrics);
expect(result.apiTimePercent).toBe(0);
expect(result.toolTimePercent).toBe(0);
expect(result.cacheEfficiency).toBe(0);
expect(result.successRate).toBe(0);
expect(result.agreementRate).toBe(0);
});
});

View File

@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
SessionMetrics,
ComputedSessionStats,
ModelMetrics,
} from '../contexts/SessionContext.js';
export function calculateErrorRate(metrics: ModelMetrics): number {
if (metrics.api.totalRequests === 0) {
return 0;
}
return (metrics.api.totalErrors / metrics.api.totalRequests) * 100;
}
export function calculateAverageLatency(metrics: ModelMetrics): number {
if (metrics.api.totalRequests === 0) {
return 0;
}
return metrics.api.totalLatencyMs / metrics.api.totalRequests;
}
export function calculateCacheHitRate(metrics: ModelMetrics): number {
if (metrics.tokens.prompt === 0) {
return 0;
}
return (metrics.tokens.cached / metrics.tokens.prompt) * 100;
}
export const computeSessionStats = (
metrics: SessionMetrics,
): ComputedSessionStats => {
const { models, tools } = metrics;
const totalApiTime = Object.values(models).reduce(
(acc, model) => acc + model.api.totalLatencyMs,
0,
);
const totalToolTime = tools.totalDurationMs;
const agentActiveTime = totalApiTime + totalToolTime;
const apiTimePercent =
agentActiveTime > 0 ? (totalApiTime / agentActiveTime) * 100 : 0;
const toolTimePercent =
agentActiveTime > 0 ? (totalToolTime / agentActiveTime) * 100 : 0;
const totalCachedTokens = Object.values(models).reduce(
(acc, model) => acc + model.tokens.cached,
0,
);
const totalPromptTokens = Object.values(models).reduce(
(acc, model) => acc + model.tokens.prompt,
0,
);
const cacheEfficiency =
totalPromptTokens > 0 ? (totalCachedTokens / totalPromptTokens) * 100 : 0;
const totalDecisions =
tools.totalDecisions.accept +
tools.totalDecisions.reject +
tools.totalDecisions.modify;
const successRate =
tools.totalCalls > 0 ? (tools.totalSuccess / tools.totalCalls) * 100 : 0;
const agreementRate =
totalDecisions > 0
? (tools.totalDecisions.accept / totalDecisions) * 100
: 0;
return {
totalApiTime,
totalToolTime,
agentActiveTime,
apiTimePercent,
toolTimePercent,
cacheEfficiency,
totalDecisions,
successRate,
agreementRate,
totalCachedTokens,
totalPromptTokens,
};
};

View File

@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
TOOL_SUCCESS_RATE_MEDIUM,
USER_AGREEMENT_RATE_HIGH,
USER_AGREEMENT_RATE_MEDIUM,
CACHE_EFFICIENCY_HIGH,
CACHE_EFFICIENCY_MEDIUM,
} from './displayUtils.js';
import { Colors } from '../colors.js';
describe('displayUtils', () => {
describe('getStatusColor', () => {
const thresholds = {
green: 80,
yellow: 50,
};
it('should return green for values >= green threshold', () => {
expect(getStatusColor(90, thresholds)).toBe(Colors.AccentGreen);
expect(getStatusColor(80, thresholds)).toBe(Colors.AccentGreen);
});
it('should return yellow for values < green and >= yellow threshold', () => {
expect(getStatusColor(79, thresholds)).toBe(Colors.AccentYellow);
expect(getStatusColor(50, thresholds)).toBe(Colors.AccentYellow);
});
it('should return red for values < yellow threshold', () => {
expect(getStatusColor(49, thresholds)).toBe(Colors.AccentRed);
expect(getStatusColor(0, thresholds)).toBe(Colors.AccentRed);
});
it('should return defaultColor for values < yellow threshold when provided', () => {
expect(
getStatusColor(49, thresholds, { defaultColor: Colors.Foreground }),
).toBe(Colors.Foreground);
});
});
describe('Threshold Constants', () => {
it('should have the correct values', () => {
expect(TOOL_SUCCESS_RATE_HIGH).toBe(95);
expect(TOOL_SUCCESS_RATE_MEDIUM).toBe(85);
expect(USER_AGREEMENT_RATE_HIGH).toBe(75);
expect(USER_AGREEMENT_RATE_MEDIUM).toBe(45);
expect(CACHE_EFFICIENCY_HIGH).toBe(40);
expect(CACHE_EFFICIENCY_MEDIUM).toBe(15);
});
});
});

View File

@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Colors } from '../colors.js';
// --- Thresholds ---
export const TOOL_SUCCESS_RATE_HIGH = 95;
export const TOOL_SUCCESS_RATE_MEDIUM = 85;
export const USER_AGREEMENT_RATE_HIGH = 75;
export const USER_AGREEMENT_RATE_MEDIUM = 45;
export const CACHE_EFFICIENCY_HIGH = 40;
export const CACHE_EFFICIENCY_MEDIUM = 15;
// --- Color Logic ---
export const getStatusColor = (
value: number,
thresholds: { green: number; yellow: number },
options: { defaultColor?: string } = {},
) => {
if (value >= thresholds.green) {
return Colors.AccentGreen;
}
if (value >= thresholds.yellow) {
return Colors.AccentYellow;
}
return options.defaultColor || Colors.AccentRed;
};

View File

@ -27,7 +27,7 @@ export const formatDuration = (milliseconds: number): string => {
} }
if (milliseconds < 1000) { if (milliseconds < 1000) {
return `${milliseconds}ms`; return `${Math.round(milliseconds)}ms`;
} }
const totalSeconds = milliseconds / 1000; const totalSeconds = milliseconds / 1000;

View File

@ -10,14 +10,8 @@ import {
GeminiEventType, GeminiEventType,
ServerGeminiToolCallRequestEvent, ServerGeminiToolCallRequestEvent,
ServerGeminiErrorEvent, ServerGeminiErrorEvent,
ServerGeminiUsageMetadataEvent,
} from './turn.js'; } from './turn.js';
import { import { GenerateContentResponse, Part, Content } from '@google/genai';
GenerateContentResponse,
Part,
Content,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import { reportError } from '../utils/errorReporting.js'; import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js'; import { GeminiChat } from './geminiChat.js';
@ -55,24 +49,6 @@ describe('Turn', () => {
}; };
let mockChatInstance: MockedChatInstance; let mockChatInstance: MockedChatInstance;
const mockMetadata1: GenerateContentResponseUsageMetadata = {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30,
cachedContentTokenCount: 5,
toolUsePromptTokenCount: 2,
thoughtsTokenCount: 3,
};
const mockMetadata2: GenerateContentResponseUsageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 200,
totalTokenCount: 300,
cachedContentTokenCount: 50,
toolUsePromptTokenCount: 20,
thoughtsTokenCount: 30,
};
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
mockChatInstance = { mockChatInstance = {
@ -245,46 +221,6 @@ describe('Turn', () => {
); );
}); });
it('should yield the last UsageMetadata event from the stream', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'First response' }] } }],
usageMetadata: mockMetadata1,
} as unknown as GenerateContentResponse;
// Add a small delay to ensure apiTimeMs is > 0
await new Promise((resolve) => setTimeout(resolve, 10));
yield {
functionCalls: [{ name: 'aTool' }],
usageMetadata: mockMetadata2,
} as unknown as GenerateContentResponse;
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
const reqParts: Part[] = [{ text: 'Test metadata' }];
for await (const event of turn.run(
reqParts,
new AbortController().signal,
)) {
events.push(event);
}
// There should be a content event, a tool call, and our metadata event
expect(events.length).toBe(3);
const metadataEvent = events[2] as ServerGeminiUsageMetadataEvent;
expect(metadataEvent.type).toBe(GeminiEventType.UsageMetadata);
// The value should be the *last* metadata object received.
expect(metadataEvent.value).toEqual(
expect.objectContaining(mockMetadata2),
);
expect(metadataEvent.value.apiTimeMs).toBeGreaterThan(0);
// Also check the public getter
expect(turn.getUsageMetadata()).toEqual(mockMetadata2);
});
it('should handle function calls with undefined name or args', async () => { it('should handle function calls with undefined name or args', async () => {
const mockResponseStream = (async function* () { const mockResponseStream = (async function* () {
yield { yield {

View File

@ -9,7 +9,6 @@ import {
GenerateContentResponse, GenerateContentResponse,
FunctionCall, FunctionCall,
FunctionDeclaration, FunctionDeclaration,
GenerateContentResponseUsageMetadata,
} from '@google/genai'; } from '@google/genai';
import { import {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
@ -48,7 +47,6 @@ export enum GeminiEventType {
UserCancelled = 'user_cancelled', UserCancelled = 'user_cancelled',
Error = 'error', Error = 'error',
ChatCompressed = 'chat_compressed', ChatCompressed = 'chat_compressed',
UsageMetadata = 'usage_metadata',
Thought = 'thought', Thought = 'thought',
} }
@ -129,11 +127,6 @@ export type ServerGeminiChatCompressedEvent = {
value: ChatCompressionInfo | null; value: ChatCompressionInfo | null;
}; };
export type ServerGeminiUsageMetadataEvent = {
type: GeminiEventType.UsageMetadata;
value: GenerateContentResponseUsageMetadata & { apiTimeMs?: number };
};
// The original union type, now composed of the individual types // The original union type, now composed of the individual types
export type ServerGeminiStreamEvent = export type ServerGeminiStreamEvent =
| ServerGeminiContentEvent | ServerGeminiContentEvent
@ -143,14 +136,12 @@ export type ServerGeminiStreamEvent =
| ServerGeminiUserCancelledEvent | ServerGeminiUserCancelledEvent
| ServerGeminiErrorEvent | ServerGeminiErrorEvent
| ServerGeminiChatCompressedEvent | ServerGeminiChatCompressedEvent
| ServerGeminiUsageMetadataEvent
| ServerGeminiThoughtEvent; | ServerGeminiThoughtEvent;
// A turn manages the agentic loop turn within the server context. // A turn manages the agentic loop turn within the server context.
export class Turn { export class Turn {
readonly pendingToolCalls: ToolCallRequestInfo[]; readonly pendingToolCalls: ToolCallRequestInfo[];
private debugResponses: GenerateContentResponse[]; private debugResponses: GenerateContentResponse[];
private lastUsageMetadata: GenerateContentResponseUsageMetadata | null = null;
constructor(private readonly chat: GeminiChat) { constructor(private readonly chat: GeminiChat) {
this.pendingToolCalls = []; this.pendingToolCalls = [];
@ -161,7 +152,6 @@ export class Turn {
req: PartListUnion, req: PartListUnion,
signal: AbortSignal, signal: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent> { ): AsyncGenerator<ServerGeminiStreamEvent> {
const startTime = Date.now();
try { try {
const responseStream = await this.chat.sendMessageStream({ const responseStream = await this.chat.sendMessageStream({
message: req, message: req,
@ -213,19 +203,6 @@ export class Turn {
yield event; yield event;
} }
} }
if (resp.usageMetadata) {
this.lastUsageMetadata =
resp.usageMetadata as GenerateContentResponseUsageMetadata;
}
}
if (this.lastUsageMetadata) {
const durationMs = Date.now() - startTime;
yield {
type: GeminiEventType.UsageMetadata,
value: { ...this.lastUsageMetadata, apiTimeMs: durationMs },
};
} }
} catch (e) { } catch (e) {
const error = toFriendlyError(e); const error = toFriendlyError(e);
@ -286,8 +263,4 @@ export class Turn {
getDebugResponses(): GenerateContentResponse[] { getDebugResponses(): GenerateContentResponse[] {
return this.debugResponses; return this.debugResponses;
} }
getUsageMetadata(): GenerateContentResponseUsageMetadata | null {
return this.lastUsageMetadata;
}
} }

View File

@ -38,3 +38,4 @@ export {
} from './types.js'; } from './types.js';
export { SpanStatusCode, ValueType } from '@opentelemetry/api'; export { SpanStatusCode, ValueType } from '@opentelemetry/api';
export { SemanticAttributes } from '@opentelemetry/semantic-conventions'; export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
export * from './uiTelemetry.js';

View File

@ -43,15 +43,22 @@ import * as metrics from './metrics.js';
import * as sdk from './sdk.js'; import * as sdk from './sdk.js';
import { vi, describe, beforeEach, it, expect } from 'vitest'; import { vi, describe, beforeEach, it, expect } from 'vitest';
import { GenerateContentResponseUsageMetadata } from '@google/genai'; import { GenerateContentResponseUsageMetadata } from '@google/genai';
import * as uiTelemetry from './uiTelemetry.js';
describe('loggers', () => { describe('loggers', () => {
const mockLogger = { const mockLogger = {
emit: vi.fn(), emit: vi.fn(),
}; };
const mockUiEvent = {
addEvent: vi.fn(),
};
beforeEach(() => { beforeEach(() => {
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(true); vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(true);
vi.spyOn(logs, 'getLogger').mockReturnValue(mockLogger); vi.spyOn(logs, 'getLogger').mockReturnValue(mockLogger);
vi.spyOn(uiTelemetry.uiTelemetryService, 'addEvent').mockImplementation(
mockUiEvent.addEvent,
);
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
}); });
@ -215,6 +222,7 @@ describe('loggers', () => {
cached_content_token_count: 10, cached_content_token_count: 10,
thoughts_token_count: 5, thoughts_token_count: 5,
tool_token_count: 2, tool_token_count: 2,
total_token_count: 0,
response_text: 'test-response', response_text: 'test-response',
}, },
}); });
@ -233,6 +241,12 @@ describe('loggers', () => {
50, 50,
'output', 'output',
); );
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_API_RESPONSE,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
it('should log an API response with an error', () => { it('should log an API response with an error', () => {
@ -263,6 +277,12 @@ describe('loggers', () => {
'error.message': 'test-error', 'error.message': 'test-error',
}, },
}); });
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_API_RESPONSE,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
}); });
@ -417,6 +437,12 @@ describe('loggers', () => {
true, true,
ToolCallDecision.ACCEPT, ToolCallDecision.ACCEPT,
); );
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_TOOL_CALL,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
it('should log a tool call with a reject decision', () => { it('should log a tool call with a reject decision', () => {
const call: ErroredToolCall = { const call: ErroredToolCall = {
@ -471,6 +497,12 @@ describe('loggers', () => {
false, false,
ToolCallDecision.REJECT, ToolCallDecision.REJECT,
); );
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_TOOL_CALL,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
it('should log a tool call with a modify decision', () => { it('should log a tool call with a modify decision', () => {
@ -527,6 +559,12 @@ describe('loggers', () => {
true, true,
ToolCallDecision.MODIFY, ToolCallDecision.MODIFY,
); );
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_TOOL_CALL,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
it('should log a tool call without a decision', () => { it('should log a tool call without a decision', () => {
@ -581,6 +619,12 @@ describe('loggers', () => {
true, true,
undefined, undefined,
); );
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_TOOL_CALL,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
it('should log a failed tool call with an error', () => { it('should log a failed tool call with an error', () => {
@ -641,6 +685,12 @@ describe('loggers', () => {
false, false,
undefined, undefined,
); );
expect(mockUiEvent.addEvent).toHaveBeenCalledWith({
...event,
'event.name': EVENT_TOOL_CALL,
'event.timestamp': '2025-01-01T00:00:00.000Z',
});
}); });
}); });
}); });

View File

@ -31,6 +31,7 @@ import {
recordToolCallMetrics, recordToolCallMetrics,
} from './metrics.js'; } from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js'; import { isTelemetrySdkInitialized } from './sdk.js';
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js'; import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
const shouldLogUserPrompts = (config: Config): boolean => const shouldLogUserPrompts = (config: Config): boolean =>
@ -98,6 +99,12 @@ export function logUserPrompt(config: Config, event: UserPromptEvent): void {
} }
export function logToolCall(config: Config, event: ToolCallEvent): void { export function logToolCall(config: Config, event: ToolCallEvent): void {
const uiEvent = {
...event,
'event.name': EVENT_TOOL_CALL,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
ClearcutLogger.getInstance(config)?.logToolCallEvent(event); ClearcutLogger.getInstance(config)?.logToolCallEvent(event);
if (!isTelemetrySdkInitialized()) return; if (!isTelemetrySdkInitialized()) return;
@ -150,6 +157,12 @@ export function logApiRequest(config: Config, event: ApiRequestEvent): void {
} }
export function logApiError(config: Config, event: ApiErrorEvent): void { export function logApiError(config: Config, event: ApiErrorEvent): void {
const uiEvent = {
...event,
'event.name': EVENT_API_ERROR,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
ClearcutLogger.getInstance(config)?.logApiErrorEvent(event); ClearcutLogger.getInstance(config)?.logApiErrorEvent(event);
if (!isTelemetrySdkInitialized()) return; if (!isTelemetrySdkInitialized()) return;
@ -186,6 +199,12 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
} }
export function logApiResponse(config: Config, event: ApiResponseEvent): void { export function logApiResponse(config: Config, event: ApiResponseEvent): void {
const uiEvent = {
...event,
'event.name': EVENT_API_RESPONSE,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
ClearcutLogger.getInstance(config)?.logApiResponseEvent(event); ClearcutLogger.getInstance(config)?.logApiResponseEvent(event);
if (!isTelemetrySdkInitialized()) return; if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = { const attributes: LogAttributes = {

View File

@ -183,6 +183,7 @@ export class ApiResponseEvent {
cached_content_token_count: number; cached_content_token_count: number;
thoughts_token_count: number; thoughts_token_count: number;
tool_token_count: number; tool_token_count: number;
total_token_count: number;
response_text?: string; response_text?: string;
constructor( constructor(
@ -202,6 +203,7 @@ export class ApiResponseEvent {
this.cached_content_token_count = usage_data?.cachedContentTokenCount ?? 0; this.cached_content_token_count = usage_data?.cachedContentTokenCount ?? 0;
this.thoughts_token_count = usage_data?.thoughtsTokenCount ?? 0; this.thoughts_token_count = usage_data?.thoughtsTokenCount ?? 0;
this.tool_token_count = usage_data?.toolUsePromptTokenCount ?? 0; this.tool_token_count = usage_data?.toolUsePromptTokenCount ?? 0;
this.total_token_count = usage_data?.totalTokenCount ?? 0;
this.response_text = response_text; this.response_text = response_text;
this.error = error; this.error = error;
} }

View File

@ -0,0 +1,510 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UiTelemetryService } from './uiTelemetry.js';
import {
ApiErrorEvent,
ApiResponseEvent,
ToolCallEvent,
ToolCallDecision,
} from './types.js';
import {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
import {
CompletedToolCall,
ErroredToolCall,
SuccessfulToolCall,
} from '../core/coreToolScheduler.js';
import { Tool, ToolConfirmationOutcome } from '../tools/tools.js';
const createFakeCompletedToolCall = (
name: string,
success: boolean,
duration = 100,
outcome?: ToolConfirmationOutcome,
error?: Error,
): CompletedToolCall => {
const request = {
callId: `call_${name}_${Date.now()}`,
name,
args: { foo: 'bar' },
isClientInitiated: false,
};
if (success) {
return {
status: 'success',
request,
tool: { name } as Tool, // Mock tool
response: {
callId: request.callId,
responseParts: {
functionResponse: {
id: request.callId,
name,
response: { output: 'Success!' },
},
},
error: undefined,
resultDisplay: 'Success!',
},
durationMs: duration,
outcome,
} as SuccessfulToolCall;
} else {
return {
status: 'error',
request,
response: {
callId: request.callId,
responseParts: {
functionResponse: {
id: request.callId,
name,
response: { error: 'Tool failed' },
},
},
error: error || new Error('Tool failed'),
resultDisplay: 'Failure!',
},
durationMs: duration,
outcome,
} as ErroredToolCall;
}
};
describe('UiTelemetryService', () => {
let service: UiTelemetryService;
beforeEach(() => {
service = new UiTelemetryService();
});
it('should have correct initial metrics', () => {
const metrics = service.getMetrics();
expect(metrics).toEqual({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
},
byName: {},
},
});
expect(service.getLastPromptTokenCount()).toBe(0);
});
it('should emit an update event when an event is added', () => {
const spy = vi.fn();
service.on('update', spy);
const event = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 10,
output_token_count: 20,
total_token_count: 30,
cached_content_token_count: 5,
thoughts_token_count: 2,
tool_token_count: 3,
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event);
expect(spy).toHaveBeenCalledOnce();
const { metrics, lastPromptTokenCount } = spy.mock.calls[0][0];
expect(metrics).toBeDefined();
expect(lastPromptTokenCount).toBe(10);
});
describe('API Response Event Processing', () => {
it('should process a single ApiResponseEvent', () => {
const event = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 10,
output_token_count: 20,
total_token_count: 30,
cached_content_token_count: 5,
thoughts_token_count: 2,
tool_token_count: 3,
} as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE };
service.addEvent(event);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro']).toEqual({
api: {
totalRequests: 1,
totalErrors: 0,
totalLatencyMs: 500,
},
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 3,
},
});
expect(service.getLastPromptTokenCount()).toBe(10);
});
it('should aggregate multiple ApiResponseEvents for the same model', () => {
const event1 = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 10,
output_token_count: 20,
total_token_count: 30,
cached_content_token_count: 5,
thoughts_token_count: 2,
tool_token_count: 3,
} as ApiResponseEvent & {
'event.name': typeof EVENT_API_RESPONSE;
};
const event2 = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 600,
input_token_count: 15,
output_token_count: 25,
total_token_count: 40,
cached_content_token_count: 10,
thoughts_token_count: 4,
tool_token_count: 6,
} as ApiResponseEvent & {
'event.name': typeof EVENT_API_RESPONSE;
};
service.addEvent(event1);
service.addEvent(event2);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro']).toEqual({
api: {
totalRequests: 2,
totalErrors: 0,
totalLatencyMs: 1100,
},
tokens: {
prompt: 25,
candidates: 45,
total: 70,
cached: 15,
thoughts: 6,
tool: 9,
},
});
expect(service.getLastPromptTokenCount()).toBe(15);
});
it('should handle ApiResponseEvents for different models', () => {
const event1 = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 10,
output_token_count: 20,
total_token_count: 30,
cached_content_token_count: 5,
thoughts_token_count: 2,
tool_token_count: 3,
} as ApiResponseEvent & {
'event.name': typeof EVENT_API_RESPONSE;
};
const event2 = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-flash',
duration_ms: 1000,
input_token_count: 100,
output_token_count: 200,
total_token_count: 300,
cached_content_token_count: 50,
thoughts_token_count: 20,
tool_token_count: 30,
} as ApiResponseEvent & {
'event.name': typeof EVENT_API_RESPONSE;
};
service.addEvent(event1);
service.addEvent(event2);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro']).toBeDefined();
expect(metrics.models['gemini-2.5-flash']).toBeDefined();
expect(metrics.models['gemini-2.5-pro'].api.totalRequests).toBe(1);
expect(metrics.models['gemini-2.5-flash'].api.totalRequests).toBe(1);
expect(service.getLastPromptTokenCount()).toBe(100);
});
});
describe('API Error Event Processing', () => {
it('should process a single ApiErrorEvent', () => {
const event = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 300,
error: 'Something went wrong',
} as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
service.addEvent(event);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro']).toEqual({
api: {
totalRequests: 1,
totalErrors: 1,
totalLatencyMs: 300,
},
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
});
});
it('should aggregate ApiErrorEvents and ApiResponseEvents', () => {
const responseEvent = {
'event.name': EVENT_API_RESPONSE,
model: 'gemini-2.5-pro',
duration_ms: 500,
input_token_count: 10,
output_token_count: 20,
total_token_count: 30,
cached_content_token_count: 5,
thoughts_token_count: 2,
tool_token_count: 3,
} as ApiResponseEvent & {
'event.name': typeof EVENT_API_RESPONSE;
};
const errorEvent = {
'event.name': EVENT_API_ERROR,
model: 'gemini-2.5-pro',
duration_ms: 300,
error: 'Something went wrong',
} as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR };
service.addEvent(responseEvent);
service.addEvent(errorEvent);
const metrics = service.getMetrics();
expect(metrics.models['gemini-2.5-pro']).toEqual({
api: {
totalRequests: 2,
totalErrors: 1,
totalLatencyMs: 800,
},
tokens: {
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 3,
},
});
});
});
describe('Tool Call Event Processing', () => {
it('should process a single successful ToolCallEvent', () => {
const toolCall = createFakeCompletedToolCall(
'test_tool',
true,
150,
ToolConfirmationOutcome.ProceedOnce,
);
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))),
'event.name': EVENT_TOOL_CALL,
});
const metrics = service.getMetrics();
const { tools } = metrics;
expect(tools.totalCalls).toBe(1);
expect(tools.totalSuccess).toBe(1);
expect(tools.totalFail).toBe(0);
expect(tools.totalDurationMs).toBe(150);
expect(tools.totalDecisions[ToolCallDecision.ACCEPT]).toBe(1);
expect(tools.byName['test_tool']).toEqual({
count: 1,
success: 1,
fail: 0,
durationMs: 150,
decisions: {
[ToolCallDecision.ACCEPT]: 1,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
},
});
});
it('should process a single failed ToolCallEvent', () => {
const toolCall = createFakeCompletedToolCall(
'test_tool',
false,
200,
ToolConfirmationOutcome.Cancel,
);
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))),
'event.name': EVENT_TOOL_CALL,
});
const metrics = service.getMetrics();
const { tools } = metrics;
expect(tools.totalCalls).toBe(1);
expect(tools.totalSuccess).toBe(0);
expect(tools.totalFail).toBe(1);
expect(tools.totalDurationMs).toBe(200);
expect(tools.totalDecisions[ToolCallDecision.REJECT]).toBe(1);
expect(tools.byName['test_tool']).toEqual({
count: 1,
success: 0,
fail: 1,
durationMs: 200,
decisions: {
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 1,
[ToolCallDecision.MODIFY]: 0,
},
});
});
it('should process a ToolCallEvent with modify decision', () => {
const toolCall = createFakeCompletedToolCall(
'test_tool',
true,
250,
ToolConfirmationOutcome.ModifyWithEditor,
);
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))),
'event.name': EVENT_TOOL_CALL,
});
const metrics = service.getMetrics();
const { tools } = metrics;
expect(tools.totalDecisions[ToolCallDecision.MODIFY]).toBe(1);
expect(tools.byName['test_tool'].decisions[ToolCallDecision.MODIFY]).toBe(
1,
);
});
it('should process a ToolCallEvent without a decision', () => {
const toolCall = createFakeCompletedToolCall('test_tool', true, 100);
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall))),
'event.name': EVENT_TOOL_CALL,
});
const metrics = service.getMetrics();
const { tools } = metrics;
expect(tools.totalDecisions).toEqual({
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
});
expect(tools.byName['test_tool'].decisions).toEqual({
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
});
});
it('should aggregate multiple ToolCallEvents for the same tool', () => {
const toolCall1 = createFakeCompletedToolCall(
'test_tool',
true,
100,
ToolConfirmationOutcome.ProceedOnce,
);
const toolCall2 = createFakeCompletedToolCall(
'test_tool',
false,
150,
ToolConfirmationOutcome.Cancel,
);
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall1))),
'event.name': EVENT_TOOL_CALL,
});
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall2))),
'event.name': EVENT_TOOL_CALL,
});
const metrics = service.getMetrics();
const { tools } = metrics;
expect(tools.totalCalls).toBe(2);
expect(tools.totalSuccess).toBe(1);
expect(tools.totalFail).toBe(1);
expect(tools.totalDurationMs).toBe(250);
expect(tools.totalDecisions[ToolCallDecision.ACCEPT]).toBe(1);
expect(tools.totalDecisions[ToolCallDecision.REJECT]).toBe(1);
expect(tools.byName['test_tool']).toEqual({
count: 2,
success: 1,
fail: 1,
durationMs: 250,
decisions: {
[ToolCallDecision.ACCEPT]: 1,
[ToolCallDecision.REJECT]: 1,
[ToolCallDecision.MODIFY]: 0,
},
});
});
it('should handle ToolCallEvents for different tools', () => {
const toolCall1 = createFakeCompletedToolCall('tool_A', true, 100);
const toolCall2 = createFakeCompletedToolCall('tool_B', false, 200);
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall1))),
'event.name': EVENT_TOOL_CALL,
});
service.addEvent({
...JSON.parse(JSON.stringify(new ToolCallEvent(toolCall2))),
'event.name': EVENT_TOOL_CALL,
});
const metrics = service.getMetrics();
const { tools } = metrics;
expect(tools.totalCalls).toBe(2);
expect(tools.totalSuccess).toBe(1);
expect(tools.totalFail).toBe(1);
expect(tools.byName['tool_A']).toBeDefined();
expect(tools.byName['tool_B']).toBeDefined();
expect(tools.byName['tool_A'].count).toBe(1);
expect(tools.byName['tool_B'].count).toBe(1);
});
});
});

View File

@ -0,0 +1,207 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'events';
import {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
import {
ApiErrorEvent,
ApiResponseEvent,
ToolCallEvent,
ToolCallDecision,
} from './types.js';
export type UiEvent =
| (ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE })
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
export interface ToolCallStats {
count: number;
success: number;
fail: number;
durationMs: number;
decisions: {
[ToolCallDecision.ACCEPT]: number;
[ToolCallDecision.REJECT]: number;
[ToolCallDecision.MODIFY]: number;
};
}
export interface ModelMetrics {
api: {
totalRequests: number;
totalErrors: number;
totalLatencyMs: number;
};
tokens: {
prompt: number;
candidates: number;
total: number;
cached: number;
thoughts: number;
tool: number;
};
}
export interface SessionMetrics {
models: Record<string, ModelMetrics>;
tools: {
totalCalls: number;
totalSuccess: number;
totalFail: number;
totalDurationMs: number;
totalDecisions: {
[ToolCallDecision.ACCEPT]: number;
[ToolCallDecision.REJECT]: number;
[ToolCallDecision.MODIFY]: number;
};
byName: Record<string, ToolCallStats>;
};
}
const createInitialModelMetrics = (): ModelMetrics => ({
api: {
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
},
tokens: {
prompt: 0,
candidates: 0,
total: 0,
cached: 0,
thoughts: 0,
tool: 0,
},
});
const createInitialMetrics = (): SessionMetrics => ({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
},
byName: {},
},
});
export class UiTelemetryService extends EventEmitter {
#metrics: SessionMetrics = createInitialMetrics();
#lastPromptTokenCount = 0;
addEvent(event: UiEvent) {
switch (event['event.name']) {
case EVENT_API_RESPONSE:
this.processApiResponse(event);
break;
case EVENT_API_ERROR:
this.processApiError(event);
break;
case EVENT_TOOL_CALL:
this.processToolCall(event);
break;
default:
// We should not emit update for any other event metric.
return;
}
this.emit('update', {
metrics: this.#metrics,
lastPromptTokenCount: this.#lastPromptTokenCount,
});
}
getMetrics(): SessionMetrics {
return this.#metrics;
}
getLastPromptTokenCount(): number {
return this.#lastPromptTokenCount;
}
private getOrCreateModelMetrics(modelName: string): ModelMetrics {
if (!this.#metrics.models[modelName]) {
this.#metrics.models[modelName] = createInitialModelMetrics();
}
return this.#metrics.models[modelName];
}
private processApiResponse(event: ApiResponseEvent) {
const modelMetrics = this.getOrCreateModelMetrics(event.model);
modelMetrics.api.totalRequests++;
modelMetrics.api.totalLatencyMs += event.duration_ms;
modelMetrics.tokens.prompt += event.input_token_count;
modelMetrics.tokens.candidates += event.output_token_count;
modelMetrics.tokens.total += event.total_token_count;
modelMetrics.tokens.cached += event.cached_content_token_count;
modelMetrics.tokens.thoughts += event.thoughts_token_count;
modelMetrics.tokens.tool += event.tool_token_count;
this.#lastPromptTokenCount = event.input_token_count;
}
private processApiError(event: ApiErrorEvent) {
const modelMetrics = this.getOrCreateModelMetrics(event.model);
modelMetrics.api.totalRequests++;
modelMetrics.api.totalErrors++;
modelMetrics.api.totalLatencyMs += event.duration_ms;
}
private processToolCall(event: ToolCallEvent) {
const { tools } = this.#metrics;
tools.totalCalls++;
tools.totalDurationMs += event.duration_ms;
if (event.success) {
tools.totalSuccess++;
} else {
tools.totalFail++;
}
if (!tools.byName[event.function_name]) {
tools.byName[event.function_name] = {
count: 0,
success: 0,
fail: 0,
durationMs: 0,
decisions: {
[ToolCallDecision.ACCEPT]: 0,
[ToolCallDecision.REJECT]: 0,
[ToolCallDecision.MODIFY]: 0,
},
};
}
const toolStats = tools.byName[event.function_name];
toolStats.count++;
toolStats.durationMs += event.duration_ms;
if (event.success) {
toolStats.success++;
} else {
toolStats.fail++;
}
if (event.decision) {
tools.totalDecisions[event.decision]++;
toolStats.decisions[event.decision]++;
}
}
}
export const uiTelemetryService = new UiTelemetryService();