fix(codex): support app-server plan mode (#622)

* fix(codex): support app-server plan mode

* fix(codex): broaden plan mode compatibility checks
This commit is contained in:
NightWatcher314
2026-05-15 10:56:05 +08:00
committed by GitHub
parent 60af9835b4
commit 66e41c90d4
9 changed files with 480 additions and 19 deletions
+13
View File
@@ -44,6 +44,19 @@ export interface ModelListResponse {
[key: string]: unknown;
}
export interface CollaborationModeListItem {
name?: string;
mode?: 'plan' | 'default' | string | null;
model?: string | null;
reasoning_effort?: ReasoningEffort | null;
[key: string]: unknown;
}
export interface CollaborationModeListResponse {
data?: CollaborationModeListItem[];
[key: string]: unknown;
}
export interface ThreadStartParams {
model?: string;
modelProvider?: string;
+8
View File
@@ -2,6 +2,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { logger } from '@/ui/logger';
import { killProcessByChildProcess } from '@/utils/process';
import type {
CollaborationModeListResponse,
InitializeParams,
InitializeResponse,
ModelListParams,
@@ -145,6 +146,13 @@ export class CodexAppServerClient {
return response as ModelListResponse;
}
async listCollaborationModes(): Promise<CollaborationModeListResponse> {
const response = await this.sendRequest('collaborationMode/list', {}, {
timeoutMs: 30_000
});
return response as CollaborationModeListResponse;
}
async startThread(params: ThreadStartParams, options?: { signal?: AbortSignal }): Promise<ThreadStartResponse> {
const response = await this.sendRequest('thread/start', params, {
signal: options?.signal,
+200 -6
View File
@@ -5,10 +5,16 @@ import type { EnhancedMode } from './loop';
const harness = vi.hoisted(() => ({
notifications: [] as Array<{ method: string; params: unknown }>,
registerRequestCalls: [] as string[],
requestHandlers: new Map<string, (params: unknown) => Promise<unknown> | unknown>(),
initializeCalls: [] as unknown[],
listCollaborationModeCalls: 0,
collaborationModeResponse: { data: [{ mode: 'default' }, { mode: 'plan' }] } as unknown,
failListCollaborationModes: false,
startThreadIds: [] as string[],
resumeThreadIds: [] as string[],
startTurnThreadIds: [] as string[],
startTurnParams: [] as Array<Record<string, unknown>>,
startTurnErrors: [] as Error[],
interruptedTurns: [] as Array<{ threadId: string; turnId: string }>,
compactThreadIds: [] as string[],
suppressTurnCompletion: false,
@@ -55,8 +61,17 @@ vi.mock('./codexAppServerClient', () => {
this.notificationHandler = handler;
}
registerRequestHandler(method: string): void {
async listCollaborationModes(): Promise<unknown> {
harness.listCollaborationModeCalls += 1;
if (harness.failListCollaborationModes) {
throw new Error('collaborationMode/list failed');
}
return harness.collaborationModeResponse;
}
registerRequestHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown): void {
harness.registerRequestCalls.push(method);
harness.requestHandlers.set(method, handler);
}
async startThread(): Promise<{ thread: { id: string }; model: string }> {
@@ -88,6 +103,11 @@ vi.mock('./codexAppServerClient', () => {
}
async startTurn(params?: { threadId?: string; input?: Array<{ text?: string }>; message?: string; userMessage?: string }): Promise<{ turn: { id?: string } }> {
harness.startTurnParams.push((params ?? {}) as Record<string, unknown>);
const nextError = harness.startTurnErrors.shift();
if (nextError) {
throw nextError;
}
const threadId = params?.threadId ?? 'thread-unknown';
harness.startTurnThreadIds.push(threadId);
harness.startTurnMessages.push(params?.input?.[0]?.text ?? params?.message ?? params?.userMessage ?? '');
@@ -639,17 +659,18 @@ type FakeAgentState = {
function createMode(): EnhancedMode {
return {
permissionMode: 'default',
collaborationMode: 'default'
collaborationMode: 'default',
model: 'gpt-5.4'
};
}
function createSessionStub(messages = ['hello from launcher test']) {
function createSessionStub(messages = ['hello from launcher test'], mode = createMode()) {
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
messages.forEach((message, index) => {
if (index === 0 && messages.length > 1) {
queue.pushIsolateAndClear(message, createMode());
queue.pushIsolateAndClear(message, mode);
} else {
queue.push(message, createMode());
queue.push(message, mode);
}
});
queue.close();
@@ -660,7 +681,9 @@ function createSessionStub(messages = ['hello from launcher test']) {
const thinkingChanges: boolean[] = [];
const foundSessionIds: string[] = [];
const resetThreadCalls: string[] = [];
let currentModel: string | null | undefined;
const collaborationModes: Array<EnhancedMode['collaborationMode'] | undefined> = [];
let currentModel: string | null | undefined = mode.model;
let currentCollaborationMode: EnhancedMode['collaborationMode'] | undefined = mode.collaborationMode;
let agentState: FakeAgentState = {
requests: {},
completedRequests: {}
@@ -706,6 +729,13 @@ function createSessionStub(messages = ['hello from launcher test']) {
getModel() {
return currentModel;
},
getCollaborationMode() {
return currentCollaborationMode;
},
setCollaborationMode(nextMode: EnhancedMode['collaborationMode']) {
currentCollaborationMode = nextMode;
collaborationModes.push(nextMode);
},
onThinkingChange(nextThinking: boolean) {
session.thinking = nextThinking;
thinkingChanges.push(nextThinking);
@@ -739,6 +769,8 @@ function createSessionStub(messages = ['hello from launcher test']) {
resetThreadCalls,
rpcHandlers,
getModel: () => currentModel,
getCollaborationMode: () => currentCollaborationMode,
collaborationModes,
getAgentState: () => agentState
};
}
@@ -747,10 +779,16 @@ describe('codexRemoteLauncher', () => {
afterEach(() => {
harness.notifications = [];
harness.registerRequestCalls = [];
harness.requestHandlers = new Map();
harness.initializeCalls = [];
harness.listCollaborationModeCalls = 0;
harness.collaborationModeResponse = { data: [{ mode: 'default' }, { mode: 'plan' }] };
harness.failListCollaborationModes = false;
harness.startThreadIds = [];
harness.resumeThreadIds = [];
harness.startTurnThreadIds = [];
harness.startTurnParams = [];
harness.startTurnErrors = [];
harness.interruptedTurns = [];
harness.compactThreadIds = [];
harness.suppressTurnCompletion = false;
@@ -816,6 +854,162 @@ describe('codexRemoteLauncher', () => {
expect(session.thinking).toBe(false);
});
it('sends Codex plan collaboration mode when the app-server advertises it', async () => {
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.listCollaborationModeCalls).toBe(1);
expect(harness.startTurnParams).toHaveLength(1);
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
mode: 'plan',
settings: {
model: 'gpt-5.4'
}
});
expect(harness.startTurnParams[0]?.model).toBeUndefined();
});
it('recognizes name-only plan collaboration mode entries', async () => {
harness.collaborationModeResponse = { data: [{ name: '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'], {
permissionMode: 'default',
collaborationMode: 'plan',
model: 'gpt-5.4'
});
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startTurnParams).toHaveLength(2);
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
mode: 'plan'
});
expect(harness.startTurnParams[1]?.collaborationMode).toBeUndefined();
expect(harness.startTurnParams[1]?.model).toBe('gpt-5.4');
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
});
});
it('retries plan turns when unsupported errors use spaced collaboration mode wording', async () => {
harness.startTurnErrors.push(new Error('unsupported collaboration mode'));
const { session, sessionEvents } = 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(2);
expect(harness.startTurnParams[0]?.collaborationMode).toMatchObject({
mode: 'plan'
});
expect(harness.startTurnParams[1]?.collaborationMode).toBeUndefined();
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
});
});
it('does not retry unrelated collaborationMode errors as normal turns', async () => {
harness.startTurnErrors.push(new Error('collaborationMode value failed policy validation'));
const { session, sessionEvents } = 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(sessionEvents).not.toContainEqual({
type: 'message',
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
});
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Process exited unexpectedly'
});
});
it('falls back to a normal turn when collaborationMode/list omits plan', async () => {
harness.collaborationModeResponse = { data: [{ mode: 'default' }] };
const { session, sessionEvents } = 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).toBeUndefined();
expect(harness.startTurnParams[0]?.model).toBe('gpt-5.4');
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
});
});
it('switches collaboration mode to default after approving exit_plan_mode', async () => {
const { session, rpcHandlers, collaborationModes, getCollaborationMode } = createSessionStub(['plan this'], {
permissionMode: 'default',
collaborationMode: 'plan',
model: 'gpt-5.4'
});
const running = codexRemoteLauncher(session as never);
await vi.waitFor(() => {
expect(harness.requestHandlers.has('item/tool/requestApproval')).toBe(true);
expect(rpcHandlers.has('permission')).toBe(true);
});
const approvalHandler = harness.requestHandlers.get('item/tool/requestApproval');
const approvalPromise = approvalHandler?.({
itemId: 'exit-1',
toolName: 'exit_plan_mode',
input: { plan: '1. Edit files' }
});
await vi.waitFor(() => {
expect(rpcHandlers.has('permission')).toBe(true);
});
await rpcHandlers.get('permission')?.({ id: 'exit-1', approved: true, decision: 'approved' });
await expect(approvalPromise).resolves.toEqual({ decision: 'accept' });
await running;
expect(collaborationModes).toContain('default');
expect(getCollaborationMode()).toBe('default');
});
it('surfaces thread-level systemError only after same-thread retries are exhausted', async () => {
harness.remainingThreadSystemErrors = 4;
const { session, sessionEvents } = createSessionStub(['first message']);
+111 -12
View File
@@ -221,6 +221,55 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
return typeof value === 'string' && value.length > 0 ? value : null;
};
const errorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error);
};
const isExitPlanModeTool = (toolName: string): boolean => {
return toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode';
};
const shouldRetryWithoutCollaborationMode = (error: unknown): boolean => {
const message = errorMessage(error).toLowerCase();
const mentionsCollaborationMode = message.includes('collaborationmode')
|| message.includes('collaboration_mode')
|| message.includes('collaboration mode');
if (!mentionsCollaborationMode) {
return false;
}
return message.includes('requires experimentalapi')
|| message.includes('unknown field')
|| message.includes('unsupported')
|| message.includes('unrecognized')
|| message.includes('unexpected')
|| message.includes('invalid field');
};
const responseContainsPlanCollaborationMode = (response: unknown): boolean => {
const record = asRecord(response);
const candidates = [
Array.isArray(response) ? response : undefined,
Array.isArray(record?.data) ? record.data : undefined
];
for (const candidate of candidates) {
if (!candidate) continue;
for (const entry of candidate) {
if (entry === 'plan') {
return true;
}
const entryRecord = asRecord(entry);
const mode = asString(entryRecord?.mode) ?? asString(entryRecord?.name);
if (mode === 'plan') {
return true;
}
}
}
return false;
};
const applyResolvedModel = (value: unknown): string | undefined => {
const resolvedModel = asString(value) ?? undefined;
if (!resolvedModel) {
@@ -429,6 +478,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
is_error: !approved,
id: randomUUID()
});
if (approved && isExitPlanModeTool(toolName)) {
session.setCollaborationMode('default');
logger.debug('[Codex] exit_plan_mode approved; collaborationMode reset to default');
}
}
});
const reasoningProcessor = new ReasoningProcessor((message) => {
@@ -2174,6 +2227,18 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
experimentalApi: true
}
});
let supportsTurnCollaborationMode = true;
let supportsPlanCollaborationMode = true;
try {
const response = await appServerClient.listCollaborationModes();
const hasPlanMode = responseContainsPlanCollaborationMode(response);
logger.debug(`[Codex] collaborationMode/list plan=${hasPlanMode}`);
if (!hasPlanMode) {
supportsPlanCollaborationMode = false;
}
} catch (error) {
logger.debug(`[Codex] collaborationMode/list failed: ${errorMessage(error)}`);
}
let hasThread = false;
let pending: QueuedMessage | null = null;
@@ -2415,21 +2480,55 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
}
const turnParams = buildTurnStartParams({
threadId: this.currentThreadId,
message: message.message,
cwd: session.path,
mode: {
...message.mode,
model: session.getModel() ?? message.mode.model
},
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
allowAnonymousTerminalEvent = false;
const turnResponse = await appServerClient.startTurn(turnParams, {
signal: this.abortController.signal
const mode = {
...message.mode,
model: session.getModel() ?? message.mode.model
};
const shouldSendCollaborationMode = supportsTurnCollaborationMode
&& Boolean(mode.collaborationMode)
&& (mode.collaborationMode !== 'plan' || supportsPlanCollaborationMode);
const buildParams = (suppressCollaborationMode: boolean) => buildTurnStartParams({
threadId: this.currentThreadId!,
message: message.message,
cwd: session.path,
mode,
cliOverrides: session.codexCliOverrides,
overrides: suppressCollaborationMode
? { suppressCollaborationMode: true }
: undefined
});
if (
mode.collaborationMode === 'plan'
&& (!supportsTurnCollaborationMode || !supportsPlanCollaborationMode)
) {
session.sendSessionEvent({
type: 'message',
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
});
}
let turnResponse: unknown;
try {
turnResponse = await appServerClient.startTurn(buildParams(!shouldSendCollaborationMode), {
signal: this.abortController.signal
});
} catch (error) {
if (shouldSendCollaborationMode && shouldRetryWithoutCollaborationMode(error)) {
supportsTurnCollaborationMode = false;
if (mode.collaborationMode === 'plan') {
session.sendSessionEvent({
type: 'message',
message: 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.'
});
}
turnResponse = await appServerClient.startTurn(buildParams(true), {
signal: this.abortController.signal
});
} else {
throw error;
}
}
const turnRecord = asRecord(turnResponse);
const turn = turnRecord ? asRecord(turnRecord.turn) : null;
const turnId = asString(turn?.id);
+1
View File
@@ -119,6 +119,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => {
this.collaborationMode = mode;
this.pushKeepAlive();
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
@@ -343,4 +343,17 @@ describe('appServerConfig', () => {
});
expect(params.model).toBeUndefined();
});
it('can suppress collaboration mode while preserving top-level model', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
cwd: '/workspace/project',
mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'plan' },
overrides: { suppressCollaborationMode: true }
});
expect(params.collaborationMode).toBeUndefined();
expect(params.model).toBe('o3');
});
});
+4 -1
View File
@@ -139,6 +139,7 @@ export function buildTurnStartParams(args: {
approvalPolicy?: TurnStartParams['approvalPolicy'];
sandboxPolicy?: TurnStartParams['sandboxPolicy'];
model?: string;
suppressCollaborationMode?: boolean;
};
}): TurnStartParams {
const params: TurnStartParams = {
@@ -163,7 +164,9 @@ export function buildTurnStartParams(args: {
params.sandboxPolicy = sandboxPolicy;
}
const collaborationMode = args.mode?.collaborationMode;
const collaborationMode = args.overrides?.suppressCollaborationMode
? undefined
: args.mode?.collaborationMode;
const model = args.overrides?.model ?? args.mode?.model;
if (args.mode?.modelReasoningEffort) {
@@ -78,6 +78,72 @@ describe('registerAppServerPermissionHandlers', () => {
});
});
it('forwards generic tool approval requests with the app-server tool name', async () => {
const { client, handlers } = createClient();
const permissionHandler = {
handleToolCall: vi.fn(async () => ({ decision: 'approved' }))
};
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never
});
const handler = handlers.get('item/tool/requestApproval');
expect(handler).toBeTypeOf('function');
await expect(handler?.({
itemId: 'tool-123',
toolName: 'exit_plan_mode',
input: { plan: '1. Edit files' }
})).resolves.toEqual({ decision: 'accept' });
expect(permissionHandler.handleToolCall).toHaveBeenCalledWith(
'tool-123',
'exit_plan_mode',
{ plan: '1. Edit files' }
);
});
it('maps latest permissions approval requests to granted permission profiles', async () => {
const { client, handlers } = createClient();
const permissions = {
network: { enabled: true },
fileSystem: null
};
const permissionHandler = {
handleToolCall: vi.fn(async () => ({ decision: 'approved_for_session' }))
};
registerAppServerPermissionHandlers({
client: client as never,
permissionHandler: permissionHandler as never
});
const handler = handlers.get('item/permissions/requestApproval');
expect(handler).toBeTypeOf('function');
await expect(handler?.({
itemId: 'perm-123',
reason: 'Need network',
cwd: '/workspace/project',
permissions
})).resolves.toEqual({
permissions,
scope: 'session'
});
expect(permissionHandler.handleToolCall).toHaveBeenCalledWith(
'perm-123',
'CodexPermission',
{
message: 'Need network',
cwd: '/workspace/project',
permissions
}
);
});
it('accepts MCP elicitation requests with schema defaults', async () => {
const { client, handlers } = createClient();
const permissionHandler = {
@@ -29,6 +29,15 @@ function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function pickToolName(record: Record<string, unknown>): string {
return asString(record.toolName)
?? asString(record.tool_name)
?? asString(record.tool)
?? asString(record.name)
?? asString(record.permission)
?? 'CodexTool';
}
function mapDecision(decision: PermissionDecision): { decision: string } {
switch (decision) {
case 'approved':
@@ -42,6 +51,29 @@ function mapDecision(decision: PermissionDecision): { decision: string } {
}
}
function mapPermissionGrant(
requested: unknown,
decision: PermissionDecision
): {
permissions: unknown;
scope: 'turn' | 'session';
} {
if (decision === 'approved' || decision === 'approved_for_session') {
return {
permissions: requested,
scope: decision === 'approved_for_session' ? 'session' : 'turn'
};
}
return {
permissions: {
network: null,
fileSystem: null
},
scope: 'turn'
};
}
function firstString(values: unknown): string | undefined {
if (!Array.isArray(values)) {
return undefined;
@@ -170,6 +202,38 @@ export function registerAppServerPermissionHandlers(args: {
return mapDecision(result.decision);
});
client.registerRequestHandler('item/permissions/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? randomUUID();
const permissions = record.permissions ?? {};
const result = await permissionHandler.handleToolCall(
toolCallId,
'CodexPermission',
{
message: asString(record.reason),
cwd: asString(record.cwd),
permissions
}
) as PermissionResult;
return mapPermissionGrant(permissions, result.decision);
});
client.registerRequestHandler('item/tool/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? asString(record.item_id) ?? randomUUID();
const toolName = pickToolName(record);
const result = await permissionHandler.handleToolCall(
toolCallId,
toolName,
record.input ?? record.arguments ?? params
) as PermissionResult;
return mapDecision(result.decision);
});
client.registerRequestHandler('item/tool/requestUserInput', async (params) => {
const record = asRecord(params) ?? {};
const requestId = asString(record.itemId) ?? randomUUID();