@@ -8,6 +8,8 @@ import type {
88 ItemStartedNotification ,
99 McpServerElicitationRequestParams ,
1010 McpServerElicitationRequestResponse ,
11+ ToolRequestUserInputParams ,
12+ ToolRequestUserInputResponse ,
1113} from "./app-server/v2" ;
1214import { logger } from "./Logger" ;
1315import { 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
0 commit comments