mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): correct context window calculation for local-mode sessions
Remote sessions get an authoritative context_window injected into usage by the CLI (from SDK result modelUsage), but local-mode sessions forward raw transcript JSONL whose usage has no context_window, so the web fell back to getContextBudgetTokens — which could not tell Fable's 1M window from its bare id (only the "[1m]" suffix was recognized) and received session.model, which is usually null for local sessions, defaulting to a 200k budget. A 1M Fable session with ~256k of context showed 135% used with an over-limit warning instead of ~26%. - modelConfig: recognize Fable ids (fable, fable[1m], claude-fable-*) as 1M - reducer: LatestUsage carries the usage-bearing message's own model - StatusBar: new contextModel prop feeds the fallback heuristic only; the model prop's other semantics are unchanged - HappyComposer forwards it; SessionChat passes latestUsage.model ?? session.model
This commit is contained in:
@@ -14,6 +14,12 @@ describe('getContextBudgetTokens', () => {
|
||||
expect(getContextBudgetTokens('claude-opus-4-8[1m]', 'claude')).toBe(990_000)
|
||||
})
|
||||
|
||||
it('uses the large budget for Fable even under its bare id (1M window)', () => {
|
||||
expect(getContextBudgetTokens('claude-fable-5', 'claude')).toBe(990_000)
|
||||
expect(getContextBudgetTokens('fable', 'claude')).toBe(990_000)
|
||||
expect(getContextBudgetTokens('fable[1m]', 'claude')).toBe(990_000)
|
||||
})
|
||||
|
||||
it('uses Codex app-server context window with headroom', () => {
|
||||
expect(getContextBudgetTokens('gpt-5.4', 'codex')).toBe(248_400)
|
||||
})
|
||||
|
||||
@@ -75,7 +75,15 @@ export function getContextBudgetTokens(model: string | null | undefined, flavor?
|
||||
return DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS
|
||||
}
|
||||
if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) {
|
||||
return trimmedModel.endsWith('[1m]')
|
||||
// Fable ships with a 1M window even under its bare id: the SDK
|
||||
// result message reports modelUsage["claude-fable-5"].contextWindow
|
||||
// = 1,000,000, so the "[1m]" suffix check alone would undercount
|
||||
// local-mode sessions (their transcript usage carries no
|
||||
// context_window and falls through to this heuristic).
|
||||
const isFable = trimmedModel === 'fable'
|
||||
|| trimmedModel === 'fable[1m]'
|
||||
|| trimmedModel.startsWith('claude-fable')
|
||||
return trimmedModel.endsWith('[1m]') || isFable
|
||||
? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS
|
||||
: DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS
|
||||
}
|
||||
|
||||
@@ -213,6 +213,36 @@ describe('reduceChatBlocks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the usage message model for the context-window heuristic', () => {
|
||||
// Local-mode Claude transcripts have no context_window in usage and
|
||||
// session.model is often null, so latestUsage.model is the only
|
||||
// signal the status bar has to resolve a plausible window.
|
||||
const messages: NormalizedMessage[] = [
|
||||
{
|
||||
id: 'local-turn',
|
||||
localId: null,
|
||||
createdAt: 1_700_000_000_000,
|
||||
role: 'agent',
|
||||
content: [],
|
||||
isSidechain: false,
|
||||
model: 'claude-fable-5',
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 250_000
|
||||
}
|
||||
}
|
||||
] as NormalizedMessage[]
|
||||
|
||||
const reduced = reduceChatBlocks(messages, null)
|
||||
|
||||
expect(reduced.latestUsage).toMatchObject({
|
||||
contextSize: 250_002,
|
||||
contextWindow: null,
|
||||
model: 'claude-fable-5'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps active goals visible across later normal user messages', () => {
|
||||
const reduced = reduceChatBlocks([
|
||||
goalMessage('goal-active', 'active', 1),
|
||||
|
||||
@@ -37,6 +37,13 @@ export type LatestUsage = {
|
||||
cacheRead: number
|
||||
contextSize: number
|
||||
contextWindow: number | null
|
||||
/**
|
||||
* Model reported by the usage-bearing message itself. Local-mode Claude
|
||||
* sessions often have session.model = null (the model is picked inside the
|
||||
* TUI), so this is the only model signal available for the context-window
|
||||
* heuristic when the usage carries no explicit context_window.
|
||||
*/
|
||||
model: string | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -179,6 +186,7 @@ export function reduceChatBlocks(
|
||||
cacheRead: msg.usage.cache_read_input_tokens ?? 0,
|
||||
contextSize: calculateContextSize(msg.usage),
|
||||
contextWindow: msg.usage.context_window ?? null,
|
||||
model: msg.model ?? null,
|
||||
timestamp: msg.createdAt
|
||||
}
|
||||
break
|
||||
|
||||
@@ -156,6 +156,8 @@ export function HappyComposer(props: {
|
||||
contextSize?: number
|
||||
contextCacheRead?: number
|
||||
contextWindow?: number | null
|
||||
/** Model for the context-window heuristic; see StatusBar.contextModel. */
|
||||
contextModel?: string | null
|
||||
controlledByUser?: boolean
|
||||
agentFlavor?: string | null
|
||||
availableModelOptions?: Array<{ value: string | null; label: string }>
|
||||
@@ -229,6 +231,7 @@ export function HappyComposer(props: {
|
||||
contextSize,
|
||||
contextCacheRead,
|
||||
contextWindow,
|
||||
contextModel,
|
||||
controlledByUser = false,
|
||||
agentFlavor,
|
||||
availableModelOptions,
|
||||
@@ -1330,6 +1333,7 @@ export function HappyComposer(props: {
|
||||
contextSize={contextSize}
|
||||
contextCacheRead={contextCacheRead}
|
||||
contextWindow={contextWindow}
|
||||
contextModel={contextModel}
|
||||
model={model}
|
||||
modelReasoningEffort={modelReasoningEffort}
|
||||
serviceTier={serviceTier}
|
||||
|
||||
@@ -196,6 +196,13 @@ export function StatusBar(props: {
|
||||
contextSize?: number
|
||||
contextCacheRead?: number
|
||||
contextWindow?: number | null
|
||||
/**
|
||||
* Model to use for the context-window fallback heuristic when
|
||||
* contextWindow is absent. Falls back to `model`. Callers pass the
|
||||
* usage-bearing message's own model here so local Claude sessions (whose
|
||||
* session.model is often null) still resolve a plausible window.
|
||||
*/
|
||||
contextModel?: string | null
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
@@ -212,30 +219,31 @@ export function StatusBar(props: {
|
||||
[props.active, props.thinking, props.agentState, props.voiceStatus, props.backgroundTaskCount, t]
|
||||
)
|
||||
|
||||
const contextHeuristicModel = props.contextModel ?? props.model
|
||||
const contextWarning = useMemo(
|
||||
() => {
|
||||
if (props.contextSize === undefined) return null
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
|
||||
if (!maxContextSize) return null
|
||||
return getContextWarning(props.contextSize, maxContextSize)
|
||||
},
|
||||
[props.contextSize, props.contextWindow, props.model, props.agentFlavor]
|
||||
[props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor]
|
||||
)
|
||||
const contextUsageLabel = useMemo(() => {
|
||||
if (props.contextSize === undefined) return null
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
|
||||
return formatContextUsageLabel(props.contextSize, maxContextSize)
|
||||
}, [props.contextSize, props.contextWindow, props.model, props.agentFlavor])
|
||||
}, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor])
|
||||
const compactContextUsageLabel = useMemo(() => {
|
||||
if (props.contextSize === undefined) return null
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
|
||||
return formatCompactContextUsageLabel(props.contextSize, maxContextSize)
|
||||
}, [props.contextSize, props.contextWindow, props.model, props.agentFlavor])
|
||||
}, [props.contextSize, props.contextWindow, contextHeuristicModel, props.agentFlavor])
|
||||
const contextUsageDetails = useMemo(() => {
|
||||
if (props.contextSize === undefined) return null
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(props.model, props.agentFlavor)
|
||||
const maxContextSize = props.contextWindow ?? getContextBudgetTokens(contextHeuristicModel, props.agentFlavor)
|
||||
return getContextUsageDetails(props.contextSize, maxContextSize, props.contextCacheRead)
|
||||
}, [props.contextSize, props.contextCacheRead, props.contextWindow, props.model, props.agentFlavor])
|
||||
}, [props.contextSize, props.contextCacheRead, props.contextWindow, contextHeuristicModel, props.agentFlavor])
|
||||
const contextUsedPercentage = contextUsageDetails?.usedPercentage ?? null
|
||||
|
||||
const permissionMode = props.permissionMode
|
||||
|
||||
@@ -1467,6 +1467,7 @@ function SessionChatInner(props: SessionChatProps) {
|
||||
contextSize={reduced.latestUsage?.contextSize}
|
||||
contextCacheRead={reduced.latestUsage?.cacheRead}
|
||||
contextWindow={reduced.latestUsage?.contextWindow ?? piContextWindow}
|
||||
contextModel={reduced.latestUsage?.model ?? props.session.model}
|
||||
controlledByUser={controlledByUser}
|
||||
onCollaborationModeChange={
|
||||
codexCollaborationModeSupported && props.session.active && !controlledByUser
|
||||
|
||||
Reference in New Issue
Block a user