Skip to content

Commit f142ca1

Browse files
committed
Support Codex request_user_input elicitation
We dont' enable experimental flags, but if we get the events, we process them.
1 parent 9c55411 commit f142ca1

6 files changed

Lines changed: 432 additions & 5 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,10 @@ export class CodexAcpClient {
616616
await this.waitForSessionNotifications(sessionId);
617617
return await elicitationHandler.handleElicitation(params);
618618
},
619+
handleUserInput: async (params) => {
620+
await this.waitForSessionNotifications(sessionId);
621+
return await elicitationHandler.handleUserInput(params);
622+
},
619623
});
620624
}
621625

src/CodexAppServerClient.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import type {
5151
ThreadStartResponse,
5252
ThreadUnsubscribeParams,
5353
ThreadUnsubscribeResponse,
54+
ToolRequestUserInputParams,
55+
ToolRequestUserInputResponse,
5456
TurnCompletedNotification,
5557
TurnInterruptParams,
5658
TurnInterruptResponse,
@@ -73,6 +75,7 @@ export interface ApprovalHandler {
7375

7476
export interface ElicitationHandler {
7577
handleElicitation(params: McpServerElicitationRequestParams): Promise<McpServerElicitationRequestResponse>;
78+
handleUserInput(params: ToolRequestUserInputParams): Promise<ToolRequestUserInputResponse>;
7679
}
7780

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

116+
const ToolRequestUserInputRequest = new RequestType<
117+
ToolRequestUserInputParams,
118+
ToolRequestUserInputResponse,
119+
void
120+
>('item/tool/requestUserInput');
121+
113122
const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000;
114123

115124
/**
@@ -217,6 +226,17 @@ export class CodexAppServerClient {
217226
}
218227
return await handler.handleElicitation(params);
219228
});
229+
230+
this.connection.onRequest(ToolRequestUserInputRequest, async (params) => {
231+
if (this.isStaleTurn(params.threadId, params.turnId)) {
232+
return { answers: {} };
233+
}
234+
const handler = this.elicitationHandlers.get(params.threadId);
235+
if (!handler) {
236+
return { answers: {} };
237+
}
238+
return await handler.handleUserInput(params);
239+
});
220240
}
221241

222242
onApprovalRequest(threadId: string, handler: ApprovalHandler): void {

src/CodexElicitationHandler.ts

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
ItemStartedNotification,
99
McpServerElicitationRequestParams,
1010
McpServerElicitationRequestResponse,
11+
ToolRequestUserInputParams,
12+
ToolRequestUserInputResponse,
1113
} from "./app-server/v2";
1214
import { logger } from "./Logger";
1315
import { McpApprovalOptionId } from "./McpApprovalOptionId";
@@ -36,6 +38,8 @@ type AcpBackedMcpElicitationParams = Extract<
3638
{ mode: "form" } | { mode: "url" }
3739
>;
3840

41+
const USER_INPUT_OTHER_FIELD_SUFFIX = "__other";
42+
3943
/**
4044
* Parses the `persist` field from the elicitation request `_meta`.
4145
* Codex advertises which persistence options the client should show.
@@ -219,6 +223,33 @@ function elicitationResponseMeta(
219223
return Object.keys(meta).length === 0 ? null : meta;
220224
}
221225

226+
function userInputOtherFieldId(questionId: string, questionIds: Set<string>): string {
227+
const base = `${questionId}${USER_INPUT_OTHER_FIELD_SUFFIX}`;
228+
if (!questionIds.has(base)) {
229+
return base;
230+
}
231+
232+
let index = 1;
233+
while (questionIds.has(`${base}${index}`)) {
234+
index += 1;
235+
}
236+
return `${base}${index}`;
237+
}
238+
239+
function userInputResponseValue(
240+
content: Record<string, acp.ElicitationContentValue>,
241+
fieldId: string
242+
): acp.ElicitationContentValue | undefined {
243+
const value = content[fieldId];
244+
if (typeof value === "string" && value.trim() === "") {
245+
return undefined;
246+
}
247+
if (Array.isArray(value) && value.length === 0) {
248+
return undefined;
249+
}
250+
return value;
251+
}
252+
222253
/**
223254
* Builds the ACP permission options for an MCP tool call approval elicitation.
224255
* Always includes "Allow Once"; adds session/always persist options when advertised.
@@ -332,8 +363,76 @@ export class CodexElicitationHandler implements ElicitationHandler {
332363
}
333364
}
334365

335-
private requestOptions(): acp.SendRequestOptions | undefined {
336-
return this.cancellationSignal ? {cancellationSignal: this.cancellationSignal} : undefined;
366+
async handleUserInput(params: ToolRequestUserInputParams): Promise<ToolRequestUserInputResponse> {
367+
if (!clientSupportsFormElicitation(this.clientCapabilities)) {
368+
return { answers: {} };
369+
}
370+
371+
try {
372+
const response = await this.requestUserInputElicitation(params);
373+
if (response === null) {
374+
return { answers: {} };
375+
}
376+
return this.convertUserInputResponse(response, params);
377+
} catch (error) {
378+
logger.error("Error handling Codex user input request", error);
379+
return { answers: {} };
380+
}
381+
}
382+
383+
private requestOptions(
384+
cancellationSignal: AbortSignal | undefined = this.cancellationSignal
385+
): acp.SendRequestOptions | undefined {
386+
return cancellationSignal ? {cancellationSignal} : undefined;
387+
}
388+
389+
private async requestUserInputElicitation(
390+
params: ToolRequestUserInputParams
391+
): Promise<acp.CreateElicitationResponse | null> {
392+
const request = this.buildUserInputRequest(params);
393+
if (params.autoResolutionMs === null) {
394+
return await this.connection.request(
395+
acp.methods.client.elicitation.create,
396+
request,
397+
this.requestOptions(),
398+
);
399+
}
400+
401+
const abortController = new AbortController();
402+
let timeout: ReturnType<typeof setTimeout> | undefined;
403+
let removeAbortListener: (() => void) | undefined;
404+
const timeoutPromise = new Promise<null>((resolve) => {
405+
const resolveWithoutInput = () => {
406+
abortController.abort();
407+
resolve(null);
408+
};
409+
timeout = setTimeout(resolveWithoutInput, Math.max(0, params.autoResolutionMs ?? 0));
410+
if (this.cancellationSignal?.aborted) {
411+
resolveWithoutInput();
412+
return;
413+
}
414+
if (this.cancellationSignal) {
415+
this.cancellationSignal.addEventListener("abort", resolveWithoutInput, { once: true });
416+
removeAbortListener = () => {
417+
this.cancellationSignal?.removeEventListener("abort", resolveWithoutInput);
418+
};
419+
}
420+
});
421+
const requestPromise = Promise.resolve(this.connection.request(
422+
acp.methods.client.elicitation.create,
423+
request,
424+
this.requestOptions(abortController.signal),
425+
));
426+
void requestPromise.catch(() => {});
427+
428+
try {
429+
return await Promise.race([requestPromise, timeoutPromise]);
430+
} finally {
431+
if (timeout) {
432+
clearTimeout(timeout);
433+
}
434+
removeAbortListener?.();
435+
}
337436
}
338437

339438
private createMcpElicitationContext(params: McpServerElicitationRequestParams): McpElicitationContext {
@@ -393,6 +492,79 @@ export class CodexElicitationHandler implements ElicitationHandler {
393492
}
394493
}
395494

495+
private buildUserInputRequest(params: ToolRequestUserInputParams): acp.CreateElicitationRequest {
496+
const properties: Record<string, acp.ElicitationPropertySchema> = {};
497+
const required: string[] = [];
498+
const questionIds = new Set(params.questions.map(question => question.id));
499+
500+
for (const question of params.questions) {
501+
const options = question.options ?? [];
502+
const hasOptions = options.length > 0;
503+
const hasOtherAnswer = question.isOther && hasOptions;
504+
const base = {
505+
title: question.header || question.id,
506+
description: question.question,
507+
_meta: {
508+
codex: {
509+
isOther: question.isOther,
510+
isSecret: question.isSecret,
511+
},
512+
},
513+
};
514+
if (!hasOtherAnswer) {
515+
required.push(question.id);
516+
}
517+
properties[question.id] = hasOptions
518+
? {
519+
...base,
520+
type: "string",
521+
oneOf: options.map(option => ({
522+
const: option.label,
523+
title: option.label,
524+
description: option.description,
525+
})),
526+
}
527+
: {
528+
...base,
529+
type: "string",
530+
};
531+
if (hasOtherAnswer) {
532+
properties[userInputOtherFieldId(question.id, questionIds)] = {
533+
type: "string",
534+
title: "Other",
535+
description: "Type your own answer instead of choosing an option above.",
536+
_meta: {
537+
codex: {
538+
questionId: question.id,
539+
isOtherAnswer: true,
540+
isSecret: question.isSecret,
541+
},
542+
},
543+
};
544+
}
545+
}
546+
547+
const firstQuestion = params.questions[0];
548+
return {
549+
sessionId: this.sessionState.sessionId,
550+
toolCallId: params.itemId,
551+
mode: "form",
552+
message: params.questions.length === 1 && firstQuestion
553+
? firstQuestion.question
554+
: "Input requested",
555+
requestedSchema: {
556+
type: "object",
557+
properties,
558+
required,
559+
},
560+
_meta: {
561+
codex: {
562+
autoResolutionMs: params.autoResolutionMs,
563+
},
564+
},
565+
};
566+
}
567+
396568
private buildPermissionRequest(
397569
params: McpServerElicitationRequestParams,
398570
context: McpElicitationContext
@@ -514,6 +686,34 @@ export class CodexElicitationHandler implements ElicitationHandler {
514686
return { action: "cancel", content: null, _meta: null };
515687
}
516688

689+
private convertUserInputResponse(
690+
response: acp.CreateElicitationResponse,
691+
params: ToolRequestUserInputParams
692+
): ToolRequestUserInputResponse {
693+
if (!acp.CreateElicitationResponse.isAccept(response)) {
694+
return { answers: {} };
695+
}
696+
697+
const answers: ToolRequestUserInputResponse["answers"] = {};
698+
const content = contentRecord(response.content);
699+
const questionIds = new Set(params.questions.map(question => question.id));
700+
for (const question of params.questions) {
701+
const value = question.isOther && question.options != null && question.options.length > 0
702+
? userInputResponseValue(content, userInputOtherFieldId(question.id, questionIds))
703+
?? userInputResponseValue(content, question.id)
704+
: userInputResponseValue(content, question.id);
705+
if (value === undefined) {
706+
continue;
707+
}
708+
answers[question.id] = {
709+
answers: Array.isArray(value)
710+
? value.map(String)
711+
: [String(value)],
712+
};
713+
}
714+
return { answers };
715+
}
716+
517717
private async publishAcceptedMcpToolApproval(
518718
context: McpElicitationContext,
519719
accepted: boolean

src/ElicitationCapabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type * as acp from "@agentclientprotocol/sdk";
2+
import type {InitializeCapabilities} from "./app-server";
23

34
export function clientSupportsFormElicitation(
45
clientCapabilities?: acp.ClientCapabilities | null

0 commit comments

Comments
 (0)