From 58ff91e5c4742765956b40b859494542a69c3f7a Mon Sep 17 00:00:00 2001 From: weishu Date: Sun, 2 Aug 2026 00:00:55 +0800 Subject: [PATCH] fix(web): correct context window calculation for local-mode sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/chat/modelConfig.test.ts | 6 ++++ web/src/chat/modelConfig.ts | 10 ++++++- web/src/chat/reducer.test.ts | 30 +++++++++++++++++++ web/src/chat/reducer.ts | 8 +++++ .../AssistantChat/HappyComposer.tsx | 4 +++ .../components/AssistantChat/StatusBar.tsx | 24 ++++++++++----- web/src/components/SessionChat.tsx | 1 + 7 files changed, 74 insertions(+), 9 deletions(-) diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index d270fb51..03a057f3 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -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) }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index b6d9b427..5cbecfda 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -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 } diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts index 0768372a..d7d012c9 100644 --- a/web/src/chat/reducer.test.ts +++ b/web/src/chat/reducer.test.ts @@ -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), diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 742005b6..b01c0c17 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -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 diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 5e969863..1d28ea35 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -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} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 790db496..86371610 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -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 diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 9f36011d..3b036fa2 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -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