fix(codex): surface thread system errors (#519)

Co-authored-by: Liu-KM <Liu-KM@users.noreply.github.com>
This commit is contained in:
Qianli Liu
2026-04-23 20:45:33 +08:00
committed by GitHub
co-authored by Liu-KM
parent 96d766d7f1
commit 19e74456fb
6 changed files with 171 additions and 19 deletions
+74 -12
View File
@@ -5,7 +5,11 @@ import type { EnhancedMode } from './loop';
const harness = vi.hoisted(() => ({
notifications: [] as Array<{ method: string; params: unknown }>,
registerRequestCalls: [] as string[],
initializeCalls: [] as unknown[]
initializeCalls: [] as unknown[],
startThreadIds: [] as string[],
resumeThreadIds: [] as string[],
startTurnThreadIds: [] as string[],
remainingThreadSystemErrors: 0
}));
vi.mock('./codexAppServerClient', () => {
@@ -28,23 +32,41 @@ vi.mock('./codexAppServerClient', () => {
}
async startThread(): Promise<{ thread: { id: string }; model: string }> {
return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' };
const id = `thread-${harness.startThreadIds.length + 1}`;
harness.startThreadIds.push(id);
return { thread: { id }, model: 'gpt-5.4' };
}
async resumeThread(): Promise<{ thread: { id: string }; model: string }> {
return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' };
async resumeThread(params?: { threadId?: string }): Promise<{ thread: { id: string }; model: string }> {
const id = params?.threadId ?? 'thread-resumed';
harness.resumeThreadIds.push(id);
return { thread: { id }, model: 'gpt-5.4' };
}
async startTurn(): Promise<{ turn: Record<string, never> }> {
const started = { turn: {} };
async startTurn(params?: { threadId?: string }): Promise<{ turn: { id?: string } }> {
const threadId = params?.threadId ?? 'thread-unknown';
harness.startTurnThreadIds.push(threadId);
const turnId = `turn-${harness.startTurnThreadIds.length}`;
const started = { turn: { id: turnId } };
harness.notifications.push({ method: 'turn/started', params: started });
this.notificationHandler?.('turn/started', started);
const completed = { status: 'Completed', turn: {} };
if (harness.remainingThreadSystemErrors > 0) {
harness.remainingThreadSystemErrors -= 1;
const failed = {
thread: { id: threadId },
status: { type: 'systemError' }
};
harness.notifications.push({ method: 'thread/status/changed', params: failed });
this.notificationHandler?.('thread/status/changed', failed);
return { turn: { id: turnId } };
}
const completed = { status: 'Completed', turn: { id: turnId } };
harness.notifications.push({ method: 'turn/completed', params: completed });
this.notificationHandler?.('turn/completed', completed);
return { turn: {} };
return { turn: { id: turnId } };
}
async interruptTurn(): Promise<Record<string, never>> {
@@ -80,9 +102,15 @@ function createMode(): EnhancedMode {
};
}
function createSessionStub() {
function createSessionStub(messages = ['hello from launcher test']) {
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
queue.push('hello from launcher test', createMode());
messages.forEach((message, index) => {
if (index === 0 && messages.length > 1) {
queue.pushIsolateAndClear(message, createMode());
} else {
queue.push(message, createMode());
}
});
queue.close();
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
@@ -168,9 +196,13 @@ describe('codexRemoteLauncher', () => {
harness.notifications = [];
harness.registerRequestCalls = [];
harness.initializeCalls = [];
harness.startThreadIds = [];
harness.resumeThreadIds = [];
harness.startTurnThreadIds = [];
harness.remainingThreadSystemErrors = 0;
});
it('finishes a turn and emits ready when task lifecycle events omit turn_id', async () => {
it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => {
const {
session,
sessionEvents,
@@ -182,7 +214,7 @@ describe('codexRemoteLauncher', () => {
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(foundSessionIds).toContain('thread-anonymous');
expect(foundSessionIds).toContain('thread-1');
expect(getModel()).toBe('gpt-5.4');
expect(harness.initializeCalls).toEqual([{
clientInfo: {
@@ -198,4 +230,34 @@ describe('codexRemoteLauncher', () => {
expect(thinkingChanges).toContain(true);
expect(session.thinking).toBe(false);
});
it('surfaces thread-level systemError as a visible failure and emits ready', async () => {
harness.remainingThreadSystemErrors = 1;
const { session, sessionEvents } = createSessionStub();
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'thread/status/changed']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Task failed: Codex thread entered systemError'
});
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
expect(session.thinking).toBe(false);
});
it('starts a fresh thread for the next queued message after thread-level systemError', async () => {
harness.remainingThreadSystemErrors = 1;
const { session } = createSessionStub(['first message', 'second message']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1', 'thread-2']);
expect(harness.resumeThreadIds).toEqual([]);
expect(harness.startTurnThreadIds).toEqual(['thread-1', 'thread-2']);
expect(session.sessionId).toBe('thread-2');
expect(session.thinking).toBe(false);
});
});