Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 63 additions & 18 deletions src/__tests__/main/parsers/usage-aggregator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Tests for usage aggregator utilities
*/

import { describe, expect, it } from 'vitest';
import {
aggregateModelUsage,
estimateContextUsage,
Expand Down Expand Up @@ -96,22 +97,40 @@ describe('estimateContextUsage', () => {
expect(result).toBe(10);
});

it('should cap at 100%', () => {
it('should correctly calculate for Claude with all token types', () => {
// Simulates a real Claude response: input + cacheRead + cacheCreation = total
const stats = createStats({
inputTokens: 150000,
outputTokens: 100000,
inputTokens: 2,
cacheReadInputTokens: 33541,
cacheCreationInputTokens: 11657,
outputTokens: 12,
contextWindow: 200000,
});
const result = estimateContextUsage(stats, 'claude-code');
// Output tokens excluded; 150k / 200k = 75%
expect(result).toBe(75);
// (2 + 33541 + 11657) / 200000 = 45200 / 200000 = 22.6% -> 23%
expect(result).toBe(23);
});

it('should return null when tokens exceed context window (accumulated values)', () => {
// When Claude Code does complex multi-tool turns, token values accumulate
// across internal API calls and can exceed the context window
const stats = createStats({
inputTokens: 21627,
cacheReadInputTokens: 1079415,
cacheCreationInputTokens: 39734,
contextWindow: 200000,
});
const result = estimateContextUsage(stats, 'claude-code');
// Total = 1,140,776 > 200,000 -> null (accumulated, skip update)
expect(result).toBeNull();
});
});

describe('when contextWindow is not provided (fallback)', () => {
it('should use claude-code default context window (200k)', () => {
const stats = createStats({ contextWindow: 0 });
const result = estimateContextUsage(stats, 'claude-code');
// 10000 + 0 + 0 = 10000 / 200000 = 5%
expect(result).toBe(5);
});

Expand Down Expand Up @@ -149,6 +168,18 @@ describe('estimateContextUsage', () => {
const result = estimateContextUsage(stats, 'claude-code');
expect(result).toBe(0);
});

it('should return null when accumulated tokens exceed default window', () => {
const stats = createStats({
inputTokens: 50000,
cacheReadInputTokens: 500000,
cacheCreationInputTokens: 10000,
contextWindow: 0,
});
const result = estimateContextUsage(stats, 'claude-code');
// 560000 > 200000 default -> null
expect(result).toBeNull();
});
});
});

Expand All @@ -166,38 +197,52 @@ describe('calculateContextTokens', () => {
...overrides,
});

