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
@@ -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();