Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,10 @@ export class CodexAcpClient {
await this.waitForSessionNotifications(sessionId);
return await elicitationHandler.handleElicitation(params);
},
handleUserInput: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await elicitationHandler.handleUserInput(params);
},
});
}

Expand Down
20 changes: 20 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import type {
ThreadStartResponse,
ThreadUnsubscribeParams,
ThreadUnsubscribeResponse,
ToolRequestUserInputParams,
ToolRequestUserInputResponse,
TurnCompletedNotification,
TurnInterruptParams,
TurnInterruptResponse,
Expand All @@ -73,6 +75,7 @@ export interface ApprovalHandler {

export interface ElicitationHandler {
handleElicitation(params: McpServerElicitationRequestParams): Promise<McpServerElicitationRequestResponse>;
handleUserInput(params: ToolRequestUserInputParams): Promise<ToolRequestUserInputResponse>;
}

export type McpStartupFailure = {
Expand Down Expand Up @@ -110,6 +113,12 @@ const McpServerElicitationRequest = new RequestType<
void
>('mcpServer/elicitation/request');

const ToolRequestUserInputRequest = new RequestType<
ToolRequestUserInputParams,
ToolRequestUserInputResponse,
void
>('item/tool/requestUserInput');

const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000;

/**
Expand Down Expand Up @@ -217,6 +226,17 @@ export class CodexAppServerClient {
}
return await handler.handleElicitation(params);
});

this.connection.onRequest(ToolRequestUserInputRequest, async (params) => {
if (this.isStaleTurn(params.threadId, params.turnId)) {
return { answers: {} };
}
const handler = this.elicitationHandlers.get(params.threadId);
if (!handler) {
return { answers: {} };
}
return await handler.handleUserInput(params);
});
}

onApprovalRequest(threadId: string, handler: ApprovalHandler): void {
Expand Down
204 changes: 202 additions & 2 deletions src/CodexElicitationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
ItemStartedNotification,
McpServerElicitationRequestParams,
McpServerElicitationRequestResponse,
ToolRequestUserInputParams,
ToolRequestUserInputResponse,
} from "./app-server/v2";
import { logger } from "./Logger";
import { McpApprovalOptionId } from "./McpApprovalOptionId";
Expand Down Expand Up @@ -36,6 +38,8 @@ type AcpBackedMcpElicitationParams = Extract<
{ mode: "form" } | { mode: "url" }
>;

const USER_INPUT_OTHER_FIELD_SUFFIX = "__other";

/**
* Parses the `persist` field from the elicitation request `_meta`.
* Codex advertises which persistence options the client should show.
Expand Down Expand Up @@ -219,6 +223,33 @@ function elicitationResponseMeta(
return Object.keys(meta).length === 0 ? null : meta;
}

function userInputOtherFieldId(questionId: string, questionIds: Set<string>): string {
const base = `${questionId}${USER_INPUT_OTHER_FIELD_SUFFIX}`;
if (!questionIds.has(base)) {
return base;
}

let index = 1;
while (questionIds.has(`${base}${index}`)) {
index += 1;
}
return `${base}${index}`;
}

function userInputResponseValue(
content: Record<string, acp.ElicitationContentValue>,
fieldId: string
): acp.ElicitationContentValue | undefined {
const value = content[fieldId];
if (typeof value === "string" && value.trim() === "") {
return undefined;
}
if (Array.isArray(value) && value.length === 0) {
return undefined;
}
return value;
}

/**
* Builds the ACP permission options for an MCP tool call approval elicitation.
* Always includes "Allow Once"; adds session/always persist options when advertised.
Expand Down Expand Up @@ -332,8 +363,76 @@ export class CodexElicitationHandler implements ElicitationHandler {
}
}

private requestOptions(): acp.SendRequestOptions | undefined {
return this.cancellationSignal ? {cancellationSignal: this.cancellationSignal} : undefined;
async handleUserInput(params: ToolRequestUserInputParams): Promise<ToolRequestUserInputResponse> {
if (!clientSupportsFormElicitation(this.clientCapabilities)) {
return { answers: {} };
}

try {
const response = await this.requestUserInputElicitation(params);
if (response === null) {
return { answers: {} };
}
return this.convertUserInputResponse(response, params);
} catch (error) {
logger.error("Error handling Codex user input request", error);
return { answers: {} };
}
}

private requestOptions(
cancellationSignal: AbortSignal | undefined = this.cancellationSignal
): acp.SendRequestOptions | undefined {
return cancellationSignal ? {cancellationSignal} : undefined;
}

private async requestUserInputElicitation(
params: ToolRequestUserInputParams
): Promise<acp.CreateElicitationResponse | null> {
const request = this.buildUserInputRequest(params);
if (params.autoResolutionMs === null) {
return await this.connection.request(
acp.methods.client.elicitation.create,
request,
this.requestOptions(),
);
}

const abortController = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
let removeAbortListener: (() => void) | undefined;
const timeoutPromise = new Promise<null>((resolve) => {
const resolveWithoutInput = () => {
abortController.abort();
resolve(null);
};
timeout = setTimeout(resolveWithoutInput, Math.max(0, params.autoResolutionMs ?? 0));
if (this.cancellationSignal?.aborted) {
resolveWithoutInput();
return;
}
if (this.cancellationSignal) {
this.cancellationSignal.addEventListener("abort", resolveWithoutInput, { once: true });
removeAbortListener = () => {
this.cancellationSignal?.removeEventListener("abort", resolveWithoutInput);
};
}
});
const requestPromise = Promise.resolve(this.connection.request(
acp.methods.client.elicitation.create,
request,
this.requestOptions(abortController.signal),
));
void requestPromise.catch(() => {});

try {
return await Promise.race([requestPromise, timeoutPromise]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
removeAbortListener?.();
}
}

private createMcpElicitationContext(params: McpServerElicitationRequestParams): McpElicitationContext {
Expand Down Expand Up @@ -393,6 +492,79 @@ export class CodexElicitationHandler implements ElicitationHandler {
}
}

private buildUserInputRequest(params: ToolRequestUserInputParams): acp.CreateElicitationRequest {
const properties: Record<string, acp.ElicitationPropertySchema> = {};
const required: string[] = [];
const questionIds = new Set(params.questions.map(question => question.id));

for (const question of params.questions) {
const options = question.options ?? [];
const hasOptions = options.length > 0;
const hasOtherAnswer = question.isOther && hasOptions;
const base = {
title: question.header || question.id,
description: question.question,
_meta: {
codex: {
isOther: question.isOther,
isSecret: question.isSecret,
},
},
};
if (!hasOtherAnswer) {
required.push(question.id);
}
properties[question.id] = hasOptions
? {
...base,
type: "string",
oneOf: options.map(option => ({
const: option.label,
title: option.label,
description: option.description,
})),
}
: {
...base,
type: "string",
};
if (hasOtherAnswer) {
properties[userInputOtherFieldId(question.id, questionIds)] = {
type: "string",
title: "Other",
description: "Type your own answer instead of choosing an option above.",
_meta: {
codex: {
questionId: question.id,
isOtherAnswer: true,
isSecret: question.isSecret,
},
},
};
}
}

const firstQuestion = params.questions[0];
return {
sessionId: this.sessionState.sessionId,
toolCallId: params.itemId,
mode: "form",
message: params.questions.length === 1 && firstQuestion
? firstQuestion.question
: "Input requested",
requestedSchema: {
type: "object",
properties,
required,
},
_meta: {
codex: {
autoResolutionMs: params.autoResolutionMs,
},
},
};
}

private buildPermissionRequest(
params: McpServerElicitationRequestParams,
context: McpElicitationContext
Expand Down Expand Up @@ -514,6 +686,34 @@ export class CodexElicitationHandler implements ElicitationHandler {
return { action: "cancel", content: null, _meta: null };
}

private convertUserInputResponse(
response: acp.CreateElicitationResponse,
params: ToolRequestUserInputParams
): ToolRequestUserInputResponse {
if (!acp.CreateElicitationResponse.isAccept(response)) {
return { answers: {} };
}

const answers: ToolRequestUserInputResponse["answers"] = {};
const content = contentRecord(response.content);
const questionIds = new Set(params.questions.map(question => question.id));
for (const question of params.questions) {
const value = question.isOther && question.options != null && question.options.length > 0
? userInputResponseValue(content, userInputOtherFieldId(question.id, questionIds))
?? userInputResponseValue(content, question.id)
: userInputResponseValue(content, question.id);
if (value === undefined) {
continue;
}
answers[question.id] = {
answers: Array.isArray(value)
? value.map(String)
: [String(value)],
};
}
return { answers };
}

private async publishAcceptedMcpToolApproval(
context: McpElicitationContext,
accepted: boolean
Expand Down
1 change: 1 addition & 0 deletions src/ElicitationCapabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type * as acp from "@agentclientprotocol/sdk";
import type {InitializeCapabilities} from "./app-server";

export function clientSupportsFormElicitation(
clientCapabilities?: acp.ClientCapabilities | null
Expand Down
Loading