@@ -314,6 +314,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
314314 private askResponseImages ?: string [ ]
315315 public lastMessageTs ?: number
316316 private autoApprovalTimeoutRef ?: NodeJS . Timeout
317+ // Number of ask() calls currently blocked waiting for a response. Used by
318+ // handleWebviewAskResponse to detect "orphaned" messageResponses: ask() clears
319+ // askResponse/askResponseText at the start of every new complete ask, so a
320+ // response written while nothing is waiting would be silently destroyed.
321+ private pendingAskCount = 0
317322
318323 // Tool Use
319324 consecutiveMistakeCount : number = 0
@@ -1383,97 +1388,108 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
13831388
13841389 const timeouts : NodeJS . Timeout [ ] = [ ]
13851390
1386- // Auto-approval (state/approval) was resolved above before the message was added.
1387- if ( approval . decision === "approve" ) {
1388- this . approveAsk ( )
1389- } else if ( approval . decision === "deny" ) {
1390- this . denyAsk ( )
1391- } else if ( approval . decision === "timeout" ) {
1392- // Store the auto-approval timeout so it can be cancelled if user interacts
1393- this . autoApprovalTimeoutRef = setTimeout ( ( ) => {
1394- const { askResponse, text, images } = approval . fn ( )
1395- this . handleWebviewAskResponse ( askResponse , text , images )
1396- this . autoApprovalTimeoutRef = undefined
1397- } , approval . timeout )
1398- timeouts . push ( this . autoApprovalTimeoutRef )
1399- }
1400-
1401- // The state is mutable if the message is complete and the task will
1402- // block (via the `pWaitFor`).
1403- const isBlocking = ! ( this . askResponse !== undefined || this . lastMessageTs !== askTs )
1404- const hasDeferredMessage = ! this . messageQueueService . isEmpty ( )
1405- const shouldPauseQueuedDrainForAsk = this . deferQueuedMessageDrainUntilResume && isResumableAsk ( type )
1406- const shouldAutoDispatchDeferredMessageForAsk =
1407- this . canAutoDispatchDeferredMessageForAsk ( type ) && ! shouldPauseQueuedDrainForAsk
1408- const hasAutoDispatchCandidate = hasDeferredMessage && shouldAutoDispatchDeferredMessageForAsk
1409- const isStatusMutable = ! partial && isBlocking && ! hasAutoDispatchCandidate && approval . decision === "ask"
1410-
1411- if ( isStatusMutable ) {
1412- const statusMutationTimeout = 2_000
1413-
1414- if ( isInteractiveAsk ( type ) ) {
1415- timeouts . push (
1416- setTimeout ( ( ) => {
1417- const message = this . findMessageByTimestamp ( askTs )
1418-
1419- if ( message ) {
1420- this . interactiveAsk = message
1421- this . emit ( RooCodeEventName . TaskInteractive , this . taskId )
1422- /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */
1423- void this . postTaskMessageToWebview ( { type : "interactionRequired" } ) . catch ( ( error ) => {
1424- console . error ( "[Task#ask] postTaskMessageToWebview interactionRequired failed:" , error )
1425- } )
1426- }
1427- } , statusMutationTimeout ) ,
1428- )
1429- } else if ( isResumableAsk ( type ) ) {
1430- timeouts . push (
1431- setTimeout ( ( ) => {
1432- const message = this . findMessageByTimestamp ( askTs )
1433-
1434- if ( message ) {
1435- this . resumableAsk = message
1436- this . emit ( RooCodeEventName . TaskResumable , this . taskId )
1437- }
1438- } , statusMutationTimeout ) ,
1439- )
1440- } else if ( isIdleAsk ( type ) ) {
1441- timeouts . push (
1442- setTimeout ( ( ) => {
1443- const message = this . findMessageByTimestamp ( askTs )
1444-
1445- if ( message ) {
1446- this . idleAsk = message
1447- this . emit ( RooCodeEventName . TaskIdle , this . taskId )
1448- }
1449- } , statusMutationTimeout ) ,
1450- )
1391+ // From here until the pWaitFor below resolves, a response can legitimately arrive
1392+ // (auto-approval, button click, auto-dispatched queue message). Mark the ask as
1393+ // pending so handleWebviewAskResponse delivers directly instead of queueing.
1394+ this . pendingAskCount ++
1395+ try {
1396+ // Auto-approval (state/approval) was resolved above before the message was added.
1397+ if ( approval . decision === "approve" ) {
1398+ this . approveAsk ( )
1399+ } else if ( approval . decision === "deny" ) {
1400+ this . denyAsk ( )
1401+ } else if ( approval . decision === "timeout" ) {
1402+ // Store the auto-approval timeout so it can be cancelled if user interacts
1403+ this . autoApprovalTimeoutRef = setTimeout ( ( ) => {
1404+ const { askResponse, text, images } = approval . fn ( )
1405+ this . handleWebviewAskResponse ( askResponse , text , images )
1406+ this . autoApprovalTimeoutRef = undefined
1407+ } , approval . timeout )
1408+ timeouts . push ( this . autoApprovalTimeoutRef )
14511409 }
1452- } else if ( hasAutoDispatchCandidate ) {
1453- this . consumeDeferredMessageForAsk ( type )
1454- }
14551410
1456- // Wait for askResponse to be set
1457- await pWaitFor (
1458- ( ) => {
1459- if ( this . abort ) {
1460- return true
1461- }
1462- if ( this . askResponse !== undefined || this . lastMessageTs !== askTs ) {
1463- return true
1411+ // The state is mutable if the message is complete and the task will
1412+ // block (via the `pWaitFor`).
1413+ const isBlocking = ! ( this . askResponse !== undefined || this . lastMessageTs !== askTs )
1414+ const hasDeferredMessage = ! this . messageQueueService . isEmpty ( )
1415+ const shouldPauseQueuedDrainForAsk = this . deferQueuedMessageDrainUntilResume && isResumableAsk ( type )
1416+ const shouldAutoDispatchDeferredMessageForAsk =
1417+ this . canAutoDispatchDeferredMessageForAsk ( type ) && ! shouldPauseQueuedDrainForAsk
1418+ const hasAutoDispatchCandidate = hasDeferredMessage && shouldAutoDispatchDeferredMessageForAsk
1419+ const isStatusMutable = ! partial && isBlocking && ! hasAutoDispatchCandidate && approval . decision === "ask"
1420+
1421+ if ( isStatusMutable ) {
1422+ const statusMutationTimeout = 2_000
1423+
1424+ if ( isInteractiveAsk ( type ) ) {
1425+ timeouts . push (
1426+ setTimeout ( ( ) => {
1427+ const message = this . findMessageByTimestamp ( askTs )
1428+
1429+ if ( message ) {
1430+ this . interactiveAsk = message
1431+ this . emit ( RooCodeEventName . TaskInteractive , this . taskId )
1432+ /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */
1433+ void this . postTaskMessageToWebview ( { type : "interactionRequired" } ) . catch ( ( error ) => {
1434+ console . error (
1435+ "[Task#ask] postTaskMessageToWebview interactionRequired failed:" ,
1436+ error ,
1437+ )
1438+ } )
1439+ }
1440+ } , statusMutationTimeout ) ,
1441+ )
1442+ } else if ( isResumableAsk ( type ) ) {
1443+ timeouts . push (
1444+ setTimeout ( ( ) => {
1445+ const message = this . findMessageByTimestamp ( askTs )
1446+
1447+ if ( message ) {
1448+ this . resumableAsk = message
1449+ this . emit ( RooCodeEventName . TaskResumable , this . taskId )
1450+ }
1451+ } , statusMutationTimeout ) ,
1452+ )
1453+ } else if ( isIdleAsk ( type ) ) {
1454+ timeouts . push (
1455+ setTimeout ( ( ) => {
1456+ const message = this . findMessageByTimestamp ( askTs )
1457+
1458+ if ( message ) {
1459+ this . idleAsk = message
1460+ this . emit ( RooCodeEventName . TaskIdle , this . taskId )
1461+ }
1462+ } , statusMutationTimeout ) ,
1463+ )
14641464 }
1465+ } else if ( hasAutoDispatchCandidate ) {
1466+ this . consumeDeferredMessageForAsk ( type )
1467+ }
14651468
1466- // If a deferred message arrives while we're blocked on a handoff ask (for example
1467- // a follow-up suggestion click that was queued while the agent still owned the turn),
1468- // consume it immediately so the task doesn't hang.
1469- if ( shouldAutoDispatchDeferredMessageForAsk && ! this . messageQueueService . isEmpty ( ) ) {
1470- this . consumeDeferredMessageForAsk ( type )
1471- }
1469+ // Wait for askResponse to be set
1470+ await pWaitFor (
1471+ ( ) => {
1472+ if ( this . abort ) {
1473+ return true
1474+ }
1475+ if ( this . askResponse !== undefined || this . lastMessageTs !== askTs ) {
1476+ return true
1477+ }
14721478
1473- return false
1474- } ,
1475- { interval : 100 } ,
1476- )
1479+ // If a deferred message arrives while we're blocked on a handoff ask (for example
1480+ // a follow-up suggestion click that was queued while the agent still owned the turn),
1481+ // consume it immediately so the task doesn't hang.
1482+ if ( shouldAutoDispatchDeferredMessageForAsk && ! this . messageQueueService . isEmpty ( ) ) {
1483+ this . consumeDeferredMessageForAsk ( type )
1484+ }
1485+
1486+ return false
1487+ } ,
1488+ { interval : 100 } ,
1489+ )
1490+ } finally {
1491+ this . pendingAskCount --
1492+ }
14771493
14781494 if ( this . abort ) {
14791495 throw new Error ( `[RooCode#ask] task ${ this . taskId } .${ this . instanceId } aborted` )
@@ -1507,6 +1523,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
15071523 }
15081524
15091525 handleWebviewAskResponse ( askResponse : ClineAskResponse , text ?: string , images ?: string [ ] ) {
1526+ // Orphaned message guard: if a text-bearing response arrives while NO ask() is
1527+ // waiting (e.g. the webview raced a phase transition and sent askResponse instead
1528+ // of queueMessage, or submitUserMessage was invoked mid-stream via the API),
1529+ // writing it to askResponse* would silently destroy it — ask() clears those
1530+ // fields at the start of every new ask. Route it into the message queue instead,
1531+ // where the normal queue/steer handoff will deliver it at the next safe boundary.
1532+ if (
1533+ askResponse === "messageResponse" &&
1534+ this . pendingAskCount === 0 &&
1535+ ! this . abort &&
1536+ ( text ?. trim ( ) || images ?. length )
1537+ ) {
1538+ this . messageQueueService . addMessage ( text ?? "" , images )
1539+ return
1540+ }
1541+
15101542 // Clear any pending auto-approval timeout when user responds
15111543 this . cancelAutoApprovalTimeout ( )
15121544
0 commit comments