Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apps/daemon/src/json-event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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;
}
Expand Down
44 changes: 44 additions & 0 deletions apps/daemon/tests/json-event-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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',
},
]);
});