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
8 changes: 8 additions & 0 deletions cli/src/codex/appServerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export interface ModelListResponse {
[key: string]: unknown;
}

export interface CollaborationModeListResponse {
data?: Array<{ mode?: string; name?: string; id?: string } | string>;
modes?: Array<{ mode?: string; name?: string; id?: string } | string>;
collaborationModes?: Array<{ mode?: string; name?: string; id?: string } | string>;
items?: Array<{ mode?: string; name?: string; id?: string } | string>;
[key: string]: unknown;
}

export interface ThreadStartParams {
model?: string;
modelProvider?: string;
Expand Down
8 changes: 8 additions & 0 deletions cli/src/codex/codexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { killProcessByChildProcess } from '@/utils/process';
import type {
InitializeParams,
InitializeResponse,
CollaborationModeListResponse,
ModelListParams,
ModelListResponse,
ThreadStartParams,
Expand Down Expand Up @@ -142,6 +143,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,
Expand Down
99 changes: 92 additions & 7 deletions cli/src/codex/codexRemoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ 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,
startThreadIds: [] as string[],
resumeThreadIds: [] as string[],
startTurnThreadIds: [] as string[],
startTurnParams: [] as Array<Record<string, unknown>>,
startTurnErrors: [] as Error[],
remainingThreadSystemErrors: 0
}));

