fix: keep inspector context responsive

The session inspector now asks the resumed CLI for a fast structural context estimate instead of forcing the full token-counting API path. This preserves live CLI state for system prompt, tools, MCP tools, skills, and messages while avoiding the 20s timeout that made historical sessions appear stuck or fall back to transcript-only estimates.

Constraint: Inspector requests must return quickly for third-party providers and historical resumed sessions.

Rejected: Increase the server timeout | the slow path can still block on provider token counting and keeps the UI feeling broken.

Confidence: high

Scope-risk: narrow

Directive: Keep interactive inspector context on the estimateOnly control path unless the UI explicitly supports a slow precise refresh.

Tested: bun test src/server/__tests__/conversations.test.ts -t 'structured session inspection|Sonnet 4.6 transcript usage'

Tested: cd desktop && bun run test -- --run src/i18n/index.test.tsx

Tested: cd desktop && bun run build

Tested: direct /api/sessions/:id/inspection?includeContext=1 returned live context in 0.046867s with System prompt, System tools, MCP tools, Messages

Tested: agent-browser automation verified no loading/error and visible System prompt/System tools/MCP tools/Messages

Not-tested: bare root tsc --noEmit, because current tsconfig scans existing desktop/src-tauri/target generated binary assets unrelated to this change
This commit is contained in:
程序员阿江(Relakkes) 2026-04-28 23:05:52 +08:00
parent f57f163604
commit 0508277998
7 changed files with 57 additions and 12 deletions

View File

@ -2973,6 +2973,7 @@ function runHeadlessStreaming(
agentDefinitions: appState.agentDefinitions, agentDefinitions: appState.agentDefinitions,
customSystemPrompt: options.systemPrompt, customSystemPrompt: options.systemPrompt,
appendSystemPrompt: options.appendSystemPrompt, appendSystemPrompt: options.appendSystemPrompt,
estimateOnly: Boolean(message.request.estimateOnly),
}, },
}) })
sendControlResponseSuccess(message, { ...data }) sendControlResponseSuccess(message, { ...data })

View File

@ -28,6 +28,7 @@ type CollectContextDataInput = {
agentDefinitions: AgentDefinitionsResult agentDefinitions: AgentDefinitionsResult
customSystemPrompt?: string customSystemPrompt?: string
appendSystemPrompt?: string appendSystemPrompt?: string
estimateOnly?: boolean
} }
} }
@ -43,6 +44,7 @@ export async function collectContextData(
agentDefinitions, agentDefinitions,
customSystemPrompt, customSystemPrompt,
appendSystemPrompt, appendSystemPrompt,
estimateOnly,
}, },
} = context } = context
@ -73,6 +75,7 @@ export async function collectContextData(
>, >,
undefined, // mainThreadAgentDefinition undefined, // mainThreadAgentDefinition
apiView, // original messages for API usage extraction apiView, // original messages for API usage extraction
{ estimateOnly },
) )
} }

View File

@ -176,6 +176,7 @@ export const SDKControlGetContextUsageRequestSchema = lazySchema(() =>
z z
.object({ .object({
subtype: z.literal('get_context_usage'), subtype: z.literal('get_context_usage'),
estimateOnly: z.boolean().optional(),
}) })
.describe( .describe(
'Requests a breakdown of current context window usage by category.', 'Requests a breakdown of current context window usage by category.',

View File

@ -690,6 +690,7 @@ describe('WebSocket Chat Integration', () => {
expect(body.usage.costDisplay).toBe('$0.1234') expect(body.usage.costDisplay).toBe('$0.1234')
expect(body.usage.source).toBe('current_process') expect(body.usage.source).toBe('current_process')
expect(body.context.model).toBe('mock-opus') expect(body.context.model).toBe('mock-opus')
expect(body.context.estimateOnly).toBe(true)
expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }]) expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }])
const basicRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) const basicRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)

View File

