@@ -143,6 +143,18 @@ async function resolveRequestedBinding(request: PluginBindingRequest) {
143143 throw new Error ( "expected pending or bound bind result" ) ;
144144}
145145
146+ async function flushMicrotasks ( ) : Promise < void > {
147+ await new Promise < void > ( ( resolve ) => setImmediate ( resolve ) ) ;
148+ }
149+
150+ function createDeferredVoid ( ) : { promise : Promise < void > ; resolve : ( ) => void } {
151+ let resolve = ( ) => { } ;
152+ const promise = new Promise < void > ( ( innerResolve ) => {
153+ resolve = innerResolve ;
154+ } ) ;
155+ return { promise, resolve } ;
156+ }
157+
146158describe ( "plugin conversation binding approvals" , ( ) => {
147159 beforeEach ( ( ) => {
148160 sessionBindingState . reset ( ) ;
@@ -406,6 +418,7 @@ describe("plugin conversation binding approvals", () => {
406418 } ) ;
407419
408420 expect ( approved . status ) . toBe ( "approved" ) ;
421+ await flushMicrotasks ( ) ;
409422 expect ( onResolved ) . toHaveBeenCalledWith ( {
410423 status : "approved" ,
411424 binding : expect . objectContaining ( {
@@ -464,6 +477,7 @@ describe("plugin conversation binding approvals", () => {
464477 } ) ;
465478
466479 expect ( denied . status ) . toBe ( "denied" ) ;
480+ await flushMicrotasks ( ) ;
467481 expect ( onResolved ) . toHaveBeenCalledWith ( {
468482 status : "denied" ,
469483 binding : undefined ,
@@ -481,6 +495,108 @@ describe("plugin conversation binding approvals", () => {
481495 } ) ;
482496 } ) ;
483497
498+ it ( "does not wait for an approved bind callback before returning" , async ( ) => {
499+ const registry = createEmptyPluginRegistry ( ) ;
500+ const callbackGate = createDeferredVoid ( ) ;
501+ const onResolved = vi . fn ( async ( ) => callbackGate . promise ) ;
502+ registry . conversationBindingResolvedHandlers . push ( {
503+ pluginId : "codex" ,
504+ pluginRoot : "/plugins/callback-slow-approve" ,
505+ handler : onResolved ,
506+ source : "/plugins/callback-slow-approve/index.ts" ,
507+ rootDir : "/plugins/callback-slow-approve" ,
508+ } ) ;
509+ setActivePluginRegistry ( registry ) ;
510+
511+ const request = await requestPluginConversationBinding ( {
512+ pluginId : "codex" ,
513+ pluginName : "Codex App Server" ,
514+ pluginRoot : "/plugins/callback-slow-approve" ,
515+ requestedBySenderId : "user-1" ,
516+ conversation : {
517+ channel : "discord" ,
518+ accountId : "isolated" ,
519+ conversationId : "channel:slow-approve" ,
520+ } ,
521+ binding : { summary : "Bind this conversation to Codex thread slow-approve." } ,
522+ } ) ;
523+
524+ expect ( request . status ) . toBe ( "pending" ) ;
525+ if ( request . status !== "pending" ) {
526+ throw new Error ( "expected pending bind request" ) ;
527+ }
528+
529+ let settled = false ;
530+ const resolutionPromise = resolvePluginConversationBindingApproval ( {
531+ approvalId : request . approvalId ,
532+ decision : "allow-once" ,
533+ senderId : "user-1" ,
534+ } ) . then ( ( result ) => {
535+ settled = true ;
536+ return result ;
537+ } ) ;
538+
539+ await flushMicrotasks ( ) ;
540+
541+ expect ( settled ) . toBe ( true ) ;
542+ expect ( onResolved ) . toHaveBeenCalledTimes ( 1 ) ;
543+
544+ callbackGate . resolve ( ) ;
545+ const approved = await resolutionPromise ;
546+ expect ( approved . status ) . toBe ( "approved" ) ;
547+ } ) ;
548+
549+ it ( "does not wait for a denied bind callback before returning" , async ( ) => {
550+ const registry = createEmptyPluginRegistry ( ) ;
551+ const callbackGate = createDeferredVoid ( ) ;
552+ const onResolved = vi . fn ( async ( ) => callbackGate . promise ) ;
553+ registry . conversationBindingResolvedHandlers . push ( {
554+ pluginId : "codex" ,
555+ pluginRoot : "/plugins/callback-slow-deny" ,
556+ handler : onResolved ,
557+ source : "/plugins/callback-slow-deny/index.ts" ,
558+ rootDir : "/plugins/callback-slow-deny" ,
559+ } ) ;
560+ setActivePluginRegistry ( registry ) ;
561+
562+ const request = await requestPluginConversationBinding ( {
563+ pluginId : "codex" ,
564+ pluginName : "Codex App Server" ,
565+ pluginRoot : "/plugins/callback-slow-deny" ,
566+ requestedBySenderId : "user-1" ,
567+ conversation : {
568+ channel : "telegram" ,
569+ accountId : "default" ,
570+ conversationId : "slow-deny" ,
571+ } ,
572+ binding : { summary : "Bind this conversation to Codex thread slow-deny." } ,
573+ } ) ;
574+
575+ expect ( request . status ) . toBe ( "pending" ) ;
576+ if ( request . status !== "pending" ) {
577+ throw new Error ( "expected pending bind request" ) ;
578+ }
579+
580+ let settled = false ;
581+ const resolutionPromise = resolvePluginConversationBindingApproval ( {
582+ approvalId : request . approvalId ,
583+ decision : "deny" ,
584+ senderId : "user-1" ,
585+ } ) . then ( ( result ) => {
586+ settled = true ;
587+ return result ;
588+ } ) ;
589+
590+ await flushMicrotasks ( ) ;
591+
592+ expect ( settled ) . toBe ( true ) ;
593+ expect ( onResolved ) . toHaveBeenCalledTimes ( 1 ) ;
594+
595+ callbackGate . resolve ( ) ;
596+ const denied = await resolutionPromise ;
597+ expect ( denied . status ) . toBe ( "denied" ) ;
598+ } ) ;
599+
484600 it ( "returns and detaches only bindings owned by the requesting plugin root" , async ( ) => {
485601 const request = await requestPluginConversationBinding ( {
486602 pluginId : "codex" ,
0 commit comments