fix: prevent session inspector context stalls

Session inspection was coupling quick status and usage rendering to live context control requests, so a slow get_context_usage path could leave the desktop inspector stuck on a loading state. The desktop panel now loads basic inspection data first, renders a transcript-based context estimate immediately, and only asks for live context details as a background refinement.

The translation hook now returns a stable function per locale so effects that depend on translation do not reset and re-fetch after every render.

Constraint: Third-party provider sessions may not expose reliable live context capabilities through the control request path.
Rejected: Keep waiting on live get_context_usage for the context tab | it recreates the user-visible loading stall.
Rejected: Hardcode provider-specific context windows | provider capabilities are not always known from the desktop session record.
Confidence: high
Scope-risk: moderate
Directive: Do not make the inspector first render depend on live CLI control requests without a transcript or cached fallback.
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: git diff --check
Tested: agent-browser verified /context renders transcript estimate without Loading context data
Not-tested: Provider-specific true context-window discovery for every third-party vendor
This commit is contained in:
程序员阿江(Relakkes) 2026-04-28 22:52:18 +08:00
parent 15c0982159
commit f57f163604
10 changed files with 318 additions and 19 deletions

View File

@ -135,6 +135,7 @@ export type SessionInspectionResponse = {
}
usage?: SessionUsageSnapshot
context?: SessionContextSnapshot
contextEstimate?: SessionContextSnapshot
errors?: Record<string, string>
}
@ -177,9 +178,12 @@ export const sessionsApi = {
return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`)
},
getInspection(sessionId: string) {
return api.get<SessionInspectionResponse>(`/api/sessions/${sessionId}/inspection`, {
timeout: 25_000,
getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number }) {
const query = options?.includeContext === undefined
? ''
: `?includeContext=${options.includeContext ? '1' : '0'}`
return api.get<SessionInspectionResponse>(`/api/sessions/${sessionId}/inspection${query}`, {
timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000),
})
},

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { skillsApi } from '../../api/skills'
import { mcpApi } from '../../api/mcp'
import {
@ -494,8 +494,19 @@ function ContextOverview({ context, categories, t }: { context: SessionContextSn
)
}
function ContextTab({ context, error, t }: { context?: SessionContextSnapshot; error?: string; t: Translate }) {
function ContextTab({
context,
error,
loading,
t,
}: {
context?: SessionContextSnapshot
error?: string
loading?: boolean
t: Translate
}) {
if (error && !context) return <ErrorState message={error} />
if (loading && !context) return <LoadingState label={t('slash.inspector.context.loading')} />
if (!context) {
return <EmptyState title={t('slash.inspector.context.emptyTitle')} body={t('slash.inspector.context.emptyBody')} />
}
@ -668,6 +679,9 @@ function SessionInspectorPanel({
const [selectedTab, setSelectedTab] = useState<SessionInspectorTab>(() => sessionInspectorInitialTab(command))
const [data, setData] = useState<SessionInspectionResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [contextLoading, setContextLoading] = useState(false)
const [contextError, setContextError] = useState<string | null>(null)
const contextRequestSessionRef = useRef<string | null>(null)
useEffect(() => {
if (command !== 'status' && command !== 'cost' && command !== 'context') return
@ -682,7 +696,10 @@ function SessionInspectorPanel({
let cancelled = false
setData(null)
setError(null)
sessionsApi.getInspection(sessionId)
setContextLoading(false)
setContextError(null)
contextRequestSessionRef.current = null
sessionsApi.getInspection(sessionId, { includeContext: false })
.then((response) => {
if (!cancelled) setData(assertSessionInspectionResponse(response, t))
})
@ -692,7 +709,41 @@ function SessionInspectorPanel({
return () => {
cancelled = true
}
}, [sessionId])
}, [sessionId, t])
useEffect(() => {
if (!sessionId || selectedTab !== 'context' || data === null || data.context) return
if (contextRequestSessionRef.current === sessionId) return
contextRequestSessionRef.current = sessionId
let cancelled = false
setContextLoading(true)
setContextError(null)
sessionsApi.getInspection(sessionId, { includeContext: true, timeout: 45_000 })
.then((response) => {
if (cancelled) return
const inspected = assertSessionInspectionResponse(response, t)
setData((current) => current
? {
...current,
context: inspected.context,
errors: {
...(current.errors ?? {}),
...(inspected.errors ?? {}),
},
}
: inspected)
setContextError(inspected.errors?.context ?? null)
})
.catch((err) => {
if (!cancelled) setContextError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setContextLoading(false)
})
return () => {
cancelled = true
}
}, [data, selectedTab, sessionId, t])
const tabs: Array<{ id: SessionInspectorTab; label: string }> = [
{ id: 'status', label: t('slash.inspector.tab.status') },
@ -709,7 +760,12 @@ function SessionInspectorPanel({
) : selectedTab === 'usage' ? (
<UsageTab usage={data.usage} context={data.context} error={data.errors?.usage} t={t} />
) : selectedTab === 'context' ? (
<ContextTab context={data.context} error={data.errors?.context} t={t} />
<ContextTab
context={data.context ?? data.contextEstimate}
error={contextError ?? data.errors?.context}
loading={contextLoading && !data.contextEstimate}
t={t}
/>
) : (
<StatusTab data={data} commands={commands} t={t} />
)}

View File

@ -0,0 +1,29 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
import { useTranslation } from '.'
describe('useTranslation', () => {
afterEach(() => {
act(() => {
useSettingsStore.getState().setLocale('zh')
})
})
it('keeps the translation function stable until the locale changes', () => {
act(() => {
useSettingsStore.getState().setLocale('zh')
})
const { result, rerender } = renderHook(() => useTranslation())
const initial = result.current
rerender()
expect(result.current).toBe(initial)
act(() => {
useSettingsStore.getState().setLocale('en')
})
expect(result.current).not.toBe(initial)
})
})

View File

@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { en, type TranslationKey } from './locales/en'
import { zh } from './locales/zh'
@ -38,8 +39,11 @@ export function translate(
*/
export function useTranslation() {
const locale = useSettingsStore((s) => s.locale)
return (key: TranslationKey, params?: Record<string, string | number>) =>
translate(locale, key, params)
return useCallback(
(key: TranslationKey, params?: Record<string, string | number>) =>
translate(locale, key, params),
[locale],
)
}
/**

View File

@ -572,6 +572,7 @@ export const en = {
'slash.inspector.usage.tokens': 'Tokens',
'slash.inspector.usage.noModelTitle': 'No model usage',
'slash.inspector.usage.noModelBody': 'Token usage will appear here after a model response is recorded.',
'slash.inspector.context.loading': 'Loading context data',
'slash.inspector.context.emptyTitle': 'No context data yet',
'slash.inspector.context.emptyBody': 'Context usage requires an active CLI session.',
'slash.inspector.context.windowUsage': 'Context window usage',

View File

@ -574,6 +574,7 @@ export const zh: Record<TranslationKey, string> = {
'slash.inspector.usage.tokens': 'Tokens',
'slash.inspector.usage.noModelTitle': '暂无模型用量',
'slash.inspector.usage.noModelBody': '记录到模型回复后,这里会显示 token 用量。',
'slash.inspector.context.loading': '正在加载上下文数据',
'slash.inspector.context.emptyTitle': '暂无上下文数据',
'slash.inspector.context.emptyBody': '上下文用量需要活跃的 CLI 会话。',
'slash.inspector.context.windowUsage': '上下文窗口用量',

View File

@ -278,6 +278,61 @@ describe('ConversationService', () => {
await fs.rm(workDir, { recursive: true, force: true })
}
})
it('should reconstruct Sonnet 4.6 transcript usage before CLI config is initialized', async () => {
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousNodeEnv = process.env.NODE_ENV
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-sonnet-'))
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-sonnet-'))
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
process.env.NODE_ENV = 'development'
try {
const svc = new SessionService()
const { sessionId } = await svc.createSession(workDir)
const found = await svc.findSessionFile(sessionId)
expect(found).not.toBeNull()
await fs.appendFile(found!.filePath, JSON.stringify({
type: 'assistant',
uuid: crypto.randomUUID(),
timestamp: '2026-04-27T12:00:00.000Z',
cwd: workDir,
version: '999.0.0-test',
message: {
role: 'assistant',
model: 'claude-sonnet-4-6',
content: [{ type: 'text', text: 'hello' }],
usage: {
input_tokens: 100,
output_tokens: 20,
},
},
}) + '\n')
const usage = await svc.getTranscriptUsage(sessionId)
const contextEstimate = await svc.getTranscriptContextEstimate(sessionId)
expect(usage?.models[0]?.model).toBe('claude-sonnet-4-6')
expect(usage?.models[0]?.contextWindow).toBe(200_000)
expect(contextEstimate?.model).toBe('claude-sonnet-4-6')
expect(contextEstimate?.totalTokens).toBe(100)
expect(contextEstimate?.rawMaxTokens).toBe(200_000)
} finally {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
await fs.rm(tmpConfigDir, { recursive: true, force: true })
await fs.rm(workDir, { recursive: true, force: true })
}
})
})
// ============================================================================
@ -636,6 +691,12 @@ describe('WebSocket Chat Integration', () => {
expect(body.usage.source).toBe('current_process')
expect(body.context.model).toBe('mock-opus')
expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }])
const basicRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`)
expect(basicRes.status).toBe(200)
const basicBody = await basicRes.json() as any
expect(basicBody.usage.source).toBe('current_process')
expect(basicBody.context).toBeUndefined()
})
it('should complete the client turn when the CLI exits after startup', async () => {

View File

@ -106,7 +106,7 @@ export async function handleSessionsApi(
{ status: 405 }
)
}
return await getSessionInspection(sessionId)
return await getSessionInspection(sessionId, url)
}
// Route to conversations handler if sub-resource is 'chat'
@ -217,7 +217,8 @@ async function getSessionSlashCommands(sessionId: string): Promise<Response> {
return Response.json({ commands: slashCommands })
}
async function getSessionInspection(sessionId: string): Promise<Response> {
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
const includeContext = url.searchParams.get('includeContext') !== '0'
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
@ -264,6 +265,10 @@ async function getSessionInspection(sessionId: string): Promise<Response> {
errors: {},
}
const transcriptUsage = await sessionService.getTranscriptUsage(sessionId)
const transcriptContextEstimate = await sessionService.getTranscriptContextEstimate(sessionId)
if (transcriptContextEstimate) {
response.contextEstimate = transcriptContextEstimate
}
if (!active) {
if (transcriptUsage) {
@ -271,15 +276,18 @@ async function getSessionInspection(sessionId: string): Promise<Response> {
}
response.errors = {
...(transcriptUsage ? {} : { usage: 'CLI session is not running' }),
context: 'CLI session is not running',
...(includeContext ? { context: 'CLI session is not running' } : {}),
}
return Response.json(response)
}
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }),
conversationService.requestControl(sessionId, { subtype: 'get_context_usage' }, 20_000),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }),
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
includeContext
? conversationService.requestControl(sessionId, { subtype: 'get_context_usage' }, 20_000)
: Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
])
const errors: Record<string, string> = {}
@ -296,7 +304,10 @@ async function getSessionInspection(sessionId: string): Promise<Response> {
}
}
if (contextResult.status === 'fulfilled') {
if (!includeContext) {
// Context can be expensive on large live sessions. The desktop UI loads it
// separately when the context tab is actually selected.
} else if (contextResult.status === 'fulfilled' && contextResult.value) {
response.context = contextResult.value
} else {
errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason)

View File

@ -66,6 +66,7 @@ export function startServer(port = PORT, host = HOST) {
const server = Bun.serve<WebSocketData>({
port,
hostname: host,
idleTimeout: 60,
async fetch(req, server) {
const url = new URL(req.url)

View File

@ -12,7 +12,11 @@ import { ApiError } from '../middleware/errorHandler.js'
import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js'
import type { FileHistorySnapshot } from '../../utils/fileHistory.js'
import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js'
import { getContextWindowForModel, getModelMaxOutputTokens } from '../../utils/context.js'
import {
MODEL_CONTEXT_WINDOW_DEFAULT,
getContextWindowForModel,
getModelMaxOutputTokens,
} from '../../utils/context.js'
import { getCanonicalName } from '../../utils/model/model.js'
// ============================================================================
@ -93,6 +97,37 @@ export type TranscriptMetadataSnapshot = {
version?: string
}
export type TranscriptContextEstimate = {
categories: Array<{
name: string
tokens: number
color: string
isDeferred?: boolean
}>
totalTokens: number
maxTokens: number
rawMaxTokens: number
percentage: number
gridRows: Array<Array<{
color: string
isFilled: boolean
categoryName: string
tokens: number
percentage: number
squareFullness: number
}>>
model: string
memoryFiles: Array<{ path: string; type: string; tokens: number }>
mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }>
agents: Array<{ agentType: string; source: string; tokens: number }>
apiUsage: {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
}
/** Raw entry parsed from a single JSONL line */
type RawEntry = {
type?: string
@ -704,6 +739,20 @@ export class SessionService {
return `$${cost > 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}`
}
private getTranscriptContextWindow(model: string): number {
try {
return getContextWindowForModel(model)
} catch (err) {
if (
err instanceof Error &&
err.message.includes('Config accessed before allowed')
) {
return MODEL_CONTEXT_WINDOW_DEFAULT
}
throw err
}
}
async getTranscriptMetadata(sessionId: string): Promise<TranscriptMetadataSnapshot | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
@ -728,6 +777,88 @@ export class SessionService {
return metadata
}
async getTranscriptContextEstimate(sessionId: string): Promise<TranscriptContextEstimate | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
const entries = await this.readJsonlFile(found.filePath)
let latest: {
model: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
} | null = null
for (const entry of entries) {
const usage = entry.message?.usage
const model = entry.message?.model
if (!usage || typeof model !== 'string') continue
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0
const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0
const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0
const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens
if (promptTokens === 0 && outputTokens === 0) continue
latest = {
model,
inputTokens,
outputTokens,
cacheReadInputTokens,
cacheCreationInputTokens,
}
}
if (!latest) return null
const rawMaxTokens = this.getTranscriptContextWindow(latest.model)
const totalTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
const categories: TranscriptContextEstimate['categories'] = [
{ name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' },
{ name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' },
{ name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' },
{ name: 'Free space', tokens: Math.max(0, rawMaxTokens - totalTokens), color: '#a1a1aa', isDeferred: true },
].filter((category) => category.tokens > 0)
const filledSquares = Math.max(0, Math.min(100, Math.round((totalTokens / Math.max(1, rawMaxTokens)) * 100)))
const gridRows = Array.from({ length: 10 }, (_, row) =>
Array.from({ length: 10 }, (_, col) => {
const index = row * 10 + col
const isFilled = index < filledSquares
return {
color: isFilled ? '#8f3217' : '#a1a1aa',
isFilled,
categoryName: isFilled ? 'Input context' : 'Free space',
tokens: Math.round(rawMaxTokens / 100),
percentage: 1,
squareFullness: isFilled ? 1 : 0,
}
}),
)
return {
categories,
totalTokens,
maxTokens: rawMaxTokens,
rawMaxTokens,
percentage,
gridRows,
model: latest.model,
memoryFiles: [],
mcpTools: [],
agents: [],
apiUsage: {
input_tokens: latest.inputTokens,
output_tokens: latest.outputTokens,
cache_creation_input_tokens: latest.cacheCreationInputTokens,
cache_read_input_tokens: latest.cacheReadInputTokens,
},
}
}
async getTranscriptUsage(sessionId: string): Promise<TranscriptUsageSnapshot | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
@ -794,7 +925,7 @@ export class SessionService {
webSearchRequests: 0,
costUSD: 0,
costDisplay: '$0.0000',
contextWindow: getContextWindowForModel(model),
contextWindow: this.getTranscriptContextWindow(model),
maxOutputTokens: getModelMaxOutputTokens(model).default,
}
models.set(model, modelUsage)