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