From 66e41c90d49e07943c63329ee403f980cfb97233 Mon Sep 17 00:00:00 2001 From: NightWatcher314 Date: Fri, 15 May 2026 10:56:05 +0800 Subject: [PATCH] fix(codex): support app-server plan mode (#622) * fix(codex): support app-server plan mode * fix(codex): broaden plan mode compatibility checks --- cli/src/codex/appServerTypes.ts | 13 ++ cli/src/codex/codexAppServerClient.ts | 8 + cli/src/codex/codexRemoteLauncher.test.ts | 206 +++++++++++++++++- cli/src/codex/codexRemoteLauncher.ts | 123 ++++++++++- cli/src/codex/session.ts | 1 + cli/src/codex/utils/appServerConfig.test.ts | 13 ++ cli/src/codex/utils/appServerConfig.ts | 5 +- .../utils/appServerPermissionAdapter.test.ts | 66 ++++++ .../codex/utils/appServerPermissionAdapter.ts | 64 ++++++ 9 files changed, 480 insertions(+), 19 deletions(-) diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index 2f13080a..fa7192cc 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -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; diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index ca3b4bfc..e5c5844b 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -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 { + const response = await this.sendRequest('collaborationMode/list', {}, { + timeoutMs: 30_000 + }); + return response as CollaborationModeListResponse; + } + async startThread(params: ThreadStartParams, options?: { signal?: AbortSignal }): Promise { const response = await this.sendRequest('thread/start', params, { signal: options?.signal, diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 2617d6c2..7cd74ab6 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -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 Promise | 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>, + 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 { + harness.listCollaborationModeCalls += 1; + if (harness.failListCollaborationModes) { + throw new Error('collaborationMode/list failed'); + } + return harness.collaborationModeResponse; + } + + registerRequestHandler(method: string, handler: (params: unknown) => Promise | 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); + 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((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 = []; + 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']); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 1343039a..99036911 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -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); diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 701c69e9..a28477ba 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -119,6 +119,7 @@ export class CodexSession extends AgentSessionBase { setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => { this.collaborationMode = mode; + this.pushKeepAlive(); }; recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 6ac817b6..7f2eaecb 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -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'); + }); }); diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 292d5dab..f7f785fa 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -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) { diff --git a/cli/src/codex/utils/appServerPermissionAdapter.test.ts b/cli/src/codex/utils/appServerPermissionAdapter.test.ts index 18529001..1c6d866f 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.test.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.test.ts @@ -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 = { diff --git a/cli/src/codex/utils/appServerPermissionAdapter.ts b/cli/src/codex/utils/appServerPermissionAdapter.ts index 396b8018..9e3f2788 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.ts @@ -29,6 +29,15 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } +function pickToolName(record: Record): 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();