diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index f3ca0eb7..fb009dfc 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -105,7 +105,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { const sdkToLogConverter = new SDKToLogConverter({ sessionId: session.sessionId || 'unknown', cwd: session.path, - version: process.env.npm_package_version + version: process.env.npm_package_version, + selectedModel: session.getModel() }, permissionHandler.getResponses()); const handleSessionFound = (sessionId: string) => { @@ -318,6 +319,12 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { let p = pending; pending = null; permissionHandler.handleModeChange(p.mode.permissionMode); + // Re-resolve the selected-model seed hint for every turn, not + // just the first: a single claudeRemote() call keeps accepting + // new turns (potentially with a different model, e.g. after a + // mid-session model switch), so a construction-time snapshot + // would go stale. See SDKToLogConverter.updateSelectedModel. + sdkToLogConverter.updateSelectedModel(p.mode.model ?? null); return p; } @@ -332,6 +339,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { modeHash = msg.hash; mode = msg.mode; permissionHandler.handleModeChange(mode.permissionMode); + sdkToLogConverter.updateSelectedModel(mode.model ?? null); return { message: msg.message, mode: msg.mode diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 5ea30327..8c4e1cbe 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -236,7 +236,7 @@ describe('SDKToLogConverter', () => { type: 'system', subtype: 'init', session_id: 'session-2', - model: 'claude-sonnet-4-6' + model: 'claude-sonnet-5' } converter.convert(initMsg) @@ -308,6 +308,452 @@ describe('SDKToLogConverter', () => { const log = converter.convert(makeAssistantMessage()) as any expect(log?.message?.usage?.context_window).toBeUndefined() }) + + it('does not downgrade to the 200k heuristic on a same-model re-init after result refined it (sticky, per-model)', () => { + // Turn 1: new-CLI-style init with no [1m] suffix (the actual regression trigger). + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-5', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + + const turn1 = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(turn1?.message?.usage?.context_window).toBe(200_000) + + // Result arrives with the authoritative window for this model. + converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-5', + modelUsage: { + 'claude-opus-4-8': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // Turn 2: the CLI re-emits system/init for the *same* model (this happens on + // every turn in the remote launcher's while-loop). The stale 200k heuristic + // must NOT clobber the value we already learned from result.modelUsage. + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-5', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + + const turn2 = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi again' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(turn2?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('gives a switched-to model its own seed instead of inheriting the previous model\'s cached window (per-model cache, not globally sticky)', () => { + // Learn opus's real 1M window first. + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-6', + model: 'claude-opus-4-8' + } as SDKSystemMessage) + converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-6', + modelUsage: { + 'claude-opus-4-8': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // User switches to a different model mid-session. It has no cached value and no + // 1M seed signal here (bare init, no selectedModel), so it gets its own + // conservative 200k seed rather than inheriting opus's cached 1M. (Its real + // window would arrive with its own first result; this asserts cache isolation, + // i.e. the value is keyed per model rather than a single global sticky number.) + converter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-6', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + + const afterSwitch = converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + // The switched-to model gets its own seed, not opus's cached 1M value. + expect(afterSwitch?.message?.usage?.context_window).toBe(200_000) + }) + + it('seeds a 1M turn-1 estimate from selectedModel for an [1m] preset whose init model arrives bare (fable[1m] shape)', () => { + // Real claude 2.1.200 shape for "fable[1m]": init.model and result keys are + // BARE ("claude-fable-5", no suffix), unlike opus[1m]/sonnet[1m] which keep it. + // So systemMsg.model.endsWith('[1m]') is false here — the selectedModel hint is + // the only thing that lets turn 1 seed 1M instead of flashing 200k until the + // first result lands. This is why the selectedModel seed is load-bearing. + const seededConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'fable[1m]' + } as any) + + seededConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-7', + model: 'claude-fable-5' + } as SDKSystemMessage) + + const turn1 = seededConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(turn1?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('seeds a conservative 200k when neither the bare init model nor a selectedModel hint indicates 1M, until result confirms it', () => { + // Bare init model + no selectedModel hint: neither signal says "1M", so we can't + // know the real window on turn 1 and seed 200k conservatively rather than + // guessing high. The authoritative value arrives with the first result. (A real + // 1M account whose init keeps the suffix, e.g. "claude-opus-4-8[1m]", or that + // carries a selectedModel hint, seeds 1M immediately instead — covered above.) + const defaultConverter = new SDKToLogConverter({ ...context } as any) + + defaultConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-8', + model: 'claude-fable-5' + } as SDKSystemMessage) + + const turn1 = defaultConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(turn1?.message?.usage?.context_window).toBe(200_000) + }) + + it('seeds correctly after a live mid-session switch TO an [1m] preset (updateSelectedModel), not the stale construction-time value', () => { + // Session started on Default (no selectedModel) -- as HAPI's remote launcher + // does for every turn via updateSelectedModel(), not just turn 1. + const liveConverter = new SDKToLogConverter({ ...context } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-9', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + const beforeSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(beforeSwitch?.message?.usage?.context_window).toBe(200_000) + + // User switches to an explicit 1M preset (fable[1m], whose init model arrives + // bare so the selectedModel hint is what carries the 1M signal). Without a live + // update, the converter would still be seeding from the session-start snapshot + // (none) and would under-seed 200k for this new model's first turn too. + liveConverter.updateSelectedModel('fable[1m]') + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-9', + model: 'claude-fable-5' + } as SDKSystemMessage) + const afterSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(afterSwitch?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('does not over-seed after a live mid-session switch AWAY FROM an [1m] preset (updateSelectedModel)', () => { + // Session started on an explicit 1M preset (fable[1m]; its init model arrives + // bare, so the selectedModel hint carries the 1M signal on turn 1). + const liveConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'fable[1m]' + } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-10', + model: 'claude-fable-5' + } as SDKSystemMessage) + const beforeSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(beforeSwitch?.message?.usage?.context_window).toBe(1_000_000) + + // User switches away to a model with no 1M signal (no "[1m]" on the updated + // selectedModel and none on the bare init model). Without a live update, the + // converter would still be seeding from the stale "fable[1m]" snapshot and + // would over-seed 1,000,000 for the newly-selected model's first turn. + liveConverter.updateSelectedModel('sonnet') + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-10', + model: 'claude-sonnet-5' + } as SDKSystemMessage) + const afterSwitch = liveConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-sonnet-5', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + expect(afterSwitch?.message?.usage?.context_window).toBe(200_000) + }) + + it('injects 1M for an explicit [1m] preset whose assistant messages report a bare model id (real opus[1m] shape)', () => { + // Real claude 2.1.200 shape for an explicit "opus[1m]" session: system/init and + // result.modelUsage both use the suffixed id "claude-opus-4-8[1m]", while each + // assistant message reports the bare "claude-opus-4-8". Because init and result + // agree, the cache stores 1M under the suffixed key and the assistant lookup — + // which goes through the resolved cache key (= the suffixed init id here), not + // the bare message.model — finds it. (A lookup keyed on the bare message.model + // would miss.) + const seededConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'opus[1m]' + } as any) + + seededConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-11', + model: 'claude-opus-4-8[1m]' + } as SDKSystemMessage) + + // result reports the authoritative 1M under the SUFFIXED key... + seededConverter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-11', + modelUsage: { + 'claude-opus-4-8[1m]': { contextWindow: 1_000_000 } + } + } as SDKResultMessage) + + // ...but the assistant message reports the BARE model id. + const assistant = seededConverter.convert({ + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [{ type: 'text', text: 'hi' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(assistant?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('injects the MAIN session window into a sidechain (subagent) message, not the subagent model\'s own window', () => { + // Main session is opus[1m] (1M). A Task subagent runs on haiku (200k). Because + // the web status bar picks the most recent usage message without filtering + // sidechains, the subagent's assistant message must carry the main 1M window, + // or the footer denominator would visibly drop to 200k while the subagent runs. + // The subagent emits no system/init of its own, so the resolved cache key stays + // on the main model — and since lookups always go through that key, the sidechain + // message inherits the main window automatically. (The subagent's own 200k is + // still cached under its own id, but it is never the resolved lookup key here.) + const liveConverter = new SDKToLogConverter({ + ...context, + selectedModel: 'opus[1m]' + } as any) + + liveConverter.convert({ + type: 'system', + subtype: 'init', + session_id: 'session-12', + model: 'claude-opus-4-8[1m]' + } as SDKSystemMessage) + liveConverter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'session-12', + modelUsage: { + 'claude-opus-4-8[1m]': { contextWindow: 1_000_000 }, + 'claude-haiku-4-5-20251001': { contextWindow: 200_000 } + } + } as SDKResultMessage) + + // Sidechain assistant message from the haiku subagent (carries parent_tool_use_id). + const sidechain = liveConverter.convert({ + type: 'assistant', + parent_tool_use_id: 'toolu_task_1', + message: { + role: 'assistant', + model: 'claude-haiku-4-5-20251001', + content: [{ type: 'text', text: 'subagent reply' }], + usage: { input_tokens: 10, output_tokens: 20 } + } + } as any) as any + + expect(sidechain?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('keeps plain vs [1m] variants of the same base model on distinct cache keys (multi-tier: no collision)', () => { + // On some tiers plain "sonnet" is 200k while "sonnet[1m]" is 1M. For sonnet the + // CLI already reports the "[1m]" on system/init.model and the result key, so the + // two variants land on DISTINCT cache keys on their own (only the per-turn + // assistant message.model is bare/lossy, which is why lookups go through the + // resolved cache key, not message.model). fable is the case where the CLI does + // NOT suffix the id and the key has to be folded — covered by the next test. + const conv = new SDKToLogConverter({ ...context, selectedModel: 'sonnet[1m]' } as any) + + // Turn 1 on sonnet[1m]: learns 1M under the suffixed key. + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-sonnet-5[1m]': { contextWindow: 1_000_000 } } + } as SDKResultMessage) + const t1 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t1?.message?.usage?.context_window).toBe(1_000_000) + + // Switch to plain sonnet (200k on this tier): learns 200k under the bare key. + conv.updateSelectedModel('sonnet') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-sonnet-5': { contextWindow: 200_000 } } + } as SDKResultMessage) + const t2 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t2?.message?.usage?.context_window).toBe(200_000) + + // Switch back to sonnet[1m], turn-1 before its result re-arrives: must still read + // 1M from the suffixed key, NOT the 200k that plain sonnet just cached under the + // bare key (that cross-contamination is exactly what suffix-stripping would cause). + conv.updateSelectedModel('sonnet[1m]') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-sonnet-5[1m]' } as SDKSystemMessage) + const t3 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-sonnet-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t3?.message?.usage?.context_window).toBe(1_000_000) + }) + + it('distinguishes fable vs fable[1m] even though the CLI reports both with the bare id', () => { + // Unlike opus[1m]/sonnet[1m], the CLI reports BOTH "fable" and "fable[1m]" with + // the bare id "claude-fable-5" on system/init and in result.modelUsage. A cache + // keyed on that raw id alone can't tell the two apart, so switching fable[1m] + // (1M) -> fable (200k) would keep showing the stale 1M until fable's result + // lands. Folding the selectedModel's "[1m]" into the cache key keeps them + // distinct. selectedModel is the ONLY turn-1 signal that separates them here. + const conv = new SDKToLogConverter({ ...context, selectedModel: 'fable[1m]' } as any) + + // fable[1m]: seeds 1M from selectedModel, result confirms 1M. + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + conv.convert({ + type: 'result', subtype: 'success', num_turns: 1, total_cost_usd: 0, + duration_ms: 1, duration_api_ms: 1, is_error: false, session_id: 's', + modelUsage: { 'claude-fable-5': { contextWindow: 1_000_000 } } + } as SDKResultMessage) + const t1 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'a' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t1?.message?.usage?.context_window).toBe(1_000_000) + + // Switch to plain fable (200k): must re-seed 200k, NOT keep the stale 1M. + conv.updateSelectedModel('fable') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + const t2 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'b' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t2?.message?.usage?.context_window).toBe(200_000) + + // Switch back to fable[1m]: its 1M entry was never overwritten by plain fable's + // 200k (distinct keys), so turn-1 before the next result still reads 1M. + conv.updateSelectedModel('fable[1m]') + conv.convert({ type: 'system', subtype: 'init', session_id: 's', model: 'claude-fable-5' } as SDKSystemMessage) + const t3 = conv.convert({ + type: 'assistant', + message: { role: 'assistant', model: 'claude-fable-5', content: [{ type: 'text', text: 'c' }], usage: { input_tokens: 10, output_tokens: 20 } } + } as any) as any + expect(t3?.message?.usage?.context_window).toBe(1_000_000) + }) }) describe('Parent-child relationships', () => { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 7dcbafb6..36ec09cf 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -24,6 +24,12 @@ export interface ConversionContext { version?: string gitBranch?: string parentUuid?: string | null + // The model preset the session actually selected at launch time (e.g. "fable[1m]"), + // with the `[1m]` suffix intact. Some 1M presets (fable[1m]) arrive on system/init + // with the suffix already dropped ("claude-fable-5"), so this preserved preset is + // the only turn-1 signal that such a session is 1M. Used only to seed the very first + // contextWindow estimate before result.modelUsage confirms the real value. + selectedModel?: string | null } type PermissionResponse = { @@ -57,8 +63,22 @@ export class SDKToLogConverter { private context: ConversionContext private responses?: Map private sidechainLastUUID = new Map(); + // The raw model id from the most recent system/init (the session's authoritative model). private resolvedModel: string | null = null - private modelContextWindow: number | null = null + // The cache key for the current session model's contextWindow. Usually equal to + // resolvedModel, but for presets whose "[1m]" variant the CLI still reports with the + // bare id (fable[1m] arrives as "claude-fable-5", same as plain fable) it folds the + // selectedModel's "[1m]" back in so the two variants don't collide. See + // computeContextWindowKey. + private resolvedContextWindowKey: string | null = null + // Per-model contextWindow cache. Keys are the CLI's model id, except that for a + // preset whose "[1m]" variant shares the bare id of its plain form (fable) the "[1m]" + // is folded back into the key (computeContextWindowKey) so a 1M variant and a + // potentially-smaller plain variant stay on distinct entries. opus[1m]/sonnet[1m] + // already arrive suffixed from the CLI, so their keys are unchanged. Keying per model + // — rather than a single sticky number — means a mid-session model switch picks up the + // new model's own window immediately instead of inheriting the previous model's. + private modelContextWindows = new Map() constructor( context: Omit, @@ -73,6 +93,39 @@ export class SDKToLogConverter { this.responses = responses } + /** + * Compute the contextWindow cache key for an init model id. + * + * The CLI reports opus[1m]/sonnet[1m] with the "[1m]" suffix already on the id, but + * reports fable[1m] with the same bare id as plain fable ("claude-fable-5"). To keep a + * 1M variant from colliding with its (possibly-smaller) plain form, we fold the "[1m]" + * back onto the bare id when the session's selected preset asks for 1M. Ids that + * already carry the suffix are returned unchanged. + */ + private computeContextWindowKey(model: string): string { + if (model.endsWith('[1m]')) { + return model + } + const wants1m = this.context.selectedModel?.endsWith('[1m]') ?? false + return wants1m ? `${model}[1m]` : model + } + + /** + * Update the originally-selected model hint (for when the session's model + * changes mid-conversation, e.g. via the web model picker). `context.selectedModel` + * is only a turn-1 seed hint (see the system/init handler in `convert()`), but the + * caller (claudeRemoteLauncher) re-resolves the active mode -- including its model -- + * on every turn, not just the first, since a single long-running `claudeRemote()` + * call keeps accepting new turns with a live-updatable mode. Without this update, + * a mid-session switch would seed new models from a stale, session-start value: + * switching *to* an 1M preset would under-seed (still guess 200k for its first + * turn), and switching *away from* one would over-seed (guess 1M for a model that + * isn't 1M-capable) until a result message corrects it. + */ + updateSelectedModel(model: string | null | undefined): void { + this.context.selectedModel = model ?? null + } + /** * Update session ID (for when session changes during resume) */ @@ -198,10 +251,24 @@ export class SDKToLogConverter { case 'assistant': { const assistantMsg = sdkMessage as SDKAssistantMessage const message = assistantMsg.message as Record - if (this.modelContextWindow !== null && message && typeof message.usage === 'object' && message.usage !== null) { + // Look up the contextWindow by the session's resolved cache key (derived + // from the last system/init model), NOT the assistant message's own `model` + // field. The message's model is always reported bare (no "[1m]"), so it + // can't tell a 200k plain preset apart from its 1M "[1m]" variant when they + // share a base id; resolvedContextWindowKey carries the disambiguated key. + // Using the resolved key also means sidechain (Task subagent) messages carry + // the MAIN session window rather than the subagent's own — the web status + // bar's latestUsage picks the most recent usage message without filtering + // sidechains (Claude usage carries no scope_role), so a subagent's smaller + // window would otherwise make the footer denominator visibly drop while it + // runs. + const contextWindow = this.resolvedContextWindowKey + ? this.modelContextWindows.get(this.resolvedContextWindowKey) + : undefined + if (contextWindow !== undefined && message && typeof message.usage === 'object' && message.usage !== null) { const usage = message.usage as Record if (usage.context_window === undefined) { - usage.context_window = this.modelContextWindow + usage.context_window = contextWindow } } logMessage = { @@ -229,13 +296,33 @@ export class SDKToLogConverter { this.updateSessionId(systemMsg.session_id) } - // Capture the resolved model name on init (e.g. "claude-opus-4-7[1m]"). - // The `[1m]` suffix is stripped on per-turn assistant messages, so - // remember the full name here to derive an initial contextWindow - // estimate. The result message later refines it with the real value. + // Capture the resolved model name on init. The remote launcher re-emits + // system/init on every turn for the lifetime of this converter, so if we + // already learned this model's real contextWindow from a previous result + // message, leave it alone — recomputing a heuristic guess here would + // downgrade an already-known-good value and is the exact cause of the + // 200k<->1M flicker. Only seed a heuristic when this model has no cached + // value yet (first time we see it in this session). if (systemMsg.subtype === 'init' && typeof systemMsg.model === 'string') { this.resolvedModel = systemMsg.model - this.modelContextWindow = systemMsg.model.endsWith('[1m]') ? 1_000_000 : 200_000 + this.resolvedContextWindowKey = this.computeContextWindowKey(systemMsg.model) + if (!this.modelContextWindows.has(this.resolvedContextWindowKey)) { + // Best-effort 1M-vs-200k seed for turn 1, before any authoritative + // result has arrived. `systemMsg.model` only tells us it's a 1M + // model for the presets whose init keeps the "[1m]" suffix + // (opus[1m]/sonnet[1m]); for others the init model is bare even + // when it's a 1M preset (fable[1m] -> "claude-fable-5"). So we + // primarily consult the originally-selected preset, which always + // preserves the suffix (e.g. "fable[1m]"), and fall back to the + // init model string. This selectedModel seed is load-bearing — + // without it, a fresh fable[1m] turn would flash 200k until the + // first result lands. Guarding/seeding on resolvedContextWindowKey + // (not the bare init id) is what forces a re-seed when switching + // fable[1m] <-> fable, whose bare ids would otherwise be identical. + const seedIs1m = (this.context.selectedModel?.endsWith('[1m]') ?? false) + || systemMsg.model.endsWith('[1m]') + this.modelContextWindows.set(this.resolvedContextWindowKey, seedIs1m ? 1_000_000 : 200_000) + } } // System messages are typically not sent to logs @@ -257,13 +344,25 @@ export class SDKToLogConverter { // They're SDK-specific messages that indicate session completion // Not part of the actual conversation log. // - // But they carry the authoritative per-model contextWindow, - // which we cache and inject into subsequent assistant messages. + // But they carry the authoritative per-model contextWindow. modelUsage is + // keyed by the same raw model id the CLI reports on system/init, so the + // entry for the current session model is stored under resolvedContextWindowKey + // (which folds in the "[1m]" for fable), matching what assistant lookups use. + // Other entries — Task subagents like haiku — are stored under their own raw + // id (never fold the session's "[1m]" onto a subagent; haiku is 200k). Always + // overwrite on result — it is ground truth — for every model reported, so a + // model switched away from earlier this session keeps its real value cached + // for if/when the session switches back to it. const resultMsg = sdkMessage as SDKResultMessage - if (resultMsg.modelUsage && this.resolvedModel) { - const cw = resultMsg.modelUsage[this.resolvedModel]?.contextWindow - if (typeof cw === 'number' && cw > 0) { - this.modelContextWindow = cw + if (resultMsg.modelUsage) { + for (const [model, usage] of Object.entries(resultMsg.modelUsage)) { + const cw = usage?.contextWindow + if (typeof cw === 'number' && cw > 0) { + const key = (this.resolvedModel && model === this.resolvedModel) + ? (this.resolvedContextWindowKey ?? model) + : model + this.modelContextWindows.set(key, cw) + } } } break diff --git a/web/src/chat/modelConfig.test.ts b/web/src/chat/modelConfig.test.ts index 6917c6c5..d270fb51 100644 --- a/web/src/chat/modelConfig.test.ts +++ b/web/src/chat/modelConfig.test.ts @@ -10,6 +10,10 @@ describe('getContextBudgetTokens', () => { expect(getContextBudgetTokens('claude-sonnet-4-6', 'claude')).toBe(190_000) }) + it('uses the large budget for a full Claude model name carrying a [1m] suffix', () => { + expect(getContextBudgetTokens('claude-opus-4-8[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 65466a54..b6d9b427 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -74,14 +74,11 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor? if (!trimmedModel) { return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS } - if (isClaudeModelPreset(trimmedModel)) { + if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) { return trimmedModel.endsWith('[1m]') ? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS : DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS } - if (trimmedModel.startsWith('claude-')) { - return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS - } return null })()