@@ -2815,6 +2815,29 @@ public async Task CancelQueuedMessageAsync_RemovesFailedQueuedCard()
28152815 Assert . False ( snapshots [ ^ 1 ] . QueuedMessagesByThread ? . ContainsKey ( "main" ) == true ) ;
28162816 }
28172817
2818+ [ Fact ]
2819+ public async Task CancelQueuedMessageAsync_RemovingLastQueuedMessageClearsStaleDrainGuard ( )
2820+ {
2821+ var ( bridge , provider , snapshots , _) = CreateProvider ( new [ ] { MainSession ( ) } ) ;
2822+ bridge . SendResults . Enqueue ( new ChatSendResult { RunId = "run-active" , Status = "started" } ) ;
2823+ await provider . LoadAsync ( ) ;
2824+ bridge . RaiseStatus ( ConnectionStatus . Connected ) ;
2825+
2826+ await provider . SendMessageAsync ( "main" , "active" ) ;
2827+ bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-active" ) ) ;
2828+ await provider . SendMessageAsync ( "main" , "queued" ) ;
2829+
2830+ var queued = Assert . Single ( GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ) ;
2831+ var scheduled = GetQueuedDrainScheduledThreads ( provider ) ;
2832+ scheduled . Add ( "main" ) ;
2833+
2834+ var canceled = await provider . CancelQueuedMessageAsync ( "main" , queued . Id ) ;
2835+
2836+ Assert . True ( canceled ) ;
2837+ Assert . DoesNotContain ( "main" , scheduled ) ;
2838+ Assert . Empty ( GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ) ;
2839+ }
2840+
28182841 [ Fact ]
28192842 public async Task CancelQueuedMessageAsync_ReturnsFalseForSendingQueuedCard ( )
28202843 {
@@ -2884,6 +2907,48 @@ public async Task CancelQueuedMessageAsync_DoesNotTurnActiveLocalRunIntoRemoteRu
28842907 Assert . Equal ( 0 , historyCalls ) ;
28852908 }
28862909
2910+ [ Fact ]
2911+ public async Task CancelQueuedMessageAsync_LastQueuedAfterLifecycleEndAllowsNextRemoteRunBackfill ( )
2912+ {
2913+ var historyCalls = 0 ;
2914+ var ( bridge , provider , snapshots , _) = CreateProvider ( new [ ] { MainSession ( ) } ) ;
2915+ bridge . SendResults . Enqueue ( new ChatSendResult { RunId = "run-active" , Status = "started" } ) ;
2916+ bridge . HistoryBehavior = _ =>
2917+ {
2918+ historyCalls ++ ;
2919+ return Task . FromResult ( new ChatHistoryInfo
2920+ {
2921+ SessionKey = "main" ,
2922+ Messages = new [ ]
2923+ {
2924+ new ChatMessageInfo { SessionKey = "main" , Role = "user" , Text = "remote prompt" } ,
2925+ } ,
2926+ } ) ;
2927+ } ;
2928+ await provider . LoadAsync ( ) ;
2929+ bridge . RaiseStatus ( ConnectionStatus . Connected ) ;
2930+
2931+ await provider . SendMessageAsync ( "main" , "active" ) ;
2932+ bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-active" ) ) ;
2933+ await provider . SendMessageAsync ( "main" , "queued" ) ;
2934+ var queued = Assert . Single ( GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ) ;
2935+
2936+ bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"end"}""" , runId : "run-active" ) ) ;
2937+ var canceled = await provider . CancelQueuedMessageAsync ( "main" , queued . Id ) ;
2938+ bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-remote" ) ) ;
2939+
2940+ Assert . True ( canceled ) ;
2941+ await WaitForConditionAsync ( ( ) =>
2942+ historyCalls > 0 &&
2943+ snapshots [ ^ 1 ] . Timelines [ "main" ] . Entries . Any ( entry =>
2944+ entry . Kind == ChatTimelineItemKind . User && entry . Text == "remote prompt" ) ) ;
2945+ Assert . True ( historyCalls > 0 ) ;
2946+ Assert . Contains ( snapshots [ ^ 1 ] . Timelines [ "main" ] . Entries , entry =>
2947+ entry . Kind == ChatTimelineItemKind . User && entry . Text == "remote prompt" ) ;
2948+ Assert . DoesNotContain ( snapshots [ ^ 1 ] . Timelines [ "main" ] . Entries , entry =>
2949+ entry . Kind == ChatTimelineItemKind . User && entry . Text == "queued" ) ;
2950+ }
2951+
28872952 [ Fact ]
28882953 public async Task QueuedSend_LifecycleStartBeforeAck_PromotesByIdempotencyKey ( )
28892954 {
@@ -2988,6 +3053,48 @@ public async Task QueuedSend_InFlightAckWithoutLifecycle_RequeuesAndRetriesSameI
29883053 e . Kind == ChatTimelineItemKind . User && e . Text == "Hello" ) ;
29893054 }
29903055
3056+ [ Fact ]
3057+ public async Task CancelQueuedMessageAsync_DeferredInFlightRetryRemovesQueuedCardWithoutAbortOrResend ( )
3058+ {
3059+ var ( bridge , provider , snapshots , _) = CreateProvider ( new [ ] { MainSession ( ) } ) ;
3060+ bridge . SendResults . Enqueue ( new ChatSendResult { RunId = "run-1" , Status = "started" } ) ;
3061+ bridge . SendResults . Enqueue ( new ChatSendResult { RunId = "run-2" , Status = "in_flight" } ) ;
3062+ bridge . SendResults . Enqueue ( new ChatSendResult { RunId = "run-2" , Status = "started" } ) ;
3063+ await provider . LoadAsync ( ) ;
3064+ bridge . RaiseStatus ( ConnectionStatus . Connected ) ;
3065+
3066+ await provider . SendMessageAsync ( "main" , "first" ) ;
3067+ bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-1" ) ) ;
3068+ await provider . SendMessageAsync ( "main" , "deferred" ) ;
3069+
3070+ bridge . RaiseChat ( new ChatMessageInfo
3071+ {
3072+ SessionKey = "main" ,
3073+ Role = "assistant" ,
3074+ Text = "first response" ,
3075+ State = "final" ,
3076+ } ) ;
3077+ await WaitForConditionAsync ( ( ) =>
3078+ {
3079+ var queued = GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ;
3080+ return bridge . SentMessages . Count == 2 &&
3081+ queued . Count == 1 &&
3082+ queued [ 0 ] . Text == "deferred" &&
3083+ queued [ 0 ] . SendState == ChatQueuedMessageSendState . Queued ;
3084+ } ) ;
3085+
3086+ var deferred = Assert . Single ( GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ) ;
3087+ var canceled = await provider . CancelQueuedMessageAsync ( "main" , deferred . Id ) ;
3088+ await Task . Delay ( 250 ) ;
3089+
3090+ Assert . True ( canceled ) ;
3091+ Assert . Empty ( GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ) ;
3092+ Assert . Equal ( new [ ] { "first" , "deferred" } , bridge . SentMessages ) ;
3093+ Assert . Empty ( bridge . AbortedRunIds ) ;
3094+ Assert . DoesNotContain ( snapshots [ ^ 1 ] . Timelines [ "main" ] . Entries , entry =>
3095+ entry . Kind == ChatTimelineItemKind . User && entry . Text == "deferred" ) ;
3096+ }
3097+
29913098 [ Fact ]
29923099 public async Task QueuedSend_InFlightAckThenLifecycleBeforeRetry_PromotesWithoutResend ( )
29933100 {
@@ -2996,6 +3103,23 @@ public async Task QueuedSend_InFlightAckThenLifecycleBeforeRetry_PromotesWithout
29963103 bridge . SendResults . Enqueue ( new ChatSendResult { RunId = "run-2" , Status = "in_flight" } ) ;
29973104 await provider . LoadAsync ( ) ;
29983105 bridge . RaiseStatus ( ConnectionStatus . Connected ) ;
3106+ var lifecycleRaisedBeforeRetry = false ;
3107+ provider . Changed += ( _ , e ) =>
3108+ {
3109+ if ( lifecycleRaisedBeforeRetry || bridge . SentMessages . Count != 2 )
3110+ return ;
3111+
3112+ var queued = GetQueuedMessages ( e . Snapshot , "main" ) ;
3113+ if ( queued . Count != 1 ||
3114+ queued [ 0 ] . Text != "Hello" ||
3115+ queued [ 0 ] . SendState != ChatQueuedMessageSendState . Queued )
3116+ {
3117+ return ;
3118+ }
3119+
3120+ lifecycleRaisedBeforeRetry = true ;
3121+ bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-2" ) ) ;
3122+ } ;
29993123
30003124 await provider . SendMessageAsync ( "main" , "first" ) ;
30013125 bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-1" ) ) ;
@@ -3017,7 +3141,6 @@ await WaitForConditionAsync(() =>
30173141 queued [ 0 ] . SendState == ChatQueuedMessageSendState . Queued ;
30183142 } ) ;
30193143
3020- bridge . RaiseAgent ( MakeAgentEvent ( "lifecycle" , """{"phase":"start"}""" , runId : "run-2" ) ) ;
30213144 await WaitForConditionAsync ( ( ) =>
30223145 GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) . Count == 0 &&
30233146 snapshots [ ^ 1 ] . Timelines [ "main" ] . Entries . Count ( e =>
@@ -3031,6 +3154,7 @@ await WaitForConditionAsync(() =>
30313154 } ) ;
30323155 await Task . Delay ( 250 ) ;
30333156
3157+ Assert . True ( lifecycleRaisedBeforeRetry ) ;
30343158 Assert . Equal ( 2 , bridge . SentMessages . Count ) ;
30353159 Assert . Empty ( GetQueuedMessages ( snapshots [ ^ 1 ] , "main" ) ) ;
30363160 Assert . Single ( snapshots [ ^ 1 ] . Timelines [ "main" ] . Entries , e =>
@@ -6935,6 +7059,15 @@ private static IReadOnlyList<ChatQueuedMessage> GetQueuedMessages(ChatDataSnapsh
69357059 ? queued
69367060 : Array . Empty < ChatQueuedMessage > ( ) ;
69377061
7062+ private static ISet < string > GetQueuedDrainScheduledThreads ( OpenClawChatDataProvider provider )
7063+ {
7064+ var field = typeof ( OpenClawChatDataProvider ) . GetField (
7065+ "_queuedDrainScheduledThreads" ,
7066+ System . Reflection . BindingFlags . Instance | System . Reflection . BindingFlags . NonPublic ) ;
7067+ Assert . NotNull ( field ) ;
7068+ return Assert . IsAssignableFrom < ISet < string > > ( field . GetValue ( provider ) ) ;
7069+ }
7070+
69387071 private static bool HasFailedQueuedMessage ( ChatDataSnapshot snapshot , string threadId , string text ) =>
69397072 GetQueuedMessages ( snapshot , threadId ) . Any ( message =>
69407073 message . Text == text &&
0 commit comments