From f1accabb29552c57a2375d44e9d701b06f31576d Mon Sep 17 00:00:00 2001 From: EthanWang <58458517+dsus4wang@users.noreply.github.com> Date: Fri, 15 May 2026 22:46:35 +0800 Subject: [PATCH] [codex] improve Codex plan mode compatibility (#538) * fix: improve codex plan mode compatibility * fix: tighten codex collaboration retry detection --------- Co-authored-by: weishu --- cli/src/codex/appServerTypes.ts | 5 ++++- cli/src/codex/codexRemoteLauncher.test.ts | 26 +++++++++++++++++++---- cli/src/codex/codexRemoteLauncher.ts | 21 ++++++++++-------- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index b498d76f..c5ceb1e3 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -53,7 +53,10 @@ export interface CollaborationModeListItem { } export interface CollaborationModeListResponse { - data?: CollaborationModeListItem[]; + data?: Array; + modes?: Array; + collaborationModes?: Array; + items?: Array; [key: string]: unknown; } diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 348f5095..fd3d3db3 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -969,6 +969,23 @@ describe('codexRemoteLauncher', () => { }); }); + it('recognizes alternate collaboration mode list envelopes', async () => { + harness.collaborationModeResponse = { collaborationModes: [{ id: 'plan' }] }; + const { session } = createSessionStub(['plan this'], { + permissionMode: 'default', + collaborationMode: 'plan', + model: 'gpt-5.4' + }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnParams).toHaveLength(1); + expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({ + mode: 'plan' + }); + }); + it('retries plan turns without collaborationMode when the runtime rejects the field', async () => { harness.startTurnErrors.push(new Error('unknown field collaborationMode')); const { session, sessionEvents } = createSessionStub(['plan this'], { @@ -1036,7 +1053,7 @@ describe('codexRemoteLauncher', () => { }); }); - it('falls back to a normal turn when collaborationMode/list omits plan', async () => { + it('still attempts plan mode when collaborationMode/list omits plan', async () => { harness.collaborationModeResponse = { data: [{ mode: 'default' }] }; const { session, sessionEvents } = createSessionStub(['plan this'], { permissionMode: 'default', @@ -1048,9 +1065,10 @@ describe('codexRemoteLauncher', () => { expect(exitReason).toBe('exit'); expect(harness.startTurnParams).toHaveLength(1); - expect(harness.startTurnParams[0]?.collaborationMode).toBeUndefined(); - expect(harness.startTurnParams[0]?.model).toBe('gpt-5.4'); - expect(sessionEvents).toContainEqual({ + expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({ + mode: 'plan' + }); + expect(sessionEvents).not.toContainEqual({ type: 'message', message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.' }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 0a421e8d..986857bb 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -266,9 +266,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return false; } - return message.includes('requires experimentalapi') - || message.includes('unknown field') + return message.includes('experimentalapi') || message.includes('unsupported') + || message.includes('unknown') || message.includes('unrecognized') || message.includes('unexpected') || message.includes('invalid field'); @@ -278,7 +278,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const record = asRecord(response); const candidates = [ Array.isArray(response) ? response : undefined, - Array.isArray(record?.data) ? record.data : undefined + Array.isArray(record?.data) ? record.data : undefined, + Array.isArray(record?.modes) ? record.modes : undefined, + Array.isArray(record?.collaborationModes) ? record.collaborationModes : undefined, + Array.isArray(record?.items) ? record.items : undefined ]; for (const candidate of candidates) { @@ -288,7 +291,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return true; } const entryRecord = asRecord(entry); - const mode = asString(entryRecord?.mode) ?? asString(entryRecord?.name); + const mode = asString(entryRecord?.mode) + ?? asString(entryRecord?.name) + ?? asString(entryRecord?.id); if (mode === 'plan') { return true; } @@ -2275,7 +2280,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }); let supportsTurnCollaborationMode = true; - let supportsPlanCollaborationMode = true; let supportsGoals = true; try { await appServerClient.setExperimentalFeatureEnablement({ enablement: { goals: true } }); @@ -2289,7 +2293,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const hasPlanMode = responseContainsPlanCollaborationMode(response); logger.debug(`[Codex] collaborationMode/list plan=${hasPlanMode}`); if (!hasPlanMode) { - supportsPlanCollaborationMode = false; + logger.debug('[Codex] collaborationMode/list did not report plan; will still attempt collaborationMode until rejected'); } } catch (error) { logger.debug(`[Codex] collaborationMode/list failed: ${errorMessage(error)}`); @@ -2735,8 +2739,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { model: session.getModel() ?? message.mode.model }; const shouldSendCollaborationMode = supportsTurnCollaborationMode - && Boolean(mode.collaborationMode) - && (mode.collaborationMode !== 'plan' || supportsPlanCollaborationMode); + && Boolean(mode.collaborationMode); const buildParams = (suppressCollaborationMode: boolean) => buildTurnStartParams({ threadId: this.currentThreadId!, message: message.message, @@ -2749,7 +2752,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }); if ( mode.collaborationMode === 'plan' - && (!supportsTurnCollaborationMode || !supportsPlanCollaborationMode) + && !supportsTurnCollaborationMode ) { session.sendSessionEvent({ type: 'message',