fix(claude): stop 1M/200k context-window flicker in the status bar#992
fix(claude): stop 1M/200k context-window flicker in the status bar#992junmo-kim wants to merge 5 commits into
Conversation
Adds an optional selectedModel field to the converter's context, wired from session.getModel() in the launcher, so a later commit can seed the turn-1 contextWindow estimate for presets whose system/init model arrives without the "[1m]" suffix. No behavior change yet.
The remote launcher re-emits system/init on every turn for the same converter instance. Its init-time estimate only checked whether the model string ended in "[1m]", but current claude CLI versions strip that suffix from system/init for some 1M presets (fable[1m] arrives as "claude-fable-5"), so the estimate guessed 200k for them. The one authoritative value is result.modelUsage[<model>].contextWindow, which arrives after the heuristic has already injected 200k into that turn's assistant message and then gets clobbered back to 200k by the very next turn's init - producing the observed 200k<->1M oscillation in the web status bar. Cache the authoritative contextWindow per model id instead of a single session-wide number, and only let system/init seed a heuristic guess for a model that has no cached value yet, so a same-model re-init no longer downgrades an already-learned value. Two observed facts about the CLI's model ids drive the design: system/init.model and the result.modelUsage keys always agree with each other within a session (both bare for plain/fable[1m], both suffixed for opus[1m]/sonnet[1m]), while each per-turn assistant message reports its model bare and thus can't distinguish a 200k plain preset from its 1M "[1m]" variant on tiers where they share a base id. So the cache is keyed on the raw id (init/result agree, no normalization) and assistant injection looks the value up via resolvedModel (the last init id) rather than the lossy message.model. Keying raw keeps a plain preset and its [1m] variant on distinct entries; looking up via resolvedModel also means sidechain (Task subagent) messages carry the main session window rather than the subagent's own, since the web status bar picks the most recent usage message without filtering sidechains and would otherwise flicker to the subagent's smaller window while it runs. For presets whose init model arrives bare even though they are 1M (fable[1m]), the originally-selected preset - which preserves the "[1m]" suffix - seeds the turn-1 estimate, kept live across mid-session model switches via updateSelectedModel() (called from the launcher on every turn) so it never goes stale.
…llback getContextBudgetTokens already special-cased "[1m]" for short preset values (e.g. "opus[1m]") but fell through to the default 200k budget for full model ids (e.g. "claude-opus-4-8[1m]"), which is what the CLI now reports once context_window isn't available and this fallback is consulted. Check the suffix on that branch too so it stays a correct last-resort even without a session-provided context_window.
isClaudeModelPreset(trimmedModel) and the startsWith('claude-') branch
below it had become byte-for-byte identical bodies after the [1m]
suffix check was added to both. Merge them into one condition; no
behavior change.
There was a problem hiding this comment.
Findings
- [Major] Raw model key still collides for
fablevsfable[1m]—shared/src/models.ts:6/shared/src/models.ts:7expose both presets, and this PR's own fable tests establish thatfable[1m]can arrive with the same baresystem/init/modelUsagekey (claude-fable-5) as plain fable. Because the new cache refuses to reseed when that raw key already exists, switchingfable[1m] -> fablekeeps the learned 1M denominator until the next result, and switching back keeps 200k until the next result. That reintroduces the status-bar flicker this PR is trying to remove. Evidencecli/src/claude/utils/sdkToLogConverter.ts:289.
Suggested fix:private resolvedContextWindowKey: string | null = null private getContextWindowKey(model: string): string { const selectedModel = this.context.selectedModel?.trim() return selectedModel ? `${model}\0${selectedModel}` : model } // system/init this.resolvedModel = systemMsg.model this.resolvedContextWindowKey = this.getContextWindowKey(systemMsg.model) if (!this.modelContextWindows.has(this.resolvedContextWindowKey)) { this.modelContextWindows.set(this.resolvedContextWindowKey, seedIs1m ? 1_000_000 : 200_000) } // assistant lookup const contextWindow = this.resolvedContextWindowKey ? this.modelContextWindows.get(this.resolvedContextWindowKey) : undefined // result const key = model === this.resolvedModel && this.resolvedContextWindowKey ? this.resolvedContextWindowKey : model this.modelContextWindows.set(key, cw)
Summary
Review mode: initial
One Major finding: the new per-raw-model cache still aliases fable's plain and 1M presets when Claude reports both with the same bare model id. No other high-confidence issues found in the reviewed diff.
Testing
Not run: bun test cli/src/claude/utils/sdkToLogConverter.test.ts web/src/chat/modelConfig.test.ts failed because bun is not installed in this runner (/bin/bash: line 1: bun: command not found).
HAPI Bot
…th bare The per-model contextWindow cache keyed on the raw system/init model id, on the assumption that a 1M preset and its plain form always land on distinct ids. That holds for opus[1m]/sonnet[1m] (the CLI reports the "[1m]" suffix on their init and result ids) but not for fable: the CLI reports both "fable" and "fable[1m]" with the same bare id "claude-fable-5". So the "seed only if not already cached" guard would skip re-seeding when switching fable[1m] -> fable, leaving the stale 1M in place until fable's own result arrived - the same switch flicker this change set out to remove, just for fable specifically. Fold the selected preset's "[1m]" back into the cache key (computeContextWindowKey): when the init model arrives bare but the session selected an "[1m]" preset, key the entry as "<id>[1m]" so the 1M and plain variants stay distinct; ids the CLI already suffixed are left as-is. Seeding, lookups, and the current-model result entry all use this resolved key. Subagent result entries (e.g. haiku) keep their own raw id so the session's "[1m]" is never folded onto a model that isn't the selected one.
|
Thanks — fixed in 8f61920. |
There was a problem hiding this comment.
Findings
- No issues found.
Summary
Review mode: follow-up after new commits
No high-confidence issues found in the latest full PR diff. The prior raw-key collision concern appears addressed by the new disambiguated context-window key and the added fable/fable[1m] regression coverage. Residual risk: tests could not be executed in this runner because Bun is unavailable.
Testing
Not run (automation): bun is not installed in this runner (/bin/bash: bun: command not found).
HAPI Bot
Problem
On a Claude session running a 1M-context model, the composer status bar shows the wrong context-window denominator and flips between values within a single session — e.g.
ctx 50k/990Kmomentarily becomesctx 50k/190Kand back. Getting close to the true limit then trips the "context almost full" warning early against the wrong denominator. This is the same 1M/200k status-bar symptom reported in #719; it resurfaced after a claude CLI change.The window comes from
sdkToLogConverter, which injects acontext_windowinto each assistant message's usage. Its per-turn estimate only checked whether the model string ended in[1m]. Current claude CLI versions no longer put that suffix on thesystem/initmodel for some 1M presets (fable[1m]arrives asclaude-fable-5), so the estimate guesses 200k for them. The authoritative value,result.modelUsage[<model>].contextWindow, only arrives after that turn's assistant message has already been sent with 200k, and the next turn'ssystem/initthen resets the single cached number back to 200k — producing the oscillation.A second, latent issue: the estimate stored one session-wide number, so a Task subagent (Haiku, 200k) could drag the main session's denominator down while it ran, and on tiers where a plain preset and its
[1m]variant share a base model id the single number could not represent both.Solution
Cache the authoritative
contextWindowper model id instead of a single session-wide number, seeded conservatively and then confirmed byresult.modelUsage:system/init.modeland theresult.modelUsagekeys always agree with each other, but the per-turnassistant.message.modelis always bare, so it cannot tell a 200k plain preset from its 1M[1m]variant when they share a base id. Assistant injection therefore looks the value up via the resolved session model (the lastsystem/initid) rather than the lossy per-message model.opus[1m]/sonnet[1m]arrive with the[1m]suffix already on the id so their key is naturally distinct from the plain form, butfable[1m]and plainfableboth arrive as the bareclaude-fable-5; to keep those two on distinct cache entries the selected preset's[1m]is folded back into the cache key (subagent result entries keep their own bare id, so a Haiku subagent is never mistaken for a 1M model).system/initonly seeds a heuristic guess for a model with no cached value yet, so a same-model re-init no longer clobbers a window already learned from a result.fable[1m]the init model has no suffix, so the originally-selected preset (which keeps[1m]) seeds the turn-1 estimate, kept live across mid-session model switches so it never goes stale.system/init, looking up viaresolvedModelgives subagent messages the main session window, so the bar no longer dips to the subagent's smaller window while it runs. A subagent's own window is still learned into the cache fromresult.modelUsage, but it is surfaced only if that model becomes the resolved (main) model.result.modelUsageis ground truth and always overwrites forward, so a conservative seed self-corrects on the first result and distinct model ids keep distinct entries. Growth is bounded by the handful of models a session actually uses.getContextBudgetTokensalready handled the[1m]suffix for short preset values; it now also recognises it on full model ids, so the last-resort budget stays correct even when nocontext_windowis provided.Tests
clisdkToLogConverterunit tests cover the mismatched suffixed-init / bare-assistant shape (opus[1m]), the bare-init 1M preset seed (fable[1m]), the conservative 200k seed when nothing indicates 1M, a sidechain subagent inheriting the main window, and a multi-tier switch where a plain preset (200k) and its[1m]variant (1M) sharing a base id stay on distinct keys without cross-contamination.webmodelConfigtests cover the full-model-id[1m]recognition.cliandwebsuites pass, typecheck is clean acrosscli/web/hub, and the behaviour was verified live against a 1M session across multiple turns.