diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3217fbcdc7..b7b1f4b9f9 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -23618,6 +23618,8 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(HistorySummarizeForHandoffResult))] [JsonSerializable(typeof(HistoryTruncateRequest))] [JsonSerializable(typeof(HistoryTruncateResult))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(IList))] [JsonSerializable(typeof(InstalledPlugin))] [JsonSerializable(typeof(InstalledPluginInfo))] [JsonSerializable(typeof(InstructionDiscoveryPath))] diff --git a/dotnet/test/E2E/ClientOptionsE2ETests.cs b/dotnet/test/E2E/ClientOptionsE2ETests.cs index c2f16a042b..d86b6e477c 100644 --- a/dotnet/test/E2E/ClientOptionsE2ETests.cs +++ b/dotnet/test/E2E/ClientOptionsE2ETests.cs @@ -219,6 +219,205 @@ public async Task Should_Forward_Granular_Multitenancy_Fields_In_Create_Wire_Req await session.DisposeAsync(); } + [Fact] + public async Task Should_Forward_Advanced_Session_Options_In_Create_Wire_Request() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + var outputDirectory = Path.Join(Ctx.WorkDir, "large-output-create"); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(path: cliPath, args: ["--capture-file", capturePath]), + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + var session = await client.CreateSessionAsync(new SessionConfig + { + ClientName = "advanced-create-client", + Model = "claude-sonnet-4.5", + ReasoningEffort = "medium", + ReasoningSummary = ReasoningSummary.Detailed, + ContextTier = ContextTier.LongContext, + EnableCitations = true, + Capi = new CapiSessionOptions { EnableWebSocketResponses = false }, + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, + CustomAgents = + [ + new CustomAgentConfig + { + Name = "agent-one", + DisplayName = "Agent One", + Description = "Handles agent-one tasks.", + Prompt = "Be agent one.", + Tools = ["view"], + Infer = true, + Skills = ["create-skill"], + Model = "claude-haiku-4.5", + }, + ], + DefaultAgent = new DefaultAgentConfig { ExcludedTools = ["edit"] }, + Agent = "agent-one", + SkillDirectories = ["skills-create"], + DisabledSkills = ["disabled-create-skill"], + PluginDirectories = ["plugins-create"], + InfiniteSessions = new InfiniteSessionConfig + { + Enabled = false, + BackgroundCompactionThreshold = 0.5, + BufferExhaustionThreshold = 0.9, + }, + LargeOutput = new LargeToolOutputConfig + { + Enabled = true, + MaxSizeBytes = 4096, + OutputDirectory = outputDirectory, + }, + Memory = new MemoryConfiguration { Enabled = true }, + GitHubToken = "session-create-token", + RemoteSession = GitHub.Copilot.Rpc.RemoteSessionMode.Export, + Cloud = new CloudSessionOptions + { + Repository = new CloudSessionRepository + { + Owner = "github", + Name = "copilot-sdk", + Branch = "main", + }, + }, + EnableMcpApps = true, + RequestCanvasRenderer = true, + RequestExtensions = true, + ExtensionSdkPath = "custom-extension-sdk", + ExtensionInfo = new ExtensionInfo { Source = "dotnet-sdk-tests", Name = "advanced-create-extension" }, + Canvases = + [ + new CanvasDeclaration + { + Id = "advanced-create-canvas", + DisplayName = "Advanced Create Canvas", + Description = "Covers create-time canvas options.", + }, + ], + Providers = + [ + new NamedProviderConfig + { + Name = "create-provider", + Type = "openai", + WireApi = "responses", + BaseUrl = "https://create-provider.example.test/v1", + ApiKey = "create-provider-key", + Headers = new Dictionary { ["X-Create-Provider"] = "yes" }, + }, + ], + Models = + [ + new ProviderModelConfig + { + Provider = "create-provider", + Id = "create-model", + Name = "Create Model", + ModelId = "claude-sonnet-4.5", + WireModel = "create-wire-model", + MaxContextWindowTokens = 12_000, + MaxPromptTokens = 10_000, + MaxOutputTokens = 2_000, + }, + ], + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var createRequest = GetCapturedRequestParams(capture.RootElement, "session.create"); + Assert.Equal("advanced-create-client", createRequest.GetProperty("clientName").GetString()); + Assert.Equal("claude-sonnet-4.5", createRequest.GetProperty("model").GetString()); + Assert.Equal("medium", createRequest.GetProperty("reasoningEffort").GetString()); + Assert.Equal("detailed", createRequest.GetProperty("reasoningSummary").GetString()); + Assert.Equal("long_context", createRequest.GetProperty("contextTier").GetString()); + Assert.True(createRequest.GetProperty("enableCitations").GetBoolean()); + Assert.False(createRequest.GetProperty("capi").GetProperty("enableWebSocketResponses").GetBoolean()); + Assert.Equal("persistent", createRequest.GetProperty("mcpOAuthTokenStorage").GetString()); + Assert.Equal("agent-one", createRequest.GetProperty("agent").GetString()); + Assert.Equal("edit", createRequest.GetProperty("defaultAgent").GetProperty("excludedTools")[0].GetString()); + Assert.Equal("agent-one", createRequest.GetProperty("customAgents")[0].GetProperty("name").GetString()); + Assert.Equal("plugins-create", createRequest.GetProperty("pluginDirectories")[0].GetString()); + Assert.Equal("disabled-create-skill", createRequest.GetProperty("disabledSkills")[0].GetString()); + Assert.False(createRequest.GetProperty("infiniteSessions").GetProperty("enabled").GetBoolean()); + Assert.True(createRequest.GetProperty("largeOutput").GetProperty("enabled").GetBoolean()); + Assert.Equal(4096, createRequest.GetProperty("largeOutput").GetProperty("maxSizeBytes").GetInt64()); + Assert.Equal(outputDirectory, createRequest.GetProperty("largeOutput").GetProperty("outputDir").GetString()); + Assert.True(createRequest.GetProperty("memory").GetProperty("enabled").GetBoolean()); + Assert.Equal("session-create-token", createRequest.GetProperty("gitHubToken").GetString()); + Assert.Equal("export", createRequest.GetProperty("remoteSession").GetString()); + Assert.Equal("github", createRequest.GetProperty("cloud").GetProperty("repository").GetProperty("owner").GetString()); + Assert.True(createRequest.GetProperty("requestMcpApps").GetBoolean()); + Assert.True(createRequest.GetProperty("requestCanvasRenderer").GetBoolean()); + Assert.True(createRequest.GetProperty("requestExtensions").GetBoolean()); + Assert.Equal("custom-extension-sdk", createRequest.GetProperty("extensionSdkPath").GetString()); + Assert.Equal("advanced-create-extension", createRequest.GetProperty("extensionInfo").GetProperty("name").GetString()); + Assert.Equal("advanced-create-canvas", createRequest.GetProperty("canvases")[0].GetProperty("id").GetString()); + Assert.Equal("create-provider", createRequest.GetProperty("providers")[0].GetProperty("name").GetString()); + Assert.Equal("responses", createRequest.GetProperty("providers")[0].GetProperty("wireApi").GetString()); + Assert.Equal("create-model", createRequest.GetProperty("models")[0].GetProperty("id").GetString()); + Assert.Equal(12000, createRequest.GetProperty("models")[0].GetProperty("maxContextWindowTokens").GetInt32()); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Should_Forward_Singular_Provider_Options_In_Create_Wire_Request() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(path: cliPath, args: ["--capture-file", capturePath]), + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-sonnet-4.5", + Provider = new ProviderConfig + { + Type = "azure", + WireApi = "responses", + Transport = "http", + BaseUrl = "https://azure-provider.example.test/openai", + ApiKey = "provider-api-key", + BearerToken = "provider-bearer-token", + Azure = new AzureOptions { ApiVersion = "2024-02-15-preview" }, + Headers = new Dictionary { ["X-Provider-Wire"] = "yes" }, + ModelId = "claude-sonnet-4.5", + WireModel = "azure-deployment", + MaxPromptTokens = 8192, + MaxOutputTokens = 1024, + }, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var provider = GetCapturedRequestParams(capture.RootElement, "session.create").GetProperty("provider"); + Assert.Equal("azure", provider.GetProperty("type").GetString()); + Assert.Equal("responses", provider.GetProperty("wireApi").GetString()); + Assert.Equal("http", provider.GetProperty("transport").GetString()); + Assert.Equal("https://azure-provider.example.test/openai", provider.GetProperty("baseUrl").GetString()); + Assert.Equal("provider-api-key", provider.GetProperty("apiKey").GetString()); + Assert.Equal("provider-bearer-token", provider.GetProperty("bearerToken").GetString()); + Assert.Equal("2024-02-15-preview", provider.GetProperty("azure").GetProperty("apiVersion").GetString()); + Assert.Equal("yes", provider.GetProperty("headers").GetProperty("X-Provider-Wire").GetString()); + Assert.Equal("claude-sonnet-4.5", provider.GetProperty("modelId").GetString()); + Assert.Equal("azure-deployment", provider.GetProperty("wireModel").GetString()); + Assert.Equal(8192, provider.GetProperty("maxPromptTokens").GetInt32()); + Assert.Equal(1024, provider.GetProperty("maxOutputTokens").GetInt32()); + + await session.DisposeAsync(); + } + [Fact] public async Task Should_Apply_Empty_Mode_Defaults_To_CreateSession_Wire_Request() { @@ -411,6 +610,88 @@ public async Task Should_Forward_Granular_Multitenancy_Fields_In_Resume_Wire_Req await session.DisposeAsync(); } + [Fact] + public async Task Should_Forward_Advanced_Session_Options_In_Resume_Wire_Request() + { + var (cliPath, capturePath) = await CreateFakeCliCaptureAsync(); + var outputDirectory = Path.Join(Ctx.WorkDir, "large-output-resume"); + using var canvasInput = JsonDocument.Parse("{\"start\":41}"); + + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + Connection = RuntimeConnection.ForStdio(path: cliPath, args: ["--capture-file", capturePath]), + UseLoggedInUser = false, + }); + + await client.StartAsync(); + + var session = await client.ResumeSessionAsync("advanced-resume-session", new ResumeSessionConfig + { + ClientName = "advanced-resume-client", + Model = "claude-haiku-4.5", + ReasoningEffort = "low", + ReasoningSummary = ReasoningSummary.None, + ContextTier = ContextTier.Default, + SuppressResumeEvent = true, + ContinuePendingWork = true, + McpOAuthTokenStorage = McpOAuthTokenStorageMode.Persistent, + PluginDirectories = ["plugins-resume"], + LargeOutput = new LargeToolOutputConfig + { + Enabled = false, + MaxSizeBytes = 2048, + OutputDirectory = outputDirectory, + }, + Memory = new MemoryConfiguration { Enabled = false }, + RemoteSession = GitHub.Copilot.Rpc.RemoteSessionMode.On, + OpenCanvases = + [ + new GitHub.Copilot.Rpc.OpenCanvasInstance + { + CanvasId = "resume-canvas", + ExtensionId = "dotnet-sdk-tests/resume-extension", + ExtensionName = "Resume Extension", + InstanceId = "resume-canvas-1", + Input = canvasInput.RootElement.Clone(), + Status = "ready", + Title = "Resume Canvas", + Url = "https://example.com/resume-canvas", + }, + ], + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath)); + var resumeRequest = GetCapturedRequestParams(capture.RootElement, "session.resume"); + Assert.Equal("advanced-resume-session", resumeRequest.GetProperty("sessionId").GetString()); + Assert.Equal("advanced-resume-client", resumeRequest.GetProperty("clientName").GetString()); + Assert.Equal("claude-haiku-4.5", resumeRequest.GetProperty("model").GetString()); + Assert.Equal("low", resumeRequest.GetProperty("reasoningEffort").GetString()); + Assert.Equal("none", resumeRequest.GetProperty("reasoningSummary").GetString()); + Assert.Equal("default", resumeRequest.GetProperty("contextTier").GetString()); + Assert.True(resumeRequest.GetProperty("disableResume").GetBoolean()); + Assert.True(resumeRequest.GetProperty("continuePendingWork").GetBoolean()); + Assert.Equal("persistent", resumeRequest.GetProperty("mcpOAuthTokenStorage").GetString()); + Assert.Equal("plugins-resume", resumeRequest.GetProperty("pluginDirectories")[0].GetString()); + Assert.False(resumeRequest.GetProperty("largeOutput").GetProperty("enabled").GetBoolean()); + Assert.Equal(2048, resumeRequest.GetProperty("largeOutput").GetProperty("maxSizeBytes").GetInt64()); + Assert.Equal(outputDirectory, resumeRequest.GetProperty("largeOutput").GetProperty("outputDir").GetString()); + Assert.False(resumeRequest.GetProperty("memory").GetProperty("enabled").GetBoolean()); + Assert.Equal("on", resumeRequest.GetProperty("remoteSession").GetString()); + + var openCanvas = resumeRequest.GetProperty("openCanvases")[0]; + Assert.Equal("resume-canvas", openCanvas.GetProperty("canvasId").GetString()); + Assert.Equal("dotnet-sdk-tests/resume-extension", openCanvas.GetProperty("extensionId").GetString()); + Assert.Equal("Resume Extension", openCanvas.GetProperty("extensionName").GetString()); + Assert.Equal("resume-canvas-1", openCanvas.GetProperty("instanceId").GetString()); + Assert.Equal(41, openCanvas.GetProperty("input").GetProperty("start").GetInt32()); + Assert.Equal("ready", openCanvas.GetProperty("status").GetString()); + Assert.Equal("Resume Canvas", openCanvas.GetProperty("title").GetString()); + Assert.Equal("https://example.com/resume-canvas", openCanvas.GetProperty("url").GetString()); + + await session.DisposeAsync(); + } + [Fact] public async Task Should_Apply_Empty_Mode_Defaults_To_ResumeSession_Wire_Request() { diff --git a/dotnet/test/E2E/McpOAuthE2ETests.cs b/dotnet/test/E2E/McpOAuthE2ETests.cs index 2bea715b7f..f101bbc31b 100644 --- a/dotnet/test/E2E/McpOAuthE2ETests.cs +++ b/dotnet/test/E2E/McpOAuthE2ETests.cs @@ -70,6 +70,57 @@ public async Task Should_Satisfy_MCP_OAuth_Using_Host_Provided_Token() Assert.Contains(requests, request => request.Authorization == $"Bearer {ExpectedToken}"); } + [Fact] + public async Task Should_Resolve_Pending_MCP_OAuth_Request_With_Direct_Rpc() + { + await using var oauthServer = await OAuthMcpServer.StartAsync(ExpectedToken); + var serverName = "oauth-direct-rpc-mcp"; + var authRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHandler = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var session = await CreateSessionAsync(new SessionConfig + { + OnMcpAuthRequest = request => + { + authRequest.TrySetResult(request); + return releaseHandler.Task; + }, + McpServers = new Dictionary + { + [serverName] = new McpHttpServerConfig + { + Url = $"{oauthServer.Url}/mcp", + Tools = ["*"], + }, + }, + }); + + var connected = WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + var request = await authRequest.Task.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.NotEmpty(request.RequestId); + Assert.Equal(serverName, request.ServerName); + Assert.Equal($"{oauthServer.Url}/mcp", request.ServerUrl); + Assert.Equal(McpOauthRequestReason.Initial, request.Reason); + Assert.NotNull(request.WwwAuthenticateParams); + Assert.Equal("mcp.read", request.WwwAuthenticateParams!.Scope); + + var handled = await session.Rpc.Mcp.Oauth.HandlePendingRequestAsync( + request.RequestId, + new McpOauthPendingRequestResponseToken + { + AccessToken = ExpectedToken, + TokenType = "Bearer", + ExpiresIn = 3600, + }); + Assert.True(handled.Success); + + await connected; + var tools = await session.Rpc.Mcp.ListToolsAsync(serverName); + Assert.Contains(tools.Tools, tool => tool.Name == "whoami"); + + releaseHandler.SetResult(McpAuthResult.FromToken(new McpAuthToken { AccessToken = ExpectedToken })); + } + [Fact] public async Task Should_Request_Replacement_Tokens_Across_MCP_OAuth_Lifecycle() { diff --git a/dotnet/test/E2E/RpcServerE2ETests.cs b/dotnet/test/E2E/RpcServerE2ETests.cs index da4a360ddc..1f240af9ee 100644 --- a/dotnet/test/E2E/RpcServerE2ETests.cs +++ b/dotnet/test/E2E/RpcServerE2ETests.cs @@ -126,6 +126,40 @@ public async Task Should_Call_Rpc_Ping_With_Typed_Params_And_Result() Assert.NotEqual(default, result.Timestamp); } + [Fact] + public async Task Should_Reject_Llm_Inference_Response_Frames_For_Missing_Request() + { + await Client.StartAsync(); + + var start = await Client.Rpc.LlmInference.HttpResponseStartAsync( + requestId: "missing-llm-inference-request", + status: 200, + headers: new Dictionary> + { + ["content-type"] = ["text/event-stream"], + }, + statusText: "OK"); + Assert.False(start.Accepted); + + var chunk = await Client.Rpc.LlmInference.HttpResponseChunkAsync( + requestId: "missing-llm-inference-request", + data: "data: {}\n\n", + binary: false, + end: false); + Assert.False(chunk.Accepted); + + var error = await Client.Rpc.LlmInference.HttpResponseChunkAsync( + requestId: "missing-llm-inference-request", + data: string.Empty, + end: true, + error: new GitHub.Copilot.Rpc.LlmInferenceHttpResponseChunkError + { + Code = "missing_request", + Message = "No pending LLM inference request.", + }); + Assert.False(error.Accepted); + } + [Fact] public async Task Should_Call_Rpc_Models_List_With_Typed_Result() { @@ -493,6 +527,44 @@ public async Task Should_Discover_Server_Mcp_And_Skills() Assert.True(discoveredSkill.Enabled); Assert.EndsWith(Path.Join(skillName, "SKILL.md"), discoveredSkill.Path); + var skillPaths = await Client.Rpc.Skills.GetDiscoveryPathsAsync( + projectPaths: [Ctx.WorkDir], + excludeHostSkills: true); + var projectSkillPath = Assert.Single(skillPaths.Paths, path => + PathEquals(Ctx.WorkDir, path.ProjectPath) && path.PreferredForCreation); + Assert.False(string.IsNullOrWhiteSpace(projectSkillPath.Path)); + + var agents = await Client.Rpc.Agents.DiscoverAsync( + projectPaths: [Ctx.WorkDir], + excludeHostAgents: true); + Assert.NotNull(agents.Agents); + Assert.All(agents.Agents, agent => Assert.False(string.IsNullOrWhiteSpace(agent.Name))); + + var agentPaths = await Client.Rpc.Agents.GetDiscoveryPathsAsync( + projectPaths: [Ctx.WorkDir], + excludeHostAgents: true); + var projectAgentPath = Assert.Single(agentPaths.Paths, path => + PathEquals(Ctx.WorkDir, path.ProjectPath) && path.PreferredForCreation); + Assert.False(string.IsNullOrWhiteSpace(projectAgentPath.Path)); + + var instructions = await Client.Rpc.Instructions.DiscoverAsync( + projectPaths: [Ctx.WorkDir], + excludeHostInstructions: true); + Assert.NotNull(instructions.Sources); + Assert.All(instructions.Sources, source => + { + Assert.False(string.IsNullOrWhiteSpace(source.Id)); + Assert.False(string.IsNullOrWhiteSpace(source.Label)); + Assert.False(string.IsNullOrWhiteSpace(source.SourcePath)); + }); + + var instructionPaths = await Client.Rpc.Instructions.GetDiscoveryPathsAsync( + projectPaths: [Ctx.WorkDir], + excludeHostInstructions: true); + Assert.NotEmpty(instructionPaths.Paths); + Assert.Contains(instructionPaths.Paths, path => PathEquals(Ctx.WorkDir, path.ProjectPath)); + Assert.All(instructionPaths.Paths, path => Assert.False(string.IsNullOrWhiteSpace(path.Path))); + try { await Client.Rpc.Skills.Config.SetDisabledSkillsAsync([skillName]); diff --git a/dotnet/test/E2E/RpcServerMiscE2ETests.cs b/dotnet/test/E2E/RpcServerMiscE2ETests.cs index 6d04a35f84..6cb21f75d7 100644 --- a/dotnet/test/E2E/RpcServerMiscE2ETests.cs +++ b/dotnet/test/E2E/RpcServerMiscE2ETests.cs @@ -4,14 +4,15 @@ using GitHub.Copilot; using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; namespace GitHub.Copilot.Test.E2E; /// -/// E2E coverage for the remaining miscellaneous server-scoped RPC methods that were previously -/// untested: user.settings.reload, agentRegistry.spawn, runtime.shutdown, sessions.open, and the +/// E2E coverage for miscellaneous server-scoped RPC methods, including account auth state, +/// user.settings get/set/reload, agentRegistry.spawn, runtime.shutdown, sessions.open, and the /// session-scoped session.extensions.sendAttachmentsToMessage. /// /// Several of these are intentionally exercised at the wiring/guard boundary because the meaningful @@ -32,6 +33,97 @@ public async Task Should_Reload_User_Settings() await Client.Rpc.User.Settings.ReloadAsync(); } + [Fact] + public async Task Should_Get_Set_And_Clear_User_Settings() + { + await Client.StartAsync(); + + var before = await Client.Rpc.User.Settings.GetAsync(); + Assert.NotNull(before.Settings); + Assert.NotEmpty(before.Settings); + Assert.All(before.Settings, setting => + { + Assert.False(string.IsNullOrWhiteSpace(setting.Key)); + Assert.True( + setting.Value.Value.ValueKind != System.Text.Json.JsonValueKind.Undefined + || setting.Value.Default.ValueKind != System.Text.Json.JsonValueKind.Undefined, + $"Setting '{setting.Key}' should expose either a value or a default."); + }); + + var settingToToggle = before.Settings.First(setting => + setting.Value.Value.ValueKind is System.Text.Json.JsonValueKind.True or System.Text.Json.JsonValueKind.False); + var settingKey = settingToToggle.Key; + var toggledValue = settingToToggle.Value.Value.ValueKind != System.Text.Json.JsonValueKind.True; + + var set = await Client.Rpc.User.Settings.SetAsync(ParseSettingJson(settingKey, toggledValue ? "true" : "false")); + Assert.NotNull(set.ShadowedKeys); + Assert.DoesNotContain(settingKey, set.ShadowedKeys); + + await Client.Rpc.User.Settings.ReloadAsync(); + var afterSet = await Client.Rpc.User.Settings.GetAsync(); + var updatedSetting = Assert.Contains(settingKey, afterSet.Settings); + Assert.False(updatedSetting.IsDefault); + Assert.Equal(toggledValue, updatedSetting.Value.GetBoolean()); + + var clear = await Client.Rpc.User.Settings.SetAsync(ParseSettingJson(settingKey, "null")); + Assert.NotNull(clear.ShadowedKeys); + + await Client.Rpc.User.Settings.ReloadAsync(); + var afterClear = await Client.Rpc.User.Settings.GetAsync(); + var clearedSetting = Assert.Contains(settingKey, afterClear.Settings); + Assert.True(clearedSetting.IsDefault); + } + + [Fact] + public async Task Should_Login_List_GetCurrentAuth_And_Logout_Account() + { + var (client, home) = await CreateIsolatedClientAsync(autoInjectGitHubToken: false); + var login = $"rpc-account-{Guid.NewGuid():N}"; + var token = $"rpc-account-token-{Guid.NewGuid():N}"; + + try + { + await Ctx.SetCopilotUserByTokenAsync(token, new CopilotUserConfig( + Login: login, + CopilotPlan: "individual_pro", + Endpoints: new CopilotUserEndpoints(Api: Ctx.ProxyUrl, Telemetry: "https://localhost:1/telemetry"), + AnalyticsTrackingId: "rpc-account-tracking-id")); + + var initial = await client.Rpc.Account.GetCurrentAuthAsync(); + Assert.Null(initial.AuthInfo); + + var loginResult = await client.Rpc.Account.LoginAsync("https://github.com", login, token); + Assert.NotNull(loginResult); + + var current = await client.Rpc.Account.GetCurrentAuthAsync(); + Assert.Null(current.AuthErrors); + var authInfo = Assert.IsType(current.AuthInfo); + Assert.Equal("https://github.com", authInfo.Host); + Assert.Equal(login, authInfo.Login); + + var users = await client.Rpc.Account.GetAllUsersAsync(); + Assert.All(users, user => Assert.False(string.IsNullOrWhiteSpace(user.AuthInfo.Type))); + var account = users.FirstOrDefault(user => + user.AuthInfo is AuthInfoUser userAuth + && string.Equals(userAuth.Login, login, StringComparison.Ordinal)); + if (account is not null) + { + Assert.Equal(token, account.Token); + } + + var logout = await client.Rpc.Account.LogoutAsync(authInfo); + Assert.False(logout.HasMoreUsers); + + var afterLogout = await client.Rpc.Account.GetCurrentAuthAsync(); + Assert.Null(afterLogout.AuthInfo); + } + finally + { + await client.DisposeAsync(); + TryDeleteDirectory(home); + } + } + [Fact] public async Task Should_Report_Agent_Registry_Spawn_Gate_Closed() { @@ -74,7 +166,7 @@ await Harness.TestHelper.WaitForConditionAsync( async () => { try { await client.Rpc.User.Settings.ReloadAsync(); return false; } - catch { return true; } + catch (Exception ex) when (IsExpectedShutdownException(ex)) { return true; } }, timeout: TimeSpan.FromSeconds(15), pollInterval: TimeSpan.FromMilliseconds(100), @@ -82,8 +174,7 @@ await Harness.TestHelper.WaitForConditionAsync( } finally { - try { await client.DisposeAsync(); } - catch { /* process is already gone after shutdown */ } + await DisposeStoppedRuntimeClientAsync(client); } } @@ -103,7 +194,7 @@ public async Task Should_Report_Not_Found_When_Opening_Session_Without_Context() } finally { - try { await client.DisposeAsync(); } catch { /* best-effort */ } + await client.DisposeAsync(); TryDeleteDirectory(home); } } @@ -127,7 +218,8 @@ public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection() /// Creates a started client backed by a throwaway COPILOT_HOME so its session store is empty and /// independent of every other test and of the shared fixture client. /// - private async Task<(CopilotClient Client, string Home)> CreateIsolatedClientAsync() + private async Task<(CopilotClient Client, string Home)> CreateIsolatedClientAsync( + bool autoInjectGitHubToken = true) { var home = Path.Combine(Path.GetTempPath(), "copilot-e2e-misc-home-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(home); @@ -137,8 +229,21 @@ public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection() env["GH_CONFIG_DIR"] = home; env["XDG_CONFIG_HOME"] = home; env["XDG_STATE_HOME"] = home; + if (!autoInjectGitHubToken) + { + env["GH_TOKEN"] = ""; + env["GITHUB_TOKEN"] = ""; + } + + var options = new CopilotClientOptions { Environment = env }; + if (!autoInjectGitHubToken) + { + options.UseLoggedInUser = false; + } - var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + var client = Ctx.CreateClient( + options: options, + autoInjectGitHubToken: autoInjectGitHubToken); await client.StartAsync(); return (client, home); } @@ -152,9 +257,36 @@ private static void TryDeleteDirectory(string path) Directory.Delete(path, recursive: true); } } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Temp directories are reclaimed by the OS; ignore transient locks on cleanup. } } + + private static async Task DisposeStoppedRuntimeClientAsync(CopilotClient client) + { + try + { + await client.DisposeAsync(); + } + catch (Exception ex) when (IsExpectedShutdownException(ex)) + { + // The runtime.shutdown test intentionally stops the process before disposal. + } + } + + private static bool IsExpectedShutdownException(Exception ex) => + ex is OperationCanceledException + or InvalidOperationException + or ObjectDisposedException + or IOException; + + private static System.Text.Json.JsonElement ParseJsonElement(string json) + { + using var document = System.Text.Json.JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static System.Text.Json.JsonElement ParseSettingJson(string key, string valueLiteral) + => ParseJsonElement("{\"" + System.Text.Json.JsonEncodedText.Encode(key) + "\":" + valueLiteral + "}"); } diff --git a/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs b/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs index e969df068c..130d2e4684 100644 --- a/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs @@ -11,8 +11,10 @@ namespace GitHub.Copilot.Test.E2E; /// /// E2E coverage for session-scoped RPC methods that were previously untested: -/// model.list, metadata.activity, permissions.getAllowAll/setAllowAll, plan.readSqlTodos, -/// telemetry.getEngagementId, tools.getCurrentMetadata, and the session-scoped plugins.reload. +/// completions, model.list, metadata.activity/context attribution/heaviest messages, +/// permissions.getAllowAll/setAllowAll, plan.readSqlTodos, provider.add, +/// telemetry.getEngagementId, tools.getCurrentMetadata/updateSubagentSettings, +/// session visibility, and the session-scoped plugins.reload. /// public class RpcSessionStateExtrasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "rpc_session_state_extras", output) @@ -41,6 +43,69 @@ public async Task Should_List_Models_For_Session() Assert.Contains(result.List, model => model.GetRawText().Contains("claude-sonnet-4.5", StringComparison.Ordinal)); } + [Fact] + public async Task Should_Add_Byok_Provider_And_Model_At_Runtime() + { + await using var session = await CreateSessionAsync(); + var providerName = $"sdk-runtime-provider-{Guid.NewGuid():N}"; + var modelId = "sdk-runtime-model"; + var selectionId = $"{providerName}/{modelId}"; + + var added = await session.Rpc.Provider.AddAsync( + providers: + [ + new GitHub.Copilot.Rpc.NamedProviderConfig + { + Name = providerName, + Type = ProviderConfigType.Openai, + WireApi = ProviderConfigWireApi.Completions, + BaseUrl = "https://api.example.test/v1", + ApiKey = "runtime-provider-secret", + Headers = new Dictionary { ["X-SDK-Provider"] = "runtime" }, + }, + ], + models: + [ + new GitHub.Copilot.Rpc.ProviderModelConfig + { + Provider = providerName, + Id = modelId, + Name = "SDK Runtime Model", + ModelId = "claude-sonnet-4.5", + WireModel = "wire-sdk-runtime-model", + MaxContextWindowTokens = 4_096, + MaxPromptTokens = 3_072, + MaxOutputTokens = 1_024, + Capabilities = new GitHub.Copilot.Rpc.ModelCapabilitiesOverride + { + Limits = new GitHub.Copilot.Rpc.ModelCapabilitiesOverrideLimits + { + MaxContextWindowTokens = 4_096, + MaxPromptTokens = 3_072, + MaxOutputTokens = 1_024, + }, + Supports = new GitHub.Copilot.Rpc.ModelCapabilitiesOverrideSupports + { + ReasoningEffort = false, + Vision = false, + }, + }, + }, + ]); + + var addedModel = Assert.Single(added.Models); + var addedModelJson = addedModel.GetRawText(); + Assert.Contains(selectionId, addedModelJson, StringComparison.Ordinal); + Assert.Contains("SDK Runtime Model", addedModelJson, StringComparison.Ordinal); + + var listed = await session.Rpc.Model.ListAsync(); + Assert.Contains(listed.List, model => model.GetRawText().Contains(selectionId, StringComparison.Ordinal)); + + var switched = await session.Rpc.Model.SwitchToAsync(selectionId); + Assert.Equal(selectionId, switched.ModelId); + Assert.Equal(selectionId, (await session.Rpc.Model.GetCurrentAsync()).ModelId); + } + [Fact] public async Task Should_Report_Session_Activity_When_Idle() { @@ -54,6 +119,36 @@ public async Task Should_Report_Session_Activity_When_Idle() Assert.False(activity.Abortable, "Expected a freshly created session to have nothing abortable."); } + [Fact] + public async Task Should_Return_Empty_Completions_When_Host_Does_Not_Provide_Them() + { + await using var session = await CreateSessionAsync(); + + var triggers = await session.Rpc.Completions.GetTriggerCharactersAsync(); + Assert.NotNull(triggers.TriggerCharacters); + Assert.Empty(triggers.TriggerCharacters); + + var completions = await session.Rpc.Completions.RequestAsync("Use @", offset: 5); + Assert.NotNull(completions.Items); + Assert.Empty(completions.Items); + } + + [Fact] + public async Task Should_Report_Visibility_As_Unsynced_For_Local_Session() + { + await using var session = await CreateSessionAsync(); + + var initial = await session.Rpc.Visibility.GetAsync(); + Assert.False(initial.Synced); + Assert.Null(initial.Status); + Assert.Null(initial.ShareUrl); + + var set = await session.Rpc.Visibility.SetAsync(SessionVisibilityStatus.Repo); + Assert.False(set.Synced); + Assert.Null(set.Status); + Assert.Null(set.ShareUrl); + } + [Fact] public async Task Should_Get_And_Set_AllowAll_Permissions() { @@ -127,6 +222,74 @@ public async Task Should_Get_Current_Tool_Metadata_After_Initialization() }); } + [Fact] + public async Task Should_Get_Context_Attribution_And_Heaviest_Messages_After_Turn() + { + await using var session = await CreateSessionAsync(); + + var answer = await session.SendAndWaitAsync(new MessageOptions + { + Prompt = "Say CONTEXT_METADATA_OK exactly.", + }); + Assert.Contains("CONTEXT_METADATA_OK", answer?.Data.Content ?? string.Empty, StringComparison.Ordinal); + + var attribution = await session.Rpc.Metadata.GetContextAttributionAsync(); + var contextAttribution = Assert.IsType( + attribution.ContextAttribution); + Assert.True(contextAttribution.TotalTokens > 0); + Assert.True(contextAttribution.Compactions.Count >= 0); + Assert.NotEmpty(contextAttribution.Entries); + Assert.All(contextAttribution.Entries, entry => + { + Assert.False(string.IsNullOrWhiteSpace(entry.Id)); + Assert.False(string.IsNullOrWhiteSpace(entry.Kind)); + Assert.False(string.IsNullOrWhiteSpace(entry.Label)); + Assert.True(entry.Tokens >= 0); + if (entry.Attributes is not null) + { + Assert.All(entry.Attributes, attribute => Assert.False(string.IsNullOrWhiteSpace(attribute.Key))); + } + }); + + var heaviest = await session.Rpc.Metadata.GetContextHeaviestMessagesAsync(limit: 2); + Assert.True(heaviest.TotalTokens > 0); + Assert.NotNull(heaviest.Messages); + Assert.True(heaviest.Messages.Count <= 2); + Assert.All(heaviest.Messages, message => + { + Assert.False(string.IsNullOrWhiteSpace(message.Id)); + Assert.False(string.IsNullOrWhiteSpace(message.Label)); + Assert.False(string.IsNullOrWhiteSpace(message.Role)); + Assert.True(message.Tokens > 0); + }); + } + + [Fact] + public async Task Should_Update_And_Clear_Live_Subagent_Settings() + { + await using var session = await CreateSessionAsync(); + + var update = await session.Rpc.Tools.UpdateSubagentSettingsAsync(new UpdateSubagentSettingsRequestSubagents + { + Agents = new Dictionary + { + ["general-purpose"] = new() + { + Model = "claude-sonnet-4.5", + EffortLevel = "high", + ContextTier = SubagentSettingsEntryContextTier.Default, + }, + }, + DisabledSubagents = ["explore"], + MaxConcurrency = 2, + MaxDepth = 1, + }); + Assert.NotNull(update); + + var clear = await session.Rpc.Tools.UpdateSubagentSettingsAsync(); + Assert.NotNull(clear); + } + [Fact] public async Task Should_Reload_Session_Plugins() { diff --git a/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs b/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs index fbb2892974..9b9738a2ee 100644 --- a/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs +++ b/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs @@ -209,6 +209,14 @@ public async Task Should_Return_Expected_Results_For_Missing_Pending_Handler_Req response: UIAutoModeSwitchResponse.No); Assert.False(autoModeSwitch.Success); + var sessionLimits = await session.Rpc.Ui.HandlePendingSessionLimitsExhaustedAsync( + requestId: "missing-session-limits-exhausted-request", + response: new UISessionLimitsExhaustedResponse + { + Action = UISessionLimitsExhaustedResponseAction.Cancel, + }); + Assert.False(sessionLimits.Success); + var exitPlanMode = await session.Rpc.Ui.HandlePendingExitPlanModeAsync( requestId: "missing-exit-plan-mode-request", response: new UIExitPlanModeResponse @@ -251,6 +259,19 @@ public async Task Should_Return_Expected_Results_For_Missing_Pending_Handler_Req LocationKey = "missing-location", }); Assert.False(locationApproval.Success); + + var missingHeaders = await session.Rpc.Mcp.Headers.HandlePendingHeadersRefreshRequestAsync( + requestId: "missing-headers-refresh-request", + result: new McpHeadersHandlePendingHeadersRefreshRequestHeaders + { + Headers = new Dictionary { ["X-SDK-Test"] = "missing" }, + }); + Assert.False(missingHeaders.Success); + + var missingNoHeaders = await session.Rpc.Mcp.Headers.HandlePendingHeadersRefreshRequestAsync( + requestId: "missing-headers-refresh-none-request", + result: new McpHeadersHandlePendingHeadersRefreshRequestNone()); + Assert.False(missingNoHeaders.Success); } [Fact] diff --git a/go/client.go b/go/client.go index 5357f28585..5b8dabf8f2 100644 --- a/go/client.go +++ b/go/client.go @@ -730,6 +730,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionSDKPath = config.ExtensionSDKPath + req.ExtensionInfo = config.ExtensionInfo req.ExpAssignments = config.ExpAssignments if len(config.Commands) > 0 { @@ -1092,6 +1093,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.RequestCanvasRenderer = config.RequestCanvasRenderer req.RequestExtensions = config.RequestExtensions req.ExtensionSDKPath = config.ExtensionSDKPath + req.ExtensionInfo = config.ExtensionInfo req.ExpAssignments = config.ExpAssignments if config.OnPermissionRequest != nil { req.RequestPermission = Bool(true) diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go index aa42be1f3a..0d3c802b06 100644 --- a/go/internal/e2e/client_options_e2e_test.go +++ b/go/internal/e2e/client_options_e2e_test.go @@ -10,6 +10,7 @@ import ( copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" ) // Mirrors the E2E portions of dotnet/test/ClientOptionsTests.cs (snapshot category "client_options"). @@ -198,6 +199,315 @@ func TestClientOptionsE2E(t *testing.T) { } }) + t.Run("should forward advanced session creation options to the CLI", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + cliPath := filepath.Join(ctx.WorkDir, "fake-cli-"+randomHex(t)+".js") + capturePath := filepath.Join(ctx.WorkDir, "fake-cli-capture-"+randomHex(t)+".json") + if err := os.WriteFile(cliPath, []byte(fakeStdioCliScript), 0644); err != nil { + t.Fatalf("Failed to write fake CLI script: %v", err) + } + + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Connection = copilot.StdioConnection{Path: cliPath, Args: []string{"--capture-file", capturePath}} + opts.GitHubToken = "advanced-create-client-token" + opts.UseLoggedInUser = copilot.Bool(false) + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + sessionID := "advanced-session-id" + workingDirectory := t.TempDir() + configDirectory := t.TempDir() + embeddingCacheStorage := "in-memory" + organizationCustomInstructions := "organization guidance" + maxAiCredits := float64(42) + extensionSDKPath := filepath.Join(ctx.WorkDir, "extension-sdk") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: sessionID, + ClientName: "go-sdk-e2e-client", + Model: "claude-sonnet-4.5", + ReasoningEffort: "low", + ReasoningSummary: copilot.ReasoningSummaryNone, + ContextTier: copilot.ContextTierLongContext, + ConfigDirectory: configDirectory, + EnableConfigDiscovery: copilot.Bool(true), + SkipEmbeddingRetrieval: copilot.Bool(true), + EmbeddingCacheStorage: &embeddingCacheStorage, + OrganizationCustomInstructions: &organizationCustomInstructions, + EnableOnDemandInstructionDiscovery: copilot.Bool(true), + EnableFileHooks: copilot.Bool(false), + EnableHostGitOperations: copilot.Bool(false), + EnableSessionStore: copilot.Bool(false), + EnableSkills: copilot.Bool(false), + WorkingDirectory: workingDirectory, + Streaming: copilot.Bool(true), + IncludeSubAgentStreamingEvents: copilot.Bool(false), + AvailableTools: []string{"read_file"}, + ExcludedTools: []string{"bash"}, + ExcludedBuiltInAgents: []string{"legacy-agent"}, + EnableSessionTelemetry: copilot.Bool(false), + EnableCitations: copilot.Bool(true), + SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: &maxAiCredits}, + SkipCustomInstructions: copilot.Bool(true), + CustomAgentsLocalOnly: copilot.Bool(true), + CoauthorEnabled: copilot.Bool(false), + ManageScheduleEnabled: copilot.Bool(false), + GitHubToken: "advanced-create-session-token", + RemoteSession: rpc.RemoteSessionModeExport, + SkillDirectories: []string{"skills"}, + PluginDirectories: []string{"plugins"}, + InstructionDirectories: []string{"instructions"}, + DisabledSkills: []string{"disabled-skill"}, + EnableMCPApps: true, + Canvases: []copilot.CanvasDeclaration{{ + ID: "canvas", + DisplayName: "Canvas", + Description: "Canvas description", + InputSchema: map[string]any{"type": "object"}, + }}, + RequestCanvasRenderer: copilot.Bool(true), + RequestExtensions: copilot.Bool(true), + ExtensionSDKPath: &extensionSDKPath, + ExtensionInfo: &copilot.ExtensionInfo{Source: "github-app", Name: "go-e2e-extension"}, + ExpAssignments: map[string]any{"feature": "enabled"}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + session.Disconnect() + + createReq := getCapturedRequest(t, capturePath, "session.create") + params, ok := createReq.Params.(map[string]any) + if !ok { + t.Fatalf("Expected session.create params object, got %T", createReq.Params) + } + expectedValues := map[string]any{ + "sessionId": sessionID, + "clientName": "go-sdk-e2e-client", + "model": "claude-sonnet-4.5", + "reasoningEffort": "low", + "reasoningSummary": "none", + "contextTier": "long_context", + "configDir": configDirectory, + "enableConfigDiscovery": true, + "skipEmbeddingRetrieval": true, + "embeddingCacheStorage": embeddingCacheStorage, + "organizationCustomInstructions": organizationCustomInstructions, + "enableOnDemandInstructionDiscovery": true, + "enableFileHooks": false, + "enableHostGitOperations": false, + "enableSessionStore": false, + "enableSkills": false, + "workingDirectory": workingDirectory, + "streaming": true, + "includeSubAgentStreamingEvents": false, + "enableSessionTelemetry": false, + "enableCitations": true, + "skipCustomInstructions": true, + "customAgentsLocalOnly": true, + "coauthorEnabled": false, + "manageScheduleEnabled": false, + "gitHubToken": "advanced-create-session-token", + "remoteSession": "export", + "requestMcpApps": true, + "requestCanvasRenderer": true, + "requestExtensions": true, + "extensionSdkPath": extensionSDKPath, + "envValueMode": "direct", + } + for key, expected := range expectedValues { + if params[key] != expected { + t.Fatalf("Expected %s=%#v, got %#v in %#v", key, expected, params[key], params) + } + } + assertStringArray(t, params["availableTools"], []string{"read_file"}) + assertStringArray(t, params["excludedTools"], []string{"bash"}) + assertStringArray(t, params["excludedBuiltinAgents"], []string{"legacy-agent"}) + assertStringArray(t, params["skillDirectories"], []string{"skills"}) + assertStringArray(t, params["pluginDirectories"], []string{"plugins"}) + assertStringArray(t, params["instructionDirectories"], []string{"instructions"}) + assertStringArray(t, params["disabledSkills"], []string{"disabled-skill"}) + if params["sessionLimits"].(map[string]any)["maxAiCredits"] != maxAiCredits { + t.Fatalf("Expected sessionLimits to be forwarded, got %#v", params["sessionLimits"]) + } + extensionInfo := params["extensionInfo"].(map[string]any) + if extensionInfo["source"] != "github-app" || extensionInfo["name"] != "go-e2e-extension" { + t.Fatalf("Expected extensionInfo to be forwarded, got %#v", extensionInfo) + } + canvases := params["canvases"].([]any) + canvas := canvases[0].(map[string]any) + if canvas["id"] != "canvas" || canvas["displayName"] != "Canvas" || canvas["description"] != "Canvas description" { + t.Fatalf("Expected canvas declaration to be forwarded, got %#v", canvas) + } + if params["expAssignments"].(map[string]any)["feature"] != "enabled" { + t.Fatalf("Expected expAssignments to be forwarded, got %#v", params["expAssignments"]) + } + }) + + t.Run("should forward singular provider configuration on session creation", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + cliPath := filepath.Join(ctx.WorkDir, "fake-cli-"+randomHex(t)+".js") + capturePath := filepath.Join(ctx.WorkDir, "fake-cli-capture-"+randomHex(t)+".json") + if err := os.WriteFile(cliPath, []byte(fakeStdioCliScript), 0644); err != nil { + t.Fatalf("Failed to write fake CLI script: %v", err) + } + + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Connection = copilot.StdioConnection{Path: cliPath, Args: []string{"--capture-file", capturePath}} + opts.GitHubToken = "provider-client-token" + opts.UseLoggedInUser = copilot.Bool(false) + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Provider: &copilot.ProviderConfig{ + Type: "openai", + WireAPI: "responses", + Transport: "websockets", + BaseURL: "https://models.example.test/v1", + APIKey: "provider-key", + ModelID: "base-model", + WireModel: "wire-model", + MaxPromptTokens: 1000, + MaxOutputTokens: 2000, + Headers: map[string]string{"x-provider": "go"}, + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + session.Disconnect() + + createReq := getCapturedRequest(t, capturePath, "session.create") + params := createReq.Params.(map[string]any) + provider := params["provider"].(map[string]any) + for key, expected := range map[string]any{ + "type": "openai", + "wireApi": "responses", + "transport": "websockets", + "baseUrl": "https://models.example.test/v1", + "apiKey": "provider-key", + "modelId": "base-model", + "wireModel": "wire-model", + "maxPromptTokens": float64(1000), + "maxOutputTokens": float64(2000), + } { + if provider[key] != expected { + t.Fatalf("Expected provider.%s=%#v, got %#v in %#v", key, expected, provider[key], provider) + } + } + if provider["headers"].(map[string]any)["x-provider"] != "go" { + t.Fatalf("Expected provider headers to be forwarded, got %#v", provider["headers"]) + } + }) + + t.Run("should forward advanced session resume options to the CLI", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + cliPath := filepath.Join(ctx.WorkDir, "fake-cli-"+randomHex(t)+".js") + capturePath := filepath.Join(ctx.WorkDir, "fake-cli-capture-"+randomHex(t)+".json") + if err := os.WriteFile(cliPath, []byte(fakeStdioCliScript), 0644); err != nil { + t.Fatalf("Failed to write fake CLI script: %v", err) + } + + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Connection = copilot.StdioConnection{Path: cliPath, Args: []string{"--capture-file", capturePath}} + opts.GitHubToken = "advanced-resume-client-token" + opts.UseLoggedInUser = copilot.Bool(false) + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + workingDirectory := t.TempDir() + configDirectory := t.TempDir() + continuePendingWork := false + extensionSDKPath := filepath.Join(ctx.WorkDir, "resume-extension-sdk") + session, err := client.ResumeSession(t.Context(), "resume-session-id", &copilot.ResumeSessionConfig{ + Model: "gpt-5-mini", + ReasoningEffort: "low", + ReasoningSummary: copilot.ReasoningSummaryNone, + ContextTier: copilot.ContextTierLongContext, + WorkingDirectory: workingDirectory, + ConfigDirectory: configDirectory, + EnableConfigDiscovery: copilot.Bool(false), + SuppressResumeEvent: true, + ContinuePendingWork: &continuePendingWork, + Streaming: copilot.Bool(true), + IncludeSubAgentStreamingEvents: copilot.Bool(false), + GitHubToken: "advanced-resume-session-token", + Canvases: []copilot.CanvasDeclaration{{ + ID: "resume-canvas", + DisplayName: "Resume Canvas", + Description: "Resume canvas description", + InputSchema: map[string]any{"type": "object"}, + }}, + OpenCanvases: []rpc.OpenCanvasInstance{{ + CanvasID: "resume-canvas", + ExtensionID: "github-app/go-e2e-extension", + InstanceID: "resume-instance", + Input: map[string]any{"value": "from-resume"}, + }}, + RequestCanvasRenderer: copilot.Bool(true), + RequestExtensions: copilot.Bool(true), + ExtensionSDKPath: &extensionSDKPath, + ExtensionInfo: &copilot.ExtensionInfo{Source: "github-app", Name: "go-e2e-extension"}, + ExpAssignments: map[string]any{"resumeFeature": "enabled"}, + }) + if err != nil { + t.Fatalf("ResumeSession failed: %v", err) + } + session.Disconnect() + + resumeReq := getCapturedRequest(t, capturePath, "session.resume") + params := resumeReq.Params.(map[string]any) + expectedValues := map[string]any{ + "sessionId": "resume-session-id", + "model": "gpt-5-mini", + "reasoningEffort": "low", + "reasoningSummary": "none", + "contextTier": "long_context", + "workingDirectory": workingDirectory, + "configDir": configDirectory, + "enableConfigDiscovery": false, + "disableResume": true, + "continuePendingWork": false, + "streaming": true, + "includeSubAgentStreamingEvents": false, + "gitHubToken": "advanced-resume-session-token", + "requestCanvasRenderer": true, + "requestExtensions": true, + "extensionSdkPath": extensionSDKPath, + "envValueMode": "direct", + } + for key, expected := range expectedValues { + if params[key] != expected { + t.Fatalf("Expected resume %s=%#v, got %#v in %#v", key, expected, params[key], params) + } + } + openCanvases := params["openCanvases"].([]any) + openCanvas := openCanvases[0].(map[string]any) + if openCanvas["canvasId"] != "resume-canvas" || openCanvas["extensionId"] != "github-app/go-e2e-extension" || + openCanvas["instanceId"] != "resume-instance" { + t.Fatalf("Expected open canvas state to be forwarded, got %#v", openCanvas) + } + extensionInfo := params["extensionInfo"].(map[string]any) + if extensionInfo["source"] != "github-app" || extensionInfo["name"] != "go-e2e-extension" { + t.Fatalf("Expected extensionInfo on resume, got %#v", extensionInfo) + } + if params["expAssignments"].(map[string]any)["resumeFeature"] != "enabled" { + t.Fatalf("Expected resume expAssignments to be forwarded, got %#v", params["expAssignments"]) + } + }) + } // --------------------------------------------------------------------------- @@ -353,8 +663,36 @@ func readCapture(t *testing.T, path string) capturedCli { return c } -// fakeStdioCliScript is identical to the one used by the .NET / Python -// equivalents (dotnet/test/ClientOptionsTests.cs and python/e2e/test_client_options.py). +func getCapturedRequest(t *testing.T, path, method string) capturedRequest { + t.Helper() + capture := readCapture(t, path) + for _, request := range capture.Requests { + if request.Method == method { + return request + } + } + t.Fatalf("Expected %s request in capture, got %+v", method, capture.Requests) + return capturedRequest{} +} + +func assertStringArray(t *testing.T, value any, expected []string) { + t.Helper() + items, ok := value.([]any) + if !ok { + t.Fatalf("Expected string array %v, got %#v", expected, value) + } + if len(items) != len(expected) { + t.Fatalf("Expected string array %v, got %#v", expected, items) + } + for i, expectedValue := range expected { + if items[i] != expectedValue { + t.Fatalf("Expected string array %v, got %#v", expected, items) + } + } +} + +// fakeStdioCliScript is intentionally kept close to the fake CLIs used by the +// other SDK client-options E2E tests, while still matching Go's request capture shape. const fakeStdioCliScript = ` const fs = require("fs"); @@ -430,6 +768,11 @@ function handleMessage(message) { writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null }); return; } + if (message.method === "session.resume") { + const sessionId = (message.params && message.params.sessionId) || "fake-session"; + writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null }); + return; + } writeResponse(message.id, {}); } diff --git a/go/internal/e2e/mcp_oauth_e2e_test.go b/go/internal/e2e/mcp_oauth_e2e_test.go index 1655e3bd1a..7959043063 100644 --- a/go/internal/e2e/mcp_oauth_e2e_test.go +++ b/go/internal/e2e/mcp_oauth_e2e_test.go @@ -226,6 +226,77 @@ func TestMCPOAuthE2E(t *testing.T) { t.Fatalf("Unexpected auth request reason: %q", observedRequest.Reason) } }) + + t.Run("resolve pending MCP OAuth request through RPC", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + ctx.ConfigureWithoutSnapshot(t) + client := ctx.NewClient() + defer client.ForceStop() + + baseURL := startOAuthMCPServer(t) + serverName := "oauth-direct-rpc-mcp" + requests := make(chan copilot.MCPAuthRequest, 1) + releaseHandler := make(chan struct{}) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + EnableMCPApps: true, + OnMCPAuthRequest: func(request copilot.MCPAuthRequest, _ copilot.MCPAuthInvocation) (*copilot.MCPAuthResult, error) { + requests <- request + <-releaseHandler + return copilot.MCPAuthResultCancelled(), nil + }, + MCPServers: map[string]copilot.MCPServerConfig{ + serverName: copilot.MCPHTTPServerConfig{ + URL: baseURL + "/mcp", + Tools: []string{"*"}, + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + t.Cleanup(func() { session.Disconnect() }) + + connected := make(chan error, 1) + go func() { + connected <- waitForMCPServerStatusResult(t.Context(), session, serverName, rpc.MCPServerStatusConnected, 60*time.Second) + }() + + var request copilot.MCPAuthRequest + select { + case request = <-requests: + case <-time.After(30 * time.Second): + t.Fatal("Timed out waiting for MCP OAuth request") + } + + tokenType := "Bearer" + expiresIn := int64(3600) + result, err := session.RPC.MCP.Oauth().HandlePendingRequest(t.Context(), &rpc.MCPOauthHandlePendingRequest{ + RequestID: request.RequestID, + Result: rpc.MCPOauthPendingRequestResponseToken{ + AccessToken: expectedMCPOAuthToken, + TokenType: &tokenType, + ExpiresIn: &expiresIn, + }, + }) + if err != nil { + close(releaseHandler) + t.Fatalf("HandlePendingRequest failed: %v", err) + } + close(releaseHandler) + if !result.Success { + t.Fatal("Expected direct MCP OAuth pending request resolution to succeed") + } + + if err := <-connected; err != nil { + t.Fatal(err) + } + requestLog := fetchOAuthMCPRequests(t, baseURL) + if !hasAuthorization(requestLog, "Bearer "+expectedMCPOAuthToken) { + t.Fatal("Expected MCP request with token supplied through direct RPC") + } + }) } type oauthMCPRequest struct { diff --git a/go/internal/e2e/mcp_server_helpers_test.go b/go/internal/e2e/mcp_server_helpers_test.go index 1860067eda..e7269b1e26 100644 --- a/go/internal/e2e/mcp_server_helpers_test.go +++ b/go/internal/e2e/mcp_server_helpers_test.go @@ -1,6 +1,8 @@ package e2e import ( + "context" + "fmt" "path/filepath" "testing" "time" @@ -33,10 +35,16 @@ func testMCPServers(t *testing.T, serverNames ...string) map[string]copilot.MCPS func waitForMCPServerStatus(t *testing.T, session *copilot.Session, serverName string, expectedStatus rpc.MCPServerStatus) { t.Helper() + if err := waitForMCPServerStatusResult(t.Context(), session, serverName, expectedStatus, 60*time.Second); err != nil { + t.Fatal(err) + } +} + +func waitForMCPServerStatusResult(ctx context.Context, session *copilot.Session, serverName string, expectedStatus rpc.MCPServerStatus, timeout time.Duration) error { var lastStatus string - deadline := time.Now().Add(60 * time.Second) + deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - result, err := session.RPC.MCP.List(t.Context()) + result, err := session.RPC.MCP.List(ctx) if err != nil { lastStatus = err.Error() } else { @@ -46,7 +54,7 @@ func waitForMCPServerStatus(t *testing.T, session *copilot.Session, serverName s continue } if server.Status == expectedStatus { - return + return nil } lastStatus = string(server.Status) break @@ -55,5 +63,5 @@ func waitForMCPServerStatus(t *testing.T, session *copilot.Session, serverName s time.Sleep(200 * time.Millisecond) } - t.Fatalf("%s did not reach %s; last status was %s", serverName, expectedStatus, lastStatus) + return fmt.Errorf("%s did not reach %s; last status was %s", serverName, expectedStatus, lastStatus) } diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go index ba661acdba..2510eb1d9c 100644 --- a/go/internal/e2e/rpc_server_e2e_test.go +++ b/go/internal/e2e/rpc_server_e2e_test.go @@ -3,6 +3,7 @@ package e2e import ( "fmt" "path/filepath" + "runtime" "strings" "testing" "time" @@ -196,6 +197,44 @@ func TestRPCServerE2E(t *testing.T) { } }) + t.Run("should return false for missing LLM response frames", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + start, err := client.RPC.LlmInference.HttpResponseStart(t.Context(), &rpc.LlmInferenceHTTPResponseStartRequest{ + RequestID: "missing-response-start-request", + Status: 200, + StatusText: rpcPtr("OK"), + Headers: map[string][]string{ + "content-type": {"application/json"}, + }, + }) + if err != nil { + t.Fatalf("LlmInference.HttpResponseStart failed: %v", err) + } + if start.Accepted { + t.Fatal("Expected Accepted=false for missing LLM response start request id") + } + + end := true + chunk, err := client.RPC.LlmInference.HttpResponseChunk(t.Context(), &rpc.LlmInferenceHTTPResponseChunkRequest{ + RequestID: "missing-response-chunk-request", + Data: "{}", + End: &end, + }) + if err != nil { + t.Fatalf("LlmInference.HttpResponseChunk failed: %v", err) + } + if chunk.Accepted { + t.Fatal("Expected Accepted=false for missing LLM response chunk request id") + } + }) + t.Run("should list find and inspect persisted session state", func(t *testing.T) { ctx := testharness.NewTestContext(t) token := "rpc-server-list-token-" + randomHex(t) @@ -554,6 +593,82 @@ func TestRPCServerE2E(t *testing.T) { t.Errorf("Expected skill path to end with %q, got %v", expectedSuffix, discovered.Path) } + excludeHost := true + skillPaths, err := client.RPC.Skills.GetDiscoveryPaths(t.Context(), &rpc.SkillsGetDiscoveryPathsRequest{ + ProjectPaths: []string{ctx.WorkDir}, + ExcludeHostSkills: &excludeHost, + }) + if err != nil { + t.Fatalf("Skills.GetDiscoveryPaths failed: %v", err) + } + projectSkillPath := findSkillDiscoveryPath(skillPaths.Paths, ctx.WorkDir) + if projectSkillPath == nil { + t.Fatalf("Expected skill discovery paths to include %q", ctx.WorkDir) + } + if strings.TrimSpace(projectSkillPath.Path) == "" { + t.Fatal("Expected non-empty skill discovery path") + } + + agents, err := client.RPC.Agents.Discover(t.Context(), &rpc.AgentsDiscoverRequest{ + ProjectPaths: []string{ctx.WorkDir}, + ExcludeHostAgents: &excludeHost, + }) + if err != nil { + t.Fatalf("Agents.Discover failed: %v", err) + } + for _, agent := range agents.Agents { + if strings.TrimSpace(agent.Name) == "" { + t.Fatalf("Expected discovered agent to have a name: %+v", agent) + } + } + + agentPaths, err := client.RPC.Agents.GetDiscoveryPaths(t.Context(), &rpc.AgentsGetDiscoveryPathsRequest{ + ProjectPaths: []string{ctx.WorkDir}, + ExcludeHostAgents: &excludeHost, + }) + if err != nil { + t.Fatalf("Agents.GetDiscoveryPaths failed: %v", err) + } + projectAgentPath := findAgentDiscoveryPath(agentPaths.Paths, ctx.WorkDir) + if projectAgentPath == nil { + t.Fatalf("Expected agent discovery paths to include %q", ctx.WorkDir) + } + if strings.TrimSpace(projectAgentPath.Path) == "" { + t.Fatal("Expected non-empty agent discovery path") + } + + instructions, err := client.RPC.Instructions.Discover(t.Context(), &rpc.InstructionsDiscoverRequest{ + ProjectPaths: []string{ctx.WorkDir}, + ExcludeHostInstructions: &excludeHost, + }) + if err != nil { + t.Fatalf("Instructions.Discover failed: %v", err) + } + for _, source := range instructions.Sources { + if strings.TrimSpace(source.ID) == "" || strings.TrimSpace(source.Label) == "" || strings.TrimSpace(source.SourcePath) == "" { + t.Fatalf("Expected discovered instruction source fields to be populated: %+v", source) + } + } + + instructionPaths, err := client.RPC.Instructions.GetDiscoveryPaths(t.Context(), &rpc.InstructionsGetDiscoveryPathsRequest{ + ProjectPaths: []string{ctx.WorkDir}, + ExcludeHostInstructions: &excludeHost, + }) + if err != nil { + t.Fatalf("Instructions.GetDiscoveryPaths failed: %v", err) + } + if len(instructionPaths.Paths) == 0 { + t.Fatal("Expected instruction discovery paths") + } + if !hasInstructionDiscoveryPath(instructionPaths.Paths, ctx.WorkDir) { + t.Fatalf("Expected instruction discovery paths to include %q", ctx.WorkDir) + } + for _, path := range instructionPaths.Paths { + if strings.TrimSpace(path.Path) == "" { + t.Fatalf("Expected non-empty instruction discovery path: %+v", path) + } + } + // Disable the skill globally and re-discover. if _, err := client.RPC.Skills.Config().SetDisabledSkills(t.Context(), &rpc.SkillsConfigSetDisabledSkillsRequest{ DisabledSkills: []string{skillName}, @@ -615,6 +730,42 @@ func findServerSkill(skills []rpc.ServerSkill, name string) *rpc.ServerSkill { return nil } +func findSkillDiscoveryPath(paths []rpc.SkillDiscoveryPath, projectPath string) *rpc.SkillDiscoveryPath { + for i, path := range paths { + if path.ProjectPath != nil && path.PreferredForCreation && pathsEqual(*path.ProjectPath, projectPath) { + return &paths[i] + } + } + return nil +} + +func findAgentDiscoveryPath(paths []rpc.AgentDiscoveryPath, projectPath string) *rpc.AgentDiscoveryPath { + for i, path := range paths { + if path.ProjectPath != nil && path.PreferredForCreation && pathsEqual(*path.ProjectPath, projectPath) { + return &paths[i] + } + } + return nil +} + +func hasInstructionDiscoveryPath(paths []rpc.InstructionDiscoveryPath, projectPath string) bool { + for _, path := range paths { + if path.ProjectPath != nil && pathsEqual(*path.ProjectPath, projectPath) { + return true + } + } + return false +} + +func pathsEqual(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + func saveSession(t *testing.T, client *copilot.Client, sessionID string) { t.Helper() if _, err := client.RPC.Sessions.Save(t.Context(), &rpc.SessionsSaveRequest{SessionID: sessionID}); err != nil { diff --git a/go/internal/e2e/rpc_server_misc_e2e_test.go b/go/internal/e2e/rpc_server_misc_e2e_test.go index 34b48b5063..38b569798c 100644 --- a/go/internal/e2e/rpc_server_misc_e2e_test.go +++ b/go/internal/e2e/rpc_server_misc_e2e_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" "github.com/github/copilot-sdk/go/rpc" ) @@ -25,6 +26,168 @@ func TestRpcServerMisc(t *testing.T) { } }) + t.Run("should_get_set_and_clear_user_settings", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + initial, err := client.RPC.User.Settings().Get(t.Context()) + if err != nil { + t.Fatalf("User.Settings.Get initial failed: %v", err) + } + if initial.Settings == nil { + t.Fatal("Expected settings map") + } + var key string + var value bool + for candidateKey, setting := range initial.Settings { + if candidateValue, ok := setting.Value.(bool); ok { + key = candidateKey + value = candidateValue + break + } + } + if key == "" { + t.Fatalf("Expected at least one boolean setting, got %+v", initial.Settings) + } + toggledValue := !value + + set, err := client.RPC.User.Settings().Set(t.Context(), &rpc.UserSettingsSetRequest{ + Settings: map[string]any{key: toggledValue}, + }) + if err != nil { + t.Fatalf("User.Settings.Set(toggle) failed: %v", err) + } + if len(set.ShadowedKeys) != 0 { + t.Fatalf("Expected no shadowed settings keys, got %+v", set.ShadowedKeys) + } + if _, err := client.RPC.User.Settings().Reload(t.Context()); err != nil { + t.Fatalf("User.Settings.Reload after set failed: %v", err) + } + afterSet, err := client.RPC.User.Settings().Get(t.Context()) + if err != nil { + t.Fatalf("User.Settings.Get after set failed: %v", err) + } + metadata, ok := afterSet.Settings[key] + if !ok { + t.Fatalf("Expected setting %q in %+v", key, afterSet.Settings) + } + if metadata.Value != toggledValue || metadata.IsDefault { + t.Fatalf("Expected explicit true setting, got %+v", metadata) + } + + clear, err := client.RPC.User.Settings().Set(t.Context(), &rpc.UserSettingsSetRequest{ + Settings: map[string]any{key: nil}, + }) + if err != nil { + t.Fatalf("User.Settings.Set(null) failed: %v", err) + } + if len(clear.ShadowedKeys) != 0 { + t.Fatalf("Expected no shadowed settings keys from clear, got %+v", clear.ShadowedKeys) + } + if _, err := client.RPC.User.Settings().Reload(t.Context()); err != nil { + t.Fatalf("User.Settings.Reload after clear failed: %v", err) + } + afterClear, err := client.RPC.User.Settings().Get(t.Context()) + if err != nil { + t.Fatalf("User.Settings.Get after clear failed: %v", err) + } + metadata, ok = afterClear.Settings[key] + if !ok { + t.Fatalf("Expected setting %q after clear in %+v", key, afterClear.Settings) + } + if !metadata.IsDefault { + t.Fatalf("Expected cleared setting to be default, got %+v", metadata) + } + }) + + t.Run("should_login_list_getcurrentauth_and_logout_account", func(t *testing.T) { + ctx.ConfigureForTest(t) + if err := ctx.SetCopilotUserByToken("go-account-token", map[string]interface{}{ + "login": "go-account-user", + "copilot_plan": "individual_pro", + "endpoints": map[string]interface{}{ + "api": ctx.ProxyURL, + "telemetry": "https://localhost:1/telemetry", + }, + "analytics_tracking_id": "go-account-user-tracking-id", + }); err != nil { + t.Fatalf("SetCopilotUserByToken failed: %v", err) + } + client := newNoTokenClient(t, ctx) + defer client.ForceStop() + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + initial, err := client.RPC.Account.GetCurrentAuth(t.Context()) + if err != nil { + t.Fatalf("Account.GetCurrentAuth initial failed: %v", err) + } + if initial.AuthInfo != nil { + t.Fatalf("Expected no initial auth info, got %+v", initial.AuthInfo) + } + + login, err := client.RPC.Account.Login(t.Context(), &rpc.AccountLoginRequest{ + Host: "https://github.com", + Login: "go-account-user", + Token: "go-account-token", + }) + if err != nil { + t.Fatalf("Account.Login failed: %v", err) + } + if login == nil { + t.Fatal("Expected login result") + } + + current, err := client.RPC.Account.GetCurrentAuth(t.Context()) + if err != nil { + t.Fatalf("Account.GetCurrentAuth after login failed: %v", err) + } + authInfo, ok := current.AuthInfo.(*rpc.UserAuthInfo) + if !ok { + t.Fatalf("Expected user auth info after login, got %#v", current.AuthInfo) + } + if authInfo.Login != "go-account-user" || authInfo.Host != "https://github.com" { + t.Fatalf("Unexpected current auth info: %+v", authInfo) + } + + users, err := client.RPC.Account.GetAllUsers(t.Context()) + if err != nil { + t.Fatalf("Account.GetAllUsers failed: %v", err) + } + if users == nil { + t.Fatal("Expected non-nil users result") + } + for _, user := range *users { + userInfo, ok := user.AuthInfo.(*rpc.UserAuthInfo) + if !ok { + t.Fatalf("Expected user auth info in all users, got %#v", user.AuthInfo) + } + if userInfo.Login == "go-account-user" && (user.Token == nil || *user.Token != "go-account-token") { + t.Fatalf("Expected logged-in user's token to round trip, got %+v", user) + } + } + + logout, err := client.RPC.Account.Logout(t.Context(), &rpc.AccountLogoutRequest{ + AuthInfo: authInfo, + }) + if err != nil { + t.Fatalf("Account.Logout failed: %v", err) + } + if logout.HasMoreUsers { + t.Fatalf("Expected no users after isolated logout, got %+v", logout) + } + afterLogout, err := client.RPC.Account.GetCurrentAuth(t.Context()) + if err != nil { + t.Fatalf("Account.GetCurrentAuth after logout failed: %v", err) + } + if afterLogout.AuthInfo != nil { + t.Fatalf("Expected no auth after logout, got %+v", afterLogout.AuthInfo) + } + }) + t.Run("should_report_agent_registry_spawn_gate_closed", func(t *testing.T) { ctx.ConfigureForTest(t) client := newStartedIsolatedPortedClient(t, ctx) @@ -91,3 +254,22 @@ func TestRpcServerMisc(t *testing.T) { assertPortedContainsFold(t, message, "extension") }) } + +func newNoTokenClient(t *testing.T, ctx *testharness.TestContext) *copilot.Client { + t.Helper() + env := append([]string{}, ctx.Env()...) + env = append(env, + "COPILOT_HOME="+t.TempDir(), + "GH_CONFIG_DIR="+t.TempDir(), + "GH_TOKEN=", + "GITHUB_TOKEN=", + "COPILOT_SDK_AUTH_TOKEN=", + ) + useLoggedInUser := false + return copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.StdioConnection{Path: ctx.CLIPath}, + WorkingDirectory: ctx.WorkDir, + Env: env, + UseLoggedInUser: &useLoggedInUser, + }) +} diff --git a/go/internal/e2e/rpc_session_state_extras_e2e_test.go b/go/internal/e2e/rpc_session_state_extras_e2e_test.go index 36f12ac548..1e33e8a8bc 100644 --- a/go/internal/e2e/rpc_session_state_extras_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_extras_e2e_test.go @@ -176,6 +176,157 @@ func TestRpcSessionStateExtras(t *testing.T) { } }) + t.Run("should_add_byok_provider_and_model_at_runtime", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + apiKey := "provider-key" + providerType := rpc.ProviderConfigTypeOpenai + wireAPI := rpc.ProviderConfigWireAPICompletions + modelName := "Go Added Model" + maxPromptTokens := float64(4096) + result, err := session.RPC.Provider.Add(t.Context(), &rpc.ProviderAddRequest{ + Providers: []rpc.NamedProviderConfig{{ + Name: "go-e2e-provider", + Type: &providerType, + BaseURL: "https://models.example.test/v1", + APIKey: &apiKey, + Headers: map[string]string{"x-provider": "go"}, + WireAPI: &wireAPI, + }}, + Models: []rpc.ProviderModelConfig{{ + ID: "small", + Provider: "go-e2e-provider", + Name: &modelName, + MaxPromptTokens: &maxPromptTokens, + }}, + }) + if err != nil { + t.Fatalf("Provider.Add failed: %v", err) + } + if len(result.Models) != 1 { + t.Fatalf("Expected one added provider model, got %+v", result.Models) + } + + selectionID := "go-e2e-provider/small" + if _, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.ModelSwitchToRequest{ModelID: selectionID}); err != nil { + t.Fatalf("Model.SwitchTo added model failed: %v", err) + } + current, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Model.GetCurrent after provider add failed: %v", err) + } + if current.ModelID == nil || *current.ModelID != selectionID { + t.Fatalf("Expected current model %q, got %+v", selectionID, current) + } + }) + + t.Run("should_return_empty_completions_when_host_does_not_provide_them", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + result, err := session.RPC.Completions.Request(t.Context(), &rpc.CompletionsRequestRequest{ + Text: "Use @ to mention context", + Offset: 5, + }) + if err != nil { + t.Fatalf("Completions.Request failed: %v", err) + } + if result.Items == nil { + t.Fatal("Expected non-nil completion items list") + } + }) + + t.Run("should_report_visibility_as_unsynced_for_local_session", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + status := rpc.SessionVisibilityStatusUnshared + set, err := session.RPC.Visibility.Set(t.Context(), &rpc.VisibilitySetRequest{Status: status}) + if err != nil { + t.Fatalf("Visibility.Set failed: %v", err) + } + if set.Synced || set.Status != nil || set.ShareURL != nil { + t.Fatalf("Expected unsynced visibility set result, got %+v", set) + } + get, err := session.RPC.Visibility.Get(t.Context()) + if err != nil { + t.Fatalf("Visibility.Get failed: %v", err) + } + if get.Synced || get.Status != nil || get.ShareURL != nil { + t.Fatalf("Expected unsynced visibility get result, got %+v", get) + } + }) + + t.Run("should_get_context_attribution_and_heaviest_messages_after_turn", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + answer, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Say CONTEXT_METADATA_OK exactly."}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if answer == nil { + t.Fatal("Expected final assistant message") + } + + attribution, err := session.RPC.Metadata.GetContextAttribution(t.Context()) + if err != nil { + t.Fatalf("Metadata.GetContextAttribution failed: %v", err) + } + if attribution == nil { + t.Fatal("Expected attribution result") + } + limit := int64(5) + heaviest, err := session.RPC.Metadata.GetContextHeaviestMessages(t.Context(), &rpc.MetadataContextHeaviestMessagesRequest{Limit: &limit}) + if err != nil { + t.Fatalf("Metadata.GetContextHeaviestMessages failed: %v", err) + } + if heaviest.Messages == nil { + t.Fatal("Expected non-nil heaviest messages list") + } + }) + + t.Run("should_update_and_clear_live_subagent_settings", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + contextTier := rpc.SubagentSettingsEntryContextTierLongContext + model := "gpt-5-mini" + reasoningEffort := "low" + update, err := session.RPC.Tools.UpdateSubagentSettings(t.Context(), &rpc.UpdateSubagentSettingsRequest{ + Subagents: &rpc.SubagentSettings{ + DisabledSubagents: []string{"legacy-agent"}, + Agents: map[string]rpc.SubagentSettingsEntry{ + "general-purpose": { + ContextTier: &contextTier, + Model: &model, + EffortLevel: &reasoningEffort, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Tools.UpdateSubagentSettings failed: %v", err) + } + if update == nil { + t.Fatal("Expected update result") + } + + clear, err := session.RPC.Tools.UpdateSubagentSettings(t.Context(), &rpc.UpdateSubagentSettingsRequest{}) + if err != nil { + t.Fatalf("Tools.UpdateSubagentSettings clear failed: %v", err) + } + if clear == nil { + t.Fatal("Expected clear result") + } + }) + t.Run("should_reload_session_plugins", func(t *testing.T) { ctx.ConfigureForTest(t) session := createPortedSession(t, client, nil) diff --git a/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go b/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go index 60ae3f1fd3..0267f8d042 100644 --- a/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go +++ b/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go @@ -302,6 +302,39 @@ func TestRPCTasksAndHandlersE2E(t *testing.T) { if locationApproval.Success { t.Error("Expected Success=false for missing location approval request id") } + + sessionLimits, err := session.RPC.UI.HandlePendingSessionLimitsExhausted(t.Context(), &rpc.UIHandlePendingSessionLimitsExhaustedRequest{ + RequestID: "missing-session-limits-request", + Response: rpc.UISessionLimitsExhaustedResponse{Action: rpc.UISessionLimitsExhaustedResponseActionCancel}, + }) + if err != nil { + t.Fatalf("UI.HandlePendingSessionLimitsExhausted failed: %v", err) + } + if sessionLimits.Success { + t.Error("Expected Success=false for missing session limits request id") + } + + headers, err := session.RPC.MCP.Headers().HandlePendingHeadersRefreshRequest(t.Context(), &rpc.MCPHeadersHandlePendingHeadersRefreshRequestRequest{ + RequestID: "missing-headers-refresh-request", + Result: rpc.MCPHeadersHandlePendingHeadersRefreshRequestHeaders{Headers: map[string]string{"authorization": "Bearer refreshed"}}, + }) + if err != nil { + t.Fatalf("MCP.Headers.HandlePendingHeadersRefreshRequest failed: %v", err) + } + if headers.Success { + t.Error("Expected Success=false for missing MCP headers refresh request id") + } + + noHeaders, err := session.RPC.MCP.Headers().HandlePendingHeadersRefreshRequest(t.Context(), &rpc.MCPHeadersHandlePendingHeadersRefreshRequestRequest{ + RequestID: "missing-headers-refresh-none-request", + Result: rpc.MCPHeadersHandlePendingHeadersRefreshRequestNone{}, + }) + if err != nil { + t.Fatalf("MCP.Headers.HandlePendingHeadersRefreshRequest none failed: %v", err) + } + if noHeaders.Success { + t.Error("Expected Success=false for missing MCP headers refresh none request id") + } }) t.Run("should round trip rpc elicitation through config handler", func(t *testing.T) { diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index 2643980b75..6b6463749f 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -158,6 +158,18 @@ func (c *TestContext) ConfigureForTest(t *testing.T) { } } +// ConfigureWithoutSnapshot initializes the replay proxy without loading a recorded CAPI +// exchange file. Use this for tests that serve all model-layer behavior locally but +// still need proxy-backed auth and GitHub API endpoints. +func (c *TestContext) ConfigureWithoutSnapshot(t *testing.T) { + t.Helper() + + dummySnapshotPath := filepath.Join(c.WorkDir, "__no_snapshot__.yaml") + if err := c.proxy.Configure(dummySnapshotPath, c.WorkDir); err != nil { + t.Fatalf("Failed to configure proxy without snapshot: %v", err) + } +} + // Close cleans up the test context resources. func (c *TestContext) Close(testFailed bool) { if c.proxy != nil { diff --git a/java/scripts/codegen/java.ts b/java/scripts/codegen/java.ts index ac65c8003d..7c2a5cebea 100644 --- a/java/scripts/codegen/java.ts +++ b/java/scripts/codegen/java.ts @@ -1651,15 +1651,16 @@ function addWrapperResultImports(resultType: string, allImports: Set, pa } /** - * Return the params class name if the method has a params schema with properties - * other than sessionId (i.e. there are user-supplied parameters). + * Return the params class name if the method has a params schema with user-supplied properties. + * Session-scoped wrappers inject sessionId automatically, but server-scoped wrappers must let + * callers supply it explicitly. */ -function wrapperParamsClassName(method: RpcMethodNode): string | null { +function wrapperParamsClassName(method: RpcMethodNode, isSession: boolean): string | null { let params = method.params; if (params?.$ref) params = resolveRef(params) as JSONSchema7; if (!params || typeof params !== "object") return null; const props = params.properties ?? {}; - const userProps = Object.keys(props).filter((k) => k !== "sessionId"); + const userProps = Object.keys(props).filter((k) => !isSession || k !== "sessionId"); if (userProps.length === 0) return null; return rpcMethodToClassName(method.rpcMethod) + "Params"; } @@ -1682,7 +1683,7 @@ function generateApiMethod( sessionIdExpr: string ): { lines: string[]; needsMapper: boolean; needsExperimentalImport: boolean } { const resultClass = wrapperResultClassName(method); - const paramsClass = wrapperParamsClassName(method); + const paramsClass = wrapperParamsClassName(method, isSession); const hasSessionId = methodHasSessionId(method); const hasExtraParams = paramsClass !== null; let needsMapper = false; @@ -1790,7 +1791,7 @@ async function generateNamespaceApiFile( const methodLines: string[] = []; for (const [key, method] of tree.methods) { const resultClass = wrapperResultClassName(method); - const paramsClass = wrapperParamsClassName(method); + const paramsClass = wrapperParamsClassName(method, isSession); addWrapperResultImports(resultClass, allImports, packageName); if (paramsClass) allImports.add(`${packageName}.${paramsClass}`); @@ -1910,7 +1911,7 @@ async function generateRpcRootFile( const methodLines: string[] = []; for (const [key, method] of tree.methods) { const resultClass = wrapperResultClassName(method); - const paramsClass = wrapperParamsClassName(method); + const paramsClass = wrapperParamsClassName(method, isSession); addWrapperResultImports(resultClass, allImports, packageName); if (paramsClass) allImports.add(`${packageName}.${paramsClass}`); diff --git a/java/src/generated/java/com/github/copilot/generated/rpc/ServerSessionsApi.java b/java/src/generated/java/com/github/copilot/generated/rpc/ServerSessionsApi.java index 338f0e9edb..7e3b4d90d8 100644 --- a/java/src/generated/java/com/github/copilot/generated/rpc/ServerSessionsApi.java +++ b/java/src/generated/java/com/github/copilot/generated/rpc/ServerSessionsApi.java @@ -55,8 +55,8 @@ public CompletableFuture fork(SessionsForkParams params) { * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture connect() { - return caller.invoke("sessions.connect", java.util.Map.of(), SessionsConnectResult.class); + public CompletableFuture connect(SessionsConnectParams params) { + return caller.invoke("sessions.connect", params, SessionsConnectResult.class); } /** @@ -110,8 +110,8 @@ public CompletableFuture getLastForContext(Sess * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture getEventFilePath() { - return caller.invoke("sessions.getEventFilePath", java.util.Map.of(), SessionsGetEventFilePathResult.class); + public CompletableFuture getEventFilePath(SessionsGetEventFilePathParams params) { + return caller.invoke("sessions.getEventFilePath", params, SessionsGetEventFilePathResult.class); } /** @@ -143,8 +143,8 @@ public CompletableFuture checkInUse(SessionsCheckInUse * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture getPersistedRemoteSteerable() { - return caller.invoke("sessions.getPersistedRemoteSteerable", java.util.Map.of(), SessionsGetPersistedRemoteSteerableResult.class); + public CompletableFuture getPersistedRemoteSteerable(SessionsGetPersistedRemoteSteerableParams params) { + return caller.invoke("sessions.getPersistedRemoteSteerable", params, SessionsGetPersistedRemoteSteerableResult.class); } /** @@ -154,8 +154,8 @@ public CompletableFuture getPersisted * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture close() { - return caller.invoke("sessions.close", java.util.Map.of(), Void.class); + public CompletableFuture close(SessionsCloseParams params) { + return caller.invoke("sessions.close", params, Void.class); } /** @@ -187,8 +187,8 @@ public CompletableFuture pruneOld(SessionsPruneOldParams * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture save() { - return caller.invoke("sessions.save", java.util.Map.of(), Void.class); + public CompletableFuture save(SessionsSaveParams params) { + return caller.invoke("sessions.save", params, Void.class); } /** @@ -198,8 +198,8 @@ public CompletableFuture save() { * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture releaseLock() { - return caller.invoke("sessions.releaseLock", java.util.Map.of(), Void.class); + public CompletableFuture releaseLock(SessionsReleaseLockParams params) { + return caller.invoke("sessions.releaseLock", params, Void.class); } /** @@ -231,8 +231,8 @@ public CompletableFuture reloadPluginHooks(SessionsReloadPluginHooksParams * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture loadDeferredRepoHooks() { - return caller.invoke("sessions.loadDeferredRepoHooks", java.util.Map.of(), SessionsLoadDeferredRepoHooksResult.class); + public CompletableFuture loadDeferredRepoHooks(SessionsLoadDeferredRepoHooksParams params) { + return caller.invoke("sessions.loadDeferredRepoHooks", params, SessionsLoadDeferredRepoHooksResult.class); } /** @@ -253,8 +253,8 @@ public CompletableFuture setAdditionalPlugins(SessionsSetAdditionalPlugins * @since 1.0.0 */ @CopilotExperimental - public CompletableFuture getBoardEntryCount() { - return caller.invoke("sessions.getBoardEntryCount", java.util.Map.of(), SessionsGetBoardEntryCountResult.class); + public CompletableFuture getBoardEntryCount(SessionsGetBoardEntryCountParams params) { + return caller.invoke("sessions.getBoardEntryCount", params, SessionsGetBoardEntryCountResult.class); } /** diff --git a/java/src/main/java/com/github/copilot/JsonRpcClient.java b/java/src/main/java/com/github/copilot/JsonRpcClient.java index a7cd0e120d..5303c4f500 100644 --- a/java/src/main/java/com/github/copilot/JsonRpcClient.java +++ b/java/src/main/java/com/github/copilot/JsonRpcClient.java @@ -70,7 +70,8 @@ static ObjectMapper createObjectMapper() { mapper.registerModule(new JavaTimeModule()); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); + mapper.setDefaultPropertyInclusion( + JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.ALWAYS)); return mapper; } diff --git a/java/src/test/java/com/github/copilot/ClientOptionsE2ETest.java b/java/src/test/java/com/github/copilot/ClientOptionsE2ETest.java new file mode 100644 index 0000000000..45056afdb4 --- /dev/null +++ b/java/src/test/java/com/github/copilot/ClientOptionsE2ETest.java @@ -0,0 +1,302 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.SessionLimitsConfig; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.ProviderConfig; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; + +class ClientOptionsE2ETest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void testShouldForwardAdvancedSessionCreationOptionsToTheCli() throws Exception { + try (var fake = FakeStdioCli.create()) { + var workDir = fake.path("create-work"); + var configDir = fake.path("create-config"); + + try (var client = fake.createClient()) { + var session = client.createSession(new SessionConfig().setSessionId("java-create-session") + .setClientName("java-e2e-client").setModel("gpt-5-mini").setReasoningEffort("low") + .setReasoningSummary("none").setContextTier("long_context") + .setAvailableTools(java.util.List.of("bash")).setExcludedTools(java.util.List.of("grep")) + .setExcludedBuiltInAgents(java.util.List.of("explore")).setEnableSessionTelemetry(true) + .setEnableCitations(true).setSessionLimits(new SessionLimitsConfig(42.0)) + .setWorkingDirectory(workDir.toString()).setStreaming(true) + .setIncludeSubAgentStreamingEvents(true).setConfigDirectory(configDir.toString()) + .setEnableConfigDiscovery(false).setSkipEmbeddingRetrieval(true) + .setOrganizationCustomInstructions("Use Java parity instructions.") + .setEnableOnDemandInstructionDiscovery(false).setEnableFileHooks(true) + .setEnableHostGitOperations(false).setEnableSessionStore(true).setEnableSkills(false) + .setEmbeddingCacheStorage("in-memory").setGitHubToken("java-session-token") + .setRemoteSession("export").setSkipCustomInstructions(true).setCustomAgentsLocalOnly(false) + .setCoauthorEnabled(true).setManageScheduleEnabled(true) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(30, TimeUnit.SECONDS); + session.close(); + } + + var create = fake.capturedRequest("session.create").path("params"); + assertEquals("java-create-session", create.path("sessionId").asText()); + assertEquals("java-e2e-client", create.path("clientName").asText()); + assertEquals("gpt-5-mini", create.path("model").asText()); + assertEquals("low", create.path("reasoningEffort").asText()); + assertEquals("none", create.path("reasoningSummary").asText()); + assertEquals("long_context", create.path("contextTier").asText()); + assertEquals("bash", create.path("availableTools").get(0).asText()); + assertEquals("grep", create.path("excludedTools").get(0).asText()); + assertEquals("explore", create.path("excludedBuiltinAgents").get(0).asText()); + assertTrue(create.path("enableSessionTelemetry").asBoolean()); + assertTrue(create.path("enableCitations").asBoolean()); + assertEquals(42.0, create.path("sessionLimits").path("maxAiCredits").asDouble()); + assertEquals(workDir.toString(), create.path("workingDirectory").asText()); + assertTrue(create.path("streaming").asBoolean()); + assertTrue(create.path("includeSubAgentStreamingEvents").asBoolean()); + assertEquals(configDir.toString(), create.path("configDir").asText()); + assertFalse(create.path("enableConfigDiscovery").asBoolean()); + assertTrue(create.path("skipEmbeddingRetrieval").asBoolean()); + assertEquals("Use Java parity instructions.", create.path("organizationCustomInstructions").asText()); + assertFalse(create.path("enableOnDemandInstructionDiscovery").asBoolean()); + assertTrue(create.path("enableFileHooks").asBoolean()); + assertFalse(create.path("enableHostGitOperations").asBoolean()); + assertTrue(create.path("enableSessionStore").asBoolean()); + assertFalse(create.path("enableSkills").asBoolean()); + assertEquals("in-memory", create.path("embeddingCacheStorage").asText()); + assertEquals("java-session-token", create.path("gitHubToken").asText()); + assertEquals("export", create.path("remoteSession").asText()); + assertEquals("direct", create.path("envValueMode").asText()); + assertTrue(create.path("requestPermission").asBoolean()); + + var update = fake.capturedRequest("session.options.update").path("params"); + assertEquals("java-create-session", update.path("sessionId").asText()); + assertTrue(update.path("skipCustomInstructions").asBoolean()); + assertFalse(update.path("customAgentsLocalOnly").asBoolean()); + assertTrue(update.path("coauthorEnabled").asBoolean()); + assertTrue(update.path("manageScheduleEnabled").asBoolean()); + } + } + + @Test + void testShouldForwardSingularProviderConfigurationOnSessionCreation() throws Exception { + try (var fake = FakeStdioCli.create()) { + try (var client = fake.createClient()) { + var session = client.createSession(new SessionConfig() + .setProvider(new ProviderConfig().setType("openai").setWireApi("responses") + .setTransport("websockets").setBaseUrl("https://models.example.test/v1") + .setApiKey("provider-key").setModelId("base-model").setWireModel("wire-model") + .setMaxPromptTokens(1000).setMaxOutputTokens(2000) + .setHeaders(Map.of("x-provider", "java"))) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(30, TimeUnit.SECONDS); + session.close(); + } + + var provider = fake.capturedRequest("session.create").path("params").path("provider"); + assertEquals("openai", provider.path("type").asText()); + assertEquals("responses", provider.path("wireApi").asText()); + assertEquals("websockets", provider.path("transport").asText()); + assertEquals("https://models.example.test/v1", provider.path("baseUrl").asText()); + assertEquals("provider-key", provider.path("apiKey").asText()); + assertEquals("base-model", provider.path("modelId").asText()); + assertEquals("wire-model", provider.path("wireModel").asText()); + assertEquals(1000, provider.path("maxPromptTokens").asInt()); + assertEquals(2000, provider.path("maxOutputTokens").asInt()); + assertEquals("java", provider.path("headers").path("x-provider").asText()); + } + } + + @Test + void testShouldForwardAdvancedSessionResumeOptionsToTheCli() throws Exception { + try (var fake = FakeStdioCli.create()) { + var workDir = fake.path("resume-work"); + var configDir = fake.path("resume-config"); + + try (var client = fake.createClient()) { + client.createSession(new SessionConfig().setSessionId("java-resume-session") + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(30, TimeUnit.SECONDS); + var session = client.resumeSession("java-resume-session", + new ResumeSessionConfig().setClientName("java-resume-client").setModel("gpt-5-mini") + .setReasoningEffort("medium").setReasoningSummary("none").setContextTier("long_context") + .setEnableCitations(true).setSessionLimits(new SessionLimitsConfig(84.0)) + .setWorkingDirectory(workDir.toString()).setConfigDirectory(configDir.toString()) + .setEnableConfigDiscovery(false).setSkipEmbeddingRetrieval(true) + .setOrganizationCustomInstructions("Use resumed Java instructions.") + .setEnableOnDemandInstructionDiscovery(false).setEnableFileHooks(true) + .setEnableHostGitOperations(false).setEnableSessionStore(true).setEnableSkills(false) + .setEmbeddingCacheStorage("in-memory").setGitHubToken("java-resume-token") + .setRemoteSession("export").setSkipCustomInstructions(false) + .setCustomAgentsLocalOnly(true).setCoauthorEnabled(false).setManageScheduleEnabled(true) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(30, TimeUnit.SECONDS); + session.close(); + } + + var resume = fake.capturedRequest("session.resume").path("params"); + assertEquals("java-resume-session", resume.path("sessionId").asText()); + assertEquals("java-resume-client", resume.path("clientName").asText()); + assertEquals("gpt-5-mini", resume.path("model").asText()); + assertEquals("medium", resume.path("reasoningEffort").asText()); + assertEquals("none", resume.path("reasoningSummary").asText()); + assertEquals("long_context", resume.path("contextTier").asText()); + assertTrue(resume.path("enableCitations").asBoolean()); + assertEquals(84.0, resume.path("sessionLimits").path("maxAiCredits").asDouble()); + assertEquals(workDir.toString(), resume.path("workingDirectory").asText()); + assertEquals(configDir.toString(), resume.path("configDir").asText()); + assertFalse(resume.path("enableConfigDiscovery").asBoolean()); + assertTrue(resume.path("skipEmbeddingRetrieval").asBoolean()); + assertEquals("Use resumed Java instructions.", resume.path("organizationCustomInstructions").asText()); + assertFalse(resume.path("enableOnDemandInstructionDiscovery").asBoolean()); + assertTrue(resume.path("enableFileHooks").asBoolean()); + assertFalse(resume.path("enableHostGitOperations").asBoolean()); + assertTrue(resume.path("enableSessionStore").asBoolean()); + assertFalse(resume.path("enableSkills").asBoolean()); + assertEquals("in-memory", resume.path("embeddingCacheStorage").asText()); + assertEquals("java-resume-token", resume.path("gitHubToken").asText()); + assertEquals("export", resume.path("remoteSession").asText()); + assertEquals("direct", resume.path("envValueMode").asText()); + assertTrue(resume.path("requestPermission").asBoolean()); + + var update = fake.capturedRequest("session.options.update").path("params"); + assertEquals("java-resume-session", update.path("sessionId").asText()); + assertFalse(update.path("skipCustomInstructions").asBoolean()); + assertTrue(update.path("customAgentsLocalOnly").asBoolean()); + assertFalse(update.path("coauthorEnabled").asBoolean()); + assertTrue(update.path("manageScheduleEnabled").asBoolean()); + } + } + + private record FakeStdioCli(Path dir, Path script, Path capture, Path workDir) implements AutoCloseable { + + static FakeStdioCli create() throws IOException { + var dir = Files.createTempDirectory("java-fake-copilot-cli-"); + var script = dir.resolve("fake-copilot-cli.js"); + var capture = dir.resolve("capture.json"); + var workDir = dir.resolve("work"); + Files.createDirectories(workDir); + Files.writeString(capture, "{\"requests\":[]}"); + Files.writeString(script, FAKE_STDIO_CLI_SCRIPT); + return new FakeStdioCli(dir, script, capture, workDir); + } + + CopilotClient createClient() { + var options = new CopilotClientOptions().setCliPath(script.toString()) + .setCliArgs(new String[]{"--capture-file", capture.toString()}).setCwd(workDir.toString()) + .setUseLoggedInUser(false); + return new CopilotClient(options); + } + + Path path(String name) throws IOException { + var path = workDir.resolve(name); + Files.createDirectories(path); + return path; + } + + JsonNode capturedRequest(String method) throws IOException { + for (JsonNode request : MAPPER.readTree(Files.readString(capture)).path("requests")) { + if (method.equals(request.path("method").asText())) { + return request; + } + } + fail("Expected captured request for " + method + " in " + Files.readString(capture)); + return null; + } + + @Override + public void close() throws IOException { + if (Files.exists(dir)) { + try (var paths = Files.walk(dir)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + }); + } + } + } + } + + private static final String FAKE_STDIO_CLI_SCRIPT = """ + const fs = require('fs'); + + const captureFileIndex = process.argv.indexOf('--capture-file'); + const captureFile = process.argv[captureFileIndex + 1]; + const capture = { requests: [] }; + fs.writeFileSync(captureFile, JSON.stringify(capture)); + + let buffer = Buffer.alloc(0); + + function persist() { + fs.writeFileSync(captureFile, JSON.stringify(capture)); + } + + function send(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8'); + process.stdout.write(`Content-Length: ${body.length}\\r\\n\\r\\n`); + process.stdout.write(body); + } + + function resultFor(message) { + switch (message.method) { + case 'connect': + return { ok: true, protocolVersion: 3, version: 'fake' }; + case 'llmInference.setProvider': + return {}; + case 'session.create': + return { sessionId: message.params?.sessionId ?? 'fake-session', openCanvases: [] }; + case 'session.resume': + return { sessionId: message.params?.sessionId ?? 'fake-session', openCanvases: [] }; + case 'session.options.update': + return { success: true }; + default: + return {}; + } + } + + function handle(message) { + capture.requests.push({ method: message.method, params: message.params ?? null }); + persist(); + send({ jsonrpc: '2.0', id: message.id, result: resultFor(message) }); + } + + process.stdin.on('data', chunk => { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const headerEnd = buffer.indexOf('\\r\\n\\r\\n'); + if (headerEnd < 0) { + return; + } + const header = buffer.subarray(0, headerEnd).toString('utf8'); + const match = /Content-Length:\\s*(\\d+)/i.exec(header); + if (!match) { + throw new Error(`Missing Content-Length in ${header}`); + } + const length = Number(match[1]); + const bodyStart = headerEnd + 4; + if (buffer.length < bodyStart + length) { + return; + } + const body = buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); + buffer = buffer.subarray(bodyStart + length); + handle(JSON.parse(body)); + } + }); + """; +} diff --git a/java/src/test/java/com/github/copilot/E2ETestContext.java b/java/src/test/java/com/github/copilot/E2ETestContext.java index 4a4da04229..f524b33dab 100644 --- a/java/src/test/java/com/github/copilot/E2ETestContext.java +++ b/java/src/test/java/com/github/copilot/E2ETestContext.java @@ -381,6 +381,24 @@ public void setCopilotUserByToken(String token, String login, String copilotPlan proxy.setCopilotUserByToken(token, login, copilotPlan, apiUrl, telemetryUrl, analyticsTrackingId); } + /** + * Configures the proxy to return a raw Copilot user response for a given token. + * + * @param token + * the GitHub token + * @param response + * the raw response object to return for the token + * @throws IOException + * if the request fails + * @throws InterruptedException + * if the request is interrupted + */ + public void setCopilotUserByToken(String token, Map response) + throws IOException, InterruptedException { + ensureProxyAlive(); + proxy.setCopilotUserByToken(token, response); + } + /** * Initializes the proxy state without loading a snapshot. *

diff --git a/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java b/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java index 1e602caaa1..e721468c00 100644 --- a/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java +++ b/java/src/test/java/com/github/copilot/McpOAuthE2ETest.java @@ -19,8 +19,11 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import org.junit.jupiter.api.AfterAll; @@ -33,7 +36,9 @@ import com.github.copilot.generated.rpc.SessionMcpAppsCallToolParams; import com.github.copilot.generated.rpc.McpServerStatus; import com.github.copilot.generated.rpc.SessionMcpListToolsParams; +import com.github.copilot.generated.rpc.SessionMcpOauthHandlePendingRequestParams; import com.github.copilot.rpc.McpAuthInvocation; +import com.github.copilot.rpc.McpAuthRequest; import com.github.copilot.rpc.McpAuthResult; import com.github.copilot.rpc.McpAuthToken; import com.github.copilot.rpc.McpHttpServerConfig; @@ -192,6 +197,62 @@ void testShouldCancelPendingMcpOauthRequest() throws Exception { } } + @Test + void testShouldResolvePendingMcpOauthRequestThroughRpc() throws Exception { + try (var oauthServer = OAuthMcpServer.start(ctx.getRepoRoot())) { + var serverName = "oauth-direct-rpc-mcp"; + var observedRequest = new AtomicReference(); + var pendingHandlerResult = new CompletableFuture(); + + try (var client = ctx.createClient(); + var session = client.createSession(new SessionConfig().setEnableMcpApps(true) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnMcpAuthRequest((request, invocation) -> { + assertNotNull(invocation); + observedRequest.set(request); + return pendingHandlerResult; + }).setMcpServers(Map.of(serverName, new McpHttpServerConfig() + .setUrl(oauthServer.url() + "/mcp").setTools(List.of("*"))))) + .get()) { + var connected = CompletableFuture.runAsync(() -> { + try { + waitForMcpServerStatus(session, serverName, McpServerStatus.CONNECTED, observedRequest); + } catch (Exception ex) { + throw new CompletionException(ex); + } + }); + + var request = waitForAuthRequest(observedRequest); + assertEquals(serverName, request.serverName()); + assertEquals(oauthServer.url() + "/mcp", request.serverUrl()); + assertEquals(McpOauthRequestReason.INITIAL, request.reason()); + assertNotNull(request.wwwAuthenticateParams()); + assertEquals(oauthServer.url() + "/.well-known/oauth-protected-resource", + request.wwwAuthenticateParams().resourceMetadataUrl()); + assertEquals("mcp.read", request.wwwAuthenticateParams().scope()); + assertEquals("invalid_token", request.wwwAuthenticateParams().error()); + + var handled = session.getRpc().mcp.oauth.handlePendingRequest( + new SessionMcpOauthHandlePendingRequestParams(null, request.requestId(), Map.of("kind", "token", + "accessToken", EXPECTED_TOKEN, "tokenType", "Bearer", "expiresIn", 3600L))) + .get(30, TimeUnit.SECONDS); + assertTrue(handled.success()); + + pendingHandlerResult.complete(McpAuthResult.cancelled()); + connected.get(60, TimeUnit.SECONDS); + var tools = session.getRpc().mcp.listTools(new SessionMcpListToolsParams(null, serverName)).get(30, + TimeUnit.SECONDS); + assertTrue(tools.tools().stream().anyMatch(tool -> "whoami".equals(tool.name()))); + } finally { + pendingHandlerResult.complete(McpAuthResult.cancelled()); + } + + var requests = oauthServer.requests(); + assertTrue( + requests.stream().anyMatch(record -> ("Bearer " + EXPECTED_TOKEN).equals(record.authorization()))); + } + } + private static void callWhoami(CopilotSession session, String serverName, String scenario) throws Exception { var result = session.getRpc().mcp.apps.callTool( new SessionMcpAppsCallToolParams(null, serverName, "whoami", Map.of("scenario", scenario), serverName)) @@ -221,6 +282,18 @@ private static void waitForMcpServerStatus(CopilotSession session, String server + (observedRequest.get() != null)); } + private static McpAuthRequest waitForAuthRequest(AtomicReference observedRequest) throws Exception { + var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (System.nanoTime() < deadline) { + var request = observedRequest.get(); + if (request != null) { + return request; + } + Thread.sleep(100); + } + throw new AssertionError("Timed out waiting for MCP OAuth request"); + } + @JsonIgnoreProperties(ignoreUnknown = true) private record OAuthMcpRequest(String authorization) { } diff --git a/java/src/test/java/com/github/copilot/RpcServerE2ETest.java b/java/src/test/java/com/github/copilot/RpcServerE2ETest.java new file mode 100644 index 0000000000..2393f334b2 --- /dev/null +++ b/java/src/test/java/com/github/copilot/RpcServerE2ETest.java @@ -0,0 +1,596 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.AccountQuotaSnapshot; +import com.github.copilot.generated.rpc.AgentsDiscoverParams; +import com.github.copilot.generated.rpc.AgentsGetDiscoveryPathsParams; +import com.github.copilot.generated.rpc.InstructionsDiscoverParams; +import com.github.copilot.generated.rpc.InstructionsGetDiscoveryPathsParams; +import com.github.copilot.generated.rpc.LlmInferenceHttpResponseChunkError; +import com.github.copilot.generated.rpc.LlmInferenceHttpResponseChunkParams; +import com.github.copilot.generated.rpc.LlmInferenceHttpResponseStartParams; +import com.github.copilot.generated.rpc.LocalSessionMetadataValue; +import com.github.copilot.generated.rpc.McpDiscoverParams; +import com.github.copilot.generated.rpc.PingParams; +import com.github.copilot.generated.rpc.SecretsAddFilterValuesParams; +import com.github.copilot.generated.rpc.ServerSkill; +import com.github.copilot.generated.rpc.SessionContext; +import com.github.copilot.generated.rpc.SessionFsSetProviderCapabilities; +import com.github.copilot.generated.rpc.SessionFsSetProviderConventions; +import com.github.copilot.generated.rpc.SessionFsSetProviderParams; +import com.github.copilot.generated.rpc.SessionsBulkDeleteParams; +import com.github.copilot.generated.rpc.SessionsCheckInUseParams; +import com.github.copilot.generated.rpc.SessionsCloseParams; +import com.github.copilot.generated.rpc.SessionsConnectParams; +import com.github.copilot.generated.rpc.SessionsEnrichMetadataParams; +import com.github.copilot.generated.rpc.SessionsFindByPrefixParams; +import com.github.copilot.generated.rpc.SessionsFindByTaskIdParams; +import com.github.copilot.generated.rpc.SessionsGetEventFilePathParams; +import com.github.copilot.generated.rpc.SessionsGetLastForContextParams; +import com.github.copilot.generated.rpc.SessionsGetPersistedRemoteSteerableParams; +import com.github.copilot.generated.rpc.SessionsLoadDeferredRepoHooksParams; +import com.github.copilot.generated.rpc.SessionsPruneOldParams; +import com.github.copilot.generated.rpc.SessionsReleaseLockParams; +import com.github.copilot.generated.rpc.SessionsReloadPluginHooksParams; +import com.github.copilot.generated.rpc.SessionsSaveParams; +import com.github.copilot.generated.rpc.SessionsSetAdditionalPluginsParams; +import com.github.copilot.generated.rpc.SkillsConfigSetDisabledSkillsParams; +import com.github.copilot.generated.rpc.SkillsDiscoverParams; +import com.github.copilot.generated.rpc.SkillsGetDiscoveryPathsParams; +import com.github.copilot.generated.rpc.ToolsListParams; +import com.github.copilot.rpc.CopilotClientOptions; +import com.github.copilot.rpc.InfiniteSessionConfig; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +class RpcServerE2ETest { + + private static final long TIMEOUT_SECONDS = 30; + private static final long SESSION_PERSISTENCE_TIMEOUT_MILLIS = 30_000; + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void testShouldCallRpcPingWithTypedParamsAndResult() throws Exception { + ctx.configureForTest("rpc_server", "should_call_rpc_ping_with_typed_params_and_result"); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + var result = client.getRpc().ping(new PingParams("typed rpc test")).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertEquals("pong: typed rpc test", result.message()); + assertNotNull(result.timestamp()); + assertNotNull(result.protocolVersion()); + assertTrue(result.protocolVersion() >= 0); + } + } + + @Test + void testShouldRejectLlmInferenceResponseFramesForMissingRequest() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var requestId = "missing-llm-inference-request"; + + var start = client.getRpc().llmInference + .httpResponseStart(new LlmInferenceHttpResponseStartParams(requestId, 200L, "OK", + Map.of("content-type", List.of("text/event-stream")))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertFalse(start.accepted()); + + var chunk = client.getRpc().llmInference + .httpResponseChunk( + new LlmInferenceHttpResponseChunkParams(requestId, "data: {}\n\n", false, false, null)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertFalse(chunk.accepted()); + + var error = client.getRpc().llmInference.httpResponseChunk(new LlmInferenceHttpResponseChunkParams( + requestId, "", null, true, + new LlmInferenceHttpResponseChunkError("No pending LLM inference request.", "missing_request"))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertFalse(error.accepted()); + } + } + + @Test + void testShouldCallRpcModelsListWithTypedResult() throws Exception { + ctx.configureForTest("rpc_server", "should_call_rpc_models_list_with_typed_result"); + var token = "rpc-models-token"; + configureAuthenticatedUser(token, null); + + try (var client = createAuthenticatedClient(token)) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + var result = client.getRpc().models.list().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertNotNull(result.models()); + assertTrue(result.models().stream().anyMatch(model -> "claude-sonnet-4.5".equals(model.id()))); + result.models().forEach(model -> { + assertFalse(model.id().isBlank()); + assertFalse(model.name().isBlank()); + }); + } + } + + @Test + void testShouldCallRpcAccountGetQuotaWhenAuthenticated() throws Exception { + ctx.configureForTest("rpc_server", "should_call_rpc_account_get_quota_when_authenticated"); + var token = "rpc-quota-token"; + configureAuthenticatedUser(token, Map.of("chat", Map.of("entitlement", 100, "overage_count", 2, + "overage_permitted", true, "percent_remaining", 75, "timestamp_utc", "2026-04-30T00:00:00Z"))); + + try (var client = createAuthenticatedClient(token)) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + var result = client.getRpc().account.getQuota().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertNotNull(result.quotaSnapshots()); + var chatQuota = result.quotaSnapshots().get("chat"); + assertNotNull(chatQuota); + assertQuota(chatQuota); + } + } + + @Test + void testShouldCallRpcToolsListWithTypedResult() throws Exception { + ctx.configureForTest("rpc_server", "should_call_rpc_tools_list_with_typed_result"); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + var result = client.getRpc().tools.list(new ToolsListParams(null)).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertNotNull(result.tools()); + assertFalse(result.tools().isEmpty()); + result.tools().forEach(tool -> assertFalse(tool.name().isBlank())); + } + } + + @Test + void testShouldCallRpcSessionFsSetProviderWithTypedResult() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + var result = client.getRpc().sessionFs + .setProvider(new SessionFsSetProviderParams(ctx.getWorkDir().toString(), + ctx.getWorkDir().resolve("session-state").toString(), currentPathConventions(), + new SessionFsSetProviderCapabilities(true))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(result.success()); + } + } + + @Test + void testShouldAddSecretFilterValues() throws Exception { + ctx.initializeProxy(); + var env = new HashMap<>(ctx.getEnvironment()); + env.put("COPILOT_ENABLE_SECRET_FILTERING", "true"); + + try (var client = ctx.createClient(new CopilotClientOptions().setEnvironment(env))) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var secret = "rpc-secret-" + UUID.randomUUID().toString().replace("-", ""); + + var result = client.getRpc().secrets.addFilterValues(new SecretsAddFilterValuesParams(List.of(secret))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(result.ok()); + } + } + + @Test + void testShouldListFindAndInspectPersistedSessionState() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + var requestedSessionId = UUID.randomUUID().toString(); + var workingDirectory = createUniqueWorkDirectory("server-rpc-list"); + var missingTaskId = "missing-task-" + UUID.randomUUID().toString().replace("-", ""); + var missingSessionId = UUID.randomUUID().toString(); + + try (var session = client.createSession(persistedSessionConfig(requestedSessionId, workingDirectory)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + var sessionId = session.getSessionId(); + session.log("SERVER_RPC_LIST_READY").get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + saveSession(client, sessionId); + assertNull(client.getRpc().sessions.close(new SessionsCloseParams(sessionId)).get(TIMEOUT_SECONDS, + TimeUnit.SECONDS)); + + var listed = client.getRpc().sessions.list().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(listed.sessions()); + + var byPrefix = client.getRpc().sessions + .findByPrefix(new SessionsFindByPrefixParams(sessionId.substring(0, 8))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(byPrefix.sessionId() == null || sessionId.equals(byPrefix.sessionId())); + + var byTaskId = client.getRpc().sessions.findByTaskId(new SessionsFindByTaskIdParams(missingTaskId)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNull(byTaskId.sessionId()); + + var lastForContext = client.getRpc().sessions + .getLastForContext(new SessionsGetLastForContextParams( + new SessionContext(workingDirectory.toString(), null, null, null, null))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(lastForContext.sessionId() == null || sessionId.equals(lastForContext.sessionId())); + + var eventFile = client.getRpc().sessions.getEventFilePath(new SessionsGetEventFilePathParams(sessionId)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(eventFile.filePath().endsWith("events.jsonl")); + + var remoteSteerable = client.getRpc().sessions + .getPersistedRemoteSteerable(new SessionsGetPersistedRemoteSteerableParams(sessionId)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNull(remoteSteerable.remoteSteerable()); + + var sizes = client.getRpc().sessions.getSizes().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(sizes.sizes()); + if (sizes.sizes().containsKey(sessionId)) { + assertTrue(sizes.sizes().get(sessionId) >= 0); + } + + var inUse = client.getRpc().sessions + .checkInUse(new SessionsCheckInUseParams(List.of(sessionId, missingSessionId))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(inUse.inUse()); + assertFalse(inUse.inUse().contains(missingSessionId)); + } + } + } + + @Test + void testShouldEnrichBasicSessionMetadata() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + var requestedSessionId = UUID.randomUUID().toString(); + var workingDirectory = createUniqueWorkDirectory("server-rpc-enrich"); + + try (var session = client.createSession(persistedSessionConfig(requestedSessionId, workingDirectory)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + var sessionId = session.getSessionId(); + session.log("SERVER_RPC_ENRICH_READY").get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + saveSession(client, sessionId); + + var now = OffsetDateTime.now().toString(); + var basic = new LocalSessionMetadataValue(sessionId, now, now, null, "Basic metadata", null, false, + null, new SessionContext(workingDirectory.toString(), null, null, null, null), null); + + var result = client.getRpc().sessions.enrichMetadata(new SessionsEnrichMetadataParams(List.of(basic))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertNotNull(result.sessions()); + assertEquals(1, result.sessions().size()); + var enriched = result.sessions().get(0); + assertEquals(sessionId, enriched.sessionId()); + assertNotNull(enriched.context()); + assertTrue(pathsEqual(workingDirectory.toString(), enriched.context().cwd())); + assertFalse(enriched.isRemote()); + } + } + } + + @Test + void testShouldCloseActiveSessionAndReleaseLock() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + var requestedSessionId = UUID.randomUUID().toString(); + var workingDirectory = createUniqueWorkDirectory("server-rpc-close"); + + try (var session = client.createSession(persistedSessionConfig(requestedSessionId, workingDirectory)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + var sessionId = session.getSessionId(); + session.log("SERVER_RPC_CLOSE_READY").get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + saveSession(client, sessionId); + + var close = client.getRpc().sessions.close(new SessionsCloseParams(sessionId)).get(TIMEOUT_SECONDS, + TimeUnit.SECONDS); + assertNull(close); + + var release = client.getRpc().sessions.releaseLock(new SessionsReleaseLockParams(sessionId)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNull(release); + + var inUse = client.getRpc().sessions.checkInUse(new SessionsCheckInUseParams(List.of(sessionId))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertFalse(inUse.inUse().contains(sessionId)); + } + } + } + + @Test + void testShouldPruneDryRunAndBulkDeletePersistedSession() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + var requestedSessionId = UUID.randomUUID().toString(); + var missingSessionId = UUID.randomUUID().toString(); + var workingDirectory = createUniqueWorkDirectory("server-rpc-delete"); + + var session = client.createSession(persistedSessionConfig(requestedSessionId, workingDirectory)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + var sessionId = session.getSessionId(); + saveSession(client, sessionId); + client.getRpc().sessions.close(new SessionsCloseParams(sessionId)).get(TIMEOUT_SECONDS, + TimeUnit.SECONDS); + + var prune = client.getRpc().sessions.pruneOld(new SessionsPruneOldParams(0L, true, true, List.of())) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(prune.dryRun()); + assertNotNull(prune.candidates()); + assertNotNull(prune.deleted()); + assertFalse(prune.deleted().contains(sessionId)); + assertFalse(prune.candidates().contains(missingSessionId)); + assertNotNull(prune.freedBytes()); + assertTrue(prune.freedBytes() >= 0); + + var delete = client.getRpc().sessions + .bulkDelete(new SessionsBulkDeleteParams(List.of(sessionId, missingSessionId))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(delete.freedBytes().containsKey(sessionId)); + assertTrue(delete.freedBytes().get(sessionId) >= 0); + if (delete.freedBytes().containsKey(missingSessionId)) { + assertEquals(0L, delete.freedBytes().get(missingSessionId)); + } + + waitForSessionAbsent(client, sessionId); + } finally { + session.close(); + } + } + } + + @Test + void testShouldSetAdditionalPluginsAndReloadDeferredHooks() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNull(client.getRpc().sessions.setAdditionalPlugins(new SessionsSetAdditionalPluginsParams(List.of())) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + var requestedSessionId = UUID.randomUUID().toString(); + var workingDirectory = createUniqueWorkDirectory("server-rpc-hooks"); + + try (var session = client.createSession( + persistedSessionConfig(requestedSessionId, workingDirectory).setEnableConfigDiscovery(false)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + var sessionId = session.getSessionId(); + var reload = client.getRpc().sessions + .reloadPluginHooks(new SessionsReloadPluginHooksParams(sessionId, true)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNull(reload); + + var loaded = client.getRpc().sessions + .loadDeferredRepoHooks(new SessionsLoadDeferredRepoHooksParams(sessionId)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(loaded.startupPrompts()); + assertEquals(0L, loaded.hookCount()); + assertTrue(loaded.startupPrompts().isEmpty()); + } finally { + client.getRpc().sessions.setAdditionalPlugins(new SessionsSetAdditionalPluginsParams(List.of())) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + } + + @Test + void testShouldReportImplementedErrorWhenConnectingUnknownRemoteSession() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var remoteSessionId = "remote-" + UUID.randomUUID().toString().replace("-", ""); + + var ex = assertThrows(Exception.class, () -> client.getRpc().sessions + .connect(new SessionsConnectParams(remoteSessionId)).get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + var text = ex.toString(); + assertFalse(text.toLowerCase().contains("unhandled method sessions.connect")); + assertTrue(text.toLowerCase().contains("session")); + } + } + + @Test + void testShouldDiscoverServerMcpSkillsAgentsAndInstructions() throws Exception { + ctx.configureForTest("rpc_server", "should_discover_server_mcp_and_skills"); + + try (var client = ctx.createClient()) { + client.start().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var workDir = ctx.getWorkDir().toString(); + var skillName = "server-rpc-skill-" + UUID.randomUUID().toString().replace("-", ""); + var skillDirectory = createSkillDirectory(skillName, "Skill discovered by server-scoped RPC tests."); + + var mcp = client.getRpc().mcp.discover(new McpDiscoverParams(workDir)).get(TIMEOUT_SECONDS, + TimeUnit.SECONDS); + assertNotNull(mcp.servers()); + + var skills = client.getRpc().skills + .discover(new SkillsDiscoverParams(null, List.of(skillDirectory.toString()), null)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var discoveredSkill = findSkill(skills.skills(), skillName); + assertEquals("Skill discovered by server-scoped RPC tests.", discoveredSkill.description()); + assertTrue(discoveredSkill.enabled()); + assertTrue(discoveredSkill.path().replace('\\', '/').endsWith(skillName + "/SKILL.md")); + + var skillPaths = client.getRpc().skills + .getDiscoveryPaths(new SkillsGetDiscoveryPathsParams(List.of(workDir), true)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var projectSkillPath = skillPaths.paths().stream().filter( + path -> pathsEqual(workDir, path.projectPath()) && Boolean.TRUE.equals(path.preferredForCreation())) + .findFirst().orElseThrow(() -> new AssertionError("Expected project skill discovery path")); + assertFalse(projectSkillPath.path().isBlank()); + + var agents = client.getRpc().agents.discover(new AgentsDiscoverParams(List.of(workDir), true)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(agents.agents()); + agents.agents().forEach(agent -> assertFalse(agent.name().isBlank())); + + var agentPaths = client.getRpc().agents + .getDiscoveryPaths(new AgentsGetDiscoveryPathsParams(List.of(workDir), true)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var projectAgentPath = agentPaths.paths().stream().filter( + path -> pathsEqual(workDir, path.projectPath()) && Boolean.TRUE.equals(path.preferredForCreation())) + .findFirst().orElseThrow(() -> new AssertionError("Expected project agent discovery path")); + assertFalse(projectAgentPath.path().isBlank()); + + var instructions = client.getRpc().instructions + .discover(new InstructionsDiscoverParams(List.of(workDir), true)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(instructions.sources()); + instructions.sources().forEach(source -> { + assertFalse(source.id().isBlank()); + assertFalse(source.label().isBlank()); + assertFalse(source.sourcePath().isBlank()); + }); + + var instructionPaths = client.getRpc().instructions + .getDiscoveryPaths(new InstructionsGetDiscoveryPathsParams(List.of(workDir), true)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertFalse(instructionPaths.paths().isEmpty()); + assertTrue(instructionPaths.paths().stream().anyMatch(path -> pathsEqual(workDir, path.projectPath()))); + instructionPaths.paths().forEach(path -> assertFalse(path.path().isBlank())); + + try { + client.getRpc().skills.config + .setDisabledSkills(new SkillsConfigSetDisabledSkillsParams(List.of(skillName))) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var disabledSkills = client.getRpc().skills + .discover(new SkillsDiscoverParams(null, List.of(skillDirectory.toString()), null)) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + var disabledSkill = findSkill(disabledSkills.skills(), skillName); + assertFalse(disabledSkill.enabled()); + } finally { + client.getRpc().skills.config.setDisabledSkills(new SkillsConfigSetDisabledSkillsParams(List.of())) + .get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + } + + private static CopilotClient createAuthenticatedClient(String token) throws Exception { + return ctx.createClient(new CopilotClientOptions().setGitHubToken(token)); + } + + private static void configureAuthenticatedUser(String token, Map quotaSnapshots) throws Exception { + var user = new HashMap(); + user.put("login", "rpc-user"); + user.put("copilot_plan", "individual_pro"); + user.put("endpoints", Map.of("api", ctx.getProxyUrl(), "telemetry", "https://localhost:1/telemetry")); + user.put("analytics_tracking_id", "rpc-user-tracking-id"); + if (quotaSnapshots != null) { + user.put("quota_snapshots", quotaSnapshots); + } + ctx.setCopilotUserByToken(token, user); + } + + private static void assertQuota(AccountQuotaSnapshot chatQuota) { + assertEquals(100L, chatQuota.entitlementRequests()); + assertEquals(25L, chatQuota.usedRequests()); + assertEquals(75.0, chatQuota.remainingPercentage()); + assertEquals(2.0, chatQuota.overage()); + assertTrue(chatQuota.usageAllowedWithExhaustedQuota()); + assertTrue(chatQuota.overageAllowedWithExhaustedQuota()); + assertEquals(OffsetDateTime.parse("2026-04-30T00:00:00Z"), chatQuota.resetDate()); + } + + private static SessionFsSetProviderConventions currentPathConventions() { + return isWindows() ? SessionFsSetProviderConventions.WINDOWS : SessionFsSetProviderConventions.POSIX; + } + + private static Path createUniqueWorkDirectory(String prefix) throws Exception { + var directory = ctx.getWorkDir().resolve(prefix + "-" + UUID.randomUUID().toString().replace("-", "")); + Files.createDirectories(directory); + return directory; + } + + private static SessionConfig persistedSessionConfig(String sessionId, Path workingDirectory) { + return new SessionConfig().setSessionId(sessionId).setWorkingDirectory(workingDirectory.toString()) + .setInfiniteSessions(new InfiniteSessionConfig().setEnabled(true)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + } + + private static void saveSession(CopilotClient client, String sessionId) throws Exception { + var save = client.getRpc().sessions.save(new SessionsSaveParams(sessionId)).get(TIMEOUT_SECONDS, + TimeUnit.SECONDS); + assertNull(save); + } + + private static void waitForSessionAbsent(CopilotClient client, String sessionId) throws Exception { + var deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SESSION_PERSISTENCE_TIMEOUT_MILLIS); + do { + var list = client.getRpc().sessions.list().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertNotNull(list.sessions()); + var present = list.sessions().stream() + .anyMatch(session -> session instanceof Map map && sessionId.equals(map.get("sessionId"))); + if (!present) { + return; + } + Thread.sleep(100); + } while (System.nanoTime() < deadline); + + throw new AssertionError("Timed out waiting for session '" + sessionId + "' to be removed."); + } + + private static Path createSkillDirectory(String skillName, String description) throws Exception { + var skillsDir = ctx.getWorkDir().resolve("server-rpc-skills") + .resolve(UUID.randomUUID().toString().replace("-", "")); + var skillSubdir = skillsDir.resolve(skillName); + Files.createDirectories(skillSubdir); + Files.writeString(skillSubdir.resolve("SKILL.md"), "---\nname: " + skillName + "\ndescription: " + description + + "\n---\n\n# " + skillName + "\n\nThis skill is used by RPC E2E tests.\n"); + return skillsDir; + } + + private static ServerSkill findSkill(List skills, String name) { + return skills.stream().filter(skill -> name.equals(skill.name())).findFirst() + .orElseThrow(() -> new AssertionError("Expected to discover skill " + name)); + } + + private static boolean pathsEqual(String expected, String actual) { + if (actual == null) { + return false; + } + + var expectedPath = Path.of(expected).toAbsolutePath().normalize().toString(); + var actualPath = Path.of(actual).toAbsolutePath().normalize().toString(); + return isWindows() ? expectedPath.equalsIgnoreCase(actualPath) : expectedPath.equals(actualPath); + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase().contains("win"); + } +} diff --git a/java/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java b/java/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java new file mode 100644 index 0000000000..1db801d841 --- /dev/null +++ b/java/src/test/java/com/github/copilot/RpcServerMiscE2ETest.java @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.AccountAllUsers; +import com.github.copilot.generated.rpc.AccountLoginParams; +import com.github.copilot.generated.rpc.AccountLogoutParams; +import com.github.copilot.generated.rpc.UserSettingMetadata; +import com.github.copilot.generated.rpc.UserSettingsSetParams; +import com.github.copilot.rpc.CopilotClientOptions; + +class RpcServerMiscE2ETest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void testShouldGetSetAndClearUserSettings() throws Exception { + ctx.configureForTest("rpc_server_misc", "should_get_set_and_clear_user_settings"); + + try (var client = ctx.createClient()) { + client.start().get(30, TimeUnit.SECONDS); + var before = client.getRpc().user.settings.get().get(30, TimeUnit.SECONDS); + var entry = before.settings().entrySet().stream().filter(e -> isBooleanSetting(e.getValue())).findFirst() + .orElseThrow(() -> new AssertionError("Expected at least one boolean user setting")); + var key = entry.getKey(); + var original = settingBoolean(entry.getValue()); + var updated = !original; + + var set = client.getRpc().user.settings.set(new UserSettingsSetParams(Map.of(key, updated))).get(30, + TimeUnit.SECONDS); + assertTrue(set.shadowedKeys().isEmpty()); + client.getRpc().user.settings.reload().get(30, TimeUnit.SECONDS); + var afterSet = client.getRpc().user.settings.get().get(30, TimeUnit.SECONDS); + assertEquals(updated, settingBoolean(afterSet.settings().get(key))); + assertFalse(afterSet.settings().get(key).isDefault()); + + var clearSettings = new HashMap(); + clearSettings.put(key, null); + var clear = client.getRpc().user.settings.set(new UserSettingsSetParams(clearSettings)).get(30, + TimeUnit.SECONDS); + assertTrue(clear.shadowedKeys().isEmpty()); + client.getRpc().user.settings.reload().get(30, TimeUnit.SECONDS); + var afterClear = client.getRpc().user.settings.get().get(30, TimeUnit.SECONDS); + assertTrue(afterClear.settings().get(key).isDefault()); + } + } + + @Test + void testShouldLoginListGetCurrentAuthAndLogoutAccount() throws Exception { + ctx.configureForTest("rpc_server_misc", "should_login_list_getcurrentauth_and_logout_account"); + var token = "java-account-token"; + var login = "java-account-user"; + ctx.setCopilotUserByToken(token, login, "individual_pro", ctx.getProxyUrl(), "https://localhost:1/telemetry", + "java-account-tracking-id"); + + var env = new HashMap<>(ctx.getEnvironment()); + env.put("GH_TOKEN", ""); + env.put("GITHUB_TOKEN", ""); + env.put("COPILOT_SDK_AUTH_TOKEN", ""); + + try (var client = new CopilotClient( + new CopilotClientOptions().setCliPath(ctx.getCliPath()).setCwd(ctx.getWorkDir().toString()) + .setEnvironment(env).setGitHubToken("").setUseLoggedInUser(false))) { + client.start().get(30, TimeUnit.SECONDS); + + var initial = client.getRpc().account.getCurrentAuth().get(30, TimeUnit.SECONDS); + assertNull(initial.authInfo()); + + var loginResult = client.getRpc().account.login(new AccountLoginParams("https://github.com", login, token)) + .get(30, TimeUnit.SECONDS); + assertNotNull(loginResult); + + var current = client.getRpc().account.getCurrentAuth().get(30, TimeUnit.SECONDS); + assertNull(current.authErrors()); + assertInstanceOf(Map.class, current.authInfo()); + @SuppressWarnings("unchecked") + var authInfo = (Map) current.authInfo(); + assertEquals(login, authInfo.get("login")); + assertEquals("https://github.com", authInfo.get("host")); + + var users = client.getRpc().account.getAllUsers().get(30, TimeUnit.SECONDS); + users.stream().filter(user -> accountLogin(user).equals(login)).findFirst() + .ifPresent(user -> assertEquals(token, user.token())); + + var logout = client.getRpc().account.logout(new AccountLogoutParams(authInfo)).get(30, TimeUnit.SECONDS); + assertFalse(logout.hasMoreUsers()); + assertNull(client.getRpc().account.getCurrentAuth().get(30, TimeUnit.SECONDS).authInfo()); + } + } + + private static boolean isBooleanSetting(UserSettingMetadata metadata) { + return metadata.value() instanceof Boolean || metadata.default_() instanceof Boolean; + } + + private static boolean settingBoolean(UserSettingMetadata metadata) { + if (metadata.value() instanceof Boolean value) { + return value; + } + return (Boolean) metadata.default_(); + } + + private static String accountLogin(AccountAllUsers user) { + if (user.authInfo() instanceof Map authInfo) { + return String.valueOf(authInfo.get("login")); + } + return ""; + } +} diff --git a/java/src/test/java/com/github/copilot/RpcSessionStateExtrasE2ETest.java b/java/src/test/java/com/github/copilot/RpcSessionStateExtrasE2ETest.java new file mode 100644 index 0000000000..0d323183b5 --- /dev/null +++ b/java/src/test/java/com/github/copilot/RpcSessionStateExtrasE2ETest.java @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.NamedProviderConfig; +import com.github.copilot.generated.rpc.ProviderConfigType; +import com.github.copilot.generated.rpc.ProviderConfigWireApi; +import com.github.copilot.generated.rpc.ProviderModelConfig; +import com.github.copilot.generated.rpc.SessionCompletionsRequestParams; +import com.github.copilot.generated.rpc.SessionMetadataGetContextHeaviestMessagesParams; +import com.github.copilot.generated.rpc.SessionModelSwitchToParams; +import com.github.copilot.generated.rpc.SessionProviderAddParams; +import com.github.copilot.generated.rpc.SessionToolsUpdateSubagentSettingsParams; +import com.github.copilot.generated.rpc.SessionVisibilitySetParams; +import com.github.copilot.generated.rpc.SessionVisibilityStatus; +import com.github.copilot.generated.rpc.SubagentSettingsEntry; +import com.github.copilot.generated.rpc.SubagentSettingsEntryContextTier; +import com.github.copilot.rpc.MessageOptions; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +class RpcSessionStateExtrasE2ETest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void testShouldAddByokProviderAndModelAtRuntime() throws Exception { + ctx.configureForTest("rpc_session_state_extras", "should_add_byok_provider_and_model_at_runtime"); + + try (var client = ctx.createClient()) { + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get()) { + var result = session.getRpc().provider.add(new SessionProviderAddParams(null, + List.of(new NamedProviderConfig("java-e2e-provider", ProviderConfigType.OPENAI, + ProviderConfigWireApi.COMPLETIONS, null, "https://models.example.test/v1", + "provider-key", null, null, Map.of("x-provider", "java"), null)), + List.of(new ProviderModelConfig("small", "java-e2e-provider", null, null, "Java Added Model", + 4096.0, null, null, null)))) + .get(30, TimeUnit.SECONDS); + assertEquals(1, result.models().size()); + + var selectionId = "java-e2e-provider/small"; + session.getRpc().model + .switchTo(new SessionModelSwitchToParams(null, selectionId, null, null, null, null)) + .get(30, TimeUnit.SECONDS); + var current = session.getRpc().model.getCurrent().get(30, TimeUnit.SECONDS); + assertEquals(selectionId, current.modelId()); + } + } + } + + @Test + void testShouldReturnEmptyCompletionsWhenHostDoesNotProvideThem() throws Exception { + ctx.configureForTest("rpc_session_state_extras", + "should_return_empty_completions_when_host_does_not_provide_them"); + + try (var client = ctx.createClient()) { + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get()) { + var result = session.getRpc().completions + .request(new SessionCompletionsRequestParams(null, "Use @ to mention context", 5L)) + .get(30, TimeUnit.SECONDS); + assertTrue(result.items().isEmpty()); + } + } + } + + @Test + void testShouldReportVisibilityAsUnsyncedForLocalSession() throws Exception { + ctx.configureForTest("rpc_session_state_extras", "should_report_visibility_as_unsynced_for_local_session"); + + try (var client = ctx.createClient()) { + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get()) { + var set = session.getRpc().visibility + .set(new SessionVisibilitySetParams(null, SessionVisibilityStatus.UNSHARED)) + .get(30, TimeUnit.SECONDS); + assertFalse(set.synced()); + assertNull(set.status()); + assertNull(set.shareUrl()); + + var get = session.getRpc().visibility.get().get(30, TimeUnit.SECONDS); + assertFalse(get.synced()); + assertNull(get.status()); + assertNull(get.shareUrl()); + } + } + } + + @Test + void testShouldGetContextAttributionAndHeaviestMessagesAfterTurn() throws Exception { + ctx.configureForTest("rpc_session_state_extras", + "should_get_context_attribution_and_heaviest_messages_after_turn"); + + try (var client = ctx.createClient()) { + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get()) { + var answer = session.sendAndWait(new MessageOptions().setPrompt("Say CONTEXT_METADATA_OK exactly.")) + .get(60, TimeUnit.SECONDS); + assertTrue(answer.getData().content().contains("CONTEXT_METADATA_OK")); + + var attribution = session.getRpc().metadata.getContextAttribution().get(30, TimeUnit.SECONDS); + assertNotNull(attribution.contextAttribution()); + var heaviest = session.getRpc().metadata + .getContextHeaviestMessages(new SessionMetadataGetContextHeaviestMessagesParams(null, 5L)) + .get(30, TimeUnit.SECONDS); + assertTrue(heaviest.totalTokens() >= 0); + } + } + } + + @Test + void testShouldUpdateAndClearLiveSubagentSettings() throws Exception { + ctx.configureForTest("rpc_session_state_extras", "should_update_and_clear_live_subagent_settings"); + + try (var client = ctx.createClient()) { + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get()) { + session.getRpc().tools.updateSubagentSettings(new SessionToolsUpdateSubagentSettingsParams(null, + new SessionToolsUpdateSubagentSettingsParams.SessionToolsUpdateSubagentSettingsParamsSubagents( + Map.of("general-purpose", + new SubagentSettingsEntry("gpt-5-mini", "low", + SubagentSettingsEntryContextTier.LONG_CONTEXT)), + List.of("legacy-agent"), null, null))) + .get(30, TimeUnit.SECONDS); + session.getRpc().tools.updateSubagentSettings(new SessionToolsUpdateSubagentSettingsParams(null, null)) + .get(30, TimeUnit.SECONDS); + } + } + } +} diff --git a/java/src/test/java/com/github/copilot/RpcTasksAndHandlersE2ETest.java b/java/src/test/java/com/github/copilot/RpcTasksAndHandlersE2ETest.java new file mode 100644 index 0000000000..89b283339e --- /dev/null +++ b/java/src/test/java/com/github/copilot/RpcTasksAndHandlersE2ETest.java @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.generated.rpc.SessionMcpHeadersHandlePendingHeadersRefreshRequestParams; +import com.github.copilot.generated.rpc.SessionUiHandlePendingSessionLimitsExhaustedParams; +import com.github.copilot.generated.rpc.UISessionLimitsExhaustedResponse; +import com.github.copilot.generated.rpc.UISessionLimitsExhaustedResponseAction; +import com.github.copilot.rpc.PermissionHandler; +import com.github.copilot.rpc.SessionConfig; + +class RpcTasksAndHandlersE2ETest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + @Test + void testShouldReturnExpectedResultsForMissingPendingHandlerRequestIds() throws Exception { + ctx.initializeProxy(); + + try (var client = ctx.createClient()) { + try (var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get()) { + var sessionLimits = session.getRpc().ui + .handlePendingSessionLimitsExhausted( + new SessionUiHandlePendingSessionLimitsExhaustedParams(null, + "missing-session-limits-request", + new UISessionLimitsExhaustedResponse( + UISessionLimitsExhaustedResponseAction.UNSET, null, null))) + .get(30, TimeUnit.SECONDS); + assertFalse(sessionLimits.success()); + + var headersRefresh = session.getRpc().mcp.headers + .handlePendingHeadersRefreshRequest( + new SessionMcpHeadersHandlePendingHeadersRefreshRequestParams(null, + "missing-headers-refresh-request", + Map.of("kind", "headers", "headers", Map.of("x-refresh", "missing")))) + .get(30, TimeUnit.SECONDS); + assertFalse(headersRefresh.success()); + + var noHeadersRefresh = session.getRpc().mcp.headers + .handlePendingHeadersRefreshRequest( + new SessionMcpHeadersHandlePendingHeadersRefreshRequestParams(null, + "missing-headers-refresh-none-request", Map.of("kind", "none"))) + .get(30, TimeUnit.SECONDS); + assertFalse(noHeadersRefresh.success()); + } + } + } +} diff --git a/nodejs/test/e2e/client_options.e2e.test.ts b/nodejs/test/e2e/client_options.e2e.test.ts index 2cfc69456f..2910b6fb9c 100644 --- a/nodejs/test/e2e/client_options.e2e.test.ts +++ b/nodejs/test/e2e/client_options.e2e.test.ts @@ -6,8 +6,8 @@ import * as fs from "fs"; import * as net from "net"; import * as path from "path"; import { describe, expect, it, onTestFinished } from "vitest"; -import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; -import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { approveAll, CopilotClient, createCanvas, RuntimeConnection } from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; const FAKE_STDIO_CLI_SCRIPT = `const fs = require("fs"); @@ -99,6 +99,17 @@ function handleMessage(message) { return; } + if (message.method === "session.resume") { + const sessionId = message.params?.sessionId ?? message.params?.[0]?.sessionId ?? "fake-session"; + writeResponse(message.id, { + sessionId, + workspacePath: null, + capabilities: null, + openCanvases: message.params?.openCanvases ?? [] + }); + return; + } + writeResponse(message.id, {}); } @@ -138,6 +149,27 @@ function assertArgumentValue( expect(args[index + 1]).toBe(expectedValue); } +function getCapturedRequest(capturePath: string, method: string): Record { + const raw = fs.readFileSync(capturePath, "utf8"); + const capture = JSON.parse(raw) as { + requests: { method: string; params: Record }[]; + }; + const request = capture.requests.find((r) => r.method === method); + expect(request, `Expected ${method} request in capture`).toBeDefined(); + return request!.params; +} + +function getObject(value: unknown): Record { + expect(value).toBeTypeOf("object"); + expect(value).not.toBeNull(); + return value as Record; +} + +function getArray(value: unknown): unknown[] { + expect(Array.isArray(value)).toBe(true); + return value as unknown[]; +} + describe("Client options", async () => { const { copilotClient: defaultClient, env, workDir } = await createSdkTestContext(); @@ -146,6 +178,7 @@ describe("Client options", async () => { workingDirectory: workDir, env, connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + gitHubToken: DEFAULT_GITHUB_TOKEN, }); onTestFinished(async () => { try { @@ -200,7 +233,7 @@ describe("Client options", async () => { workingDirectory: clientCwd, env, connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - gitHubToken: process.env.CI ? "fake-token-for-e2e-tests" : undefined, + gitHubToken: DEFAULT_GITHUB_TOKEN, }); onTestFinished(async () => { try { @@ -318,6 +351,315 @@ describe("Client options", async () => { await session.disconnect(); }); + it("should forward advanced session options in create wire request", async () => { + const cliPath = path.join( + workDir, + `fake-cli-advanced-create-${Date.now()}-${Math.random().toString(36).slice(2)}.js` + ); + const capturePath = path.join( + workDir, + `fake-cli-advanced-create-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.json` + ); + const outputDirectory = path.join(workDir, "large-output-create"); + fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); + + const client = new CopilotClient({ + workingDirectory: workDir, + env, + connection: RuntimeConnection.forStdio({ + path: cliPath, + args: ["--capture-file", capturePath], + }), + useLoggedInUser: false, + }); + onTestFinished(async () => { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors + } + }); + + await client.start(); + + const canvas = createCanvas({ + id: "advanced-create-canvas", + displayName: "Advanced Create Canvas", + description: "Covers create-time canvas options.", + open: () => ({ url: "https://example.test/advanced-create-canvas" }), + }); + const session = await client.createSession({ + clientName: "advanced-create-client", + model: "claude-sonnet-4.5", + reasoningEffort: "medium", + reasoningSummary: "detailed", + contextTier: "long_context", + enableCitations: true, + capi: { enableWebSocketResponses: false }, + mcpOAuthTokenStorage: "persistent", + customAgents: [ + { + name: "agent-one", + displayName: "Agent One", + description: "Handles agent-one tasks.", + prompt: "Be agent one.", + tools: ["view"], + infer: true, + skills: ["create-skill"], + model: "claude-haiku-4.5", + }, + ], + defaultAgent: { excludedTools: ["edit"] }, + agent: "agent-one", + skillDirectories: ["skills-create"], + disabledSkills: ["disabled-create-skill"], + pluginDirectories: ["plugins-create"], + infiniteSessions: { + enabled: false, + backgroundCompactionThreshold: 0.5, + bufferExhaustionThreshold: 0.9, + }, + largeOutput: { + enabled: true, + maxSizeBytes: 4096, + outputDirectory, + }, + memory: { enabled: true }, + gitHubToken: "session-create-token", + remoteSession: "export", + cloud: { + repository: { + owner: "github", + name: "copilot-sdk", + branch: "main", + }, + }, + enableMcpApps: true, + requestCanvasRenderer: true, + requestExtensions: true, + extensionSdkPath: "custom-extension-sdk", + extensionInfo: { source: "typescript-sdk-tests", name: "advanced-create-extension" }, + canvases: [canvas], + providers: [ + { + name: "create-provider", + type: "openai", + wireApi: "responses", + baseUrl: "https://create-provider.example.test/v1", + apiKey: "create-provider-key", + headers: { "X-Create-Provider": "yes" }, + }, + ], + models: [ + { + provider: "create-provider", + id: "create-model", + name: "Create Model", + modelId: "claude-sonnet-4.5", + wireModel: "create-wire-model", + maxContextWindowTokens: 12_000, + maxPromptTokens: 10_000, + maxOutputTokens: 2_000, + }, + ], + onPermissionRequest: approveAll, + }); + + const createRequest = getCapturedRequest(capturePath, "session.create"); + expect(createRequest.clientName).toBe("advanced-create-client"); + expect(createRequest.model).toBe("claude-sonnet-4.5"); + expect(createRequest.reasoningEffort).toBe("medium"); + expect(createRequest.reasoningSummary).toBe("detailed"); + expect(createRequest.contextTier).toBe("long_context"); + expect(createRequest.enableCitations).toBe(true); + expect(getObject(createRequest.capi).enableWebSocketResponses).toBe(false); + expect(createRequest.mcpOAuthTokenStorage).toBe("persistent"); + expect(createRequest.agent).toBe("agent-one"); + expect(getArray(getObject(createRequest.defaultAgent).excludedTools)[0]).toBe("edit"); + expect(getObject(getArray(createRequest.customAgents)[0]).name).toBe("agent-one"); + expect(getArray(createRequest.pluginDirectories)[0]).toBe("plugins-create"); + expect(getArray(createRequest.disabledSkills)[0]).toBe("disabled-create-skill"); + expect(getObject(createRequest.infiniteSessions).enabled).toBe(false); + expect(getObject(createRequest.largeOutput).enabled).toBe(true); + expect(getObject(createRequest.largeOutput).maxSizeBytes).toBe(4096); + expect(getObject(createRequest.largeOutput).outputDir).toBe(outputDirectory); + expect(getObject(createRequest.memory).enabled).toBe(true); + expect(createRequest.gitHubToken).toBe("session-create-token"); + expect(createRequest.remoteSession).toBe("export"); + expect(getObject(getObject(createRequest.cloud).repository).owner).toBe("github"); + expect(createRequest.requestMcpApps).toBe(true); + expect(createRequest.requestCanvasRenderer).toBe(true); + expect(createRequest.requestExtensions).toBe(true); + expect(createRequest.extensionSdkPath).toBe("custom-extension-sdk"); + expect(getObject(createRequest.extensionInfo).name).toBe("advanced-create-extension"); + expect(getObject(getArray(createRequest.canvases)[0]).id).toBe("advanced-create-canvas"); + expect(getObject(getArray(createRequest.providers)[0]).name).toBe("create-provider"); + expect(getObject(getArray(createRequest.providers)[0]).wireApi).toBe("responses"); + expect(getObject(getArray(createRequest.models)[0]).id).toBe("create-model"); + expect(getObject(getArray(createRequest.models)[0]).maxContextWindowTokens).toBe(12_000); + + await session.disconnect(); + }); + + it("should forward singular provider options in create wire request", async () => { + const cliPath = path.join( + workDir, + `fake-cli-provider-create-${Date.now()}-${Math.random().toString(36).slice(2)}.js` + ); + const capturePath = path.join( + workDir, + `fake-cli-provider-create-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.json` + ); + fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); + + const client = new CopilotClient({ + workingDirectory: workDir, + env, + connection: RuntimeConnection.forStdio({ + path: cliPath, + args: ["--capture-file", capturePath], + }), + useLoggedInUser: false, + }); + onTestFinished(async () => { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors + } + }); + + await client.start(); + + const session = await client.createSession({ + model: "claude-sonnet-4.5", + provider: { + type: "azure", + wireApi: "responses", + transport: "http", + baseUrl: "https://azure-provider.example.test/openai", + apiKey: "provider-api-key", + bearerToken: "provider-bearer-token", + azure: { apiVersion: "2024-02-15-preview" }, + headers: { "X-Provider-Wire": "yes" }, + modelId: "claude-sonnet-4.5", + wireModel: "azure-deployment", + maxPromptTokens: 8192, + maxOutputTokens: 1024, + }, + onPermissionRequest: approveAll, + }); + + const provider = getObject(getCapturedRequest(capturePath, "session.create").provider); + expect(provider.type).toBe("azure"); + expect(provider.wireApi).toBe("responses"); + expect(provider.transport).toBe("http"); + expect(provider.baseUrl).toBe("https://azure-provider.example.test/openai"); + expect(provider.apiKey).toBe("provider-api-key"); + expect(provider.bearerToken).toBe("provider-bearer-token"); + expect(getObject(provider.azure).apiVersion).toBe("2024-02-15-preview"); + expect(getObject(provider.headers)["X-Provider-Wire"]).toBe("yes"); + expect(provider.modelId).toBe("claude-sonnet-4.5"); + expect(provider.wireModel).toBe("azure-deployment"); + expect(provider.maxPromptTokens).toBe(8192); + expect(provider.maxOutputTokens).toBe(1024); + + await session.disconnect(); + }); + + it("should forward advanced session options in resume wire request", async () => { + const cliPath = path.join( + workDir, + `fake-cli-advanced-resume-${Date.now()}-${Math.random().toString(36).slice(2)}.js` + ); + const capturePath = path.join( + workDir, + `fake-cli-advanced-resume-capture-${Date.now()}-${Math.random().toString(36).slice(2)}.json` + ); + const outputDirectory = path.join(workDir, "large-output-resume"); + fs.writeFileSync(cliPath, FAKE_STDIO_CLI_SCRIPT); + + const client = new CopilotClient({ + workingDirectory: workDir, + env, + connection: RuntimeConnection.forStdio({ + path: cliPath, + args: ["--capture-file", capturePath], + }), + useLoggedInUser: false, + }); + onTestFinished(async () => { + try { + await client.forceStop(); + } catch { + // Ignore cleanup errors + } + }); + + await client.start(); + + const session = await client.resumeSession("advanced-resume-session", { + clientName: "advanced-resume-client", + model: "claude-haiku-4.5", + reasoningEffort: "low", + reasoningSummary: "none", + contextTier: "default", + suppressResumeEvent: true, + continuePendingWork: true, + mcpOAuthTokenStorage: "persistent", + pluginDirectories: ["plugins-resume"], + largeOutput: { + enabled: false, + maxSizeBytes: 2048, + outputDirectory, + }, + memory: { enabled: false }, + remoteSession: "on", + openCanvases: [ + { + canvasId: "resume-canvas", + extensionId: "typescript-sdk-tests/resume-extension", + extensionName: "Resume Extension", + instanceId: "resume-canvas-1", + input: { start: 41 }, + status: "ready", + title: "Resume Canvas", + url: "https://example.com/resume-canvas", + }, + ], + onPermissionRequest: approveAll, + }); + + const resumeRequest = getCapturedRequest(capturePath, "session.resume"); + expect(resumeRequest.sessionId).toBe("advanced-resume-session"); + expect(resumeRequest.clientName).toBe("advanced-resume-client"); + expect(resumeRequest.model).toBe("claude-haiku-4.5"); + expect(resumeRequest.reasoningEffort).toBe("low"); + expect(resumeRequest.reasoningSummary).toBe("none"); + expect(resumeRequest.contextTier).toBe("default"); + expect(resumeRequest.disableResume).toBe(true); + expect(resumeRequest.continuePendingWork).toBe(true); + expect(resumeRequest.mcpOAuthTokenStorage).toBe("persistent"); + expect(getArray(resumeRequest.pluginDirectories)[0]).toBe("plugins-resume"); + expect(getObject(resumeRequest.largeOutput).enabled).toBe(false); + expect(getObject(resumeRequest.largeOutput).maxSizeBytes).toBe(2048); + expect(getObject(resumeRequest.largeOutput).outputDir).toBe(outputDirectory); + expect(getObject(resumeRequest.memory).enabled).toBe(false); + expect(resumeRequest.remoteSession).toBe("on"); + + const openCanvas = getObject(getArray(resumeRequest.openCanvases)[0]); + expect(openCanvas.canvasId).toBe("resume-canvas"); + expect(openCanvas.extensionId).toBe("typescript-sdk-tests/resume-extension"); + expect(openCanvas.extensionName).toBe("Resume Extension"); + expect(openCanvas.instanceId).toBe("resume-canvas-1"); + expect(getObject(openCanvas.input).start).toBe(41); + expect(openCanvas.status).toBe("ready"); + expect(openCanvas.title).toBe("Resume Canvas"); + expect(openCanvas.url).toBe("https://example.com/resume-canvas"); + + await session.disconnect(); + }); + it("should throw when gitHubToken used with forUri", () => { expect(() => { new CopilotClient({ diff --git a/nodejs/test/e2e/mcp_oauth.e2e.test.ts b/nodejs/test/e2e/mcp_oauth.e2e.test.ts index 509932f971..0556e857fc 100644 --- a/nodejs/test/e2e/mcp_oauth.e2e.test.ts +++ b/nodejs/test/e2e/mcp_oauth.e2e.test.ts @@ -89,6 +89,73 @@ describe("MCP OAuth host auth", async () => { ).toBe(true); }); + it( + "should resolve pending MCP OAuth request with direct RPC", + { timeout: 120_000 }, + async () => { + const oauthServer = await startOAuthMcpServer(); + const serverName = "oauth-direct-rpc-mcp"; + let resolveAuthRequest!: (request: McpAuthRequest) => void; + const authRequest = new Promise((resolve) => { + resolveAuthRequest = resolve; + }); + let releaseHandler!: (value: unknown) => void; + const handlerResult = new Promise((resolve) => { + releaseHandler = resolve; + }); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + enableMcpApps: true, + onMcpAuthRequest: async (request) => { + resolveAuthRequest(request); + await handlerResult; + return { kind: "token", accessToken: EXPECTED_TOKEN }; + }, + mcpServers: { + [serverName]: { + type: "http", + url: `${oauthServer.url}/mcp`, + tools: ["*"], + oauthClientId: "sdk-e2e-client", + oauthPublicClient: true, + } as unknown as MCPServerConfig, + }, + }); + onTestFinished(() => disconnectSession(session)); + + const connected = waitForMcpServerStatus(session, serverName); + const request = await authRequest; + expect(request).toMatchObject({ + requestId: expect.any(String), + serverName, + serverUrl: `${oauthServer.url}/mcp`, + reason: "initial", + wwwAuthenticateParams: { + resourceMetadataUrl: `${oauthServer.url}/.well-known/oauth-protected-resource`, + scope: "mcp.read", + error: "invalid_token", + }, + }); + + const handled = await session.rpc.mcp.oauth.handlePendingRequest({ + requestId: request.requestId, + result: { + kind: "token", + accessToken: EXPECTED_TOKEN, + tokenType: "Bearer", + expiresIn: 3600, + }, + }); + expect(handled.success).toBe(true); + + await connected; + const tools = await session.rpc.mcp.listTools({ serverName }); + expect(tools.tools.map((tool) => tool.name)).toContain("whoami"); + releaseHandler(undefined); + } + ); + it( "should request host-owned replacement tokens across the MCP OAuth lifecycle", { timeout: 120_000 }, diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 3d4b00c961..8913146b12 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -103,6 +103,39 @@ describe("Server-scoped RPC", async () => { expect(Date.parse(result.timestamp)).not.toBeNaN(); }); + it("should reject llm inference response frames for missing request", async () => { + await client.start(); + + const start = await client.rpc.llmInference.httpResponseStart({ + requestId: "missing-llm-inference-request", + status: 200, + headers: { + "content-type": ["text/event-stream"], + }, + statusText: "OK", + }); + expect(start.accepted).toBe(false); + + const chunk = await client.rpc.llmInference.httpResponseChunk({ + requestId: "missing-llm-inference-request", + data: "data: {}\n\n", + binary: false, + end: false, + }); + expect(chunk.accepted).toBe(false); + + const error = await client.rpc.llmInference.httpResponseChunk({ + requestId: "missing-llm-inference-request", + data: "", + end: true, + error: { + code: "missing_request", + message: "No pending LLM inference request.", + }, + }); + expect(error.accepted).toBe(false); + }); + it("should call rpc models list with typed result", async () => { const token = "rpc-models-token"; await configureAuthenticatedUser(token); @@ -401,6 +434,59 @@ describe("Server-scoped RPC", async () => { expect(discovered[0].enabled).toBe(true); expect(discovered[0].path.endsWith(path.join(skillName, "SKILL.md"))).toBe(true); + const skillPaths = await client.rpc.skills.getDiscoveryPaths({ + projectPaths: [workDir], + excludeHostSkills: true, + }); + const projectSkillPath = skillPaths.paths.find( + (p) => p.projectPath && pathsEqual(p.projectPath, workDir) && p.preferredForCreation + ); + if (!projectSkillPath) { + throw new Error(`Expected skill discovery paths to include ${workDir}`); + } + expect(projectSkillPath.path.trim()).not.toBe(""); + + const agents = await client.rpc.agents.discover({ + projectPaths: [workDir], + excludeHostAgents: true, + }); + expect(agents.agents.every((agent) => agent.name.trim() !== "")).toBe(true); + + const agentPaths = await client.rpc.agents.getDiscoveryPaths({ + projectPaths: [workDir], + excludeHostAgents: true, + }); + const projectAgentPath = agentPaths.paths.find( + (p) => p.projectPath && pathsEqual(p.projectPath, workDir) && p.preferredForCreation + ); + if (!projectAgentPath) { + throw new Error(`Expected agent discovery paths to include ${workDir}`); + } + expect(projectAgentPath.path.trim()).not.toBe(""); + + const instructions = await client.rpc.instructions.discover({ + projectPaths: [workDir], + excludeHostInstructions: true, + }); + expect( + instructions.sources.every( + (source) => + source.id.trim() !== "" && + source.label.trim() !== "" && + source.sourcePath.trim() !== "" + ) + ).toBe(true); + + const instructionPaths = await client.rpc.instructions.getDiscoveryPaths({ + projectPaths: [workDir], + excludeHostInstructions: true, + }); + expect(instructionPaths.paths.length).toBeGreaterThan(0); + expect( + instructionPaths.paths.some((p) => p.projectPath && pathsEqual(p.projectPath, workDir)) + ).toBe(true); + expect(instructionPaths.paths.every((p) => p.path.trim() !== "")).toBe(true); + try { await client.rpc.skills.config.setDisabledSkills({ disabledSkills: [skillName] }); const disabled = await client.rpc.skills.discover({ diff --git a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts index d358c7d9d1..c38fccca17 100644 --- a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts @@ -11,7 +11,7 @@ import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestCon import { formatError, waitForCondition } from "./harness/sdkTestHelper.js"; describe("Miscellaneous server-scoped RPC", async () => { - const { copilotClient: client, env, workDir } = await createSdkTestContext(); + const { copilotClient: client, env, openAiEndpoint, workDir } = await createSdkTestContext(); function createUniqueDirectory(prefix: string): string { const directory = join(workDir, `${prefix}-${randomUUID()}`); @@ -19,7 +19,10 @@ describe("Miscellaneous server-scoped RPC", async () => { return directory; } - function createClient(extraEnv: Record = {}): CopilotClient { + function createClient( + extraEnv: Record, + gitHubToken: string | undefined + ): CopilotClient { return new CopilotClient({ workingDirectory: workDir, env: { @@ -28,21 +31,29 @@ describe("Miscellaneous server-scoped RPC", async () => { }, logLevel: "error", connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), - gitHubToken: DEFAULT_GITHUB_TOKEN, + gitHubToken, + useLoggedInUser: gitHubToken === undefined ? false : undefined, }); } - async function createIsolatedStartedClient(): Promise<{ + async function createIsolatedStartedClient( + gitHubToken: string | null = DEFAULT_GITHUB_TOKEN + ): Promise<{ client: CopilotClient; home: string; }> { const home = createUniqueDirectory("copilot-e2e-misc-home"); - const isolatedClient = createClient({ - COPILOT_HOME: home, - GH_CONFIG_DIR: home, - XDG_CONFIG_HOME: home, - XDG_STATE_HOME: home, - }); + const effectiveGitHubToken = gitHubToken === null ? undefined : gitHubToken; + const isolatedClient = createClient( + { + COPILOT_HOME: home, + GH_CONFIG_DIR: home, + XDG_CONFIG_HOME: home, + XDG_STATE_HOME: home, + COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, + }, + effectiveGitHubToken + ); try { await isolatedClient.start(); return { client: isolatedClient, home }; @@ -83,6 +94,101 @@ describe("Miscellaneous server-scoped RPC", async () => { await client.rpc.user.settings.reload(); }); + it("should get set and clear user settings", { timeout: 120_000 }, async () => { + const { client: isolatedClient, home } = await createIsolatedStartedClient(); + try { + const before = await isolatedClient.rpc.user.settings.get(); + expect(Object.keys(before.settings).length).toBeGreaterThan(0); + for (const [key, setting] of Object.entries(before.settings)) { + expect(key.trim()).toBeTruthy(); + expect(setting.value !== undefined || setting.default !== undefined).toBe(true); + } + + const entry = Object.entries(before.settings).find( + ([, setting]) => typeof setting.value === "boolean" + ); + expect(entry).toBeDefined(); + const [settingKey, setting] = entry!; + const toggledValue = setting.value !== true; + + const set = await isolatedClient.rpc.user.settings.set({ + settings: { [settingKey]: toggledValue }, + }); + expect(set.shadowedKeys).not.toContain(settingKey); + + await isolatedClient.rpc.user.settings.reload(); + const afterSet = await isolatedClient.rpc.user.settings.get(); + expect(afterSet.settings[settingKey].isDefault).toBe(false); + expect(afterSet.settings[settingKey].value).toBe(toggledValue); + + await isolatedClient.rpc.user.settings.set({ + settings: { [settingKey]: null }, + }); + await isolatedClient.rpc.user.settings.reload(); + const afterClear = await isolatedClient.rpc.user.settings.get(); + expect(afterClear.settings[settingKey].isDefault).toBe(true); + } finally { + await disposeIsolated(isolatedClient, home); + } + }); + + it("should login list getCurrentAuth and logout account", { timeout: 120_000 }, async () => { + const login = `rpc-account-${randomUUID().replaceAll("-", "")}`; + const token = `rpc-account-token-${randomUUID().replaceAll("-", "")}`; + await openAiEndpoint.setCopilotUserByToken(token, { + login, + copilot_plan: "individual_pro", + endpoints: { + api: env.COPILOT_API_URL, + telemetry: "https://localhost:1/telemetry", + }, + analytics_tracking_id: "rpc-account-tracking-id", + }); + + const { client: isolatedClient, home } = await createIsolatedStartedClient(null); + try { + const initial = await isolatedClient.rpc.account.getCurrentAuth(); + expect(initial.authInfo).toBeUndefined(); + + const loginResult = await isolatedClient.rpc.account.login({ + host: "https://github.com", + login, + token, + }); + expect(typeof loginResult.storedInVault).toBe("boolean"); + + const current = await isolatedClient.rpc.account.getCurrentAuth(); + expect(current.authErrors).toBeUndefined(); + expect(current.authInfo).toMatchObject({ + type: "user", + host: "https://github.com", + login, + }); + + const users = await isolatedClient.rpc.account.getAllUsers(); + expect(Array.isArray(users)).toBe(true); + for (const user of users) { + expect(user.authInfo.type.trim()).toBeTruthy(); + } + const account = users.find( + (user) => user.authInfo.type === "user" && user.authInfo.login === login + ); + if (account) { + expect(account?.token).toBe(token); + } + + const logout = await isolatedClient.rpc.account.logout({ + authInfo: current.authInfo!, + }); + expect(logout.hasMoreUsers).toBe(false); + + const afterLogout = await isolatedClient.rpc.account.getCurrentAuth(); + expect(afterLogout.authInfo).toBeUndefined(); + } finally { + await disposeIsolated(isolatedClient, home); + } + }); + it("should report agent registry spawn gate closed", { timeout: 120_000 }, async () => { const { client: isolatedClient, home } = await createIsolatedStartedClient(); try { @@ -104,7 +210,7 @@ describe("Miscellaneous server-scoped RPC", async () => { }); it("should shut down owned runtime", { timeout: 120_000 }, async () => { - const dedicatedClient = createClient(); + const dedicatedClient = createClient({}, DEFAULT_GITHUB_TOKEN); try { await dedicatedClient.start(); await dedicatedClient.rpc.user.settings.reload(); diff --git a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts index 4b0ff9ba17..6e84a6f669 100644 --- a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts @@ -103,6 +103,103 @@ describe("Session-scoped state extras RPC", async () => { } }); + it("should add byok provider and model at runtime", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const providerName = `sdk-runtime-provider-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const modelId = "sdk-runtime-model"; + const selectionId = `${providerName}/${modelId}`; + + const added = await session.rpc.provider.add({ + providers: [ + { + name: providerName, + type: "openai", + wireApi: "completions", + baseUrl: "https://api.example.test/v1", + apiKey: "runtime-provider-secret", + headers: { "X-SDK-Provider": "runtime" }, + }, + ], + models: [ + { + provider: providerName, + id: modelId, + name: "SDK Runtime Model", + modelId: "claude-sonnet-4.5", + wireModel: "wire-sdk-runtime-model", + maxContextWindowTokens: 4096, + maxPromptTokens: 3072, + maxOutputTokens: 1024, + capabilities: { + limits: { + maxContextWindowTokens: 4096, + maxPromptTokens: 3072, + maxOutputTokens: 1024, + }, + supports: { + reasoningEffort: false, + vision: false, + }, + }, + }, + ], + }); + + expect(added.models).toHaveLength(1); + expect(JSON.stringify(added.models[0])).toContain(selectionId); + expect(JSON.stringify(added.models[0])).toContain("SDK Runtime Model"); + + const listed = await session.rpc.model.list(); + expect(listed.list.some((model) => JSON.stringify(model).includes(selectionId))).toBe( + true + ); + + const switched = await session.rpc.model.switchTo({ modelId: selectionId }); + expect(switched.modelId).toBe(selectionId); + expect((await session.rpc.model.getCurrent()).modelId).toBe(selectionId); + } finally { + await session.disconnect(); + } + }); + + it( + "should return empty completions when host does not provide them", + { timeout: 120_000 }, + async () => { + const session = await createSession(); + try { + const triggers = await session.rpc.completions.getTriggerCharacters(); + expect(triggers.triggerCharacters).toEqual([]); + + const completions = await session.rpc.completions.request({ + text: "Use @", + offset: 5, + }); + expect(completions.items).toEqual([]); + } finally { + await session.disconnect(); + } + } + ); + + it("should report visibility as unsynced for local session", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const initial = await session.rpc.visibility.get(); + expect(initial.synced).toBe(false); + expect(initial.status).toBeUndefined(); + expect(initial.shareUrl).toBeUndefined(); + + const set = await session.rpc.visibility.set({ status: "repo" }); + expect(set.synced).toBe(false); + expect(set.status).toBeUndefined(); + expect(set.shareUrl).toBeUndefined(); + } finally { + await session.disconnect(); + } + }); + it("should get and set allowall permissions", { timeout: 120_000 }, async () => { const session = await createSession(); try { @@ -128,6 +225,72 @@ describe("Session-scoped state extras RPC", async () => { } }); + it( + "should get context attribution and heaviest messages after turn", + { timeout: 120_000 }, + async () => { + const session = await createSession(); + try { + const answer = await session.sendAndWait({ + prompt: "Say CONTEXT_METADATA_OK exactly.", + }); + expect(answer?.data.content ?? "").toContain("CONTEXT_METADATA_OK"); + + const attribution = await session.rpc.metadata.getContextAttribution(); + expect(attribution.contextAttribution).not.toBeNull(); + const contextAttribution = attribution.contextAttribution!; + expect(contextAttribution.totalTokens).toBeGreaterThan(0); + expect(contextAttribution.entries.length).toBeGreaterThan(0); + for (const entry of contextAttribution.entries) { + expect(entry.id.trim()).toBeTruthy(); + expect(entry.kind.trim()).toBeTruthy(); + expect(entry.label.trim()).toBeTruthy(); + expect(entry.tokens).toBeGreaterThanOrEqual(0); + for (const attribute of entry.attributes ?? []) { + expect(attribute.key.trim()).toBeTruthy(); + } + } + + const heaviest = await session.rpc.metadata.getContextHeaviestMessages({ + limit: 2, + }); + expect(heaviest.totalTokens).toBeGreaterThan(0); + expect(heaviest.messages.length).toBeLessThanOrEqual(2); + for (const message of heaviest.messages) { + expect(message.id.trim()).toBeTruthy(); + expect(message.tokens).toBeGreaterThanOrEqual(0); + } + } finally { + await session.disconnect(); + } + } + ); + + it("should update and clear live subagent settings", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + await expect( + session.rpc.tools.updateSubagentSettings({ + subagents: { + "general-purpose": { + model: "claude-haiku-4.5", + effortLevel: "low", + contextTier: "default", + }, + }, + }) + ).resolves.toBeDefined(); + + await expect( + session.rpc.tools.updateSubagentSettings({ + subagents: null, + }) + ).resolves.toBeDefined(); + } finally { + await session.disconnect(); + } + }); + it("should read empty sql todos for fresh session", { timeout: 120_000 }, async () => { const session = await createSession(); try { diff --git a/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts b/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts index e7f7664c3c..cb41c69e68 100644 --- a/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts +++ b/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts @@ -173,6 +173,27 @@ describe("Session tasks RPC and pending handlers", async () => { }); expect(locationApproval.success).toBe(false); + const sessionLimits = await session.rpc.ui.handlePendingSessionLimitsExhausted({ + requestId: "missing-session-limits-request", + response: { action: "cancel" }, + }); + expect(sessionLimits.success).toBe(false); + + const headers = await session.rpc.mcp.headers.handlePendingHeadersRefreshRequest({ + requestId: "missing-headers-refresh-request", + result: { + kind: "headers", + headers: { "X-SDK-Test": "missing" }, + }, + }); + expect(headers.success).toBe(false); + + const noHeaders = await session.rpc.mcp.headers.handlePendingHeadersRefreshRequest({ + requestId: "missing-headers-refresh-none-request", + result: { kind: "none" }, + }); + expect(noHeaders.success).toBe(false); + await session.disconnect(); }); diff --git a/python/e2e/test_client_options_e2e.py b/python/e2e/test_client_options_e2e.py index 9a70c6cbfe..bca4dbb722 100644 --- a/python/e2e/test_client_options_e2e.py +++ b/python/e2e/test_client_options_e2e.py @@ -20,11 +20,20 @@ import pytest -from copilot import CopilotClient, RuntimeConnection +from copilot import ( + CanvasDeclaration, + CloudSessionOptions, + CloudSessionRepository, + CopilotClient, + ExtensionInfo, + OpenCanvasInstance, + RemoteSessionMode, + RuntimeConnection, +) from copilot.rpc import PingRequest from copilot.session import PermissionHandler -from .testharness import E2ETestContext +from .testharness import DEFAULT_GITHUB_TOKEN, E2ETestContext pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -56,9 +65,7 @@ def _make_options( "connection": connection, "working_directory": ctx.work_dir, "env": ctx.get_env(), - "github_token": ( - "fake-token-for-e2e-tests" if os.environ.get("GITHUB_ACTIONS") == "true" else None - ), + "github_token": DEFAULT_GITHUB_TOKEN, } base.update(overrides) return base @@ -147,6 +154,16 @@ def _get_available_port() -> int: writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null }); return; } + if (message.method === "session.resume") { + const sessionId = message.params?.sessionId ?? message.params?.session_id ?? "fake-session"; + writeResponse(message.id, { + sessionId, + workspacePath: null, + capabilities: null, + openCanvases: message.params?.openCanvases ?? [], + }); + return; + } writeResponse(message.id, {}); } @@ -164,6 +181,14 @@ def _assert_arg_value(args: list[str], name: str, expected_value: str) -> None: assert args[index + 1] == expected_value +def _get_captured_request(capture_path: str, method: str) -> dict: + with open(capture_path) as f: + capture = json.load(f) + request = next((r for r in capture["requests"] if r["method"] == method), None) + assert request is not None, f"Expected {method} request in capture" + return request["params"] + + class TestClientOptions: async def test_should_listen_on_configured_tcp_port(self, ctx: E2ETestContext): port = _get_available_port() @@ -277,3 +302,287 @@ async def test_should_propagate_process_options_to_spawned_cli(self, ctx: E2ETes await client.stop() except Exception: await client.force_stop() + + async def test_should_forward_advanced_session_options_in_create_wire_request( + self, ctx: E2ETestContext + ): + cli_path = os.path.join(ctx.work_dir, f"fake-cli-advanced-create-{os.getpid()}.js") + capture_path = os.path.join( + ctx.work_dir, f"fake-cli-advanced-create-capture-{os.getpid()}.json" + ) + output_directory = os.path.join(ctx.work_dir, "large-output-create") + with open(cli_path, "w") as f: + f.write(FAKE_STDIO_CLI_SCRIPT) + + client = CopilotClient( + **_make_options( + ctx, + cli_path=cli_path, + cli_args=["--capture-file", capture_path], + use_logged_in_user=False, + ) + ) + try: + await client.start() + session = await client.create_session( + client_name="advanced-create-client", + model="claude-sonnet-4.5", + reasoning_effort="medium", + reasoning_summary="detailed", + context_tier="long_context", + enable_citations=True, + capi={"enable_web_socket_responses": False}, + mcp_oauth_token_storage="persistent", + custom_agents=[ + { + "name": "agent-one", + "display_name": "Agent One", + "description": "Handles agent-one tasks.", + "prompt": "Be agent one.", + "tools": ["view"], + "infer": True, + "skills": ["create-skill"], + "model": "claude-haiku-4.5", + } + ], + default_agent={"excluded_tools": ["edit"]}, + agent="agent-one", + skill_directories=["skills-create"], + disabled_skills=["disabled-create-skill"], + plugin_directories=["plugins-create"], + infinite_sessions={ + "enabled": False, + "background_compaction_threshold": 0.5, + "buffer_exhaustion_threshold": 0.9, + }, + large_output={ + "enabled": True, + "max_size_bytes": 4096, + "output_directory": output_directory, + }, + memory={"enabled": True}, + github_token="session-create-token", + remote_session=RemoteSessionMode.EXPORT, + cloud=CloudSessionOptions( + repository=CloudSessionRepository( + owner="github", + name="copilot-sdk", + branch="main", + ) + ), + enable_mcp_apps=True, + request_canvas_renderer=True, + request_extensions=True, + extension_sdk_path="custom-extension-sdk", + extension_info=ExtensionInfo( + source="python-sdk-tests", + name="advanced-create-extension", + ), + canvases=[ + CanvasDeclaration( + id="advanced-create-canvas", + display_name="Advanced Create Canvas", + description="Covers create-time canvas options.", + ) + ], + providers=[ + { + "name": "create-provider", + "type": "openai", + "wire_api": "responses", + "base_url": "https://create-provider.example.test/v1", + "api_key": "create-provider-key", + "headers": {"X-Create-Provider": "yes"}, + } + ], + models=[ + { + "provider": "create-provider", + "id": "create-model", + "name": "Create Model", + "model_id": "claude-sonnet-4.5", + "wire_model": "create-wire-model", + "max_context_window_tokens": 12_000, + "max_prompt_tokens": 10_000, + "max_output_tokens": 2_000, + } + ], + on_permission_request=PermissionHandler.approve_all, + ) + try: + params = _get_captured_request(capture_path, "session.create") + assert params["clientName"] == "advanced-create-client" + assert params["model"] == "claude-sonnet-4.5" + assert params["reasoningEffort"] == "medium" + assert params["reasoningSummary"] == "detailed" + assert params["contextTier"] == "long_context" + assert params["enableCitations"] is True + assert params["capi"]["enableWebSocketResponses"] is False + assert params["mcpOAuthTokenStorage"] == "persistent" + assert params["agent"] == "agent-one" + assert params["defaultAgent"]["excludedTools"][0] == "edit" + assert params["customAgents"][0]["name"] == "agent-one" + assert params["pluginDirectories"][0] == "plugins-create" + assert params["disabledSkills"][0] == "disabled-create-skill" + assert params["infiniteSessions"]["enabled"] is False + assert params["largeOutput"]["enabled"] is True + assert params["largeOutput"]["maxSizeBytes"] == 4096 + assert params["largeOutput"]["outputDir"] == output_directory + assert params["memory"]["enabled"] is True + assert params["gitHubToken"] == "session-create-token" + assert params["remoteSession"] == "export" + assert params["cloud"]["repository"]["owner"] == "github" + assert params["requestMcpApps"] is True + assert params["requestCanvasRenderer"] is True + assert params["requestExtensions"] is True + assert params["extensionSdkPath"] == "custom-extension-sdk" + assert params["extensionInfo"]["name"] == "advanced-create-extension" + assert params["canvases"][0]["id"] == "advanced-create-canvas" + assert params["providers"][0]["name"] == "create-provider" + assert params["providers"][0]["wireApi"] == "responses" + assert params["models"][0]["id"] == "create-model" + assert params["models"][0]["maxContextWindowTokens"] == 12_000 + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_should_forward_singular_provider_options_in_create_wire_request( + self, ctx: E2ETestContext + ): + cli_path = os.path.join(ctx.work_dir, f"fake-cli-provider-create-{os.getpid()}.js") + capture_path = os.path.join( + ctx.work_dir, f"fake-cli-provider-create-capture-{os.getpid()}.json" + ) + with open(cli_path, "w") as f: + f.write(FAKE_STDIO_CLI_SCRIPT) + + client = CopilotClient( + **_make_options( + ctx, + cli_path=cli_path, + cli_args=["--capture-file", capture_path], + use_logged_in_user=False, + ) + ) + try: + await client.start() + session = await client.create_session( + model="claude-sonnet-4.5", + provider={ + "type": "azure", + "wire_api": "responses", + "transport": "http", + "base_url": "https://azure-provider.example.test/openai", + "api_key": "provider-api-key", + "bearer_token": "provider-bearer-token", + "azure": {"api_version": "2024-02-15-preview"}, + "headers": {"X-Provider-Wire": "yes"}, + "model_id": "claude-sonnet-4.5", + "wire_model": "azure-deployment", + "max_prompt_tokens": 8192, + "max_output_tokens": 1024, + }, + on_permission_request=PermissionHandler.approve_all, + ) + try: + provider = _get_captured_request(capture_path, "session.create")["provider"] + assert provider["type"] == "azure" + assert provider["wireApi"] == "responses" + assert provider["transport"] == "http" + assert provider["baseUrl"] == "https://azure-provider.example.test/openai" + assert provider["apiKey"] == "provider-api-key" + assert provider["bearerToken"] == "provider-bearer-token" + assert provider["azure"]["apiVersion"] == "2024-02-15-preview" + assert provider["headers"]["X-Provider-Wire"] == "yes" + assert provider["modelId"] == "claude-sonnet-4.5" + assert provider["wireModel"] == "azure-deployment" + assert provider["maxPromptTokens"] == 8192 + assert provider["maxOutputTokens"] == 1024 + finally: + await session.disconnect() + finally: + await client.stop() + + async def test_should_forward_advanced_session_options_in_resume_wire_request( + self, ctx: E2ETestContext + ): + cli_path = os.path.join(ctx.work_dir, f"fake-cli-advanced-resume-{os.getpid()}.js") + capture_path = os.path.join( + ctx.work_dir, f"fake-cli-advanced-resume-capture-{os.getpid()}.json" + ) + output_directory = os.path.join(ctx.work_dir, "large-output-resume") + with open(cli_path, "w") as f: + f.write(FAKE_STDIO_CLI_SCRIPT) + + client = CopilotClient( + **_make_options( + ctx, + cli_path=cli_path, + cli_args=["--capture-file", capture_path], + use_logged_in_user=False, + ) + ) + try: + await client.start() + session = await client.resume_session( + "advanced-resume-session", + client_name="advanced-resume-client", + model="claude-haiku-4.5", + reasoning_effort="low", + reasoning_summary="none", + context_tier="default", + continue_pending_work=True, + mcp_oauth_token_storage="persistent", + plugin_directories=["plugins-resume"], + large_output={ + "enabled": False, + "max_size_bytes": 2048, + "output_directory": output_directory, + }, + memory={"enabled": False}, + remote_session=RemoteSessionMode.ON, + open_canvases=[ + OpenCanvasInstance( + canvas_id="resume-canvas", + extension_id="python-sdk-tests/resume-extension", + extension_name="Resume Extension", + instance_id="resume-canvas-1", + input={"start": 41}, + status="ready", + title="Resume Canvas", + url="https://example.com/resume-canvas", + ) + ], + on_permission_request=PermissionHandler.approve_all, + ) + try: + params = _get_captured_request(capture_path, "session.resume") + assert params["sessionId"] == "advanced-resume-session" + assert params["clientName"] == "advanced-resume-client" + assert params["model"] == "claude-haiku-4.5" + assert params["reasoningEffort"] == "low" + assert params["reasoningSummary"] == "none" + assert params["contextTier"] == "default" + assert params["continuePendingWork"] is True + assert params["mcpOAuthTokenStorage"] == "persistent" + assert params["pluginDirectories"][0] == "plugins-resume" + assert params["largeOutput"]["enabled"] is False + assert params["largeOutput"]["maxSizeBytes"] == 2048 + assert params["largeOutput"]["outputDir"] == output_directory + assert params["memory"]["enabled"] is False + assert params["remoteSession"] == "on" + + open_canvas = params["openCanvases"][0] + assert open_canvas["canvasId"] == "resume-canvas" + assert open_canvas["extensionId"] == "python-sdk-tests/resume-extension" + assert open_canvas["extensionName"] == "Resume Extension" + assert open_canvas["instanceId"] == "resume-canvas-1" + assert open_canvas["input"]["start"] == 41 + assert open_canvas["status"] == "ready" + assert open_canvas["title"] == "Resume Canvas" + assert open_canvas["url"] == "https://example.com/resume-canvas" + finally: + await session.disconnect() + finally: + await client.stop() diff --git a/python/e2e/test_mcp_oauth_e2e.py b/python/e2e/test_mcp_oauth_e2e.py index 6c33165000..8322a3aba1 100644 --- a/python/e2e/test_mcp_oauth_e2e.py +++ b/python/e2e/test_mcp_oauth_e2e.py @@ -7,7 +7,13 @@ import httpx import pytest -from copilot.generated.rpc import MCPAppsCallToolRequest, MCPListToolsRequest +from copilot.generated.rpc import ( + MCPAppsCallToolRequest, + MCPListToolsRequest, + MCPOauthHandlePendingRequest, + MCPOauthPendingRequestResponse, + MCPOauthPendingRequestResponseKind, +) from copilot.session import MCPServerConfig, PermissionHandler from copilot.session_events import McpServerStatus @@ -153,6 +159,75 @@ def on_mcp_auth_request(request, _invocation): finally: await _stop_process(process) + async def test_should_resolve_pending_mcp_oauth_request_with_direct_rpc( + self, ctx: E2ETestContext + ): + url, process = await _start_oauth_mcp_server() + server_name = "oauth-direct-rpc-mcp" + loop = asyncio.get_running_loop() + observed_request = loop.create_future() + release_handler = asyncio.Event() + + async def on_mcp_auth_request(request, _invocation): + if not observed_request.done(): + observed_request.set_result(request) + await release_handler.wait() + return {"kind": "token", "accessToken": EXPECTED_TOKEN} + + try: + mcp_servers: dict[str, MCPServerConfig] = { + server_name: { + "type": "http", + "url": f"{url}/mcp", + "tools": ["*"], + "oauthClientId": "sdk-e2e-client", + "oauthPublicClient": True, + } + } + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_mcp_auth_request=on_mcp_auth_request, + mcp_servers=mcp_servers, + enable_mcp_apps=True, + ) as session: + connected = asyncio.create_task(_wait_for_mcp_server_status(session, server_name)) + try: + request = await asyncio.wait_for(observed_request, timeout=30.0) + assert request["serverName"] == server_name + assert request["serverUrl"] == f"{url}/mcp" + assert request["reason"] == "initial" + assert request["wwwAuthenticateParams"] == { + "resourceMetadataUrl": f"{url}/.well-known/oauth-protected-resource", + "scope": "mcp.read", + "error": "invalid_token", + } + + handled = await session.rpc.mcp.oauth.handle_pending_request( + MCPOauthHandlePendingRequest( + request_id=request["requestId"], + result=MCPOauthPendingRequestResponse( + kind=MCPOauthPendingRequestResponseKind.TOKEN, + access_token=EXPECTED_TOKEN, + token_type="Bearer", + expires_in=3600, + ), + ) + ) + assert handled.success is True + + connected_result = await asyncio.wait_for(connected, timeout=60.0) + assert connected_result is None + tools = await session.rpc.mcp.list_tools( + MCPListToolsRequest(server_name=server_name) + ) + assert [tool.name for tool in tools.tools] == ["whoami"] + finally: + release_handler.set() + if not connected.done(): + connected.cancel() + finally: + await _stop_process(process) + async def test_should_request_replacement_tokens_across_mcp_oauth_lifecycle( self, ctx: E2ETestContext ): diff --git a/python/e2e/test_rpc_server_e2e.py b/python/e2e/test_rpc_server_e2e.py index 244eb2ec49..fb422d5b31 100644 --- a/python/e2e/test_rpc_server_e2e.py +++ b/python/e2e/test_rpc_server_e2e.py @@ -16,7 +16,14 @@ from copilot import CopilotClient, RuntimeConnection from copilot.rpc import ( AccountGetQuotaRequest, + AgentsDiscoverRequest, + AgentsGetDiscoveryPathsRequest, ConnectRemoteSessionParams, + InstructionsDiscoverRequest, + InstructionsGetDiscoveryPathsRequest, + LlmInferenceHTTPResponseChunkError, + LlmInferenceHTTPResponseChunkRequest, + LlmInferenceHTTPResponseStartRequest, LocalSessionMetadataValue, MCPDiscoverRequest, ModelsListRequest, @@ -43,6 +50,7 @@ SessionsSetAdditionalPluginsRequest, SkillsConfigSetDisabledSkillsRequest, SkillsDiscoverRequest, + SkillsGetDiscoveryPathsRequest, ToolsListRequest, ) from copilot.session import PermissionHandler @@ -68,6 +76,12 @@ def _create_skill_directory(work_dir: str, skill_name: str, description: str) -> return str(skills_dir) +def _paths_equal(left: str, right: str | None) -> bool: + if right is None: + return False + return os.path.normcase(os.path.abspath(left)) == os.path.normcase(os.path.abspath(right)) + + @pytest.fixture(scope="module") async def authed_ctx(ctx: E2ETestContext): """Configure proxy to redirect GitHub user lookups so per-token auth works.""" @@ -123,6 +137,44 @@ async def test_should_call_rpc_ping_with_typed_params_and_result(self, ctx: E2ET assert result.message == "pong: typed rpc test" assert result.timestamp is not None + async def test_should_reject_llm_inference_response_frames_for_missing_request( + self, ctx: E2ETestContext + ): + await ctx.client.start() + + start = await ctx.client.rpc.llm_inference.http_response_start( + LlmInferenceHTTPResponseStartRequest( + request_id="missing-llm-inference-request", + status=200, + status_text="OK", + headers={"content-type": ["text/event-stream"]}, + ) + ) + assert start.accepted is False + + chunk = await ctx.client.rpc.llm_inference.http_response_chunk( + LlmInferenceHTTPResponseChunkRequest( + request_id="missing-llm-inference-request", + data="data: {}\n\n", + binary=False, + end=False, + ) + ) + assert chunk.accepted is False + + error = await ctx.client.rpc.llm_inference.http_response_chunk( + LlmInferenceHTTPResponseChunkRequest( + request_id="missing-llm-inference-request", + data="", + end=True, + error=LlmInferenceHTTPResponseChunkError( + message="No pending LLM inference request.", + code="missing_request", + ), + ) + ) + assert error.accepted is False + async def test_should_call_rpc_models_list_with_typed_result(self, authed_ctx: E2ETestContext): token = "rpc-models-token" await _configure_user(authed_ctx, token) @@ -444,6 +496,68 @@ async def test_should_discover_server_mcp_and_skills(self, ctx: E2ETestContext): assert discovered.enabled is True assert discovered.path.endswith(os.path.join(skill_name, "SKILL.md")) + skill_paths = await ctx.client.rpc.skills.get_discovery_paths( + SkillsGetDiscoveryPathsRequest( + project_paths=[ctx.work_dir], + exclude_host_skills=True, + ) + ) + project_skill_path = next( + ( + path + for path in skill_paths.paths + if _paths_equal(ctx.work_dir, path.project_path) and path.preferred_for_creation + ), + None, + ) + assert project_skill_path is not None + assert project_skill_path.path.strip() + + agents = await ctx.client.rpc.agents.discover( + AgentsDiscoverRequest(project_paths=[ctx.work_dir], exclude_host_agents=True) + ) + assert all(agent.name.strip() for agent in agents.agents) + + agent_paths = await ctx.client.rpc.agents.get_discovery_paths( + AgentsGetDiscoveryPathsRequest( + project_paths=[ctx.work_dir], + exclude_host_agents=True, + ) + ) + project_agent_path = next( + ( + path + for path in agent_paths.paths + if _paths_equal(ctx.work_dir, path.project_path) and path.preferred_for_creation + ), + None, + ) + assert project_agent_path is not None + assert project_agent_path.path.strip() + + instructions = await ctx.client.rpc.instructions.discover( + InstructionsDiscoverRequest( + project_paths=[ctx.work_dir], + exclude_host_instructions=True, + ) + ) + assert all( + source.id.strip() and source.label.strip() and source.source_path.strip() + for source in instructions.sources + ) + + instruction_paths = await ctx.client.rpc.instructions.get_discovery_paths( + InstructionsGetDiscoveryPathsRequest( + project_paths=[ctx.work_dir], + exclude_host_instructions=True, + ) + ) + assert instruction_paths.paths + assert any( + _paths_equal(ctx.work_dir, path.project_path) for path in instruction_paths.paths + ) + assert all(path.path.strip() for path in instruction_paths.paths) + try: await ctx.client.rpc.skills.config.set_disabled_skills( SkillsConfigSetDisabledSkillsRequest(disabled_skills=[skill_name]) diff --git a/python/e2e/test_rpc_server_misc_e2e.py b/python/e2e/test_rpc_server_misc_e2e.py index 6f3870224a..d5ade1aec1 100644 --- a/python/e2e/test_rpc_server_misc_e2e.py +++ b/python/e2e/test_rpc_server_misc_e2e.py @@ -16,10 +16,13 @@ from copilot import CopilotClient, RuntimeConnection from copilot.rpc import ( + AccountLoginRequest, + AccountLogoutRequest, AgentRegistrySpawnRequest, SendAttachmentsToMessageParams, SessionsOpenResumeLast, SessionsOpenStatus, + UserSettingsSetRequest, ) from copilot.session import PermissionHandler @@ -37,17 +40,24 @@ def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: ) -async def _create_isolated_client(ctx: E2ETestContext) -> tuple[CopilotClient, Path]: +async def _create_isolated_client( + ctx: E2ETestContext, github_token: str | None = DEFAULT_GITHUB_TOKEN +) -> tuple[CopilotClient, Path]: home = Path(ctx.work_dir) / f"copilot-e2e-misc-home-{uuid.uuid4().hex}" home.mkdir(parents=True) env = ctx.get_env() for key in ("COPILOT_HOME", "GH_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_STATE_HOME"): env[key] = str(home) + env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url + if github_token is None: + env["GH_TOKEN"] = "" + env["GITHUB_TOKEN"] = "" client = CopilotClient( connection=RuntimeConnection.for_stdio(path=ctx.cli_path), working_directory=ctx.work_dir, env=env, - github_token=DEFAULT_GITHUB_TOKEN, + github_token=github_token, + use_logged_in_user=False if github_token is None else None, ) await client.start() return client, home @@ -70,6 +80,96 @@ async def test_should_reload_user_settings(self, ctx: E2ETestContext): await ctx.client.rpc.user.settings.reload() + async def test_should_get_set_and_clear_user_settings(self, ctx: E2ETestContext): + client, home = await _create_isolated_client(ctx) + try: + before = await client.rpc.user.settings.get() + assert len(before.settings) > 0 + for key, setting in before.settings.items(): + assert key.strip() + assert isinstance(setting.is_default, bool) + + setting_key, setting = next( + (key, value) + for key, value in before.settings.items() + if isinstance(value.value, bool) + ) + toggled_value = setting.value is not True + + set_result = await client.rpc.user.settings.set( + UserSettingsSetRequest(settings={setting_key: toggled_value}) + ) + assert setting_key not in set_result.shadowed_keys + + await client.rpc.user.settings.reload() + after_set = await client.rpc.user.settings.get() + assert after_set.settings[setting_key].is_default is False + assert after_set.settings[setting_key].value is toggled_value + + await client.rpc.user.settings.set(UserSettingsSetRequest(settings={setting_key: None})) + await client.rpc.user.settings.reload() + after_clear = await client.rpc.user.settings.get() + assert after_clear.settings[setting_key].is_default is True + finally: + await _dispose_isolated(client, home) + + async def test_should_login_list_get_current_auth_and_logout_account(self, ctx: E2ETestContext): + login = f"rpc-account-{uuid.uuid4().hex}" + token = f"rpc-account-token-{uuid.uuid4().hex}" + await ctx.set_copilot_user_by_token( + token, + { + "login": login, + "copilot_plan": "individual_pro", + "endpoints": { + "api": ctx.proxy_url, + "telemetry": "https://localhost:1/telemetry", + }, + "analytics_tracking_id": "rpc-account-tracking-id", + }, + ) + + client, home = await _create_isolated_client(ctx, github_token=None) + try: + initial = await client.rpc.account.get_current_auth() + assert initial.auth_info is None + + login_result = await client.rpc.account.login( + AccountLoginRequest(host="https://github.com", login=login, token=token) + ) + assert isinstance(login_result.stored_in_vault, bool) + + current = await client.rpc.account.get_current_auth() + assert current.auth_errors is None + assert current.auth_info is not None + assert current.auth_info.type == "user" + assert current.auth_info.host == "https://github.com" + assert current.auth_info.login == login + + users = await client.rpc.account.get_all_users() + assert isinstance(users, list) + account = next( + ( + user + for user in users + if user.auth_info.type == "user" + and getattr(user.auth_info, "login", None) == login + ), + None, + ) + if account is not None: + assert account.token == token + + logout = await client.rpc.account.logout( + AccountLogoutRequest(auth_info=current.auth_info) + ) + assert logout.has_more_users is False + + after_logout = await client.rpc.account.get_current_auth() + assert after_logout.auth_info is None + finally: + await _dispose_isolated(client, home) + async def test_should_report_agent_registry_spawn_gate_closed(self, ctx: E2ETestContext): client, home = await _create_isolated_client(ctx) try: diff --git a/python/e2e/test_rpc_session_state_extras_e2e.py b/python/e2e/test_rpc_session_state_extras_e2e.py index fb12157849..5d0d881a00 100644 --- a/python/e2e/test_rpc_session_state_extras_e2e.py +++ b/python/e2e/test_rpc_session_state_extras_e2e.py @@ -9,11 +9,28 @@ import contextlib import json +import time import pytest from copilot import CopilotClient, RuntimeConnection -from copilot.rpc import PermissionsSetAllowAllRequest +from copilot.rpc import ( + CompletionsRequestRequest, + MetadataContextHeaviestMessagesRequest, + ModelSwitchToRequest, + NamedProviderConfig, + PermissionsSetAllowAllRequest, + ProviderAddRequest, + ProviderModelConfig, + ProviderType, + ProviderWireAPI, + SessionVisibilityStatus, + SubagentSettings, + SubagentSettingsEntry, + SubagentSettingsEntryContextTier, + UpdateSubagentSettingsRequest, + VisibilitySetRequest, +) from copilot.session import PermissionHandler from .testharness import E2ETestContext @@ -83,6 +100,86 @@ async def test_should_report_session_activity_when_idle(self, ctx: E2ETestContex assert activity.has_active_work is False assert activity.abortable is False + async def test_should_add_byok_provider_and_model_at_runtime(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + provider_name = f"sdk-runtime-provider-{time.time_ns()}" + model_id = "sdk-runtime-model" + selection_id = f"{provider_name}/{model_id}" + + added = await session.rpc.provider.add( + ProviderAddRequest( + providers=[ + NamedProviderConfig( + name=provider_name, + type=ProviderType.OPENAI, + wire_api=ProviderWireAPI.COMPLETIONS, + base_url="https://api.example.test/v1", + api_key="runtime-provider-secret", + headers={"X-SDK-Provider": "runtime"}, + ) + ], + models=[ + ProviderModelConfig( + provider=provider_name, + id=model_id, + name="SDK Runtime Model", + model_id="claude-sonnet-4.5", + wire_model="wire-sdk-runtime-model", + max_context_window_tokens=4096, + max_prompt_tokens=3072, + max_output_tokens=1024, + ) + ], + ) + ) + + assert len(added.models) == 1 + assert selection_id in json.dumps(added.models[0], sort_keys=True) + assert "SDK Runtime Model" in json.dumps(added.models[0], sort_keys=True) + + listed = await session.rpc.model.list() + assert any(selection_id in json.dumps(model, sort_keys=True) for model in listed.list) + + switched = await session.rpc.model.switch_to( + ModelSwitchToRequest(model_id=selection_id) + ) + assert switched.model_id == selection_id + assert (await session.rpc.model.get_current()).model_id == selection_id + + async def test_should_return_empty_completions_when_host_does_not_provide_them( + self, ctx: E2ETestContext + ): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + triggers = await session.rpc.completions.get_trigger_characters() + assert triggers.trigger_characters == [] + + completions = await session.rpc.completions.request( + CompletionsRequestRequest(text="Use @", offset=5) + ) + assert completions.items == [] + + async def test_should_report_visibility_as_unsynced_for_local_session( + self, ctx: E2ETestContext + ): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + initial = await session.rpc.visibility.get() + assert initial.synced is False + assert initial.status is None + assert initial.share_url is None + + updated = await session.rpc.visibility.set( + VisibilitySetRequest(status=SessionVisibilityStatus.REPO) + ) + assert updated.synced is False + assert updated.status is None + assert updated.share_url is None + async def test_should_get_and_set_allowall_permissions(self, ctx: E2ETestContext): async with await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, @@ -110,6 +207,60 @@ async def test_should_get_and_set_allowall_permissions(self, ctx: E2ETestContext PermissionsSetAllowAllRequest(enabled=False) ) + async def test_should_get_context_attribution_and_heaviest_messages_after_turn( + self, ctx: E2ETestContext + ): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + answer = await session.send_and_wait("Say CONTEXT_METADATA_OK exactly.", timeout=60.0) + assert answer is not None + assert "CONTEXT_METADATA_OK" in (answer.data.content or "") + + attribution = await session.rpc.metadata.get_context_attribution() + assert attribution.context_attribution is not None + context_attribution = attribution.context_attribution + assert context_attribution.total_tokens > 0 + assert len(context_attribution.entries) > 0 + for entry in context_attribution.entries: + assert entry.id.strip() + assert entry.kind.strip() + assert entry.label.strip() + assert entry.tokens >= 0 + for key in entry.attributes or {}: + assert key.strip() + + heaviest = await session.rpc.metadata.get_context_heaviest_messages( + MetadataContextHeaviestMessagesRequest(limit=2) + ) + assert heaviest.total_tokens > 0 + assert len(heaviest.messages) <= 2 + for message in heaviest.messages: + assert message.id.strip() + assert message.tokens >= 0 + + async def test_should_update_and_clear_live_subagent_settings(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + await session.rpc.tools.update_subagent_settings( + UpdateSubagentSettingsRequest( + subagents=SubagentSettings( + { + "general-purpose": SubagentSettingsEntry( + model="claude-haiku-4.5", + effort_level="low", + context_tier=SubagentSettingsEntryContextTier.DEFAULT, + ) + } + ) + ) + ) + + await session.rpc.tools.update_subagent_settings( + UpdateSubagentSettingsRequest(subagents=None) + ) + async def test_should_read_empty_sql_todos_for_fresh_session(self, ctx: E2ETestContext): async with await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, diff --git a/python/e2e/test_rpc_tasks_and_handlers_e2e.py b/python/e2e/test_rpc_tasks_and_handlers_e2e.py index d712580a67..6a99cbb75d 100644 --- a/python/e2e/test_rpc_tasks_and_handlers_e2e.py +++ b/python/e2e/test_rpc_tasks_and_handlers_e2e.py @@ -14,6 +14,9 @@ from copilot.rpc import ( CommandsHandlePendingCommandRequest, HandlePendingToolCallRequest, + MCPHeadersHandlePendingHeadersRefreshRequest, + MCPHeadersHandlePendingHeadersRefreshRequestKind, + MCPHeadersHandlePendingHeadersRefreshRequestRequest, PermissionDecisionApproveForLocation, PermissionDecisionApproveForLocationApprovalCustomTool, PermissionDecisionApproveForSession, @@ -41,7 +44,10 @@ UIHandlePendingElicitationRequest, UIHandlePendingExitPlanModeRequest, UIHandlePendingSamplingRequest, + UIHandlePendingSessionLimitsExhaustedRequest, UIHandlePendingUserInputRequest, + UISessionLimitsExhaustedResponse, + UISessionLimitsExhaustedResponseAction, UIUnregisterDirectAutoModeSwitchHandlerRequest, UIUserInputResponse, ) @@ -253,6 +259,37 @@ async def test_should_return_expected_results_for_missing_pending_handler_reques ) ) assert location_approval.success is False + + session_limits = await session.rpc.ui.handle_pending_session_limits_exhausted( + UIHandlePendingSessionLimitsExhaustedRequest( + request_id="missing-session-limits-request", + response=UISessionLimitsExhaustedResponse( + action=UISessionLimitsExhaustedResponseAction.CANCEL + ), + ) + ) + assert session_limits.success is False + + headers = await session.rpc.mcp.headers.handle_pending_headers_refresh_request( + MCPHeadersHandlePendingHeadersRefreshRequestRequest( + request_id="missing-headers-refresh-request", + result=MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.HEADERS, + headers={"X-SDK-Test": "missing"}, + ), + ) + ) + assert headers.success is False + + no_headers = await session.rpc.mcp.headers.handle_pending_headers_refresh_request( + MCPHeadersHandlePendingHeadersRefreshRequestRequest( + request_id="missing-headers-refresh-none-request", + result=MCPHeadersHandlePendingHeadersRefreshRequest( + kind=MCPHeadersHandlePendingHeadersRefreshRequestKind.NONE, + ), + ) + ) + assert no_headers.success is False finally: await session.disconnect() diff --git a/rust/tests/e2e/client_options.rs b/rust/tests/e2e/client_options.rs index 8b13789179..dbf3c5c83d 100644 --- a/rust/tests/e2e/client_options.rs +++ b/rust/tests/e2e/client_options.rs @@ -1 +1,485 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use github_copilot_sdk::canvas::CanvasDeclaration; +use github_copilot_sdk::rpc::{OpenCanvasInstance, RemoteSessionMode}; +use github_copilot_sdk::session_events::{ReasoningSummary, SessionLimitsConfig}; +use github_copilot_sdk::{ + CliProgram, Client, ClientOptions, ExtensionInfo, ProviderConfig, ResumeSessionConfig, + SessionConfig, SessionId, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tempfile::TempDir; + +#[tokio::test] +async fn should_forward_advanced_session_creation_options_to_the_cli() { + let fake = FakeCli::new(); + let client = Client::start(fake.client_options("advanced-create-client-token")) + .await + .expect("start fake CLI client"); + + let config_dir = fake.path("config"); + let working_dir = fake.path("workspace"); + let extension_sdk_path = fake.path("extension-sdk"); + let session = client + .create_session( + SessionConfig::default() + .with_session_id("advanced-session-id") + .with_client_name("rust-sdk-e2e-client") + .with_model("claude-sonnet-4.5") + .with_reasoning_effort("low") + .with_reasoning_summary(ReasoningSummary::None) + .with_context_tier("long_context") + .with_config_directory(config_dir.clone()) + .with_enable_config_discovery(true) + .with_skip_embedding_retrieval(true) + .with_embedding_cache_storage("in-memory") + .with_organization_custom_instructions("organization guidance") + .with_enable_on_demand_instruction_discovery(true) + .with_enable_file_hooks(false) + .with_enable_host_git_operations(false) + .with_enable_session_store(false) + .with_enable_skills(false) + .with_working_directory(working_dir.clone()) + .with_streaming(true) + .with_include_sub_agent_streaming_events(false) + .with_available_tools(["read_file"]) + .with_excluded_tools(["bash"]) + .with_excluded_builtin_agents(["legacy-agent"]) + .with_enable_session_telemetry(false) + .with_enable_citations(true) + .with_session_limits(SessionLimitsConfig { + max_ai_credits: Some(42.0), + }) + .with_skip_custom_instructions(true) + .with_custom_agents_local_only(true) + .with_coauthor_enabled(false) + .with_manage_schedule_enabled(false) + .with_github_token("advanced-create-session-token") + .with_remote_session(RemoteSessionMode::Export) + .with_skill_directories([PathBuf::from("skills")]) + .with_plugin_directories([PathBuf::from("plugins")]) + .with_instruction_directories([PathBuf::from("instructions")]) + .with_disabled_skills(["disabled-skill"]) + .with_enable_mcp_apps(true) + .with_canvases([CanvasDeclaration::new( + "canvas", + "Canvas", + "Canvas description", + )]) + .with_request_canvas_renderer(true) + .with_request_extensions(true) + .with_extension_sdk_path(path_string(&extension_sdk_path)) + .with_extension_info(ExtensionInfo::new("github-app", "rust-e2e-extension")) + .with_exp_assignments(json!({ "feature": "enabled" })), + ) + .await + .expect("create session"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + + let create = fake.captured_request("session.create"); + let params = create.params.as_object().expect("session.create params"); + assert_json_values( + params, + [ + ("sessionId", json!("advanced-session-id")), + ("clientName", json!("rust-sdk-e2e-client")), + ("model", json!("claude-sonnet-4.5")), + ("reasoningEffort", json!("low")), + ("reasoningSummary", json!("none")), + ("contextTier", json!("long_context")), + ("configDir", json!(path_string(&config_dir))), + ("enableConfigDiscovery", json!(true)), + ("skipEmbeddingRetrieval", json!(true)), + ("embeddingCacheStorage", json!("in-memory")), + ( + "organizationCustomInstructions", + json!("organization guidance"), + ), + ("enableOnDemandInstructionDiscovery", json!(true)), + ("enableFileHooks", json!(false)), + ("enableHostGitOperations", json!(false)), + ("enableSessionStore", json!(false)), + ("enableSkills", json!(false)), + ("workingDirectory", json!(path_string(&working_dir))), + ("streaming", json!(true)), + ("includeSubAgentStreamingEvents", json!(false)), + ("enableSessionTelemetry", json!(false)), + ("enableCitations", json!(true)), + ("gitHubToken", json!("advanced-create-session-token")), + ("remoteSession", json!("export")), + ("requestMcpApps", json!(true)), + ("requestCanvasRenderer", json!(true)), + ("requestExtensions", json!(true)), + ("extensionSdkPath", json!(path_string(&extension_sdk_path))), + ("envValueMode", json!("direct")), + ], + ); + assert_eq!(params["availableTools"], json!(["read_file"])); + assert_eq!(params["excludedTools"], json!(["bash"])); + assert_eq!(params["excludedBuiltinAgents"], json!(["legacy-agent"])); + assert_eq!(params["skillDirectories"], json!(["skills"])); + assert_eq!(params["pluginDirectories"], json!(["plugins"])); + assert_eq!(params["instructionDirectories"], json!(["instructions"])); + assert_eq!(params["disabledSkills"], json!(["disabled-skill"])); + assert_eq!(params["sessionLimits"]["maxAiCredits"], json!(42)); + assert_eq!( + params["extensionInfo"], + json!({ "source": "github-app", "name": "rust-e2e-extension" }) + ); + assert_eq!(params["canvases"][0]["id"], json!("canvas")); + assert_eq!(params["canvases"][0]["displayName"], json!("Canvas")); + assert_eq!( + params["canvases"][0]["description"], + json!("Canvas description") + ); + assert_eq!(params["expAssignments"]["feature"], json!("enabled")); + + let update = fake.captured_request("session.options.update"); + let update_params = update.params.as_object().expect("options update params"); + assert_json_values( + update_params, + [ + ("sessionId", json!("advanced-session-id")), + ("skipCustomInstructions", json!(true)), + ("customAgentsLocalOnly", json!(true)), + ("coauthorEnabled", json!(false)), + ("manageScheduleEnabled", json!(false)), + ], + ); +} + +#[tokio::test] +async fn should_forward_singular_provider_configuration_on_session_creation() { + let fake = FakeCli::new(); + let client = Client::start(fake.client_options("provider-client-token")) + .await + .expect("start fake CLI client"); + + let session = client + .create_session( + SessionConfig::default().with_provider( + ProviderConfig::new("https://models.example.test/v1") + .with_provider_type("openai") + .with_wire_api("responses") + .with_transport("websockets") + .with_api_key("provider-key") + .with_model_id("base-model") + .with_wire_model("wire-model") + .with_max_prompt_tokens(1000) + .with_max_output_tokens(2000) + .with_headers(HashMap::from([( + "x-provider".to_string(), + "rust".to_string(), + )])), + ), + ) + .await + .expect("create session"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + + let create = fake.captured_request("session.create"); + let provider = create.params["provider"] + .as_object() + .expect("provider params"); + assert_json_values( + provider, + [ + ("type", json!("openai")), + ("wireApi", json!("responses")), + ("transport", json!("websockets")), + ("baseUrl", json!("https://models.example.test/v1")), + ("apiKey", json!("provider-key")), + ("modelId", json!("base-model")), + ("wireModel", json!("wire-model")), + ("maxPromptTokens", json!(1000)), + ("maxOutputTokens", json!(2000)), + ], + ); + assert_eq!(provider["headers"]["x-provider"], json!("rust")); +} + +#[tokio::test] +async fn should_forward_advanced_session_resume_options_to_the_cli() { + let fake = FakeCli::new(); + let client = Client::start(fake.client_options("advanced-resume-client-token")) + .await + .expect("start fake CLI client"); + + let config_dir = fake.path("resume-config"); + let working_dir = fake.path("resume-workspace"); + let extension_sdk_path = fake.path("resume-extension-sdk"); + let session = client + .resume_session( + ResumeSessionConfig::new(SessionId::from("resume-session-id")) + .with_model("gpt-5-mini") + .with_reasoning_effort("low") + .with_reasoning_summary(ReasoningSummary::None) + .with_context_tier("long_context") + .with_working_directory(working_dir.clone()) + .with_config_directory(config_dir.clone()) + .with_enable_config_discovery(false) + .with_suppress_resume_event(true) + .with_continue_pending_work(false) + .with_streaming(true) + .with_include_sub_agent_streaming_events(false) + .with_github_token("advanced-resume-session-token") + .with_canvases([CanvasDeclaration::new( + "resume-canvas", + "Resume Canvas", + "Resume canvas description", + )]) + .with_open_canvases([OpenCanvasInstance { + canvas_id: "resume-canvas".to_string(), + extension_id: "github-app/rust-e2e-extension".to_string(), + extension_name: None, + input: Some(json!({ "value": "from-resume" })), + instance_id: "resume-instance".to_string(), + status: None, + title: None, + url: None, + }]) + .with_request_canvas_renderer(true) + .with_request_extensions(true) + .with_extension_sdk_path(path_string(&extension_sdk_path)) + .with_extension_info(ExtensionInfo::new("github-app", "rust-e2e-extension")) + .with_skip_custom_instructions(true) + .with_custom_agents_local_only(true) + .with_coauthor_enabled(false) + .with_manage_schedule_enabled(false) + .with_exp_assignments(json!({ "resumeFeature": "enabled" })), + ) + .await + .expect("resume session"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + + let resume = fake.captured_request("session.resume"); + let params = resume.params.as_object().expect("session.resume params"); + assert_json_values( + params, + [ + ("sessionId", json!("resume-session-id")), + ("model", json!("gpt-5-mini")), + ("reasoningEffort", json!("low")), + ("reasoningSummary", json!("none")), + ("contextTier", json!("long_context")), + ("workingDirectory", json!(path_string(&working_dir))), + ("configDir", json!(path_string(&config_dir))), + ("enableConfigDiscovery", json!(false)), + ("disableResume", json!(true)), + ("continuePendingWork", json!(false)), + ("streaming", json!(true)), + ("includeSubAgentStreamingEvents", json!(false)), + ("gitHubToken", json!("advanced-resume-session-token")), + ("requestCanvasRenderer", json!(true)), + ("requestExtensions", json!(true)), + ("extensionSdkPath", json!(path_string(&extension_sdk_path))), + ("envValueMode", json!("direct")), + ], + ); + assert_eq!( + params["openCanvases"][0]["canvasId"], + json!("resume-canvas") + ); + assert_eq!( + params["openCanvases"][0]["extensionId"], + json!("github-app/rust-e2e-extension") + ); + assert_eq!( + params["openCanvases"][0]["instanceId"], + json!("resume-instance") + ); + assert_eq!( + params["extensionInfo"], + json!({ "source": "github-app", "name": "rust-e2e-extension" }) + ); + assert_eq!(params["expAssignments"]["resumeFeature"], json!("enabled")); + + let update = fake.captured_request("session.options.update"); + let update_params = update.params.as_object().expect("options update params"); + assert_json_values( + update_params, + [ + ("sessionId", json!("resume-session-id")), + ("skipCustomInstructions", json!(true)), + ("customAgentsLocalOnly", json!(true)), + ("coauthorEnabled", json!(false)), + ("manageScheduleEnabled", json!(false)), + ], + ); +} + +struct FakeCli { + _dir: TempDir, + script_path: PathBuf, + capture_path: PathBuf, + work_dir: PathBuf, +} + +impl FakeCli { + fn new() -> Self { + let dir = tempfile::tempdir().expect("create fake CLI temp dir"); + let script_path = dir.path().join("fake-cli.js"); + let capture_path = dir.path().join("fake-cli-capture.json"); + let work_dir = dir.path().join("cwd"); + std::fs::create_dir(&work_dir).expect("create fake CLI cwd"); + std::fs::write(&script_path, FAKE_STDIO_CLI_SCRIPT).expect("write fake CLI script"); + Self { + _dir: dir, + script_path, + capture_path, + work_dir, + } + } + + fn client_options(&self, token: &str) -> ClientOptions { + ClientOptions::new() + .with_program(CliProgram::Path(PathBuf::from("node"))) + .with_prefix_args([self.script_path.as_os_str().to_owned()]) + .with_cwd(&self.work_dir) + .with_extra_args([ + "--capture-file".to_string(), + self.capture_path.to_string_lossy().into_owned(), + ]) + .with_github_token(token) + .with_use_logged_in_user(false) + } + + fn path(&self, name: &str) -> PathBuf { + let path = self.work_dir.join(name); + std::fs::create_dir_all(&path).expect("create fake CLI test path"); + path + } + + fn captured_request(&self, method: &str) -> CapturedRequest { + let capture = self.capture(); + capture + .requests + .iter() + .find(|request| request.method == method) + .cloned() + .unwrap_or_else(|| panic!("expected {method} request in {capture:?}")) + } + + fn capture(&self) -> CapturedCli { + let text = std::fs::read_to_string(&self.capture_path).expect("read fake CLI capture file"); + serde_json::from_str(&text).expect("parse fake CLI capture file") + } +} + +#[derive(Debug, Deserialize)] +struct CapturedCli { + requests: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct CapturedRequest { + method: String, + #[serde(default)] + params: Value, +} + +fn assert_json_values<'a>( + object: &serde_json::Map, + expected: impl IntoIterator, +) { + for (key, expected_value) in expected { + assert_eq!( + object.get(key), + Some(&expected_value), + "unexpected value for key {key} in {object:?}" + ); + } +} + +fn path_string(path: &std::path::Path) -> String { + path.to_string_lossy().into_owned() +} + +const FAKE_STDIO_CLI_SCRIPT: &str = r#" +const fs = require("fs"); + +const captureIndex = process.argv.indexOf("--capture-file"); +const captureFile = captureIndex >= 0 ? process.argv[captureIndex + 1] : undefined; +const requests = []; + +function saveCapture() { + if (!captureFile) { + return; + } + fs.writeFileSync(captureFile, JSON.stringify({ + requests, + args: process.argv.slice(2), + cwd: process.cwd(), + env: { + COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN, + }, + })); +} + +saveCapture(); + +let buffer = Buffer.alloc(0); +process.stdin.on("data", chunk => { + buffer = Buffer.concat([buffer, chunk]); + processBuffer(); +}); +process.stdin.resume(); + +function processBuffer() { + while (true) { + const headerEnd = buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = buffer.subarray(0, headerEnd).toString("utf8"); + const match = /Content-Length:\s*(\d+)/i.exec(header); + if (!match) throw new Error("Missing Content-Length header"); + const length = Number(match[1]); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (buffer.length < bodyEnd) return; + const body = buffer.subarray(bodyStart, bodyEnd).toString("utf8"); + buffer = buffer.subarray(bodyEnd); + handleMessage(JSON.parse(body)); + } +} + +function handleMessage(message) { + if (!Object.prototype.hasOwnProperty.call(message, "id")) { + return; + } + requests.push({ method: message.method, params: message.params }); + saveCapture(); + if (message.method === "connect") { + writeResponse(message.id, { ok: true, protocolVersion: 3, version: "fake" }); + return; + } + if (message.method === "ping") { + writeResponse(message.id, { message: "pong", protocolVersion: 3, timestamp: Date.now() }); + return; + } + if (message.method === "session.create") { + const sessionId = (message.params && message.params.sessionId) || "fake-session"; + writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null }); + return; + } + if (message.method === "session.resume") { + const sessionId = (message.params && message.params.sessionId) || "fake-session"; + writeResponse(message.id, { sessionId, workspacePath: null, capabilities: null, openCanvases: [] }); + return; + } + if (message.method === "session.options.update") { + writeResponse(message.id, { success: true }); + return; + } + writeResponse(message.id, {}); +} + +function writeResponse(id, result) { + const body = JSON.stringify({ jsonrpc: "2.0", id, result }); + process.stdout.write("Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n\r\n" + body); +} +"#; diff --git a/rust/tests/e2e/mcp_oauth.rs b/rust/tests/e2e/mcp_oauth.rs index 98d4cf0309..f330df1155 100644 --- a/rust/tests/e2e/mcp_oauth.rs +++ b/rust/tests/e2e/mcp_oauth.rs @@ -14,6 +14,7 @@ use serde::Deserialize; use serde_json::Value; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; +use tokio::sync::Notify; use super::support::{wait_for_condition, with_e2e_context_no_snapshot}; @@ -98,11 +99,9 @@ async fn should_satisfy_mcp_oauth_using_host_provided_token() { .iter() .any(|request| request.authorization.is_none()) ); - assert!( - requests.iter().any( - |request| request.authorization.as_deref() == Some("Bearer sdk-host-token") - ) - ); + assert!(requests.iter().any(|request| { + request.authorization.as_deref() == Some(&format!("Bearer {EXPECTED_TOKEN}")) + })); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -160,24 +159,15 @@ async fn should_request_replacement_tokens_across_mcp_oauth_lifecycle() { ); let requests = oauth_server.requests().await; - assert!( - requests - .iter() - .any(|request| request.authorization.as_deref() - == Some("Bearer sdk-host-token-refresh")) - ); - assert!( - requests - .iter() - .any(|request| request.authorization.as_deref() - == Some("Bearer sdk-host-token-upscope")) - ); - assert!( - requests - .iter() - .any(|request| request.authorization.as_deref() - == Some("Bearer sdk-host-token-reauth")) - ); + assert!(requests.iter().any(|request| { + request.authorization.as_deref() == Some(&format!("Bearer {REFRESH_TOKEN}")) + })); + assert!(requests.iter().any(|request| { + request.authorization.as_deref() == Some(&format!("Bearer {UPSCOPE_TOKEN}")) + })); + assert!(requests.iter().any(|request| { + request.authorization.as_deref() == Some(&format!("Bearer {REAUTH_TOKEN}")) + })); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -235,6 +225,119 @@ async fn should_cancel_pending_mcp_oauth_request() { .await; } +#[tokio::test] +async fn should_resolve_pending_mcp_oauth_request_through_rpc() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let mut oauth_server = OAuthMcpServer::start( + ctx.repo_root() + .join("test/harness/test-mcp-oauth-server.mjs"), + ) + .await; + let server_name = "oauth-direct-rpc-mcp"; + let observed_request = Arc::new(Mutex::new(None)); + let request_observed = Arc::new(Notify::new()); + let release_handler = Arc::new(Notify::new()); + let handler = Arc::new(BlockingAuthHandler { + request: observed_request.clone(), + request_observed: request_observed.clone(), + release: release_handler.clone(), + }); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_enable_mcp_apps(true) + .with_mcp_auth_handler(handler) + .with_mcp_servers(HashMap::from([( + server_name.to_string(), + McpServerConfig::Http(McpHttpServerConfig { + tools: Some(vec!["*".to_string()]), + timeout: None, + url: format!("{}/mcp", oauth_server.url), + headers: HashMap::new(), + }), + )])), + ) + .await + .expect("create session"); + + let connected = + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Connected); + tokio::pin!(connected); + tokio::select! { + () = request_observed.notified() => {} + () = &mut connected => panic!("MCP server connected before OAuth request was observed"), + } + let request = observed_request + .lock() + .clone() + .expect("MCP auth request"); + assert_eq!(request.server_name, server_name); + assert_eq!(request.server_url, format!("{}/mcp", oauth_server.url)); + assert_eq!(request.reason, McpOauthRequestReason::Initial); + let www_authenticate = request + .www_authenticate_params + .as_ref() + .expect("WWW-Authenticate params"); + assert_eq!( + www_authenticate.resource_metadata_url, + Some(format!( + "{}/.well-known/oauth-protected-resource", + oauth_server.url + )) + ); + assert_eq!(www_authenticate.scope.as_deref(), Some("mcp.read")); + assert_eq!(www_authenticate.error.as_deref(), Some("invalid_token")); + + let handled = session + .rpc() + .mcp() + .oauth() + .handle_pending_request(github_copilot_sdk::rpc::McpOauthHandlePendingRequest { + request_id: request.request_id, + result: github_copilot_sdk::rpc::McpOauthPendingRequestResponse::Token( + github_copilot_sdk::rpc::McpOauthPendingRequestResponseToken { + access_token: EXPECTED_TOKEN.to_string(), + expires_in: Some(3600), + kind: github_copilot_sdk::rpc::McpOauthPendingRequestResponseTokenKind::Token, + token_type: Some("Bearer".to_string()), + }, + ), + }) + .await + .expect("handle pending MCP OAuth request"); + assert!(handled.success); + + release_handler.notify_one(); + connected.await; + let tools = session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { + server_name: server_name.to_string(), + }) + .await + .expect("list MCP tools"); + assert!(tools.tools.iter().any(|tool| tool.name == "whoami")); + let requests = oauth_server.requests().await; + assert!( + requests + .iter() + .any(|request| { + request.authorization.as_deref() == Some(&format!("Bearer {EXPECTED_TOKEN}")) + }) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + oauth_server.stop().await; + }) + }) + .await; +} + #[derive(Default)] struct TokenAuthHandler { request: Mutex>, @@ -338,6 +441,32 @@ impl McpAuthHandler for CancelAuthHandler { } } +struct BlockingAuthHandler { + request: Arc>>, + request_observed: Arc, + release: Arc, +} + +#[async_trait] +impl McpAuthHandler for BlockingAuthHandler { + async fn handle( + &self, + _session_id: SessionId, + request_id: RequestId, + request: McpAuthRequest, + ) -> McpAuthResult { + assert_eq!(request.request_id, request_id); + *self.request.lock() = Some(request); + self.request_observed.notify_one(); + self.release.notified().await; + McpAuthResult::Token { + access_token: EXPECTED_TOKEN.to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(3600), + } + } +} + #[derive(Deserialize)] struct OAuthMcpRequest { authorization: Option, diff --git a/rust/tests/e2e/rpc_server.rs b/rust/tests/e2e/rpc_server.rs index 27cad2f69a..665041f49d 100644 --- a/rust/tests/e2e/rpc_server.rs +++ b/rust/tests/e2e/rpc_server.rs @@ -1,18 +1,23 @@ -use github_copilot_sdk::Client; +use std::collections::HashMap; + use github_copilot_sdk::rpc::{ - ConnectRemoteSessionParams, LocalSessionMetadataValue, McpDiscoverRequest, NameSetRequest, - PingRequest, SecretsAddFilterValuesRequest, SessionContext, SessionFsSetProviderConventions, + AgentsDiscoverRequest, AgentsGetDiscoveryPathsRequest, ConnectRemoteSessionParams, + InstructionsDiscoverRequest, InstructionsGetDiscoveryPathsRequest, + LlmInferenceHttpResponseChunkRequest, LlmInferenceHttpResponseStartRequest, + LocalSessionMetadataValue, McpDiscoverRequest, NameSetRequest, PingRequest, + SecretsAddFilterValuesRequest, SessionContext, SessionFsSetProviderConventions, SessionFsSetProviderRequest, SessionListFilter, SessionsBulkDeleteRequest, SessionsCheckInUseRequest, SessionsCloseRequest, SessionsEnrichMetadataRequest, SessionsFindByPrefixRequest, SessionsFindByTaskIDRequest, SessionsGetLastForContextRequest, SessionsListRequest, SessionsLoadDeferredRepoHooksRequest, SessionsPruneOldRequest, SessionsReleaseLockRequest, SessionsReloadPluginHooksRequest, SessionsSaveRequest, SessionsSetAdditionalPluginsRequest, SkillsConfigSetDisabledSkillsRequest, - SkillsDiscoverRequest, ToolsListRequest, + SkillsDiscoverRequest, SkillsGetDiscoveryPathsRequest, ToolsListRequest, }; +use github_copilot_sdk::{Client, RequestId}; use serde_json::json; -use super::support::with_e2e_context; +use super::support::{with_e2e_context, with_e2e_context_no_snapshot}; #[tokio::test] async fn should_call_rpc_ping_with_typed_params_and_result() { @@ -136,6 +141,49 @@ async fn should_call_rpc_tools_list_with_typed_result() { .await; } +#[tokio::test] +async fn should_reject_llm_response_frames_for_unknown_request() { + with_e2e_context_no_snapshot(|ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + let request_id = RequestId::from("missing-llm-response-request"); + + let start = client + .rpc() + .llm_inference() + .http_response_start(LlmInferenceHttpResponseStartRequest { + headers: HashMap::from([( + "content-type".to_string(), + vec!["application/json".to_string()], + )]), + request_id: request_id.clone(), + status: 200, + status_text: Some("OK".to_string()), + }) + .await + .expect("send unknown LLM response start"); + assert!(!start.accepted); + + let chunk = client + .rpc() + .llm_inference() + .http_response_chunk(LlmInferenceHttpResponseChunkRequest { + binary: Some(false), + data: "{}".to_string(), + end: Some(true), + error: None, + request_id, + }) + .await + .expect("send unknown LLM response chunk"); + assert!(!chunk.accepted); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + #[tokio::test] async fn should_discover_server_mcp_and_skills() { with_e2e_context( @@ -150,12 +198,13 @@ async fn should_discover_server_mcp_and_skills() { "Skill discovered by server-scoped RPC tests.", ); let client = ctx.start_client().await; + let project_path = ctx.work_dir().to_string_lossy().to_string(); let mcp = client .rpc() .mcp() .discover(McpDiscoverRequest { - working_directory: Some(ctx.work_dir().to_string_lossy().to_string()), + working_directory: Some(project_path.clone()), }) .await .expect("mcp discover"); @@ -179,6 +228,101 @@ async fn should_discover_server_mcp_and_skills() { "Skill discovered by server-scoped RPC tests." ); + let skill_paths = client + .rpc() + .skills() + .get_discovery_paths(SkillsGetDiscoveryPathsRequest { + exclude_host_skills: Some(true), + project_paths: Some(vec![project_path.clone()]), + }) + .await + .expect("skills discovery paths"); + let project_skill_path = skill_paths + .paths + .iter() + .find(|path| { + path.project_path + .as_deref() + .is_some_and(|path| paths_equal(path, &project_path)) + && path.preferred_for_creation + }) + .expect("project skill discovery path"); + assert!(!project_skill_path.path.trim().is_empty()); + + let agents = client + .rpc() + .agents() + .discover(AgentsDiscoverRequest { + exclude_host_agents: Some(true), + project_paths: Some(vec![project_path.clone()]), + }) + .await + .expect("agents discover"); + assert!( + agents + .agents + .iter() + .all(|agent| !agent.name.trim().is_empty()) + ); + + let agent_paths = client + .rpc() + .agents() + .get_discovery_paths(AgentsGetDiscoveryPathsRequest { + exclude_host_agents: Some(true), + project_paths: Some(vec![project_path.clone()]), + }) + .await + .expect("agents discovery paths"); + let project_agent_path = agent_paths + .paths + .iter() + .find(|path| { + path.project_path + .as_deref() + .is_some_and(|path| paths_equal(path, &project_path)) + && path.preferred_for_creation + }) + .expect("project agent discovery path"); + assert!(!project_agent_path.path.trim().is_empty()); + + let instructions = client + .rpc() + .instructions() + .discover(InstructionsDiscoverRequest { + exclude_host_instructions: Some(true), + project_paths: Some(vec![project_path.clone()]), + }) + .await + .expect("instructions discover"); + assert!(instructions.sources.iter().all(|source| { + !source.id.trim().is_empty() + && !source.label.trim().is_empty() + && !source.source_path.trim().is_empty() + })); + + let instruction_paths = client + .rpc() + .instructions() + .get_discovery_paths(InstructionsGetDiscoveryPathsRequest { + exclude_host_instructions: Some(true), + project_paths: Some(vec![project_path.clone()]), + }) + .await + .expect("instructions discovery paths"); + assert!(!instruction_paths.paths.is_empty()); + assert!(instruction_paths.paths.iter().any(|path| { + path.project_path + .as_deref() + .is_some_and(|path| paths_equal(path, &project_path)) + })); + assert!( + instruction_paths + .paths + .iter() + .all(|path| !path.path.trim().is_empty()) + ); + client .rpc() .skills() @@ -701,3 +845,19 @@ fn assert_server_skill( ); skill } + +fn paths_equal(left: &str, right: &str) -> bool { + fn normalize(path: &str) -> String { + let mut normalized = path.replace('\\', "/"); + while normalized.ends_with('/') && normalized.len() > 1 { + normalized.pop(); + } + if cfg!(windows) { + normalized.to_ascii_lowercase() + } else { + normalized + } + } + + normalize(left) == normalize(right) +} diff --git a/rust/tests/e2e/rpc_server_misc.rs b/rust/tests/e2e/rpc_server_misc.rs index 2886aff280..b9e5cdf5c4 100644 --- a/rust/tests/e2e/rpc_server_misc.rs +++ b/rust/tests/e2e/rpc_server_misc.rs @@ -1,7 +1,9 @@ use github_copilot_sdk::Client; use github_copilot_sdk::rpc::{ - AgentRegistrySpawnRequest, SendAttachmentsToMessageParams, SessionsOpenStatus, + AccountLoginRequest, AccountLogoutRequest, AgentRegistrySpawnRequest, + SendAttachmentsToMessageParams, SessionsOpenStatus, UserSettingsSetRequest, }; +use serde_json::{Map, Value, json}; use super::support::{wait_for_condition, with_e2e_context}; @@ -25,6 +27,183 @@ async fn should_reload_user_settings() { .await; } +#[tokio::test] +async fn should_get_set_and_clear_user_settings() { + with_e2e_context( + "rpc_server_misc", + "should_get_set_and_clear_user_settings", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let initial = client + .rpc() + .user() + .settings() + .get() + .await + .expect("get initial user settings"); + let (key, value) = initial + .settings + .iter() + .find_map(|(key, setting)| { + setting.value.as_bool().map(|value| (key.clone(), value)) + }) + .expect("at least one boolean user setting"); + let toggled = !value; + + let set = client + .rpc() + .user() + .settings() + .set(UserSettingsSetRequest { + settings: setting_patch(&key, json!(toggled)), + }) + .await + .expect("set user setting"); + assert!(set.shadowed_keys.is_empty()); + client + .rpc() + .user() + .settings() + .reload() + .await + .expect("reload after set"); + let after_set = client + .rpc() + .user() + .settings() + .get() + .await + .expect("get after set"); + let metadata = after_set.settings.get(&key).expect("updated setting"); + assert_eq!(metadata.value, json!(toggled)); + assert!(!metadata.is_default); + + let clear = client + .rpc() + .user() + .settings() + .set(UserSettingsSetRequest { + settings: setting_patch(&key, Value::Null), + }) + .await + .expect("clear user setting"); + assert!(clear.shadowed_keys.is_empty()); + client + .rpc() + .user() + .settings() + .reload() + .await + .expect("reload after clear"); + let after_clear = client + .rpc() + .user() + .settings() + .get() + .await + .expect("get after clear"); + assert!( + after_clear + .settings + .get(&key) + .expect("cleared setting") + .is_default + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_login_list_getcurrentauth_and_logout_account() { + with_e2e_context( + "rpc_server_misc", + "should_login_list_getcurrentauth_and_logout_account", + |ctx| { + Box::pin(async move { + ctx.set_copilot_user_by_token_with_login("rust-account-token", "rust-account-user"); + let client = Client::start(ctx.client_options().with_use_logged_in_user(false)) + .await + .expect("start no-token client"); + + let initial = client + .rpc() + .account() + .get_current_auth() + .await + .expect("get initial auth"); + assert!(initial.auth_info.is_none()); + + let login = client + .rpc() + .account() + .login(AccountLoginRequest { + host: "https://github.com".to_string(), + login: "rust-account-user".to_string(), + token: "rust-account-token".to_string(), + }) + .await + .expect("account login"); + let _stored_in_vault = login.stored_in_vault; + + let current = client + .rpc() + .account() + .get_current_auth() + .await + .expect("get current auth after login"); + let auth_info = current.auth_info.expect("auth info after login"); + assert_eq!(auth_info["login"], json!("rust-account-user")); + assert_eq!(auth_info["host"], json!("https://github.com")); + + let users = client + .rpc() + .account() + .get_all_users() + .await + .expect("get all users"); + if let Some(user) = users + .iter() + .find(|user| user.auth_info["login"] == json!("rust-account-user")) + { + user.token + .as_deref() + .filter(|token| *token == "rust-account-token") + .unwrap_or_else(|| { + panic!("expected stored account token, got {:?}", user.token) + }); + } + + let logout = client + .rpc() + .account() + .logout(AccountLogoutRequest { auth_info }) + .await + .expect("account logout"); + assert!(!logout.has_more_users); + assert!( + client + .rpc() + .account() + .get_current_auth() + .await + .expect("get auth after logout") + .auth_info + .is_none() + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_report_agent_registry_spawn_gate_closed() { with_e2e_context( @@ -168,3 +347,9 @@ fn assert_not_unhandled(message: &str) { "{message}" ); } + +fn setting_patch(key: &str, value: Value) -> Value { + let mut settings = Map::new(); + settings.insert(key.to_string(), value); + Value::Object(settings) +} diff --git a/rust/tests/e2e/rpc_session_state_extras.rs b/rust/tests/e2e/rpc_session_state_extras.rs index b8b8073a38..2e7fbc44ad 100644 --- a/rust/tests/e2e/rpc_session_state_extras.rs +++ b/rust/tests/e2e/rpc_session_state_extras.rs @@ -1,5 +1,13 @@ +use std::collections::HashMap; + use github_copilot_sdk::Client; -use github_copilot_sdk::rpc::PermissionsSetAllowAllRequest; +use github_copilot_sdk::rpc::{ + CompletionsRequestRequest, MetadataContextHeaviestMessagesRequest, ModelSwitchToRequest, + NamedProviderConfig, PermissionsSetAllowAllRequest, ProviderAddRequest, ProviderConfigType, + ProviderConfigWireApi, ProviderModelConfig, SessionVisibilityStatus, SubagentSettingsEntry, + SubagentSettingsEntryContextTier, UpdateSubagentSettingsRequest, + UpdateSubagentSettingsRequestSubagents, VisibilitySetRequest, +}; use super::support::{assistant_message_content, with_e2e_context}; @@ -252,6 +260,257 @@ async fn should_get_current_tool_metadata_after_initialization() { .await; } +#[tokio::test] +async fn should_add_byok_provider_and_model_at_runtime() { + with_e2e_context( + "rpc_session_state_extras", + "should_add_byok_provider_and_model_at_runtime", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .provider() + .add(ProviderAddRequest { + providers: Some(vec![NamedProviderConfig { + api_key: Some("provider-key".to_string()), + azure: None, + base_url: "https://models.example.test/v1".to_string(), + bearer_token: None, + has_bearer_token_provider: None, + headers: Some(HashMap::from([( + "x-provider".to_string(), + "rust".to_string(), + )])), + name: "rust-e2e-provider".to_string(), + transport: None, + r#type: Some(ProviderConfigType::Openai), + wire_api: Some(ProviderConfigWireApi::Completions), + }]), + models: Some(vec![ProviderModelConfig { + capabilities: None, + id: "small".to_string(), + max_context_window_tokens: None, + max_output_tokens: None, + max_prompt_tokens: Some(4096.0), + model_id: None, + name: Some("Rust Added Model".to_string()), + provider: "rust-e2e-provider".to_string(), + wire_model: None, + }]), + }) + .await + .expect("add provider model"); + assert_eq!(result.models.len(), 1); + + let selection_id = "rust-e2e-provider/small"; + session + .rpc() + .model() + .switch_to(ModelSwitchToRequest { + context_tier: None, + model_capabilities: None, + model_id: selection_id.to_string(), + reasoning_effort: None, + reasoning_summary: None, + }) + .await + .expect("switch to added model"); + let current = session + .rpc() + .model() + .get_current() + .await + .expect("get current model"); + assert_eq!(current.model_id.as_deref(), Some(selection_id)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_empty_completions_when_host_does_not_provide_them() { + with_e2e_context( + "rpc_session_state_extras", + "should_return_empty_completions_when_host_does_not_provide_them", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session + .rpc() + .completions() + .request(CompletionsRequestRequest { + offset: 5, + text: "Use @ to mention context".to_string(), + }) + .await + .expect("request completions"); + assert!(result.items.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_visibility_as_unsynced_for_local_session() { + with_e2e_context( + "rpc_session_state_extras", + "should_report_visibility_as_unsynced_for_local_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let set = session + .rpc() + .visibility() + .set(VisibilitySetRequest { + status: SessionVisibilityStatus::Unshared, + }) + .await + .expect("set visibility"); + assert!(!set.synced); + assert!(set.status.is_none()); + assert!(set.share_url.is_none()); + let get = session + .rpc() + .visibility() + .get() + .await + .expect("get visibility"); + assert!(!get.synced); + assert!(get.status.is_none()); + assert!(get.share_url.is_none()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_context_attribution_and_heaviest_messages_after_turn() { + with_e2e_context( + "rpc_session_state_extras", + "should_get_context_attribution_and_heaviest_messages_after_turn", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Say CONTEXT_METADATA_OK exactly.") + .await + .expect("send prompt") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("CONTEXT_METADATA_OK")); + + let attribution = session + .rpc() + .metadata() + .get_context_attribution() + .await + .expect("get context attribution"); + assert!(attribution.context_attribution.is_some()); + let heaviest = session + .rpc() + .metadata() + .get_context_heaviest_messages(MetadataContextHeaviestMessagesRequest { + limit: Some(5), + }) + .await + .expect("get heaviest messages"); + assert!(heaviest.total_tokens >= 0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_update_and_clear_live_subagent_settings() { + with_e2e_context( + "rpc_session_state_extras", + "should_update_and_clear_live_subagent_settings", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + session + .rpc() + .tools() + .update_subagent_settings(UpdateSubagentSettingsRequest { + subagents: Some(UpdateSubagentSettingsRequestSubagents { + agents: Some(HashMap::from([( + "general-purpose".to_string(), + SubagentSettingsEntry { + context_tier: Some( + SubagentSettingsEntryContextTier::LongContext, + ), + effort_level: Some("low".to_string()), + model: Some("gpt-5-mini".to_string()), + }, + )])), + disabled_subagents: Some(vec!["legacy-agent".to_string()]), + max_concurrency: None, + max_depth: None, + }), + }) + .await + .expect("update subagent settings"); + session + .rpc() + .tools() + .update_subagent_settings(UpdateSubagentSettingsRequest { subagents: None }) + .await + .expect("clear subagent settings"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_reload_session_plugins() { with_e2e_context( diff --git a/rust/tests/e2e/rpc_tasks_and_handlers.rs b/rust/tests/e2e/rpc_tasks_and_handlers.rs index 9226addc0c..601cc70bf2 100644 --- a/rust/tests/e2e/rpc_tasks_and_handlers.rs +++ b/rust/tests/e2e/rpc_tasks_and_handlers.rs @@ -1,5 +1,11 @@ +use std::collections::HashMap; + use github_copilot_sdk::rpc::{ - CommandsHandlePendingCommandRequest, HandlePendingToolCallRequest, PermissionDecision, + CommandsHandlePendingCommandRequest, HandlePendingToolCallRequest, + McpHeadersHandlePendingHeadersRefreshRequest, + McpHeadersHandlePendingHeadersRefreshRequestHeaders, + McpHeadersHandlePendingHeadersRefreshRequestHeadersKind, + McpHeadersHandlePendingHeadersRefreshRequestRequest, PermissionDecision, PermissionDecisionApproveForLocation, PermissionDecisionApproveForLocationApproval, PermissionDecisionApproveForLocationApprovalCustomTool, PermissionDecisionApproveForLocationApprovalCustomToolKind, @@ -16,8 +22,9 @@ use github_copilot_sdk::rpc::{ UIElicitationResponse, UIElicitationResponseAction, UIExitPlanModeResponse, UIHandlePendingAutoModeSwitchRequest, UIHandlePendingElicitationRequest, UIHandlePendingExitPlanModeRequest, UIHandlePendingSamplingRequest, - UIHandlePendingUserInputRequest, UIUnregisterDirectAutoModeSwitchHandlerRequest, - UIUserInputResponse, + UIHandlePendingSessionLimitsExhaustedRequest, UIHandlePendingUserInputRequest, + UISessionLimitsExhaustedResponse, UISessionLimitsExhaustedResponseAction, + UIUnregisterDirectAutoModeSwitchHandlerRequest, UIUserInputResponse, }; use super::support::with_e2e_context; @@ -323,6 +330,23 @@ async fn should_return_expected_results_for_missing_pending_handler_requestids() .expect("handle missing exit plan"); assert!(!exit_plan.success); + let session_limits = session + .rpc() + .ui() + .handle_pending_session_limits_exhausted( + UIHandlePendingSessionLimitsExhaustedRequest { + request_id: "missing-session-limits-request".into(), + response: UISessionLimitsExhaustedResponse { + action: UISessionLimitsExhaustedResponseAction::Unset, + additional_ai_credits: None, + max_ai_credits: None, + }, + }, + ) + .await + .expect("handle missing session limits exhausted"); + assert!(!session_limits.success); + for (request_id, result) in [ ( "missing-permission-request", @@ -385,6 +409,28 @@ async fn should_return_expected_results_for_missing_pending_handler_requestids() assert!(!permission.success, "{request_id} should not be handled"); } + let headers_refresh = session + .rpc() + .mcp() + .headers() + .handle_pending_headers_refresh_request( + McpHeadersHandlePendingHeadersRefreshRequestRequest { + request_id: "missing-headers-refresh-request".into(), + result: McpHeadersHandlePendingHeadersRefreshRequest::Headers( + McpHeadersHandlePendingHeadersRefreshRequestHeaders { + headers: HashMap::from([( + "x-refresh".to_string(), + "missing".to_string(), + )]), + kind: McpHeadersHandlePendingHeadersRefreshRequestHeadersKind::Headers, + }, + ), + }, + ) + .await + .expect("handle missing headers refresh"); + assert!(!headers_refresh.success); + session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); }) diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index b06809c183..c9a5f8237d 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -1431,6 +1431,7 @@ let nonExperimentalRpcTypes = new Set(); let rpcKnownTypes = new Map(); let rpcEnumOutput: string[] = []; let externalRpcValueTypes = new Set(); +let rpcRootJsonSerializableTypes = new Set(); /** Schema definitions available during RPC generation (for $ref resolution). */ let rpcDefinitions: DefinitionCollections = { definitions: {}, $defs: {} }; @@ -1703,7 +1704,11 @@ function emitRpcResultType(typeName: string, schema: JSONSchema7, visibility: "p return typeName; } - return resolveRpcType(schema, true, typeName, "", classes); + const resultType = resolveRpcType(schema, true, typeName, "", classes); + if (resultType.includes("<") || resultType.endsWith("[]")) { + rpcRootJsonSerializableTypes.add(resultType.replace(/\?$/, "")); + } + return resultType; } /** @@ -2446,6 +2451,7 @@ function generateRpcCode( nonExperimentalRpcTypes.clear(); rpcKnownTypes.clear(); rpcEnumOutput = []; + rpcRootJsonSerializableTypes.clear(); generatedEnums.clear(); // Clear shared enum deduplication map externalRpcValueTypes = new Set([...externalValueTypes].map(typeToClassName)); rpcDefinitions = collectDefinitionCollections(schema as Record); @@ -2513,7 +2519,9 @@ namespace GitHub.Copilot.Rpc; if (clientGlobalParts.length > 0) lines.push(...clientGlobalParts, ""); // Add JsonSerializerContext for AOT/trimming support - const typeNames = [...emittedRpcClassSchemas.keys(), ...emittedRpcEnumResultTypes].sort(); + const typeNames = [ + ...new Set([...emittedRpcClassSchemas.keys(), ...emittedRpcEnumResultTypes, ...rpcRootJsonSerializableTypes]), + ].sort(); if (typeNames.length > 0) { lines.push(`[JsonSourceGenerationOptions(`); lines.push(` JsonSerializerDefaults.Web,`); diff --git a/test/snapshots/rpc_server_misc/should_get_set_and_clear_user_settings.yaml b/test/snapshots/rpc_server_misc/should_get_set_and_clear_user_settings.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_get_set_and_clear_user_settings.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_misc/should_login_list_getcurrentauth_and_logout_account.yaml b/test/snapshots/rpc_server_misc/should_login_list_getcurrentauth_and_logout_account.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_login_list_getcurrentauth_and_logout_account.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_add_byok_provider_and_model_at_runtime.yaml b/test/snapshots/rpc_session_state_extras/should_add_byok_provider_and_model_at_runtime.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_add_byok_provider_and_model_at_runtime.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_get_context_attribution_and_heaviest_messages_after_turn.yaml b/test/snapshots/rpc_session_state_extras/should_get_context_attribution_and_heaviest_messages_after_turn.yaml new file mode 100644 index 0000000000..c4798dc83d --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_get_context_attribution_and_heaviest_messages_after_turn.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Say CONTEXT_METADATA_OK exactly. + - role: assistant + content: CONTEXT_METADATA_OK diff --git a/test/snapshots/rpc_session_state_extras/should_report_visibility_as_unsynced_for_local_session.yaml b/test/snapshots/rpc_session_state_extras/should_report_visibility_as_unsynced_for_local_session.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_report_visibility_as_unsynced_for_local_session.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_return_empty_completions_when_host_does_not_provide_them.yaml b/test/snapshots/rpc_session_state_extras/should_return_empty_completions_when_host_does_not_provide_them.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_return_empty_completions_when_host_does_not_provide_them.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_update_and_clear_live_subagent_settings.yaml b/test/snapshots/rpc_session_state_extras/should_update_and_clear_live_subagent_settings.yaml new file mode 100644 index 0000000000..056351ddb4 --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_update_and_clear_live_subagent_settings.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: []