@ -239,6 +239,7 @@ ws.addEventListener('message', (event) => {
}), }),
), ),
model: 'mock-opus', model: 'mock-opus',
estimateOnly: parsed.request.estimateOnly === true,
memoryFiles: [], memoryFiles: [],
mcpTools: [{ name: 'mock_tool', serverName: 'mock', tokens: 144, isLoaded: true }], mcpTools: [{ name: 'mock_tool', serverName: 'mock', tokens: 144, isLoaded: true }],
agents: [], agents: [],

View File

@ -285,7 +285,11 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([ const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs), conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
includeContext includeContext
? conversationService.requestControl(sessionId, { subtype: 'get_context_usage' }, 20_000) ? conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
: Promise.resolve(null), : Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs), conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
]) ])

View File

@ -77,7 +77,12 @@ export const TOOL_TOKEN_COUNT_OVERHEAD = 500
async function countTokensWithFallback( async function countTokensWithFallback(
messages: Anthropic.Beta.Messages.BetaMessageParam[], messages: Anthropic.Beta.Messages.BetaMessageParam[],
tools: Anthropic.Beta.Messages.BetaToolUnion[], tools: Anthropic.Beta.Messages.BetaToolUnion[],
estimateOnly = false,
): Promise<number | null> { ): Promise<number | null> {
if (estimateOnly) {
return roughTokenCountEstimation(jsonStringify({ messages, tools }))
}
try { try {
const result = await countMessagesTokensWithAPI(messages, tools) const result = await countMessagesTokensWithAPI(messages, tools)
if (result !== null) { if (result !== null) {
@ -236,6 +241,7 @@ export async function countToolDefinitionTokens(
getToolPermissionContext: () => Promise<ToolPermissionContext>, getToolPermissionContext: () => Promise<ToolPermissionContext>,
agentInfo: AgentDefinitionsResult | null, agentInfo: AgentDefinitionsResult | null,
model?: string, model?: string,
estimateOnly = false,
): Promise<number> { ): Promise<number> {
const toolSchemas = await Promise.all( const toolSchemas = await Promise.all(
tools.map(tool => tools.map(tool =>
@ -247,7 +253,7 @@ export async function countToolDefinitionTokens(
}), }),
), ),
) )
const result = await countTokensWithFallback([], toolSchemas) const result = await countTokensWithFallback([], toolSchemas, estimateOnly)
if (result === null || result === 0) { if (result === null || result === 0) {
const toolNames = tools.map(t => t.name).join(', ') const toolNames = tools.map(t => t.name).join(', ')
logForDebugging( logForDebugging(
@ -271,6 +277,7 @@ function extractSectionName(content: string): string {
async function countSystemTokens( async function countSystemTokens(
effectiveSystemPrompt: readonly string[], effectiveSystemPrompt: readonly string[],
estimateOnly = false,
): Promise<{ ): Promise<{
systemPromptTokens: number systemPromptTokens: number
systemPromptSections: SystemPromptSectionDetail[] systemPromptSections: SystemPromptSectionDetail[]
@ -298,7 +305,7 @@ async function countSystemTokens(
const systemTokenCounts = await Promise.all( const systemTokenCounts = await Promise.all(
namedEntries.map(({ content }) => namedEntries.map(({ content }) =>
countTokensWithFallback([{ role: 'user', content }], []), countTokensWithFallback([{ role: 'user', content }], [], estimateOnly),
), ),
) )
@ -317,7 +324,7 @@ async function countSystemTokens(
return { systemPromptTokens, systemPromptSections } return { systemPromptTokens, systemPromptSections }
} }
async function countMemoryFileTokens(): Promise<{ async function countMemoryFileTokens(estimateOnly = false): Promise<{
memoryFileDetails: MemoryFile[] memoryFileDetails: MemoryFile[]
claudeMdTokens: number claudeMdTokens: number
}> { }> {
@ -342,6 +349,7 @@ async function countMemoryFileTokens(): Promise<{
const tokens = await countTokensWithFallback( const tokens = await countTokensWithFallback(
[{ role: 'user', content: file.content }], [{ role: 'user', content: file.content }],
[], [],
estimateOnly,
) )
return { file, tokens: tokens || 0 } return { file, tokens: tokens || 0 }
@ -366,6 +374,7 @@ async function countBuiltInToolTokens(
agentInfo: AgentDefinitionsResult | null, agentInfo: AgentDefinitionsResult | null,
model?: string, model?: string,
messages?: Message[], messages?: Message[],
estimateOnly = false,
): Promise<{ ): Promise<{
builtInToolTokens: number builtInToolTokens: number
deferredBuiltinDetails: DeferredBuiltinTool[] deferredBuiltinDetails: DeferredBuiltinTool[]
@ -405,6 +414,7 @@ async function countBuiltInToolTokens(
getToolPermissionContext, getToolPermissionContext,
agentInfo, agentInfo,
model, model,
estimateOnly,
) )
: 0 : 0
@ -469,6 +479,7 @@ async function countBuiltInToolTokens(
getToolPermissionContext, getToolPermissionContext,
agentInfo, agentInfo,
model, model,
estimateOnly,
), ),
), ),
) )
@ -496,6 +507,7 @@ async function countBuiltInToolTokens(
getToolPermissionContext, getToolPermissionContext,
agentInfo, agentInfo,
model, model,
estimateOnly,
) )
return { return {
builtInToolTokens: alwaysLoadedTokens + deferredTokens, builtInToolTokens: alwaysLoadedTokens + deferredTokens,
@ -522,6 +534,7 @@ async function countSlashCommandTokens(
tools: Tools, tools: Tools,
getToolPermissionContext: () => Promise<ToolPermissionContext>, getToolPermissionContext: () => Promise<ToolPermissionContext>,
agentInfo: AgentDefinitionsResult | null, agentInfo: AgentDefinitionsResult | null,
estimateOnly = false,
): Promise<{ ): Promise<{
slashCommandTokens: number slashCommandTokens: number
commandInfo: { totalCommands: number; includedCommands: number } commandInfo: { totalCommands: number; includedCommands: number }
@ -540,6 +553,8 @@ async function countSlashCommandTokens(
[slashCommandTool], [slashCommandTool],
getToolPermissionContext, getToolPermissionContext,
agentInfo, agentInfo,
undefined,
estimateOnly,
) )
return { return {
@ -555,6 +570,7 @@ async function countSkillTokens(
tools: Tools, tools: Tools,
getToolPermissionContext: () => Promise<ToolPermissionContext>, getToolPermissionContext: () => Promise<ToolPermissionContext>,
agentInfo: AgentDefinitionsResult | null, agentInfo: AgentDefinitionsResult | null,
estimateOnly = false,
): Promise<{ ): Promise<{
skillTokens: number skillTokens: number
skillInfo: { skillInfo: {
@ -582,6 +598,8 @@ async function countSkillTokens(
[slashCommandTool], [slashCommandTool],
getToolPermissionContext, getToolPermissionContext,
agentInfo, agentInfo,
undefined,
estimateOnly,
) )
// Calculate per-skill token estimates based on frontmatter only // Calculate per-skill token estimates based on frontmatter only
@ -619,6 +637,7 @@ export async function countMcpToolTokens(
agentInfo: AgentDefinitionsResult | null, agentInfo: AgentDefinitionsResult | null,
model: string, model: string,
messages?: Message[], messages?: Message[],
estimateOnly = false,
): Promise<{ ): Promise<{
mcpToolTokens: number mcpToolTokens: number
mcpToolDetails: McpTool[] mcpToolDetails: McpTool[]
@ -633,6 +652,7 @@ export async function countMcpToolTokens(
getToolPermissionContext, getToolPermissionContext,
agentInfo, agentInfo,
model, model,
estimateOnly,
) )
// Subtract the single overhead since we made one bulk call // Subtract the single overhead since we made one bulk call
const totalTokens = Math.max( const totalTokens = Math.max(
@ -729,9 +749,10 @@ export async function countMcpToolTokens(
} }
} }
async function countCustomAgentTokens(agentDefinitions: { async function countCustomAgentTokens(
activeAgents: AgentDefinition[] agentDefinitions: { activeAgents: AgentDefinition[] },
}): Promise<{ estimateOnly = false,
): Promise<{
agentTokens: number agentTokens: number
agentDetails: Agent[] agentDetails: Agent[]
}> { }> {
@ -751,6 +772,7 @@ async function countCustomAgentTokens(agentDefinitions: {
}, },
], ],
[], [],
estimateOnly,
), ),
), ),
) )
@ -852,6 +874,7 @@ function processAttachment(
async function approximateMessageTokens( async function approximateMessageTokens(
messages: Message[], messages: Message[],
estimateOnly = false,
): Promise<MessageBreakdown> { ): Promise<MessageBreakdown> {
const microcompactResult = await microcompactMessages(messages) const microcompactResult = await microcompactMessages(messages)
@ -909,6 +932,7 @@ async function approximateMessageTokens(
return _.message return _.message
}), }),
[], [],
estimateOnly,
) )
breakdown.totalTokens = approximateMessageTokens ?? 0 breakdown.totalTokens = approximateMessageTokens ?? 0
@ -926,7 +950,9 @@ export async function analyzeContextUsage(
mainThreadAgentDefinition?: AgentDefinition, mainThreadAgentDefinition?: AgentDefinition,
/** Original messages before microcompact, used to extract API usage */ /** Original messages before microcompact, used to extract API usage */
originalMessages?: Message[], originalMessages?: Message[],
analysisOptions?: { estimateOnly?: boolean },
): Promise<ContextData> { ): Promise<ContextData> {
const estimateOnly = analysisOptions?.estimateOnly ?? false
const runtimeModel = getRuntimeMainLoopModel({ const runtimeModel = getRuntimeMainLoopModel({
permissionMode: (await getToolPermissionContext()).mode, permissionMode: (await getToolPermissionContext()).mode,
mainLoopModel: model, mainLoopModel: model,
@ -961,14 +987,15 @@ export async function analyzeContextUsage(
{ slashCommandTokens, commandInfo }, { slashCommandTokens, commandInfo },
messageBreakdown, messageBreakdown,
] = await Promise.all([ ] = await Promise.all([
countSystemTokens(effectiveSystemPrompt), countSystemTokens(effectiveSystemPrompt, estimateOnly),
countMemoryFileTokens(), countMemoryFileTokens(estimateOnly),
countBuiltInToolTokens( countBuiltInToolTokens(
tools, tools,
getToolPermissionContext, getToolPermissionContext,
agentDefinitions, agentDefinitions,
runtimeModel, runtimeModel,
messages, messages,
estimateOnly,
), ),
countMcpToolTokens( countMcpToolTokens(
tools, tools,
@ -976,10 +1003,16 @@ export async function analyzeContextUsage(
agentDefinitions, agentDefinitions,
runtimeModel, runtimeModel,
messages, messages,
estimateOnly,
), ),
countCustomAgentTokens(agentDefinitions), countCustomAgentTokens(agentDefinitions, estimateOnly),
countSlashCommandTokens(tools, getToolPermissionContext, agentDefinitions), countSlashCommandTokens(
approximateMessageTokens(messages), tools,
getToolPermissionContext,
agentDefinitions,
estimateOnly,
),
approximateMessageTokens(messages, estimateOnly),
]) ])
// Count skills separately with error isolation // Count skills separately with error isolation
@ -987,6 +1020,7 @@ export async function analyzeContextUsage(
tools, tools,
getToolPermissionContext, getToolPermissionContext,
agentDefinitions, agentDefinitions,
estimateOnly,
) )
const skillInfo = skillResult.skillInfo const skillInfo = skillResult.skillInfo
// Use sum of individual skill token estimates (matches what's shown in details) // Use sum of individual skill token estimates (matches what's shown in details)