@@ -537,6 +537,229 @@ describe("Discord controller flows", () => {
537537 expect ( sendComponentMessage ) . not . toHaveBeenCalled ( ) ;
538538 } ) ;
539539
540+ it ( "acknowledges and clears Discord pending-input buttons by message id" , async ( ) => {
541+ const { controller } = await createControllerHarness ( ) ;
542+ await ( controller as any ) . store . upsertPendingRequest ( {
543+ requestId : "pending-1" ,
544+ conversation : {
545+ channel : "discord" ,
546+ accountId : "default" ,
547+ conversationId : "channel:chan-1" ,
548+ } ,
549+ threadId : "thread-1" ,
550+ workspaceDir : "/repo/openclaw" ,
551+ state : {
552+ requestId : "pending-1" ,
553+ options : [ "Approve Once" , "Cancel" ] ,
554+ expiresAt : Date . now ( ) + 60_000 ,
555+ } ,
556+ updatedAt : Date . now ( ) ,
557+ } ) ;
558+ const callback = await ( controller as any ) . store . putCallback ( {
559+ kind : "pending-input" ,
560+ conversation : {
561+ channel : "discord" ,
562+ accountId : "default" ,
563+ conversationId : "channel:chan-1" ,
564+ } ,
565+ requestId : "pending-1" ,
566+ actionIndex : 0 ,
567+ } ) ;
568+ const acknowledge = vi . fn ( async ( ) => { } ) ;
569+ const clearComponents = vi . fn ( async ( ) => { } ) ;
570+ const reply = vi . fn ( async ( ) => { } ) ;
571+ const followUp = vi . fn ( async ( ) => { } ) ;
572+ const submitPendingInput = vi . fn ( async ( ) => true ) ;
573+ ( controller as any ) . activeRuns . set ( "discord::default::channel:chan-1::" , {
574+ conversation : {
575+ channel : "discord" ,
576+ accountId : "default" ,
577+ conversationId : "channel:chan-1" ,
578+ } ,
579+ workspaceDir : "/repo/openclaw" ,
580+ mode : "default" ,
581+ handle : {
582+ result : Promise . resolve ( { threadId : "thread-1" , text : "done" } ) ,
583+ queueMessage : vi . fn ( async ( ) => false ) ,
584+ submitPendingInput,
585+ submitPendingInputPayload : vi . fn ( async ( ) => false ) ,
586+ interrupt : vi . fn ( async ( ) => { } ) ,
587+ isAwaitingInput : vi . fn ( ( ) => true ) ,
588+ getThreadId : vi . fn ( ( ) => "thread-1" ) ,
589+ } ,
590+ } ) ;
591+
592+ await controller . handleDiscordInteractive ( {
593+ channel : "discord" ,
594+ accountId : "default" ,
595+ interactionId : "interaction-1" ,
596+ conversationId : "channel:chan-1" ,
597+ auth : { isAuthorizedSender : true } ,
598+ interaction : {
599+ kind : "button" ,
600+ data : `codexapp:${ callback . token } ` ,
601+ namespace : "codexapp" ,
602+ payload : callback . token ,
603+ messageId : "message-1" ,
604+ } ,
605+ senderId : "user-1" ,
606+ senderUsername : "Ada" ,
607+ respond : {
608+ acknowledge,
609+ reply,
610+ followUp,
611+ editMessage : vi . fn ( async ( ) => { } ) ,
612+ clearComponents,
613+ } ,
614+ } as any ) ;
615+
616+ expect ( submitPendingInput ) . toHaveBeenCalledWith ( 0 ) ;
617+ expect ( acknowledge ) . toHaveBeenCalledTimes ( 1 ) ;
618+ expect ( clearComponents ) . not . toHaveBeenCalled ( ) ;
619+ expect ( discordSdkState . editDiscordComponentMessage ) . toHaveBeenCalledWith (
620+ "channel:chan-1" ,
621+ "message-1" ,
622+ {
623+ text : "Sent to Codex." ,
624+ } ,
625+ expect . objectContaining ( { accountId : "default" } ) ,
626+ ) ;
627+ expect ( reply ) . not . toHaveBeenCalled ( ) ;
628+ expect ( followUp ) . not . toHaveBeenCalled ( ) ;
629+ } ) ;
630+
631+ it ( "does not send a second Discord response after completing a questionnaire" , async ( ) => {
632+ const { controller } = await createControllerHarness ( ) ;
633+ await ( controller as any ) . store . upsertPendingRequest ( {
634+ requestId : "questionnaire-1" ,
635+ conversation : {
636+ channel : "discord" ,
637+ accountId : "default" ,
638+ conversationId : "channel:chan-1" ,
639+ } ,
640+ threadId : "thread-1" ,
641+ workspaceDir : "/repo/openclaw" ,
642+ state : {
643+ requestId : "questionnaire-1" ,
644+ options : [ ] ,
645+ expiresAt : Date . now ( ) + 60_000 ,
646+ questionnaire : {
647+ currentIndex : 1 ,
648+ awaitingFreeform : false ,
649+ questions : [
650+ {
651+ index : 0 ,
652+ id : "milk" ,
653+ header : "Milk" ,
654+ prompt : "Do you like milk on cereal?" ,
655+ options : [
656+ { key : "A" , label : "Yes" , description : "Sure." } ,
657+ { key : "B" , label : "No" , description : "Nope." } ,
658+ ] ,
659+ } ,
660+ {
661+ index : 1 ,
662+ id : "type" ,
663+ header : "Type" ,
664+ prompt : "What kind of milk?" ,
665+ options : [
666+ { key : "A" , label : "Whole" , description : "Richer." } ,
667+ { key : "B" , label : "2%" , description : "Lighter." } ,
668+ ] ,
669+ } ,
670+ ] ,
671+ answers : [
672+ {
673+ kind : "option" ,
674+ optionKey : "A" ,
675+ optionLabel : "Yes" ,
676+ } ,
677+ null ,
678+ ] ,
679+ } ,
680+ } ,
681+ updatedAt : Date . now ( ) ,
682+ } ) ;
683+ const callback = await ( controller as any ) . store . putCallback ( {
684+ kind : "pending-questionnaire" ,
685+ conversation : {
686+ channel : "discord" ,
687+ accountId : "default" ,
688+ conversationId : "channel:chan-1" ,
689+ } ,
690+ requestId : "questionnaire-1" ,
691+ questionIndex : 1 ,
692+ action : "select" ,
693+ optionIndex : 0 ,
694+ } ) ;
695+ const acknowledge = vi . fn ( async ( ) => { } ) ;
696+ const clearComponents = vi . fn ( async ( ) => { } ) ;
697+ const reply = vi . fn ( async ( ) => { } ) ;
698+ const followUp = vi . fn ( async ( ) => { } ) ;
699+ const submitPendingInputPayload = vi . fn ( async ( ) => true ) ;
700+ ( controller as any ) . activeRuns . set ( "discord::default::channel:chan-1::" , {
701+ conversation : {
702+ channel : "discord" ,
703+ accountId : "default" ,
704+ conversationId : "channel:chan-1" ,
705+ } ,
706+ workspaceDir : "/repo/openclaw" ,
707+ mode : "plan" ,
708+ handle : {
709+ result : Promise . resolve ( { threadId : "thread-1" , text : "done" } ) ,
710+ queueMessage : vi . fn ( async ( ) => false ) ,
711+ submitPendingInput : vi . fn ( async ( ) => false ) ,
712+ submitPendingInputPayload,
713+ interrupt : vi . fn ( async ( ) => { } ) ,
714+ isAwaitingInput : vi . fn ( ( ) => true ) ,
715+ getThreadId : vi . fn ( ( ) => "thread-1" ) ,
716+ } ,
717+ } ) ;
718+
719+ await controller . handleDiscordInteractive ( {
720+ channel : "discord" ,
721+ accountId : "default" ,
722+ interactionId : "interaction-1" ,
723+ conversationId : "channel:chan-1" ,
724+ auth : { isAuthorizedSender : true } ,
725+ interaction : {
726+ kind : "button" ,
727+ data : `codexapp:${ callback . token } ` ,
728+ namespace : "codexapp" ,
729+ payload : callback . token ,
730+ messageId : "message-1" ,
731+ } ,
732+ senderId : "user-1" ,
733+ senderUsername : "Ada" ,
734+ respond : {
735+ acknowledge,
736+ reply,
737+ followUp,
738+ editMessage : vi . fn ( async ( ) => { } ) ,
739+ clearComponents,
740+ } ,
741+ } as any ) ;
742+
743+ expect ( submitPendingInputPayload ) . toHaveBeenCalledWith ( {
744+ answers : {
745+ milk : { answers : [ "Yes" ] } ,
746+ type : { answers : [ "Whole" ] } ,
747+ } ,
748+ } ) ;
749+ expect ( acknowledge ) . toHaveBeenCalledTimes ( 1 ) ;
750+ expect ( clearComponents ) . not . toHaveBeenCalled ( ) ;
751+ expect ( discordSdkState . editDiscordComponentMessage ) . toHaveBeenCalledWith (
752+ "channel:chan-1" ,
753+ "message-1" ,
754+ {
755+ text : "Recorded your answers and sent them to Codex." ,
756+ } ,
757+ expect . objectContaining ( { accountId : "default" } ) ,
758+ ) ;
759+ expect ( reply ) . not . toHaveBeenCalled ( ) ;
760+ expect ( followUp ) . not . toHaveBeenCalled ( ) ;
761+ } ) ;
762+
540763 it ( "normalizes raw Discord callback conversation ids for guild interactions" , async ( ) => {
541764 const { controller, sendComponentMessage } = await createControllerHarness ( ) ;
542765 const callback = await ( controller as any ) . store . putCallback ( {
@@ -1742,6 +1965,7 @@ describe("Discord controller flows", () => {
17421965 } ) ) ;
17431966 ( controller as any ) . client . startTurn = startTurn ;
17441967 const reply = vi . fn ( async ( ) => { } ) ;
1968+ const followUp = vi . fn ( async ( ) => { } ) ;
17451969
17461970 await controller . handleDiscordInteractive ( {
17471971 channel : "discord" ,
@@ -1761,13 +1985,14 @@ describe("Discord controller flows", () => {
17611985 respond : {
17621986 acknowledge : vi . fn ( async ( ) => { } ) ,
17631987 reply,
1764- followUp : vi . fn ( async ( ) => { } ) ,
1988+ followUp,
17651989 editMessage : vi . fn ( async ( ) => { } ) ,
17661990 clearComponents : vi . fn ( async ( ) => { } ) ,
17671991 } ,
17681992 } as any ) ;
17691993
1770- expect ( reply ) . toHaveBeenCalledWith ( { text : "Sent the plan to Codex." , ephemeral : true } ) ;
1994+ expect ( reply ) . not . toHaveBeenCalled ( ) ;
1995+ expect ( followUp ) . toHaveBeenCalledWith ( { text : "Sent the plan to Codex." , ephemeral : true } ) ;
17711996 expect ( startTurn ) . toHaveBeenCalledWith (
17721997 expect . objectContaining ( {
17731998 prompt : "Implement the plan." ,
0 commit comments