mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user