it('should exclude output tokens and cacheReadInputTokens for Claude agents', () => {
it('should include input + cacheRead + cacheCreation for Claude agents', () => {
const stats = createStats();
const result = calculateContextTokens(stats, 'claude-code');
// 10000 + 1000 = 11000 (no output tokens, no cacheRead - cumulative)
expect(result).toBe(11000);
// 10000 + 2000 + 1000 = 13000 (all input token types, excludes output)
expect(result).toBe(13000);
});

it('should include output tokens but exclude cacheReadInputTokens for Codex agents', () => {
it('should include input + cacheCreation + output for Codex agents', () => {
const stats = createStats();
const result = calculateContextTokens(stats, 'codex');
// 10000 + 5000 + 1000 = 16000 (includes output, excludes cacheRead)
// 10000 + 1000 + 5000 = 16000 (combined input+output window)
expect(result).toBe(16000);
});

it('should default to Claude behavior when agent is undefined', () => {
const stats = createStats();
const result = calculateContextTokens(stats);
// 10000 + 1000 = 11000 (excludes cacheRead)
expect(result).toBe(11000);
// 10000 + 2000 + 1000 = 13000 (Claude default: all input token types)
expect(result).toBe(13000);
});

it('should calculate correctly for typical first Claude turn', () => {
// Real-world scenario: first message with system prompt cache
const stats = createStats({
inputTokens: 2,
cacheReadInputTokens: 33541,
cacheCreationInputTokens: 11657,
outputTokens: 12,
});
const result = calculateContextTokens(stats, 'claude-code');
// 2 + 33541 + 11657 = 45200 (total context for the API call)
expect(result).toBe(45200);
});

it('should exclude cacheReadInputTokens because they are cumulative session totals', () => {
// cacheReadInputTokens accumulate across all turns in a session and can
// exceed the context window. Including them would cause context % > 100%.
it('should handle accumulated values from multi-tool turns', () => {
// When values are accumulated across internal API calls,
// the total can exceed the context window. calculateContextTokens
// returns the raw total; callers must check against contextWindow.
const stats = createStats({
inputTokens: 5000,
cacheCreationInputTokens: 1000,
cacheReadInputTokens: 500000, // Very high cumulative value
cacheReadInputTokens: 500000, // Accumulated from many internal calls
});
const result = calculateContextTokens(stats, 'claude-code');
// Should only be 5000 + 1000 = 6000, NOT 506000
expect(result).toBe(6000);
// 5000 + 500000 + 1000 = 506000 (raw total, may exceed window)
expect(result).toBe(506000);
});
});

Expand Down
6 changes: 4 additions & 2 deletions src/__tests__/main/process-listeners/usage-listener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,19 +162,21 @@ describe('Usage Listener', () => {
});
});

it('should handle zero context window gracefully', async () => {
it('should handle zero context window gracefully (falls back to 200k default)', async () => {
setupListener();
const handler = eventHandlers.get('usage');
const usageStats = createMockUsageStats({ contextWindow: 0 });

handler?.('group-chat-test-chat-123-participant-TestAgent-abc123', usageStats);

// With contextWindow 0, falls back to 200k default
// 1800 / 200000 = 0.9% -> rounds to 1%
await vi.waitFor(() => {
expect(mockDeps.groupChatStorage.updateParticipant).toHaveBeenCalledWith(
'test-chat-123',
'TestAgent',
expect.objectContaining({
contextUsage: 0,
contextUsage: 1,
})
);
});
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/renderer/components/HistoryDetailModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,9 +470,9 @@ describe('HistoryDetailModal', () => {
/>
);

// Context = (inputTokens + cacheCreationInputTokens) / contextWindow (cacheRead excluded)
// (5000 + 5000) / 100000 = 10%
expect(screen.getByText('10%')).toBeInTheDocument();
// Context = (inputTokens + cacheReadInputTokens + cacheCreationInputTokens) / contextWindow
// (5000 + 2000 + 5000) / 100000 = 12%
expect(screen.getByText('12%')).toBeInTheDocument();
});

it('should display token counts', () => {
Expand Down
18 changes: 10 additions & 8 deletions src/__tests__/renderer/components/MainPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1955,8 +1955,8 @@ describe('MainPanel', () => {
<MainPanel {...defaultProps} activeSession={session} getContextColor={getContextColor} />
);

// Context usage should be 50000 / 200000 * 100 = 25% (cacheRead excluded - cumulative)
expect(getContextColor).toHaveBeenCalledWith(25, theme);
// Context usage: (50000 + 25000 + 0) / 200000 * 100 = 38% (input + cacheRead + cacheCreation)
expect(getContextColor).toHaveBeenCalledWith(38, theme);
});
});

Expand Down Expand Up @@ -2373,9 +2373,10 @@ describe('MainPanel', () => {
expect(screen.queryByText('Context Window')).not.toBeInTheDocument();
});

it('should cap context usage at 100%', () => {
const getContextColor = vi.fn().mockReturnValue('#ef4444');
it('should use preserved session.contextUsage when accumulated values exceed window', () => {
const getContextColor = vi.fn().mockReturnValue('#22c55e');
const session = createSession({
contextUsage: 45, // Preserved valid percentage from last non-accumulated update
aiTabs: [
{
id: 'tab-1',
Expand All @@ -2386,8 +2387,8 @@ describe('MainPanel', () => {
usageStats: {
inputTokens: 150000,
outputTokens: 100000,
cacheReadInputTokens: 100000, // Excluded from calculation (cumulative)
cacheCreationInputTokens: 100000, // Included in calculation
cacheReadInputTokens: 100000, // Accumulated from multi-tool turn
cacheCreationInputTokens: 100000, // Accumulated from multi-tool turn
totalCostUsd: 0.05,
contextWindow: 200000,
},
Expand All @@ -2400,8 +2401,9 @@ describe('MainPanel', () => {
<MainPanel {...defaultProps} activeSession={session} getContextColor={getContextColor} />
);

// Context usage: (150000 + 100000) / 200000 = 125% -> capped at 100%
expect(getContextColor).toHaveBeenCalledWith(100, theme);
// raw = 150000 + 100000 + 100000 = 350000 > 200000 (accumulated)
// Falls back to session.contextUsage = 45%
expect(getContextColor).toHaveBeenCalledWith(45, theme);
});
});

Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/renderer/utils/contextExtractor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,8 @@ describe('calculateTotalTokens', () => {

const total = calculateTotalTokens(contexts);

// input + cacheCreation for each context (cacheRead excluded - cumulative)
expect(total).toBe(450); // (100+25) + (300+25)
// input + cacheRead + cacheCreation for each context
expect(total).toBe(575); // (100+50+25) + (300+75+25)
});
});

Expand Down Expand Up @@ -694,8 +694,8 @@ describe('getContextSummary', () => {

expect(summary.totalSources).toBe(2);
expect(summary.totalLogs).toBe(5);
// (100+25) + (200+25) = 350 (cacheRead excluded - cumulative)
expect(summary.estimatedTokens).toBe(350);
// (100+50+25) + (200+75+25) = 475 (input + cacheRead + cacheCreation)
expect(summary.estimatedTokens).toBe(475);
expect(summary.byAgent['claude-code']).toBe(1);
expect(summary.byAgent['opencode']).toBe(1);
});
Expand Down
Loading