/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ #if NET8_0_OR_GREATER using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Diagnostics; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using GitHub.Copilot.Rpc; using Microsoft.Extensions.AI; using Xunit; namespace GitHub.Copilot.Test.Unit; public sealed class ClientSessionLifetimeTests { private sealed record RpcRequestRecord(string Method, JsonElement Params); [Theory] [InlineData("static")] [InlineData("")] public async Task GitHubTokenProvider_Is_Mutually_Exclusive_With_Static_Token(string staticToken) { await using var client = new CopilotClient(); var config = new SessionConfig { GitHubToken = staticToken, GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) }; var error = await Assert.ThrowsAsync(() => client.CreateSessionAsync(config)); Assert.Contains("cannot be used together", error.Message); } [Fact] public async Task GitHubTokenProvider_Is_Released_When_Session_Is_Deleted() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var session = await client.CreateSessionAsync(new SessionConfig { GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) }); var registrationId = Assert.Single(server.Requests, request => request.Method == "session.create") .Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); await client.DeleteSessionAsync(session.SessionId); var error = await Assert.ThrowsAsync(() => server.SendRequestAsync("gitHubToken.getToken", TokenRequest(registrationId))); Assert.Contains("Unknown GitHub token provider registration ID", error.Message); await session.DisposeAsync(); } [Fact] public async Task GitHubTokenProvider_Is_Serialized_And_Maps_Callbacks() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); GitHubTokenProviderArgs? callbackArgs = null; var session = await client.CreateSessionAsync(new SessionConfig { GitHubTokenProvider = args => { callbackArgs = args; return Task.FromResult(GitHubTokenProviderResult.FromToken(new GitHubToken { AccessToken = "secret-token", TokenType = "bearer", ExpiresIn = 8 * 60 * 60 })); } }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); var registrationId = request.Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); Assert.False(string.IsNullOrEmpty(registrationId)); Assert.False(request.Params.TryGetProperty("gitHubToken", out _)); var result = await server.SendRequestAsync("gitHubToken.getToken", new Dictionary { ["registrationId"] = registrationId, ["host"] = "github.example.com", ["sessionId"] = session.SessionId, ["reason"] = "refresh" }); Assert.True(result.TryGetProperty("kind", out var kind), result.ToString()); Assert.Equal("token", kind.GetString()); Assert.Equal("secret-token", result.GetProperty("accessToken").GetString()); Assert.Equal(8 * 60 * 60, result.GetProperty("expiresIn").GetInt64()); Assert.NotNull(callbackArgs); Assert.Equal("github.example.com", callbackArgs.Host); Assert.Equal(session.SessionId, callbackArgs.SessionId); Assert.Equal(GitHubTokenRequestReason.Refresh, callbackArgs.Reason); Assert.DoesNotContain("secret-token", new GitHubToken { AccessToken = "secret-token", ExpiresIn = 8 * 60 * 60 }.ToString()); await session.DisposeAsync(); var error = await Assert.ThrowsAsync(() => server.SendRequestAsync("gitHubToken.getToken", new Dictionary { ["registrationId"] = registrationId, ["host"] = "github.com", ["reason"] = "initial" })); Assert.Contains("Unknown GitHub token provider registration ID", error.Message); server.ClearRequests(); var resumed = await client.ResumeSessionAsync("resumed-session", new ResumeSessionConfig { GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) }); var resumeRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); Assert.False(string.IsNullOrEmpty( resumeRequest.Params.GetProperty("gitHubTokenProviderRegistrationId").GetString())); await resumed.DisposeAsync(); } [Fact] public async Task GitHubTokenProvider_Handles_Cancellation_Errors_And_Rollback() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var cancelledSession = await client.CreateSessionAsync(new SessionConfig { GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) }); var cancelledId = Assert.Single(server.Requests, request => request.Method == "session.create") .Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); var cancelled = await server.SendRequestAsync("gitHubToken.getToken", TokenRequest(cancelledId)); Assert.True(cancelled.TryGetProperty("kind", out var cancelledKind), cancelled.ToString()); Assert.Equal("cancelled", cancelledKind.GetString()); await cancelledSession.DisposeAsync(); server.ClearRequests(); var providerSession = await client.CreateSessionAsync(new SessionConfig { GitHubTokenProvider = _ => Task.FromException( new InvalidOperationException("provider failed")) }); var providerId = Assert.Single(server.Requests, request => request.Method == "session.create") .Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); var callbackError = await Assert.ThrowsAsync(() => server.SendRequestAsync("gitHubToken.getToken", TokenRequest(providerId))); Assert.Contains("provider failed", callbackError.Message); await providerSession.DisposeAsync(); server.ClearRequests(); server.FailSessionCreate(); await Assert.ThrowsAsync(() => client.CreateSessionAsync(new SessionConfig { GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) })); var rolledBackId = Assert.Single(server.Requests, request => request.Method == "session.create") .Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); var rollbackError = await Assert.ThrowsAsync(() => server.SendRequestAsync("gitHubToken.getToken", TokenRequest(rolledBackId))); Assert.Contains("Unknown GitHub token provider registration ID", rollbackError.Message); } [Fact] public async Task GitHubTokenProvider_Resume_Replaces_Ownership() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var first = await client.CreateSessionAsync(new SessionConfig { SessionId = "replacement-session", GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) }); var firstId = Assert.Single(server.Requests, request => request.Method == "session.create") .Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); await first.DisposeAsync(); await Assert.ThrowsAsync(() => server.SendRequestAsync("gitHubToken.getToken", TokenRequest(firstId))); server.ClearRequests(); var resumed = await client.ResumeSessionAsync("replacement-session", new ResumeSessionConfig { GitHubTokenProvider = _ => Task.FromResult(GitHubTokenProviderResult.Cancel()) }); var secondId = Assert.Single(server.Requests, request => request.Method == "session.resume") .Params.GetProperty("gitHubTokenProviderRegistrationId").GetString(); var result = await server.SendRequestAsync("gitHubToken.getToken", TokenRequest(secondId)); Assert.Equal("cancelled", result.GetProperty("kind").GetString()); await resumed.DisposeAsync(); await Assert.ThrowsAsync(() => server.SendRequestAsync("gitHubToken.getToken", TokenRequest(secondId))); } private static Dictionary TokenRequest(string? registrationId) => new() { ["registrationId"] = registrationId, ["host"] = "github.com", ["reason"] = "initial" }; [Fact] public async Task StopAsync_Requests_Runtime_Shutdown_For_Owned_Process() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); using var process = StartExitedProcess(); await ReplaceConnectionCliProcessAsync(client, process); await client.StopAsync(); Assert.Equal(1, server.RuntimeShutdownCount); } [Fact] public async Task DisposeAsync_Requests_Runtime_Shutdown_For_Owned_Process() { await using var server = await FakeCopilotServer.StartAsync(); var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); using var process = StartExitedProcess(); await ReplaceConnectionCliProcessAsync(client, process); await client.DisposeAsync(); Assert.Equal(1, server.RuntimeShutdownCount); } [Fact] public async Task StopAsync_Does_Not_Throw_When_Runtime_Shutdown_Fails() { await using var server = await FakeCopilotServer.StartAsync(); server.FailRuntimeShutdown(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); using var process = StartExitedProcess(); await ReplaceConnectionCliProcessAsync(client, process); await client.StopAsync(); Assert.Equal(1, server.RuntimeShutdownCount); } [Fact] public async Task ForceStopAsync_And_External_Stop_Do_Not_Request_Runtime_Shutdown() { await using var forceServer = await FakeCopilotServer.StartAsync(); await using var forceClient = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(forceServer.Url) }); await forceClient.StartAsync(); using var process = StartExitedProcess(); await ReplaceConnectionCliProcessAsync(forceClient, process); await forceClient.ForceStopAsync(); Assert.Equal(0, forceServer.RuntimeShutdownCount); await using var externalServer = await FakeCopilotServer.StartAsync(); await using var externalClient = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(externalServer.Url) }); await externalClient.StartAsync(); await externalClient.StopAsync(); Assert.Equal(0, externalServer.RuntimeShutdownCount); } [Fact] public async Task Dropped_Session_Remains_Rooted_By_Client() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var weakSession = await CreateDroppedSessionAsync(client); ForceCollect(); Assert.True( weakSession.TryGetTarget(out _), "CopilotClient should root created sessions until they are explicitly disposed or the client stops."); AssertSessionCount(client, sessions: 1); GC.KeepAlive(client); } [Fact] public async Task Disposed_Session_Is_Removed_From_Client() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); AssertSessionCount(client, sessions: 1); await session.DisposeAsync(); AssertSessionCount(client, sessions: 0); } [Fact] public async Task Disposing_Session_Remains_Rooted_Until_Destroy_Completes() { await using var server = await FakeCopilotServer.StartAsync(); server.DelayDestroy(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); AssertSessionCount(client, sessions: 1); var disposeTask = session.DisposeAsync().AsTask(); await server.DestroyStarted; AssertSessionCount(client, sessions: 1); server.CompleteDestroy(); await disposeTask; AssertSessionCount(client, sessions: 0); } [Fact] public async Task StopAsync_Removes_Rooted_Sessions() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); _ = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); AssertSessionCount(client, sessions: 1); await client.StopAsync(); AssertSessionCount(client, sessions: 0); } [Fact] public async Task StopAsync_Keeps_Session_Rooted_Until_Destroy_Completes() { await using var server = await FakeCopilotServer.StartAsync(); server.DelayDestroy(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); _ = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); AssertSessionCount(client, sessions: 1); var stopTask = client.StopAsync(); await server.DestroyStarted; AssertSessionCount(client, sessions: 1); server.CompleteDestroy(); await stopTask; AssertSessionCount(client, sessions: 0); } [Fact] public async Task ForceStopAsync_Unblocks_StopAsync_When_Session_Destroy_Hangs() { await using var server = await FakeCopilotServer.StartAsync(); server.DelayDestroy(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); _ = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var stopTask = client.StopAsync(); await server.DestroyStarted; await client.ForceStopAsync(); await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); AssertSessionCount(client, sessions: 0); } [Fact] public async Task ResumeSessionAsync_Throws_When_Same_Client_Already_Tracks_Session() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var sessionId = "same-session-id"; await using var session = await client.CreateSessionAsync(new SessionConfig { SessionId = sessionId, OnPermissionRequest = PermissionHandler.ApproveAll }); AssertSessionCount(client, sessions: 1); var exception = await Assert.ThrowsAsync(() => client.ResumeSessionAsync(sessionId, new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll })); Assert.Contains(sessionId, exception.Message); AssertSessionCount(client, sessions: 1); } [Fact] public async Task CreateSessionAsync_Serializes_CustomAgent_ReasoningEffort() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { CustomAgents = [ new CustomAgentConfig { Name = "reasoning-agent", Prompt = "Think carefully.", ReasoningEffort = "high" } ], OnPermissionRequest = PermissionHandler.ApproveAll }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); var agent = Assert.Single(request.Params.GetProperty("customAgents").EnumerateArray()); Assert.Equal("high", agent.GetProperty("reasoningEffort").GetString()); } [Fact] public async Task CreateSessionAsync_Omits_CustomAgent_ReasoningEffort_When_Unset() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { CustomAgents = [ new CustomAgentConfig { Name = "default-agent", Prompt = "Use runtime defaults." } ], OnPermissionRequest = PermissionHandler.ApproveAll }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); var agent = Assert.Single(request.Params.GetProperty("customAgents").EnumerateArray()); Assert.False(agent.TryGetProperty("reasoningEffort", out _)); } public static TheoryData CapiAutoTiers => new() { { AutoTier.Efficiency, "efficiency", null }, { AutoTier.Balance, "balance", null }, { AutoTier.Intelligence, "intelligence", null }, { AutoTier.Efficiency, "efficiency", false }, { AutoTier.Balance, "balance", false }, { AutoTier.Intelligence, "intelligence", false }, }; [Theory] [MemberData(nameof(CapiAutoTiers))] public async Task SessionRequests_Serialize_CapiAutoTier(AutoTier tier, string expectedTier, bool? enableWebSocketResponses) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var capi = new CapiSessionOptions { AutoTier = tier, EnableWebSocketResponses = enableWebSocketResponses }; await using var created = await client.CreateSessionAsync(new SessionConfig { Model = "auto", Capi = capi, OnPermissionRequest = PermissionHandler.ApproveAll }); await using var resumed = await client.ResumeSessionAsync("resume-with-auto-tier", new ResumeSessionConfig { Model = "auto", Capi = capi, OnPermissionRequest = PermissionHandler.ApproveAll }); foreach (var method in new[] { "session.create", "session.resume" }) { var request = Assert.Single(server.Requests, request => request.Method == method); var serializedCapi = request.Params.GetProperty("capi"); Assert.Equal(expectedTier, serializedCapi.GetProperty("autoTier").GetString()); if (enableWebSocketResponses.HasValue) { Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean()); } else { Assert.False(serializedCapi.TryGetProperty("enableWebSocketResponses", out _)); } } } [Theory] [InlineData("efficiency")] [InlineData("balance")] [InlineData("intelligence")] public async Task SetModelAsync_Serializes_AutoTier(string expectedTier) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.SetModelAsync("auto", new SetModelOptions { AutoTier = new AutoTier(expectedTier) }); var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchTo"); Assert.Equal("auto", request.Params.GetProperty("modelId").GetString()); Assert.Equal(expectedTier, request.Params.GetProperty("autoTier").GetString()); } [Fact] public async Task SetModelAsync_Omits_AutoTier_WhenUnset() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.SetModelAsync("gpt-5.4"); var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchTo"); Assert.False(request.Params.TryGetProperty("autoTier", out _)); } [Fact] public async Task SetModelAsync_Writes_Null_AutoTier_WhenCleared() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.SetModelAsync("auto", new SetModelOptions { ResetAutoTier = true }); // An explicit null must survive to the wire. Omitting it would mean "leave the // preference alone" rather than "use provider-default routing". var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchTo"); Assert.True(request.Params.TryGetProperty("autoTier", out var autoTier)); Assert.Equal(JsonValueKind.Null, autoTier.ValueKind); } [Fact] public async Task SetModelAsync_Rejects_Conflicting_AutoTier_Options() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await Assert.ThrowsAsync(() => session.SetModelAsync( "auto", new SetModelOptions { AutoTier = AutoTier.Balance, ResetAutoTier = true })); } [Fact] public async Task SetAutoTierAsync_Serializes_Tier_And_Returns_Snapshot() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var result = await session.SetAutoTierAsync(AutoTier.Intelligence); var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchAutoTier"); Assert.Equal("intelligence", request.Params.GetProperty("autoTier").GetString()); Assert.Equal(ModelSwitchAutoTierStatus.Pending, result.Status); Assert.Equal(AutoTier.Balance, result.EffectiveAutoTier); } [Fact] public async Task SetAutoTierAsync_Writes_Null_Tier_ForDefaultRouting() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.SetAutoTierAsync(null); var request = Assert.Single(server.Requests, request => request.Method == "session.model.switchAutoTier"); Assert.True(request.Params.TryGetProperty("autoTier", out var autoTier)); Assert.Equal(JsonValueKind.Null, autoTier.ValueKind); } [Theory] [InlineData(false, null)] [InlineData(true, null)] [InlineData(true, false)] public async Task SessionRequests_Omit_CapiAutoTier_WhenUnset(bool includeCapi, bool? enableWebSocketResponses) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var capi = includeCapi ? new CapiSessionOptions { EnableWebSocketResponses = enableWebSocketResponses } : null; await using var created = await client.CreateSessionAsync(new SessionConfig { Model = "auto", Capi = capi, OnPermissionRequest = PermissionHandler.ApproveAll }); await using var resumed = await client.ResumeSessionAsync("resume-without-auto-tier", new ResumeSessionConfig { Capi = capi, OnPermissionRequest = PermissionHandler.ApproveAll }); foreach (var method in new[] { "session.create", "session.resume" }) { var request = Assert.Single(server.Requests, request => request.Method == method); Assert.Equal(includeCapi, request.Params.TryGetProperty("capi", out var serializedCapi)); if (includeCapi) { Assert.False(serializedCapi.TryGetProperty("autoTier", out _)); if (enableWebSocketResponses.HasValue) { Assert.Equal(enableWebSocketResponses.Value, serializedCapi.GetProperty("enableWebSocketResponses").GetBoolean()); } else { Assert.Empty(serializedCapi.EnumerateObject()); } } } } [Fact] public async Task CreateSessionAsync_Forwards_AskUserVariant() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { AskUserVariant = AskUserVariant.Elicitation, OnPermissionRequest = PermissionHandler.ApproveAll }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); Assert.Equal("elicitation", request.Params.GetProperty("askUserVariant").GetString()); server.ClearRequests(); await using var defaultSession = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var defaultRequest = Assert.Single(server.Requests, request => request.Method == "session.create"); Assert.False(defaultRequest.Params.TryGetProperty("askUserVariant", out _)); } [Fact] public async Task ResumeSessionAsync_Forwards_AskUserVariant_On_Cold_Resume() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.ResumeSessionAsync("ask-user-variant", new ResumeSessionConfig { AskUserVariant = AskUserVariant.Legacy, OnPermissionRequest = PermissionHandler.ApproveAll }); var request = Assert.Single(server.Requests, request => request.Method == "session.resume"); Assert.Equal("legacy", request.Params.GetProperty("askUserVariant").GetString()); server.ClearRequests(); await using var defaultSession = await client.ResumeSessionAsync("ask-user-variant-default", new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var defaultRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); Assert.False(defaultRequest.Params.TryGetProperty("askUserVariant", out _)); } [Fact] public async Task SessionRequests_Serialize_AdditionalDirectories() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var created = await client.CreateSessionAsync(new SessionConfig { AdditionalDirectories = ["/repo/shared", "/repo/generated"], OnPermissionRequest = PermissionHandler.ApproveAll }); var createRequest = Assert.Single(server.Requests, request => request.Method == "session.create"); Assert.Collection( createRequest.Params.GetProperty("additionalDirectories").EnumerateArray(), value => Assert.Equal("/repo/shared", value.GetString()), value => Assert.Equal("/repo/generated", value.GetString())); server.ClearRequests(); await using var resumed = await client.ResumeSessionAsync("resume-with-additional-directories", new ResumeSessionConfig { AdditionalDirectories = ["/repo/resumed"], OnPermissionRequest = PermissionHandler.ApproveAll }); var resumeRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); Assert.Collection( resumeRequest.Params.GetProperty("additionalDirectories").EnumerateArray(), value => Assert.Equal("/repo/resumed", value.GetString())); } [Fact] public async Task SessionRequests_Serialize_Terminal_Tools() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var terminalTool = CopilotTool.DefineTool( (Func)(() => "done"), new CopilotToolOptions { IsTerminal = true }); var plainTool = CopilotTool.DefineTool((Func)(() => "continue")); await using var created = await client.CreateSessionAsync(new SessionConfig { Tools = [terminalTool, plainTool], OnPermissionRequest = PermissionHandler.ApproveAll }); var createRequest = Assert.Single(server.Requests, request => request.Method == "session.create"); var createTools = createRequest.Params.GetProperty("tools"); Assert.True(createTools[0].GetProperty("isTerminal").GetBoolean()); Assert.False(createTools[1].TryGetProperty("isTerminal", out _)); server.ClearRequests(); await using var resumed = await client.ResumeSessionAsync("resume-with-terminal-tool", new ResumeSessionConfig { Tools = [terminalTool], OnPermissionRequest = PermissionHandler.ApproveAll }); var resumeRequest = Assert.Single(server.Requests, request => request.Method == "session.resume"); Assert.True(resumeRequest.Params.GetProperty("tools")[0].GetProperty("isTerminal").GetBoolean()); } [Fact] public async Task ExternalTool_String_Arguments_Bind_To_Single_Function_Parameter() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); string? receivedPatch = null; var tool = CopilotTool.DefineTool( (string patch) => { receivedPatch = patch; return "applied"; }, new CopilotToolOptions { OverridesBuiltInTool = true }, new AIFunctionFactoryOptions { Name = "apply_patch" }); await using var session = await client.CreateSessionAsync(new SessionConfig { Tools = [tool], OnPermissionRequest = PermissionHandler.ApproveAll }); server.ClearRequests(); using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\""); DispatchEvent(session, new ExternalToolRequestedEvent { Data = new ExternalToolRequestedData { Arguments = arguments.RootElement.Clone(), RequestId = "apply-patch-request", SessionId = session.SessionId, ToolCallId = "apply-patch-call", ToolName = "apply_patch" } }); var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); Assert.Equal("*** Begin Patch\n*** End Patch", receivedPatch); Assert.False(request.Params.TryGetProperty("error", out _)); Assert.Equal("applied", request.Params.GetProperty("result").GetProperty("textResultForLlm").GetString()); } [Fact] public async Task ExternalTool_String_Arguments_Reject_Ambiguous_Function_Parameters() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var invoked = false; var tool = CopilotTool.DefineTool( (string patch, string explanation) => { invoked = true; return "applied"; }, new CopilotToolOptions { OverridesBuiltInTool = true }, new AIFunctionFactoryOptions { Name = "apply_patch" }); await using var session = await client.CreateSessionAsync(new SessionConfig { Tools = [tool], OnPermissionRequest = PermissionHandler.ApproveAll }); server.ClearRequests(); using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\""); DispatchEvent(session, new ExternalToolRequestedEvent { Data = new ExternalToolRequestedData { Arguments = arguments.RootElement.Clone(), RequestId = "ambiguous-apply-patch-request", SessionId = session.SessionId, ToolCallId = "ambiguous-apply-patch-call", ToolName = "apply_patch" } }); var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); Assert.False(invoked); Assert.Contains("received non-object arguments", request.Params.GetProperty("error").GetString()); Assert.False(request.Params.TryGetProperty("result", out _)); } [Fact] public async Task ExternalTool_Number_Arguments_Bind_To_Single_Function_Parameter() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); int? receivedLine = null; var tool = CopilotTool.DefineTool( (int line) => { receivedLine = line; return "selected"; }, factoryOptions: new AIFunctionFactoryOptions { Name = "select_line" }); await using var session = await client.CreateSessionAsync(new SessionConfig { Tools = [tool], OnPermissionRequest = PermissionHandler.ApproveAll }); server.ClearRequests(); using var arguments = JsonDocument.Parse("42"); DispatchEvent(session, new ExternalToolRequestedEvent { Data = new ExternalToolRequestedData { Arguments = arguments.RootElement.Clone(), RequestId = "select-line-request", SessionId = session.SessionId, ToolCallId = "select-line-call", ToolName = "select_line" } }); var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); Assert.Equal(42, receivedLine); Assert.False(request.Params.TryGetProperty("error", out _)); } [Fact] public async Task ExternalTool_String_Arguments_Bind_To_Sole_Required_Function_Parameter() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); string? receivedPatch = null; string? receivedExplanation = null; var tool = CopilotTool.DefineTool( (string patch, string? explanation = null) => { receivedPatch = patch; receivedExplanation = explanation; return "applied"; }, new CopilotToolOptions { OverridesBuiltInTool = true }, new AIFunctionFactoryOptions { Name = "apply_patch" }); await using var session = await client.CreateSessionAsync(new SessionConfig { Tools = [tool], OnPermissionRequest = PermissionHandler.ApproveAll }); server.ClearRequests(); using var arguments = JsonDocument.Parse("\"*** Begin Patch\\n*** End Patch\""); DispatchEvent(session, new ExternalToolRequestedEvent { Data = new ExternalToolRequestedData { Arguments = arguments.RootElement.Clone(), RequestId = "optional-apply-patch-request", SessionId = session.SessionId, ToolCallId = "optional-apply-patch-call", ToolName = "apply_patch" } }); var request = await WaitForRequestAsync(server, "session.tools.handlePendingToolCall"); Assert.Equal("*** Begin Patch\n*** End Patch", receivedPatch); Assert.Null(receivedExplanation); Assert.False(request.Params.TryGetProperty("error", out _)); } [Fact] public async Task EmptyMode_Create_Sends_Empty_IncludedBuiltinSkills() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), Mode = CopilotClientMode.Empty, BaseDirectory = Path.GetTempPath(), }); await using var created = await client.CreateSessionAsync(new SessionConfig { AvailableTools = [], OnPermissionRequest = PermissionHandler.ApproveAll }); var update = Assert.Single(server.Requests, request => request.Method == "session.options.update"); Assert.True(update.Params.TryGetProperty("includedBuiltinSkills", out var skills)); Assert.Equal(JsonValueKind.Array, skills.ValueKind); Assert.Equal(0, skills.GetArrayLength()); // Adjacent unconditional plugin isolation is still present. Assert.True(update.Params.TryGetProperty("installedPlugins", out var plugins)); Assert.Equal(0, plugins.GetArrayLength()); } [Fact] public async Task EmptyMode_Resume_Sends_Empty_IncludedBuiltinSkills() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), Mode = CopilotClientMode.Empty, BaseDirectory = Path.GetTempPath(), }); await using var resumed = await client.ResumeSessionAsync("resume-empty-skills", new ResumeSessionConfig { AvailableTools = [], OnPermissionRequest = PermissionHandler.ApproveAll }); var update = Assert.Single(server.Requests, request => request.Method == "session.options.update"); Assert.True(update.Params.TryGetProperty("includedBuiltinSkills", out var skills)); Assert.Equal(JsonValueKind.Array, skills.ValueKind); Assert.Equal(0, skills.GetArrayLength()); } [Fact] public async Task EmptyMode_Resume_Preserves_Explicit_IncludedBuiltinSkills() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), Mode = CopilotClientMode.Empty, BaseDirectory = Path.GetTempPath(), }); await using var resumed = await client.ResumeSessionAsync("resume-selected-skills", new ResumeSessionConfig { AvailableTools = [], IncludedBuiltinSkills = ["code-review"], OnPermissionRequest = PermissionHandler.ApproveAll }); var update = Assert.Single(server.Requests, request => request.Method == "session.options.update"); var skills = update.Params.GetProperty("includedBuiltinSkills"); Assert.Equal(["code-review"], skills.EnumerateArray().Select(value => value.GetString())); } [Fact] public async Task EmptyMode_Create_With_EnableSkills_Still_Sends_Empty_IncludedBuiltinSkills() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), Mode = CopilotClientMode.Empty, BaseDirectory = Path.GetTempPath(), }); // Caller opts into their own custom skills. Runtime-bundled built-ins must // still be excluded: the empty post-patch cannot be weakened by the caller. await using var created = await client.CreateSessionAsync(new SessionConfig { AvailableTools = [], EnableSkills = true, SkillDirectories = [Path.Combine(Path.GetTempPath(), "skills")], OnPermissionRequest = PermissionHandler.ApproveAll }); var update = Assert.Single(server.Requests, request => request.Method == "session.options.update"); Assert.True(update.Params.TryGetProperty("includedBuiltinSkills", out var skills)); Assert.Equal(JsonValueKind.Array, skills.ValueKind); Assert.Equal(0, skills.GetArrayLength()); } [Fact] public async Task EmptyMode_Create_Preserves_Explicit_IncludedBuiltinSkills() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url), Mode = CopilotClientMode.Empty, BaseDirectory = Path.GetTempPath(), }); await using var created = await client.CreateSessionAsync(new SessionConfig { AvailableTools = [], IncludedBuiltinSkills = ["code-review"], OnPermissionRequest = PermissionHandler.ApproveAll }); var update = Assert.Single(server.Requests, request => request.Method == "session.options.update"); var skills = update.Params.GetProperty("includedBuiltinSkills"); Assert.Equal(["code-review"], skills.EnumerateArray().Select(value => value.GetString())); } [Fact] public async Task CopilotCliMode_Create_Does_Not_Inject_IncludedBuiltinSkills() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var created = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); // In the default copilot-cli mode with no overridable options set, no // options patch is sent at all, so the field is never injected. Assert.DoesNotContain(server.Requests, request => request.Method == "session.options.update" && request.Params.TryGetProperty("includedBuiltinSkills", out _)); } [Fact] public async Task CreateSessionAsync_Registers_McpAuth_Interest_Only_When_Handler_Configured() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var withoutAuth = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, OnEvent = _ => { } }); Assert.DoesNotContain(server.Requests, request => request.Method == "session.eventLog.registerInterest" && request.Params.GetProperty("eventType").GetString() == "mcp.oauth_required"); Assert.Contains(server.Requests, request => request.Method == "session.create" && request.Params.GetProperty("requestPermission").GetBoolean()); server.ClearRequests(); await using var withAuth = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, OnMcpAuthRequest = _ => Task.FromResult(McpAuthResult.Cancel()) }); Assert.Collection( server.Requests.Take(2), request => Assert.Equal("session.create", request.Method), request => { Assert.Equal("session.eventLog.registerInterest", request.Method); Assert.Equal("mcp.oauth_required", request.Params.GetProperty("eventType").GetString()); }); } [Fact] public async Task CreateSessionAsync_Registers_McpAuth_Interest_After_Cloud_Create_When_Handler_Configured() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var cloud = new CloudSessionOptions { Repository = new CloudSessionRepository { Owner = "github", Name = "copilot-sdk", Branch = "main" } }; await using var withoutAuth = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, Cloud = cloud }); Assert.DoesNotContain(server.Requests, request => request.Method == "session.eventLog.registerInterest" && request.Params.GetProperty("eventType").GetString() == "mcp.oauth_required"); server.ClearRequests(); await using var withAuth = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, OnMcpAuthRequest = _ => Task.FromResult(McpAuthResult.Cancel()), Cloud = cloud }); Assert.Collection( server.Requests.Take(2), request => Assert.Equal("session.create", request.Method), request => { Assert.Equal("session.eventLog.registerInterest", request.Method); Assert.Equal("mcp.oauth_required", request.Params.GetProperty("eventType").GetString()); }); } [Fact] public async Task ResumeSessionAsync_Registers_McpAuth_Interest_Only_When_Handler_Configured() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var withoutAuth = await client.ResumeSessionAsync("session-without-auth", new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, OnEvent = _ => { } }); Assert.DoesNotContain(server.Requests, request => request.Method == "session.eventLog.registerInterest" && request.Params.GetProperty("eventType").GetString() == "mcp.oauth_required"); Assert.Contains(server.Requests, request => request.Method == "session.resume" && request.Params.GetProperty("requestPermission").GetBoolean()); server.ClearRequests(); await using var withAuth = await client.ResumeSessionAsync("session-with-auth", new ResumeSessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, OnMcpAuthRequest = _ => Task.FromResult(McpAuthResult.Cancel()) }); Assert.Collection( server.Requests.Take(2), request => Assert.Equal("session.resume", request.Method), request => { Assert.Equal("session.eventLog.registerInterest", request.Method); Assert.Equal("mcp.oauth_required", request.Params.GetProperty("eventType").GetString()); }); } [Fact] public async Task McpAuth_Handler_Exception_Cancels_Pending_Request() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll, OnMcpAuthRequest = _ => throw new ApplicationException("boom") }); DispatchEvent(session, new McpOauthRequiredEvent { Data = new McpOauthRequiredData { RequestId = "mcp-auth-request-1", ServerName = "oauth-mcp", ServerUrl = "http://localhost/mcp", Reason = McpOauthRequestReason.Initial } }); var request = await WaitForRequestAsync(server, "session.mcp.oauth.handlePendingRequest"); Assert.Equal("mcp-auth-request-1", request.Params.GetProperty("requestId").GetString()); Assert.Equal("cancelled", request.Params.GetProperty("result").GetProperty("kind").GetString()); } [Fact] public async Task ExternalToolCompleted_Cancels_Blocked_Tool_When_Cancellation_Callback_Throws() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var toolCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var session = await client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(BlockedTool, "blocked_tool")], OnPermissionRequest = PermissionHandler.ApproveAll }); DispatchEvent(session, ExternalToolRequested("request-1")); await toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); DispatchEvent(session, new ExternalToolCompletedEvent { Data = new ExternalToolCompletedData { RequestId = "request-1" } }); await toolCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Task.Delay(50); Assert.DoesNotContain(server.Requests, request => request.Method == "session.tools.handlePendingToolCall" && request.Params.GetProperty("requestId").GetString() == "request-1"); async Task BlockedTool(CancellationToken cancellationToken) { toolStarted.TrySetResult(); using var registration = cancellationToken.Register( () => throw new InvalidOperationException("cancellation callback failed")); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return "unreachable"; } catch (OperationCanceledException) { toolCancelled.TrySetResult(); throw; } } } [Fact] public async Task ExternalToolCompleted_Does_Not_Block_Event_Dispatch_On_Cancellation_Callback() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var callbackStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var toolCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var session = await client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(BlockedTool, "blocked_tool")], OnPermissionRequest = PermissionHandler.ApproveAll }); DispatchEvent(session, ExternalToolRequested("request-blocking-callback")); await toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); var dispatchTask = Task.Run(() => DispatchEvent(session, new ExternalToolCompletedEvent { Data = new ExternalToolCompletedData { RequestId = "request-blocking-callback" } })); await callbackStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); try { await dispatchTask.WaitAsync(TimeSpan.FromSeconds(5)); } finally { releaseCallback.TrySetResult(); } await toolCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); async Task BlockedTool(CancellationToken cancellationToken) { toolStarted.TrySetResult(); using var registration = cancellationToken.Register(() => { callbackStarted.TrySetResult(); releaseCallback.Task.GetAwaiter().GetResult(); }); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return "unreachable"; } catch (OperationCanceledException) { toolCancelled.TrySetResult(); throw; } } } [Fact] public async Task ForceStopAsync_Cancels_Blocked_Tool_When_Cancellation_Callback_Throws() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var toolCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var session = await client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(BlockedTool, "blocked_tool")], OnPermissionRequest = PermissionHandler.ApproveAll }); DispatchEvent(session, ExternalToolRequested("request-force-stop")); await toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); await client.ForceStopAsync(); await toolCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); async Task BlockedTool(CancellationToken cancellationToken) { toolStarted.TrySetResult(); using var registration = cancellationToken.Register( () => throw new InvalidOperationException("cancellation callback failed")); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return "unreachable"; } catch (OperationCanceledException) { toolCancelled.TrySetResult(); throw; } } } [Fact] public async Task ForceStopAsync_Does_Not_Start_Late_External_Tool() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var session = await client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(Tool, "late_tool")], OnPermissionRequest = PermissionHandler.ApproveAll }); await client.ForceStopAsync(); DispatchEvent(session, ExternalToolRequested("request-after-force-stop", "late_tool")); Assert.False(toolStarted.Task.IsCompleted); string Tool() { toolStarted.TrySetResult(); return "unexpected"; } } [Fact] public async Task ConnectionClose_Cancels_Blocked_Tool_Delegate() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var toolCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var session = await client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(BlockedTool, "blocked_tool")], OnPermissionRequest = PermissionHandler.ApproveAll }); DispatchEvent(session, ExternalToolRequested("request-connection-close")); await toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); server.CloseConnection(); await toolCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); await client.ForceStopAsync(); async Task BlockedTool(CancellationToken cancellationToken) { toolStarted.TrySetResult(); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return "unreachable"; } catch (OperationCanceledException) { toolCancelled.TrySetResult(); throw; } } } [Fact] public async Task DisposeAsync_Cancels_Blocked_Tool_Delegate() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var toolCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var session = await client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(BlockedTool, "blocked_tool")], OnPermissionRequest = PermissionHandler.ApproveAll }); DispatchEvent(session, ExternalToolRequested("request-2")); await toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); await session.DisposeAsync(); await toolCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); async Task BlockedTool(CancellationToken cancellationToken) { toolStarted.TrySetResult(); try { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return "unreachable"; } catch (OperationCanceledException) { toolCancelled.TrySetResult(); throw; } } } private static ExternalToolRequestedEvent ExternalToolRequested(string requestId, string toolName = "blocked_tool") => new() { Data = new ExternalToolRequestedData { RequestId = requestId, SessionId = "session-1", ToolCallId = "tool-call-1", ToolName = toolName } }; [Fact] public async Task Generated_Session_Rpc_Throws_When_Session_Disposed() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); await session.DisposeAsync(); await Assert.ThrowsAsync(() => session.Rpc.Model.GetCurrentAsync()); } [Fact] public async Task SendAsync_MessageSource_Is_Omitted_By_Default() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig()); var options = new MessageOptions { Prompt = "User input" }; Assert.Null(options.Source); Assert.Null(options.Clone().Source); await session.SendAsync(options); await session.SendAsync("More user input"); var requests = server.Requests.Where(request => request.Method == "session.send").ToArray(); Assert.Equal(2, requests.Length); Assert.All(requests, request => AssertMessageSource(request.Params, null)); } [Theory] [MemberData(nameof(SerializationTests.MessageSources), MemberType = typeof(SerializationTests))] public async Task SendAsync_MessageSource_Preserves_Other_Options(MessageSource? source, string? wireSource) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig()); var options = new MessageOptions { Prompt = "Background context", Source = source }; Assert.Equal("message-1", await session.SendAsync(options)); var request = Assert.Single(server.Requests, request => request.Method == "session.send").Params; AssertMessageSource(request, wireSource); foreach (var property in new[] { "mode", "agentMode", "attachments", "displayPrompt", "requestHeaders" }) { Assert.False(request.TryGetProperty(property, out _)); } using var activity = new Activity("message-source-test").SetIdFormat(ActivityIdFormat.W3C); activity.TraceStateString = "test=message-source"; activity.Start(); foreach (var mode in new[] { "enqueue", "immediate" }) { server.ClearRequests(); options.Mode = mode; options.AgentMode = AgentMode.Plan; options.DisplayPrompt = "Background update"; options.Attachments = [new AttachmentFile { Path = "/context.txt", DisplayName = "context.txt" }]; options.RequestHeaders = new Dictionary { ["X-Test"] = "source-parity" }; Assert.Equal("message-1", await session.SendAsync(options)); request = Assert.Single(server.Requests, request => request.Method == "session.send").Params; AssertMessageSource(request, wireSource); Assert.Equal(session.SessionId, request.GetProperty("sessionId").GetString()); Assert.Equal(options.Prompt, request.GetProperty("prompt").GetString()); Assert.Equal(mode, request.GetProperty("mode").GetString()); Assert.Equal("plan", request.GetProperty("agentMode").GetString()); Assert.Equal(options.DisplayPrompt, request.GetProperty("displayPrompt").GetString()); Assert.Equal("source-parity", request.GetProperty("requestHeaders").GetProperty("X-Test").GetString()); var attachment = Assert.Single(request.GetProperty("attachments").EnumerateArray()); Assert.Equal("file", attachment.GetProperty("type").GetString()); Assert.Equal("/context.txt", attachment.GetProperty("path").GetString()); Assert.Equal("context.txt", attachment.GetProperty("displayName").GetString()); Assert.Equal(activity.Id, request.GetProperty("traceparent").GetString()); Assert.Equal(activity.TraceStateString, request.GetProperty("tracestate").GetString()); Assert.Equal(source, options.Source); } } [Theory] [InlineData(null)] [InlineData("user")] [InlineData("system")] [InlineData("agent-Reviewer-7")] public async Task Raw_SendAsync_MessageSource_Remains_Available(string? source) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig()); var result = await session.Rpc.SendAsync("Context", source: source); Assert.Equal("message-1", result.MessageId); AssertMessageSource(Assert.Single(server.Requests, request => request.Method == "session.send").Params, source); } public static IEnumerable MessageSourcesAndOutcomes { get { foreach (var row in SerializationTests.MessageSources) { yield return [row[0], row[1], false, null]; yield return [row[0], row[1], true, null]; } yield return [MessageSource.Agent("Reviewer-7"), "agent-Reviewer-7", false, "enqueue"]; yield return [MessageSource.Agent("Reviewer-7"), "agent-Reviewer-7", true, "enqueue"]; yield return [MessageSource.Agent("Reviewer-7"), "agent-Reviewer-7", false, "immediate"]; yield return [MessageSource.Agent("Reviewer-7"), "agent-Reviewer-7", true, "immediate"]; } } [Theory] [MemberData(nameof(MessageSourcesAndOutcomes))] public async Task SendAndWaitAsync_MessageSource_Completes_On_Idle(MessageSource? source, string? wireSource, bool hasAssistantMessage, string? mode) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig()); var assistantReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var subscription = session.On(_ => assistantReceived.TrySetResult()); var sendTask = session.SendAndWaitAsync(new MessageOptions { Prompt = "Context", Source = source, Mode = mode }); var request = await WaitForRequestAsync(server, "session.send"); AssertMessageSource(request.Params, wireSource); if (mode is null) { Assert.False(request.Params.TryGetProperty("mode", out _)); } else { Assert.Equal(mode, request.Params.GetProperty("mode").GetString()); } if (hasAssistantMessage) { await server.SendSessionEventAsync(session.SessionId, "assistant.message", new() { ["messageId"] = "assistant-1", ["content"] = "Acknowledged" }); await assistantReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); } Assert.False(sendTask.IsCompleted); await server.SendSessionEventAsync(session.SessionId, "session.idle", new()); var result = await sendTask.WaitAsync(TimeSpan.FromSeconds(5)); if (hasAssistantMessage) { Assert.NotNull(result); Assert.Equal("Acknowledged", result.Data.Content); } else { Assert.Null(result); } } [Theory] [MemberData(nameof(MessageSourcesAndOutcomes))] public async Task SendAndWaitAsync_MessageSource_Propagates_Errors(MessageSource? source, string? wireSource, bool rpcError, string? mode) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig()); if (rpcError) { server.FailSessionSend(); } var sendTask = session.SendAndWaitAsync(new MessageOptions { Prompt = "Context", Source = source, Mode = mode }); var request = await WaitForRequestAsync(server, "session.send"); AssertMessageSource(request.Params, wireSource); if (mode is null) { Assert.False(request.Params.TryGetProperty("mode", out _)); } else { Assert.Equal(mode, request.Params.GetProperty("mode").GetString()); } if (rpcError) { var error = await Assert.ThrowsAsync(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5))); Assert.Contains("session send failed", error.Message); } else { await server.SendSessionEventAsync(session.SessionId, "session.error", new() { ["errorType"] = "query", ["message"] = "model request failed" }); var error = await Assert.ThrowsAsync(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal("Session error: model request failed", error.Message); } } public static TheoryData MessageSourcesAndCancellation => new() { { MessageSource.System, false }, { MessageSource.System, true }, { MessageSource.Agent("Reviewer-7"), false }, { MessageSource.Agent("Reviewer-7"), true }, }; [Theory] [MemberData(nameof(MessageSourcesAndCancellation))] public async Task SendAndWaitAsync_MessageSource_Preserves_Timeout_And_Cancellation(MessageSource source, bool cancel) { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig()); using var cancellation = new CancellationTokenSource(); var sendTask = session.SendAndWaitAsync( new MessageOptions { Prompt = "Context", Source = source }, timeout: cancel ? TimeSpan.FromSeconds(30) : TimeSpan.FromMilliseconds(50), cancellationToken: cancellation.Token); var request = await WaitForRequestAsync(server, "session.send"); AssertMessageSource(request.Params, source.Value); if (cancel) { cancellation.Cancel(); var error = await Assert.ThrowsAnyAsync(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5))); Assert.Equal(cancellation.Token, error.CancellationToken); } else { var error = await Assert.ThrowsAsync(() => sendTask.WaitAsync(TimeSpan.FromSeconds(5))); Assert.Contains("SendAndWaitAsync timed out", error.Message); } } private static void AssertMessageSource(JsonElement request, string? source) { if (source is null) { Assert.False(request.TryGetProperty("source", out _)); } else { Assert.Equal(source, request.GetProperty("source").GetString()); } Assert.False(request.TryGetProperty("billable", out _)); Assert.False(request.TryGetProperty("wait", out _)); } [Fact] public async Task SendAndWaitAsync_Skips_Autopilot_Continuation_Idle() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var sendTask = session.SendAndWaitAsync(new MessageOptions { Prompt = "keep going" }); await WaitForRequestAsync(server, "session.send"); var continuationIdleProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var subscription = session.On(idle => { if (idle.Data.Mode == SessionMode.Autopilot) { continuationIdleProcessed.TrySetResult(); } }); DispatchEvent(session, new AssistantMessageEvent { Id = Guid.NewGuid(), Data = new AssistantMessageData { Content = "intermediate", MessageId = "assistant-1" } }); DispatchEvent(session, new SessionIdleEvent { Id = Guid.NewGuid(), Data = new SessionIdleData { Mode = SessionMode.Autopilot } }); await continuationIdleProcessed.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.False(sendTask.IsCompleted); DispatchEvent(session, new AssistantMessageEvent { Id = Guid.NewGuid(), Data = new AssistantMessageData { Content = "final", MessageId = "assistant-2" } }); DispatchEvent(session, new SessionIdleEvent { Id = Guid.NewGuid(), Data = new SessionIdleData { Mode = SessionMode.Interactive } }); var result = await sendTask.WaitAsync(TimeSpan.FromSeconds(5)); Assert.NotNull(result); Assert.Equal("final", result.Data.Content); } [MethodImpl(MethodImplOptions.NoInlining)] private static async Task> CreateDroppedSessionAsync(CopilotClient client) { var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); return new WeakReference(session); } private static void ForceCollect() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } private static void AssertSessionCount(CopilotClient client, int sessions) { Assert.Equal(sessions, GetPrivateDictionaryCount(client, "_sessions")); } private static int GetPrivateDictionaryCount(CopilotClient client, string fieldName) { var field = typeof(CopilotClient).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Field '{fieldName}' was not found."); var dictionary = field.GetValue(client) ?? throw new InvalidOperationException($"Field '{fieldName}' was null."); var count = dictionary.GetType().GetProperty("Count") ?? throw new InvalidOperationException($"Field '{fieldName}' does not expose Count."); return (int)count.GetValue(dictionary)!; } [Fact] public async Task CreateSessionAsync_Serializes_ManagedSettings_Permissions() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); var permissionInvocation = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); await using var session = await client.CreateSessionAsync(new SessionConfig { ManagedSettings = new ManagedSettings { Permissions = new ManagedSettingsPermissions { DisableBypassPermissionsMode = DisableBypassPermissionsModes.Disable, Deny = ["shell(rm*)"], Ask = ["write"], Allow = [] } }, OnPermissionRequest = (_, invocation) => { permissionInvocation.TrySetResult(invocation); return Task.FromResult(PermissionDecision.NoResult()); } }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); Assert.False(request.Params.TryGetProperty("enableManagedSettings", out _)); var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions"); Assert.Equal("disable", permissions.GetProperty("disableBypassPermissionsMode").GetString()); Assert.Equal("shell(rm*)", Assert.Single(permissions.GetProperty("deny").EnumerateArray()).GetString()); Assert.Equal("write", Assert.Single(permissions.GetProperty("ask").EnumerateArray()).GetString()); Assert.Empty(permissions.GetProperty("allow").EnumerateArray()); DispatchEvent(session, new PermissionRequestedEvent { Data = new PermissionRequestedData { PermissionRequest = new PermissionRequest { Kind = "read" }, RequestId = "managed-permission" } }); var invocation = await permissionInvocation.Task.WaitAsync(TimeSpan.FromSeconds(5)); Assert.True(invocation.ManagedSettingsEnabled); } [Fact] public async Task CreateSessionAsync_Serializes_Future_ManagedSettings_Bypass_Mode() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { ManagedSettings = new ManagedSettings { Permissions = new ManagedSettingsPermissions { DisableBypassPermissionsMode = "future-fail-closed-mode" } }, OnPermissionRequest = PermissionHandler.ApproveAll }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions"); Assert.Equal( "future-fail-closed-mode", permissions.GetProperty("disableBypassPermissionsMode").GetString()); } [Fact] public async Task PermissionResponse_Forwards_DecisionContext_As_Sibling_Of_Result() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = (_, _) => Task.FromResult( new PermissionDecisionApproveOnce { DecisionContext = new PermissionDecisionContext { Outcome = PermissionDecisionOutcome.AutoApproved, Source = PermissionDecisionSource.HostPolicy, Surface = PermissionDecisionSurface.Sdk } }) }); DispatchEvent(session, new PermissionRequestedEvent { Data = new PermissionRequestedData { PermissionRequest = new PermissionRequest { Kind = "read" }, RequestId = "req-with-context" } }); var request = await WaitForRequestAsync(server, "session.permissions.handlePendingPermissionRequest"); Assert.True(request.Params.TryGetProperty("decisionContext", out var decisionContext)); Assert.Equal("auto_approved", decisionContext.GetProperty("outcome").GetString()); Assert.Equal("host_policy", decisionContext.GetProperty("source").GetString()); Assert.Equal("sdk", decisionContext.GetProperty("surface").GetString()); var result = request.Params.GetProperty("result"); Assert.Equal("approve-once", result.GetProperty("kind").GetString()); Assert.False(result.TryGetProperty("decisionContext", out _)); } [Fact] public async Task PermissionResponse_Omits_DecisionContext_When_Not_Supplied() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = (_, _) => Task.FromResult(PermissionDecision.ApproveOnce()) }); DispatchEvent(session, new PermissionRequestedEvent { Data = new PermissionRequestedData { PermissionRequest = new PermissionRequest { Kind = "read" }, RequestId = "req-no-context" } }); var request = await WaitForRequestAsync(server, "session.permissions.handlePendingPermissionRequest"); Assert.False(request.Params.TryGetProperty("decisionContext", out _)); var result = request.Params.GetProperty("result"); Assert.Equal("approve-once", result.GetProperty("kind").GetString()); Assert.False(result.TryGetProperty("decisionContext", out _)); } [Fact] public async Task PermissionResponse_Uses_Latest_Context_When_Reassigned() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = (_, _) => { var decision = new PermissionDecisionApproveOnce { DecisionContext = new PermissionDecisionContext { Outcome = PermissionDecisionOutcome.PromptedUser, Source = PermissionDecisionSource.HumanResponse, Surface = PermissionDecisionSurface.Tui } }; decision.DecisionContext = new PermissionDecisionContext { Outcome = PermissionDecisionOutcome.AutoApproved, Source = PermissionDecisionSource.HostPolicy, Surface = PermissionDecisionSurface.Sdk }; return Task.FromResult(decision); } }); DispatchEvent(session, new PermissionRequestedEvent { Data = new PermissionRequestedData { PermissionRequest = new PermissionRequest { Kind = "read" }, RequestId = "req-replace-context" } }); var request = await WaitForRequestAsync(server, "session.permissions.handlePendingPermissionRequest"); var decisionContext = request.Params.GetProperty("decisionContext"); Assert.Equal("auto_approved", decisionContext.GetProperty("outcome").GetString()); Assert.Equal("host_policy", decisionContext.GetProperty("source").GetString()); Assert.Equal("sdk", decisionContext.GetProperty("surface").GetString()); } [Fact] public async Task PermissionResponse_Is_Suppressed_For_NoResult_Even_With_Context() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); var handlerInvoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = (_, _) => { handlerInvoked.TrySetResult(); return Task.FromResult( new PermissionDecisionNoResult { DecisionContext = new PermissionDecisionContext { Outcome = PermissionDecisionOutcome.PromptedUser, Source = PermissionDecisionSource.HumanResponse, Surface = PermissionDecisionSurface.Sdk } }); } }); DispatchEvent(session, new PermissionRequestedEvent { Data = new PermissionRequestedData { PermissionRequest = new PermissionRequest { Kind = "read" }, RequestId = "req-no-result" } }); await handlerInvoked.Task.WaitAsync(TimeSpan.FromSeconds(5)); // Give the send path a chance to (incorrectly) fire before asserting suppression. await Task.Delay(200); Assert.DoesNotContain(server.Requests, request => request.Method == "session.permissions.handlePendingPermissionRequest"); } [Fact] public async Task PermissionResponse_Never_Nests_DecisionContext_Inside_Result() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = (_, _) => Task.FromResult( new PermissionDecisionReject { Feedback = "denied by policy", DecisionContext = new PermissionDecisionContext { Outcome = PermissionDecisionOutcome.AutopilotDenied, Source = PermissionDecisionSource.HostPolicy, Surface = PermissionDecisionSurface.Sdk } }) }); DispatchEvent(session, new PermissionRequestedEvent { Data = new PermissionRequestedData { PermissionRequest = new PermissionRequest { Kind = "read" }, RequestId = "req-reject-context" } }); var request = await WaitForRequestAsync(server, "session.permissions.handlePendingPermissionRequest"); var result = request.Params.GetProperty("result"); Assert.Equal("reject", result.GetProperty("kind").GetString()); Assert.Equal("denied by policy", result.GetProperty("feedback").GetString()); // The context provenance must never be serialized inside the decision itself. Assert.False(result.TryGetProperty("decisionContext", out _)); // It is forwarded as a sibling instead. Assert.True(request.Params.TryGetProperty("decisionContext", out _)); } [Fact] public async Task CreateSessionAsync_Omits_ManagedSettings_When_Unset() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await client.StartAsync(); await using var session = await client.CreateSessionAsync(new SessionConfig { OnPermissionRequest = PermissionHandler.ApproveAll }); var request = Assert.Single(server.Requests, request => request.Method == "session.create"); Assert.False(request.Params.TryGetProperty("managedSettings", out _)); } [Fact] public async Task ResumeSessionAsync_Serializes_ManagedSettings_Permissions() { await using var server = await FakeCopilotServer.StartAsync(); await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); await using var session = await client.ResumeSessionAsync("session-managed", new ResumeSessionConfig { ManagedSettings = new ManagedSettings { Permissions = new ManagedSettingsPermissions { Deny = ["shell(rm*)"] } }, OnPermissionRequest = PermissionHandler.ApproveAll, OnEvent = _ => { } }); var request = Assert.Single(server.Requests, request => request.Method == "session.resume"); var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions"); Assert.Equal("shell(rm*)", Assert.Single(permissions.GetProperty("deny").EnumerateArray()).GetString()); } private static void DispatchEvent(CopilotSession session, SessionEvent evt) { var method = typeof(CopilotSession).GetMethod("DispatchEvent", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("DispatchEvent method was not found."); method.Invoke(session, [evt]); } private static async Task WaitForRequestAsync(FakeCopilotServer server, string method) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) { var request = server.Requests.FirstOrDefault(request => request.Method == method); if (request is not null) { return request; } await Task.Delay(20, CancellationToken.None); } throw new TimeoutException($"Timed out waiting for RPC method '{method}'."); } private static async Task ReplaceConnectionCliProcessAsync(CopilotClient client, Process process) { var field = typeof(CopilotClient).GetField("_connectionTask", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("_connectionTask field was not found."); var connectionTask = (Task)field.GetValue(client)!; await connectionTask; var resultProperty = connectionTask.GetType().GetProperty(nameof(Task.Result)) ?? throw new InvalidOperationException("Connection task result property was not found."); var connection = resultProperty.GetValue(connectionTask)!; var connectionType = connection.GetType(); var rpc = connectionType.GetProperty("Rpc")!.GetValue(connection); var networkStream = connectionType.GetProperty("NetworkStream")!.GetValue(connection); var constructor = connectionType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(); var updatedConnection = constructor.Invoke([rpc, process, networkStream, null, null]); var fromResult = typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(connectionType); field.SetValue(client, fromResult.Invoke(null, [updatedConnection])); } private static Process StartExitedProcess() { var startInfo = OperatingSystem.IsWindows() ? new ProcessStartInfo(Environment.GetEnvironmentVariable("COMSPEC") ?? "cmd.exe", "/c exit 0") : new ProcessStartInfo("/bin/sh", "-c \"exit 0\""); startInfo.UseShellExecute = false; var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start test process."); process.WaitForExit(); return process; } private sealed class FakeCopilotServer : IAsyncDisposable { private readonly TcpListener _listener; private readonly CancellationTokenSource _cts = new(); private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly TaskCompletionSource _destroyStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _allowDestroy = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task _serverTask; private readonly List _requests = []; private readonly object _requestsLock = new(); private readonly ConcurrentDictionary> _pendingRequests = new(); private NetworkStream? _stream; private int _nextRequestId; private string? _lastSessionId; private bool _delayDestroy; private bool _failRuntimeShutdown; private bool _failSessionCreate; private bool _failSessionSend; private FakeCopilotServer(TcpListener listener) { _listener = listener; _serverTask = RunAsync(); } public string Url { get { var endpoint = (IPEndPoint)_listener.LocalEndpoint; return $"http://127.0.0.1:{endpoint.Port}"; } } public static Task StartAsync() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); return Task.FromResult(new FakeCopilotServer(listener)); } public Task DestroyStarted => _destroyStarted.Task; public int RuntimeShutdownCount { get; private set; } public IReadOnlyList Requests { get { lock (_requestsLock) { return _requests.ToArray(); } } } public void ClearRequests() { lock (_requestsLock) { _requests.Clear(); } } public void DelayDestroy() { _delayDestroy = true; } public void CompleteDestroy() { _allowDestroy.TrySetResult(); } public void FailRuntimeShutdown() { _failRuntimeShutdown = true; } public void FailSessionCreate() { _failSessionCreate = true; } public void FailSessionSend() { _failSessionSend = true; } public void CloseConnection() { _stream?.Dispose(); } public async Task SendRequestAsync(string method, Dictionary parameters) { var stream = _stream ?? throw new InvalidOperationException("Client is not connected."); var id = Interlocked.Increment(ref _nextRequestId); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (!_pendingRequests.TryAdd(id, completion)) { throw new InvalidOperationException("Failed to track callback request."); } await WriteMessageAsync(stream, new Dictionary { ["jsonrpc"] = "2.0", ["id"] = id, ["method"] = method, ["params"] = parameters }, _cts.Token); return await completion.Task.WaitAsync(_cts.Token); } public Task SendSessionEventAsync(string sessionId, string type, Dictionary data) { var stream = _stream ?? throw new InvalidOperationException("Client is not connected."); return WriteMessageAsync(stream, new Dictionary { ["jsonrpc"] = "2.0", ["method"] = "session.event", ["params"] = new Dictionary { ["sessionId"] = sessionId, ["event"] = new Dictionary { ["id"] = Guid.NewGuid().ToString(), ["timestamp"] = DateTimeOffset.UtcNow.ToString("O"), ["parentId"] = null, ["type"] = type, ["data"] = data } } }, _cts.Token); } public async ValueTask DisposeAsync() { _allowDestroy.TrySetResult(); _cts.Cancel(); _listener.Stop(); try { await _serverTask; } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) { } _cts.Dispose(); _writeLock.Dispose(); } private async Task RunAsync() { using var tcpClient = await _listener.AcceptTcpClientAsync(_cts.Token); using var stream = tcpClient.GetStream(); _stream = stream; while (!_cts.Token.IsCancellationRequested) { using var message = await ReadMessageAsync(stream, _cts.Token); if (message is null) { return; } var root = message.RootElement; if (root.TryGetProperty("method", out _)) { await HandleRequestAsync(stream, root, _cts.Token); continue; } if (root.TryGetProperty("id", out var responseId) && responseId.TryGetInt32(out var id) && _pendingRequests.TryRemove(id, out var completion)) { if (root.TryGetProperty("error", out var error)) { completion.TrySetException(new InvalidOperationException( error.GetProperty("message").GetString())); } else { completion.TrySetResult(root.GetProperty("result").Clone()); } } } } private async Task HandleRequestAsync(Stream stream, JsonElement request, CancellationToken cancellationToken) { if (!request.TryGetProperty("id", out var idElement)) { return; } var id = idElement.Clone(); var method = request.GetProperty("method").GetString(); if (method == "runtime.shutdown" && _failRuntimeShutdown) { RuntimeShutdownCount++; await WriteMessageAsync(stream, new Dictionary { ["jsonrpc"] = "2.0", ["id"] = id, ["error"] = new Dictionary { ["code"] = -32000, ["message"] = "runtime shutdown failed" } }, cancellationToken); return; } var paramsElement = request.TryGetProperty("params", out var rawParams) ? rawParams.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); lock (_requestsLock) { _requests.Add(new RpcRequestRecord(method!, paramsElement)); } if (method == "session.create" && _failSessionCreate) { _failSessionCreate = false; await WriteMessageAsync(stream, new Dictionary { ["jsonrpc"] = "2.0", ["id"] = id, ["error"] = new Dictionary { ["code"] = -32000, ["message"] = "session create failed" } }, cancellationToken); return; } if (method == "session.send" && _failSessionSend) { _failSessionSend = false; await WriteMessageAsync(stream, new Dictionary { ["jsonrpc"] = "2.0", ["id"] = id, ["error"] = new Dictionary { ["code"] = -32000, ["message"] = "session send failed" } }, cancellationToken); return; } object? result = method switch { "connect" => new Dictionary { ["ok"] = true, ["protocolVersion"] = 3, ["version"] = "test" }, "session.create" => CreateSessionResult(request), "session.resume" => CreateSessionResult(request), "session.eventLog.registerInterest" => new Dictionary { ["id"] = "interest-1" }, "session.send" => new Dictionary { ["messageId"] = "message-1" }, "session.options.update" => new Dictionary { ["success"] = true }, "session.mcp.oauth.handlePendingRequest" => new Dictionary { ["success"] = true }, "session.permissions.handlePendingPermissionRequest" => new Dictionary { ["success"] = true }, "session.tools.handlePendingToolCall" => new Dictionary { ["success"] = true }, "session.model.switchTo" => new Dictionary { ["modelId"] = "auto" }, "session.model.switchAutoTier" => new Dictionary { ["status"] = "pending", ["effectiveAutoTier"] = "balance" }, "session.delete" => new Dictionary { ["success"] = true }, "session.detach" => await DetachSessionAsync(cancellationToken), "runtime.shutdown" => HandleRuntimeShutdown(), _ => throw new InvalidOperationException($"Unexpected RPC method '{method}'.") }; await WriteMessageAsync(stream, new Dictionary { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = result }, cancellationToken); } private Dictionary CreateSessionResult(JsonElement request) { string? sessionId = null; if (request.TryGetProperty("params", out var paramsProp) && paramsProp.ValueKind == JsonValueKind.Object && paramsProp.TryGetProperty("sessionId", out var sidProp) && sidProp.ValueKind == JsonValueKind.String) { sessionId = sidProp.GetString(); } if (string.IsNullOrEmpty(sessionId)) { sessionId = Guid.NewGuid().ToString(); } _lastSessionId = sessionId; return new Dictionary { ["sessionId"] = _lastSessionId, ["workspacePath"] = null, ["capabilities"] = null }; } private async Task> DetachSessionAsync(CancellationToken cancellationToken) { if (_delayDestroy) { _destroyStarted.TrySetResult(); await _allowDestroy.Task.WaitAsync(cancellationToken); } return new Dictionary { ["success"] = true }; } private Dictionary HandleRuntimeShutdown() { RuntimeShutdownCount++; return []; } private async Task WriteMessageAsync(Stream stream, object payload, CancellationToken cancellationToken) { using var bodyStream = new MemoryStream(); using (var writer = new Utf8JsonWriter(bodyStream)) { WriteJsonValue(writer, payload); } var body = bodyStream.ToArray(); var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); await _writeLock.WaitAsync(cancellationToken); try { await stream.WriteAsync(header, cancellationToken); await stream.WriteAsync(body, cancellationToken); await stream.FlushAsync(cancellationToken); } finally { _writeLock.Release(); } } private static void WriteJsonValue(Utf8JsonWriter writer, object? value) { switch (value) { case null: writer.WriteNullValue(); break; case string stringValue: writer.WriteStringValue(stringValue); break; case bool boolValue: writer.WriteBooleanValue(boolValue); break; case int intValue: writer.WriteNumberValue(intValue); break; case long longValue: writer.WriteNumberValue(longValue); break; case JsonElement jsonElement: jsonElement.WriteTo(writer); break; case Dictionary dictionary: writer.WriteStartObject(); foreach (var (propertyName, propertyValue) in dictionary) { writer.WritePropertyName(propertyName); WriteJsonValue(writer, propertyValue); } writer.WriteEndObject(); break; case object?[] array: writer.WriteStartArray(); foreach (var item in array) { WriteJsonValue(writer, item); } writer.WriteEndArray(); break; default: throw new InvalidOperationException($"Unexpected JSON value type '{value.GetType().Name}'."); } } private static async Task ReadMessageAsync(Stream stream, CancellationToken cancellationToken) { var headerBytes = new List(); while (true) { var value = await ReadByteAsync(stream, cancellationToken); if (value < 0) { return null; } headerBytes.Add((byte)value); var count = headerBytes.Count; if (count >= 4 && headerBytes[count - 4] == '\r' && headerBytes[count - 3] == '\n' && headerBytes[count - 2] == '\r' && headerBytes[count - 1] == '\n') { break; } } var header = Encoding.ASCII.GetString([.. headerBytes]); var contentLength = header .Split(["\r\n"], StringSplitOptions.RemoveEmptyEntries) .Select(line => line.Split(':', 2)) .Where(parts => parts.Length == 2 && parts[0].Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) .Select(parts => int.Parse(parts[1].Trim(), System.Globalization.CultureInfo.InvariantCulture)) .Single(); var body = new byte[contentLength]; var offset = 0; while (offset < body.Length) { var read = await stream.ReadAsync(body.AsMemory(offset, body.Length - offset), cancellationToken); if (read == 0) { return null; } offset += read; } return JsonDocument.Parse(body); } private static async Task ReadByteAsync(Stream stream, CancellationToken cancellationToken) { var buffer = new byte[1]; var read = await stream.ReadAsync(buffer, cancellationToken); return read == 0 ? -1 : buffer[0]; } } } #endif