Expand All @@ -23,12 +27,18 @@ vi.mock('./codexAppServerClient', () => {
return { protocolVersion: 1 };
}

async listCollaborationModes(): Promise<{ collaborationModes: Array<{ mode: string }> }> {
harness.listCollaborationModeCalls += 1;
return { collaborationModes: [{ mode: 'default' }, { mode: 'plan' }] };
}

setNotificationHandler(handler: ((method: string, params: unknown) => void) | null): void {
this.notificationHandler = handler;
}

registerRequestHandler(method: string): void {
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 }> {
Expand All @@ -43,7 +53,12 @@ vi.mock('./codexAppServerClient', () => {
return { thread: { id }, model: 'gpt-5.4' };
}

async startTurn(params?: { threadId?: string }): Promise<{ turn: { id?: string } }> {
async startTurn(params?: { threadId?: string; collaborationMode?: unknown }): 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);
const turnId = `turn-${harness.startTurnThreadIds.length}`;
Expand Down Expand Up @@ -98,17 +113,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: EnhancedMode = 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();
Expand All @@ -117,7 +133,9 @@ function createSessionStub(messages = ['hello from launcher test']) {
const codexMessages: unknown[] = [];
const thinkingChanges: boolean[] = [];
const foundSessionIds: 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: {}
Expand Down Expand Up @@ -160,6 +178,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);
Expand Down Expand Up @@ -187,6 +212,8 @@ function createSessionStub(messages = ['hello from launcher test']) {
foundSessionIds,
rpcHandlers,
getModel: () => currentModel,
getCollaborationMode: () => currentCollaborationMode,
collaborationModes,
getAgentState: () => agentState
};
}
Expand All @@ -195,10 +222,14 @@ describe('codexRemoteLauncher', () => {
afterEach(() => {
harness.notifications = [];
harness.registerRequestCalls = [];
harness.requestHandlers = new Map();
harness.initializeCalls = [];
harness.listCollaborationModeCalls = 0;
harness.startThreadIds = [];
harness.resumeThreadIds = [];
harness.startTurnThreadIds = [];
harness.startTurnParams = [];
harness.startTurnErrors = [];
harness.remainingThreadSystemErrors = 0;
});

Expand Down Expand Up @@ -260,4 +291,58 @@ describe('codexRemoteLauncher', () => {
expect(session.sessionId).toBe('thread-2');
expect(session.thinking).toBe(false);
});

it('retries plan turns without collaborationMode when the runtime rejects the field', async () => {
harness.startTurnErrors.push(new Error('unknown field collaborationMode; experimentalApi is required'));
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.listCollaborationModeCalls).toBe(1);
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('switches collaboration mode to default after approving exit_plan_mode', async () => {
const { session, rpcHandlers, collaborationModes, getCollaborationMode } = createSessionStub([], {
permissionMode: 'default',
collaborationMode: 'plan',
model: 'gpt-5.4'
});

const exitReasonPromise = codexRemoteLauncher(session as never);
await new Promise((resolve) => setTimeout(resolve, 0));

const approvalHandler = harness.requestHandlers.get('item/tool/requestApproval');
expect(approvalHandler).toBeTypeOf('function');
const approvalPromise = approvalHandler?.({
itemId: 'exit-1',
toolName: 'exit_plan_mode',
input: { plan: '1. Edit files' }
});
await new Promise((resolve) => setTimeout(resolve, 0));

const permissionRpc = rpcHandlers.get('permission');
expect(permissionRpc).toBeTypeOf('function');
await permissionRpc?.({ id: 'exit-1', approved: true, decision: 'approved' });
await expect(approvalPromise).resolves.toEqual({ decision: 'accept' });

const exitReason = await exitReasonPromise;

expect(exitReason).toBe('exit');
expect(collaborationModes).toContain('default');
expect(getCollaborationMode()).toBe('default');
});
});
115 changes: 107 additions & 8 deletions cli/src/codex/codexRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,65 @@ import {
type HappyServer = Awaited<ReturnType<typeof buildHapiMcpBridge>>['server'];
type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string };

function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}

function isExitPlanModeTool(toolName: string): boolean {
return toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode';
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function shouldRetryWithoutCollaborationMode(error: unknown): boolean {
const message = errorMessage(error).toLowerCase();
if (!message.includes('collaborationmode') && !message.includes('collaboration_mode')) {
return false;
}
return message.includes('experimentalapi')
|| message.includes('unsupported')
|| message.includes('unknown')
|| message.includes('unexpected')
|| message.includes('unrecognized')
|| message.includes('invalid')
|| message.includes('field')
|| message.includes('mode');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] message.includes("mode") is always true once the guard has matched collaborationMode, so this retries any app-server error that mentions the field, including non-compatibility failures. That silently resends a plan turn as a normal turn instead of surfacing the actual error.

Suggested fix:

return message.includes("experimentalapi")
    || message.includes("unsupported")
    || message.includes("unknown")
    || message.includes("unexpected")
    || message.includes("unrecognized")
    || message.includes("invalid field");

}

function responseContainsPlanCollaborationMode(response: unknown): boolean {
const record = response && typeof response === 'object' ? response as Record<string, unknown> : null;
const candidates = [
Array.isArray(response) ? response : 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) {
if (!candidate) continue;
for (const entry of candidate) {
if (entry === 'plan') {
return true;
}
if (!entry || typeof entry !== 'object') {
continue;
}
const entryRecord = entry as Record<string, unknown>;
const mode = asString(entryRecord.mode)
?? asString(entryRecord.name)
?? asString(entryRecord.id);
if (mode === 'plan') {
return true;
}
}
}

return false;
}

class CodexRemoteLauncher extends RemoteLauncherBase {
private readonly session: CodexSession;
private readonly appServerClient: CodexAppServerClient;
Expand Down Expand Up @@ -225,6 +284,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) => {
Expand Down Expand Up @@ -583,6 +646,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
experimentalApi: true
}
});
let supportsTurnCollaborationMode = true;
try {
const response = await appServerClient.listCollaborationModes();
const hasPlanMode = responseContainsPlanCollaborationMode(response);
logger.debug(`[Codex] collaborationMode/list plan=${hasPlanMode}`);
if (!hasPlanMode) {
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)}`);
}

let hasThread = false;
let pending: QueuedMessage | null = null;
Expand Down Expand Up @@ -694,21 +768,46 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
}
}

const turnParams = buildTurnStartParams({
threadId: this.currentThreadId,
turnInFlight = true;
allowAnonymousTerminalEvent = false;

const buildParams = (suppressCollaborationMode: boolean) => 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
cliOverrides: session.codexCliOverrides,
overrides: suppressCollaborationMode
? { suppressCollaborationMode: true }
: undefined
});

let turnResponse: unknown;
const shouldSendCollaborationMode = supportsTurnCollaborationMode && Boolean(message.mode.collaborationMode);
try {
turnResponse = await appServerClient.startTurn(buildParams(!shouldSendCollaborationMode), {
signal: this.abortController.signal
});
} catch (error) {
if (shouldSendCollaborationMode && shouldRetryWithoutCollaborationMode(error)) {
supportsTurnCollaborationMode = false;
if (message.mode.collaborationMode === 'plan') {
const fallbackMessage = 'Plan mode is not supported by this Codex runtime. Sent as a normal turn instead.';
logger.debug(`[Codex] ${fallbackMessage}`);
session.sendSessionEvent({ type: 'message', message: fallbackMessage });
} else {
logger.debug('[Codex] collaborationMode is not supported by this Codex runtime; retrying without it');
}
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);
Expand Down
1 change: 1 addition & 0 deletions cli/src/codex/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {

setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => {
this.collaborationMode = mode;
this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime());
};

recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
Expand Down
13 changes: 13 additions & 0 deletions cli/src/codex/utils/appServerConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,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');
});
});
Loading
Loading