Skip to content

fix(claude): stop 1M/200k context-window flicker in the status bar#992

Open
junmo-kim wants to merge 5 commits into
tiann:mainfrom
junmo-kim:fix/claude-1m-context-window
Open

fix(claude): stop 1M/200k context-window flicker in the status bar#992
junmo-kim wants to merge 5 commits into
tiann:mainfrom
junmo-kim:fix/claude-1m-context-window

Conversation

@junmo-kim

@junmo-kim junmo-kim commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

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/990K momentarily becomes ctx 50k/190K and 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 a context_window into 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 the system/init model for some 1M presets (fable[1m] arrives as claude-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's system/init then 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 contextWindow per model id instead of a single session-wide number, seeded conservatively and then confirmed by result.modelUsage:

  • Key on the session model, disambiguated by the selected preset. Within a session system/init.model and the result.modelUsage keys always agree with each other, but the per-turn assistant.message.model is 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 last system/init id) 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, but fable[1m] and plain fable both arrive as the bare claude-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).
  • Seed once, never downgrade. system/init only 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.
  • Seed 1M presets whose init arrives bare. For 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.
  • Sidechain windows follow the main session. Because Task subagents do not emit their own system/init, looking up via resolvedModel gives 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 from result.modelUsage, but it is surfaced only if that model becomes the resolved (main) model.
  • Cache lifecycle. The map lives for one converter (one session) and holds one entry per model id seen. It needs no explicit invalidation: result.modelUsage is 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.
  • Web fallback (defense in depth). getContextBudgetTokens already 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 no context_window is provided.

Tests

  • cli sdkToLogConverter unit 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.
  • web modelConfig tests cover the full-model-id [1m] recognition.
  • Full cli and web suites pass, typecheck is clean across cli/web/hub, and the behaviour was verified live against a 1M session across multiple turns.

junmo-kim added 4 commits July 4, 2026 23:08
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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Raw model key still collides for fable vs fable[1m]shared/src/models.ts:6/shared/src/models.ts:7 expose both presets, and this PR's own fable tests establish that fable[1m] can arrive with the same bare system/init/modelUsage key (claude-fable-5) as plain fable. Because the new cache refuses to reseed when that raw key already exists, switching fable[1m] -> fable keeps 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. Evidence cli/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

Comment thread cli/src/claude/utils/sdkToLogConverter.ts Outdated
…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.
@junmo-kim

Copy link
Copy Markdown
Contributor Author

Thanks — fixed in 8f61920. fable[1m] and plain fable both arrive as the bare claude-fable-5, so the raw-key cache did collide there (unlike opus[1m]/sonnet[1m], which the CLI reports suffixed). The new commit folds the selected preset's [1m] back into the cache key via computeContextWindowKey so the two stay on distinct entries and a fable[1m] <-> fable switch re-seeds correctly; subagent result entries keep their own bare id so a Haiku subagent is never folded to 1M. Added a regression test covering the fable[1m] -> fable -> fable[1m] switch (1M / 200k / 1M).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant