Merge pull request #2914 from pocharlies/fix/context-usage-server-total

fix(ui): stop the context meter from counting every internal round-trip
This commit is contained in:
Serhii Dziupin
2026-08-18 11:07:04 +03:00
committed by GitHub
11 changed files with 156 additions and 25 deletions
+1
View File
@@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file.
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech).
- Chat: saved chats in the context panel open again instead of staying blank.
- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies).
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept.
- Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507).
- Browser: typing a comment on a page no longer triggers app shortcuts.
@@ -388,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
tokens?: {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -395,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta
};
};
if (message.role !== 'assistant' || !message.tokens) continue;
// Multi-step turns accumulate the fields across API round-trips, so
// summing them overstates the window. The server-reported total is the
// final round-trip's window; sum only when the server did not send it.
const reportedTotal = getTokenCount(message.tokens.total);
if (reportedTotal > 0) return reportedTotal;
const total = getTokenCount(message.tokens.input)
+ getTokenCount(message.tokens.output)
+ getTokenCount(message.tokens.reasoning)
@@ -14,8 +14,8 @@ describe('computeContextUsage', () => {
});
test('reports the latest turn rather than a sum across turns', () => {
// Each assistant turn reports the whole window it saw, so adding them up
// would report several times the real fill.
// 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'),
@@ -61,4 +61,24 @@ describe('computeContextUsage', () => {
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);
});
});
@@ -13,7 +13,11 @@
* 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;
@@ -37,18 +41,12 @@ type WorkStatusContextUsage = {
/** The store's own fallback when a model exposes no context limit. */
export const DEFAULT_CONTEXT_LIMIT = 200_000;
const sumTokens = (tokens: MessageTokens): number => (
(tokens.input ?? 0)
+ (tokens.output ?? 0)
+ (tokens.reasoning ?? 0)
+ (tokens.cache?.read ?? 0)
+ (tokens.cache?.write ?? 0)
);
/**
* Usage from the newest assistant message that reported a non-zero token count.
* Each assistant turn reports the whole window it saw, so the latest one is the
* current fill not a sum across turns.
* 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[],
@@ -60,7 +58,7 @@ export const computeContextUsage = (
const message = messages[index];
if (message?.role !== 'assistant' || !message.tokens) continue;
const totalTokens = sumTokens(message.tokens);
const totalTokens = contextTokensFromBreakdown(message.tokens);
if (totalTokens <= 0) continue;
const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT;
@@ -92,6 +92,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
}
const breakdown = source as {
total?: unknown;
input?: unknown;
output?: unknown;
reasoning?: unknown;
@@ -103,6 +104,10 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
const reasoning = toNonNegativeNumber(breakdown.reasoning);
const cacheRead = toNonNegativeNumber(breakdown.cache?.read);
const cacheWrite = toNonNegativeNumber(breakdown.cache?.write);
// Multi-step turns accumulate the fields across API round-trips (every tool
// call re-reads the whole cached prompt), so summing them overstates the
// window. The server-reported total is the final round-trip's window.
const reportedTotal = toNonNegativeNumber(breakdown.total);
return {
input,
@@ -110,7 +115,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => {
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite,
};
};
@@ -8,6 +8,7 @@ import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown';
@@ -702,7 +703,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}
if (!lastTokens && message.tokens) {
const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0);
const total = contextTokensFromBreakdown(message.tokens);
if (total > 0) {
lastTokens = message.tokens;
lastMessageId = (currentSessionMessages[i] as { id?: string }).id;
@@ -730,7 +731,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
}
const lastTokens = headerMessageSummary.lastTokens;
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const totalTokens = contextTokensFromBreakdown(lastTokens);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined;
@@ -18,6 +18,7 @@ import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { Icon } from "@/components/icon/Icon";
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
type MiniChatMode = 'session' | 'draft';
@@ -157,7 +158,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
}
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } };
type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } };
let lastTokens: AssistantTokens | undefined;
let lastMessageId: string | undefined;
@@ -166,7 +167,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
if (message.role !== 'assistant') continue;
const tokens = (message as { tokens?: AssistantTokens }).tokens;
if (!tokens) continue;
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0);
const total = contextTokensFromBreakdown(tokens);
if (total > 0) {
lastTokens = tokens;
lastMessageId = message.id;
@@ -178,7 +179,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
}
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0);
const totalTokens = contextTokensFromBreakdown(lastTokens);
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000;
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0;
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined;
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test"
import { computeCacheHitRate, sumTokenBreakdown } from "./tokenUtils"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { computeCacheHitRate, contextTokensFromBreakdown, extractTokensFromMessage, sumTokenBreakdown } from "./tokenUtils"
const assistantMessage = (tokens: unknown): { info: Message; parts: Part[] } => ({
info: { tokens } as unknown as Message,
parts: [],
})
describe("computeCacheHitRate", () => {
test("returns zero and hasInput=false for null input", () => {
@@ -95,3 +101,67 @@ describe("sumTokenBreakdown (regression)", () => {
expect(sumTokenBreakdown(undefined)).toBe(0)
})
})
describe("contextTokensFromBreakdown", () => {
test("prefers the server-reported total over the summed fields", () => {
const breakdown = { total: 500, input: 100, output: 50, reasoning: 20, cache: { read: 800, write: 20 } }
expect(contextTokensFromBreakdown(breakdown)).toBe(500)
})
test("real multi-step turn: summing overstates a 1M window 14x, the total matches it", () => {
// Captured from opencode 1.18.18 (/session/:id/message) after a turn with
// ~14 tool-call round-trips. Every round-trip re-reads the whole cached
// prompt, so cache.read accumulates to 3.29M while the window really held
// 232,872. Summing rendered the context meter at 330.6% of a 1M window.
const breakdown = { total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } }
expect(contextTokensFromBreakdown(breakdown)).toBe(232_872)
expect(sumTokenBreakdown(breakdown)).toBe(3_306_479)
})
test("single-step turn: the total and the summed fields agree", () => {
// Captured from the same server: one round-trip, nothing accumulates.
const breakdown = { total: 117_714, input: 1_116, output: 87, reasoning: 543, cache: { read: 115_968, write: 0 } }
expect(contextTokensFromBreakdown(breakdown)).toBe(sumTokenBreakdown(breakdown))
})
test("falls back to summing when the server sends no total (older servers)", () => {
expect(contextTokensFromBreakdown({ input: 100, output: 50, reasoning: 20, cache: { read: 80, write: 20 } })).toBe(270)
})
test("falls back to summing when the total is zero or not a finite number", () => {
expect(contextTokensFromBreakdown({ total: 0, input: 40 })).toBe(40)
expect(contextTokensFromBreakdown({ total: Number.NaN, input: 40 })).toBe(40)
})
test("handles null and undefined", () => {
expect(contextTokensFromBreakdown(null)).toBe(0)
expect(contextTokensFromBreakdown(undefined)).toBe(0)
})
})
describe("extractTokensFromMessage", () => {
test("uses the reported total from the message info breakdown", () => {
const message = assistantMessage({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })
expect(extractTokensFromMessage(message)).toBe(232_872)
})
test("sums the info breakdown when no total is reported", () => {
expect(extractTokensFromMessage(assistantMessage({ input: 100, output: 50, reasoning: 20, cache: { read: 80, write: 20 } }))).toBe(270)
})
test("returns plain numeric tokens as-is", () => {
expect(extractTokensFromMessage(assistantMessage(1234))).toBe(1234)
})
test("prefers the reported total when tokens live on a part", () => {
const message: { info: Message; parts: Part[] } = {
info: {} as Message,
parts: [{ tokens: { total: 500, input: 2_000 } } as unknown as Part],
}
expect(extractTokensFromMessage(message)).toBe(500)
})
test("returns 0 when neither info nor parts carry tokens", () => {
expect(extractTokensFromMessage({ info: {} as Message, parts: [] })).toBe(0)
})
})
+29 -2
View File
@@ -1,6 +1,8 @@
import type { Message, Part } from "@opencode-ai/sdk/v2";
type TokenBreakdown = {
/** Server-reported window of the turn's final round-trip. Optional in the schema; absent on older servers. */
total?: number;
input?: number;
output?: number;
reasoning?: number;
@@ -24,6 +26,31 @@ export const sumTokenBreakdown = (breakdown: TokenBreakdown | null | undefined):
return inputTokens + outputTokens + reasoningTokens + cacheReadTokens + cacheWriteTokens;
};
/**
* Tokens the context window actually holds, from one message's token payload.
*
* The breakdown fields accumulate across every API round-trip inside a single
* assistant turn: each tool call re-reads the whole (cached) prompt, so on a
* multi-step turn `cache.read` alone can add up to several times the context
* window (observed on opencode 1.18.18: cache.read 3,291,956 on a turn whose
* 1M window really held 232,872 rendered as a 330% context readout). The
* server reports the final round-trip's window as `tokens.total` (optional in
* the message schema, absent on older servers). Prefer it; fall back to
* summing the fields only when the server did not send it.
*/
export const contextTokensFromBreakdown = (breakdown: TokenBreakdown | null | undefined): number => {
if (!breakdown || typeof breakdown !== 'object') {
return 0;
}
const reportedTotal = breakdown.total;
if (typeof reportedTotal === 'number' && Number.isFinite(reportedTotal) && reportedTotal > 0) {
return reportedTotal;
}
return sumTokenBreakdown(breakdown);
};
export const extractTokensFromMessage = (message: { info: Message; parts: Part[] }): number => {
const tokens = (message.info as { tokens?: number | TokenBreakdown }).tokens;
@@ -32,7 +59,7 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[]
}
if (tokens && typeof tokens === 'object') {
return sumTokenBreakdown(tokens);
return contextTokensFromBreakdown(tokens);
}
const tokenPart = message.parts.find(
@@ -47,7 +74,7 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[]
return tokenPart.tokens;
}
return sumTokenBreakdown(tokenPart.tokens);
return contextTokensFromBreakdown(tokenPart.tokens);
};
type CacheHitRateResult = {
+4 -3
View File
@@ -87,6 +87,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils"
export type { AttachedFile }
@@ -1174,7 +1175,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const messages = getSyncMessages(sessionId)
if (messages.length === 0) return null
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } }
let lastTokens: AssistantTokens | undefined
let lastMessageId: string | undefined
for (let i = messages.length - 1; i >= 0; i--) {
@@ -1182,7 +1183,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (msg.role !== "assistant") continue
const tokens = (msg as { tokens?: AssistantTokens }).tokens
if (!tokens) continue
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
const total = contextTokensFromBreakdown(tokens)
if (total > 0) {
lastTokens = tokens
lastMessageId = msg.id
@@ -1192,7 +1193,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (!lastTokens) return null
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0)
const totalTokens = contextTokensFromBreakdown(lastTokens)
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined
+1
View File
@@ -4,6 +4,7 @@
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech).
- The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds (thanks to @pocharlies).
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept.
- Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).