The token breakdown of an assistant message accumulates across every API round-trip inside the turn: each tool call re-reads the whole cached prompt, so input/cache.read add up to several times the context window. Every context-usage surface summed those fields, which is why the meter could read 330% of a 1M window whose real fill was 232,872 tokens (23.3%), and why reopening an older session jumps the readout (#2562). The server reports the final round-trip's window as tokens.total (optional in the message schema; opencode 1.18.18 returns it, verified against its live /session/:id/message API). Prefer it everywhere the window fill is displayed and fall back to summing only when the server did not send it: contextTokensFromBreakdown in tokenUtils now owns that rule, and the context store extractor, sync store getter, work status panel, context sidebar, VS Code layout, mini chat, and mobile metadata all use it instead of their own inline sums. Fixes #2562
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { computeContextUsage, DEFAULT_CONTEXT_LIMIT } from './contextUsage';
|
|
|
|
const assistant = (tokens: Record<string, unknown>, id = 'msg') => ({ id, role: 'assistant', tokens });
|
|
|
|
describe('computeContextUsage', () => {
|
|
test('sums every token bucket of the newest reporting assistant message', () => {
|
|
const usage = computeContextUsage(
|
|
[assistant({ input: 100, output: 20, reasoning: 5, cache: { read: 800, write: 75 } })],
|
|
2000,
|
|
);
|
|
expect(usage?.totalTokens).toBe(1000);
|
|
expect(usage?.percent).toBe(50);
|
|
});
|
|
|
|
test('reports the latest turn rather than a sum across turns', () => {
|
|
// A turn's tokens describe that turn's window, so adding turns up would
|
|
// report several times the real fill.
|
|
const usage = computeContextUsage(
|
|
[
|
|
assistant({ input: 400, output: 0, reasoning: 0 }, 'old'),
|
|
assistant({ input: 900, output: 0, reasoning: 0 }, 'new'),
|
|
],
|
|
1000,
|
|
);
|
|
expect(usage?.totalTokens).toBe(900);
|
|
});
|
|
|
|
test('skips user messages and assistant turns that reported nothing', () => {
|
|
const usage = computeContextUsage(
|
|
[
|
|
assistant({ input: 300, output: 0, reasoning: 0 }, 'real'),
|
|
assistant({ input: 0, output: 0, reasoning: 0 }, 'zeroed'),
|
|
{ id: 'user', role: 'user' },
|
|
],
|
|
1000,
|
|
);
|
|
expect(usage?.totalTokens).toBe(300);
|
|
});
|
|
|
|
test('leaves the percentage unrounded', () => {
|
|
// Rounding here is what made the panel print "34.0%" against the header's
|
|
// "33.6%".
|
|
const usage = computeContextUsage([assistant({ input: 336, output: 0, reasoning: 0 })], 1000);
|
|
expect(usage?.percent.toFixed(1)).toBe('33.6');
|
|
});
|
|
|
|
test('falls back to the default limit when the model exposes none', () => {
|
|
const usage = computeContextUsage([assistant({ input: 20_000, output: 0, reasoning: 0 })], 0);
|
|
expect(usage?.limit).toBe(DEFAULT_CONTEXT_LIMIT);
|
|
expect(usage?.percent).toBe(10);
|
|
});
|
|
|
|
test('returns null when no message carries usable tokens', () => {
|
|
expect(computeContextUsage([], 1000)).toBeNull();
|
|
expect(computeContextUsage([{ id: 'u', role: 'user' }], 1000)).toBeNull();
|
|
expect(computeContextUsage([assistant({ input: 0, output: 0, reasoning: 0 })], 1000)).toBeNull();
|
|
});
|
|
|
|
test('tolerates partial token payloads', () => {
|
|
const usage = computeContextUsage([assistant({ input: 10 })], 100);
|
|
expect(usage?.totalTokens).toBe(10);
|
|
});
|
|
|
|
test('prefers the server-reported total over summing round-trip fields', () => {
|
|
// Real payload from opencode 1.18.18: ~14 tool-call round-trips accumulated
|
|
// cache.read to 3.29M while the 1M window really held 232,872. Summing
|
|
// rendered 330.6%; the reported total renders the real 23.3%.
|
|
const usage = computeContextUsage(
|
|
[assistant({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })],
|
|
1_000_000,
|
|
);
|
|
expect(usage?.totalTokens).toBe(232_872);
|
|
expect(usage?.percent.toFixed(4)).toBe('23.2872');
|
|
});
|
|
|
|
test('selects a message whose only signal is the reported total', () => {
|
|
const usage = computeContextUsage(
|
|
[assistant({ total: 5_000, input: 0, output: 0, reasoning: 0 })],
|
|
100_000,
|
|
);
|
|
expect(usage?.totalTokens).toBe(5_000);
|
|
});
|
|
});
|