fix: Make desktop MCP tools ready before first turn

Desktop sessions could show MCP configuration in the UI while the CLI SDK child process still started cold on the first user message. Start eligible desktop chat sessions as soon as their websocket is connected, keep init metadata cached but muted, and wait briefly for MCP startup in SDK print mode so turn one sees the tools.

Constraint: Desktop wraps the existing CLI SDK bridge rather than owning MCP startup directly.
Rejected: Prewarm every restored tab | synthetic tabs such as settings would start unnecessary CLI subprocesses.
Confidence: high
Scope-risk: moderate
Directive: Do not remove the synthetic-tab guard without validating restored settings and scheduled-task tabs in the browser.
Tested: bun test src/server/__tests__/conversations.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/mcp.test.ts
Tested: cd desktop && bun run test -- chatStore.test.ts mcpSettings.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: agent-browser E2E new session prewarm plus first message reuse with mock CLI
Not-tested: Full desktop Vitest suite still has pre-existing English-vs-Chinese copy assertion failures.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-24 17:11:03 +08:00
parent 77d2cbfab8
commit 761031c036
12 changed files with 431 additions and 53 deletions

View File

@ -15,6 +15,23 @@ vi.mock('../api/skills', () => ({
vi.mock('../api/mcp', () => ({
mcpApi: {
list: vi.fn(async () => ({ servers: [] })),
status: vi.fn(async (name: string) => ({
server: {
name,
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: 'User',
summary: 'https://mcp.example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://mcp.example.com/mcp', headers: {} },
},
})),
},
}))

View File

@ -118,9 +118,27 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
useEffect(() => {
let cancelled = false
mcpApi.list(cwd)
.then((response) => {
.then(async (response) => {
if (cancelled) return
setServers(response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project'))
const visibleServers = response.servers.filter((server) => server.scope === 'user' || server.scope === 'local' || server.scope === 'project')
setServers(visibleServers)
const statusResults = await Promise.allSettled(
visibleServers.map((server) => mcpApi.status(server.name, cwd)),
)
if (cancelled) return
const liveServers = new Map<string, McpServerRecord>()
for (const result of statusResults) {
if (result.status === 'fulfilled') {
liveServers.set(result.value.server.name, result.value.server)
}
}
if (liveServers.size > 0) {
setServers((current) =>
current?.map((server) => liveServers.get(server.name) ?? server) ?? current,
)
}
})
.catch((err) => {
if (cancelled) return

View File

@ -29,8 +29,9 @@ export function AppShell() {
// Restore tabs from localStorage
await useTabStore.getState().restoreTabs()
const activeId = useTabStore.getState().activeTabId
if (activeId) {
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) {

View File

@ -274,6 +274,47 @@ describe('chatStore history mapping', () => {
providerId: 'provider-1',
modelId: 'kimi-k2.6',
})
expect(sendMock.mock.calls.slice(0, 2)).toEqual([
[
TEST_SESSION_ID,
{
type: 'set_runtime_config',
providerId: 'provider-1',
modelId: 'kimi-k2.6',
},
],
[TEST_SESSION_ID, { type: 'prewarm_session' }],
])
})
it('prewarms regular desktop sessions when connecting', () => {
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'prewarm_session',
})
})
it('does not prewarm team member sessions', () => {
getMemberBySessionIdMock.mockReturnValue({
agentId: 'reviewer@test-team',
role: 'reviewer',
status: 'running',
})
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'prewarm_session',
})
})
it('does not prewarm synthetic app tabs', () => {
useChatStore.getState().connectToSession('__settings__')
expect(sendMock).not.toHaveBeenCalledWith('__settings__', {
type: 'prewarm_session',
})
})
it('sends explicit runtime overrides over websocket', () => {

View File

@ -228,6 +228,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
if (runtimeSelection) {
wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection })
}
if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) {
wsManager.send(sessionId, { type: 'prewarm_session' })
}
get().loadHistory(sessionId)
sessionsApi.getSlashCommands(sessionId)

View File

@ -6,6 +6,7 @@ import type { RuntimeSelection } from './runtime'
// ─── Client → Server ──────────────────────────────────────────────
export type ClientMessage =
| { type: 'prewarm_session' }
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
| {
type: 'permission_response'

View File

@ -2723,6 +2723,26 @@ async function run(): Promise<CommanderCommand> {
}));
}, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`));
};
const getDesktopMcpStartupTimeoutMs = (): number => {
const raw = process.env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS;
if (raw === undefined) return 5_000;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) return 5_000;
return parsed;
};
const waitForDesktopMcpStartup = async (promise: Promise<void>, label: string): Promise<void> => {
const timeoutMs = getDesktopMcpStartupTimeoutMs();
if (timeoutMs === 0) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([promise.then(() => false), new Promise<boolean>(resolve => {
timer = setTimeout(resolve, timeoutMs, true);
})]);
if (timer) clearTimeout(timer);
if (timedOut) {
logForDebugging(`[MCP] ${label} servers not ready after ${timeoutMs}ms — proceeding; background connection continues`);
}
};
// Await all MCP configs for regular print-mode sessions — they're
// often single-turn, so "late-connecting servers visible next turn"
// doesn't help. SDK init message and turn-1 tool list should include
@ -2742,10 +2762,13 @@ async function run(): Promise<CommanderCommand> {
// fetch was kicked off early (line ~2558) so only residual time blocks
// here. --bare skips claude.ai entirely for perf-sensitive scripts.
profileCheckpoint('before_connectMcp');
const regularMcpConnect = connectMcpBatch(regularMcpConfigs, 'regular');
if (sdkUrl) {
void connectMcpBatch(regularMcpConfigs, 'regular');
if (process.env.CC_HAHA_DESKTOP_AWAIT_MCP === '1') {
await waitForDesktopMcpStartup(regularMcpConnect, 'regular MCP');
}
} else {
await connectMcpBatch(regularMcpConfigs, 'regular');
await regularMcpConnect;
}
profileCheckpoint('after_connectMcp');
// Dedup: suppress plugin MCP servers that duplicate a claude.ai

View File

@ -249,6 +249,17 @@ describe('ConversationService', () => {
expect(args).toContain('--replay-user-messages')
})
test('buildChildEnv asks desktop SDK sessions to wait briefly for MCP tools', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
)) as Record<string, string>
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP).toBe('1')
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS).toBe('5000')
})
test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => {
const service = new ConversationService() as any
const args = service.buildSessionCliArgs(

View File

@ -272,6 +272,19 @@ describe('WebSocket Chat Integration', () => {
return messages
}
async function waitUntil(
predicate: () => boolean | Promise<boolean>,
label: string,
timeoutMs = 8000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (await predicate()) return
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error(`Timed out waiting for ${label}`)
}
const originalCliPath = process.env.CLAUDE_CLI_PATH
beforeAll(async () => {
@ -505,6 +518,123 @@ describe('WebSocket Chat Integration', () => {
expect(secondTurn.some((m) => m.type === 'error')).toBe(false)
})
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd() }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
const startCalls: Array<{ sessionId: string }> = []
conversationService.startSession = (async function patchedStartSession(
sid: string,
workDir: string,
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
) {
startCalls.push({ sessionId: sid })
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
let connected = false
let awaitingCompletion = false
let preUserMessageCount = 0
let resolveCompletion: (() => void) | null = null
let rejectCompletion: ((err: Error) => void) | null = null
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timed out waiting for prewarm connection for session ${sessionId}`))
}, 5000)
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string)
messages.push(msg)
if (msg.type === 'connected' && !connected) {
connected = true
clearTimeout(timeout)
ws.send(JSON.stringify({ type: 'prewarm_session' }))
resolve()
return
}
if (msg.type === 'error') {
const err = new Error(msg.message)
clearTimeout(timeout)
rejectCompletion?.(err)
reject(err)
return
}
if (awaitingCompletion && msg.type === 'message_complete') {
resolveCompletion?.()
}
}
ws.onerror = () => {
const err = new Error(`WebSocket error for prewarm session ${sessionId}`)
clearTimeout(timeout)
rejectCompletion?.(err)
reject(err)
}
})
await waitUntil(
() => startCalls.length === 1 && conversationService.hasSession(sessionId),
`prewarmed CLI process for ${sessionId}`,
)
await waitUntil(async () => {
const commandsRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
if (!commandsRes.ok) return false
const { commands } = await commandsRes.json() as { commands?: Array<{ name: string }> }
if (!Array.isArray(commands)) return false
return commands.some((command) => command.name === 'help')
}, `prewarmed slash commands for ${sessionId}`)
preUserMessageCount = messages.length
expect(
messages
.slice(0, preUserMessageCount)
.some((msg) => ['content_start', 'content_delta', 'thinking', 'message_complete'].includes(msg.type)),
).toBe(false)
awaitingCompletion = true
const completion = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timed out waiting for completion after prewarm for session ${sessionId}`))
}, 10_000)
resolveCompletion = () => {
clearTimeout(timeout)
resolve()
}
rejectCompletion = (err) => {
clearTimeout(timeout)
reject(err)
}
})
ws.send(JSON.stringify({ type: 'user_message', content: 'first turn after prewarm' }))
await completion
expect(startCalls).toHaveLength(1)
expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true)
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
expect(messages.some((msg) => msg.type === 'error')).toBe(false)
} finally {
ws.close()
conversationService.startSession = originalStartSession
conversationService.stopSession(sessionId)
}
}, 20_000)
it('should resume streaming to a reconnected client during an active turn', async () => {
await withMockStreamDelay(150, async () => {
const sessionId = `chat-reconnect-${crypto.randomUUID()}`

View File

@ -232,6 +232,10 @@ export class ConversationService {
}
}
getRecentSdkMessages(sessionId: string): any[] {
return [...(this.sessions.get(sessionId)?.sdkMessages ?? [])]
}
sendMessage(
sessionId: string,
content: string,
@ -565,6 +569,12 @@ export class ConversationService {
...(desktopServerUrl
? { CC_HAHA_DESKTOP_SERVER_URL: desktopServerUrl }
: {}),
...(sdkUrl
? {
CC_HAHA_DESKTOP_AWAIT_MCP: '1',
CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS: '5000',
}
: {}),
// Tell the CLI entrypoint to skip project .env loading. Provider env
// should come from Desktop-managed config or inherited launch env, not
// be reintroduced from the repo's .env file.

View File

@ -9,6 +9,7 @@
// ============================================================================
export type ClientMessage =
| { type: 'prewarm_session' }
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
| {
type: 'permission_response'

View File

@ -55,6 +55,11 @@ const runtimeOverrides = new Map<string, {
}>()
const runtimeTransitionPromises = new Map<string, Promise<void>>()
const sessionStartupPromises = new Map<string, Promise<void>>()
const prewarmPendingSessions = new Set<string>()
const prewarmedSessions = new Set<string>()
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000
export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
return sessionSlashCommands.get(sessionId) || []
@ -98,7 +103,11 @@ export const handleWebSocket = {
}
activeSessions.set(sessionId, ws)
rebindSessionOutput(sessionId, ws)
if (prewarmedSessions.has(sessionId)) {
bindPrewarmMetadataCapture(sessionId)
} else {
rebindSessionOutput(sessionId, ws)
}
const msg: ServerMessage = { type: 'connected', sessionId }
ws.send(JSON.stringify(msg))
@ -139,6 +148,10 @@ export const handleWebSocket = {
void handleSetRuntimeConfig(ws, message)
break
case 'prewarm_session':
handlePrewarmSession(ws)
break
case 'stop_generation':
handleStopGeneration(ws)
break
@ -196,10 +209,10 @@ async function handleUserMessage(
message: Extract<ClientMessage, { type: 'user_message' }>
) {
const { sessionId } = ws.data
let workDir = os.homedir()
// Clear any stale stop flag from a previous turn
sessionStopRequested.delete(sessionId)
clearPrewarmState(sessionId)
// Send thinking status
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
@ -222,45 +235,22 @@ async function handleUserMessage(
}
// 启动 CLI 子进程(如果还没有)
if (!conversationService.hasSession(sessionId)) {
try {
// Resolve the session's actual working directory
try {
const resolved = await sessionService.getSessionWorkDir(sessionId)
if (resolved) workDir = resolved
console.log(
`[WS] handleUserMessage: sessionId=${sessionId}, resolved workDir=${JSON.stringify(
resolved,
)}, will spawn CLI with workDir=${workDir}`,
)
} catch (resolveErr) {
// fallback to cwd if session file not found
console.warn(
`[WS] handleUserMessage: failed to resolve workDir for ${sessionId}, using fallback=${workDir}: ${
resolveErr instanceof Error ? resolveErr.message : String(resolveErr)
}`,
)
}
const runtimeSettings = await getRuntimeSettings(sessionId)
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}`
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
const code =
err instanceof ConversationStartupError ? err.code : 'CLI_START_FAILED'
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: errMsg,
code,
retryable:
err instanceof ConversationStartupError ? err.retryable : false,
})
sendMessage(ws, { type: 'status', state: 'idle' })
return
}
try {
await ensureCliSessionStarted(ws, sessionId, 'user_message')
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err)
const code =
err instanceof ConversationStartupError ? err.code : 'CLI_START_FAILED'
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
sendMessage(ws, {
type: 'error',
message: errMsg,
code,
retryable:
err instanceof ConversationStartupError ? err.retryable : false,
})
sendMessage(ws, { type: 'status', state: 'idle' })
return
}
// Track user message for title generation
@ -302,6 +292,29 @@ async function handleUserMessage(
userMessageSent = true
}
function handlePrewarmSession(ws: ServerWebSocket<WebSocketData>) {
const { sessionId } = ws.data
if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) {
return
}
prewarmPendingSessions.add(sessionId)
void ensureCliSessionStarted(ws, sessionId, 'prewarm_session')
.then(() => {
if (!prewarmPendingSessions.delete(sessionId)) return
bindPrewarmMetadataCapture(sessionId)
markPrewarmed(sessionId)
})
.catch((err) => {
prewarmPendingSessions.delete(sessionId)
console.warn(
`[WS] Prewarm failed for ${sessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
)
})
}
function handlePermissionResponse(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'permission_response' }>
@ -575,6 +588,120 @@ function cleanupSessionRuntimeState(sessionId: string) {
sessionTitleState.delete(sessionId)
runtimeOverrides.delete(sessionId)
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
clearPrewarmState(sessionId)
}
function getPrewarmIdleTimeoutMs(): number {
const raw = process.env.CC_HAHA_PREWARM_IDLE_TIMEOUT_MS
if (!raw) return DEFAULT_PREWARM_IDLE_TIMEOUT_MS
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 0
? parsed
: DEFAULT_PREWARM_IDLE_TIMEOUT_MS
}
function clearPrewarmState(sessionId: string) {
prewarmPendingSessions.delete(sessionId)
prewarmedSessions.delete(sessionId)
const timer = prewarmIdleTimers.get(sessionId)
if (timer) {
clearTimeout(timer)
prewarmIdleTimers.delete(sessionId)
}
}
function markPrewarmed(sessionId: string) {
prewarmedSessions.add(sessionId)
const timeoutMs = getPrewarmIdleTimeoutMs()
if (timeoutMs === 0) return
const existingTimer = prewarmIdleTimers.get(sessionId)
if (existingTimer) clearTimeout(existingTimer)
const timer = setTimeout(() => {
prewarmIdleTimers.delete(sessionId)
if (!prewarmedSessions.has(sessionId)) return
console.log(`[WS] Prewarmed session ${sessionId} idle for ${timeoutMs}ms, stopping CLI subprocess`)
conversationService.stopSession(sessionId)
prewarmedSessions.delete(sessionId)
}, timeoutMs)
prewarmIdleTimers.set(sessionId, timer)
}
function cacheSessionInitMetadata(sessionId: string, cliMsg: any) {
if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
description: typeof cmd === 'string' ? '' : (cmd.description || ''),
})))
}
}
function bindPrewarmMetadataCapture(sessionId: string) {
for (const msg of conversationService.getRecentSdkMessages(sessionId)) {
cacheSessionInitMetadata(sessionId, msg)
}
if (!conversationService.hasSession(sessionId)) return
conversationService.clearOutputCallbacks(sessionId)
conversationService.onOutput(sessionId, (cliMsg) => {
cacheSessionInitMetadata(sessionId, cliMsg)
})
}
async function resolveSessionWorkDir(sessionId: string, fallback = os.homedir()): Promise<string> {
let workDir = fallback
try {
const resolved = await sessionService.getSessionWorkDir(sessionId)
if (resolved) workDir = resolved
console.log(
`[WS] resolveSessionWorkDir: sessionId=${sessionId}, resolved workDir=${JSON.stringify(
resolved,
)}, will spawn CLI with workDir=${workDir}`,
)
} catch (resolveErr) {
console.warn(
`[WS] resolveSessionWorkDir: failed to resolve workDir for ${sessionId}, using fallback=${workDir}: ${
resolveErr instanceof Error ? resolveErr.message : String(resolveErr)
}`,
)
}
return workDir
}
async function ensureCliSessionStarted(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
reason: 'user_message' | 'prewarm_session',
): Promise<void> {
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
await pendingStartup
return
}
if (conversationService.hasSession(sessionId)) return
const startup = (async () => {
const workDir = await resolveSessionWorkDir(sessionId)
const runtimeSettings = await getRuntimeSettings(sessionId)
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}`
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
})()
sessionStartupPromises.set(sessionId, startup)
try {
await startup
} finally {
if (sessionStartupPromises.get(sessionId) === startup) {
sessionStartupPromises.delete(sessionId)
}
}
}
function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
@ -844,12 +971,7 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
// NOTE: Do NOT send status:idle here — the CLI init fires while
// processing the first user message, and sending idle would reset
// the frontend's streaming state prematurely.
if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) {
sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({
name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''),
description: typeof cmd === 'string' ? '' : (cmd.description || ''),
})))
}
cacheSessionInitMetadata(sessionId, cliMsg)
const messages: ServerMessage[] = [
// Send model info as a system notification, not a status change
{ type: 'system_notification', subtype: 'init', message: `Model: ${cliMsg.model || 'unknown'}`, data: { model: cliMsg.model } },