mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user