From eaf5ac49bd1573f15dcc313a8b0afdf92c0744b7 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:22:41 +0800 Subject: [PATCH] fix: stop context/cache stats from jumping (subagent usage + stripped context_window) (#1256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): exclude subagent usage from the parent context indicator The status bar's `ctx N/M` and `cache N` come from latestUsage, which scans the normalized messages backwards for the most recent usage. That scan includes sidechain messages, so while a Task subagent runs its usage — describing the subagent's own, much smaller context — becomes the parent's numerator, then snaps back when the parent resumes. The existing `scope_role !== 'child'` guard never fired on any path. Claude never stamps scope_role (sdkToLogConverter.ts says so outright), and Codex drops child token_count events in the CLI before they can reach the web layer, so no producer ever emits 'child'. isSidechain is the signal that actually survives. sdkToLogConverter.ts:308-313 already documents this exact reducer behaviour, but works around only the denominator by forcing the main session's context_window onto sidechain messages. The numerator was left unguarded. * fix(cli): stop stripping context_window from local-session usage UsageSchema is a plain z.object, so Zod's default strip mode drops every undeclared key. sessionScanner forwards parsed.data rather than the raw line, so on the local-JSONL path usage is truncated to the five declared fields and context_window — injected on the SDK path by sdkToLogConverter — never survives. The web status bar then falls back to getContextBudgetTokens, which subtracts a 10k headroom, so the same model reports a 1.0M denominator on a remote session and 990k on a local one. RawMessageSchema right below already carries .passthrough() with a comment about losing message.model and messageId the same way; the nested usage object just never got the same treatment. --- cli/src/claude/types.ts | 7 +++++- web/src/chat/reducer.test.ts | 47 ++++++++++++++++++++++++++++++++++++ web/src/chat/reducer.ts | 17 ++++++++++--- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts index 213742df..2d802894 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -6,13 +6,18 @@ import { z } from "zod"; // Usage statistics for assistant messages - used in apiSession.ts +// `passthrough` for the same reason as RawMessageSchema below: the SDK path +// injects `context_window` onto this object (sdkToLogConverter.ts) and Anthropic +// keeps adding usage breakdowns. Under Zod's default `strip`, the local-JSONL +// path silently dropped `context_window`, which made the web status bar fall +// back to a heuristic denominator for local sessions only. export const UsageSchema = z.object({ input_tokens: z.number().int().nonnegative(), cache_creation_input_tokens: z.number().int().nonnegative().optional(), cache_read_input_tokens: z.number().int().nonnegative().optional(), output_tokens: z.number().int().nonnegative(), service_tier: z.string().optional(), -}); +}).passthrough(); // `passthrough` keeps fields the SDK adds going forward (e.g. `model`, future // usage breakdowns) so the hub forwards them verbatim. Without it, Zod's diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 93e1d3db..0768372a 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -166,6 +166,53 @@ describe('reduceChatBlocks', () => { }) }) + it('ignores Claude subagent usage when calculating parent latest usage', () => { + // Claude never stamps scope_role, so a Task subagent's assistant + // messages look like ordinary parent usage apart from isSidechain. + // Letting them through made the status bar's ctx numerator collapse + // while a subagent ran and snap back when the parent resumed. + const messages: NormalizedMessage[] = [ + { + id: 'parent-turn', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [], + isSidechain: false, + usage: { + input_tokens: 500, + output_tokens: 20, + cache_read_input_tokens: 120_000, + context_window: 200_000 + } + }, + { + id: 'subagent-turn', + localId: null, + createdAt: 1_700_000_001_000, + role: 'agent', + content: [], + isSidechain: true, + parentToolUseId: 'tc-task-1', + usage: { + input_tokens: 300, + output_tokens: 5, + cache_read_input_tokens: 8_000, + context_window: 200_000 + } + } + ] as NormalizedMessage[] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.latestUsage).toMatchObject({ + inputTokens: 500, + outputTokens: 20, + cacheRead: 120_000, + contextSize: 120_500 + }) + }) + it('keeps active goals visible across later normal user messages', () => { const reduced = reduceChatBlocks([ goalMessage('goal-active', 'active', 1), diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index dca7b9ba..742005b6 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -15,8 +15,19 @@ function calculateContextSize(usage: UsageData): number { return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } -function isUsageVisibleInParentContext(usage: UsageData): boolean { - return usage.scope_role !== 'child' +/** + * Whether a message's usage describes the *parent* thread's context. + * + * A Task subagent runs with its own, much smaller context, so letting its usage + * through makes the status bar's numerator collapse mid-run and snap back when + * the parent resumes. `scope_role` alone cannot catch this: Claude never stamps + * it (see sdkToLogConverter.ts), and Codex drops child `token_count` events in + * the CLI before they reach us — so the guard never actually fires on any path. + * `isSidechain` is the signal that survives both. + */ +function isUsageVisibleInParentContext(msg: NormalizedMessage): boolean { + if (msg.isSidechain) return false + return msg.usage?.scope_role !== 'child' } export type LatestUsage = { @@ -160,7 +171,7 @@ export function reduceChatBlocks( let latestUsage: LatestUsage | null = null for (let i = normalized.length - 1; i >= 0; i--) { const msg = normalized[i] - if (msg.usage && isUsageVisibleInParentContext(msg.usage)) { + if (msg.usage && isUsageVisibleInParentContext(msg)) { latestUsage = { inputTokens: msg.usage.input_tokens, outputTokens: msg.usage.output_tokens,