mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
77d2cbfab8
commit
761031c036
@ -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: {} },
|
||||
},
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
27
src/main.tsx
27
src/main.tsx
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()}`
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
// ============================================================================
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'prewarm_session' }
|
||||
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
|
||||
| {
|
||||
type: 'permission_response'
|
||||
|
||||
@ -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 } },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user