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,
customSystemPrompt: options.systemPrompt,
appendSystemPrompt: options.appendSystemPrompt,
estimateOnly: Boolean(message.request.estimateOnly),
},
})
sendControlResponseSuccess(message, { ...data })

View File

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

View File

@ -176,6 +176,7 @@ export const SDKControlGetContextUsageRequestSchema = lazySchema(() =>
z
.object({
subtype: z.literal('get_context_usage'),
estimateOnly: z.boolean().optional(),
})
.describe(
'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.source).toBe('current_process')
expect(body.context.model).toBe('mock-opus')
expect(body.context.estimateOnly).toBe(true)
expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }])
const basicRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)

View File

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

View File

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

View File

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