diff --git a/apps/daemon/src/json-event-stream.ts b/apps/daemon/src/json-event-stream.ts index c1455afc27..57c618f08e 100644 --- a/apps/daemon/src/json-event-stream.ts +++ b/apps/daemon/src/json-event-stream.ts @@ -71,6 +71,16 @@ function extractErrorMessage(value: unknown, fallback: string): string { return fallback; } +function isRecoverableCodexReconnect(message: string): boolean { + return ( + message.startsWith('Reconnecting...') && + ( + message.includes('timeout waiting for child process to exit') || + message.includes('stream disconnected before completion') + ) + ); +} + function formatOpenCodeUsage(tokens: unknown): Usage | null { if (!isRecord(tokens)) return null; const usage: Usage = {}; @@ -272,11 +282,7 @@ function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: Pars if (obj.type === 'error') { const message = extractErrorMessage(obj.message ?? obj.error, 'Codex error'); // Reconnecting events are recoverable — treat as status warning, not fatal - if ( - typeof message === 'string' && - message.includes('Reconnecting...') && - message.includes('timeout waiting for child process to exit') - ) { + if (isRecoverableCodexReconnect(message)) { onEvent({ type: 'status', label: message }); return true; } diff --git a/apps/daemon/tests/json-event-stream.test.ts b/apps/daemon/tests/json-event-stream.test.ts index 32a60d24cf..47d6f0c6e6 100644 --- a/apps/daemon/tests/json-event-stream.test.ts +++ b/apps/daemon/tests/json-event-stream.test.ts @@ -476,6 +476,32 @@ test('codex json stream treats reconnect errors as status warnings not fatal (re ]); }); +test('codex json stream treats stream disconnect reconnect errors as status warnings not fatal', () => { + const { events, handler } = collectEvents('codex'); + + handler.feed( + JSON.stringify({ type: 'thread.started', thread_id: 'thr-1' }) + '\n' + + JSON.stringify({ type: 'turn.started' }) + '\n' + + JSON.stringify({ + type: 'error', + message: 'Reconnecting... 2/5 (stream disconnected before completion: Connection reset by peer (os error 54))', + }) + '\n' + + JSON.stringify({ type: 'item.completed', item: { id: 'item-0', type: 'agent_message', text: 'OK' } }) + '\n' + + JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 5, output_tokens: 2, cached_input_tokens: 0 } }) + '\n', + ); + + assert.deepEqual(events, [ + { type: 'status', label: 'initializing' }, + { type: 'status', label: 'running' }, + { + type: 'status', + label: 'Reconnecting... 2/5 (stream disconnected before completion: Connection reset by peer (os error 54))', + }, + { type: 'text_delta', delta: 'OK' }, + { type: 'usage', usage: { input_tokens: 5, output_tokens: 2, cached_read_tokens: 0 } }, + ]); +}); + test('codex json stream still treats real errors as fatal after reconnect warnings', () => { const { events, handler } = collectEvents('codex'); @@ -489,3 +515,21 @@ test('codex json stream still treats real errors as fatal after reconnect warnin { type: 'error', message: 'Authentication failed: invalid API key' }, ]); }); + +test('codex json stream does not downgrade non-reconnect errors that mention reconnect text', () => { + const { events, handler } = collectEvents('codex'); + + handler.feed( + JSON.stringify({ + type: 'error', + message: 'Authentication failed after Reconnecting... stream disconnected before completion', + }) + '\n', + ); + + assert.deepEqual(events, [ + { + type: 'error', + message: 'Authentication failed after Reconnecting... stream disconnected before completion', + }, + ]); +});