/*--------------------------------------------------------------------------------------------- * 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; /// /// Custom fixture for multi-client tests that uses TCP mode so a second client can connect. /// 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, 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(); var client2Requested = new TaskCompletionSource(); var client1Completed = new TaskCompletionSource(); var client2Completed = new TaskCompletionSource(); 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 var timeout = Task.Delay(TimeSpan.FromSeconds(10)); var allEvents = Task.WhenAll( client1Requested.Task, client2Requested.Task, client1Completed.Task, client2Completed.Task); Assert.Equal(allEvents, await Task.WhenAny(allEvents, timeout)); 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(); 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().Task, }); var client1Events = new ConcurrentBag(); var client2Events = new ConcurrentBag(); 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() .Concat(client2Events.OfType())) { 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().Task, }); var client1Events = new ConcurrentBag(); var client2Events = new ConcurrentBag(); 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() .Concat(client2Events.OfType())) { 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}"; } }