Skip to content

Commit daedce3

Browse files
stephentoubCopilot
andcommitted
Fix abort recovery test event subscription race
Subscribe before sending the recovery prompt so an ephemeral session.idle cannot be lost. Exercise the shared abort scenario through the fake RPC server with events delivered before replies, retaining the original ordering and timeout budget. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 39c777f commit daedce3

2 files changed

Lines changed: 123 additions & 13 deletions

File tree

‎dotnet/test/E2E/SessionE2ETests.cs‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -349,11 +349,15 @@ await Assert.ThrowsAsync<IOException>(() =>
349349
[Fact]
350350
public async Task Should_Abort_A_Session()
351351
{
352-
var session = await CreateSessionAsync();
352+
await using var session = await CreateSessionAsync();
353+
await AssertAbortAndRecoveryAsync(session, TimeSpan.FromSeconds(120));
354+
}
353355

356+
internal static async Task AssertAbortAndRecoveryAsync(CopilotSession session, TimeSpan timeout)
357+
{
354358
// Set up wait for tool execution to start BEFORE sending
355-
var toolStartTask = TestHelper.GetNextEventOfTypeAsync<ToolExecutionStartEvent>(session);
356-
var sessionIdleTask = TestHelper.GetNextEventOfTypeAsync<SessionIdleEvent>(session);
359+
var toolStartTask = TestHelper.GetNextEventOfTypeAsync<ToolExecutionStartEvent>(session, timeout);
360+
var sessionIdleTask = TestHelper.GetNextEventOfTypeAsync<SessionIdleEvent>(session, timeout);
357361

358362
// Send a message that will take some time to process
359363
await session.SendAsync(new MessageOptions
@@ -375,8 +379,8 @@ await session.SendAsync(new MessageOptions
375379
// Verify an abort event exists in messages
376380
Assert.Contains(messages, m => m is AbortEvent);
377381

378-
await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" });
379-
var recoveryMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
382+
// Subscribe before sending: session.idle is ephemeral and cannot be backfilled.
383+
var recoveryMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }, timeout);
380384
Assert.NotNull(recoveryMessage);
381385
Assert.Contains("4", recoveryMessage.Data.Content ?? string.Empty);
382386
}

‎dotnet/test/Unit/ClientSessionLifetimeTests.cs‎

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)