@@ -236,6 +236,25 @@ export class MessageBridge {
236236 cardMessageId : string ;
237237 turnId : string ;
238238 } > ( ) ;
239+ /**
240+ * AskUserQuestion calls that fired between turns (no activeTurn in the
241+ * executor at the time of the PreToolUse hook). The bridge displays them
242+ * as standalone question cards and routes the user's next typed reply
243+ * back to {@link PersistentClaudeExecutor.resolveQuestion}.
244+ *
245+ * Without this, the question text would only appear inside the coalesced
246+ * "Agent activity" body and the user's reply would be treated as a fresh
247+ * user turn — which then blocks for 6 minutes on the still-hanging hook.
248+ *
249+ * Single in-flight slot per chatId. If a second between-turn question
250+ * fires while one is still pending, the later one wins — the older
251+ * resolver hangs until its 6-minute timeout (rare in practice).
252+ */
253+ private pendingBetweenTurnQuestions = new Map < string , {
254+ toolUseId : string ;
255+ questions : PendingQuestion [ 'questions' ] ;
256+ cardMessageId : string ;
257+ } > ( ) ;
239258 /** Callback for activity lifecycle events (task started/completed/failed). */
240259 onActivityEvent ?: ( event : ActivityEventData ) => void ;
241260
@@ -473,6 +492,19 @@ export class MessageBridge {
473492 if ( cont ) {
474493 cont . abortController . abort ( ) ;
475494 }
495+ // Between-turn question whose resolver is now dead — flush the
496+ // question card to an error state and drop the bookkeeping so the
497+ // user's next message isn't intercepted as the answer.
498+ const q = this . pendingBetweenTurnQuestions . get ( chatId ) ;
499+ if ( q ) {
500+ this . pendingBetweenTurnQuestions . delete ( chatId ) ;
501+ void this . finalizeBetweenTurnQuestionCard ( q . cardMessageId , {
502+ status : 'error' ,
503+ userPrompt : 'Question' ,
504+ responseText : '_Question canceled — agent session ended._' ,
505+ toolCalls : [ ] ,
506+ } ) ;
507+ }
476508 } ) ;
477509 this . logger . info (
478510 {
@@ -512,9 +544,190 @@ export class MessageBridge {
512544 // (card + stream loop + finalize). Errors are logged inside.
513545 void this . handleContinuationTurn ( chatId , handle as ExecutionHandle ) ;
514546 } ) ;
547+ exec . on ( 'between-turn-question' , ( payload : {
548+ toolUseId : string ;
549+ questions : PendingQuestion [ 'questions' ] ;
550+ } ) => {
551+ void this . handleBetweenTurnQuestion ( chatId , payload ) ;
552+ } ) ;
515553 this . logger . debug ( { chatId } , 'MessageBridge: attached executor subscriptions' ) ;
516554 }
517555
556+ /**
557+ * Surface a between-turn AskUserQuestion as its own card on the chat.
558+ * Called from the `between-turn-question` executor event. The user's
559+ * next typed reply for this chatId is intercepted in
560+ * {@link handleMessage} and routed back via the executor's
561+ * {@link PersistentClaudeExecutor.resolveQuestion}.
562+ *
563+ * Single in-flight slot per chatId — if one is already pending, the
564+ * older one is abandoned (its resolver will hang to the SDK's 6-min
565+ * timeout, then return empty answers). The older card is marked
566+ * "superseded" so the user sees what happened.
567+ */
568+ private async handleBetweenTurnQuestion (
569+ chatId : string ,
570+ payload : { toolUseId : string ; questions : PendingQuestion [ 'questions' ] } ,
571+ ) : Promise < void > {
572+ if ( ! payload . questions || payload . questions . length === 0 ) {
573+ this . logger . warn ( { chatId, toolUseId : payload . toolUseId } , 'between-turn question with no parsed questions; skipping card' ) ;
574+ return ;
575+ }
576+ const existing = this . pendingBetweenTurnQuestions . get ( chatId ) ;
577+ if ( existing ) {
578+ this . logger . warn (
579+ { chatId, prevToolUseId : existing . toolUseId , newToolUseId : payload . toolUseId } ,
580+ 'MessageBridge: between-turn question superseded by newer one' ,
581+ ) ;
582+ void this . finalizeBetweenTurnQuestionCard ( existing . cardMessageId , {
583+ status : 'error' ,
584+ userPrompt : 'Question' ,
585+ responseText : '_Superseded by a newer question._' ,
586+ toolCalls : [ ] ,
587+ } ) ;
588+ this . pendingBetweenTurnQuestions . delete ( chatId ) ;
589+ }
590+
591+ // Show only the first question on the card (matches runOneTurn — the
592+ // bridge surfaces one sub-question at a time, advancing the card on
593+ // each typed reply). Multi-question case is logged below; the existing
594+ // bridge code path doesn't support advancing between-turn sub-questions
595+ // yet, so we route only the first answer and short-circuit the rest.
596+ if ( payload . questions . length > 1 ) {
597+ this . logger . warn (
598+ { chatId, toolUseId : payload . toolUseId , total : payload . questions . length } ,
599+ 'between-turn AskUserQuestion has multiple sub-questions; only the first will be displayed and routed' ,
600+ ) ;
601+ }
602+ const displayQuestion : PendingQuestion = {
603+ toolUseId : payload . toolUseId ,
604+ questions : [ payload . questions [ 0 ] ] ,
605+ } ;
606+
607+ const card : CardState = {
608+ status : 'waiting_for_input' ,
609+ userPrompt : '(between-turn question)' ,
610+ responseText : '' ,
611+ toolCalls : [ ] ,
612+ pendingQuestion : displayQuestion ,
613+ } ;
614+
615+ const send = this . sender . sendQuestionCard
616+ ? this . sender . sendQuestionCard . bind ( this . sender )
617+ : this . sender . sendCard . bind ( this . sender ) ;
618+ let cardMessageId : string | undefined ;
619+ try {
620+ cardMessageId = await send ( chatId , card ) ;
621+ } catch ( err ) {
622+ this . logger . error ( { err, chatId, toolUseId : payload . toolUseId } , 'MessageBridge: failed to send between-turn question card' ) ;
623+ return ;
624+ }
625+ if ( ! cardMessageId ) {
626+ this . logger . warn ( { chatId, toolUseId : payload . toolUseId } , 'MessageBridge: between-turn question card returned no messageId' ) ;
627+ return ;
628+ }
629+
630+ this . pendingBetweenTurnQuestions . set ( chatId , {
631+ toolUseId : payload . toolUseId ,
632+ questions : payload . questions ,
633+ cardMessageId,
634+ } ) ;
635+ this . logger . info (
636+ { chatId, toolUseId : payload . toolUseId , cardMessageId } ,
637+ 'MessageBridge: between-turn question card opened' ,
638+ ) ;
639+ }
640+
641+ /**
642+ * Update the dedicated question card after the user answers (or after the
643+ * executor is torn down). Uses updateQuestionCard if the sender supports
644+ * it, else falls back to updateCard.
645+ */
646+ private async finalizeBetweenTurnQuestionCard (
647+ cardMessageId : string ,
648+ state : CardState ,
649+ ) : Promise < void > {
650+ try {
651+ const update = this . sender . updateQuestionCard
652+ ? this . sender . updateQuestionCard . bind ( this . sender )
653+ : this . sender . updateCard . bind ( this . sender ) ;
654+ await update ( cardMessageId , state ) ;
655+ } catch ( err ) {
656+ this . logger . warn ( { err, cardMessageId } , 'MessageBridge: failed to update between-turn question card' ) ;
657+ }
658+ }
659+
660+ /**
661+ * Treat the user's typed reply as the answer to a pending between-turn
662+ * question. Routes through {@link PersistentClaudeExecutor.resolveQuestion}
663+ * so the AskUserQuestion PreToolUse hook unblocks and the SDK proceeds.
664+ * Returns true if the message was consumed as an answer (caller should
665+ * NOT continue to executeQuery).
666+ */
667+ private async tryHandleBetweenTurnQuestionReply ( msg : IncomingMessage ) : Promise < boolean > {
668+ const { chatId, text, imageKey } = msg ;
669+ const pending = this . pendingBetweenTurnQuestions . get ( chatId ) ;
670+ if ( ! pending ) return false ;
671+
672+ // Image-only reply isn't a valid answer; nudge the user.
673+ if ( imageKey && ! text . trim ( ) ) {
674+ await this . sender . sendText ( chatId , '请用文字回复问题卡片中的选项编号或自定义答案。' ) ;
675+ return true ;
676+ }
677+
678+ const trimmed = text . trim ( ) ;
679+ const firstQ = pending . questions [ 0 ] ;
680+ let answerText : string ;
681+ const num = parseInt ( trimmed , 10 ) ;
682+ if ( Number . isFinite ( num ) && num >= 1 && num <= firstQ . options . length ) {
683+ answerText = firstQ . options [ num - 1 ] . label ;
684+ } else {
685+ answerText = trimmed ;
686+ }
687+
688+ const answers : Record < string , string > = { [ firstQ . header ] : answerText } ;
689+ // For multi-sub-question between-turn calls (rare; logged on arrival),
690+ // synthesize empty answers for the rest so the hook still resolves.
691+ for ( let i = 1 ; i < pending . questions . length ; i ++ ) {
692+ answers [ pending . questions [ i ] . header ] = '' ;
693+ }
694+
695+ const executor = this . persistentRegistry ?. peek ( chatId ) ;
696+ if ( ! executor ) {
697+ this . logger . warn (
698+ { chatId, toolUseId : pending . toolUseId } ,
699+ 'MessageBridge: between-turn answer arrived but executor is gone; dropping' ,
700+ ) ;
701+ this . pendingBetweenTurnQuestions . delete ( chatId ) ;
702+ await this . finalizeBetweenTurnQuestionCard ( pending . cardMessageId , {
703+ status : 'error' ,
704+ userPrompt : 'Question' ,
705+ responseText : '_Question canceled — agent session ended._' ,
706+ toolCalls : [ ] ,
707+ } ) ;
708+ return true ;
709+ }
710+
711+ this . pendingBetweenTurnQuestions . delete ( chatId ) ;
712+ try {
713+ executor . resolveQuestion ( pending . toolUseId , answers ) ;
714+ } catch ( err ) {
715+ this . logger . error ( { err, chatId, toolUseId : pending . toolUseId } , 'MessageBridge: resolveQuestion threw' ) ;
716+ }
717+ this . logger . info (
718+ { chatId, toolUseId : pending . toolUseId , answer : answerText } ,
719+ 'MessageBridge: resolved between-turn question' ,
720+ ) ;
721+
722+ await this . finalizeBetweenTurnQuestionCard ( pending . cardMessageId , {
723+ status : 'complete' ,
724+ userPrompt : 'Question' ,
725+ responseText : `> **Reply:** ${ answerText } ` ,
726+ toolCalls : [ ] ,
727+ } ) ;
728+ return true ;
729+ }
730+
518731 /**
519732 * Buffer a spontaneous message and (re)arm the coalesce timer. We extract
520733 * just the human-readable bits — assistant text and tool_use intent —
@@ -1011,6 +1224,18 @@ export class MessageBridge {
10111224 return ;
10121225 }
10131226
1227+ // Between-turn AskUserQuestion reply — must run BEFORE the
1228+ // running-task / queue branches below, because no `runningTasks` entry
1229+ // exists for between-turn questions (they fire from the persistent
1230+ // executor outside of any user-initiated turn). If we let the message
1231+ // fall through, it would spawn a fresh turn that immediately blocks on
1232+ // the still-hanging hook for 6 minutes. See:
1233+ // [[bug_feishu_v2_mobile_action_buttons]] history and
1234+ // PersistentClaudeExecutor.askUserQuestionHook.
1235+ if ( await this . tryHandleBetweenTurnQuestionReply ( msg ) ) {
1236+ return ;
1237+ }
1238+
10141239 // Check if there's a pending question waiting for an answer
10151240 const task = this . runningTasks . get ( chatId ) ;
10161241 if ( task && task . pendingQuestion ) {
0 commit comments