Files
openchamber/packages/ui/src/components/chat/work-status/contextUsage.ts
T
dibanez 9e1a9b59b1 fix(ui): stop the context meter from counting every internal round-trip
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
2026-08-15 21:46:49 +02:00

70 lines
2.4 KiB
TypeScript

/**
* Context-window usage for a specific session.
*
* `useSessionUIStore.getContextUsage` cannot serve this panel. It reads
* `getSyncMessages(sessionId)` with **no directory**, which resolves to the
* *current* directory's child store, and it keys off the store's own
* `currentSessionId`. A session held by another directory — a worktree, or any
* moment right after a directory switch — therefore reads as "no messages" and
* the readout silently disappears while the header still shows a value.
*
* This computes the same quantity from messages the caller has already
* subscribed to for a known session and directory, so there is no hidden
* global read to race with.
*/
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
type MessageTokens = {
/** Server-reported window of the turn's final round-trip; absent on older servers. */
total?: number;
input?: number;
output?: number;
reasoning?: number;
cache?: { read?: number; write?: number };
};
type MessageLike = {
id?: string;
role?: string;
tokens?: MessageTokens;
};
type WorkStatusContextUsage = {
totalTokens: number;
/** Context limit actually used for the ratio, after the default fallback. */
limit: number;
/** Unrounded, so the panel and the header cannot disagree by a rounding step. */
percent: number;
};
/** The store's own fallback when a model exposes no context limit. */
export const DEFAULT_CONTEXT_LIMIT = 200_000;
/**
* Usage from the newest assistant message that reported a non-zero token count.
* The latest turn describes the current fill — not a sum across turns. Within
* a turn, the server-reported `total` is the final round-trip's window;
* summing the breakdown fields instead overstates multi-step turns, whose
* input/cache fields accumulate across round-trips.
*/
export const computeContextUsage = (
messages: readonly MessageLike[],
contextLimit: number,
): WorkStatusContextUsage | null => {
if (messages.length === 0) return null;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== 'assistant' || !message.tokens) continue;
const totalTokens = contextTokensFromBreakdown(message.tokens);
if (totalTokens <= 0) continue;
const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT;
return { totalTokens, limit, percent: (totalTokens / limit) * 100 };
}
return null;
};