@@ -1717,6 +1717,87 @@ private static void AssertMessageSource(JsonElement request, string? source)
17171717 Assert . False ( request . TryGetProperty ( "wait" , out _ ) ) ;
17181718 }
17191719
1720+ [ Theory ]
1721+ [ InlineData ( true ) ]
1722+ [ InlineData ( false ) ]
1723+ public async Task Abort_Recovery_Observes_Early_Events ( bool recoveryCompletesBeforeReply )
1724+ {
1725+ await using var server = await FakeCopilotServer . StartAsync ( ) ;
1726+ await using var client = new CopilotClient ( new CopilotClientOptions { Connection = RuntimeConnection . ForUri ( server . Url ) } ) ;
1727+ await using var session = await client . CreateSessionAsync ( new SessionConfig ( ) ) ;
1728+ var timeout = TimeSpan . FromSeconds ( 5 ) ;
1729+ var sendCount = 0 ;
1730+ server . BeforeResponseAsync = async ( request , cancellationToken ) =>
1731+ {
1732+ if ( request . Method == "session.send" )
1733+ {
1734+ sendCount ++ ;
1735+ await server . SendSessionEventAsync ( session . SessionId , "user.message" , new ( )
1736+ {
1737+ [ "content" ] = request . Params . GetProperty ( "prompt" ) . GetString ( )
1738+ } ) ;
1739+ if ( sendCount == 1 )
1740+ {
1741+ await SendAndDrainAsync ( "tool.execution_start" , new ( )
1742+ {
1743+ [ "toolCallId" ] = "slow-tool" ,
1744+ [ "toolName" ] = "shell"
1745+ } , cancellationToken ) ;
1746+ }
1747+ else
1748+ {
1749+ Assert . Equal ( 2 , sendCount ) ;
1750+ await SendAndDrainAsync ( "assistant.message" , new ( )
1751+ {
1752+ [ "messageId" ] = "recovery-message" ,
1753+ [ "content" ] = "4"
1754+ } , cancellationToken ) ;
1755+ if ( recoveryCompletesBeforeReply )
1756+ {
1757+ await SendAndDrainAsync ( "session.idle" , new ( ) , cancellationToken ) ;
1758+ }
1759+ }
1760+ }
1761+ else if ( request . Method == "session.abort" )
1762+ {
1763+ Assert . Equal ( 1 , sendCount ) ;
1764+ await server . SendSessionEventAsync ( session . SessionId , "abort" , new ( )
1765+ {
1766+ [ "reason" ] = "user"
1767+ } ) ;
1768+ await SendAndDrainAsync ( "session.idle" , new ( ) { [ "aborted" ] = true } , cancellationToken ) ;
1769+ }
1770+ } ;
1771+ server . AfterResponseAsync = async ( request , cancellationToken ) =>
1772+ {
1773+ if ( request . Method == "session.send" && sendCount == 2 && ! recoveryCompletesBeforeReply )
1774+ {
1775+ await SendAndDrainAsync ( "session.idle" , new ( ) , cancellationToken ) ;
1776+ }
1777+ } ;
1778+
1779+ // Exercise the E2E test's actual ordering and assertions, without launching a CLI.
1780+ await E2E . SessionE2ETests . AssertAbortAndRecoveryAsync ( session , timeout ) ;
1781+
1782+ Assert . Equal (
1783+ [ "session.send" , "session.abort" , "session.send" ] ,
1784+ server . Requests . Select ( request => request . Method )
1785+ . Where ( method => method is "session.send" or "session.abort" ) ) ;
1786+ var history = await session . GetEventsAsync ( ) ;
1787+ Assert . DoesNotContain ( history , evt => evt is SessionIdleEvent ) ;
1788+ Assert . Equal ( "4" , Assert . Single ( history . OfType < AssistantMessageEvent > ( ) ) . Data . Content ) ;
1789+
1790+ async Task SendAndDrainAsync ( string type , Dictionary < string , object ? > data , CancellationToken cancellationToken )
1791+ {
1792+ var drained = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
1793+ using var subscription = session . On < SessionTitleChangedEvent > ( _ => drained . TrySetResult ( ) ) ;
1794+ await server . SendSessionEventAsync ( session . SessionId , type , data ) ;
1795+ // A later event is a fence: every subscriber has finished handling the target event.
1796+ await server . SendSessionEventAsync ( session . SessionId , "session.title_changed" , new ( ) { [ "title" ] = "fence" } ) ;
1797+ await drained . Task . WaitAsync ( timeout , cancellationToken ) ;
1798+ }
1799+ }
1800+
17201801 [ Fact ]
17211802 public async Task SendAndWaitAsync_Skips_Autopilot_Continuation_Idle ( )
17221803 {
@@ -2192,6 +2273,7 @@ private sealed class FakeCopilotServer : IAsyncDisposable
21922273 private readonly TaskCompletionSource _allowDestroy = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
21932274 private readonly Task _serverTask ;
21942275 private readonly List < RpcRequestRecord > _requests = [ ] ;
2276+ private readonly ConcurrentQueue < object ? > _sessionEvents = new ( ) ;
21952277 private readonly object _requestsLock = new ( ) ;
21962278 private readonly ConcurrentDictionary < int , TaskCompletionSource < JsonElement > > _pendingRequests = new ( ) ;
21972279 private NetworkStream ? _stream ;
@@ -2228,6 +2310,10 @@ public static Task<FakeCopilotServer> StartAsync()
22282310
22292311 public int RuntimeShutdownCount { get ; private set ; }
22302312
2313+ public Func < RpcRequestRecord , CancellationToken , Task > ? BeforeResponseAsync { get ; set ; }
2314+
2315+ public Func < RpcRequestRecord , CancellationToken , Task > ? AfterResponseAsync { get ; set ; }
2316+
22312317 public IReadOnlyList < RpcRequestRecord > Requests
22322318 {
22332319 get
@@ -2300,21 +2386,27 @@ public async Task<JsonElement> SendRequestAsync(string method, Dictionary<string
23002386 public Task SendSessionEventAsync ( string sessionId , string type , Dictionary < string , object ? > data )
23012387 {
23022388 var stream = _stream ?? throw new InvalidOperationException ( "Client is not connected." ) ;
2389+ var evt = new Dictionary < string , object ? >
2390+ {
2391+ [ "id" ] = Guid . NewGuid ( ) . ToString ( ) ,
2392+ [ "timestamp" ] = DateTimeOffset . UtcNow . ToString ( "O" ) ,
2393+ [ "parentId" ] = null ,
2394+ [ "type" ] = type ,
2395+ [ "data" ] = data
2396+ } ;
2397+ // Idle is ephemeral in the runtime and cannot be backfilled from history.
2398+ if ( type != "session.idle" )
2399+ {
2400+ _sessionEvents . Enqueue ( evt ) ;
2401+ }
23032402 return WriteMessageAsync ( stream , new Dictionary < string , object ? >
23042403 {
23052404 [ "jsonrpc" ] = "2.0" ,
23062405 [ "method" ] = "session.event" ,
23072406 [ "params" ] = new Dictionary < string , object ? >
23082407 {
23092408 [ "sessionId" ] = sessionId ,
2310- [ "event" ] = new Dictionary < string , object ? >
2311- {
2312- [ "id" ] = Guid . NewGuid ( ) . ToString ( ) ,
2313- [ "timestamp" ] = DateTimeOffset . UtcNow . ToString ( "O" ) ,
2314- [ "parentId" ] = null ,
2315- [ "type" ] = type ,
2316- [ "data" ] = data
2317- }
2409+ [ "event" ] = evt
23182410 }
23192411 } , _cts . Token ) ;
23202412 }
@@ -2437,6 +2529,11 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
24372529 } , cancellationToken ) ;
24382530 return ;
24392531 }
2532+ var requestRecord = new RpcRequestRecord ( method ! , paramsElement ) ;
2533+ if ( BeforeResponseAsync is { } beforeResponse )
2534+ {
2535+ await beforeResponse ( requestRecord , cancellationToken ) ;
2536+ }
24402537 object ? result = method switch
24412538 {
24422539 "connect" => new Dictionary < string , object ? >
@@ -2455,6 +2552,11 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
24552552 {
24562553 [ "messageId" ] = "message-1"
24572554 } ,
2555+ "session.abort" => new Dictionary < string , object ? > ( ) ,
2556+ "session.getMessages" => new Dictionary < string , object ? >
2557+ {
2558+ [ "events" ] = _sessionEvents . ToArray ( )
2559+ } ,
24582560 "session.options.update" => new Dictionary < string , object ? >
24592561 {
24602562 [ "success" ] = true
@@ -2495,6 +2597,10 @@ private async Task HandleRequestAsync(Stream stream, JsonElement request, Cancel
24952597 [ "id" ] = id ,
24962598 [ "result" ] = result
24972599 } , cancellationToken ) ;
2600+ if ( AfterResponseAsync is { } afterResponse )
2601+ {
2602+ await afterResponse ( requestRecord , cancellationToken ) ;
2603+ }
24982604 }
24992605
25002606 private Dictionary < string , object ? > CreateSessionResult ( JsonElement request )
0 commit comments