fix(web): correct context window calculation for local-mode sessions

Remote sessions get an authoritative context_window injected into usage by
the CLI (from SDK result modelUsage), but local-mode sessions forward raw
transcript JSONL whose usage has no context_window, so the web fell back to
getContextBudgetTokens — which could not tell Fable's 1M window from its
bare id (only the "[1m]" suffix was recognized) and received session.model,
which is usually null for local sessions, defaulting to a 200k budget. A 1M
Fable session with ~256k of context showed 135% used with an over-limit
warning instead of ~26%.

- modelConfig: recognize Fable ids (fable, fable[1m], claude-fable-*) as 1M
- reducer: LatestUsage carries the usage-bearing message's own model
- StatusBar: new contextModel prop feeds the fallback heuristic only;
  the model prop's other semantics are unchanged
- HappyComposer forwards it; SessionChat passes
  latestUsage.model ?? session.model
This commit is contained in:
weishu
2026-08-02 00:00:55 +08:00
parent 3e61d276bd
commit 58ff91e5c4
7 changed files with 74 additions and 9 deletions
+6
View File
@@ -14,6 +14,12 @@ describe('getContextBudgetTokens', () => {
expect(getContextBudgetTokens('claude-opus-4-8[1m]', 'claude')).toBe(990_000)
})
it('uses the large budget for Fable even under its bare id (1M window)', () => {
expect(getContextBudgetTokens('claude-fable-5', 'claude')).toBe(990_000)
expect(getContextBudgetTokens('fable', 'claude')).toBe(990_000)
expect(getContextBudgetTokens('fable[1m]', 'claude')).toBe(990_000)
})
it('uses Codex app-server context window with headroom', () => {
expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBe(248_400)
})
+9 -1
View File
@@ -75,7 +75,15 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor?
return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS
}
if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) {
return trimmedModel.endsWith('[1m]')
// Fable ships with a 1M window even under its bare id: the SDK
// result message reports modelUsage["claude-fable-5"].contextWindow
// = 1,000,000, so the "[1m]" suffix check alone would undercount
// local-mode sessions (their transcript usage carries no
// context_window and falls through to this heuristic).
const isFable = trimmedModel === 'fable'
|| trimmedModel === 'fable[1m]'
|| trimmedModel.startsWith('claude-fable')
return trimmedModel.endsWith('[1m]') || isFable
? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS
: DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS
}
+30
View File
@@ -213,6 +213,36 @@ describe('reduceChatBlocks', () => {
})
})
it('carries the usage message model for the context-window heuristic', () => {
// Local-mode Claude transcripts have no context_window in usage and
// session.model is often null, so latestUsage.model is the only
// signal the status bar has to resolve a plausible window.
const messages: NormalizedMessage[] = [
{
id: 'local-turn',
localId: null,
createdAt: 1_700_000_000_000,
role: 'agent',
content: [],
isSidechain: false,
model: 'claude-fable-5',
usage: {
input_tokens: 2,
output_tokens: 50,
cache_read_input_tokens: 250_000
}
}
] as NormalizedMessage[]
const reduced = reduceChatBlocks(messages, null)
expect(reduced.latestUsage).toMatchObject({
contextSize: 250_002,
contextWindow: null,
model: 'claude-fable-5'
})
})
it('keeps active goals visible across later normal user messages', () => {
const reduced = reduceChatBlocks([
goalMessage('goal-active', 'active', 1),
+8
View File
@@ -37,6 +37,13 @@ export type LatestUsage = {
cacheRead: number
contextSize: number
contextWindow: number | null
/**
* Model reported by the usage-bearing message itself. Local-mode Claude
* sessions often have session.model = null (the model is picked inside the
* TUI), so this is the only model signal available for the context-window
* heuristic when the usage carries no explicit context_window.
*/
model: string | null
timestamp: number
}
@@ -179,6 +186,7 @@ export function reduceChatBlocks(
cacheRead: msg.usage.cache_read_input_tokens ?? 0,
contextSize: calculateContextSize(msg.usage),
contextWindow: msg.usage.context_window ?? null,
model: msg.model ?? null,
timestamp: msg.createdAt
}
break
@@ -156,6 +156,8 @@ export function HappyComposer(props: {
contextSize?: number
contextCacheRead?: number
contextWindow?: number | null
/** Model for the context-window heuristic; see StatusBar.contextModel. */
contextModel?: string | null
controlledByUser?: boolean
agentFlavor?: string | null
availableModelOptions?: Array<{ value: string | null; label: string }>
@@ -229,6 +231,7 @@ export function HappyComposer(props: {
contextSize,
contextCacheRead,
contextWindow,
contextModel,
controlledByUser = false,
agentFlavor,
availableModelOptions,
@@ -1330,6 +1333,7 @@ export function HappyComposer(props: {
contextSize={contextSize}
contextCacheRead={contextCacheRead}
contextWindow={contextWindow}
contextModel={contextModel}
model={model}
modelReasoningEffort={modelReasoningEffort}
serviceTier={serviceTier}
+16 -8
View File
@@ -196,6 +196,13 @@ export function StatusBar(props: {
contextSize?: number
contextCacheRead?: number
contextWindow?: number | null
/**
* Model to use for the context-window fallback heuristic when
* contextWindow is absent. Falls back to `model`. Callers pass the
* usage-bearing message's own model here so local Claude sessions (whose
* session.model is often null) still resolve a plausible window.
*/
contextModel?: string | null
model?: string | null
modelReasoningEffort?: string | null
serviceTier?: string | null
@@ -212,30 +219,31 @@ export function StatusBar(props: {
[props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t]
)
const contextHeuristicModel = props.contextModel ?? props.model
const contextWarning = useMemo(
() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
if (!maxContextSize) return null
return getContextWarning(props.contextSize, maxContextSize)
},
[props.contextSize, props.contextWindow, props.model, props.agentFlavor]
[props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor]
)
const contextUsageLabel = useMemo(() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
return formatContextUsageLabel(props.contextSize, maxContextSize)
}, [props.contextSize, props.contextWindow, props.model, props.agentFlavor])
}, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor])
const compactContextUsageLabel = useMemo(() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
return formatCompactContextUsageLabel(props.contextSize, maxContextSize)
}, [props.contextSize, props.contextWindow, props.model, props.agentFlavor])
}, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor])
const contextUsageDetails = useMemo(() => {
if (props.contextSize === undefined) return null
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
return getContextUsageDetails(props.contextSize, maxContextSize, props.contextCacheRead)
}, [props.contextSize, props.contextCacheRead, props.contextWindow, props.model, props.agentFlavor])
}, [props.contextSize, props.contextCacheRead, props.contextWindow, contextHeuristicModel, props.agentFlavor])
const contextUsedPercentage = contextUsageDetails?.usedPercentage ?? null
const permissionMode = props.permissionMode
+1
View File
@@ -1467,6 +1467,7 @@ function SessionChatInner(props: SessionChatProps) {
contextSize={reduced.latestUsage?.contextSize}
contextCacheRead={reduced.latestUsage?.cacheRead}
contextWindow={reduced.latestUsage?.contextWindow ?? piContextWindow}
contextModel={reduced.latestUsage?.model ?? props.session.model}
controlledByUser={controlledByUser}
onCollaborationModeChange={
codexCollaborationModeSupported && props.session.active && !controlledByUser