fix: stop context/cache stats from jumping (subagent usage + stripped context_window) (#1256)

* 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.
This commit is contained in:
Haoqing Wang
2026-07-30 23:22:41 +08:00
committed by GitHub
parent a742fdf1a8
commit eaf5ac49bd
3 changed files with 67 additions and 4 deletions
+6 -1
View File
@@ -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
+47
View File
@@ -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),
+14 -3
View File
@@ -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,