Prevent desktop turns from hanging after CLI exits

A desktop session can pass the startup grace window and then lose its CLI subprocess before a result arrives. Previously that path only deleted the active process record, leaving the WebSocket client in an active turn with no terminal message. The process-exit handler now emits a synthetic CLI error result through the existing translation path so the UI receives both an error and message_complete. The mock SDK fixture can reproduce post-startup exits, and the integration test locks the terminal event behavior.

Constraint: The frontend already relies on result is_error translation to emit error plus message_complete.

Rejected: Add a separate WebSocket process-exit message type | it would duplicate existing result-error handling and require another frontend state path.

Confidence: high

Scope-risk: narrow

Directive: Keep process-exit failures on the same result-error path unless the client protocol gains a dedicated terminal error event.

Tested: bun test src/server/__tests__/conversations.test.ts

Tested: cd desktop && bun run lint

Tested: agent-browser against http://127.0.0.1:1421 with mock CLI late exit; page showed CLI process exited unexpectedly and composer became editable again
This commit is contained in:
程序员阿江(Relakkes) 2026-04-27 20:25:51 +08:00
parent 1631c449f6
commit 664c6ebd9f
3 changed files with 116 additions and 0 deletions

View File

@ -235,6 +235,29 @@ describe('WebSocket Chat Integration', () => {
}
}
async function withMockExitAfterFirstUser<T>(
delayMs: number | undefined,
callback: () => Promise<T>,
): Promise<T> {
const previousDelay = process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
if (delayMs && delayMs > 0) {
process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS = String(delayMs)
} else {
delete process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
}
try {
return await callback()
} finally {
if (previousDelay === undefined) {
delete process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
} else {
process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS = previousDelay
}
}
}
async function runTurn(sessionId: string, content: string, allowError = false): Promise<any[]> {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
@ -277,6 +300,39 @@ describe('WebSocket Chat Integration', () => {
return messages
}
async function runTurnUntilComplete(sessionId: string, content: string): Promise<any[]> {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error(`Timed out waiting for terminal event for session ${sessionId}`))
}, 10000)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'user_message', content }))
}
if (msg.type === 'message_complete') {
clearTimeout(timeout)
ws.close()
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
ws.close()
reject(new Error(`WebSocket error for session ${sessionId}`))
}
})
return messages
}
async function waitUntil(
predicate: () => boolean | Promise<boolean>,
label: string,
@ -434,6 +490,24 @@ describe('WebSocket Chat Integration', () => {
).toBe(true)
})
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'),
)
expect(
messages.some(
(m) =>
m.type === 'error' &&
m.code === 'CLI_ERROR' &&
typeof m.message === 'string' &&
m.message.includes('CLI process exited unexpectedly'),
),
).toBe(true)
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
expect(messages.at(-1)?.type).toBe('message_complete')
}, 15_000)
it('should handle permission_response without error', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)

View File

@ -26,7 +26,10 @@ const sdkUrl = getArg('--sdk-url')
const sessionId = getArg('--session-id') || crypto.randomUUID()
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')
let initSent = false
let firstUserExitScheduled = false
if (!sdkUrl) {
console.error('Missing --sdk-url')
@ -51,6 +54,9 @@ ws.addEventListener('open', () => {
if (initMode !== 'on_first_user') {
sendInit()
}
if (exitAfterOpenMs > 0) {
setTimeout(() => process.exit(1), exitAfterOpenMs)
}
})
ws.addEventListener('message', (event) => {
@ -63,6 +69,11 @@ ws.addEventListener('message', (event) => {
if (parsed.type === 'user') {
sendInit()
if (exitAfterFirstUserMs > 0 && !firstUserExitScheduled) {
firstUserExitScheduled = true
setTimeout(() => process.exit(1), exitAfterFirstUserMs)
continue
}
const text = extractUserText(parsed)
emit(ws, {
type: 'stream_event',

View File

@ -477,6 +477,17 @@ export class ConversationService {
const activeSession = this.sessions.get(sessionId)
if (activeSession?.proc === proc) {
const exitError = this.buildRuntimeExitMessage(sessionId, code)
for (const cb of activeSession.outputCallbacks) {
cb({
type: 'result',
subtype: 'error',
is_error: true,
result: exitError,
usage: { input_tokens: 0, output_tokens: 0 },
session_id: sessionId,
})
}
this.sessions.delete(sessionId)
}
}
@ -783,6 +794,26 @@ export class ConversationService {
)
}
private buildRuntimeExitMessage(sessionId: string, exitCode: number): string {
const session = this.sessions.get(sessionId)
const stderrText = session?.stderrLines.join('\n').trim() ?? ''
const recentMessages = session?.sdkMessages ?? []
const resultMessage = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'result' && msg.is_error)
const authStatus = [...recentMessages]
.reverse()
.find((msg) => msg?.type === 'auth_status')
const detail =
this.extractStartupDetail(resultMessage) ||
this.extractStartupDetail(authStatus) ||
stderrText
return detail
? `CLI process exited unexpectedly (code ${exitCode}): ${detail}`
: `CLI process exited unexpectedly with code ${exitCode}.`
}
private extractStartupDetail(message: any): string {
if (!message) return ''