mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
fix(claude): stop 1M/200k context-window flicker in the status bar (#992)
* refactor(claude): thread session's selected model into SDKToLogConverter
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.
* fix(claude): key contextWindow cache by model to stop 1M/200k flicker
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.
* fix(web): recognize [1m] suffix on full Claude model ids in budget fallback
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.
* refactor(web): merge duplicate Claude context-budget branches
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.
* fix(claude): distinguish fable from fable[1m] when the CLI reports both 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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, PermissionResponse>
|
||||
private sidechainLastUUID = new Map<string, string>();
|
||||
// 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<string, number>()
|
||||
|
||||
constructor(
|
||||
context: Omit<ConversionContext, 'parentUuid'>,
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user