From cf828daf958c64fc605ba05472119bcf8f9418ba Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 9 Jun 2026 13:05:30 -0400 Subject: [PATCH 1/2] Add E2E coverage for newly added RPC methods across all SDKs Adds meaningful end-to-end tests for RPC surface area that was previously uncovered: server plugins/marketplaces, server remote-control, MCP server lifecycle, session-state extras, user-requested shell exec/cancel, the UI ephemeral query, and miscellaneous server methods (settings reload, attachments gating, agent-registry spawn gate, sessions.open, runtime.shutdown). Authored first in C# (35 tests / 7 files) so the assertions and isolation patterns could be reviewed, then ported faithfully to Python, Go, Rust, and TypeScript. All five suites share the same 35 recorded snapshots under test/snapshots so they exercise identical runtime exchanges. The tests are written to be deterministic in CI: shell commands pass the bare script body (the runtime wraps it in the platform shell itself, so no nested shell can be orphaned on cancel and lock the work dir), synchronization is condition-based polling rather than fixed sleeps, sessions are disposed deterministically, and error-path tests assert on the specific domain error rather than only a generic "unhandled method". Plugin/marketplace and session-spawning tests isolate per-test home directories. The Python harness gains a small wait_for_condition helper and skips snapshot writes under GITHUB_ACTIONS to match the other suites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs | 200 +++++++ dotnet/test/E2E/RpcServerMiscE2ETests.cs | 160 +++++ dotnet/test/E2E/RpcServerPluginsE2ETests.cs | 339 +++++++++++ .../E2E/RpcServerRemoteControlE2ETests.cs | 105 ++++ .../test/E2E/RpcSessionStateExtrasE2ETests.cs | 166 ++++++ .../test/E2E/RpcShellUserRequestedE2ETests.cs | 119 ++++ .../test/E2E/RpcUiEphemeralQueryE2ETests.cs | 37 ++ go/internal/e2e/rpc_mcp_lifecycle_e2e_test.go | 230 ++++++++ go/internal/e2e/rpc_server_misc_e2e_test.go | 93 +++ .../e2e/rpc_server_plugins_e2e_test.go | 468 +++++++++++++++ .../e2e/rpc_server_remote_control_e2e_test.go | 112 ++++ .../e2e/rpc_session_state_extras_e2e_test.go | 200 +++++++ .../e2e/rpc_shell_user_requested_e2e_test.go | 140 +++++ .../e2e/rpc_ui_ephemeral_query_e2e_test.go | 37 ++ nodejs/test/e2e/rpc_mcp_lifecycle.e2e.test.ts | 262 +++++++++ nodejs/test/e2e/rpc_server_misc.e2e.test.ts | 169 ++++++ .../test/e2e/rpc_server_plugins.e2e.test.ts | 331 +++++++++++ .../e2e/rpc_server_remote_control.e2e.test.ts | 130 ++++ .../e2e/rpc_session_state_extras.e2e.test.ts | 187 ++++++ .../e2e/rpc_shell_user_requested.e2e.test.ts | 146 +++++ .../e2e/rpc_ui_ephemeral_query.e2e.test.ts | 26 + python/e2e/conftest.py | 5 +- python/e2e/test_rpc_mcp_lifecycle_e2e.py | 279 +++++++++ python/e2e/test_rpc_server_misc_e2e.py | 135 +++++ python/e2e/test_rpc_server_plugins_e2e.py | 296 ++++++++++ .../e2e/test_rpc_server_remote_control_e2e.py | 130 ++++ .../e2e/test_rpc_session_state_extras_e2e.py | 152 +++++ .../e2e/test_rpc_shell_user_requested_e2e.py | 122 ++++ python/e2e/test_rpc_ui_ephemeral_query_e2e.py | 33 ++ python/e2e/testharness/__init__.py | 3 +- python/e2e/testharness/helper.py | 28 + rust/tests/e2e.rs | 14 + rust/tests/e2e/rpc_mcp_lifecycle.rs | 462 +++++++++++++++ rust/tests/e2e/rpc_server_misc.rs | 170 ++++++ rust/tests/e2e/rpc_server_plugins.rs | 554 ++++++++++++++++++ rust/tests/e2e/rpc_server_remote_control.rs | 179 ++++++ rust/tests/e2e/rpc_session_state_extras.rs | 291 +++++++++ rust/tests/e2e/rpc_shell_user_requested.rs | 164 ++++++ rust/tests/e2e/rpc_ui_ephemeral_query.rs | 38 ++ .../should_configure_github_mcp_server.yaml | 3 + ...t_running_status_for_connected_server.yaml | 3 + ...er_and_unregister_external_mcp_client.yaml | 3 + ...should_reload_mcp_servers_with_config.yaml | 3 + ...oauth_request_without_pending_request.yaml | 3 + .../should_start_and_restart_mcp_server.yaml | 3 + .../should_stop_running_mcp_server.yaml | 3 + ..._listing_tools_for_unconnected_server.yaml | 3 + ...chments_from_non_extension_connection.yaml | 3 + .../should_reload_user_settings.yaml | 3 + ...port_agent_registry_spawn_gate_closed.yaml | 3 + ..._when_opening_session_without_context.yaml | 3 + .../should_shut_down_owned_runtime.yaml | 3 + ...enable_and_disable_marketplace_plugin.yaml | 3 + ...local_plugin_with_deprecation_warning.yaml | 3 + ...install_plugin_from_local_marketplace.yaml | 3 + ..._refresh_and_remove_local_marketplace.yaml | 3 + .../should_reload_mcp_config_cache.yaml | 3 + .../should_update_all_installed_plugins.yaml | 3 + ...ould_update_single_marketplace_plugin.yaml | 3 + ...ng_remote_control_for_unknown_session.yaml | 3 + ...ansfer_when_off_with_compare_and_swap.yaml | 3 + ...ot_stopped_when_remote_control_is_off.yaml | 3 + ...d_report_remote_control_status_as_off.yaml | 3 + ..._treat_set_steering_as_no_op_when_off.yaml | 3 + ...ould_get_and_set_allowall_permissions.yaml | 3 + ...nt_tool_metadata_after_initialization.yaml | 10 + .../should_get_telemetry_engagement_id.yaml | 3 + .../should_list_models_for_session.yaml | 3 + ...ead_empty_sql_todos_for_fresh_session.yaml | 3 + .../should_reload_session_plugins.yaml | 3 + ...uld_report_session_activity_when_idle.yaml | 3 + ...d_cancel_user_requested_shell_command.yaml | 3 + ..._execute_user_requested_shell_command.yaml | 3 + .../should_answer_ephemeral_query.yaml | 10 + 74 files changed, 6829 insertions(+), 2 deletions(-) create mode 100644 dotnet/test/E2E/RpcMcpLifecycleE2ETests.cs create mode 100644 dotnet/test/E2E/RpcServerMiscE2ETests.cs create mode 100644 dotnet/test/E2E/RpcServerPluginsE2ETests.cs create mode 100644 dotnet/test/E2E/RpcServerRemoteControlE2ETests.cs create mode 100644 dotnet/test/E2E/RpcSessionStateExtrasE2ETests.cs create mode 100644 dotnet/test/E2E/RpcShellUserRequestedE2ETests.cs create mode 100644 dotnet/test/E2E/RpcUiEphemeralQueryE2ETests.cs create mode 100644 go/internal/e2e/rpc_mcp_lifecycle_e2e_test.go create mode 100644 go/internal/e2e/rpc_server_misc_e2e_test.go create mode 100644 go/internal/e2e/rpc_server_plugins_e2e_test.go create mode 100644 go/internal/e2e/rpc_server_remote_control_e2e_test.go create mode 100644 go/internal/e2e/rpc_session_state_extras_e2e_test.go create mode 100644 go/internal/e2e/rpc_shell_user_requested_e2e_test.go create mode 100644 go/internal/e2e/rpc_ui_ephemeral_query_e2e_test.go create mode 100644 nodejs/test/e2e/rpc_mcp_lifecycle.e2e.test.ts create mode 100644 nodejs/test/e2e/rpc_server_misc.e2e.test.ts create mode 100644 nodejs/test/e2e/rpc_server_plugins.e2e.test.ts create mode 100644 nodejs/test/e2e/rpc_server_remote_control.e2e.test.ts create mode 100644 nodejs/test/e2e/rpc_session_state_extras.e2e.test.ts create mode 100644 nodejs/test/e2e/rpc_shell_user_requested.e2e.test.ts create mode 100644 nodejs/test/e2e/rpc_ui_ephemeral_query.e2e.test.ts create mode 100644 python/e2e/test_rpc_mcp_lifecycle_e2e.py create mode 100644 python/e2e/test_rpc_server_misc_e2e.py create mode 100644 python/e2e/test_rpc_server_plugins_e2e.py create mode 100644 python/e2e/test_rpc_server_remote_control_e2e.py create mode 100644 python/e2e/test_rpc_session_state_extras_e2e.py create mode 100644 python/e2e/test_rpc_shell_user_requested_e2e.py create mode 100644 python/e2e/test_rpc_ui_ephemeral_query_e2e.py create mode 100644 rust/tests/e2e/rpc_mcp_lifecycle.rs create mode 100644 rust/tests/e2e/rpc_server_misc.rs create mode 100644 rust/tests/e2e/rpc_server_plugins.rs create mode 100644 rust/tests/e2e/rpc_server_remote_control.rs create mode 100644 rust/tests/e2e/rpc_session_state_extras.rs create mode 100644 rust/tests/e2e/rpc_shell_user_requested.rs create mode 100644 rust/tests/e2e/rpc_ui_ephemeral_query.rs create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_configure_github_mcp_server.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_list_tools_and_report_running_status_for_connected_server.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_reload_mcp_servers_with_config.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_respond_to_mcp_oauth_request_without_pending_request.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_start_and_restart_mcp_server.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_stop_running_mcp_server.yaml create mode 100644 test/snapshots/rpc_mcp_lifecycle/should_throw_when_listing_tools_for_unconnected_server.yaml create mode 100644 test/snapshots/rpc_server_misc/should_reject_send_attachments_from_non_extension_connection.yaml create mode 100644 test/snapshots/rpc_server_misc/should_reload_user_settings.yaml create mode 100644 test/snapshots/rpc_server_misc/should_report_agent_registry_spawn_gate_closed.yaml create mode 100644 test/snapshots/rpc_server_misc/should_report_not_found_when_opening_session_without_context.yaml create mode 100644 test/snapshots/rpc_server_misc/should_shut_down_owned_runtime.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_enable_and_disable_marketplace_plugin.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_install_direct_local_plugin_with_deprecation_warning.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_install_list_and_uninstall_plugin_from_local_marketplace.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_list_browse_refresh_and_remove_local_marketplace.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_reload_mcp_config_cache.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_update_all_installed_plugins.yaml create mode 100644 test/snapshots/rpc_server_plugins/should_update_single_marketplace_plugin.yaml create mode 100644 test/snapshots/rpc_server_remote_control/should_reach_runtime_when_starting_remote_control_for_unknown_session.yaml create mode 100644 test/snapshots/rpc_server_remote_control/should_reject_transfer_when_off_with_compare_and_swap.yaml create mode 100644 test/snapshots/rpc_server_remote_control/should_report_not_stopped_when_remote_control_is_off.yaml create mode 100644 test/snapshots/rpc_server_remote_control/should_report_remote_control_status_as_off.yaml create mode 100644 test/snapshots/rpc_server_remote_control/should_treat_set_steering_as_no_op_when_off.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_get_and_set_allowall_permissions.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_get_current_tool_metadata_after_initialization.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_get_telemetry_engagement_id.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_list_models_for_session.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_read_empty_sql_todos_for_fresh_session.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_reload_session_plugins.yaml create mode 100644 test/snapshots/rpc_session_state_extras/should_report_session_activity_when_idle.yaml create mode 100644 test/snapshots/rpc_shell_user_requested/should_cancel_user_requested_shell_command.yaml create mode 100644 test/snapshots/rpc_shell_user_requested/should_execute_user_requested_shell_command.yaml create mode 100644 test/snapshots/rpc_ui_ephemeral_query/should_answer_ephemeral_query.yaml 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..c6ab39d1c --- /dev/null +++ b/rust/tests/e2e/rpc_shell_user_requested.rs @@ -0,0 +1,164 @@ +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 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; + + let result = tokio::time::timeout(Duration::from_secs(30), execute_task) + .await + .expect("cancelled shell command should finish") + .expect("shell execution task should not panic") + .expect("execute user-requested shell command"); + 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 From 94d923b331b4cad2c049136b361e1fd28c7d8a19 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 9 Jun 2026 13:50:18 -0400 Subject: [PATCH 2/2] Abort leaked shell task on timeout in Rust cancel E2E test In the user-requested shell cancel test, the spawned execute_user_requested JoinHandle was moved into tokio::time::timeout and dropped on the timeout path. Dropping a JoinHandle detaches the task rather than cancelling it, so a timed-out shell command would keep running in the background and could hold file handles open, destabilizing later tests. Await the handle by mutable reference and abort() it before panicking so the failure path cleans up after itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rust/tests/e2e/rpc_shell_user_requested.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/rust/tests/e2e/rpc_shell_user_requested.rs b/rust/tests/e2e/rpc_shell_user_requested.rs index c6ab39d1c..7bd52ae9f 100644 --- a/rust/tests/e2e/rpc_shell_user_requested.rs +++ b/rust/tests/e2e/rpc_shell_user_requested.rs @@ -88,7 +88,7 @@ async fn should_cancel_user_requested_shell_command() { let command = create_marker_then_sleep_command(&marker_path, 60); let execute_session = Arc::clone(&session); let execute_request_id = request_id.clone(); - let execute_task = tokio::spawn(async move { + let mut execute_task = tokio::spawn(async move { execute_session .rpc() .shell() @@ -115,11 +115,20 @@ async fn should_cancel_user_requested_shell_command() { }) .await; - let result = tokio::time::timeout(Duration::from_secs(30), execute_task) - .await - .expect("cancelled shell command should finish") - .expect("shell execution task should not panic") - .expect("execute user-requested shell command"); + // 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");