-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathMultiClientTests.cs
More file actions
346 lines (282 loc) · 13.4 KB
/
MultiClientTests.cs
File metadata and controls
346 lines (282 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Reflection;
using System.Text.RegularExpressions;
using GitHub.Copilot.SDK.Test.Harness;
using Microsoft.Extensions.AI;
using Xunit;
using Xunit.Abstractions;
namespace GitHub.Copilot.SDK.Test;
/// <summary>
/// Custom fixture for multi-client tests that uses TCP mode so a second client can connect.
/// </summary>
public class MultiClientTestFixture : IAsyncLifetime
{
public E2ETestContext Ctx { get; private set; } = null!;
public CopilotClient Client1 { get; private set; } = null!;
public async Task InitializeAsync()
{
Ctx = await E2ETestContext.CreateAsync();
Client1 = Ctx.CreateClient(useStdio: false);
}
public async Task DisposeAsync()
{
if (Client1 is not null)
{
await Client1.ForceStopAsync();
}
await Ctx.DisposeAsync();
}
}
public class MultiClientTests : IClassFixture<MultiClientTestFixture>, IAsyncLifetime
{
private readonly MultiClientTestFixture _fixture;
private readonly string _testName;
private CopilotClient? _client2;
private E2ETestContext Ctx => _fixture.Ctx;
private CopilotClient Client1 => _fixture.Client1;
public MultiClientTests(MultiClientTestFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_testName = GetTestName(output);
}
private static string GetTestName(ITestOutputHelper output)
{
var type = output.GetType();
var testField = type.GetField("test", BindingFlags.Instance | BindingFlags.NonPublic);
var test = (ITest?)testField?.GetValue(output);
return test?.TestCase.TestMethod.Method.Name ?? throw new InvalidOperationException("Couldn't find test name");
}
public async Task InitializeAsync()
{
await Ctx.ConfigureForTestAsync("multi_client", _testName);
// Trigger connection so we can read the port
var initSession = await Client1.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
});
await initSession.DisposeAsync();
var port = Client1.ActualPort
?? throw new InvalidOperationException("Client1 is not using TCP mode; ActualPort is null");
_client2 = new CopilotClient(new CopilotClientOptions
{
CliUrl = $"localhost:{port}",
});
}
public async Task DisposeAsync()
{
if (_client2 is not null)
{
await _client2.ForceStopAsync();
_client2 = null;
}
}
private CopilotClient Client2 => _client2 ?? throw new InvalidOperationException("Client2 not initialized");
[Fact]
public async Task Both_Clients_See_Tool_Request_And_Completion_Events()
{
var tool = AIFunctionFactory.Create(MagicNumber, "magic_number");
var session1 = await Client1.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
Tools = [tool],
});
var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
});
// Set up event waiters BEFORE sending the prompt to avoid race conditions
var client1Requested = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var client2Requested = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var client1Completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var client2Completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var sub1 = session1.On(evt =>
{
if (evt is ExternalToolRequestedEvent) client1Requested.TrySetResult(true);
if (evt is ExternalToolCompletedEvent) client1Completed.TrySetResult(true);
});
using var sub2 = session2.On(evt =>
{
if (evt is ExternalToolRequestedEvent) client2Requested.TrySetResult(true);
if (evt is ExternalToolCompletedEvent) client2Completed.TrySetResult(true);
});
var response = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Use the magic_number tool with seed 'hello' and tell me the result",
});
Assert.NotNull(response);
Assert.Contains("MAGIC_hello_42", response!.Data.Content ?? string.Empty);
// Wait for all broadcast events to arrive on both clients
await Task.WhenAll(
client1Requested.Task, client2Requested.Task,
client1Completed.Task, client2Completed.Task).WaitAsync(TimeSpan.FromSeconds(10));
await session2.DisposeAsync();
[Description("Returns a magic number")]
static string MagicNumber([Description("A seed value")] string seed) => $"MAGIC_{seed}_42";
}
[Fact]
public async Task One_Client_Approves_Permission_And_Both_See_The_Result()
{
var client1PermissionRequests = new List<PermissionRequest>();
var session1 = await Client1.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = (request, _) =>
{
client1PermissionRequests.Add(request);
return Task.FromResult(new PermissionRequestResult
{
Kind = PermissionRequestResultKind.Approved,
});
},
});
// Client 2 resumes — its handler never completes, so only client 1's approval takes effect
var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
{
OnPermissionRequest = (_, _) => new TaskCompletionSource<PermissionRequestResult>().Task,
});
var client1Events = new ConcurrentBag<SessionEvent>();
var client2Events = new ConcurrentBag<SessionEvent>();
using var sub1 = session1.On(evt => client1Events.Add(evt));
using var sub2 = session2.On(evt => client2Events.Add(evt));
var response = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Create a file called hello.txt containing the text 'hello world'",
});
Assert.NotNull(response);
Assert.NotEmpty(client1PermissionRequests);
Assert.Contains(client1Events, e => e is PermissionRequestedEvent);
Assert.Contains(client2Events, e => e is PermissionRequestedEvent);
Assert.Contains(client1Events, e => e is PermissionCompletedEvent);
Assert.Contains(client2Events, e => e is PermissionCompletedEvent);
foreach (var evt in client1Events.OfType<PermissionCompletedEvent>()
.Concat(client2Events.OfType<PermissionCompletedEvent>()))
{
Assert.Equal(PermissionCompletedDataResultKind.Approved, evt.Data.Result.Kind);
}
await session2.DisposeAsync();
}
[Fact]
public async Task One_Client_Rejects_Permission_And_Both_See_The_Result()
{
var session1 = await Client1.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = (_, _) => Task.FromResult(new PermissionRequestResult
{
Kind = PermissionRequestResultKind.DeniedInteractivelyByUser,
}),
});
// Client 2 resumes — its handler never completes
var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
{
OnPermissionRequest = (_, _) => new TaskCompletionSource<PermissionRequestResult>().Task,
});
var client1Events = new ConcurrentBag<SessionEvent>();
var client2Events = new ConcurrentBag<SessionEvent>();
using var sub1 = session1.On(evt => client1Events.Add(evt));
using var sub2 = session2.On(evt => client2Events.Add(evt));
// Write a file so the agent has something to edit
await File.WriteAllTextAsync(Path.Combine(Ctx.WorkDir, "protected.txt"), "protected content");
await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Edit protected.txt and replace 'protected' with 'hacked'.",
});
// Verify the file was NOT modified
var content = await File.ReadAllTextAsync(Path.Combine(Ctx.WorkDir, "protected.txt"));
Assert.Equal("protected content", content);
Assert.Contains(client1Events, e => e is PermissionRequestedEvent);
Assert.Contains(client2Events, e => e is PermissionRequestedEvent);
foreach (var evt in client1Events.OfType<PermissionCompletedEvent>()
.Concat(client2Events.OfType<PermissionCompletedEvent>()))
{
Assert.Equal(PermissionCompletedDataResultKind.DeniedInteractivelyByUser, evt.Data.Result.Kind);
}
await session2.DisposeAsync();
}
[Fact]
public async Task Two_Clients_Register_Different_Tools_And_Agent_Uses_Both()
{
var toolA = AIFunctionFactory.Create(CityLookup, "city_lookup");
var toolB = AIFunctionFactory.Create(CurrencyLookup, "currency_lookup");
var session1 = await Client1.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
Tools = [toolA],
});
var session2 = await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
Tools = [toolB],
});
// Send prompts sequentially to avoid nondeterministic tool_call ordering
var response1 = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Use the city_lookup tool with countryCode 'US' and tell me the result.",
});
Assert.NotNull(response1);
Assert.Contains("CITY_FOR_US", response1!.Data.Content ?? string.Empty);
var response2 = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Now use the currency_lookup tool with countryCode 'US' and tell me the result.",
});
Assert.NotNull(response2);
Assert.Contains("CURRENCY_FOR_US", response2!.Data.Content ?? string.Empty);
await session2.DisposeAsync();
[Description("Returns a city name for a given country code")]
static string CityLookup([Description("A two-letter country code")] string countryCode) => $"CITY_FOR_{countryCode}";
[Description("Returns a currency for a given country code")]
static string CurrencyLookup([Description("A two-letter country code")] string countryCode) => $"CURRENCY_FOR_{countryCode}";
}
[Fact]
public async Task Disconnecting_Client_Removes_Its_Tools()
{
var toolA = AIFunctionFactory.Create(StableTool, "stable_tool");
var toolB = AIFunctionFactory.Create(EphemeralTool, "ephemeral_tool");
var session1 = await Client1.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
Tools = [toolA],
});
await Client2.ResumeSessionAsync(session1.SessionId, new ResumeSessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
Tools = [toolB],
});
// Verify both tools work before disconnect (sequential to avoid nondeterministic tool_call ordering)
var stableResponse = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Use the stable_tool with input 'test1' and tell me the result.",
});
Assert.NotNull(stableResponse);
Assert.Contains("STABLE_test1", stableResponse!.Data.Content ?? string.Empty);
var ephemeralResponse = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Use the ephemeral_tool with input 'test2' and tell me the result.",
});
Assert.NotNull(ephemeralResponse);
Assert.Contains("EPHEMERAL_test2", ephemeralResponse!.Data.Content ?? string.Empty);
// Disconnect client 2
await Client2.ForceStopAsync();
await Task.Delay(500); // Let the server process the disconnection
// Recreate client2 for cleanup
var port = Client1.ActualPort!.Value;
_client2 = new CopilotClient(new CopilotClientOptions
{
CliUrl = $"localhost:{port}",
});
// Now only stable_tool should be available
var afterResponse = await session1.SendAndWaitAsync(new MessageOptions
{
Prompt = "Use the stable_tool with input 'still_here'. Also try using ephemeral_tool if it is available.",
});
Assert.NotNull(afterResponse);
Assert.Contains("STABLE_still_here", afterResponse!.Data.Content ?? string.Empty);
Assert.DoesNotContain("EPHEMERAL_", afterResponse!.Data.Content ?? string.Empty);
[Description("A tool that persists across disconnects")]
static string StableTool([Description("Input value")] string input) => $"STABLE_{input}";
[Description("A tool that will disappear when its client disconnects")]
static string EphemeralTool([Description("Input value")] string input) => $"EPHEMERAL_{input}";
}
}