diff --git a/dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs b/dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs new file mode 100644 index 000000000..b87f7a478 --- /dev/null +++ b/dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E coverage for the session-scoped MCP lifecycle RPC methods that were previously untested: +/// listTools, isServerRunning, stopServer, startServer, restartServer, registerExternalClient, +/// unregisterExternalClient, reloadWithConfig, configureGitHub, and oauth.respond. +/// +public class RpcMcpLifecycleE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_mcp_lifecycle", output) +{ + [Fact] + public async Task Should_List_Tools_And_Report_Running_Status_For_Connected_Server() + { + const string serverName = "rpc-lifecycle-list-server"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(serverName), + }); + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + + var tools = await session.Rpc.Mcp.ListToolsAsync(serverName); + Assert.NotNull(tools.Tools); + Assert.NotEmpty(tools.Tools); + Assert.All(tools.Tools, tool => Assert.False(string.IsNullOrWhiteSpace(tool.Name))); + + // A connected server reports running; a name that was never configured does not. + Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running); + Assert.False((await session.Rpc.Mcp.IsServerRunningAsync($"missing-{Guid.NewGuid():N}")).Running); + } + + [Fact] + public async Task Should_Throw_When_Listing_Tools_For_Unconnected_Server() + { + const string serverName = "rpc-lifecycle-unconnected-host"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(serverName), + }); + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + + // The MCP host is initialized (a server is connected), but the requested server is not, + // so listTools reaches the runtime and fails with a domain error rather than "Unhandled method". + var ex = await Assert.ThrowsAnyAsync( + () => session.Rpc.Mcp.ListToolsAsync($"missing-{Guid.NewGuid():N}")); + var message = ex.ToString(); + AssertNotUnhandledMethod(message); + Assert.Contains("not connected", message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Should_Stop_Running_Mcp_Server() + { + const string serverName = "rpc-lifecycle-stop-server"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(serverName), + }); + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running); + + await session.Rpc.Mcp.StopServerAsync(serverName); + + await WaitForMcpRunningAsync(session, serverName, expectedRunning: false); + } + + [Fact] + public async Task Should_Start_And_Restart_Mcp_Server() + { + const string hostServer = "rpc-lifecycle-host-server"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(hostServer), + }); + await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected); + + // Start a brand-new server through the lifecycle API, reusing the exact stdio config shape + // the bulk session-config path uses so the runtime accepts and connects it. + const string startedServer = "rpc-lifecycle-started-server"; + var config = CreateTestMcpServers(startedServer)[startedServer]; + + await session.Rpc.Mcp.StartServerAsync(startedServer, config); + await WaitForMcpRunningAsync(session, startedServer, expectedRunning: true); + + // The freshly started server exposes its tools just like a config-provided server. + var tools = await session.Rpc.Mcp.ListToolsAsync(startedServer); + Assert.NotEmpty(tools.Tools); + + // Restart stops then starts the same server; it must end up running again. + await session.Rpc.Mcp.RestartServerAsync(startedServer, config); + await WaitForMcpRunningAsync(session, startedServer, expectedRunning: true); + } + + [Fact] + public async Task Should_Register_And_Unregister_External_Mcp_Client() + { + const string hostServer = "rpc-lifecycle-extclient-host"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(hostServer), + }); + await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected); + + const string externalName = "rpc-lifecycle-external-client"; + Assert.False((await session.Rpc.Mcp.IsServerRunningAsync(externalName)).Running); + + // The runtime stores the supplied client/transport handles on the host registry, so the + // registered name immediately reports as running until it is unregistered again. + await session.Rpc.Mcp.RegisterExternalClientAsync( + externalName, + client: new Dictionary { ["id"] = externalName }, + transport: new Dictionary { ["kind"] = "in-process" }, + config: new Dictionary { ["command"] = "noop" }); + Assert.True((await session.Rpc.Mcp.IsServerRunningAsync(externalName)).Running); + + await session.Rpc.Mcp.UnregisterExternalClientAsync(externalName); + Assert.False((await session.Rpc.Mcp.IsServerRunningAsync(externalName)).Running); + } + + [Fact] + public async Task Should_Reload_Mcp_Servers_With_Config() + { + const string hostServer = "rpc-lifecycle-reload-host"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(hostServer), + }); + await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected); + + // reloadWithConfig drives the runtime's reloadMcpServers with an explicit host config and + // returns the startup filtering result. Reloading an empty server set is a valid no-op. + var result = await session.Rpc.Mcp.ReloadWithConfigAsync(new Dictionary + { + ["mcpServers"] = new Dictionary(), + ["disabledServers"] = new List(), + }); + + Assert.NotNull(result); + Assert.NotNull(result.FilteredServers); + Assert.Empty(result.FilteredServers); + } + + [Fact] + public async Task Should_Configure_GitHub_Mcp_Server() + { + const string hostServer = "rpc-lifecycle-configure-host"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(hostServer), + }); + await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected); + + // configureGitHub forwards a typed auth-info union to the runtime. An "api-key" auth info is + // a recognized type that the runtime declines to act on, so configuration is left unchanged + // (changed=false) while still proving the method is wired through to the handler. + var result = await session.Rpc.Mcp.ConfigureGitHubAsync(new Dictionary + { + ["type"] = "api-key", + }); + + Assert.NotNull(result); + Assert.False(result.Changed); + } + + [Fact] + public async Task Should_Respond_To_Mcp_Oauth_Request_Without_Pending_Request() + { + const string hostServer = "rpc-lifecycle-oauth-host"; + await using var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(hostServer), + }); + await WaitForMcpServerStatusAsync(session, hostServer, McpServerStatus.Connected); + + // With no pending OAuth request, the runtime's respondToMcpOAuth is a tolerant no-op: it + // looks up the request id, finds nothing, and returns an empty result without throwing. The + // call must reach the runtime and complete successfully, proving the method is wired. + var result = await session.Rpc.Mcp.Oauth.RespondAsync($"missing-{Guid.NewGuid():N}"); + Assert.NotNull(result); + } + + private static Task WaitForMcpRunningAsync(CopilotSession session, string serverName, bool expectedRunning) => + Harness.TestHelper.WaitForConditionAsync( + async () => (await session.Rpc.Mcp.IsServerRunningAsync(serverName)).Running == expectedRunning, + timeout: TimeSpan.FromSeconds(60), + pollInterval: TimeSpan.FromMilliseconds(200), + timeoutMessage: $"{serverName} running={expectedRunning}"); + + private static void AssertNotUnhandledMethod(string message) + { + Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/test/E2E/RpcServerMiscE2ETests.cs b/dotnet/test/E2E/RpcServerMiscE2ETests.cs new file mode 100644 index 000000000..6d04a35f8 --- /dev/null +++ b/dotnet/test/E2E/RpcServerMiscE2ETests.cs @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +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 +/// session-scoped session.extensions.sendAttachmentsToMessage. +/// +/// Several of these are intentionally exercised at the wiring/guard boundary because the meaningful +/// "happy path" requires capabilities the SDK host does not expose (a registered agent-registry +/// delegate, an extension-owned connection). For those we assert the method reaches the runtime and +/// enforces its documented guard rather than failing as an unknown method. +/// +public class RpcServerMiscE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_server_misc", output) +{ + [Fact] + public async Task Should_Reload_User_Settings() + { + await Client.StartAsync(); + + // Drops the runtime's in-memory user-settings cache so the next read observes disk. Returns + // no value; success is simply completing without error. + await Client.Rpc.User.Settings.ReloadAsync(); + } + + [Fact] + public async Task Should_Report_Agent_Registry_Spawn_Gate_Closed() + { + await Client.StartAsync(); + + // agentRegistry.spawn is gated off on the SDK host (no spawn delegate is registered). The + // call must still reach the runtime and be rejected by that gate, proving the method is + // wired rather than an unknown method. + var ex = await Assert.ThrowsAnyAsync( + () => Client.Rpc.AgentRegistry.SpawnAsync(cwd: Path.GetTempPath())); + + var message = ex.ToString(); + Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("agentRegistry.spawn", message, StringComparison.OrdinalIgnoreCase); + Assert.True( + message.Contains("not enabled", StringComparison.OrdinalIgnoreCase) + || message.Contains("no delegate", StringComparison.OrdinalIgnoreCase), + message); + } + + [Fact] + public async Task Should_Shut_Down_Owned_Runtime() + { + // runtime.shutdown must only ever target a dedicated, SDK-owned runtime — never the shared + // fixture client whose process backs every other test. + var client = Ctx.CreateClient(); + await client.StartAsync(); + + try + { + // Confirm the runtime is live before shutting it down. + await client.Rpc.User.Settings.ReloadAsync(); + + await client.Rpc.Runtime.ShutdownAsync(); + + // After a graceful shutdown the runtime tears down and stops serving. Poll until a + // follow-up RPC fails rather than asserting on a single immediate call, which could race + // shutdown propagation across the connection. + await Harness.TestHelper.WaitForConditionAsync( + async () => + { + try { await client.Rpc.User.Settings.ReloadAsync(); return false; } + catch { return true; } + }, + timeout: TimeSpan.FromSeconds(15), + pollInterval: TimeSpan.FromMilliseconds(100), + timeoutMessage: "Runtime kept serving RPCs after a graceful shutdown."); + } + finally + { + try { await client.DisposeAsync(); } + catch { /* process is already gone after shutdown */ } + } + } + + [Fact] + public async Task Should_Report_Not_Found_When_Opening_Session_Without_Context() + { + // sessions.open with no parameters asks the runtime to resume the last session for the + // (unspecified) context. A fresh runtime with its own empty COPILOT_HOME has no such + // session, so the documented "not_found" outcome is returned deterministically. + var (client, home) = await CreateIsolatedClientAsync(); + try + { + var result = await client.Rpc.Sessions.OpenAsync(); + + Assert.Equal(SessionsOpenStatus.NotFound, result.Status); + Assert.Null(result.SessionId); + } + finally + { + try { await client.DisposeAsync(); } catch { /* best-effort */ } + TryDeleteDirectory(home); + } + } + + [Fact] + public async Task Should_Reject_Send_Attachments_From_Non_Extension_Connection() + { + // session.extensions.sendAttachmentsToMessage may only be called over an extension-owned + // connection. A normal SDK session connection has no extensionId, so the runtime rejects the + // push — confirming the method is wired and enforces its ownership guard. + await using var session = await CreateSessionAsync(); + + var ex = await Assert.ThrowsAnyAsync( + () => session.Rpc.Extensions.SendAttachmentsToMessageAsync(new List())); + var message = ex.ToString(); + Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("extension", message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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() + { + var home = Path.Combine(Path.GetTempPath(), "copilot-e2e-misc-home-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(home); + + var env = Ctx.GetEnvironment(); + env["COPILOT_HOME"] = home; + env["GH_CONFIG_DIR"] = home; + env["XDG_CONFIG_HOME"] = home; + env["XDG_STATE_HOME"] = home; + + var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + await client.StartAsync(); + return (client, home); + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Temp directories are reclaimed by the OS; ignore transient locks on cleanup. + } + } +} diff --git a/dotnet/test/E2E/RpcServerPluginsE2ETests.cs b/dotnet/test/E2E/RpcServerPluginsE2ETests.cs new file mode 100644 index 000000000..58d2cb525 --- /dev/null +++ b/dotnet/test/E2E/RpcServerPluginsE2ETests.cs @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E coverage for the server-scoped plugin and marketplace RPC methods that were previously +/// untested: plugins.install/list/uninstall/update/updateAll/enable/disable, +/// plugins.marketplaces.add/list/browse/refresh/remove, and mcp.config.reload. +/// +/// All fixtures are self-contained local directories so the tests run fully offline (the E2E +/// proxy blocks github.com). A local marketplace directory with the plugin nested inside it +/// (a "monorepo" marketplace) lets the runtime install a real marketplace-scoped plugin without +/// any network access, which in turn makes enable/disable/update meaningful rather than no-ops. +/// Each test runs against its own client with a fresh COPILOT_HOME so installed-plugin and +/// marketplace state never leaks between tests. +/// +public class RpcServerPluginsE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_server_plugins", output) +{ + private const string MarketplaceName = "csharp-e2e-marketplace"; + private const string PluginName = "csharp-e2e-plugin"; + private const string DirectPluginName = "csharp-e2e-direct"; + + [Fact] + public async Task Should_Install_List_And_Uninstall_Plugin_From_Local_Marketplace() + { + var marketplaceDir = CreateLocalMarketplaceFixture(); + var (client, home) = await CreateIsolatedClientAsync(); + try + { + await client.Rpc.Plugins.Marketplaces.AddAsync(marketplaceDir); + + var spec = $"{PluginName}@{MarketplaceName}"; + var install = await client.Rpc.Plugins.InstallAsync(spec); + + Assert.Equal(PluginName, install.Plugin.Name); + Assert.Equal(MarketplaceName, install.Plugin.Marketplace); + Assert.True(install.Plugin.Enabled); + Assert.True(install.SkillsInstalled >= 1, $"expected at least one skill, got {install.SkillsInstalled}"); + // Marketplace installs are the supported path and must NOT carry the deprecation warning. + Assert.Null(install.DeprecationWarning); + + var afterInstall = await client.Rpc.Plugins.ListAsync(); + var listed = Assert.Single( + afterInstall.Plugins, + p => p.Name == PluginName && p.Marketplace == MarketplaceName); + Assert.True(listed.Enabled); + + await client.Rpc.Plugins.UninstallAsync(spec); + + var afterUninstall = await client.Rpc.Plugins.ListAsync(); + Assert.DoesNotContain(afterUninstall.Plugins, p => p.Name == PluginName && p.Marketplace == MarketplaceName); + } + finally + { + await DisposeIsolatedAsync(client, home, marketplaceDir); + } + } + + [Fact] + public async Task Should_Enable_And_Disable_Marketplace_Plugin() + { + var marketplaceDir = CreateLocalMarketplaceFixture(); + var (client, home) = await CreateIsolatedClientAsync(); + try + { + var spec = $"{PluginName}@{MarketplaceName}"; + await client.Rpc.Plugins.Marketplaces.AddAsync(marketplaceDir); + await client.Rpc.Plugins.InstallAsync(spec); + + await client.Rpc.Plugins.DisableAsync([spec]); + Assert.False(GetPlugin(await client.Rpc.Plugins.ListAsync()).Enabled); + + await client.Rpc.Plugins.EnableAsync([spec]); + Assert.True(GetPlugin(await client.Rpc.Plugins.ListAsync()).Enabled); + } + finally + { + await DisposeIsolatedAsync(client, home, marketplaceDir); + } + + static InstalledPluginInfo GetPlugin(PluginListResult list) => + Assert.Single(list.Plugins, p => p.Name == PluginName && p.Marketplace == MarketplaceName); + } + + [Fact] + public async Task Should_Update_Single_Marketplace_Plugin() + { + var marketplaceDir = CreateLocalMarketplaceFixture(); + var (client, home) = await CreateIsolatedClientAsync(); + try + { + var spec = $"{PluginName}@{MarketplaceName}"; + await client.Rpc.Plugins.Marketplaces.AddAsync(marketplaceDir); + await client.Rpc.Plugins.InstallAsync(spec); + + // Re-installs from the (local) marketplace catalog and re-counts skills. + var update = await client.Rpc.Plugins.UpdateAsync(spec); + + Assert.True(update.SkillsInstalled >= 1, $"expected at least one skill, got {update.SkillsInstalled}"); + Assert.Equal("1.0.0", update.PreviousVersion); + Assert.Equal("1.0.0", update.NewVersion); + } + finally + { + await DisposeIsolatedAsync(client, home, marketplaceDir); + } + } + + [Fact] + public async Task Should_Update_All_Installed_Plugins() + { + var marketplaceDir = CreateLocalMarketplaceFixture(); + var (client, home) = await CreateIsolatedClientAsync(); + try + { + var spec = $"{PluginName}@{MarketplaceName}"; + await client.Rpc.Plugins.Marketplaces.AddAsync(marketplaceDir); + await client.Rpc.Plugins.InstallAsync(spec); + + var result = await client.Rpc.Plugins.UpdateAllAsync(); + + var entry = Assert.Single( + result.Results, + r => r.Name == PluginName && r.Marketplace == MarketplaceName); + Assert.True(entry.Success, entry.Error); + Assert.True(entry.SkillsInstalled >= 1); + } + finally + { + await DisposeIsolatedAsync(client, home, marketplaceDir); + } + } + + [Fact] + public async Task Should_Install_Direct_Local_Plugin_With_Deprecation_Warning() + { + var pluginDir = CreateDirectPluginFixture(); + var (client, home) = await CreateIsolatedClientAsync(); + try + { + var install = await client.Rpc.Plugins.InstallAsync(pluginDir); + + Assert.Equal(DirectPluginName, install.Plugin.Name); + // Direct (local path) installs have no originating marketplace and are deprecated. + Assert.Equal(string.Empty, install.Plugin.Marketplace); + Assert.NotNull(install.DeprecationWarning); + Assert.Contains("deprecated", install.DeprecationWarning, StringComparison.OrdinalIgnoreCase); + Assert.True(install.SkillsInstalled >= 1, $"expected at least one skill, got {install.SkillsInstalled}"); + + var afterInstall = await client.Rpc.Plugins.ListAsync(); + Assert.Single(afterInstall.Plugins, p => p.Name == DirectPluginName); + + await client.Rpc.Plugins.UninstallAsync(DirectPluginName); + + var afterUninstall = await client.Rpc.Plugins.ListAsync(); + Assert.DoesNotContain(afterUninstall.Plugins, p => p.Name == DirectPluginName); + } + finally + { + await DisposeIsolatedAsync(client, home, pluginDir); + } + } + + [Fact] + public async Task Should_List_Browse_Refresh_And_Remove_Local_Marketplace() + { + var marketplaceDir = CreateLocalMarketplaceFixture(); + var (client, home) = await CreateIsolatedClientAsync(); + try + { + var add = await client.Rpc.Plugins.Marketplaces.AddAsync(marketplaceDir); + Assert.Equal(MarketplaceName, add.Name); + + var list = await client.Rpc.Plugins.Marketplaces.ListAsync(); + var mine = Assert.Single(list.Marketplaces, m => m.Name == MarketplaceName); + Assert.NotEqual(true, mine.IsDefault); + // The runtime always ships built-in default marketplaces alongside user-added ones. + Assert.Contains(list.Marketplaces, m => m.IsDefault == true); + + var browse = await client.Rpc.Plugins.Marketplaces.BrowseAsync(MarketplaceName); + var advertised = Assert.Single(browse.Plugins, p => p.Name == PluginName); + Assert.False(string.IsNullOrEmpty(advertised.Description)); + + var refresh = await client.Rpc.Plugins.Marketplaces.RefreshAsync(MarketplaceName); + var refreshed = Assert.Single(refresh.Results, r => r.Name == MarketplaceName); + Assert.True(refreshed.Success, refreshed.Error); + + var remove = await client.Rpc.Plugins.Marketplaces.RemoveAsync(MarketplaceName); + Assert.True(remove.Removed); + + var afterRemove = await client.Rpc.Plugins.Marketplaces.ListAsync(); + Assert.DoesNotContain(afterRemove.Marketplaces, m => m.Name == MarketplaceName); + } + finally + { + await DisposeIsolatedAsync(client, home, marketplaceDir); + } + } + + [Fact] + public async Task Should_Reload_Mcp_Config_Cache() + { + var (client, home) = await CreateIsolatedClientAsync(); + try + { + // Drops the runtime's in-memory MCP server-definition cache; succeeds with no return value. + await client.Rpc.Mcp.Config.ReloadAsync(); + } + finally + { + await DisposeIsolatedAsync(client, home, null); + } + } + + /// + /// Creates a self-contained local marketplace directory: a marketplace.json catalog plus the + /// plugin it advertises nested inside as a subdirectory. The plugin's catalog source is a + /// relative path, so the runtime resolves and installs it purely from the local filesystem. + /// + private static string CreateLocalMarketplaceFixture() + { + var dir = Path.Combine(Path.GetTempPath(), "copilot-e2e-mp-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + + var manifest = $$""" + { + "name": "{{MarketplaceName}}", + "owner": { "name": "Copilot SDK E2E" }, + "metadata": { "description": "Local marketplace fixture for SDK E2E tests." }, + "plugins": [ + { + "name": "{{PluginName}}", + "source": "./{{PluginName}}", + "description": "E2E demo plugin advertised by the local marketplace.", + "version": "1.0.0" + } + ] + } + """; + File.WriteAllText(Path.Combine(dir, "marketplace.json"), manifest); + + var pluginDir = Path.Combine(dir, PluginName); + Directory.CreateDirectory(pluginDir); + WriteSkillFile(pluginDir); + + return dir; + } + + /// + /// Creates a directory installable as a direct (deprecated) local plugin: a minimal plugin.json + /// manifest plus a single skill. + /// + private static string CreateDirectPluginFixture() + { + var dir = Path.Combine(Path.GetTempPath(), "copilot-e2e-plugin-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + + var manifest = $$""" + { + "name": "{{DirectPluginName}}", + "description": "E2E demo plugin installed directly from a local path.", + "version": "1.0.0" + } + """; + File.WriteAllText(Path.Combine(dir, "plugin.json"), manifest); + WriteSkillFile(dir); + + return dir; + } + + private static void WriteSkillFile(string pluginDir) + { + const string skill = """ + --- + name: csharp-e2e-skill + description: A demo skill contributed by the E2E test plugin. + --- + # Demo Skill + + This skill exists so the plugin reports at least one installed skill. + """; + File.WriteAllText(Path.Combine(pluginDir, "SKILL.md"), skill); + } + + /// + /// Creates a started client backed by a throwaway COPILOT_HOME so plugin/marketplace state is + /// isolated from every other test and from the shared fixture client. + /// + private async Task<(CopilotClient Client, string Home)> CreateIsolatedClientAsync() + { + var home = Path.Combine(Path.GetTempPath(), "copilot-e2e-home-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(home); + + var env = Ctx.GetEnvironment(); + env["COPILOT_HOME"] = home; + env["GH_CONFIG_DIR"] = home; + env["XDG_CONFIG_HOME"] = home; + env["XDG_STATE_HOME"] = home; + + var client = Ctx.CreateClient(options: new CopilotClientOptions { Environment = env }); + await client.StartAsync(); + return (client, home); + } + + private static async Task DisposeIsolatedAsync(CopilotClient client, string home, string? fixtureDir) + { + try { await client.DisposeAsync(); } + catch { /* best-effort */ } + + TryDeleteDirectory(home); + if (fixtureDir is not null) + { + TryDeleteDirectory(fixtureDir); + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // Temp directories are reclaimed by the OS; ignore transient locks on cleanup. + } + } +} diff --git a/dotnet/test/E2E/RpcServerRemoteControlE2ETests.cs b/dotnet/test/E2E/RpcServerRemoteControlE2ETests.cs new file mode 100644 index 000000000..5fb874a52 --- /dev/null +++ b/dotnet/test/E2E/RpcServerRemoteControlE2ETests.cs @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E coverage for the server-scoped remote-control RPC methods that were previously untested: +/// getRemoteControlStatus, setRemoteControlSteering, stopRemoteControl, transferRemoteControl, and +/// startRemoteControl. The remote-control singleton is per-runtime shared state, so every test uses +/// its own dedicated client process and leaves the singleton in the "off" state. +/// +public class RpcServerRemoteControlE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_server_remote_control", output) +{ + [Fact] + public async Task Should_Report_Remote_Control_Status_As_Off() + { + await using var client = Ctx.CreateClient(); + await client.StartAsync(); + + var result = await client.Rpc.Sessions.GetRemoteControlStatusAsync(); + + // A runtime that has never attached remote control reports the off singleton state. + Assert.IsType(result.Status); + Assert.Equal("off", result.Status.State); + } + + [Fact] + public async Task Should_Treat_Set_Steering_As_No_Op_When_Off() + { + await using var client = Ctx.CreateClient(); + await client.StartAsync(); + + // Steering only applies to an active singleton; with remote control off it is a no-op that + // returns the unchanged off status rather than failing. + var result = await client.Rpc.Sessions.SetRemoteControlSteeringAsync(false); + + Assert.IsType(result.Status); + } + + [Fact] + public async Task Should_Report_Not_Stopped_When_Remote_Control_Is_Off() + { + await using var client = Ctx.CreateClient(); + await client.StartAsync(); + + var result = await client.Rpc.Sessions.StopRemoteControlAsync(); + + // Nothing is attached, so there is nothing to tear down. + Assert.False(result.Stopped); + Assert.IsType(result.Status); + } + + [Fact] + public async Task Should_Reject_Transfer_When_Off_With_Compare_And_Swap() + { + await using var client = Ctx.CreateClient(); + await client.StartAsync(); + + // Compare-and-swap transfer is rejected because the singleton is off (it points at no + // session), so the expected-from guard can never match and nothing is rebound. + var result = await client.Rpc.Sessions.TransferRemoteControlAsync( + toSessionId: $"rc-to-{Guid.NewGuid():N}", + expectedFromSessionId: $"rc-from-{Guid.NewGuid():N}"); + + Assert.False(result.Transferred); + Assert.IsType(result.Status); + } + + [Fact] + public async Task Should_Reach_Runtime_When_Starting_Remote_Control_For_Unknown_Session() + { + await using var client = Ctx.CreateClient(); + await client.StartAsync(); + + try + { + // startRemoteControl attaches the singleton to a local session. A well-formed session id + // that the runtime does not know is rejected at the runtime (not as an unhandled method), + // proving the method is wired through without requiring a live Mission Control backend. + var ex = await Assert.ThrowsAnyAsync( + () => client.Rpc.Sessions.StartRemoteControlAsync( + $"missing-session-{Guid.NewGuid():N}", + new RemoteControlConfig { Remote = false, Explicit = false, Silent = true, Steerable = false })); + + var message = ex.ToString(); + Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase); + Assert.True( + message.Contains("session", StringComparison.OrdinalIgnoreCase) + || message.Contains("remote", StringComparison.OrdinalIgnoreCase), + message); + } + finally + { + // Force the singleton back to off regardless of how the start attempt resolved. + try { await client.Rpc.Sessions.StopRemoteControlAsync(force: true); } + catch { /* best-effort reset */ } + } + } +} diff --git a/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs b/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs new file mode 100644 index 000000000..5b1ce1484 --- /dev/null +++ b/dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +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. +/// +public class RpcSessionStateExtrasE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_session_state_extras", output) +{ + [Fact] + public async Task Should_List_Models_For_Session() + { + // model.list resolves models through the session's own auth context, which requires the + // GitHub token -> user resolution to be served by the proxy (a fresh shared client does not + // route token resolution there). Use a dedicated authenticated client like the server-scoped + // models.list coverage does. + const string token = "rpc-session-model-list-token"; + await ConfigureAuthenticatedUserAsync(token); + await using var client = CreateAuthenticatedClient(token); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + Model = "claude-sonnet-4.5", + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + var result = await session.Rpc.Model.ListAsync(); + + Assert.NotNull(result.List); + Assert.NotEmpty(result.List); + // The configured model must be present in the returned catalog. + Assert.Contains(result.List, model => model.GetRawText().Contains("claude-sonnet-4.5", StringComparison.Ordinal)); + } + + [Fact] + public async Task Should_Report_Session_Activity_When_Idle() + { + await using var session = await CreateSessionAsync(); + + var activity = await session.Rpc.Metadata.ActivityAsync(); + + // A freshly created session that has not been sent any work is idle: no active turns or + // tasks, and nothing to abort. + Assert.False(activity.HasActiveWork, "Expected a freshly created session to report no active work."); + Assert.False(activity.Abortable, "Expected a freshly created session to have nothing abortable."); + } + + [Fact] + public async Task Should_Get_And_Set_AllowAll_Permissions() + { + await using var session = await CreateSessionAsync(); + + try + { + var initial = await session.Rpc.Permissions.GetAllowAllAsync(); + Assert.False(initial.Enabled, "Allow-all should be disabled on a fresh session."); + + var enable = await session.Rpc.Permissions.SetAllowAllAsync(true); + Assert.True(enable.Success); + Assert.True(enable.Enabled); + Assert.True((await session.Rpc.Permissions.GetAllowAllAsync()).Enabled); + + var disable = await session.Rpc.Permissions.SetAllowAllAsync(false); + Assert.True(disable.Success); + Assert.False(disable.Enabled); + Assert.False((await session.Rpc.Permissions.GetAllowAllAsync()).Enabled); + } + finally + { + await session.Rpc.Permissions.SetAllowAllAsync(false); + } + } + + [Fact] + public async Task Should_Read_Empty_Sql_Todos_For_Fresh_Session() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Plan.ReadSqlTodosAsync(); + + // A fresh session has never written to its SQL todos table, so the query returns an empty + // (but non-null) row set rather than failing. + Assert.NotNull(result.Rows); + Assert.Empty(result.Rows); + } + + [Fact] + public async Task Should_Get_Telemetry_Engagement_Id() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Telemetry.GetEngagementIdAsync(); + + // The engagement id is optional (null until telemetry assigns one), but the call must + // round-trip without error and return a result object. + Assert.NotNull(result); + } + + [Fact] + public async Task Should_Get_Current_Tool_Metadata_After_Initialization() + { + await using var session = await CreateSessionAsync(); + + // getCurrentMetadata returns the tool snapshot captured for the most recent turn; it is null + // until the session has processed a turn. Drive one real turn so the runtime computes and + // records the current tool metadata. + var answer = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + Assert.NotNull(answer); + + var result = await session.Rpc.Tools.GetCurrentMetadataAsync(); + + Assert.NotNull(result.Tools); + Assert.NotEmpty(result.Tools!); + Assert.All(result.Tools!, tool => + { + Assert.False(string.IsNullOrWhiteSpace(tool.Name)); + Assert.NotNull(tool.Description); + }); + } + + [Fact] + public async Task Should_Reload_Session_Plugins() + { + await using var session = await CreateSessionAsync(); + + // Reloading refreshes the session's plugin set; with no plugins configured it is a no-op + // that must still complete successfully and leave the plugin list queryable. + await session.Rpc.Plugins.ReloadAsync(); + + var plugins = await session.Rpc.Plugins.ListAsync(); + Assert.NotNull(plugins.Plugins); + Assert.All(plugins.Plugins, plugin => Assert.False(string.IsNullOrWhiteSpace(plugin.Name))); + } + + private CopilotClient CreateAuthenticatedClient(string token) + { + var env = new Dictionary(Ctx.GetEnvironment()) + { + ["COPILOT_DEBUG_GITHUB_API_URL"] = Ctx.ProxyUrl, + }; + + return Ctx.CreateClient(options: new CopilotClientOptions + { + Environment = env, + GitHubToken = token, + }); + } + + private async Task ConfigureAuthenticatedUserAsync(string token) + { + await Ctx.SetCopilotUserByTokenAsync(token, new CopilotUserConfig( + Login: "rpc-session-extras-user", + CopilotPlan: "individual_pro", + Endpoints: new CopilotUserEndpoints(Api: Ctx.ProxyUrl, Telemetry: "https://localhost:1/telemetry"), + AnalyticsTrackingId: "rpc-session-extras-tracking-id")); + } +} diff --git a/dotnet/test/E2E/RpcShellUserRequestedE2ETests.cs b/dotnet/test/E2E/RpcShellUserRequestedE2ETests.cs new file mode 100644 index 000000000..c51b385d8 --- /dev/null +++ b/dotnet/test/E2E/RpcShellUserRequestedE2ETests.cs @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E coverage for the session-scoped user-requested shell RPC methods that were previously +/// untested: shell.executeUserRequested and shell.cancelUserRequested. +/// +public class RpcShellUserRequestedE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_shell_user_requested", output) +{ + [Fact] + public async Task Should_Execute_User_Requested_Shell_Command() + { + await using var session = await CreateSessionAsync(); + var marker = $"copilotusershell{Guid.NewGuid():N}"; + var requestId = $"req-{Guid.NewGuid():N}"; + + var result = await session.Rpc.Shell.ExecuteUserRequestedAsync(requestId, $"echo {marker}"); + + Assert.True(result.Success, $"Expected the shell command to succeed. Error: {result.Error}"); + Assert.True(result.ExitCode == 0, $"Expected exit code 0 but got {result.ExitCode}."); + Assert.Contains(marker, result.Output, StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(result.ToolCallId)); + } + + [Fact] + public async Task Should_Cancel_User_Requested_Shell_Command() + { + await using var session = await CreateSessionAsync(); + + // Cancelling an unknown request id is a clean negative: nothing is in flight to cancel. + var missing = await session.Rpc.Shell.CancelUserRequestedAsync($"missing-{Guid.NewGuid():N}"); + Assert.False(missing.Cancelled); + + // De-race an in-flight cancellation: launch a long command that first writes a marker file + // (so we know it is genuinely running) and then sleeps. Keep the marker outside the fixture + // workspace so Windows cleanup is not blocked by lingering process handles. + var requestId = $"req-{Guid.NewGuid():N}"; + var markerPath = Path.Join(Path.GetTempPath(), $"shell-cancel-{Guid.NewGuid():N}.txt"); + var executeTask = session.Rpc.Shell.ExecuteUserRequestedAsync( + requestId, + CreateMarkerThenSleepCommand(markerPath, seconds: 60)); + + try + { + await WaitForFileExistsAsync(markerPath); + + // The marker proves the child process reached the command body, but the runtime may not + // yet have registered the request in its cancellable in-flight map. Poll the cancel until + // it takes effect so the assertion is not racy. WaitForConditionAsync stops on the first + // call that reports Cancelled, so the command is cancelled exactly once. + await TestHelper.WaitForConditionAsync( + async () => (await session.Rpc.Shell.CancelUserRequestedAsync(requestId)).Cancelled, + timeout: TimeSpan.FromSeconds(15), + pollInterval: TimeSpan.FromMilliseconds(100), + timeoutMessage: "Timed out waiting for the user-requested shell command to become cancellable."); + + // The aborted execution returns a non-success result rather than hanging. + var result = await executeTask.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.False(result.Success); + } + finally + { + if (!executeTask.IsCompleted) + { + try { await executeTask.WaitAsync(TimeSpan.FromSeconds(30)); } + catch { /* best-effort drain so the long command does not outlive the test */ } + } + + TryDeleteFile(markerPath); + } + } + + private static string CreateMarkerThenSleepCommand(string markerPath, int seconds) + { + // The runtime already runs the command through the platform shell (pwsh -Command "" on + // Windows, sh -c "" elsewhere), so emit the script body directly instead of spawning a + // *second* nested shell. Cancellation kills only the shell the runtime spawned; a nested + // powershell.exe/sh would be orphaned and keep the session working directory locked, which + // breaks fixture cleanup on Windows (manifesting as an IOException during teardown). + if (OperatingSystem.IsWindows()) + { + return $"Set-Content -LiteralPath '{markerPath}' -Value 'running'; Start-Sleep -Seconds {seconds}"; + } + + return $"echo running > '{markerPath}'; sleep {seconds}"; + } + + private static async Task WaitForFileExistsAsync(string path) + { + await TestHelper.WaitForConditionAsync( + () => File.Exists(path), + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: $"Timed out waiting for the shell command to create '{path}'.", + pollInterval: TimeSpan.FromMilliseconds(100)); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (TestHelper.IsTransientFileSystemException(ex)) + { + // Best-effort cleanup; the OS temp directory is reclaimed independently. + } + } +} diff --git a/dotnet/test/E2E/RpcUiEphemeralQueryE2ETests.cs b/dotnet/test/E2E/RpcUiEphemeralQueryE2ETests.cs new file mode 100644 index 000000000..76043fd73 --- /dev/null +++ b/dotnet/test/E2E/RpcUiEphemeralQueryE2ETests.cs @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot; +using GitHub.Copilot.Rpc; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +/// +/// E2E coverage for the session-scoped session.ui.ephemeralQuery RPC method. Unlike the +/// other newly covered methods this one is model-backed: the runtime runs a transient, no-tools +/// model completion against the current conversation context and returns the assistant's answer +/// without recording it in the conversation history. The exchange is served from a recorded +/// snapshot so the assertion on the answer text is deterministic. +/// +public class RpcUiEphemeralQueryE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_ui_ephemeral_query", output) +{ + [Fact] + public async Task Should_Answer_Ephemeral_Query() + { + await using var session = await CreateSessionAsync(); + + // A fresh session has no prior turns, so the ephemeral query is sent to the model as a + // single user message with the runtime's transient "quick side question" system prompt. + // The recorded snapshot supplies a canned answer, letting us assert a meaningful value. + var result = await session.Rpc.Ui.EphemeralQueryAsync( + "In one word, what is the primary color of a clear daytime sky?"); + + Assert.NotNull(result); + Assert.False(string.IsNullOrWhiteSpace(result.Answer)); + Assert.Contains("blue", result.Answer, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/go/internal/e2e/rpc_mcp_lifecycle_e2e_test.go b/go/internal/e2e/rpc_mcp_lifecycle_e2e_test.go new file mode 100644 index 000000000..36bd5f5c3 --- /dev/null +++ b/go/internal/e2e/rpc_mcp_lifecycle_e2e_test.go @@ -0,0 +1,230 @@ +package e2e + +import ( + "strings" + "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" +) + +func TestRpcMcpLifecycle(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should_list_tools_and_report_running_status_for_connected_server", func(t *testing.T) { + ctx.ConfigureForTest(t) + const serverName = "rpc-lifecycle-list-server" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, serverName)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) + + tools, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: serverName}) + if err != nil { + t.Fatalf("MCP.ListTools failed: %v", err) + } + if len(tools.Tools) == 0 { + t.Fatal("Expected connected MCP server to expose at least one tool") + } + for _, tool := range tools.Tools { + if strings.TrimSpace(tool.Name) == "" { + t.Fatalf("Expected non-empty MCP tool name, got %+v", tool) + } + } + + running, err := session.RPC.MCP.IsServerRunning(t.Context(), &rpc.MCPIsServerRunningRequest{ServerName: serverName}) + if err != nil { + t.Fatalf("MCP.IsServerRunning(%s) failed: %v", serverName, err) + } + if !running.Running { + t.Fatalf("Expected %s to be running", serverName) + } + missing, err := session.RPC.MCP.IsServerRunning(t.Context(), &rpc.MCPIsServerRunningRequest{ServerName: "missing-" + randomHex(t)}) + if err != nil { + t.Fatalf("MCP.IsServerRunning(missing) failed: %v", err) + } + if missing.Running { + t.Fatal("Expected missing MCP server not to be running") + } + }) + + t.Run("should_throw_when_listing_tools_for_unconnected_server", func(t *testing.T) { + ctx.ConfigureForTest(t) + const serverName = "rpc-lifecycle-unconnected-host" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, serverName)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) + + _, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: "missing-" + randomHex(t)}) + if err == nil { + t.Fatal("Expected MCP.ListTools for an unconnected server to fail") + } + message := err.Error() + assertPortedNoUnhandledMethod(t, message) + assertPortedContainsFold(t, message, "not connected") + }) + + t.Run("should_stop_running_mcp_server", func(t *testing.T) { + ctx.ConfigureForTest(t) + const serverName = "rpc-lifecycle-stop-server" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, serverName)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, serverName, rpc.MCPServerStatusConnected) + waitForPortedMCPRunning(t, session, serverName, true) + + if _, err := session.RPC.MCP.StopServer(t.Context(), &rpc.MCPStopServerRequest{ServerName: serverName}); err != nil { + t.Fatalf("MCP.StopServer failed: %v", err) + } + waitForPortedMCPRunning(t, session, serverName, false) + }) + + t.Run("should_start_and_restart_mcp_server", func(t *testing.T) { + ctx.ConfigureForTest(t) + const hostServer = "rpc-lifecycle-host-server" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected) + + const startedServer = "rpc-lifecycle-started-server" + config := testMCPServers(t, startedServer)[startedServer] + if _, err := session.RPC.MCP.StartServer(t.Context(), &rpc.MCPStartServerRequest{ServerName: startedServer, Config: config}); err != nil { + t.Fatalf("MCP.StartServer failed: %v", err) + } + waitForPortedMCPRunning(t, session, startedServer, true) + + tools, err := session.RPC.MCP.ListTools(t.Context(), &rpc.MCPListToolsRequest{ServerName: startedServer}) + if err != nil { + t.Fatalf("MCP.ListTools(started) failed: %v", err) + } + if len(tools.Tools) == 0 { + t.Fatal("Expected started MCP server to expose tools") + } + + if _, err := session.RPC.MCP.RestartServer(t.Context(), &rpc.MCPRestartServerRequest{ServerName: startedServer, Config: config}); err != nil { + t.Fatalf("MCP.RestartServer failed: %v", err) + } + waitForPortedMCPRunning(t, session, startedServer, true) + }) + + t.Run("should_register_and_unregister_external_mcp_client", func(t *testing.T) { + ctx.ConfigureForTest(t) + const hostServer = "rpc-lifecycle-extclient-host" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected) + + const externalName = "rpc-lifecycle-external-client" + initial, err := session.RPC.MCP.IsServerRunning(t.Context(), &rpc.MCPIsServerRunningRequest{ServerName: externalName}) + if err != nil { + t.Fatalf("MCP.IsServerRunning(initial external) failed: %v", err) + } + if initial.Running { + t.Fatal("Expected external client to start as not running") + } + + if _, err := session.RPC.MCP.RegisterExternalClient(t.Context(), &rpc.MCPRegisterExternalClientRequest{ + ServerName: externalName, + Client: map[string]any{"id": externalName}, + Transport: map[string]any{"kind": "in-process"}, + Config: map[string]any{"command": "noop"}, + }); err != nil { + t.Fatalf("MCP.RegisterExternalClient failed: %v", err) + } + waitForPortedMCPRunning(t, session, externalName, true) + + if _, err := session.RPC.MCP.UnregisterExternalClient(t.Context(), &rpc.MCPUnregisterExternalClientRequest{ServerName: externalName}); err != nil { + t.Fatalf("MCP.UnregisterExternalClient failed: %v", err) + } + waitForPortedMCPRunning(t, session, externalName, false) + }) + + t.Run("should_reload_mcp_servers_with_config", func(t *testing.T) { + ctx.ConfigureForTest(t) + const hostServer = "rpc-lifecycle-reload-host" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected) + + result, err := session.RPC.MCP.ReloadWithConfig(t.Context(), &rpc.MCPReloadWithConfigRequest{Config: map[string]any{ + "mcpServers": map[string]any{}, + "disabledServers": []string{}, + }}) + if err != nil { + t.Fatalf("MCP.ReloadWithConfig failed: %v", err) + } + if result == nil { + t.Fatal("Expected non-nil reload result") + } + if result.FilteredServers == nil { + t.Fatal("Expected non-nil FilteredServers") + } + if len(result.FilteredServers) != 0 { + t.Fatalf("Expected no filtered servers, got %+v", result.FilteredServers) + } + }) + + t.Run("should_configure_github_mcp_server", func(t *testing.T) { + ctx.ConfigureForTest(t) + const hostServer = "rpc-lifecycle-configure-host" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected) + + result, err := session.RPC.MCP.ConfigureGitHub(t.Context(), &rpc.MCPConfigureGitHubRequest{AuthInfo: map[string]any{"type": "api-key"}}) + if err != nil { + t.Fatalf("MCP.ConfigureGitHub failed: %v", err) + } + if result == nil { + t.Fatal("Expected non-nil configure result") + } + if result.Changed { + t.Fatal("Expected Changed=false") + } + }) + + t.Run("should_respond_to_mcp_oauth_request_without_pending_request", func(t *testing.T) { + ctx.ConfigureForTest(t) + const hostServer = "rpc-lifecycle-oauth-host" + session := createPortedSession(t, client, &copilot.SessionConfig{MCPServers: testMCPServers(t, hostServer)}) + defer session.Disconnect() + waitForPortedMCPServerStatus(t, session, hostServer, rpc.MCPServerStatusConnected) + + result, err := session.RPC.MCP.Oauth().Respond(t.Context(), &rpc.MCPOauthRespondRequest{RequestID: "missing-" + randomHex(t)}) + if err != nil { + t.Fatalf("MCP.Oauth.Respond failed: %v", err) + } + if result == nil { + t.Fatal("Expected non-nil OAuth respond result") + } + }) +} + +func waitForPortedMCPServerStatus(t *testing.T, session *copilot.Session, serverName string, expectedStatus rpc.MCPServerStatus) { + t.Helper() + waitForRPCCondition(t, 60*time.Second, serverName+" reaching "+string(expectedStatus), func() (bool, error) { + result, err := session.RPC.MCP.List(t.Context()) + if err != nil { + return false, err + } + for _, server := range result.Servers { + if server.Name == serverName { + return server.Status == expectedStatus, nil + } + } + return false, nil + }) +} + +func waitForPortedMCPRunning(t *testing.T, session *copilot.Session, serverName string, expectedRunning bool) { + t.Helper() + waitForRPCCondition(t, 60*time.Second, serverName+" running state", func() (bool, error) { + result, err := session.RPC.MCP.IsServerRunning(t.Context(), &rpc.MCPIsServerRunningRequest{ServerName: serverName}) + if err != nil { + return false, err + } + return result.Running == expectedRunning, nil + }) +} diff --git a/go/internal/e2e/rpc_server_misc_e2e_test.go b/go/internal/e2e/rpc_server_misc_e2e_test.go new file mode 100644 index 000000000..34b48b506 --- /dev/null +++ b/go/internal/e2e/rpc_server_misc_e2e_test.go @@ -0,0 +1,93 @@ +package e2e + +import ( + "strings" + "testing" + "time" + + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpcServerMisc(t *testing.T) { + ctx := testharness.NewTestContext(t) + sharedClient := ctx.NewClient() + t.Cleanup(func() { sharedClient.ForceStop() }) + + t.Run("should_reload_user_settings", func(t *testing.T) { + ctx.ConfigureForTest(t) + if err := sharedClient.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + if _, err := sharedClient.RPC.User.Settings().Reload(t.Context()); err != nil { + t.Fatalf("User.Settings.Reload failed: %v", err) + } + }) + + t.Run("should_report_agent_registry_spawn_gate_closed", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + _, err := client.RPC.AgentRegistry.Spawn(t.Context(), &rpc.AgentRegistrySpawnRequest{Cwd: ctx.WorkDir}) + if err == nil { + t.Fatal("Expected AgentRegistry.Spawn to be rejected by the closed spawn gate") + } + message := err.Error() + assertPortedNoUnhandledMethod(t, message) + assertPortedContainsFold(t, message, "agentRegistry.spawn") + if !strings.Contains(strings.ToLower(message), "not enabled") && !strings.Contains(strings.ToLower(message), "no delegate") { + t.Fatalf("Expected agentRegistry.spawn gate error, got %s", message) + } + }) + + t.Run("should_shut_down_owned_runtime", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedPortedClient(t, ctx) + defer client.ForceStop() + + if _, err := client.RPC.User.Settings().Reload(t.Context()); err != nil { + t.Fatalf("User.Settings.Reload before shutdown failed: %v", err) + } + if _, err := client.RPC.Runtime.Shutdown(t.Context()); err != nil { + t.Fatalf("Runtime.Shutdown failed: %v", err) + } + + waitForRPCCondition(t, 15*time.Second, "runtime to stop serving RPCs after shutdown", func() (bool, error) { + _, err := client.RPC.User.Settings().Reload(t.Context()) + return err != nil, nil + }) + }) + + t.Run("should_report_not_found_when_opening_session_without_context", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + result, err := client.RPC.Sessions.Open(t.Context(), nil) + if err != nil { + t.Fatalf("Sessions.Open failed: %v", err) + } + if result.Status != rpc.SessionsOpenStatusNotFound { + t.Fatalf("Expected Sessions.Open status not_found, got %+v", result) + } + if result.SessionID != nil { + t.Fatalf("Expected nil session ID for not_found, got %q", *result.SessionID) + } + }) + + t.Run("should_reject_send_attachments_from_non_extension_connection", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, sharedClient, nil) + defer session.Disconnect() + + _, err := session.RPC.Extensions.SendAttachmentsToMessage(t.Context(), &rpc.SendAttachmentsToMessageParams{Attachments: []rpc.PushAttachment{}}) + if err == nil { + t.Fatal("Expected SendAttachmentsToMessage from a normal SDK connection to fail") + } + message := err.Error() + assertPortedNoUnhandledMethod(t, message) + assertPortedContainsFold(t, message, "extension") + }) +} diff --git a/go/internal/e2e/rpc_server_plugins_e2e_test.go b/go/internal/e2e/rpc_server_plugins_e2e_test.go new file mode 100644 index 000000000..e79760e4a --- /dev/null +++ b/go/internal/e2e/rpc_server_plugins_e2e_test.go @@ -0,0 +1,468 @@ +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +const ( + portedMarketplaceName = "go-e2e-marketplace" + portedPluginName = "go-e2e-plugin" + portedDirectPluginName = "go-e2e-direct" +) + +func TestRpcServerPlugins(t *testing.T) { + ctx := testharness.NewTestContext(t) + + t.Run("should_install_list_and_uninstall_plugin_from_local_marketplace", func(t *testing.T) { + ctx.ConfigureForTest(t) + marketplaceDir := createPortedLocalMarketplaceFixture(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + if _, err := client.RPC.Plugins.Marketplaces().Add(t.Context(), &rpc.PluginsMarketplacesAddRequest{Source: marketplaceDir}); err != nil { + t.Fatalf("Plugins.Marketplaces.Add failed: %v", err) + } + + spec := portedPluginName + "@" + portedMarketplaceName + install, err := client.RPC.Plugins.Install(t.Context(), &rpc.PluginsInstallRequest{Source: spec}) + if err != nil { + t.Fatalf("Plugins.Install failed: %v", err) + } + if install.Plugin.Name != portedPluginName { + t.Fatalf("Expected installed plugin name %q, got %q", portedPluginName, install.Plugin.Name) + } + if install.Plugin.Marketplace != portedMarketplaceName { + t.Fatalf("Expected marketplace %q, got %q", portedMarketplaceName, install.Plugin.Marketplace) + } + if !install.Plugin.Enabled { + t.Fatal("Expected installed marketplace plugin to be enabled") + } + if install.SkillsInstalled < 1 { + t.Fatalf("Expected at least one skill, got %d", install.SkillsInstalled) + } + if install.DeprecationWarning != nil { + t.Fatalf("Marketplace install should not return deprecation warning, got %q", *install.DeprecationWarning) + } + + afterInstall, err := client.RPC.Plugins.List(t.Context()) + if err != nil { + t.Fatalf("Plugins.List after install failed: %v", err) + } + listed := findPortedInstalledPlugin(afterInstall.Plugins, portedPluginName, portedMarketplaceName) + if listed == nil { + t.Fatalf("Expected installed plugin %q in marketplace %q", portedPluginName, portedMarketplaceName) + } + if !listed.Enabled { + t.Fatal("Expected listed marketplace plugin to be enabled") + } + + if _, err := client.RPC.Plugins.Uninstall(t.Context(), &rpc.PluginsUninstallRequest{Name: spec}); err != nil { + t.Fatalf("Plugins.Uninstall failed: %v", err) + } + + afterUninstall, err := client.RPC.Plugins.List(t.Context()) + if err != nil { + t.Fatalf("Plugins.List after uninstall failed: %v", err) + } + if findPortedInstalledPlugin(afterUninstall.Plugins, portedPluginName, portedMarketplaceName) != nil { + t.Fatalf("Expected plugin %q to be removed", spec) + } + }) + + t.Run("should_enable_and_disable_marketplace_plugin", func(t *testing.T) { + ctx.ConfigureForTest(t) + marketplaceDir := createPortedLocalMarketplaceFixture(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + spec := portedPluginName + "@" + portedMarketplaceName + if _, err := client.RPC.Plugins.Marketplaces().Add(t.Context(), &rpc.PluginsMarketplacesAddRequest{Source: marketplaceDir}); err != nil { + t.Fatalf("Plugins.Marketplaces.Add failed: %v", err) + } + if _, err := client.RPC.Plugins.Install(t.Context(), &rpc.PluginsInstallRequest{Source: spec}); err != nil { + t.Fatalf("Plugins.Install failed: %v", err) + } + + if _, err := client.RPC.Plugins.Disable(t.Context(), &rpc.PluginsDisableRequest{Names: []string{spec}}); err != nil { + t.Fatalf("Plugins.Disable failed: %v", err) + } + if plugin := getPortedInstalledPlugin(t, client, portedPluginName, portedMarketplaceName); plugin.Enabled { + t.Fatal("Expected plugin to be disabled") + } + + if _, err := client.RPC.Plugins.Enable(t.Context(), &rpc.PluginsEnableRequest{Names: []string{spec}}); err != nil { + t.Fatalf("Plugins.Enable failed: %v", err) + } + if plugin := getPortedInstalledPlugin(t, client, portedPluginName, portedMarketplaceName); !plugin.Enabled { + t.Fatal("Expected plugin to be enabled") + } + }) + + t.Run("should_update_single_marketplace_plugin", func(t *testing.T) { + ctx.ConfigureForTest(t) + marketplaceDir := createPortedLocalMarketplaceFixture(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + spec := portedPluginName + "@" + portedMarketplaceName + if _, err := client.RPC.Plugins.Marketplaces().Add(t.Context(), &rpc.PluginsMarketplacesAddRequest{Source: marketplaceDir}); err != nil { + t.Fatalf("Plugins.Marketplaces.Add failed: %v", err) + } + if _, err := client.RPC.Plugins.Install(t.Context(), &rpc.PluginsInstallRequest{Source: spec}); err != nil { + t.Fatalf("Plugins.Install failed: %v", err) + } + + update, err := client.RPC.Plugins.Update(t.Context(), &rpc.PluginsUpdateRequest{Name: spec}) + if err != nil { + t.Fatalf("Plugins.Update failed: %v", err) + } + if update.SkillsInstalled < 1 { + t.Fatalf("Expected at least one skill, got %d", update.SkillsInstalled) + } + if update.PreviousVersion == nil || *update.PreviousVersion != "1.0.0" { + t.Fatalf("Expected previous version 1.0.0, got %v", update.PreviousVersion) + } + if update.NewVersion == nil || *update.NewVersion != "1.0.0" { + t.Fatalf("Expected new version 1.0.0, got %v", update.NewVersion) + } + }) + + t.Run("should_update_all_installed_plugins", func(t *testing.T) { + ctx.ConfigureForTest(t) + marketplaceDir := createPortedLocalMarketplaceFixture(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + spec := portedPluginName + "@" + portedMarketplaceName + if _, err := client.RPC.Plugins.Marketplaces().Add(t.Context(), &rpc.PluginsMarketplacesAddRequest{Source: marketplaceDir}); err != nil { + t.Fatalf("Plugins.Marketplaces.Add failed: %v", err) + } + if _, err := client.RPC.Plugins.Install(t.Context(), &rpc.PluginsInstallRequest{Source: spec}); err != nil { + t.Fatalf("Plugins.Install failed: %v", err) + } + + result, err := client.RPC.Plugins.UpdateAll(t.Context()) + if err != nil { + t.Fatalf("Plugins.UpdateAll failed: %v", err) + } + var matches []rpc.PluginUpdateAllEntry + for _, entry := range result.Results { + if entry.Name == portedPluginName && entry.Marketplace == portedMarketplaceName { + matches = append(matches, entry) + } + } + if len(matches) != 1 { + t.Fatalf("Expected exactly one update result for %q, got %d in %+v", spec, len(matches), result.Results) + } + entry := matches[0] + if !entry.Success { + t.Fatalf("Expected update all entry to succeed, got error %v", entry.Error) + } + if entry.SkillsInstalled == nil || *entry.SkillsInstalled < 1 { + t.Fatalf("Expected at least one skill installed, got %v", entry.SkillsInstalled) + } + }) + + t.Run("should_install_direct_local_plugin_with_deprecation_warning", func(t *testing.T) { + ctx.ConfigureForTest(t) + pluginDir := createPortedDirectPluginFixture(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + install, err := client.RPC.Plugins.Install(t.Context(), &rpc.PluginsInstallRequest{Source: pluginDir}) + if err != nil { + t.Fatalf("Plugins.Install direct failed: %v", err) + } + if install.Plugin.Name != portedDirectPluginName { + t.Fatalf("Expected installed plugin name %q, got %q", portedDirectPluginName, install.Plugin.Name) + } + if install.Plugin.Marketplace != "" { + t.Fatalf("Expected direct plugin marketplace to be empty, got %q", install.Plugin.Marketplace) + } + if install.DeprecationWarning == nil || !strings.Contains(strings.ToLower(*install.DeprecationWarning), "deprecated") { + t.Fatalf("Expected deprecation warning containing deprecated, got %v", install.DeprecationWarning) + } + if install.SkillsInstalled < 1 { + t.Fatalf("Expected at least one skill, got %d", install.SkillsInstalled) + } + + afterInstall, err := client.RPC.Plugins.List(t.Context()) + if err != nil { + t.Fatalf("Plugins.List after direct install failed: %v", err) + } + if countPortedInstalledPluginByName(afterInstall.Plugins, portedDirectPluginName) != 1 { + t.Fatalf("Expected exactly one direct plugin named %q, got %+v", portedDirectPluginName, afterInstall.Plugins) + } + + if _, err := client.RPC.Plugins.Uninstall(t.Context(), &rpc.PluginsUninstallRequest{Name: portedDirectPluginName}); err != nil { + t.Fatalf("Plugins.Uninstall direct failed: %v", err) + } + afterUninstall, err := client.RPC.Plugins.List(t.Context()) + if err != nil { + t.Fatalf("Plugins.List after direct uninstall failed: %v", err) + } + if countPortedInstalledPluginByName(afterUninstall.Plugins, portedDirectPluginName) != 0 { + t.Fatalf("Expected direct plugin %q to be removed, got %+v", portedDirectPluginName, afterUninstall.Plugins) + } + }) + + t.Run("should_list_browse_refresh_and_remove_local_marketplace", func(t *testing.T) { + ctx.ConfigureForTest(t) + marketplaceDir := createPortedLocalMarketplaceFixture(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + add, err := client.RPC.Plugins.Marketplaces().Add(t.Context(), &rpc.PluginsMarketplacesAddRequest{Source: marketplaceDir}) + if err != nil { + t.Fatalf("Plugins.Marketplaces.Add failed: %v", err) + } + if add.Name != portedMarketplaceName { + t.Fatalf("Expected marketplace name %q, got %q", portedMarketplaceName, add.Name) + } + + list, err := client.RPC.Plugins.Marketplaces().List(t.Context()) + if err != nil { + t.Fatalf("Plugins.Marketplaces.List failed: %v", err) + } + mine := findPortedMarketplace(list.Marketplaces, portedMarketplaceName) + if mine == nil { + t.Fatalf("Expected marketplace %q in list %+v", portedMarketplaceName, list.Marketplaces) + } + if mine.IsDefault != nil && *mine.IsDefault { + t.Fatal("Expected local marketplace not to be marked default") + } + if !containsPortedDefaultMarketplace(list.Marketplaces) { + t.Fatalf("Expected built-in default marketplace in %+v", list.Marketplaces) + } + + browse, err := client.RPC.Plugins.Marketplaces().Browse(t.Context(), &rpc.PluginsMarketplacesBrowseRequest{Name: portedMarketplaceName}) + if err != nil { + t.Fatalf("Plugins.Marketplaces.Browse failed: %v", err) + } + var advertised []rpc.MarketplacePluginInfo + for _, plugin := range browse.Plugins { + if plugin.Name == portedPluginName { + advertised = append(advertised, plugin) + } + } + if len(advertised) != 1 { + t.Fatalf("Expected one advertised plugin %q, got %+v", portedPluginName, browse.Plugins) + } + if advertised[0].Description == nil || strings.TrimSpace(*advertised[0].Description) == "" { + t.Fatalf("Expected advertised plugin description, got %+v", advertised[0]) + } + + refreshName := portedMarketplaceName + refresh, err := client.RPC.Plugins.Marketplaces().Refresh(t.Context(), &rpc.PluginsMarketplacesRefreshRequest{Name: &refreshName}) + if err != nil { + t.Fatalf("Plugins.Marketplaces.Refresh failed: %v", err) + } + var refreshMatches []rpc.MarketplaceRefreshEntry + for _, entry := range refresh.Results { + if entry.Name == portedMarketplaceName { + refreshMatches = append(refreshMatches, entry) + } + } + if len(refreshMatches) != 1 { + t.Fatalf("Expected one refresh result for %q, got %+v", portedMarketplaceName, refresh.Results) + } + if !refreshMatches[0].Success { + t.Fatalf("Expected refresh success, got error %v", refreshMatches[0].Error) + } + + remove, err := client.RPC.Plugins.Marketplaces().Remove(t.Context(), &rpc.PluginsMarketplacesRemoveRequest{Name: portedMarketplaceName}) + if err != nil { + t.Fatalf("Plugins.Marketplaces.Remove failed: %v", err) + } + if !remove.Removed { + t.Fatalf("Expected marketplace removal, got %+v", remove) + } + + afterRemove, err := client.RPC.Plugins.Marketplaces().List(t.Context()) + if err != nil { + t.Fatalf("Plugins.Marketplaces.List after remove failed: %v", err) + } + if findPortedMarketplace(afterRemove.Marketplaces, portedMarketplaceName) != nil { + t.Fatalf("Expected marketplace %q to be removed, got %+v", portedMarketplaceName, afterRemove.Marketplaces) + } + }) + + t.Run("should_reload_mcp_config_cache", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedIsolatedPortedClient(t, ctx) + defer client.ForceStop() + + if _, err := client.RPC.MCP.Config().Reload(t.Context()); err != nil { + t.Fatalf("MCP.Config.Reload failed: %v", err) + } + }) +} + +func newStartedPortedClient(t *testing.T, ctx *testharness.TestContext, opts ...func(*copilot.ClientOptions)) *copilot.Client { + t.Helper() + client := ctx.NewClient(opts...) + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + return client +} + +func newStartedIsolatedPortedClient(t *testing.T, ctx *testharness.TestContext) *copilot.Client { + t.Helper() + home := t.TempDir() + return newStartedPortedClient(t, ctx, func(opts *copilot.ClientOptions) { + opts.Env = append(opts.Env, + "COPILOT_HOME="+home, + "GH_CONFIG_DIR="+home, + "XDG_CONFIG_HOME="+home, + "XDG_STATE_HOME="+home, + ) + }) +} + +func createPortedSession(t *testing.T, client *copilot.Client, config *copilot.SessionConfig) *copilot.Session { + t.Helper() + if config == nil { + config = &copilot.SessionConfig{} + } + if config.OnPermissionRequest == nil { + config.OnPermissionRequest = copilot.PermissionHandler.ApproveAll + } + session, err := client.CreateSession(t.Context(), config) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} + +func assertPortedNoUnhandledMethod(t *testing.T, message string) { + t.Helper() + if strings.Contains(strings.ToLower(message), "unhandled method") { + t.Fatalf("Expected RPC to reach runtime, got %s", message) + } +} + +func assertPortedContainsFold(t *testing.T, message string, fragments ...string) { + t.Helper() + lower := strings.ToLower(message) + for _, fragment := range fragments { + if strings.Contains(lower, strings.ToLower(fragment)) { + return + } + } + t.Fatalf("Expected %q to contain one of %v", message, fragments) +} + +func createPortedLocalMarketplaceFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + manifest := `{ + "name": "` + portedMarketplaceName + `", + "owner": { "name": "Copilot SDK E2E" }, + "metadata": { "description": "Local marketplace fixture for SDK E2E tests." }, + "plugins": [ + { + "name": "` + portedPluginName + `", + "source": "./` + portedPluginName + `", + "description": "E2E demo plugin advertised by the local marketplace.", + "version": "1.0.0" + } + ] +}` + if err := os.WriteFile(filepath.Join(dir, "marketplace.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("Failed to write marketplace manifest: %v", err) + } + pluginDir := filepath.Join(dir, portedPluginName) + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatalf("Failed to create marketplace plugin directory: %v", err) + } + writePortedSkillFile(t, pluginDir) + return dir +} + +func createPortedDirectPluginFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + manifest := `{ + "name": "` + portedDirectPluginName + `", + "description": "E2E demo plugin installed directly from a local path.", + "version": "1.0.0" +}` + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(manifest), 0644); err != nil { + t.Fatalf("Failed to write direct plugin manifest: %v", err) + } + writePortedSkillFile(t, dir) + return dir +} + +func writePortedSkillFile(t *testing.T, pluginDir string) { + t.Helper() + const skill = `--- +name: go-e2e-skill +description: A demo skill contributed by the E2E test plugin. +--- +# Demo Skill + +This skill exists so the plugin reports at least one installed skill. +` + if err := os.WriteFile(filepath.Join(pluginDir, "SKILL.md"), []byte(skill), 0644); err != nil { + t.Fatalf("Failed to write skill file: %v", err) + } +} + +func getPortedInstalledPlugin(t *testing.T, client *copilot.Client, name, marketplace string) *rpc.InstalledPluginInfo { + t.Helper() + list, err := client.RPC.Plugins.List(t.Context()) + if err != nil { + t.Fatalf("Plugins.List failed: %v", err) + } + plugin := findPortedInstalledPlugin(list.Plugins, name, marketplace) + if plugin == nil { + t.Fatalf("Expected installed plugin %q in marketplace %q, got %+v", name, marketplace, list.Plugins) + } + return plugin +} + +func findPortedInstalledPlugin(plugins []rpc.InstalledPluginInfo, name, marketplace string) *rpc.InstalledPluginInfo { + for i := range plugins { + if plugins[i].Name == name && plugins[i].Marketplace == marketplace { + return &plugins[i] + } + } + return nil +} + +func countPortedInstalledPluginByName(plugins []rpc.InstalledPluginInfo, name string) int { + count := 0 + for _, plugin := range plugins { + if plugin.Name == name { + count++ + } + } + return count +} + +func findPortedMarketplace(marketplaces []rpc.MarketplaceInfo, name string) *rpc.MarketplaceInfo { + for i := range marketplaces { + if marketplaces[i].Name == name { + return &marketplaces[i] + } + } + return nil +} + +func containsPortedDefaultMarketplace(marketplaces []rpc.MarketplaceInfo) bool { + for _, marketplace := range marketplaces { + if marketplace.IsDefault != nil && *marketplace.IsDefault { + return true + } + } + return false +} diff --git a/go/internal/e2e/rpc_server_remote_control_e2e_test.go b/go/internal/e2e/rpc_server_remote_control_e2e_test.go new file mode 100644 index 000000000..7990b32c0 --- /dev/null +++ b/go/internal/e2e/rpc_server_remote_control_e2e_test.go @@ -0,0 +1,112 @@ +package e2e + +import ( + "strings" + "testing" + + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpcServerRemoteControl(t *testing.T) { + ctx := testharness.NewTestContext(t) + + t.Run("should_report_remote_control_status_as_off", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedPortedClient(t, ctx) + defer client.ForceStop() + + result, err := client.RPC.Sessions.GetRemoteControlStatus(t.Context()) + if err != nil { + t.Fatalf("Sessions.GetRemoteControlStatus failed: %v", err) + } + assertPortedRemoteControlOff(t, result.Status) + }) + + t.Run("should_treat_set_steering_as_no_op_when_off", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedPortedClient(t, ctx) + defer client.ForceStop() + + result, err := client.RPC.Sessions.SetRemoteControlSteering(t.Context(), &rpc.SessionsSetRemoteControlSteeringRequest{Enabled: false}) + if err != nil { + t.Fatalf("Sessions.SetRemoteControlSteering failed: %v", err) + } + assertPortedRemoteControlOff(t, result.Status) + }) + + t.Run("should_report_not_stopped_when_remote_control_is_off", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedPortedClient(t, ctx) + defer client.ForceStop() + + result, err := client.RPC.Sessions.StopRemoteControl(t.Context(), &rpc.SessionsStopRemoteControlRequest{}) + if err != nil { + t.Fatalf("Sessions.StopRemoteControl failed: %v", err) + } + if result.Stopped { + t.Fatalf("Expected Stopped=false, got %+v", result) + } + assertPortedRemoteControlOff(t, result.Status) + }) + + t.Run("should_reject_transfer_when_off_with_compare_and_swap", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedPortedClient(t, ctx) + defer client.ForceStop() + + from := "rc-from-" + randomHex(t) + result, err := client.RPC.Sessions.TransferRemoteControl(t.Context(), &rpc.SessionsTransferRemoteControlRequest{ + ToSessionID: "rc-to-" + randomHex(t), + ExpectedFromSessionID: &from, + }) + if err != nil { + t.Fatalf("Sessions.TransferRemoteControl failed: %v", err) + } + if result.Transferred { + t.Fatalf("Expected Transferred=false, got %+v", result) + } + assertPortedRemoteControlOff(t, result.Status) + }) + + t.Run("should_reach_runtime_when_starting_remote_control_for_unknown_session", func(t *testing.T) { + ctx.ConfigureForTest(t) + client := newStartedPortedClient(t, ctx) + defer client.ForceStop() + defer func() { + force := true + _, _ = client.RPC.Sessions.StopRemoteControl(t.Context(), &rpc.SessionsStopRemoteControlRequest{Force: &force}) + }() + + _, err := client.RPC.Sessions.StartRemoteControl(t.Context(), &rpc.SessionsStartRemoteControlRequest{ + SessionID: "missing-session-" + randomHex(t), + Config: rpc.RemoteControlConfig{ + Remote: false, + Explicit: false, + Silent: true, + Steerable: false, + }, + }) + if err == nil { + t.Fatal("Expected StartRemoteControl for an unknown session to fail") + } + message := err.Error() + assertPortedNoUnhandledMethod(t, message) + if !strings.Contains(strings.ToLower(message), "session") && !strings.Contains(strings.ToLower(message), "remote") { + t.Fatalf("Expected error to mention session or remote, got %s", message) + } + }) +} + +func assertPortedRemoteControlOff(t *testing.T, status rpc.RemoteControlStatus) { + t.Helper() + if status == nil { + t.Fatal("Expected remote control status, got nil") + } + if status.State() != rpc.RemoteControlStatusStateOff { + t.Fatalf("Expected remote control state off, got %s (%T)", status.State(), status) + } + if _, ok := status.(*rpc.RemoteControlStatusOff); !ok { + t.Fatalf("Expected *RemoteControlStatusOff, got %T", status) + } +} diff --git a/go/internal/e2e/rpc_session_state_extras_e2e_test.go b/go/internal/e2e/rpc_session_state_extras_e2e_test.go new file mode 100644 index 000000000..f4de1c186 --- /dev/null +++ b/go/internal/e2e/rpc_session_state_extras_e2e_test.go @@ -0,0 +1,200 @@ +package e2e + +import ( + "encoding/json" + "strings" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpcSessionStateExtras(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should_list_models_for_session", func(t *testing.T) { + ctx.ConfigureForTest(t) + const token = "rpc-session-model-list-token" + registerProxyUser(t, ctx, token, "rpc-session-extras-user", nil) + authClient := newAuthenticatedClient(ctx, token) + defer authClient.ForceStop() + + session := createPortedSession(t, authClient, &copilot.SessionConfig{Model: "claude-sonnet-4.5"}) + defer session.Disconnect() + + result, err := session.RPC.Model.List(t.Context()) + if err != nil { + t.Fatalf("Model.List failed: %v", err) + } + if result.List == nil { + t.Fatal("Expected non-nil model list") + } + if len(result.List) == 0 { + t.Fatal("Expected non-empty model list") + } + found := false + for _, model := range result.List { + data, err := json.Marshal(model) + if err == nil && strings.Contains(string(data), "claude-sonnet-4.5") { + found = true + break + } + } + if !found { + t.Fatalf("Expected model list to include claude-sonnet-4.5, got %+v", result.List) + } + }) + + t.Run("should_report_session_activity_when_idle", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + activity, err := session.RPC.Metadata.Activity(t.Context()) + if err != nil { + t.Fatalf("Metadata.Activity failed: %v", err) + } + if activity.HasActiveWork { + t.Fatal("Expected a fresh session to report no active work") + } + if activity.Abortable { + t.Fatal("Expected a fresh session to have nothing abortable") + } + }) + + t.Run("should_get_and_set_allowall_permissions", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + defer func() { + _, _ = session.RPC.Permissions.SetAllowAll(t.Context(), &rpc.PermissionsSetAllowAllRequest{Enabled: false}) + }() + + initial, err := session.RPC.Permissions.GetAllowAll(t.Context()) + if err != nil { + t.Fatalf("Permissions.GetAllowAll initial failed: %v", err) + } + if initial.Enabled { + t.Fatal("Allow-all should be disabled on a fresh session") + } + + enable, err := session.RPC.Permissions.SetAllowAll(t.Context(), &rpc.PermissionsSetAllowAllRequest{Enabled: true}) + if err != nil { + t.Fatalf("Permissions.SetAllowAll(true) failed: %v", err) + } + if !enable.Success || !enable.Enabled { + t.Fatalf("Expected successful enable, got %+v", enable) + } + afterEnable, err := session.RPC.Permissions.GetAllowAll(t.Context()) + if err != nil { + t.Fatalf("Permissions.GetAllowAll after enable failed: %v", err) + } + if !afterEnable.Enabled { + t.Fatal("Expected allow-all to be enabled") + } + + disable, err := session.RPC.Permissions.SetAllowAll(t.Context(), &rpc.PermissionsSetAllowAllRequest{Enabled: false}) + if err != nil { + t.Fatalf("Permissions.SetAllowAll(false) failed: %v", err) + } + if !disable.Success || disable.Enabled { + t.Fatalf("Expected successful disable, got %+v", disable) + } + afterDisable, err := session.RPC.Permissions.GetAllowAll(t.Context()) + if err != nil { + t.Fatalf("Permissions.GetAllowAll after disable failed: %v", err) + } + if afterDisable.Enabled { + t.Fatal("Expected allow-all to be disabled") + } + }) + + t.Run("should_read_empty_sql_todos_for_fresh_session", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + result, err := session.RPC.Plan.ReadSqlTodos(t.Context()) + if err != nil { + t.Fatalf("Plan.ReadSqlTodos failed: %v", err) + } + if result.Rows == nil { + t.Fatal("Expected non-nil SQL todo rows") + } + if len(result.Rows) != 0 { + t.Fatalf("Expected empty SQL todo rows, got %+v", result.Rows) + } + }) + + t.Run("should_get_telemetry_engagement_id", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + result, err := session.RPC.Telemetry.GetEngagementId(t.Context()) + if err != nil { + t.Fatalf("Telemetry.GetEngagementId failed: %v", err) + } + if result == nil { + t.Fatal("Expected non-nil telemetry engagement result") + } + }) + + t.Run("should_get_current_tool_metadata_after_initialization", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + answer, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "What is 2+2?"}) + if err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + if answer == nil { + t.Fatal("Expected a final assistant message") + } + + result, err := session.RPC.Tools.GetCurrentMetadata(t.Context()) + if err != nil { + t.Fatalf("Tools.GetCurrentMetadata failed: %v", err) + } + if result.Tools == nil { + t.Fatal("Expected non-nil current tool metadata") + } + if len(result.Tools) == 0 { + t.Fatal("Expected non-empty current tool metadata") + } + for _, tool := range result.Tools { + if strings.TrimSpace(tool.Name) == "" { + t.Fatalf("Expected non-empty tool name, got %+v", tool) + } + if strings.TrimSpace(tool.Description) == "" { + t.Fatalf("Expected non-empty tool description, got %+v", tool) + } + } + }) + + t.Run("should_reload_session_plugins", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + if _, err := session.RPC.Plugins.Reload(t.Context()); err != nil { + t.Fatalf("Plugins.Reload failed: %v", err) + } + plugins, err := session.RPC.Plugins.List(t.Context()) + if err != nil { + t.Fatalf("Plugins.List failed: %v", err) + } + if plugins.Plugins == nil { + t.Fatal("Expected non-nil session plugin list") + } + for _, plugin := range plugins.Plugins { + if strings.TrimSpace(plugin.Name) == "" { + t.Fatalf("Expected non-empty plugin name, got %+v", plugin) + } + } + }) +} diff --git a/go/internal/e2e/rpc_shell_user_requested_e2e_test.go b/go/internal/e2e/rpc_shell_user_requested_e2e_test.go new file mode 100644 index 000000000..0c388a3c1 --- /dev/null +++ b/go/internal/e2e/rpc_shell_user_requested_e2e_test.go @@ -0,0 +1,140 @@ +package e2e + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpcShellUserRequested(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should_execute_user_requested_shell_command", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + marker := "copilotusershell" + randomHex(t) + requestID := "req-" + randomHex(t) + + result, err := session.RPC.Shell.ExecuteUserRequested(t.Context(), &rpc.ShellExecuteUserRequestedRequest{ + RequestID: requestID, + Command: "echo " + marker, + }) + if err != nil { + t.Fatalf("Shell.ExecuteUserRequested failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected shell command to succeed, got error %v", result.Error) + } + if result.ExitCode == nil || *result.ExitCode != 0 { + t.Fatalf("Expected exit code 0, got %v", result.ExitCode) + } + if !strings.Contains(result.Output, marker) { + t.Fatalf("Expected output to contain %q, got %q", marker, result.Output) + } + if strings.TrimSpace(result.ToolCallID) == "" { + t.Fatal("Expected non-empty tool call ID") + } + }) + + t.Run("should_cancel_user_requested_shell_command", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + missing, err := session.RPC.Shell.CancelUserRequested(t.Context(), &rpc.ShellCancelUserRequestedRequest{RequestID: "missing-" + randomHex(t)}) + if err != nil { + t.Fatalf("Shell.CancelUserRequested(missing) failed: %v", err) + } + if missing.Cancelled { + t.Fatal("Expected cancelling an unknown request to return Cancelled=false") + } + + requestID := "req-" + randomHex(t) + markerPath := filepath.Join(os.TempDir(), "shell-cancel-"+randomHex(t)+".txt") + defer tryRemovePortedFile(markerPath) + + type executeResult struct { + result *rpc.UserRequestedShellCommandResult + err error + } + executeCh := make(chan executeResult, 1) + execDone := false + go func() { + result, err := session.RPC.Shell.ExecuteUserRequested(t.Context(), &rpc.ShellExecuteUserRequestedRequest{ + RequestID: requestID, + Command: createPortedMarkerThenSleepCommand(markerPath, 60), + }) + executeCh <- executeResult{result: result, err: err} + }() + defer func() { + if execDone { + return + } + _, _ = session.RPC.Shell.CancelUserRequested(t.Context(), &rpc.ShellCancelUserRequestedRequest{RequestID: requestID}) + select { + case <-executeCh: + case <-time.After(30 * time.Second): + } + }() + + waitForRPCCondition(t, 30*time.Second, "user-requested shell marker file", func() (bool, error) { + _, err := os.Stat(markerPath) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err + }) + + waitForRPCCondition(t, 15*time.Second, "user-requested shell command to become cancellable", func() (bool, error) { + cancel, err := session.RPC.Shell.CancelUserRequested(t.Context(), &rpc.ShellCancelUserRequestedRequest{RequestID: requestID}) + if err != nil { + return false, err + } + return cancel.Cancelled, nil + }) + + select { + case execution := <-executeCh: + execDone = true + if execution.err != nil { + t.Fatalf("ExecuteUserRequested returned error after cancellation: %v", execution.err) + } + if execution.result == nil { + t.Fatal("Expected execution result after cancellation") + } + if execution.result.Success { + t.Fatalf("Expected cancelled execution to be unsuccessful, got %+v", execution.result) + } + case <-time.After(30 * time.Second): + t.Fatal("Timed out waiting for cancelled user-requested shell command to finish") + } + }) +} + +func createPortedMarkerThenSleepCommand(markerPath string, seconds int) string { + if runtime.GOOS == "windows" { + escaped := strings.ReplaceAll(markerPath, "'", "''") + return fmt.Sprintf("Set-Content -LiteralPath '%s' -Value 'running'; Start-Sleep -Seconds %d", escaped, seconds) + } + escaped := strings.ReplaceAll(markerPath, "'", "'\\''") + return fmt.Sprintf("echo running > '%s'; sleep %d", escaped, seconds) +} + +func tryRemovePortedFile(path string) { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + _ = err + } +} diff --git a/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go b/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go new file mode 100644 index 000000000..c6e403360 --- /dev/null +++ b/go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go @@ -0,0 +1,37 @@ +package e2e + +import ( + "strings" + "testing" + + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +func TestRpcUiEphemeralQuery(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should_answer_ephemeral_query", func(t *testing.T) { + ctx.ConfigureForTest(t) + session := createPortedSession(t, client, nil) + defer session.Disconnect() + + result, err := session.RPC.UI.EphemeralQuery(t.Context(), &rpc.UIEphemeralQueryRequest{ + Question: "In one word, what is the primary color of a clear daytime sky?", + }) + if err != nil { + t.Fatalf("UI.EphemeralQuery failed: %v", err) + } + if result == nil { + t.Fatal("Expected non-nil ephemeral query result") + } + if strings.TrimSpace(result.Answer) == "" { + t.Fatal("Expected non-empty ephemeral query answer") + } + if !strings.Contains(strings.ToLower(result.Answer), "blue") { + t.Fatalf("Expected answer to contain blue, got %q", result.Answer) + } + }) +} diff --git a/nodejs/test/e2e/rpc_mcp_lifecycle.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_lifecycle.e2e.test.ts new file mode 100644 index 000000000..22db56d9b --- /dev/null +++ b/nodejs/test/e2e/rpc_mcp_lifecycle.e2e.test.ts @@ -0,0 +1,262 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import type { CopilotSession, MCPServerConfig, MCPStdioServerConfig } from "../../src/index.js"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { formatError, waitForCondition } from "./harness/sdkTestHelper.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const TEST_MCP_SERVER = resolve(__dirname, "../../../test/harness/test-mcp-server.mjs"); +const TEST_HARNESS_DIR = dirname(TEST_MCP_SERVER); + +describe("Session-scoped MCP lifecycle RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + function createTestMcpServers(...serverNames: string[]): Record { + return Object.fromEntries( + serverNames.map((name) => [ + name, + { + type: "local", + command: "node", + args: [TEST_MCP_SERVER], + workingDirectory: TEST_HARNESS_DIR, + tools: ["*"], + } as MCPStdioServerConfig, + ]) + ); + } + + async function createSessionWithMcp(serverName: string): Promise { + return client.createSession({ + onPermissionRequest: approveAll, + mcpServers: createTestMcpServers(serverName), + }); + } + + async function waitForMcpServerStatus( + session: CopilotSession, + serverName: string, + expectedStatus = "connected" + ): Promise { + let lastStatus = ""; + await waitForCondition( + async () => { + const result = await session.rpc.mcp.list(); + const server = result.servers.find((entry) => entry.name === serverName); + lastStatus = server?.status ?? ""; + return server?.status === expectedStatus; + }, + { + timeoutMs: 60_000, + intervalMs: 200, + timeoutMessage: `${serverName} did not reach ${expectedStatus}; last status was ${lastStatus}`, + } + ); + } + + async function waitForMcpRunning( + session: CopilotSession, + serverName: string, + expectedRunning: boolean + ): Promise { + await waitForCondition( + async () => + (await session.rpc.mcp.isServerRunning({ serverName })).running === expectedRunning, + { + timeoutMs: 60_000, + intervalMs: 200, + timeoutMessage: `${serverName} running=${expectedRunning}`, + } + ); + } + + function missingName(prefix: string): string { + return `${prefix}-${randomUUID().replace(/-/g, "")}`; + } + + function assertNotUnhandledMethod(message: string): void { + expect(message.toLowerCase()).not.toContain("unhandled method"); + } + + it( + "should list tools and report running status for connected server", + { timeout: 120_000 }, + async () => { + const serverName = "rpc-lifecycle-list-server"; + const session = await createSessionWithMcp(serverName); + try { + await waitForMcpServerStatus(session, serverName); + + const tools = await session.rpc.mcp.listTools({ serverName }); + expect(tools.tools.length).toBeGreaterThan(0); + for (const tool of tools.tools) { + expect(tool.name).toBeTruthy(); + } + + expect((await session.rpc.mcp.isServerRunning({ serverName })).running).toBe(true); + expect( + ( + await session.rpc.mcp.isServerRunning({ + serverName: missingName("missing"), + }) + ).running + ).toBe(false); + } finally { + await session.disconnect(); + } + } + ); + + it("should throw when listing tools for unconnected server", { timeout: 120_000 }, async () => { + const serverName = "rpc-lifecycle-unconnected-host"; + const session = await createSessionWithMcp(serverName); + try { + await waitForMcpServerStatus(session, serverName); + + await expect( + session.rpc.mcp.listTools({ serverName: missingName("missing") }) + ).rejects.toSatisfy((error: unknown) => { + const message = formatError(error); + assertNotUnhandledMethod(message); + expect(message.toLowerCase()).toContain("not connected"); + return true; + }); + } finally { + await session.disconnect(); + } + }); + + it("should stop running mcp server", { timeout: 180_000 }, async () => { + const serverName = "rpc-lifecycle-stop-server"; + const session = await createSessionWithMcp(serverName); + try { + await waitForMcpServerStatus(session, serverName); + expect((await session.rpc.mcp.isServerRunning({ serverName })).running).toBe(true); + + await session.rpc.mcp.stopServer({ serverName }); + + await waitForMcpRunning(session, serverName, false); + } finally { + await session.disconnect(); + } + }); + + it("should start and restart mcp server", { timeout: 180_000 }, async () => { + const hostServer = "rpc-lifecycle-host-server"; + const session = await createSessionWithMcp(hostServer); + try { + await waitForMcpServerStatus(session, hostServer); + + const startedServer = "rpc-lifecycle-started-server"; + const config = createTestMcpServers(startedServer)[startedServer] as unknown as Record< + string, + unknown + >; + + await session.rpc.mcp.startServer({ serverName: startedServer, config }); + await waitForMcpRunning(session, startedServer, true); + + const tools = await session.rpc.mcp.listTools({ serverName: startedServer }); + expect(tools.tools.length).toBeGreaterThan(0); + + await session.rpc.mcp.restartServer({ serverName: startedServer, config }); + await waitForMcpRunning(session, startedServer, true); + } finally { + await session.disconnect(); + } + }); + + it("should register and unregister external mcp client", { timeout: 120_000 }, async () => { + const hostServer = "rpc-lifecycle-extclient-host"; + const session = await createSessionWithMcp(hostServer); + try { + await waitForMcpServerStatus(session, hostServer); + + const externalName = "rpc-lifecycle-external-client"; + expect( + (await session.rpc.mcp.isServerRunning({ serverName: externalName })).running + ).toBe(false); + + await session.rpc.mcp.registerExternalClient({ + serverName: externalName, + client: { id: externalName }, + transport: { kind: "in-process" }, + config: { command: "noop" }, + }); + expect( + (await session.rpc.mcp.isServerRunning({ serverName: externalName })).running + ).toBe(true); + + await session.rpc.mcp.unregisterExternalClient({ serverName: externalName }); + expect( + (await session.rpc.mcp.isServerRunning({ serverName: externalName })).running + ).toBe(false); + } finally { + await session.disconnect(); + } + }); + + it("should reload mcp servers with config", { timeout: 120_000 }, async () => { + const hostServer = "rpc-lifecycle-reload-host"; + const session = await createSessionWithMcp(hostServer); + try { + await waitForMcpServerStatus(session, hostServer); + + const result = await session.rpc.mcp.reloadWithConfig({ + config: { + mcpServers: {}, + disabledServers: [], + }, + }); + + expect(result).toBeDefined(); + expect(result.filteredServers).toEqual([]); + } finally { + await session.disconnect(); + } + }); + + it("should configure github mcp server", { timeout: 120_000 }, async () => { + const hostServer = "rpc-lifecycle-configure-host"; + const session = await createSessionWithMcp(hostServer); + try { + await waitForMcpServerStatus(session, hostServer); + + const result = await session.rpc.mcp.configureGitHub({ + authInfo: { type: "api-key" }, + }); + + expect(result).toBeDefined(); + expect(result.changed).toBe(false); + } finally { + await session.disconnect(); + } + }); + + it( + "should respond to mcp oauth request without pending request", + { timeout: 120_000 }, + async () => { + const hostServer = "rpc-lifecycle-oauth-host"; + const session = await createSessionWithMcp(hostServer); + try { + await waitForMcpServerStatus(session, hostServer); + + const result = await session.rpc.mcp.oauth.respond({ + requestId: missingName("missing"), + }); + expect(result).toBeDefined(); + } finally { + await session.disconnect(); + } + } + ); +}); diff --git a/nodejs/test/e2e/rpc_server_misc.e2e.test.ts b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts new file mode 100644 index 000000000..d358c7d9d --- /dev/null +++ b/nodejs/test/e2e/rpc_server_misc.e2e.test.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; +import { formatError, waitForCondition } from "./harness/sdkTestHelper.js"; + +describe("Miscellaneous server-scoped RPC", async () => { + const { copilotClient: client, env, workDir } = await createSdkTestContext(); + + function createUniqueDirectory(prefix: string): string { + const directory = join(workDir, `${prefix}-${randomUUID()}`); + mkdirSync(directory, { recursive: true }); + return directory; + } + + function createClient(extraEnv: Record = {}): CopilotClient { + return new CopilotClient({ + workingDirectory: workDir, + env: { + ...env, + ...extraEnv, + }, + logLevel: "error", + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + gitHubToken: DEFAULT_GITHUB_TOKEN, + }); + } + + async function createIsolatedStartedClient(): 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, + }); + try { + await isolatedClient.start(); + return { client: isolatedClient, home }; + } catch (error) { + await disposeIsolated(isolatedClient, home); + throw error; + } + } + + async function disposeIsolated(isolatedClient: CopilotClient, home: string): Promise { + try { + await isolatedClient.forceStop(); + } catch { + // Best-effort cleanup. + } + tryRemoveDirectory(home); + } + + async function forceStop(target: CopilotClient): Promise { + try { + await target.forceStop(); + } catch { + // Runtime may already be gone. + } + } + + function tryRemoveDirectory(directory: string): void { + try { + rmSync(directory, { recursive: true, force: true }); + } catch { + // Temp directories are reclaimed by the harness/OS. + } + } + + it("should reload user settings", { timeout: 120_000 }, async () => { + await client.start(); + + await client.rpc.user.settings.reload(); + }); + + it("should report agent registry spawn gate closed", { timeout: 120_000 }, async () => { + const { client: isolatedClient, home } = await createIsolatedStartedClient(); + try { + await expect( + isolatedClient.rpc.agentRegistry.spawn({ cwd: workDir }) + ).rejects.toSatisfy((error: unknown) => { + const message = formatError(error); + expect(message.toLowerCase()).not.toContain("unhandled method"); + expect(message.toLowerCase()).toContain("agentregistry.spawn"); + expect( + message.toLowerCase().includes("not enabled") || + message.toLowerCase().includes("no delegate") + ).toBe(true); + return true; + }); + } finally { + await disposeIsolated(isolatedClient, home); + } + }); + + it("should shut down owned runtime", { timeout: 120_000 }, async () => { + const dedicatedClient = createClient(); + try { + await dedicatedClient.start(); + await dedicatedClient.rpc.user.settings.reload(); + + await dedicatedClient.rpc.runtime.shutdown(); + + await waitForCondition( + async () => { + try { + await dedicatedClient.rpc.user.settings.reload(); + return false; + } catch { + return true; + } + }, + { + timeoutMs: 15_000, + intervalMs: 100, + timeoutMessage: "Runtime kept serving RPCs after a graceful shutdown.", + } + ); + } finally { + await forceStop(dedicatedClient); + } + }); + + it( + "should report not found when opening session without context", + { timeout: 120_000 }, + async () => { + const { client: isolatedClient, home } = await createIsolatedStartedClient(); + try { + const result = await isolatedClient.rpc.sessions.open({ kind: "resumeLast" }); + + expect(result.status).toBe("not_found"); + expect(result.sessionId ?? null).toBeNull(); + } finally { + await disposeIsolated(isolatedClient, home); + } + } + ); + + it( + "should reject send attachments from non extension connection", + { timeout: 120_000 }, + async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expect( + session.rpc.extensions.sendAttachmentsToMessage({ attachments: [] }) + ).rejects.toSatisfy((error: unknown) => { + const message = formatError(error); + expect(message.toLowerCase()).not.toContain("unhandled method"); + expect(message.toLowerCase()).toContain("extension"); + return true; + }); + } finally { + await session.disconnect(); + } + } + ); +}); diff --git a/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts new file mode 100644 index 000000000..c678c0543 --- /dev/null +++ b/nodejs/test/e2e/rpc_server_plugins.e2e.test.ts @@ -0,0 +1,331 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; + +const MARKETPLACE_NAME = "csharp-e2e-marketplace"; +const PLUGIN_NAME = "csharp-e2e-plugin"; +const DIRECT_PLUGIN_NAME = "csharp-e2e-direct"; + +describe("Server-scoped plugin RPC", async () => { + const { env, workDir } = await createSdkTestContext(); + + function createUniqueDirectory(prefix: string): string { + const directory = join(workDir, `${prefix}-${randomUUID()}`); + mkdirSync(directory, { recursive: true }); + return directory; + } + + function createClient(home: string): CopilotClient { + return new CopilotClient({ + workingDirectory: workDir, + env: { + ...env, + COPILOT_HOME: home, + GH_CONFIG_DIR: home, + XDG_CONFIG_HOME: home, + XDG_STATE_HOME: home, + }, + logLevel: "error", + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + gitHubToken: DEFAULT_GITHUB_TOKEN, + }); + } + + async function createIsolatedStartedClient(): Promise<{ + client: CopilotClient; + home: string; + }> { + const home = createUniqueDirectory("copilot-e2e-home"); + const client = createClient(home); + try { + await client.start(); + return { client, home }; + } catch (error) { + await disposeIsolated(client, home); + throw error; + } + } + + async function disposeIsolated( + client: CopilotClient, + home: string, + fixtureDir?: string + ): Promise { + try { + await client.forceStop(); + } catch { + // Best-effort cleanup. + } + tryRemoveDirectory(home); + if (fixtureDir) { + tryRemoveDirectory(fixtureDir); + } + } + + function tryRemoveDirectory(directory: string): void { + try { + rmSync(directory, { recursive: true, force: true }); + } catch { + // Temp directories are reclaimed by the harness/OS. + } + } + + function createLocalMarketplaceFixture(): string { + const directory = createUniqueDirectory("copilot-e2e-mp"); + const manifest = `{ + "name": "${MARKETPLACE_NAME}", + "owner": { "name": "Copilot SDK E2E" }, + "metadata": { "description": "Local marketplace fixture for SDK E2E tests." }, + "plugins": [ + { + "name": "${PLUGIN_NAME}", + "source": "./${PLUGIN_NAME}", + "description": "E2E demo plugin advertised by the local marketplace.", + "version": "1.0.0" + } + ] +} +`; + writeFileSync(join(directory, "marketplace.json"), manifest); + + const pluginDir = join(directory, PLUGIN_NAME); + mkdirSync(pluginDir, { recursive: true }); + writeSkillFile(pluginDir); + + return directory; + } + + function createDirectPluginFixture(): string { + const directory = createUniqueDirectory("copilot-e2e-plugin"); + const manifest = `{ + "name": "${DIRECT_PLUGIN_NAME}", + "description": "E2E demo plugin installed directly from a local path.", + "version": "1.0.0" +} +`; + writeFileSync(join(directory, "plugin.json"), manifest); + writeSkillFile(directory); + return directory; + } + + function writeSkillFile(pluginDir: string): void { + const skill = `--- +name: csharp-e2e-skill +description: A demo skill contributed by the E2E test plugin. +--- +# Demo Skill + +This skill exists so the plugin reports at least one installed skill. +`; + writeFileSync(join(pluginDir, "SKILL.md"), skill); + } + + it( + "should install list and uninstall plugin from local marketplace", + { timeout: 120_000 }, + async () => { + const marketplaceDir = createLocalMarketplaceFixture(); + const { client, home } = await createIsolatedStartedClient(); + try { + await client.rpc.plugins.marketplaces.add({ source: marketplaceDir }); + + const spec = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + const install = await client.rpc.plugins.install({ source: spec }); + + expect(install.plugin.name).toBe(PLUGIN_NAME); + expect(install.plugin.marketplace).toBe(MARKETPLACE_NAME); + expect(install.plugin.enabled).toBe(true); + expect(install.skillsInstalled).toBeGreaterThanOrEqual(1); + expect(install.deprecationWarning ?? null).toBeNull(); + + const afterInstall = await client.rpc.plugins.list(); + const listed = afterInstall.plugins.filter( + (plugin) => + plugin.name === PLUGIN_NAME && plugin.marketplace === MARKETPLACE_NAME + ); + expect(listed).toHaveLength(1); + expect(listed[0].enabled).toBe(true); + + await client.rpc.plugins.uninstall({ name: spec }); + + const afterUninstall = await client.rpc.plugins.list(); + expect( + afterUninstall.plugins.some( + (plugin) => + plugin.name === PLUGIN_NAME && plugin.marketplace === MARKETPLACE_NAME + ) + ).toBe(false); + } finally { + await disposeIsolated(client, home, marketplaceDir); + } + } + ); + + it("should enable and disable marketplace plugin", { timeout: 120_000 }, async () => { + const marketplaceDir = createLocalMarketplaceFixture(); + const { client, home } = await createIsolatedStartedClient(); + try { + const spec = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + await client.rpc.plugins.marketplaces.add({ source: marketplaceDir }); + await client.rpc.plugins.install({ source: spec }); + + await client.rpc.plugins.disable({ names: [spec] }); + expect(getPlugin(await client.rpc.plugins.list()).enabled).toBe(false); + + await client.rpc.plugins.enable({ names: [spec] }); + expect(getPlugin(await client.rpc.plugins.list()).enabled).toBe(true); + } finally { + await disposeIsolated(client, home, marketplaceDir); + } + }); + + it("should update single marketplace plugin", { timeout: 120_000 }, async () => { + const marketplaceDir = createLocalMarketplaceFixture(); + const { client, home } = await createIsolatedStartedClient(); + try { + const spec = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + await client.rpc.plugins.marketplaces.add({ source: marketplaceDir }); + await client.rpc.plugins.install({ source: spec }); + + const update = await client.rpc.plugins.update({ name: spec }); + + expect(update.skillsInstalled).toBeGreaterThanOrEqual(1); + expect(update.previousVersion).toBe("1.0.0"); + expect(update.newVersion).toBe("1.0.0"); + } finally { + await disposeIsolated(client, home, marketplaceDir); + } + }); + + it("should update all installed plugins", { timeout: 120_000 }, async () => { + const marketplaceDir = createLocalMarketplaceFixture(); + const { client, home } = await createIsolatedStartedClient(); + try { + const spec = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + await client.rpc.plugins.marketplaces.add({ source: marketplaceDir }); + await client.rpc.plugins.install({ source: spec }); + + const result = await client.rpc.plugins.updateAll(); + + const entries = result.results.filter( + (entry) => entry.name === PLUGIN_NAME && entry.marketplace === MARKETPLACE_NAME + ); + expect(entries).toHaveLength(1); + expect(entries[0].success).toBe(true); + expect(entries[0].skillsInstalled).toBeGreaterThanOrEqual(1); + } finally { + await disposeIsolated(client, home, marketplaceDir); + } + }); + + it( + "should install direct local plugin with deprecation warning", + { timeout: 120_000 }, + async () => { + const pluginDir = createDirectPluginFixture(); + const { client, home } = await createIsolatedStartedClient(); + try { + const install = await client.rpc.plugins.install({ source: pluginDir }); + + expect(install.plugin.name).toBe(DIRECT_PLUGIN_NAME); + expect(install.plugin.marketplace).toBe(""); + expect(install.deprecationWarning).toBeTruthy(); + expect(install.deprecationWarning?.toLowerCase()).toContain("deprecated"); + expect(install.skillsInstalled).toBeGreaterThanOrEqual(1); + + const afterInstall = await client.rpc.plugins.list(); + expect( + afterInstall.plugins.filter((plugin) => plugin.name === DIRECT_PLUGIN_NAME) + ).toHaveLength(1); + + await client.rpc.plugins.uninstall({ name: DIRECT_PLUGIN_NAME }); + + const afterUninstall = await client.rpc.plugins.list(); + expect( + afterUninstall.plugins.some((plugin) => plugin.name === DIRECT_PLUGIN_NAME) + ).toBe(false); + } finally { + await disposeIsolated(client, home, pluginDir); + } + } + ); + + it( + "should list browse refresh and remove local marketplace", + { timeout: 120_000 }, + async () => { + const marketplaceDir = createLocalMarketplaceFixture(); + const { client, home } = await createIsolatedStartedClient(); + try { + const add = await client.rpc.plugins.marketplaces.add({ source: marketplaceDir }); + expect(add.name).toBe(MARKETPLACE_NAME); + + const list = await client.rpc.plugins.marketplaces.list(); + const mine = list.marketplaces.filter( + (marketplace) => marketplace.name === MARKETPLACE_NAME + ); + expect(mine).toHaveLength(1); + expect(mine[0].isDefault).not.toBe(true); + expect( + list.marketplaces.some((marketplace) => marketplace.isDefault === true) + ).toBe(true); + + const browse = await client.rpc.plugins.marketplaces.browse({ + name: MARKETPLACE_NAME, + }); + const advertised = browse.plugins.filter((plugin) => plugin.name === PLUGIN_NAME); + expect(advertised).toHaveLength(1); + expect(advertised[0].description).toBeTruthy(); + + const refresh = await client.rpc.plugins.marketplaces.refresh({ + name: MARKETPLACE_NAME, + }); + const refreshed = refresh.results.filter( + (result) => result.name === MARKETPLACE_NAME + ); + expect(refreshed).toHaveLength(1); + expect(refreshed[0].success).toBe(true); + + const remove = await client.rpc.plugins.marketplaces.remove({ + name: MARKETPLACE_NAME, + }); + expect(remove.removed).toBe(true); + + const afterRemove = await client.rpc.plugins.marketplaces.list(); + expect( + afterRemove.marketplaces.some( + (marketplace) => marketplace.name === MARKETPLACE_NAME + ) + ).toBe(false); + } finally { + await disposeIsolated(client, home, marketplaceDir); + } + } + ); + + it("should reload mcp config cache", { timeout: 120_000 }, async () => { + const { client, home } = await createIsolatedStartedClient(); + try { + await client.rpc.mcp.config.reload(); + } finally { + await disposeIsolated(client, home); + } + }); + + function getPlugin(list: { + plugins: Array<{ name: string; marketplace: string; enabled: boolean }>; + }) { + const plugins = list.plugins.filter( + (plugin) => plugin.name === PLUGIN_NAME && plugin.marketplace === MARKETPLACE_NAME + ); + expect(plugins).toHaveLength(1); + return plugins[0]; + } +}); diff --git a/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts new file mode 100644 index 000000000..2e6c6cf05 --- /dev/null +++ b/nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { CopilotClient, RuntimeConnection } from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; +import { formatError } from "./harness/sdkTestHelper.js"; + +describe("Server-scoped remote-control RPC", async () => { + const { env, workDir } = await createSdkTestContext(); + + function createDedicatedClient(): CopilotClient { + return new CopilotClient({ + workingDirectory: workDir, + env, + logLevel: "error", + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + gitHubToken: DEFAULT_GITHUB_TOKEN, + }); + } + + async function forceStop(client: CopilotClient): Promise { + try { + await client.forceStop(); + } catch { + // Runtime may already be gone. + } + } + + function uniqueSessionId(prefix: string): string { + return `${prefix}-${randomUUID().replace(/-/g, "")}`; + } + + it("should report remote control status as off", { timeout: 120_000 }, async () => { + const client = createDedicatedClient(); + try { + await client.start(); + + const result = await client.rpc.sessions.getRemoteControlStatus(); + + expect(result.status.state).toBe("off"); + } finally { + await forceStop(client); + } + }); + + it("should treat set steering as no op when off", { timeout: 120_000 }, async () => { + const client = createDedicatedClient(); + try { + await client.start(); + + const result = await client.rpc.sessions.setRemoteControlSteering({ enabled: false }); + + expect(result.status.state).toBe("off"); + } finally { + await forceStop(client); + } + }); + + it("should report not stopped when remote control is off", { timeout: 120_000 }, async () => { + const client = createDedicatedClient(); + try { + await client.start(); + + const result = await client.rpc.sessions.stopRemoteControl({}); + + expect(result.stopped).toBe(false); + expect(result.status.state).toBe("off"); + } finally { + await forceStop(client); + } + }); + + it("should reject transfer when off with compare and swap", { timeout: 120_000 }, async () => { + const client = createDedicatedClient(); + try { + await client.start(); + + const result = await client.rpc.sessions.transferRemoteControl({ + toSessionId: uniqueSessionId("rc-to"), + expectedFromSessionId: uniqueSessionId("rc-from"), + }); + + expect(result.transferred).toBe(false); + expect(result.status.state).toBe("off"); + } finally { + await forceStop(client); + } + }); + + it( + "should reach runtime when starting remote control for unknown session", + { timeout: 120_000 }, + async () => { + const client = createDedicatedClient(); + try { + await client.start(); + + await expect( + client.rpc.sessions.startRemoteControl({ + sessionId: uniqueSessionId("missing-session"), + config: { + remote: false, + explicit: false, + silent: true, + steerable: false, + }, + }) + ).rejects.toSatisfy((error: unknown) => { + const message = formatError(error); + expect(message.toLowerCase()).not.toContain("unhandled method"); + expect( + message.toLowerCase().includes("session") || + message.toLowerCase().includes("remote") + ).toBe(true); + return true; + }); + } finally { + try { + await client.rpc.sessions.stopRemoteControl({ force: true }); + } catch { + // Best-effort reset. + } + await forceStop(client); + } + } + ); +}); diff --git a/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts new file mode 100644 index 000000000..4b0ff9ba1 --- /dev/null +++ b/nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import type { CopilotSession } from "../../src/index.js"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; +import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; + +describe("Session-scoped state extras RPC", async () => { + const { copilotClient: client, env, openAiEndpoint, workDir } = await createSdkTestContext(); + + function createClientWithEnv( + extraEnv: Record, + token = DEFAULT_GITHUB_TOKEN + ): CopilotClient { + return new CopilotClient({ + workingDirectory: workDir, + env: { + ...env, + ...extraEnv, + }, + logLevel: "error", + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + gitHubToken: token, + }); + } + + function createAuthenticatedClient(token: string): CopilotClient { + return createClientWithEnv( + { + COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, + }, + token + ); + } + + async function configureAuthenticatedUser(token: string): Promise { + await openAiEndpoint.setCopilotUserByToken(token, { + login: "rpc-session-extras-user", + copilot_plan: "individual_pro", + endpoints: { + api: env.COPILOT_API_URL, + telemetry: "https://localhost:1/telemetry", + }, + analytics_tracking_id: "rpc-session-extras-tracking-id", + }); + } + + async function createSession(): Promise { + return client.createSession({ onPermissionRequest: approveAll }); + } + + async function disconnect(session: CopilotSession | undefined): Promise { + if (!session) { + return; + } + try { + await session.disconnect(); + } catch { + // Best-effort cleanup. + } + } + + it("should list models for session", { timeout: 120_000 }, async () => { + const token = "rpc-session-model-list-token"; + await configureAuthenticatedUser(token); + const authClient = createAuthenticatedClient(token); + let session: CopilotSession | undefined; + try { + await authClient.start(); + session = await authClient.createSession({ + model: "claude-sonnet-4.5", + onPermissionRequest: approveAll, + }); + + const result = await session.rpc.model.list(); + + expect(Array.isArray(result.list)).toBe(true); + expect(result.list.length).toBeGreaterThan(0); + expect( + result.list.some((model) => JSON.stringify(model).includes("claude-sonnet-4.5")) + ).toBe(true); + } finally { + await disconnect(session); + try { + await authClient.forceStop(); + } catch { + // Best-effort cleanup. + } + } + }); + + it("should report session activity when idle", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const activity = await session.rpc.metadata.activity(); + + expect(activity.hasActiveWork).toBe(false); + expect(activity.abortable).toBe(false); + } finally { + await session.disconnect(); + } + }); + + it("should get and set allowall permissions", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const initial = await session.rpc.permissions.getAllowAll(); + expect(initial.enabled).toBe(false); + + const enable = await session.rpc.permissions.setAllowAll({ enabled: true }); + expect(enable.success).toBe(true); + expect(enable.enabled).toBe(true); + expect((await session.rpc.permissions.getAllowAll()).enabled).toBe(true); + + const disable = await session.rpc.permissions.setAllowAll({ enabled: false }); + expect(disable.success).toBe(true); + expect(disable.enabled).toBe(false); + expect((await session.rpc.permissions.getAllowAll()).enabled).toBe(false); + } finally { + try { + await session.rpc.permissions.setAllowAll({ enabled: false }); + } catch { + // Best-effort reset. + } + await session.disconnect(); + } + }); + + it("should read empty sql todos for fresh session", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const result = await session.rpc.plan.readSqlTodos(); + + expect(result.rows).toBeDefined(); + expect(result.rows).toEqual([]); + } finally { + await session.disconnect(); + } + }); + + it("should get telemetry engagement id", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const result = await session.rpc.telemetry.getEngagementId(); + + expect(result).toBeDefined(); + } finally { + await session.disconnect(); + } + }); + + it("should get current tool metadata after initialization", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + const answer = await session.sendAndWait({ prompt: "What is 2+2?" }); + expect(answer).toBeDefined(); + + const result = await session.rpc.tools.getCurrentMetadata(); + + expect(result.tools).not.toBeNull(); + expect(result.tools!.length).toBeGreaterThan(0); + for (const tool of result.tools!) { + expect(tool.name).toBeTruthy(); + expect(tool.description).toBeDefined(); + } + } finally { + await session.disconnect(); + } + }); + + it("should reload session plugins", { timeout: 120_000 }, async () => { + const session = await createSession(); + try { + await session.rpc.plugins.reload(); + + const plugins = await session.rpc.plugins.list(); + expect(plugins.plugins).toBeDefined(); + for (const plugin of plugins.plugins) { + expect(plugin.name).toBeTruthy(); + } + } finally { + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/rpc_shell_user_requested.e2e.test.ts b/nodejs/test/e2e/rpc_shell_user_requested.e2e.test.ts new file mode 100644 index 000000000..961771f78 --- /dev/null +++ b/nodejs/test/e2e/rpc_shell_user_requested.e2e.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +describe("User-requested shell RPC", async () => { + const { copilotClient: client, homeDir } = await createSdkTestContext(); + + function compactUuid(): string { + return randomUUID().replace(/-/g, ""); + } + + function quotePowerShell(value: string): string { + return `'${value.replace(/'/g, "''")}'`; + } + + function quoteSh(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; + } + + function createMarkerThenSleepCommand(markerPath: string, seconds: number): string { + if (process.platform === "win32") { + return `Set-Content -LiteralPath ${quotePowerShell(markerPath)} -Value 'running'; Start-Sleep -Seconds ${seconds}`; + } + return `echo running > ${quoteSh(markerPath)}; sleep ${seconds}`; + } + + async function waitForFileExists(filePath: string): Promise { + await waitForCondition(() => existsSync(filePath), { + timeoutMs: 30_000, + intervalMs: 100, + timeoutMessage: `Timed out waiting for the shell command to create '${filePath}'.`, + }); + } + + async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string + ): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + function tryDeleteFile(filePath: string): void { + try { + rmSync(filePath, { force: true }); + } catch { + // Best-effort cleanup. + } + } + + it("should execute user requested shell command", { timeout: 120_000 }, async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const marker = `copilotusershell${compactUuid()}`; + const requestId = `req-${compactUuid()}`; + + const result = await session.rpc.shell.executeUserRequested({ + requestId, + command: `echo ${marker}`, + }); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.output).toContain(marker); + expect(result.toolCallId).toBeTruthy(); + } finally { + await session.disconnect(); + } + }); + + it("should cancel user requested shell command", { timeout: 120_000 }, async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const markerPath = join(homeDir, `shell-cancel-${compactUuid()}.txt`); + let executeTask: + | Promise>> + | undefined; + let executeSettled = false; + try { + const missing = await session.rpc.shell.cancelUserRequested({ + requestId: `missing-${compactUuid()}`, + }); + expect(missing.cancelled).toBe(false); + + const requestId = `req-${compactUuid()}`; + executeTask = session.rpc.shell.executeUserRequested({ + requestId, + command: createMarkerThenSleepCommand(markerPath, 60), + }); + executeTask + .finally(() => { + executeSettled = true; + }) + .catch(() => {}); + executeTask.catch(() => {}); + + await waitForFileExists(markerPath); + + await waitForCondition( + async () => (await session.rpc.shell.cancelUserRequested({ requestId })).cancelled, + { + timeoutMs: 15_000, + intervalMs: 100, + timeoutMessage: + "Timed out waiting for the user-requested shell command to become cancellable.", + } + ); + + const result = await withTimeout( + executeTask, + 30_000, + "Timed out waiting for cancelled shell command to finish." + ); + expect(result.success).toBe(false); + } finally { + if (executeTask && !executeSettled) { + await withTimeout( + executeTask, + 30_000, + "Timed out draining cancelled shell command." + ).catch(() => {}); + } + tryDeleteFile(markerPath); + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/rpc_ui_ephemeral_query.e2e.test.ts b/nodejs/test/e2e/rpc_ui_ephemeral_query.e2e.test.ts new file mode 100644 index 000000000..662294d70 --- /dev/null +++ b/nodejs/test/e2e/rpc_ui_ephemeral_query.e2e.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("UI ephemeral query RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should answer ephemeral query", { timeout: 120_000 }, async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.ui.ephemeralQuery({ + question: "In one word, what is the primary color of a clear daytime sky?", + }); + + expect(result).toBeDefined(); + expect(result.answer.trim()).toBeTruthy(); + expect(result.answer.toLowerCase()).toContain("blue"); + } finally { + await session.disconnect(); + } + }); +}); diff --git a/python/e2e/conftest.py b/python/e2e/conftest.py index 35d05d101..7409cf556 100644 --- a/python/e2e/conftest.py +++ b/python/e2e/conftest.py @@ -1,5 +1,7 @@ """Shared pytest fixtures for e2e tests.""" +import os + import pytest import pytest_asyncio @@ -24,7 +26,8 @@ async def ctx(request): await context.setup() yield context any_failed = request.session.stash.get("any_test_failed", False) - await context.teardown(test_failed=any_failed) + skip_writing_cache = any_failed or bool(os.environ.get("GITHUB_ACTIONS")) + await context.teardown(test_failed=skip_writing_cache) @pytest_asyncio.fixture(autouse=True, loop_scope="module") diff --git a/python/e2e/test_rpc_mcp_lifecycle_e2e.py b/python/e2e/test_rpc_mcp_lifecycle_e2e.py new file mode 100644 index 000000000..090a3b07c --- /dev/null +++ b/python/e2e/test_rpc_mcp_lifecycle_e2e.py @@ -0,0 +1,279 @@ +""" +E2E coverage for session-scoped MCP lifecycle RPC methods. + +Mirrors ``dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs`` (snapshot category +``rpc_mcp_lifecycle``). +""" + +from __future__ import annotations + +import uuid +from pathlib import Path + +import pytest + +from copilot.rpc import ( + MCPConfigureGitHubRequest, + MCPIsServerRunningRequest, + MCPListToolsRequest, + MCPOauthRespondRequest, + MCPRegisterExternalClientRequest, + MCPReloadWithConfigRequest, + MCPRestartServerRequest, + MCPStartServerRequest, + MCPStopServerRequest, + MCPUnregisterExternalClientRequest, +) +from copilot.session import PermissionHandler +from copilot.session_events import McpServerStatus + +from .testharness import E2ETestContext, wait_for_condition + +pytestmark = pytest.mark.asyncio(loop_scope="module") + +TEST_MCP_SERVER = str( + (Path(__file__).parents[2] / "test" / "harness" / "test-mcp-server.mjs").resolve() +) +TEST_HARNESS_DIR = str((Path(__file__).parents[2] / "test" / "harness").resolve()) + + +def _test_mcp_servers(*server_names: str) -> dict[str, dict]: + return { + server_name: { + "command": "node", + "args": [TEST_MCP_SERVER], + "tools": ["*"], + "working_directory": TEST_HARNESS_DIR, + } + for server_name in server_names + } + + +def _wire_mcp_server_config() -> dict: + return { + "command": "node", + "args": [TEST_MCP_SERVER], + "tools": ["*"], + "cwd": TEST_HARNESS_DIR, + } + + +async def _wait_for_mcp_server_status( + session, + server_name: str, + expected_status: McpServerStatus = McpServerStatus.CONNECTED, +) -> None: + last_status = "" + + async def connected() -> bool: + nonlocal last_status + result = await session.rpc.mcp.list() + server = next((s for s in result.servers if s.name == server_name), None) + if server is not None: + last_status = server.status + if server is None: + last_status = "" + return False + return server.status == expected_status + + await wait_for_condition( + connected, + timeout=60.0, + poll_interval=0.2, + timeout_message=( + f"{server_name} did not reach {expected_status.value}; last status was {last_status}" + ), + ) + + +async def _wait_for_mcp_running(session, server_name: str, expected_running: bool) -> None: + async def matches() -> bool: + result = await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=server_name) + ) + return result.running is expected_running + + await wait_for_condition( + matches, + timeout=60.0, + poll_interval=0.2, + timeout_message=f"{server_name} running={expected_running}", + ) + + +def _assert_not_unhandled_method(message: str) -> None: + assert "Unhandled method".lower() not in message.lower() + + +class TestRpcMcpLifecycle: + async def test_should_list_tools_and_report_running_status_for_connected_server( + self, ctx: E2ETestContext + ): + server_name = "rpc-lifecycle-list-server" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(server_name), + ) as session: + await _wait_for_mcp_server_status(session, server_name) + + tools = await session.rpc.mcp.list_tools(MCPListToolsRequest(server_name=server_name)) + assert tools.tools is not None + assert len(tools.tools) > 0 + assert all((tool.name or "").strip() for tool in tools.tools) + + running = await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=server_name) + ) + assert running.running is True + + missing = await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=f"missing-{uuid.uuid4().hex}") + ) + assert missing.running is False + + async def test_should_throw_when_listing_tools_for_unconnected_server( + self, ctx: E2ETestContext + ): + server_name = "rpc-lifecycle-unconnected-host" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(server_name), + ) as session: + await _wait_for_mcp_server_status(session, server_name) + + with pytest.raises(Exception) as excinfo: + await session.rpc.mcp.list_tools( + MCPListToolsRequest(server_name=f"missing-{uuid.uuid4().hex}") + ) + message = str(excinfo.value) + _assert_not_unhandled_method(message) + assert "not connected" in message.lower() + + async def test_should_stop_running_mcp_server(self, ctx: E2ETestContext): + server_name = "rpc-lifecycle-stop-server" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(server_name), + ) as session: + await _wait_for_mcp_server_status(session, server_name) + assert ( + await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=server_name) + ) + ).running is True + + await session.rpc.mcp.stop_server(MCPStopServerRequest(server_name=server_name)) + + await _wait_for_mcp_running(session, server_name, expected_running=False) + + async def test_should_start_and_restart_mcp_server(self, ctx: E2ETestContext): + host_server = "rpc-lifecycle-host-server" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(host_server), + ) as session: + await _wait_for_mcp_server_status(session, host_server) + + started_server = "rpc-lifecycle-started-server" + config = _wire_mcp_server_config() + + await session.rpc.mcp.start_server( + MCPStartServerRequest(server_name=started_server, config=config) + ) + await _wait_for_mcp_running(session, started_server, expected_running=True) + + tools = await session.rpc.mcp.list_tools( + MCPListToolsRequest(server_name=started_server) + ) + assert len(tools.tools) > 0 + + await session.rpc.mcp.restart_server( + MCPRestartServerRequest(server_name=started_server, config=config) + ) + await _wait_for_mcp_running(session, started_server, expected_running=True) + + async def test_should_register_and_unregister_external_mcp_client(self, ctx: E2ETestContext): + host_server = "rpc-lifecycle-extclient-host" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(host_server), + ) as session: + await _wait_for_mcp_server_status(session, host_server) + + external_name = "rpc-lifecycle-external-client" + assert ( + await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=external_name) + ) + ).running is False + + await session.rpc.mcp.register_external_client( + MCPRegisterExternalClientRequest( + server_name=external_name, + client={"id": external_name}, + transport={"kind": "in-process"}, + config={"command": "noop"}, + ) + ) + assert ( + await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=external_name) + ) + ).running is True + + await session.rpc.mcp.unregister_external_client( + MCPUnregisterExternalClientRequest(server_name=external_name) + ) + assert ( + await session.rpc.mcp.is_server_running( + MCPIsServerRunningRequest(server_name=external_name) + ) + ).running is False + + async def test_should_reload_mcp_servers_with_config(self, ctx: E2ETestContext): + host_server = "rpc-lifecycle-reload-host" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(host_server), + ) as session: + await _wait_for_mcp_server_status(session, host_server) + + result = await session.rpc.mcp.reload_with_config( + MCPReloadWithConfigRequest( + config={"mcpServers": {}, "disabledServers": []}, + ) + ) + + assert result is not None + assert result.filtered_servers is not None + assert result.filtered_servers == [] + + async def test_should_configure_github_mcp_server(self, ctx: E2ETestContext): + host_server = "rpc-lifecycle-configure-host" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(host_server), + ) as session: + await _wait_for_mcp_server_status(session, host_server) + + result = await session.rpc.mcp.configure_git_hub( + MCPConfigureGitHubRequest(auth_info={"type": "api-key"}) + ) + + assert result is not None + assert result.changed is False + + async def test_should_respond_to_mcp_oauth_request_without_pending_request( + self, ctx: E2ETestContext + ): + host_server = "rpc-lifecycle-oauth-host" + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + mcp_servers=_test_mcp_servers(host_server), + ) as session: + await _wait_for_mcp_server_status(session, host_server) + + result = await session.rpc.mcp.oauth.respond( + MCPOauthRespondRequest(request_id=f"missing-{uuid.uuid4().hex}") + ) + assert result is not None diff --git a/python/e2e/test_rpc_server_misc_e2e.py b/python/e2e/test_rpc_server_misc_e2e.py new file mode 100644 index 000000000..6f3870224 --- /dev/null +++ b/python/e2e/test_rpc_server_misc_e2e.py @@ -0,0 +1,135 @@ +""" +E2E coverage for miscellaneous server-scoped RPC methods. + +Mirrors ``dotnet/test/E2E/RpcServerMiscE2ETests.cs`` (snapshot category +``rpc_server_misc``). +""" + +from __future__ import annotations + +import contextlib +import shutil +import uuid +from pathlib import Path + +import pytest + +from copilot import CopilotClient, RuntimeConnection +from copilot.rpc import ( + AgentRegistrySpawnRequest, + SendAttachmentsToMessageParams, + SessionsOpenResumeLast, + SessionsOpenStatus, +) +from copilot.session import PermissionHandler + +from .testharness import DEFAULT_GITHUB_TOKEN, E2ETestContext, wait_for_condition + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: + return CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token=DEFAULT_GITHUB_TOKEN, + ) + + +async def _create_isolated_client(ctx: E2ETestContext) -> 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) + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=env, + github_token=DEFAULT_GITHUB_TOKEN, + ) + await client.start() + return client, home + + +async def _stop_client(client: CopilotClient) -> None: + with contextlib.suppress(ExceptionGroup, Exception): + await client.stop() + + +async def _dispose_isolated(client: CopilotClient, home: Path) -> None: + await _stop_client(client) + with contextlib.suppress(OSError): + shutil.rmtree(home, ignore_errors=True) + + +class TestRpcServerMisc: + async def test_should_reload_user_settings(self, ctx: E2ETestContext): + await ctx.client.start() + + await ctx.client.rpc.user.settings.reload() + + async def test_should_report_agent_registry_spawn_gate_closed(self, ctx: E2ETestContext): + client, home = await _create_isolated_client(ctx) + try: + with pytest.raises(Exception) as excinfo: + await client.rpc.agent_registry.spawn(AgentRegistrySpawnRequest(cwd=ctx.work_dir)) + + message = str(excinfo.value) + assert "Unhandled method".lower() not in message.lower() + assert "agentRegistry.spawn".lower() in message.lower() + assert "not enabled" in message.lower() or "no delegate" in message.lower(), message + finally: + await _dispose_isolated(client, home) + + async def test_should_shut_down_owned_runtime(self, ctx: E2ETestContext): + client = _create_dedicated_client(ctx) + try: + await client.start() + await client.rpc.user.settings.reload() + + await client.rpc.runtime.shutdown() + + async def stopped_serving() -> bool: + try: + await client.rpc.user.settings.reload(timeout=1.0) + return False + except Exception: + return True + + await wait_for_condition( + stopped_serving, + timeout=15.0, + poll_interval=0.1, + timeout_message="Runtime kept serving RPCs after a graceful shutdown.", + ) + finally: + await _stop_client(client) + + async def test_should_report_not_found_when_opening_session_without_context( + self, ctx: E2ETestContext + ): + client, home = await _create_isolated_client(ctx) + try: + result = await client.rpc.sessions.open(SessionsOpenResumeLast()) + + assert result.status == SessionsOpenStatus.NOT_FOUND + assert result.session_id is None + finally: + await _dispose_isolated(client, home) + + async def test_should_reject_send_attachments_from_non_extension_connection( + self, ctx: E2ETestContext + ): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + with pytest.raises(Exception) as excinfo: + await session.rpc.extensions.send_attachments_to_message( + SendAttachmentsToMessageParams(attachments=[]) + ) + + message = str(excinfo.value) + assert "Unhandled method".lower() not in message.lower() + assert "extension" in message.lower() diff --git a/python/e2e/test_rpc_server_plugins_e2e.py b/python/e2e/test_rpc_server_plugins_e2e.py new file mode 100644 index 000000000..a325242e9 --- /dev/null +++ b/python/e2e/test_rpc_server_plugins_e2e.py @@ -0,0 +1,296 @@ +""" +E2E coverage for server-scoped plugin and marketplace RPC methods. + +Mirrors ``dotnet/test/E2E/RpcServerPluginsE2ETests.cs`` (snapshot +category ``rpc_server_plugins``). +""" + +from __future__ import annotations + +import contextlib +import shutil +import uuid +from pathlib import Path + +import pytest + +from copilot import CopilotClient, RuntimeConnection +from copilot.rpc import ( + PluginsDisableRequest, + PluginsEnableRequest, + PluginsInstallRequest, + PluginsMarketplacesAddRequest, + PluginsMarketplacesBrowseRequest, + PluginsMarketplacesRefreshRequest, + PluginsMarketplacesRemoveRequest, + PluginsUninstallRequest, + PluginsUpdateRequest, +) + +from .testharness import DEFAULT_GITHUB_TOKEN, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + +MARKETPLACE_NAME = "csharp-e2e-marketplace" +PLUGIN_NAME = "csharp-e2e-plugin" +DIRECT_PLUGIN_NAME = "csharp-e2e-direct" + + +def _write_skill_file(plugin_dir: Path) -> None: + skill = """--- +name: csharp-e2e-skill +description: A demo skill contributed by the E2E test plugin. +--- +# Demo Skill + +This skill exists so the plugin reports at least one installed skill. +""" + (plugin_dir / "SKILL.md").write_text(skill, encoding="utf-8", newline="\n") + + +def _create_local_marketplace_fixture(ctx: E2ETestContext) -> Path: + directory = Path(ctx.work_dir) / f"copilot-e2e-mp-{uuid.uuid4().hex}" + directory.mkdir(parents=True) + manifest = f"""{{ + "name": "{MARKETPLACE_NAME}", + "owner": {{ "name": "Copilot SDK E2E" }}, + "metadata": {{ "description": "Local marketplace fixture for SDK E2E tests." }}, + "plugins": [ + {{ + "name": "{PLUGIN_NAME}", + "source": "./{PLUGIN_NAME}", + "description": "E2E demo plugin advertised by the local marketplace.", + "version": "1.0.0" + }} + ] +}} +""" + (directory / "marketplace.json").write_text(manifest, encoding="utf-8", newline="\n") + plugin_dir = directory / PLUGIN_NAME + plugin_dir.mkdir() + _write_skill_file(plugin_dir) + return directory + + +def _create_direct_plugin_fixture(ctx: E2ETestContext) -> Path: + directory = Path(ctx.work_dir) / f"copilot-e2e-plugin-{uuid.uuid4().hex}" + directory.mkdir(parents=True) + manifest = f"""{{ + "name": "{DIRECT_PLUGIN_NAME}", + "description": "E2E demo plugin installed directly from a local path.", + "version": "1.0.0" +}} +""" + (directory / "plugin.json").write_text(manifest, encoding="utf-8", newline="\n") + _write_skill_file(directory) + return directory + + +async def _create_isolated_client(ctx: E2ETestContext) -> tuple[CopilotClient, Path]: + home = Path(ctx.work_dir) / f"copilot-e2e-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) + client = CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=env, + github_token=DEFAULT_GITHUB_TOKEN, + ) + await client.start() + return client, home + + +async def _dispose_isolated(client: CopilotClient, home: Path, fixture_dir: Path | None) -> None: + with contextlib.suppress(ExceptionGroup): + await client.stop() + with contextlib.suppress(OSError): + shutil.rmtree(home, ignore_errors=True) + if fixture_dir is not None: + with contextlib.suppress(OSError): + shutil.rmtree(fixture_dir, ignore_errors=True) + + +class TestRpcServerPlugins: + async def test_should_install_list_and_uninstall_plugin_from_local_marketplace( + self, ctx: E2ETestContext + ): + marketplace_dir = _create_local_marketplace_fixture(ctx) + client, home = await _create_isolated_client(ctx) + try: + await client.rpc.plugins.marketplaces.add( + PluginsMarketplacesAddRequest(source=str(marketplace_dir)) + ) + + spec = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + install = await client.rpc.plugins.install(PluginsInstallRequest(source=spec)) + + assert install.plugin.name == PLUGIN_NAME + assert install.plugin.marketplace == MARKETPLACE_NAME + assert install.plugin.enabled is True + assert install.skills_installed >= 1 + assert install.deprecation_warning is None + + after_install = await client.rpc.plugins.list() + listed = [ + p + for p in after_install.plugins + if p.name == PLUGIN_NAME and p.marketplace == MARKETPLACE_NAME + ] + assert len(listed) == 1 + assert listed[0].enabled is True + + await client.rpc.plugins.uninstall(PluginsUninstallRequest(name=spec)) + + after_uninstall = await client.rpc.plugins.list() + assert not any( + p.name == PLUGIN_NAME and p.marketplace == MARKETPLACE_NAME + for p in after_uninstall.plugins + ) + finally: + await _dispose_isolated(client, home, marketplace_dir) + + async def test_should_enable_and_disable_marketplace_plugin(self, ctx: E2ETestContext): + marketplace_dir = _create_local_marketplace_fixture(ctx) + client, home = await _create_isolated_client(ctx) + try: + spec = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + await client.rpc.plugins.marketplaces.add( + PluginsMarketplacesAddRequest(source=str(marketplace_dir)) + ) + await client.rpc.plugins.install(PluginsInstallRequest(source=spec)) + + await client.rpc.plugins.disable(PluginsDisableRequest(names=[spec])) + assert _single_marketplace_plugin(await client.rpc.plugins.list()).enabled is False + + await client.rpc.plugins.enable(PluginsEnableRequest(names=[spec])) + assert _single_marketplace_plugin(await client.rpc.plugins.list()).enabled is True + finally: + await _dispose_isolated(client, home, marketplace_dir) + + async def test_should_update_single_marketplace_plugin(self, ctx: E2ETestContext): + marketplace_dir = _create_local_marketplace_fixture(ctx) + client, home = await _create_isolated_client(ctx) + try: + spec = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + await client.rpc.plugins.marketplaces.add( + PluginsMarketplacesAddRequest(source=str(marketplace_dir)) + ) + await client.rpc.plugins.install(PluginsInstallRequest(source=spec)) + + update = await client.rpc.plugins.update(PluginsUpdateRequest(name=spec)) + + assert update.skills_installed >= 1 + assert update.previous_version == "1.0.0" + assert update.new_version == "1.0.0" + finally: + await _dispose_isolated(client, home, marketplace_dir) + + async def test_should_update_all_installed_plugins(self, ctx: E2ETestContext): + marketplace_dir = _create_local_marketplace_fixture(ctx) + client, home = await _create_isolated_client(ctx) + try: + spec = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + await client.rpc.plugins.marketplaces.add( + PluginsMarketplacesAddRequest(source=str(marketplace_dir)) + ) + await client.rpc.plugins.install(PluginsInstallRequest(source=spec)) + + result = await client.rpc.plugins.update_all() + + entries = [ + r + for r in result.results + if r.name == PLUGIN_NAME and r.marketplace == MARKETPLACE_NAME + ] + assert len(entries) == 1 + entry = entries[0] + assert entry.success is True, entry.error + assert entry.skills_installed is not None and entry.skills_installed >= 1 + finally: + await _dispose_isolated(client, home, marketplace_dir) + + async def test_should_install_direct_local_plugin_with_deprecation_warning( + self, ctx: E2ETestContext + ): + plugin_dir = _create_direct_plugin_fixture(ctx) + client, home = await _create_isolated_client(ctx) + try: + install = await client.rpc.plugins.install( + PluginsInstallRequest(source=str(plugin_dir)) + ) + + assert install.plugin.name == DIRECT_PLUGIN_NAME + assert install.plugin.marketplace == "" + assert install.deprecation_warning is not None + assert "deprecated" in install.deprecation_warning.lower() + assert install.skills_installed >= 1 + + after_install = await client.rpc.plugins.list() + assert len([p for p in after_install.plugins if p.name == DIRECT_PLUGIN_NAME]) == 1 + + await client.rpc.plugins.uninstall(PluginsUninstallRequest(name=DIRECT_PLUGIN_NAME)) + + after_uninstall = await client.rpc.plugins.list() + assert not any(p.name == DIRECT_PLUGIN_NAME for p in after_uninstall.plugins) + finally: + await _dispose_isolated(client, home, plugin_dir) + + async def test_should_list_browse_refresh_and_remove_local_marketplace( + self, ctx: E2ETestContext + ): + marketplace_dir = _create_local_marketplace_fixture(ctx) + client, home = await _create_isolated_client(ctx) + try: + add = await client.rpc.plugins.marketplaces.add( + PluginsMarketplacesAddRequest(source=str(marketplace_dir)) + ) + assert add.name == MARKETPLACE_NAME + + marketplaces = await client.rpc.plugins.marketplaces.list() + mine = [m for m in marketplaces.marketplaces if m.name == MARKETPLACE_NAME] + assert len(mine) == 1 + assert mine[0].is_default is not True + assert any(m.is_default is True for m in marketplaces.marketplaces) + + browse = await client.rpc.plugins.marketplaces.browse( + PluginsMarketplacesBrowseRequest(name=MARKETPLACE_NAME) + ) + advertised = [p for p in browse.plugins if p.name == PLUGIN_NAME] + assert len(advertised) == 1 + assert (advertised[0].description or "").strip() + + refresh = await client.rpc.plugins.marketplaces.refresh( + PluginsMarketplacesRefreshRequest(name=MARKETPLACE_NAME) + ) + refreshed = [r for r in refresh.results if r.name == MARKETPLACE_NAME] + assert len(refreshed) == 1 + assert refreshed[0].success is True, refreshed[0].error + + remove = await client.rpc.plugins.marketplaces.remove( + PluginsMarketplacesRemoveRequest(name=MARKETPLACE_NAME) + ) + assert remove.removed is True + + after_remove = await client.rpc.plugins.marketplaces.list() + assert not any(m.name == MARKETPLACE_NAME for m in after_remove.marketplaces) + finally: + await _dispose_isolated(client, home, marketplace_dir) + + async def test_should_reload_mcp_config_cache(self, ctx: E2ETestContext): + client, home = await _create_isolated_client(ctx) + try: + await client.rpc.mcp.config.reload() + finally: + await _dispose_isolated(client, home, None) + + +def _single_marketplace_plugin(plugin_list): + plugins = [ + p + for p in plugin_list.plugins + if p.name == PLUGIN_NAME and p.marketplace == MARKETPLACE_NAME + ] + assert len(plugins) == 1 + return plugins[0] diff --git a/python/e2e/test_rpc_server_remote_control_e2e.py b/python/e2e/test_rpc_server_remote_control_e2e.py new file mode 100644 index 000000000..0fe2cc1b3 --- /dev/null +++ b/python/e2e/test_rpc_server_remote_control_e2e.py @@ -0,0 +1,130 @@ +""" +E2E coverage for server-scoped remote-control RPC methods. + +Mirrors ``dotnet/test/E2E/RpcServerRemoteControlE2ETests.cs`` (snapshot +category ``rpc_server_remote_control``). +""" + +from __future__ import annotations + +import contextlib +import uuid + +import pytest + +from copilot import CopilotClient, RuntimeConnection +from copilot.rpc import ( + RemoteControlConfig, + RemoteControlStatusOff, + SessionsSetRemoteControlSteeringRequest, + SessionsStartRemoteControlRequest, + SessionsStopRemoteControlRequest, + SessionsTransferRemoteControlRequest, +) + +from .testharness import DEFAULT_GITHUB_TOKEN, E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _create_dedicated_client(ctx: E2ETestContext) -> CopilotClient: + return CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=ctx.get_env(), + github_token=DEFAULT_GITHUB_TOKEN, + ) + + +async def _stop_client(client: CopilotClient) -> None: + with contextlib.suppress(ExceptionGroup): + await client.stop() + + +class TestRpcServerRemoteControl: + async def test_should_report_remote_control_status_as_off(self, ctx: E2ETestContext): + client = _create_dedicated_client(ctx) + try: + await client.start() + + result = await client.rpc.sessions.get_remote_control_status() + + assert isinstance(result.status, RemoteControlStatusOff) + assert result.status.state == "off" + finally: + await _stop_client(client) + + async def test_should_treat_set_steering_as_no_op_when_off(self, ctx: E2ETestContext): + client = _create_dedicated_client(ctx) + try: + await client.start() + + result = await client.rpc.sessions.set_remote_control_steering( + SessionsSetRemoteControlSteeringRequest(enabled=False) + ) + + assert isinstance(result.status, RemoteControlStatusOff) + finally: + await _stop_client(client) + + async def test_should_report_not_stopped_when_remote_control_is_off(self, ctx: E2ETestContext): + client = _create_dedicated_client(ctx) + try: + await client.start() + + result = await client.rpc.sessions.stop_remote_control( + SessionsStopRemoteControlRequest() + ) + + assert result.stopped is False + assert isinstance(result.status, RemoteControlStatusOff) + finally: + await _stop_client(client) + + async def test_should_reject_transfer_when_off_with_compare_and_swap(self, ctx: E2ETestContext): + client = _create_dedicated_client(ctx) + try: + await client.start() + + result = await client.rpc.sessions.transfer_remote_control( + SessionsTransferRemoteControlRequest( + to_session_id=f"rc-to-{uuid.uuid4().hex}", + expected_from_session_id=f"rc-from-{uuid.uuid4().hex}", + ) + ) + + assert result.transferred is False + assert isinstance(result.status, RemoteControlStatusOff) + finally: + await _stop_client(client) + + async def test_should_reach_runtime_when_starting_remote_control_for_unknown_session( + self, ctx: E2ETestContext + ): + client = _create_dedicated_client(ctx) + try: + await client.start() + + try: + with pytest.raises(Exception) as excinfo: + await client.rpc.sessions.start_remote_control( + SessionsStartRemoteControlRequest( + session_id=f"missing-session-{uuid.uuid4().hex}", + config=RemoteControlConfig( + explicit=False, + remote=False, + silent=True, + steerable=False, + ), + ) + ) + message = str(excinfo.value) + assert "Unhandled method".lower() not in message.lower() + assert "session" in message.lower() or "remote" in message.lower(), message + finally: + with contextlib.suppress(Exception): + await client.rpc.sessions.stop_remote_control( + SessionsStopRemoteControlRequest(force=True) + ) + finally: + await _stop_client(client) diff --git a/python/e2e/test_rpc_session_state_extras_e2e.py b/python/e2e/test_rpc_session_state_extras_e2e.py new file mode 100644 index 000000000..fb1215784 --- /dev/null +++ b/python/e2e/test_rpc_session_state_extras_e2e.py @@ -0,0 +1,152 @@ +""" +E2E coverage for additional session-scoped RPC methods. + +Mirrors ``dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs`` (snapshot +category ``rpc_session_state_extras``). +""" + +from __future__ import annotations + +import contextlib +import json + +import pytest + +from copilot import CopilotClient, RuntimeConnection +from copilot.rpc import PermissionsSetAllowAllRequest +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: + env = ctx.get_env() + env["COPILOT_DEBUG_GITHUB_API_URL"] = ctx.proxy_url + return CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=env, + github_token=token, + ) + + +async def _configure_user(ctx: E2ETestContext, token: str) -> None: + await ctx.set_copilot_user_by_token( + token, + { + "login": "rpc-session-extras-user", + "copilot_plan": "individual_pro", + "endpoints": { + "api": ctx.proxy_url, + "telemetry": "https://localhost:1/telemetry", + }, + "analytics_tracking_id": "rpc-session-extras-tracking-id", + }, + ) + + +async def _stop_client(client: CopilotClient) -> None: + with contextlib.suppress(ExceptionGroup): + await client.stop() + + +class TestRpcSessionStateExtras: + async def test_should_list_models_for_session(self, ctx: E2ETestContext): + token = "rpc-session-model-list-token" + await _configure_user(ctx, token) + client = _make_authed_client(ctx, token) + try: + async with await client.create_session( + model="claude-sonnet-4.5", + on_permission_request=PermissionHandler.approve_all, + github_token=token, + ) as session: + result = await session.rpc.model.list() + + assert result.list is not None + assert len(result.list) > 0 + assert any( + "claude-sonnet-4.5" in json.dumps(model, sort_keys=True) + for model in result.list + ) + finally: + await _stop_client(client) + + async def test_should_report_session_activity_when_idle(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + activity = await session.rpc.metadata.activity() + + assert activity.has_active_work is False + assert activity.abortable is False + + 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, + ) as session: + try: + initial = await session.rpc.permissions.get_allow_all() + assert initial.enabled is False + + enable = await session.rpc.permissions.set_allow_all( + PermissionsSetAllowAllRequest(enabled=True) + ) + assert enable.success is True + assert enable.enabled is True + assert (await session.rpc.permissions.get_allow_all()).enabled is True + + disable = await session.rpc.permissions.set_allow_all( + PermissionsSetAllowAllRequest(enabled=False) + ) + assert disable.success is True + assert disable.enabled is False + assert (await session.rpc.permissions.get_allow_all()).enabled is False + finally: + with contextlib.suppress(Exception): + await session.rpc.permissions.set_allow_all( + PermissionsSetAllowAllRequest(enabled=False) + ) + + 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, + ) as session: + result = await session.rpc.plan.read_sql_todos() + + assert result.rows is not None + assert result.rows == [] + + async def test_should_get_telemetry_engagement_id(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + result = await session.rpc.telemetry.get_engagement_id() + + assert result is not None + + async def test_should_get_current_tool_metadata_after_initialization(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + answer = await session.send_and_wait("What is 2+2?", timeout=60.0) + assert answer is not None + + result = await session.rpc.tools.get_current_metadata() + + assert result.tools is not None + assert len(result.tools) > 0 + assert all((tool.name or "").strip() for tool in result.tools) + assert all(tool.description is not None for tool in result.tools) + + async def test_should_reload_session_plugins(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + await session.rpc.plugins.reload() + + plugins = await session.rpc.plugins.list() + assert plugins.plugins is not None + assert all((plugin.name or "").strip() for plugin in plugins.plugins) diff --git a/python/e2e/test_rpc_shell_user_requested_e2e.py b/python/e2e/test_rpc_shell_user_requested_e2e.py new file mode 100644 index 000000000..11775e5fc --- /dev/null +++ b/python/e2e/test_rpc_shell_user_requested_e2e.py @@ -0,0 +1,122 @@ +""" +E2E coverage for session-scoped user-requested shell RPC methods. + +Mirrors ``dotnet/test/E2E/RpcShellUserRequestedE2ETests.cs`` (snapshot +category ``rpc_shell_user_requested``). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import sys +import uuid +from pathlib import Path + +import pytest + +from copilot.rpc import ShellCancelUserRequestedRequest, ShellExecuteUserRequestedRequest +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext, wait_for_condition + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _create_marker_then_sleep_command(marker_path: Path, seconds: int) -> str: + if sys.platform == "win32": + return ( + f"Set-Content -LiteralPath '{marker_path}' -Value 'running'; " + f"Start-Sleep -Seconds {seconds}" + ) + return f"printf '%s' running > '{marker_path}'; sleep {seconds}" + + +class TestRpcShellUserRequested: + async def test_should_execute_user_requested_shell_command(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + marker = f"copilotusershell{uuid.uuid4().hex}" + request_id = f"req-{uuid.uuid4().hex}" + + result = await session.rpc.shell.execute_user_requested( + ShellExecuteUserRequestedRequest(command=f"echo {marker}", request_id=request_id) + ) + + assert result.success is True, f"Expected success. Error: {result.error}" + assert result.exit_code == 0 + assert marker in result.output + assert (result.tool_call_id or "").strip() + + async def test_should_cancel_user_requested_shell_command(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + missing = await session.rpc.shell.cancel_user_requested( + ShellCancelUserRequestedRequest(request_id=f"missing-{uuid.uuid4().hex}") + ) + assert missing.cancelled is False + + request_id = f"req-{uuid.uuid4().hex}" + marker_path = Path(ctx.home_dir) / f"shell-cancel-{uuid.uuid4().hex}.txt" + execute_task = asyncio.create_task( + session.rpc.shell.execute_user_requested( + ShellExecuteUserRequestedRequest( + request_id=request_id, + command=_create_marker_then_sleep_command(marker_path, seconds=60), + ) + ) + ) + + try: + await wait_for_condition( + marker_path.exists, + timeout=30.0, + poll_interval=0.1, + timeout_message=( + f"Timed out waiting for the shell command to create '{marker_path}'." + ), + ) + + async def cancel_took_effect() -> bool: + result = await session.rpc.shell.cancel_user_requested( + ShellCancelUserRequestedRequest(request_id=request_id) + ) + return result.cancelled + + await wait_for_condition( + cancel_took_effect, + timeout=15.0, + poll_interval=0.1, + timeout_message=( + "Timed out waiting for the user-requested shell command " + "to become cancellable." + ), + ) + + await wait_for_condition( + execute_task.done, + timeout=30.0, + poll_interval=0.1, + timeout_message="Timed out waiting for cancelled shell command to finish.", + ) + result = await execute_task + assert result.success is False + finally: + if not execute_task.done(): + with contextlib.suppress(Exception): + await session.rpc.shell.cancel_user_requested( + ShellCancelUserRequestedRequest(request_id=request_id) + ) + with contextlib.suppress(Exception): + await wait_for_condition( + execute_task.done, + timeout=30.0, + poll_interval=0.1, + timeout_message="Timed out draining shell command task.", + ) + if not execute_task.done(): + execute_task.cancel() + with contextlib.suppress(OSError): + marker_path.unlink(missing_ok=True) diff --git a/python/e2e/test_rpc_ui_ephemeral_query_e2e.py b/python/e2e/test_rpc_ui_ephemeral_query_e2e.py new file mode 100644 index 000000000..117fe083d --- /dev/null +++ b/python/e2e/test_rpc_ui_ephemeral_query_e2e.py @@ -0,0 +1,33 @@ +""" +E2E coverage for session-scoped UI ephemeral query RPC. + +Mirrors ``dotnet/test/E2E/RpcUiEphemeralQueryE2ETests.cs`` (snapshot +category ``rpc_ui_ephemeral_query``). +""" + +from __future__ import annotations + +import pytest + +from copilot.rpc import UIEphemeralQueryRequest +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestRpcUiEphemeralQuery: + async def test_should_answer_ephemeral_query(self, ctx: E2ETestContext): + async with await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) as session: + result = await session.rpc.ui.ephemeral_query( + UIEphemeralQueryRequest( + question="In one word, what is the primary color of a clear daytime sky?" + ) + ) + + assert result is not None + assert (result.answer or "").strip() + assert "blue" in result.answer.lower() diff --git a/python/e2e/testharness/__init__.py b/python/e2e/testharness/__init__.py index 28558d687..83f548eaa 100644 --- a/python/e2e/testharness/__init__.py +++ b/python/e2e/testharness/__init__.py @@ -1,7 +1,7 @@ """Test harness for E2E tests.""" from .context import CLI_PATH, DEFAULT_GITHUB_TOKEN, E2ETestContext -from .helper import get_final_assistant_message, get_next_event_of_type +from .helper import get_final_assistant_message, get_next_event_of_type, wait_for_condition from .proxy import CapiProxy __all__ = [ @@ -11,4 +11,5 @@ "CapiProxy", "get_final_assistant_message", "get_next_event_of_type", + "wait_for_condition", ] diff --git a/python/e2e/testharness/helper.py b/python/e2e/testharness/helper.py index 0c85a0316..7933dd9ec 100644 --- a/python/e2e/testharness/helper.py +++ b/python/e2e/testharness/helper.py @@ -3,7 +3,10 @@ """ import asyncio +import inspect import os +import time +from collections.abc import Awaitable, Callable from copilot import CopilotSession from copilot.session_events import ( @@ -139,6 +142,31 @@ def read_file(work_dir: str, filename: str) -> str: return f.read() +async def wait_for_condition( + condition: Callable[[], bool | Awaitable[bool]], + *, + timeout: float = 120.0, + poll_interval: float = 0.1, + timeout_message: str = "Timed out waiting for condition.", +) -> None: + """Poll until condition returns true, with timeout only as a failsafe.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = condition() + if inspect.isawaitable(result): + result = await result + if result: + return + await asyncio.sleep(poll_interval) + + result = condition() + if inspect.isawaitable(result): + result = await result + if result: + return + raise TimeoutError(timeout_message) + + async def get_next_event_of_type(session: CopilotSession, event_type: str, timeout: float = 30.0): """ Wait for and return the next event of a specific type from a session. diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index b24a647cd..40bb5adb7 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -63,6 +63,8 @@ mod rpc_event_side_effects; mod rpc_mcp_and_skills; #[path = "e2e/rpc_mcp_config.rs"] mod rpc_mcp_config; +#[path = "e2e/rpc_mcp_lifecycle.rs"] +mod rpc_mcp_lifecycle; #[path = "e2e/rpc_queue.rs"] mod rpc_queue; #[path = "e2e/rpc_remote.rs"] @@ -71,14 +73,26 @@ mod rpc_remote; mod rpc_schedule; #[path = "e2e/rpc_server.rs"] mod rpc_server; +#[path = "e2e/rpc_server_misc.rs"] +mod rpc_server_misc; +#[path = "e2e/rpc_server_plugins.rs"] +mod rpc_server_plugins; +#[path = "e2e/rpc_server_remote_control.rs"] +mod rpc_server_remote_control; #[path = "e2e/rpc_session_state.rs"] mod rpc_session_state; +#[path = "e2e/rpc_session_state_extras.rs"] +mod rpc_session_state_extras; #[path = "e2e/rpc_shell_and_fleet.rs"] mod rpc_shell_and_fleet; #[path = "e2e/rpc_shell_edge_cases.rs"] mod rpc_shell_edge_cases; +#[path = "e2e/rpc_shell_user_requested.rs"] +mod rpc_shell_user_requested; #[path = "e2e/rpc_tasks_and_handlers.rs"] mod rpc_tasks_and_handlers; +#[path = "e2e/rpc_ui_ephemeral_query.rs"] +mod rpc_ui_ephemeral_query; #[path = "e2e/rpc_workspace_checkpoints.rs"] mod rpc_workspace_checkpoints; #[path = "e2e/session.rs"] diff --git a/rust/tests/e2e/rpc_mcp_lifecycle.rs b/rust/tests/e2e/rpc_mcp_lifecycle.rs new file mode 100644 index 000000000..fc7982832 --- /dev/null +++ b/rust/tests/e2e/rpc_mcp_lifecycle.rs @@ -0,0 +1,462 @@ +use std::collections::HashMap; +use std::path::Path; + +use github_copilot_sdk::rpc::{ + McpConfigureGitHubResult, McpIsServerRunningRequest, McpListToolsRequest, + McpStartServersResult, McpStopServerRequest, +}; +use github_copilot_sdk::session::Session; +use github_copilot_sdk::session_events::McpServerStatus; +use github_copilot_sdk::{Error, McpServerConfig, McpStdioServerConfig}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_list_tools_and_report_running_status_for_connected_server() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_list_tools_and_report_running_status_for_connected_server", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let server_name = "rpc-lifecycle-list-server"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), server_name), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Connected).await; + + let tools = session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { + server_name: server_name.to_string(), + }) + .await + .expect("list MCP tools"); + assert!(!tools.tools.is_empty()); + assert!(tools.tools.iter().all(|tool| !tool.name.trim().is_empty())); + + assert!(is_mcp_server_running(&session, server_name).await); + assert!( + !is_mcp_server_running( + &session, + &format!("missing-{}", uuid::Uuid::new_v4().simple()) + ) + .await + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_throw_when_listing_tools_for_unconnected_server() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_throw_when_listing_tools_for_unconnected_server", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let server_name = "rpc-lifecycle-unconnected-host"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), server_name), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Connected).await; + + let err = session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { + server_name: format!("missing-{}", uuid::Uuid::new_v4().simple()), + }) + .await + .expect_err("missing server should fail"); + assert_error_contains(&err, "not connected"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_stop_running_mcp_server() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_stop_running_mcp_server", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let server_name = "rpc-lifecycle-stop-server"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), server_name), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, server_name, McpServerStatus::Connected).await; + assert!(is_mcp_server_running(&session, server_name).await); + + session + .rpc() + .mcp() + .stop_server(McpStopServerRequest { + server_name: server_name.to_string(), + }) + .await + .expect("stop MCP server"); + + wait_for_mcp_running(&session, server_name, false).await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_start_and_restart_mcp_server() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_start_and_restart_mcp_server", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let host_server = "rpc-lifecycle-host-server"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), host_server), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, host_server, McpServerStatus::Connected).await; + + let started_server = "rpc-lifecycle-started-server"; + let config = test_mcp_server_config(ctx.repo_root()); + let config_value = serde_json::to_value(&config).expect("serialize MCP config"); + call_session_rpc( + &session, + "session.mcp.startServer", + json!({ "serverName": started_server, "config": config_value }), + ) + .await + .expect("start MCP server"); + wait_for_mcp_running(&session, started_server, true).await; + + let tools = session + .rpc() + .mcp() + .list_tools(McpListToolsRequest { + server_name: started_server.to_string(), + }) + .await + .expect("list started MCP tools"); + assert!(!tools.tools.is_empty()); + + let config_value = serde_json::to_value(&config).expect("serialize MCP config"); + call_session_rpc( + &session, + "session.mcp.restartServer", + json!({ "serverName": started_server, "config": config_value }), + ) + .await + .expect("restart MCP server"); + wait_for_mcp_running(&session, started_server, true).await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_register_and_unregister_external_mcp_client() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_register_and_unregister_external_mcp_client", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let host_server = "rpc-lifecycle-extclient-host"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), host_server), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, host_server, McpServerStatus::Connected).await; + + let external_name = "rpc-lifecycle-external-client"; + assert!(!is_mcp_server_running(&session, external_name).await); + + call_session_rpc( + &session, + "session.mcp.registerExternalClient", + json!({ + "serverName": external_name, + "client": { "id": external_name }, + "transport": { "kind": "in-process" }, + "config": { "command": "noop" } + }), + ) + .await + .expect("register external MCP client"); + assert!(is_mcp_server_running(&session, external_name).await); + + call_session_rpc( + &session, + "session.mcp.unregisterExternalClient", + json!({ "serverName": external_name }), + ) + .await + .expect("unregister external MCP client"); + assert!(!is_mcp_server_running(&session, external_name).await); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reload_mcp_servers_with_config() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_reload_mcp_servers_with_config", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let host_server = "rpc-lifecycle-reload-host"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), host_server), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, host_server, McpServerStatus::Connected).await; + + let result: McpStartServersResult = call_session_rpc_typed( + &session, + "session.mcp.reloadWithConfig", + json!({ + "config": { + "mcpServers": {}, + "disabledServers": [] + } + }), + ) + .await + .expect("reload MCP with config"); + + assert!(result.filtered_servers.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_configure_github_mcp_server() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_configure_github_mcp_server", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let host_server = "rpc-lifecycle-configure-host"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), host_server), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, host_server, McpServerStatus::Connected).await; + + let result: McpConfigureGitHubResult = call_session_rpc_typed( + &session, + "session.mcp.configureGitHub", + json!({ "authInfo": { "type": "api-key" } }), + ) + .await + .expect("configure GitHub MCP"); + + assert!(!result.changed); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_respond_to_mcp_oauth_request_without_pending_request() { + with_e2e_context( + "rpc_mcp_lifecycle", + "should_respond_to_mcp_oauth_request_without_pending_request", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let host_server = "rpc-lifecycle-oauth-host"; + let client = ctx.start_client().await; + let session = + client + .create_session(ctx.approve_all_session_config().with_mcp_servers( + create_test_mcp_servers(ctx.repo_root(), host_server), + )) + .await + .expect("create session"); + wait_for_mcp_server_status(&session, host_server, McpServerStatus::Connected).await; + + let result = call_session_rpc( + &session, + "session.mcp.oauth.respond", + json!({ + "requestId": format!("missing-{}", uuid::Uuid::new_v4().simple()) + }), + ) + .await + .expect("respond to missing MCP OAuth request"); + assert!(result.is_object()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn create_test_mcp_servers( + repo_root: &Path, + server_name: &str, +) -> HashMap { + HashMap::from([(server_name.to_string(), test_mcp_server_config(repo_root))]) +} + +fn test_mcp_server_config(repo_root: &Path) -> McpServerConfig { + let harness_dir = repo_root.join("test").join("harness"); + let server_path = harness_dir + .join("test-mcp-server.mjs") + .to_string_lossy() + .to_string(); + McpServerConfig::Stdio(McpStdioServerConfig { + tools: Some(vec!["*".to_string()]), + command: if cfg!(windows) { + "node.exe".to_string() + } else { + "node".to_string() + }, + args: vec![server_path], + working_directory: Some(harness_dir.to_string_lossy().to_string()), + ..McpStdioServerConfig::default() + }) +} + +async fn wait_for_mcp_server_status( + session: &Session, + server_name: &str, + expected_status: McpServerStatus, +) { + wait_for_condition("MCP server status", || async { + session + .rpc() + .mcp() + .list() + .await + .expect("list MCP servers") + .servers + .iter() + .any(|server| server.name == server_name && server.status == expected_status) + }) + .await; +} + +async fn wait_for_mcp_running(session: &Session, server_name: &str, expected_running: bool) { + wait_for_condition("MCP server running state", || async { + is_mcp_server_running(session, server_name).await == expected_running + }) + .await; +} + +async fn is_mcp_server_running(session: &Session, server_name: &str) -> bool { + session + .rpc() + .mcp() + .is_server_running(McpIsServerRunningRequest { + server_name: server_name.to_string(), + }) + .await + .expect("check MCP running") + .running +} + +async fn call_session_rpc( + session: &Session, + method: &'static str, + mut params: Value, +) -> Result { + params["sessionId"] = json!(session.id()); + session.client().call(method, Some(params)).await +} + +async fn call_session_rpc_typed( + session: &Session, + method: &'static str, + params: Value, +) -> Result { + let value = call_session_rpc(session, method, params).await?; + Ok(serde_json::from_value(value)?) +} + +fn assert_error_contains(err: &Error, expected: &str) { + let message = err.to_string(); + assert!( + !message.to_ascii_lowercase().contains("unhandled method"), + "{message}" + ); + assert!( + message + .to_ascii_lowercase() + .contains(&expected.to_ascii_lowercase()), + "expected error to contain {expected:?}, got {message}" + ); +} diff --git a/rust/tests/e2e/rpc_server_misc.rs b/rust/tests/e2e/rpc_server_misc.rs new file mode 100644 index 000000000..2886aff28 --- /dev/null +++ b/rust/tests/e2e/rpc_server_misc.rs @@ -0,0 +1,170 @@ +use github_copilot_sdk::Client; +use github_copilot_sdk::rpc::{ + AgentRegistrySpawnRequest, SendAttachmentsToMessageParams, SessionsOpenStatus, +}; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_reload_user_settings() { + with_e2e_context("rpc_server_misc", "should_reload_user_settings", |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + client + .rpc() + .user() + .settings() + .reload() + .await + .expect("reload user settings"); + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_report_agent_registry_spawn_gate_closed() { + with_e2e_context( + "rpc_server_misc", + "should_report_agent_registry_spawn_gate_closed", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let err = client + .rpc() + .agent_registry() + .spawn(AgentRegistrySpawnRequest { + agent_name: None, + cwd: ctx.work_dir().to_string_lossy().to_string(), + initial_prompt: None, + model: None, + name: None, + permission_mode: None, + }) + .await + .expect_err("agent registry spawn should be gated"); + + let message = err.to_string(); + assert_not_unhandled(&message); + let lower = message.to_ascii_lowercase(); + assert!(lower.contains("agentregistry.spawn"), "{message}"); + assert!( + lower.contains("not enabled") || lower.contains("no delegate"), + "{message}" + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_shut_down_owned_runtime() { + with_e2e_context("rpc_server_misc", "should_shut_down_owned_runtime", |ctx| { + Box::pin(async move { + let client = Client::start(ctx.client_options()) + .await + .expect("start dedicated client"); + + client + .rpc() + .user() + .settings() + .reload() + .await + .expect("runtime should start live"); + + client + .rpc() + .runtime() + .shutdown() + .await + .expect("shut down runtime"); + + wait_for_condition("runtime to stop serving RPCs", || async { + client.rpc().user().settings().reload().await.is_err() + }) + .await; + + let _ = client.stop().await; + }) + }) + .await; +} + +#[tokio::test] +async fn should_report_not_found_when_opening_session_without_context() { + with_e2e_context( + "rpc_server_misc", + "should_report_not_found_when_opening_session_without_context", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .sessions() + .open() + .await + .expect("open session without context"); + + assert_eq!(result.status, SessionsOpenStatus::NotFound); + assert!(result.session_id.is_none()); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reject_send_attachments_from_non_extension_connection() { + with_e2e_context( + "rpc_server_misc", + "should_reject_send_attachments_from_non_extension_connection", + |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 err = session + .rpc() + .extensions() + .send_attachments_to_message(SendAttachmentsToMessageParams { + attachments: Vec::new(), + instance_id: None, + }) + .await + .expect_err("normal session connection should be rejected"); + let message = err.to_string(); + assert_not_unhandled(&message); + assert!( + message.to_ascii_lowercase().contains("extension"), + "{message}" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn assert_not_unhandled(message: &str) { + assert!( + !message.to_ascii_lowercase().contains("unhandled method"), + "{message}" + ); +} diff --git a/rust/tests/e2e/rpc_server_plugins.rs b/rust/tests/e2e/rpc_server_plugins.rs new file mode 100644 index 000000000..895ab2801 --- /dev/null +++ b/rust/tests/e2e/rpc_server_plugins.rs @@ -0,0 +1,554 @@ +use std::fs; +use std::path::Path; + +use github_copilot_sdk::rpc::{ + InstalledPluginInfo, PluginListResult, PluginsDisableRequest, PluginsEnableRequest, + PluginsInstallRequest, PluginsMarketplacesAddRequest, PluginsMarketplacesBrowseRequest, + PluginsMarketplacesRefreshRequest, PluginsMarketplacesRemoveRequest, PluginsUninstallRequest, + PluginsUpdateRequest, +}; + +use super::support::with_e2e_context; + +const MARKETPLACE_NAME: &str = "csharp-e2e-marketplace"; +const PLUGIN_NAME: &str = "csharp-e2e-plugin"; +const DIRECT_PLUGIN_NAME: &str = "csharp-e2e-direct"; + +#[tokio::test] +async fn should_install_list_and_uninstall_plugin_from_local_marketplace() { + with_e2e_context( + "rpc_server_plugins", + "should_install_list_and_uninstall_plugin_from_local_marketplace", + |ctx| { + Box::pin(async move { + let marketplace = create_local_marketplace_fixture(); + let client = ctx.start_client().await; + let spec = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + + client + .rpc() + .plugins() + .marketplaces() + .add(PluginsMarketplacesAddRequest { + source: marketplace.source(), + }) + .await + .expect("add marketplace"); + + let install = client + .rpc() + .plugins() + .install(PluginsInstallRequest { + source: spec.clone(), + working_directory: None, + }) + .await + .expect("install marketplace plugin"); + + assert_eq!(install.plugin.name, PLUGIN_NAME); + assert_eq!(install.plugin.marketplace, MARKETPLACE_NAME); + assert!(install.plugin.enabled); + assert!(install.skills_installed >= 1); + assert!(install.deprecation_warning.is_none()); + + let after_install = client.rpc().plugins().list().await.expect("list plugins"); + let listed = single_plugin(&after_install, PLUGIN_NAME, MARKETPLACE_NAME); + assert!(listed.enabled); + + client + .rpc() + .plugins() + .uninstall(PluginsUninstallRequest { name: spec }) + .await + .expect("uninstall marketplace plugin"); + + let after_uninstall = client + .rpc() + .plugins() + .list() + .await + .expect("list after uninstall"); + assert!(!contains_plugin( + &after_uninstall, + PLUGIN_NAME, + MARKETPLACE_NAME + )); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_enable_and_disable_marketplace_plugin() { + with_e2e_context( + "rpc_server_plugins", + "should_enable_and_disable_marketplace_plugin", + |ctx| { + Box::pin(async move { + let marketplace = create_local_marketplace_fixture(); + let client = ctx.start_client().await; + let spec = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + + client + .rpc() + .plugins() + .marketplaces() + .add(PluginsMarketplacesAddRequest { + source: marketplace.source(), + }) + .await + .expect("add marketplace"); + client + .rpc() + .plugins() + .install(PluginsInstallRequest { + source: spec.clone(), + working_directory: None, + }) + .await + .expect("install marketplace plugin"); + + client + .rpc() + .plugins() + .disable(PluginsDisableRequest { + names: vec![spec.clone()], + }) + .await + .expect("disable plugin"); + assert!( + !single_plugin( + &client.rpc().plugins().list().await.expect("list disabled"), + PLUGIN_NAME, + MARKETPLACE_NAME + ) + .enabled + ); + + client + .rpc() + .plugins() + .enable(PluginsEnableRequest { names: vec![spec] }) + .await + .expect("enable plugin"); + assert!( + single_plugin( + &client.rpc().plugins().list().await.expect("list enabled"), + PLUGIN_NAME, + MARKETPLACE_NAME + ) + .enabled + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_update_single_marketplace_plugin() { + with_e2e_context( + "rpc_server_plugins", + "should_update_single_marketplace_plugin", + |ctx| { + Box::pin(async move { + let marketplace = create_local_marketplace_fixture(); + let client = ctx.start_client().await; + let spec = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + + client + .rpc() + .plugins() + .marketplaces() + .add(PluginsMarketplacesAddRequest { + source: marketplace.source(), + }) + .await + .expect("add marketplace"); + client + .rpc() + .plugins() + .install(PluginsInstallRequest { + source: spec.clone(), + working_directory: None, + }) + .await + .expect("install marketplace plugin"); + + let update = client + .rpc() + .plugins() + .update(PluginsUpdateRequest { name: spec }) + .await + .expect("update plugin"); + + assert!(update.skills_installed >= 1); + assert_eq!(update.previous_version.as_deref(), Some("1.0.0")); + assert_eq!(update.new_version.as_deref(), Some("1.0.0")); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_update_all_installed_plugins() { + with_e2e_context( + "rpc_server_plugins", + "should_update_all_installed_plugins", + |ctx| { + Box::pin(async move { + let marketplace = create_local_marketplace_fixture(); + let client = ctx.start_client().await; + let spec = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + + client + .rpc() + .plugins() + .marketplaces() + .add(PluginsMarketplacesAddRequest { + source: marketplace.source(), + }) + .await + .expect("add marketplace"); + client + .rpc() + .plugins() + .install(PluginsInstallRequest { + source: spec, + working_directory: None, + }) + .await + .expect("install marketplace plugin"); + + let result = client + .rpc() + .plugins() + .update_all() + .await + .expect("update all plugins"); + + let matches: Vec<_> = result + .results + .iter() + .filter(|entry| { + entry.name == PLUGIN_NAME && entry.marketplace == MARKETPLACE_NAME + }) + .collect(); + assert_eq!(matches.len(), 1, "expected one update entry: {result:?}"); + let entry = matches[0]; + assert!(entry.success, "{:?}", entry.error); + assert!(entry.skills_installed.unwrap_or_default() >= 1); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_install_direct_local_plugin_with_deprecation_warning() { + with_e2e_context( + "rpc_server_plugins", + "should_install_direct_local_plugin_with_deprecation_warning", + |ctx| { + Box::pin(async move { + let plugin = create_direct_plugin_fixture(); + let client = ctx.start_client().await; + + let install = client + .rpc() + .plugins() + .install(PluginsInstallRequest { + source: plugin.source(), + working_directory: None, + }) + .await + .expect("install direct plugin"); + + assert_eq!(install.plugin.name, DIRECT_PLUGIN_NAME); + assert_eq!(install.plugin.marketplace, ""); + let warning = install + .deprecation_warning + .as_deref() + .expect("direct installs should warn"); + assert!(warning.to_ascii_lowercase().contains("deprecated")); + assert!(install.skills_installed >= 1); + + let after_install = client.rpc().plugins().list().await.expect("list plugins"); + let direct_matches = after_install + .plugins + .iter() + .filter(|plugin| plugin.name == DIRECT_PLUGIN_NAME) + .count(); + assert_eq!( + direct_matches, 1, + "expected direct plugin in {after_install:?}" + ); + + client + .rpc() + .plugins() + .uninstall(PluginsUninstallRequest { + name: DIRECT_PLUGIN_NAME.to_string(), + }) + .await + .expect("uninstall direct plugin"); + + let after_uninstall = client + .rpc() + .plugins() + .list() + .await + .expect("list after uninstall"); + assert!( + !after_uninstall + .plugins + .iter() + .any(|plugin| plugin.name == DIRECT_PLUGIN_NAME) + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_list_browse_refresh_and_remove_local_marketplace() { + with_e2e_context( + "rpc_server_plugins", + "should_list_browse_refresh_and_remove_local_marketplace", + |ctx| { + Box::pin(async move { + let marketplace = create_local_marketplace_fixture(); + let client = ctx.start_client().await; + + let add = client + .rpc() + .plugins() + .marketplaces() + .add(PluginsMarketplacesAddRequest { + source: marketplace.source(), + }) + .await + .expect("add marketplace"); + assert_eq!(add.name, MARKETPLACE_NAME); + + let list = client + .rpc() + .plugins() + .marketplaces() + .list() + .await + .expect("list marketplaces"); + let mine: Vec<_> = list + .marketplaces + .iter() + .filter(|marketplace| marketplace.name == MARKETPLACE_NAME) + .collect(); + assert_eq!(mine.len(), 1, "expected local marketplace in {list:?}"); + assert_ne!(mine[0].is_default, Some(true)); + assert!( + list.marketplaces + .iter() + .any(|marketplace| marketplace.is_default == Some(true)) + ); + + let browse = client + .rpc() + .plugins() + .marketplaces() + .browse(PluginsMarketplacesBrowseRequest { + name: MARKETPLACE_NAME.to_string(), + }) + .await + .expect("browse marketplace"); + let advertised: Vec<_> = browse + .plugins + .iter() + .filter(|plugin| plugin.name == PLUGIN_NAME) + .collect(); + assert_eq!( + advertised.len(), + 1, + "expected advertised plugin in {browse:?}" + ); + assert!( + advertised[0] + .description + .as_deref() + .is_some_and(|description| !description.is_empty()) + ); + + let refresh = client + .rpc() + .plugins() + .marketplaces() + .refresh_with_params(PluginsMarketplacesRefreshRequest { + name: Some(MARKETPLACE_NAME.to_string()), + }) + .await + .expect("refresh marketplace"); + let refreshed: Vec<_> = refresh + .results + .iter() + .filter(|entry| entry.name == MARKETPLACE_NAME) + .collect(); + assert_eq!(refreshed.len(), 1, "expected refresh result in {refresh:?}"); + assert!(refreshed[0].success, "{:?}", refreshed[0].error); + + let remove = client + .rpc() + .plugins() + .marketplaces() + .remove(PluginsMarketplacesRemoveRequest { + force: None, + name: MARKETPLACE_NAME.to_string(), + }) + .await + .expect("remove marketplace"); + assert!(remove.removed); + + let after_remove = client + .rpc() + .plugins() + .marketplaces() + .list() + .await + .expect("list after remove"); + assert!( + !after_remove + .marketplaces + .iter() + .any(|marketplace| marketplace.name == MARKETPLACE_NAME) + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reload_mcp_config_cache() { + with_e2e_context( + "rpc_server_plugins", + "should_reload_mcp_config_cache", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + client + .rpc() + .mcp() + .config() + .reload() + .await + .expect("reload MCP config cache"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct LocalFixture { + dir: tempfile::TempDir, +} + +impl LocalFixture { + fn source(&self) -> String { + self.dir.path().to_string_lossy().to_string() + } +} + +fn create_local_marketplace_fixture() -> LocalFixture { + let dir = tempfile::Builder::new() + .prefix("copilot-e2e-mp-") + .tempdir() + .expect("create local marketplace fixture"); + let manifest = format!( + r#"{{ + "name": "{MARKETPLACE_NAME}", + "owner": {{ "name": "Copilot SDK E2E" }}, + "metadata": {{ "description": "Local marketplace fixture for SDK E2E tests." }}, + "plugins": [ + {{ + "name": "{PLUGIN_NAME}", + "source": "./{PLUGIN_NAME}", + "description": "E2E demo plugin advertised by the local marketplace.", + "version": "1.0.0" + }} + ] +}} +"# + ); + fs::write(dir.path().join("marketplace.json"), manifest).expect("write marketplace manifest"); + + let plugin_dir = dir.path().join(PLUGIN_NAME); + fs::create_dir_all(&plugin_dir).expect("create marketplace plugin directory"); + write_skill_file(&plugin_dir); + + LocalFixture { dir } +} + +fn create_direct_plugin_fixture() -> LocalFixture { + let dir = tempfile::Builder::new() + .prefix("copilot-e2e-plugin-") + .tempdir() + .expect("create direct plugin fixture"); + let manifest = format!( + r#"{{ + "name": "{DIRECT_PLUGIN_NAME}", + "description": "E2E demo plugin installed directly from a local path.", + "version": "1.0.0" +}} +"# + ); + fs::write(dir.path().join("plugin.json"), manifest).expect("write plugin manifest"); + write_skill_file(dir.path()); + + LocalFixture { dir } +} + +fn write_skill_file(plugin_dir: &Path) { + let skill = r#"--- +name: csharp-e2e-skill +description: A demo skill contributed by the E2E test plugin. +--- +# Demo Skill + +This skill exists so the plugin reports at least one installed skill. +"#; + fs::write(plugin_dir.join("SKILL.md"), skill).expect("write skill file"); +} + +fn single_plugin<'a>( + list: &'a PluginListResult, + name: &str, + marketplace: &str, +) -> &'a InstalledPluginInfo { + let matches: Vec<_> = list + .plugins + .iter() + .filter(|plugin| plugin.name == name && plugin.marketplace == marketplace) + .collect(); + assert_eq!(matches.len(), 1, "expected one plugin in {list:?}"); + matches[0] +} + +fn contains_plugin(list: &PluginListResult, name: &str, marketplace: &str) -> bool { + list.plugins + .iter() + .any(|plugin| plugin.name == name && plugin.marketplace == marketplace) +} diff --git a/rust/tests/e2e/rpc_server_remote_control.rs b/rust/tests/e2e/rpc_server_remote_control.rs new file mode 100644 index 000000000..a49f1d12a --- /dev/null +++ b/rust/tests/e2e/rpc_server_remote_control.rs @@ -0,0 +1,179 @@ +use github_copilot_sdk::SessionId; +use github_copilot_sdk::rpc::{ + RemoteControlConfig, SessionsSetRemoteControlSteeringRequest, + SessionsStartRemoteControlRequest, SessionsStopRemoteControlRequest, + SessionsTransferRemoteControlRequest, +}; +use serde_json::Value; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_report_remote_control_status_as_off() { + with_e2e_context( + "rpc_server_remote_control", + "should_report_remote_control_status_as_off", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .sessions() + .get_remote_control_status() + .await + .expect("get remote control status"); + + assert_status_off(&result.status); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_treat_set_steering_as_no_op_when_off() { + with_e2e_context( + "rpc_server_remote_control", + "should_treat_set_steering_as_no_op_when_off", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .sessions() + .set_remote_control_steering(SessionsSetRemoteControlSteeringRequest { + enabled: false, + }) + .await + .expect("set remote control steering"); + + assert_status_off(&result.status); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_not_stopped_when_remote_control_is_off() { + with_e2e_context( + "rpc_server_remote_control", + "should_report_not_stopped_when_remote_control_is_off", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .sessions() + .stop_remote_control() + .await + .expect("stop remote control"); + + assert!(!result.stopped); + assert_status_off(&result.status); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reject_transfer_when_off_with_compare_and_swap() { + with_e2e_context( + "rpc_server_remote_control", + "should_reject_transfer_when_off_with_compare_and_swap", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .sessions() + .transfer_remote_control(SessionsTransferRemoteControlRequest { + expected_from_session_id: Some(format!( + "rc-from-{}", + uuid::Uuid::new_v4().simple() + )), + to_session_id: format!("rc-to-{}", uuid::Uuid::new_v4().simple()), + }) + .await + .expect("transfer remote control"); + + assert!(!result.transferred); + assert_status_off(&result.status); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reach_runtime_when_starting_remote_control_for_unknown_session() { + with_e2e_context( + "rpc_server_remote_control", + "should_reach_runtime_when_starting_remote_control_for_unknown_session", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .sessions() + .start_remote_control(SessionsStartRemoteControlRequest { + session_id: SessionId::from(format!( + "missing-session-{}", + uuid::Uuid::new_v4().simple() + )), + config: RemoteControlConfig { + existing_mc_session: None, + explicit: false, + remote: false, + silent: true, + steerable: false, + task_id: None, + }, + }) + .await; + + let _ = client + .rpc() + .sessions() + .stop_remote_control_with_params(SessionsStopRemoteControlRequest { + expected_session_id: None, + force: Some(true), + }) + .await; + + let err = result.expect_err("unknown session should fail"); + let message = err.to_string(); + assert_not_unhandled(&message); + let lower = message.to_ascii_lowercase(); + assert!( + lower.contains("session") || lower.contains("remote"), + "{message}" + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn assert_status_off(status: &Value) { + assert_eq!(status.get("state").and_then(Value::as_str), Some("off")); +} + +fn assert_not_unhandled(message: &str) { + assert!( + !message.to_ascii_lowercase().contains("unhandled method"), + "{message}" + ); +} diff --git a/rust/tests/e2e/rpc_session_state_extras.rs b/rust/tests/e2e/rpc_session_state_extras.rs new file mode 100644 index 000000000..10954b4e2 --- /dev/null +++ b/rust/tests/e2e/rpc_session_state_extras.rs @@ -0,0 +1,291 @@ +use github_copilot_sdk::Client; +use github_copilot_sdk::rpc::PermissionsSetAllowAllRequest; + +use super::support::{assistant_message_content, with_e2e_context}; + +const MODEL_ID: &str = "claude-sonnet-4.5"; + +#[tokio::test] +async fn should_list_models_for_session() { + with_e2e_context( + "rpc_session_state_extras", + "should_list_models_for_session", + |ctx| { + Box::pin(async move { + let token = "rpc-session-model-list-token"; + ctx.set_copilot_user_by_token_with_login(token, "rpc-session-extras-user"); + let client = Client::start(ctx.client_options().with_github_token(token)) + .await + .expect("start authenticated client"); + let session = client + .create_session( + ctx.approve_all_session_config() + .with_github_token(token) + .with_model(MODEL_ID), + ) + .await + .expect("create session"); + + let result = session.rpc().model().list().await.expect("list models"); + + assert!(!result.list.is_empty()); + assert!( + result + .list + .iter() + .any(|model| model.to_string().contains(MODEL_ID)) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_session_activity_when_idle() { + with_e2e_context( + "rpc_session_state_extras", + "should_report_session_activity_when_idle", + |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 activity = session + .rpc() + .metadata() + .activity() + .await + .expect("get activity"); + + assert!(!activity.has_active_work); + assert!(!activity.abortable); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_and_set_allowall_permissions() { + with_e2e_context( + "rpc_session_state_extras", + "should_get_and_set_allowall_permissions", + |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 initial = session + .rpc() + .permissions() + .get_allow_all() + .await + .expect("get initial allow-all"); + assert!(!initial.enabled); + + let enable = session + .rpc() + .permissions() + .set_allow_all(PermissionsSetAllowAllRequest { + enabled: true, + source: None, + }) + .await + .expect("enable allow-all"); + assert!(enable.success); + assert!(enable.enabled); + assert!( + session + .rpc() + .permissions() + .get_allow_all() + .await + .expect("get enabled allow-all") + .enabled + ); + + let disable = session + .rpc() + .permissions() + .set_allow_all(PermissionsSetAllowAllRequest { + enabled: false, + source: None, + }) + .await + .expect("disable allow-all"); + assert!(disable.success); + assert!(!disable.enabled); + assert!( + !session + .rpc() + .permissions() + .get_allow_all() + .await + .expect("get disabled allow-all") + .enabled + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_read_empty_sql_todos_for_fresh_session() { + with_e2e_context( + "rpc_session_state_extras", + "should_read_empty_sql_todos_for_fresh_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 result = session + .rpc() + .plan() + .read_sql_todos() + .await + .expect("read SQL todos"); + + assert!(result.rows.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_telemetry_engagement_id() { + with_e2e_context( + "rpc_session_state_extras", + "should_get_telemetry_engagement_id", + |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() + .telemetry() + .get_engagement_id() + .await + .expect("get telemetry engagement id"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_current_tool_metadata_after_initialization() { + with_e2e_context( + "rpc_session_state_extras", + "should_get_current_tool_metadata_after_initialization", + |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("What is 2+2?") + .await + .expect("send prompt") + .expect("assistant message"); + assert!(!assistant_message_content(&answer).trim().is_empty()); + + let result = session + .rpc() + .tools() + .get_current_metadata() + .await + .expect("get current tool metadata"); + + let tools = result.tools.expect("current tool metadata"); + assert!(!tools.is_empty()); + assert!(tools.iter().all(|tool| !tool.name.trim().is_empty())); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reload_session_plugins() { + with_e2e_context( + "rpc_session_state_extras", + "should_reload_session_plugins", + |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() + .plugins() + .reload() + .await + .expect("reload session plugins"); + + let plugins = session + .rpc() + .plugins() + .list() + .await + .expect("list session plugins"); + assert!( + plugins + .plugins + .iter() + .all(|plugin| !plugin.name.trim().is_empty()) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_shell_user_requested.rs b/rust/tests/e2e/rpc_shell_user_requested.rs new file mode 100644 index 000000000..7bd52ae9f --- /dev/null +++ b/rust/tests/e2e/rpc_shell_user_requested.rs @@ -0,0 +1,173 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use github_copilot_sdk::RequestId; +use github_copilot_sdk::rpc::{ShellCancelUserRequestedRequest, ShellExecuteUserRequestedRequest}; + +use super::support::{wait_for_condition, with_e2e_context}; + +#[tokio::test] +async fn should_execute_user_requested_shell_command() { + with_e2e_context( + "rpc_shell_user_requested", + "should_execute_user_requested_shell_command", + |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 marker = format!("copilotusershell{}", uuid::Uuid::new_v4().simple()); + let request_id = RequestId::new(format!("req-{}", uuid::Uuid::new_v4().simple())); + + let result = session + .rpc() + .shell() + .execute_user_requested(ShellExecuteUserRequestedRequest { + request_id, + command: format!("echo {marker}"), + }) + .await + .expect("execute user-requested shell command"); + + assert!( + result.success, + "expected shell command to succeed: {:?}", + result.error + ); + assert_eq!(result.exit_code, Some(0)); + assert!(result.output.contains(&marker)); + assert!(!result.tool_call_id.trim().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_cancel_user_requested_shell_command() { + with_e2e_context( + "rpc_shell_user_requested", + "should_cancel_user_requested_shell_command", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = Arc::new( + client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"), + ); + + let missing = session + .rpc() + .shell() + .cancel_user_requested(ShellCancelUserRequestedRequest { + request_id: RequestId::new(format!( + "missing-{}", + uuid::Uuid::new_v4().simple() + )), + }) + .await + .expect("cancel missing request"); + assert!(!missing.cancelled); + + let request_id = RequestId::new(format!("req-{}", uuid::Uuid::new_v4().simple())); + let marker_dir = tempfile::Builder::new() + .prefix("shell-cancel-") + .tempdir() + .expect("create shell cancel marker directory"); + let marker_path = marker_dir.path().join("marker.txt"); + let command = create_marker_then_sleep_command(&marker_path, 60); + let execute_session = Arc::clone(&session); + let execute_request_id = request_id.clone(); + let mut execute_task = tokio::spawn(async move { + execute_session + .rpc() + .shell() + .execute_user_requested(ShellExecuteUserRequestedRequest { + request_id: execute_request_id, + command, + }) + .await + }); + + wait_for_file_text(&marker_path, "running").await; + wait_for_condition("user-requested shell command cancellation", || { + let session = Arc::clone(&session); + let request_id = request_id.clone(); + async move { + session + .rpc() + .shell() + .cancel_user_requested(ShellCancelUserRequestedRequest { request_id }) + .await + .expect("cancel running request") + .cancelled + } + }) + .await; + + // Await the spawned task by mutable reference so a timeout can abort it instead of + // dropping the handle. A dropped JoinHandle detaches the task, leaving the shell + // command running in the background where it can keep file handles open and + // destabilize later tests. + let result = + match tokio::time::timeout(Duration::from_secs(30), &mut execute_task).await { + Ok(joined) => joined + .expect("shell execution task should not panic") + .expect("execute user-requested shell command"), + Err(_elapsed) => { + execute_task.abort(); + panic!("cancelled shell command did not finish within 30s"); + } + }; + assert!(!result.success); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +async fn wait_for_file_text(path: &Path, expected: &'static str) { + wait_for_condition("shell marker text", || async { + std::fs::read_to_string(path).is_ok_and(|content| content.contains(expected)) + }) + .await; +} + +#[cfg(windows)] +fn create_marker_then_sleep_command(marker_path: &Path, seconds: u64) -> String { + format!( + "Set-Content -LiteralPath {} -Value 'running'; Start-Sleep -Seconds {seconds}", + powershell_quote(marker_path) + ) +} + +#[cfg(not(windows))] +fn create_marker_then_sleep_command(marker_path: &Path, seconds: u64) -> String { + format!( + "echo running > {}; sleep {seconds}", + posix_shell_quote(marker_path) + ) +} + +#[cfg(windows)] +fn powershell_quote(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "''")) +} + +#[cfg(not(windows))] +fn posix_shell_quote(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "'\\''")) +} diff --git a/rust/tests/e2e/rpc_ui_ephemeral_query.rs b/rust/tests/e2e/rpc_ui_ephemeral_query.rs new file mode 100644 index 000000000..83852d092 --- /dev/null +++ b/rust/tests/e2e/rpc_ui_ephemeral_query.rs @@ -0,0 +1,38 @@ +use github_copilot_sdk::rpc::UIEphemeralQueryRequest; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_answer_ephemeral_query() { + with_e2e_context( + "rpc_ui_ephemeral_query", + "should_answer_ephemeral_query", + |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 mut request = UIEphemeralQueryRequest::default(); + request.question = + "In one word, what is the primary color of a clear daytime sky?".to_string(); + let result = session + .rpc() + .ui() + .ephemeral_query(request) + .await + .expect("answer ephemeral query"); + + assert!(!result.answer.trim().is_empty()); + assert!(result.answer.to_ascii_lowercase().contains("blue")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/test/snapshots/rpc_mcp_lifecycle/should_configure_github_mcp_server.yaml b/test/snapshots/rpc_mcp_lifecycle/should_configure_github_mcp_server.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_configure_github_mcp_server.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_list_tools_and_report_running_status_for_connected_server.yaml b/test/snapshots/rpc_mcp_lifecycle/should_list_tools_and_report_running_status_for_connected_server.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_list_tools_and_report_running_status_for_connected_server.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml b/test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_reload_mcp_servers_with_config.yaml b/test/snapshots/rpc_mcp_lifecycle/should_reload_mcp_servers_with_config.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_reload_mcp_servers_with_config.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_respond_to_mcp_oauth_request_without_pending_request.yaml b/test/snapshots/rpc_mcp_lifecycle/should_respond_to_mcp_oauth_request_without_pending_request.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_respond_to_mcp_oauth_request_without_pending_request.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_start_and_restart_mcp_server.yaml b/test/snapshots/rpc_mcp_lifecycle/should_start_and_restart_mcp_server.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_start_and_restart_mcp_server.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_stop_running_mcp_server.yaml b/test/snapshots/rpc_mcp_lifecycle/should_stop_running_mcp_server.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_stop_running_mcp_server.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_mcp_lifecycle/should_throw_when_listing_tools_for_unconnected_server.yaml b/test/snapshots/rpc_mcp_lifecycle/should_throw_when_listing_tools_for_unconnected_server.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_mcp_lifecycle/should_throw_when_listing_tools_for_unconnected_server.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_misc/should_reject_send_attachments_from_non_extension_connection.yaml b/test/snapshots/rpc_server_misc/should_reject_send_attachments_from_non_extension_connection.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_reject_send_attachments_from_non_extension_connection.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_misc/should_reload_user_settings.yaml b/test/snapshots/rpc_server_misc/should_reload_user_settings.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_reload_user_settings.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_misc/should_report_agent_registry_spawn_gate_closed.yaml b/test/snapshots/rpc_server_misc/should_report_agent_registry_spawn_gate_closed.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_report_agent_registry_spawn_gate_closed.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_misc/should_report_not_found_when_opening_session_without_context.yaml b/test/snapshots/rpc_server_misc/should_report_not_found_when_opening_session_without_context.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_report_not_found_when_opening_session_without_context.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_misc/should_shut_down_owned_runtime.yaml b/test/snapshots/rpc_server_misc/should_shut_down_owned_runtime.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_misc/should_shut_down_owned_runtime.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_enable_and_disable_marketplace_plugin.yaml b/test/snapshots/rpc_server_plugins/should_enable_and_disable_marketplace_plugin.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_enable_and_disable_marketplace_plugin.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_install_direct_local_plugin_with_deprecation_warning.yaml b/test/snapshots/rpc_server_plugins/should_install_direct_local_plugin_with_deprecation_warning.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_install_direct_local_plugin_with_deprecation_warning.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_install_list_and_uninstall_plugin_from_local_marketplace.yaml b/test/snapshots/rpc_server_plugins/should_install_list_and_uninstall_plugin_from_local_marketplace.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_install_list_and_uninstall_plugin_from_local_marketplace.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_list_browse_refresh_and_remove_local_marketplace.yaml b/test/snapshots/rpc_server_plugins/should_list_browse_refresh_and_remove_local_marketplace.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_list_browse_refresh_and_remove_local_marketplace.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_reload_mcp_config_cache.yaml b/test/snapshots/rpc_server_plugins/should_reload_mcp_config_cache.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_reload_mcp_config_cache.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_update_all_installed_plugins.yaml b/test/snapshots/rpc_server_plugins/should_update_all_installed_plugins.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_update_all_installed_plugins.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_plugins/should_update_single_marketplace_plugin.yaml b/test/snapshots/rpc_server_plugins/should_update_single_marketplace_plugin.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_plugins/should_update_single_marketplace_plugin.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_remote_control/should_reach_runtime_when_starting_remote_control_for_unknown_session.yaml b/test/snapshots/rpc_server_remote_control/should_reach_runtime_when_starting_remote_control_for_unknown_session.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_remote_control/should_reach_runtime_when_starting_remote_control_for_unknown_session.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_remote_control/should_reject_transfer_when_off_with_compare_and_swap.yaml b/test/snapshots/rpc_server_remote_control/should_reject_transfer_when_off_with_compare_and_swap.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_remote_control/should_reject_transfer_when_off_with_compare_and_swap.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_remote_control/should_report_not_stopped_when_remote_control_is_off.yaml b/test/snapshots/rpc_server_remote_control/should_report_not_stopped_when_remote_control_is_off.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_remote_control/should_report_not_stopped_when_remote_control_is_off.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_remote_control/should_report_remote_control_status_as_off.yaml b/test/snapshots/rpc_server_remote_control/should_report_remote_control_status_as_off.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_remote_control/should_report_remote_control_status_as_off.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_server_remote_control/should_treat_set_steering_as_no_op_when_off.yaml b/test/snapshots/rpc_server_remote_control/should_treat_set_steering_as_no_op_when_off.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_server_remote_control/should_treat_set_steering_as_no_op_when_off.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_get_and_set_allowall_permissions.yaml b/test/snapshots/rpc_session_state_extras/should_get_and_set_allowall_permissions.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_get_and_set_allowall_permissions.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_get_current_tool_metadata_after_initialization.yaml b/test/snapshots/rpc_session_state_extras/should_get_current_tool_metadata_after_initialization.yaml new file mode 100644 index 000000000..80307eb59 --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_get_current_tool_metadata_after_initialization.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: "4" \ No newline at end of file diff --git a/test/snapshots/rpc_session_state_extras/should_get_telemetry_engagement_id.yaml b/test/snapshots/rpc_session_state_extras/should_get_telemetry_engagement_id.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_get_telemetry_engagement_id.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_list_models_for_session.yaml b/test/snapshots/rpc_session_state_extras/should_list_models_for_session.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_list_models_for_session.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_read_empty_sql_todos_for_fresh_session.yaml b/test/snapshots/rpc_session_state_extras/should_read_empty_sql_todos_for_fresh_session.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_read_empty_sql_todos_for_fresh_session.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_reload_session_plugins.yaml b/test/snapshots/rpc_session_state_extras/should_reload_session_plugins.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_reload_session_plugins.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_session_state_extras/should_report_session_activity_when_idle.yaml b/test/snapshots/rpc_session_state_extras/should_report_session_activity_when_idle.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_session_state_extras/should_report_session_activity_when_idle.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_user_requested/should_cancel_user_requested_shell_command.yaml b/test/snapshots/rpc_shell_user_requested/should_cancel_user_requested_shell_command.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_user_requested/should_cancel_user_requested_shell_command.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_shell_user_requested/should_execute_user_requested_shell_command.yaml b/test/snapshots/rpc_shell_user_requested/should_execute_user_requested_shell_command.yaml new file mode 100644 index 000000000..056351ddb --- /dev/null +++ b/test/snapshots/rpc_shell_user_requested/should_execute_user_requested_shell_command.yaml @@ -0,0 +1,3 @@ +models: + - claude-sonnet-4.5 +conversations: [] diff --git a/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml b/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml new file mode 100644 index 000000000..015797050 --- /dev/null +++ b/test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: In one word, what is the primary color of a clear daytime sky? + - role: assistant + content: Blue. \ No newline at end of file