fix(claude): propagate real contextWindow from SDK result to web (#720)

The "Default" model in NewSession sends no --model flag, so Claude CLI
picks its own default (e.g. Opus 4.7 [1m] on Pro accounts). The web
status bar then falls back to 200K - 10K headroom = 190K because the
Claude SDK path never plumbs the real per-model contextWindow through
to the wire-level `modelContextWindow`, unlike the ACP/Codex backends.

Fix the gap in three places:

- cli/src/claude/sdk/types.ts: declare optional `modelUsage` on
  SDKResultMessage to surface what Claude CLI already emits
  (`modelUsage[<model>].contextWindow`).

- cli/src/claude/utils/sdkToLogConverter.ts: on system.init, capture
  the resolved model name (full form with `[1m]` suffix) and derive
  an initial contextWindow from the suffix. On every assistant
  message, inject the cached contextWindow into `usage.context_window`
  when absent. On result, refine the cache with the authoritative
  value from `modelUsage`.

- web/src/chat/normalizeAgent.ts: forward `context_window` through
  the assistant usage normalization, so the existing reducer path
  (reducer.ts:175 → StatusBar.tsx:175) can render the real window.

Closes #719.
This commit is contained in:
hanger
2026-05-28 15:02:20 +08:00
committed by GitHub
parent a5c5ee7a2a
commit 485bb46db8
4 changed files with 151 additions and 2 deletions
+10
View File
@@ -66,6 +66,16 @@ export interface SDKResultMessage extends SDKMessage {
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
modelUsage?: Record<string, {
inputTokens?: number
outputTokens?: number
cacheReadInputTokens?: number
cacheCreationInputTokens?: number
webSearchRequests?: number
costUSD?: number
contextWindow?: number
maxOutputTokens?: number
}>
total_cost_usd: number
duration_ms: number
duration_api_ms: number
@@ -200,6 +200,116 @@ describe('SDKToLogConverter', () => {
})
})
describe('Context window propagation', () => {
function makeAssistantMessage(): SDKAssistantMessage {
return {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
usage: {
input_tokens: 10,
output_tokens: 20,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
service_tier: 'standard'
}
} as any
}
}
it('infers 1M contextWindow from [1m] suffix on system.init', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-1',
model: 'claude-opus-4-7[1m]'
}
converter.convert(initMsg)
const assistantLog = converter.convert(makeAssistantMessage()) as any
expect(assistantLog?.message?.usage?.context_window).toBe(1_000_000)
})
it('infers 200k contextWindow when [1m] suffix is absent', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-2',
model: 'claude-sonnet-4-6'
}
converter.convert(initMsg)
const assistantLog = converter.convert(makeAssistantMessage()) as any
expect(assistantLog?.message?.usage?.context_window).toBe(200_000)
})
it('refines contextWindow from result.modelUsage and applies to later assistants', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-3',
model: 'claude-opus-4-7[1m]'
}
converter.convert(initMsg)
// First assistant gets the 1M estimate from the [1m] suffix
const first = converter.convert(makeAssistantMessage()) as any
expect(first?.message?.usage?.context_window).toBe(1_000_000)
// Result message reports authoritative contextWindow (say, 500k)
const resultMsg: SDKResultMessage = {
type: 'result',
subtype: 'success',
num_turns: 1,
total_cost_usd: 0,
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
session_id: 'session-3',
modelUsage: {
'claude-opus-4-7[1m]': { contextWindow: 500_000 }
}
}
converter.convert(resultMsg)
// Subsequent assistant message uses the refined value
const second = converter.convert(makeAssistantMessage()) as any
expect(second?.message?.usage?.context_window).toBe(500_000)
})
it('does not overwrite an explicit context_window already set by upstream', () => {
const initMsg: SDKSystemMessage = {
type: 'system',
subtype: 'init',
session_id: 'session-4',
model: 'claude-opus-4-7[1m]'
}
converter.convert(initMsg)
const assistantMsg: SDKAssistantMessage = {
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
usage: {
input_tokens: 10,
output_tokens: 20,
context_window: 42
}
} as any
}
const log = converter.convert(assistantMsg) as any
expect(log?.message?.usage?.context_window).toBe(42)
})
it('leaves usage untouched when no system.init was seen', () => {
const log = converter.convert(makeAssistantMessage()) as any
expect(log?.message?.usage?.context_window).toBeUndefined()
})
})
describe('Parent-child relationships', () => {
it('should track parent UUIDs across messages', () => {
const msg1: SDKUserMessage = {
+29 -1
View File
@@ -57,6 +57,8 @@ export class SDKToLogConverter {
private context: ConversionContext
private responses?: Map<string, PermissionResponse>
private sidechainLastUUID = new Map<string, string>();
private resolvedModel: string | null = null
private modelContextWindow: number | null = null
constructor(
context: Omit<ConversionContext, 'parentUuid'>,
@@ -195,6 +197,13 @@ 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) {
const usage = message.usage as Record<string, unknown>
if (usage.context_window === undefined) {
usage.context_window = this.modelContextWindow
}
}
logMessage = {
...baseFields,
type: 'assistant',
@@ -220,6 +229,15 @@ 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.
if (systemMsg.subtype === 'init' && typeof systemMsg.model === 'string') {
this.resolvedModel = systemMsg.model
this.modelContextWindow = systemMsg.model.endsWith('[1m]') ? 1_000_000 : 200_000
}
// System messages are typically not sent to logs
// but we can convert them if needed
logMessage = {
@@ -237,7 +255,17 @@ export class SDKToLogConverter {
case 'result': {
// Result messages are not converted to log messages
// They're SDK-specific messages that indicate session completion
// Not part of the actual conversation log
// Not part of the actual conversation log.
//
// But they carry the authoritative per-model contextWindow,
// which we cache and inject into subsequent assistant messages.
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
}
}
break
}
+2 -1
View File
@@ -274,7 +274,8 @@ function normalizeAssistantOutput(
output_tokens: outputTokens,
cache_creation_input_tokens: asNumber(usage?.cache_creation_input_tokens) ?? undefined,
cache_read_input_tokens: asNumber(usage?.cache_read_input_tokens) ?? undefined,
service_tier: asString(usage?.service_tier) ?? undefined
service_tier: asString(usage?.service_tier) ?? undefined,
context_window: asNumber(usage?.context_window) ?? undefined
} : undefined
}
}