diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 58a5bf4d..2d6dc7e7 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -235,6 +235,29 @@ describe('WebSocket Chat Integration', () => { } } + async function withMockExitAfterFirstUser( + delayMs: number | undefined, + callback: () => Promise, + ): Promise { + 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 { 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 { + const messages: any[] = [] + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + + await new Promise((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, 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`) diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts index b0e624c3..20ebcabd 100644 --- a/src/server/__tests__/fixtures/mock-sdk-cli.ts +++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts @@ -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', diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 0db3170d..127d5532 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -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 ''