fix: avoid misleading context loading on empty desktop sessions

The desktop composer now treats draft and not-yet-running sessions as pending
until a real CLI context snapshot exists. Context inspection has a lightweight
context-only path so the composer and /context panel do not wait on unrelated
usage or MCP status work, and desktop boot no longer blocks the first shell
render on tab restoration.

Constraint: Empty composer state has no live CLI context to inspect until a session starts
Rejected: Display 0% before live context exists | it implied one user message consumed the fixed prompt/tool baseline
Confidence: high
Scope-risk: moderate
Directive: Do not show numeric context usage without a real context snapshot
Tested: cd desktop && bun run test -- pages.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: bun run check:server
Tested: Browser reload at http://127.0.0.1:5174 with cleared open-tabs showed pending context and no /inspection request
This commit is contained in:
程序员阿江(Relakkes) 2026-05-05 18:25:53 +08:00
parent 2459488703
commit e4f9825bc1
13 changed files with 375 additions and 75 deletions

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { renderToStaticMarkup } from 'react-dom/server'
import '@testing-library/jest-dom'
import { skillsApi } from '../api/skills'
@ -75,6 +76,7 @@ import { ToolInspection } from '../pages/ToolInspection'
// Layout components (chrome is now here, not in pages)
import { Sidebar } from '../components/layout/Sidebar'
import { UserMessage } from '../components/chat/UserMessage'
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
import { useChatStore } from '../stores/chatStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
@ -136,6 +138,29 @@ describe('Content-only pages render without errors', () => {
expect(container.innerHTML).toContain('Ask anything')
})
it('EmptySession shows draft context usage before a session is created', async () => {
render(<EmptySession />)
const indicator = await screen.findByLabelText('Context usage not calculated')
expect(indicator).toHaveTextContent('--')
expect(vi.mocked(sessionsApi.getInspection)).not.toHaveBeenCalled()
})
it('ContextUsageIndicator does not render a first-paint spinner for draft sessions', () => {
const html = renderToStaticMarkup(
<ContextUsageIndicator
chatState="idle"
messageCount={0}
fallbackModelLabel="kimi-k2.6"
draft
/>,
)
expect(html).toMatch(/aria-label="(Context usage not calculated|上下文用量待计算)"/)
expect(html).toContain('--')
expect(html).not.toContain('animate-spin')
})
it('EmptySession plus menu exposes uploads and slash commands before chat starts', () => {
render(<EmptySession />)
fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' }))
@ -677,7 +702,8 @@ describe('Content-only pages render without errors', () => {
expect(screen.getByText('120,000')).toBeInTheDocument()
expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, {
includeContext: true,
timeout: 45_000,
contextOnly: true,
timeout: 20_000,
})
useTabStore.setState({ tabs: [], activeTabId: null })
@ -739,6 +765,74 @@ describe('Content-only pages render without errors', () => {
useChatStore.setState({ sessions: {} })
})
it('ActiveSession treats an empty idle session without a running CLI as a 0% placeholder', async () => {
const SESSION_ID = 'context-empty-idle-session'
vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({
active: false,
status: {
sessionId: SESSION_ID,
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions',
model: 'kimi-k2.6',
},
errors: {
context: 'CLI session is not running',
},
})
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 0,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
const indicator = await screen.findByLabelText('Context usage not calculated')
expect(indicator).toHaveTextContent('--')
expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0)
expect(screen.getByText('Context usage will be calculated after the session starts.')).toBeInTheDocument()
expect(screen.queryByText('CLI session is not running')).not.toBeInTheDocument()
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
})
it('ActiveSession shows context estimate during compaction or reconnect fallback', async () => {
const SESSION_ID = 'context-estimate-session'
vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({
@ -825,6 +919,76 @@ describe('Content-only pages render without errors', () => {
useChatStore.setState({ sessions: {} })
})
it('ActiveSession keeps selected runtime model visible when context is unavailable', async () => {
const SESSION_ID = 'context-unavailable-model-session'
vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({
active: false,
status: {
sessionId: SESSION_ID,
workDir: '/workspace/project',
cwd: '/workspace/project',
permissionMode: 'bypassPermissions',
},
errors: {
context: 'CLI session is not running',
},
})
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
useSessionStore.setState({
sessions: [{
id: SESSION_ID,
title: 'Test',
createdAt: '2026-04-10T00:00:00.000Z',
modifiedAt: '2026-04-10T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
}],
activeSessionId: SESSION_ID,
isLoading: false,
error: null,
})
useSessionRuntimeStore.getState().setSelection(SESSION_ID, {
providerId: 'volcengine-provider',
modelId: 'kimi-k2.6',
})
useChatStore.setState({
sessions: {
[SESSION_ID]: {
messages: [{ id: 'm-1', type: 'assistant_text', content: 'ready', timestamp: Date.now() }],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
render(<ActiveSession />)
expect(await screen.findByLabelText('Context usage unavailable')).toBeInTheDocument()
expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0)
expect(screen.queryByText('Unknown model')).not.toBeInTheDocument()
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: {} })
useSessionRuntimeStore.setState({ selections: {} })
})
it('ActiveSession refreshes context usage when the selected runtime model changes', async () => {
const SESSION_ID = 'context-runtime-refresh-session'
vi.mocked(sessionsApi.getInspection)

View File

@ -274,11 +274,16 @@ export const sessionsApi = {
return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`)
},
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}`, {
getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number; contextOnly?: boolean }) {
const query = new URLSearchParams()
if (options?.includeContext !== undefined) {
query.set('includeContext', options.includeContext ? '1' : '0')
}
if (options?.contextOnly) {
query.set('contextOnly', '1')
}
const suffix = query.size > 0 ? `?${query.toString()}` : ''
return api.get<SessionInspectionResponse>(`/api/sessions/${sessionId}/inspection${suffix}`, {
timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000),
})
},

View File

@ -6,6 +6,7 @@ import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useTeamStore } from '../../stores/teamStore'
import { useSettingsStore } from '../../stores/settingsStore'
import {
formatWorkspaceReferencePrompt,
useWorkspaceChatContextStore,
@ -94,9 +95,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const runtimeSelection = useSessionRuntimeStore((state) =>
activeTabId ? state.selections[activeTabId] : undefined,
)
const currentModel = useSettingsStore((state) => state.currentModel)
const runtimeSelectionKey = runtimeSelection
? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}`
: undefined
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
@ -826,6 +829,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
chatState={chatState}
messageCount={messageCount}
runtimeSelectionKey={runtimeSelectionKey}
fallbackModelLabel={runtimeModelLabel}
compact={compact}
/>
)}

View File

@ -4,14 +4,17 @@ import { useTranslation } from '../../i18n'
import type { ChatState } from '../../types/chat'
type Props = {
sessionId: string
sessionId?: string
chatState: ChatState
messageCount: number
runtimeSelectionKey?: string
fallbackModelLabel?: string
draft?: boolean
compact?: boolean
}
const ACTIVE_REFRESH_MS = 30_000
const CONTEXT_REQUEST_TIMEOUT_MS = 20_000
function formatNumber(value: number | undefined) {
return new Intl.NumberFormat().format(value ?? 0)
@ -38,38 +41,66 @@ function pickUsedContextCategory(context: SessionContextSnapshot) {
.slice(0, 4)
}
function firstNonEmpty(...values: Array<string | undefined | null>) {
return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim()
}
function isCliNotRunningError(error: string | null) {
return error?.toLowerCase().includes('cli session is not running') ?? false
}
function shouldFetchContext(sessionId: string | undefined, draft: boolean) {
return Boolean(sessionId) && !draft
}
export function ContextUsageIndicator({
sessionId,
chatState,
messageCount,
runtimeSelectionKey = '',
fallbackModelLabel,
draft = false,
compact = false,
}: Props) {
const t = useTranslation()
const [context, setContext] = useState<SessionContextSnapshot | null>(null)
const [contextSource, setContextSource] = useState<'live' | 'estimate' | null>(null)
const [loading, setLoading] = useState(true)
const [loading, setLoading] = useState(() => shouldFetchContext(sessionId, draft))
const [error, setError] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
const [inspectionModel, setInspectionModel] = useState<string | null>(null)
const requestSeq = useRef(0)
const contextIdentityRef = useRef('')
const refresh = useCallback(async () => {
if (!sessionId) return
if (!sessionId || draft) {
setLoading(false)
return
}
const activeSessionId = sessionId
const seq = requestSeq.current + 1
requestSeq.current = seq
setLoading(true)
setError(null)
try {
const inspection = await sessionsApi.getInspection(sessionId, {
const inspection = await sessionsApi.getInspection(activeSessionId, {
includeContext: true,
timeout: 45_000,
contextOnly: true,
timeout: CONTEXT_REQUEST_TIMEOUT_MS,
})
if (seq !== requestSeq.current) return
const nextContext = inspection.context ?? inspection.contextEstimate ?? null
const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null
const usageModel = inspection.usage?.models.find((model) => firstNonEmpty(model.displayName, model.model)) ?? null
setContext(nextContext)
setContextSource(nextSource)
setInspectionModel(firstNonEmpty(
inspection.context?.model,
inspection.contextEstimate?.model,
inspection.status?.model,
usageModel?.displayName,
usageModel?.model,
) ?? null)
setError(nextContext ? null : inspection.errors?.context ?? null)
setUpdatedAt(Date.now())
} catch (err) {
@ -78,7 +109,7 @@ export function ContextUsageIndicator({
} finally {
if (seq === requestSeq.current) setLoading(false)
}
}, [sessionId])
}, [draft, sessionId])
useEffect(() => {
const contextIdentity = `${sessionId}:${runtimeSelectionKey}`
@ -89,13 +120,9 @@ export function ContextUsageIndicator({
setContextSource(null)
setError(null)
setUpdatedAt(null)
setInspectionModel(null)
}
void refresh()
if (!identityChanged) return
const retryTimer = setTimeout(() => {
void refresh()
}, 2_500)
return () => clearTimeout(retryTimer)
}, [messageCount, refresh, runtimeSelectionKey, sessionId])
useEffect(() => {
@ -111,6 +138,10 @@ export function ContextUsageIndicator({
return pickUsedContextCategory(context)
}, [context])
const hasPlaceholderContext = !context && (
draft || (!loading && messageCount === 0 && (!error || isCliNotRunningError(error)))
)
const isPendingContext = hasPlaceholderContext && !context
const percentage = context ? Math.max(0, Math.min(100, context.percentage)) : 0
const usedTokens = context?.totalTokens ?? 0
const maxTokens = context?.rawMaxTokens ?? 0
@ -126,8 +157,11 @@ export function ContextUsageIndicator({
: 'var(--color-surface-container-high)',
}
const displayPercent = context ? formatPercent(percentage) : '--'
const displayModel = firstNonEmpty(context?.model, inspectionModel, fallbackModelLabel)
const ariaLabel = context
? t('contextIndicator.ariaLabel', { percent: formatPercent(percentage) })
: isPendingContext
? t('contextIndicator.pendingAria')
: loading
? t('contextIndicator.loadingAria')
: t('contextIndicator.unavailableAria')
@ -144,18 +178,20 @@ export function ContextUsageIndicator({
compact ? 'px-2' : 'px-2.5'
}`}
>
<span
className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full"
style={ringStyle}
>
<span className="absolute inset-[3px] rounded-full bg-[var(--color-surface-container-lowest)]" />
<span className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full">
{loading && !context ? (
<span className="material-symbols-outlined relative animate-spin text-[13px]">progress_activity</span>
<span className="absolute inset-[2px] rounded-full border-2 border-[var(--color-text-tertiary)] border-t-transparent motion-safe:animate-spin" />
) : (
<span
className="relative h-[5px] w-[5px] rounded-full"
style={{ backgroundColor: context ? strokeColor : 'var(--color-text-tertiary)' }}
/>
className="relative grid h-[18px] w-[18px] place-items-center rounded-full"
style={ringStyle}
>
<span className="absolute inset-[3px] rounded-full bg-[var(--color-surface-container-lowest)]" />
<span
className="relative h-[5px] w-[5px] rounded-full"
style={{ backgroundColor: context ? strokeColor : 'var(--color-text-tertiary)' }}
/>
</span>
)}
</span>
<span className="font-mono text-[11px] font-semibold tabular-nums">
@ -170,7 +206,7 @@ export function ContextUsageIndicator({
{t('contextIndicator.title')}
</div>
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{context?.model ?? t('contextIndicator.modelUnknown')}
{displayModel ?? t('contextIndicator.modelUnknown')}
</div>
</div>
<div className="shrink-0 font-mono text-xl font-semibold text-[var(--color-text-primary)]">
@ -191,7 +227,7 @@ export function ContextUsageIndicator({
</div>
<div className="col-span-2">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(maxTokens)}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
</div>
</div>
{details.length > 0 && (
@ -221,9 +257,13 @@ export function ContextUsageIndicator({
)}
</div>
</>
) : isPendingContext ? (
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
{t('contextIndicator.pendingDetail')}
</div>
) : (
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
{loading ? t('contextIndicator.loading') : error ?? t('contextIndicator.unavailableDetail')}
{loading ? t('contextIndicator.loading') : t('contextIndicator.unavailableDetail')}
</div>
)}
</div>

View File

@ -717,7 +717,7 @@ function SessionInspectorPanel({
let cancelled = false
setContextLoading(true)
setContextError(null)
sessionsApi.getInspection(sessionId, { includeContext: true, timeout: 45_000 })
sessionsApi.getInspection(sessionId, { includeContext: true, contextOnly: true, timeout: 45_000 })
.then((response) => {
if (cancelled) return
const inspected = assertSessionInspectionResponse(response, t)

View File

@ -28,16 +28,19 @@ export function AppShell() {
await initializeDesktopServerUrl()
await fetchSettings()
// Restore tabs from localStorage
await useTabStore.getState().restoreTabs()
const { activeTabId: activeId, tabs } = useTabStore.getState()
const activeTab = tabs.find((tab) => tab.sessionId === activeId)
if (activeId && activeTab?.type === 'session') {
useChatStore.getState().connectToSession(activeId)
}
if (!cancelled) {
setReady(true)
}
void (async () => {
await useTabStore.getState().restoreTabs()
if (cancelled) return
const { activeTabId: activeId, tabs } = useTabStore.getState()
const activeTab = tabs.find((tab) => tab.sessionId === activeId)
if (activeId && activeTab?.type === 'session') {
useChatStore.getState().connectToSession(activeId)
}
})().catch(() => {})
} catch (error) {
if (!cancelled) {
setStartupError(error instanceof Error ? error.message : String(error))

View File

@ -757,6 +757,7 @@ export const en = {
'slash.inspector.context.noCategoriesTitle': 'No context categories',
'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.',
'contextIndicator.ariaLabel': 'Context usage {percent}',
'contextIndicator.pendingAria': 'Context usage not calculated',
'contextIndicator.loadingAria': 'Context usage loading',
'contextIndicator.unavailableAria': 'Context usage unavailable',
'contextIndicator.title': 'Context',
@ -765,6 +766,7 @@ export const en = {
'contextIndicator.free': 'Free',
'contextIndicator.window': 'Window',
'contextIndicator.loading': 'Loading context usage...',
'contextIndicator.pendingDetail': 'Context usage will be calculated after the session starts.',
'contextIndicator.unavailable': 'n/a',
'contextIndicator.unavailableDetail': 'Context usage is unavailable for this session.',
'contextIndicator.updatedUnknown': 'Not refreshed yet',

View File

@ -759,6 +759,7 @@ export const zh: Record<TranslationKey, string> = {
'slash.inspector.context.noCategoriesTitle': '暂无上下文分类',
'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。',
'contextIndicator.ariaLabel': '上下文用量 {percent}',
'contextIndicator.pendingAria': '上下文用量待计算',
'contextIndicator.loadingAria': '上下文用量加载中',
'contextIndicator.unavailableAria': '上下文用量不可用',
'contextIndicator.title': '上下文',
@ -767,6 +768,7 @@ export const zh: Record<TranslationKey, string> = {
'contextIndicator.free': '剩余',
'contextIndicator.window': '窗口',
'contextIndicator.loading': '正在加载上下文用量...',
'contextIndicator.pendingDetail': '会话启动后计算上下文用量。',
'contextIndicator.unavailable': '不可用',
'contextIndicator.unavailableDetail': '此会话暂时无法获取上下文用量。',
'contextIndicator.updatedUnknown': '尚未刷新',

View File

@ -13,6 +13,7 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator'
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel'
import {
@ -63,6 +64,12 @@ export function EmptySession() {
const connectToSession = useChatStore((state) => state.connectToSession)
const setActiveView = useUIStore((state) => state.setActiveView)
const addToast = useUIStore((state) => state.addToast)
const currentModel = useSettingsStore((state) => state.currentModel)
const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY])
const draftRuntimeSelectionKey = draftRuntimeSelection
? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}`
: undefined
const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
useEffect(() => {
textareaRef.current?.focus()
@ -667,6 +674,13 @@ export function EmptySession() {
</div>
<div className="flex items-center gap-3">
<ContextUsageIndicator
chatState="idle"
messageCount={0}
runtimeSelectionKey={draftRuntimeSelectionKey}
fallbackModelLabel={draftModelLabel}
draft
/>
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting || isCreatingSession} />
<button
onClick={handleSubmit}

View File

@ -154,7 +154,11 @@ export const useTabStore = create<TabStore>((set, get) => ({
if (!raw) return
const data = JSON.parse(raw) as TabPersistence
if (!data.openTabs || data.openTabs.length === 0) return
if (!data.openTabs || data.openTabs.length === 0) {
set({ tabs: [], activeTabId: null })
localStorage.removeItem(TAB_STORAGE_KEY)
return
}
const { sessions } = await sessionsApi.list({ limit: 200 })
const existingIds = new Set(sessions.map((s) => s.id))
@ -179,7 +183,11 @@ export const useTabStore = create<TabStore>((set, get) => ({
}
})
if (validTabs.length === 0) return
if (validTabs.length === 0) {
set({ tabs: [], activeTabId: null })
localStorage.removeItem(TAB_STORAGE_KEY)
return
}
const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId)
? data.activeTabId

View File

@ -447,6 +447,29 @@ describe('WebSocket Chat Integration', () => {
}
}
async function withMockMcpStatusDelay<T>(
delayMs: number | undefined,
callback: () => Promise<T>,
): Promise<T> {
const previousDelay = process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
if (delayMs && delayMs > 0) {
process.env.MOCK_SDK_MCP_STATUS_DELAY_MS = String(delayMs)
} else {
delete process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
}
try {
return await callback()
} finally {
if (previousDelay === undefined) {
delete process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
} else {
process.env.MOCK_SDK_MCP_STATUS_DELAY_MS = previousDelay
}
}
}
async function withMockExitAfterFirstUser<T>(
delayMs: number | undefined,
callback: () => Promise<T>,
@ -819,6 +842,24 @@ describe('WebSocket Chat Integration', () => {
expect(basicBody.context).toBeUndefined()
})
it('should expose context-only inspection without waiting on mcp status', async () => {
await withMockMcpStatusDelay(2_000, async () => {
const sessionId = `chat-context-only-${crypto.randomUUID()}`
await runTurn(sessionId, 'hello before context-only inspection')
const startedAt = performance.now()
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=1&contextOnly=1`)
const elapsedMs = performance.now() - startedAt
expect(res.status).toBe(200)
const body = await res.json() as any
expect(body.context.model).toBe('mock-opus')
expect(body.context.estimateOnly).toBe(true)
expect(body.usage).toBeUndefined()
expect(elapsedMs).toBeLessThan(1_500)
})
})
it('should complete the client turn when the CLI exits after startup', async () => {
const messages = await withMockExitAfterFirstUser(50, () =>
runTurnUntilComplete(`chat-late-exit-${crypto.randomUUID()}`, 'trigger late exit'),

View File

@ -28,6 +28,7 @@ const initMode = process.env.MOCK_SDK_INIT_MODE || 'on_open'
const streamDelayMs = Number(process.env.MOCK_SDK_STREAM_DELAY_MS || '0')
const exitAfterOpenMs = Number(process.env.MOCK_SDK_EXIT_AFTER_OPEN_MS || '0')
const exitAfterFirstUserMs = Number(process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS || '0')
const mcpStatusDelayMs = Number(process.env.MOCK_SDK_MCP_STATUS_DELAY_MS || '0')
const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || ''
const exitBeforeSdkMs = Number(process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS || '0')
let initSent = false
@ -288,6 +289,9 @@ ws.addEventListener('message', (event) => {
}
if (parsed.type === 'control_request' && parsed.request?.subtype === 'mcp_status') {
if (mcpStatusDelayMs > 0) {
await delay(mcpStatusDelayMs)
}
emit(ws, {
type: 'control_response',
response: {

View File

@ -349,6 +349,7 @@ async function getSessionSlashCommands(sessionId: string): Promise<Response> {
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
const includeContext = url.searchParams.get('includeContext') !== '0'
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
const workDir =
conversationService.getSessionWorkDir(sessionId) ||
await sessionService.getSessionWorkDir(sessionId)
@ -411,46 +412,58 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
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' }, basicControlTimeoutMs),
includeContext
? conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
: Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
])
const errors: Record<string, string> = {}
if (usageResult.status === 'fulfilled') {
response.usage = chooseRicherUsage(
{ ...usageResult.value, source: 'current_process' },
transcriptUsage,
)
} else {
if (transcriptUsage) {
response.usage = transcriptUsage
} else {
errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason)
if (contextOnly) {
try {
response.context = await conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
} catch (error) {
errors.context = error instanceof Error ? error.message : String(error)
}
}
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)
}
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
includeContext
? conversationService.requestControl(
sessionId,
{ subtype: 'get_context_usage', estimateOnly: true },
20_000,
)
: Promise.resolve(null),
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
])
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
response.status = {
...response.status,
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
if (usageResult.status === 'fulfilled') {
response.usage = chooseRicherUsage(
{ ...usageResult.value, source: 'current_process' },
transcriptUsage,
)
} else {
if (transcriptUsage) {
response.usage = transcriptUsage
} else {
errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason)
}
}
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)
}
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
response.status = {
...response.status,
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
}
}
}