diff --git a/dotnet/test/E2E/CommandsE2ETests.cs b/dotnet/test/E2E/CommandsE2ETests.cs index 60a62bd58..20db2d7cb 100644 --- a/dotnet/test/E2E/CommandsE2ETests.cs +++ b/dotnet/test/E2E/CommandsE2ETests.cs @@ -2,6 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; @@ -10,6 +12,174 @@ namespace GitHub.Copilot.Test.E2E; public class CommandsE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "commands", output) { + private static readonly string[] KnownBuiltinCommands = ["help", "model", "compact"]; + + [Fact] + public async Task Session_Commands_List_Returns_Builtins_And_Respects_Client_Command_Filter() + { + var session = await CreateSessionAsync(new SessionConfig + { + Commands = + [ + new CommandDefinition { Name = "deploy", Description = "Deploy the app", Handler = _ => Task.CompletedTask }, + new CommandDefinition { Name = "rollback", Description = "Rollback the app", Handler = _ => Task.CompletedTask }, + ], + }); + + CommandList? clientCommands = null; + await TestHelper.WaitForConditionAsync( + async () => + { + clientCommands = await session.Rpc.Commands.ListAsync(new CommandsListRequest + { + IncludeBuiltins = false, + IncludeClientCommands = true, + IncludeSkills = false, + }); + return clientCommands.Commands.Any(c => IsCommand(c, "deploy", SlashCommandKind.Client)) && + clientCommands.Commands.Any(c => IsCommand(c, "rollback", SlashCommandKind.Client)); + }, + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: "Timed out waiting for client commands to be listed."); + Assert.Contains(clientCommands!.Commands, c => IsCommand(c, "deploy", SlashCommandKind.Client)); + Assert.Contains(clientCommands.Commands, c => IsCommand(c, "rollback", SlashCommandKind.Client)); + Assert.DoesNotContain(clientCommands.Commands, c => c.Kind == SlashCommandKind.Builtin); + + var builtinCommands = await session.Rpc.Commands.ListAsync(new CommandsListRequest + { + IncludeBuiltins = true, + IncludeClientCommands = false, + IncludeSkills = false, + }); + Assert.True( + builtinCommands.Commands.Any(IsKnownBuiltin), + $"Expected a known built-in command. Actual commands: {FormatCommands(builtinCommands.Commands)}"); + Assert.DoesNotContain(builtinCommands.Commands, c => string.Equals(c.Name, "deploy", StringComparison.OrdinalIgnoreCase)); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Session_Commands_Invoke_Known_Builtin_Returns_Expected_Result() + { + var session = await CreateSessionAsync(); + + var builtinCommands = await session.Rpc.Commands.ListAsync(new CommandsListRequest + { + IncludeBuiltins = true, + IncludeClientCommands = false, + IncludeSkills = false, + }); + var commandName = KnownBuiltinCommands.FirstOrDefault(name => + builtinCommands.Commands.Any(c => IsCommand(c, name, SlashCommandKind.Builtin))); + Assert.NotNull(commandName); + + var result = await session.Rpc.Commands.InvokeAsync(commandName); + + switch (result) + { + case SlashCommandInvocationResultText text: + Assert.False(string.IsNullOrWhiteSpace(text.Text)); + break; + + case SlashCommandInvocationResultSelectSubcommand select: + Assert.False(string.IsNullOrWhiteSpace(select.Title)); + Assert.NotEmpty(select.Options); + break; + + case SlashCommandInvocationResultAgentPrompt prompt: + Assert.False(string.IsNullOrWhiteSpace(prompt.DisplayPrompt)); + Assert.False(string.IsNullOrWhiteSpace(prompt.Prompt)); + break; + + case SlashCommandInvocationResultCompleted completed: + Assert.True(completed.Message is null || !string.IsNullOrWhiteSpace(completed.Message)); + break; + + default: + Assert.Fail($"Unexpected invocation result: {result.GetType().Name}"); + break; + } + + await session.DisposeAsync(); + } + + [Fact] + public async Task Session_Commands_Execute_Runs_Registered_Command_Handler() + { + CommandContext? capturedContext = null; + var session = await CreateSessionAsync(new SessionConfig + { + Commands = + [ + new CommandDefinition + { + Name = "deploy", + Description = "Deploy the app", + Handler = ctx => + { + capturedContext = ctx; + return Task.CompletedTask; + }, + }, + ], + }); + + await TestHelper.WaitForConditionAsync( + async () => + { + var commands = await session.Rpc.Commands.ListAsync(new CommandsListRequest + { + IncludeBuiltins = false, + IncludeClientCommands = true, + IncludeSkills = false, + }); + return commands.Commands.Any(c => IsCommand(c, "deploy", SlashCommandKind.Client)); + }, + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: "Timed out waiting for registered command to be listed."); + + var result = await session.Rpc.Commands.ExecuteAsync("deploy", "production"); + + Assert.Null(result.Error); + await TestHelper.WaitForConditionAsync( + () => capturedContext is not null, + timeout: TimeSpan.FromSeconds(10), + timeoutMessage: "Timed out waiting for command handler execution."); + Assert.Equal(session.SessionId, capturedContext!.SessionId); + Assert.Equal("/deploy production", capturedContext.Command); + Assert.Equal("deploy", capturedContext.CommandName); + Assert.Equal("production", capturedContext.Args); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Session_Commands_Enqueue_Accepts_Deterministic_Command() + { + var session = await CreateSessionAsync(); + + var result = await session.Rpc.Commands.EnqueueAsync("/help"); + + Assert.True(result.Queued); + + await session.DisposeAsync(); + } + + [Fact] + public async Task Session_Commands_RespondToQueuedCommand_Returns_False_For_Unknown_RequestId() + { + var session = await CreateSessionAsync(); + + var result = await session.Rpc.Commands.RespondToQueuedCommandAsync( + "missing-queued-command-request", + new QueuedCommandResult { Handled = false }); + + Assert.False(result.Success); + + await session.DisposeAsync(); + } + [Fact] public async Task Session_With_Commands_Creates_Successfully() { @@ -134,4 +304,20 @@ public void Resume_Config_Commands_Are_Cloned() Assert.Single(clone.Commands!); Assert.Equal("deploy", clone.Commands![0].Name); } + + private static bool IsCommand(SlashCommandInfo command, string name, SlashCommandKind kind) + { + return string.Equals(command.Name, name, StringComparison.OrdinalIgnoreCase) && command.Kind == kind; + } + + private static bool IsKnownBuiltin(SlashCommandInfo command) + { + return command.Kind == SlashCommandKind.Builtin && + KnownBuiltinCommands.Contains(command.Name, StringComparer.OrdinalIgnoreCase); + } + + private static string FormatCommands(IEnumerable commands) + { + return string.Join(", ", commands.Select(c => $"{c.Name}:{c.Kind.Value}")); + } } diff --git a/dotnet/test/E2E/CompactionE2ETests.cs b/dotnet/test/E2E/CompactionE2ETests.cs index 63d467535..6060ce7a3 100644 --- a/dotnet/test/E2E/CompactionE2ETests.cs +++ b/dotnet/test/E2E/CompactionE2ETests.cs @@ -98,4 +98,43 @@ public async Task Should_Not_Emit_Compaction_Events_When_Infinite_Sessions_Disab // Should not have any compaction events when disabled Assert.Empty(compactionEvents); } + + [Fact] + public async Task Should_Return_Empty_Handoff_Summary_For_Fresh_Session() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.History.SummarizeForHandoffAsync(); + + Assert.NotNull(result); + Assert.NotNull(result.Summary); + Assert.Equal(string.Empty, result.Summary); + } + + [Fact] + public async Task Should_Summarize_For_Handoff_After_NonEphemeral_Log_Event() + { + await using var session = await CreateSessionAsync(); + + await session.LogAsync("handoff summary log coverage"); + + var result = await session.Rpc.History.SummarizeForHandoffAsync(); + + Assert.NotNull(result); + Assert.NotNull(result.Summary); + } + + [Fact] + public async Task Should_Report_No_Op_When_Cancelling_Compaction_Without_In_Flight_Work() + { + await using var session = await CreateSessionAsync(); + + var backgroundResult = await session.Rpc.History.CancelBackgroundCompactionAsync(); + var manualResult = await session.Rpc.History.AbortManualCompactionAsync(); + + Assert.NotNull(backgroundResult); + Assert.False(backgroundResult.Cancelled); + Assert.NotNull(manualResult); + Assert.False(manualResult.Aborted); + } } diff --git a/dotnet/test/E2E/PermissionE2ETests.cs b/dotnet/test/E2E/PermissionE2ETests.cs index 53b52c31c..08d36129f 100644 --- a/dotnet/test/E2E/PermissionE2ETests.cs +++ b/dotnet/test/E2E/PermissionE2ETests.cs @@ -575,4 +575,177 @@ await session.SendAndWaitAsync(new MessageOptions await session.Rpc.Permissions.SetApproveAllAsync(false); } } + + [Fact] + public async Task Should_Configure_And_Update_Permission_Paths() + { + var session = await CreateSessionAsync(); + var configuredAllowedDirectory = CreateUniqueWorkDirectory("configured-allowed"); + var addedAllowedDirectory = CreateUniqueWorkDirectory("added-allowed"); + var newPrimaryDirectory = CreateUniqueWorkDirectory("new-primary"); + + var configureResult = await session.Rpc.Permissions.ConfigureAsync( + approveAllToolPermissionRequests: false, + approveAllReadPermissionRequests: true, + rules: new PermissionRulesSet + { + Approved = [new PermissionRule { Kind = "read", Argument = null }], + Denied = [new PermissionRule { Kind = "write", Argument = null }], + }, + paths: new PermissionPathsConfig + { + WorkspacePath = Ctx.WorkDir, + AdditionalDirectories = [configuredAllowedDirectory], + IncludeTempDirectory = false, + Unrestricted = false, + }, + urls: new PermissionUrlsConfig + { + InitialAllowed = ["https://example.invalid/permissions-configure"], + Unrestricted = false, + }); + Assert.True(configureResult.Success); + + var configuredList = await session.Rpc.Permissions.Paths.ListAsync(); + AssertPathEqual(Ctx.WorkDir, configuredList.Primary); + AssertContainsPath(configuredList.Directories, Ctx.WorkDir); + AssertContainsPath(configuredList.Directories, configuredAllowedDirectory); + + var addResult = await session.Rpc.Permissions.Paths.AddAsync(addedAllowedDirectory); + Assert.True(addResult.Success); + + var allowedCheck = await session.Rpc.Permissions.Paths.IsPathWithinAllowedDirectoriesAsync( + Path.Join(addedAllowedDirectory, "child.txt")); + Assert.True(allowedCheck.Allowed); + + var updatePrimaryResult = await session.Rpc.Permissions.Paths.UpdatePrimaryAsync(newPrimaryDirectory); + Assert.True(updatePrimaryResult.Success); + + var updatedList = await session.Rpc.Permissions.Paths.ListAsync(); + AssertPathEqual(newPrimaryDirectory, updatedList.Primary); + AssertContainsPath(updatedList.Directories, newPrimaryDirectory); + + var newPrimaryWorkspaceCheck = await session.Rpc.Permissions.Paths.IsPathWithinWorkspaceAsync( + Path.Join(newPrimaryDirectory, "child.txt")); + Assert.True(newPrimaryWorkspaceCheck.Allowed); + } + + [Fact] + public async Task Should_Invoke_Permission_State_Rpc_Apis() + { + var session = await CreateSessionAsync(); + + var pendingRequests = await session.Rpc.Permissions.PendingRequestsAsync(); + Assert.Empty(pendingRequests.Items); + + var setRequiredResult = await session.Rpc.Permissions.SetRequiredAsync(true); + Assert.True(setRequiredResult.Success); + + var clearRequiredResult = await session.Rpc.Permissions.SetRequiredAsync(false); + Assert.True(clearRequiredResult.Success); + + var promptShownResult = await session.Rpc.Permissions.NotifyPromptShownAsync( + $"Permission prompt shown from {nameof(Should_Invoke_Permission_State_Rpc_Apis)}"); + Assert.True(promptShownResult.Success); + + var rule = new PermissionRule + { + Kind = "commands", + Argument = $"dotnet-permission-e2e-{Guid.NewGuid():N}", + }; + + var addRuleResult = await session.Rpc.Permissions.ModifyRulesAsync( + PermissionsModifyRulesScope.Session, + add: [rule]); + Assert.True(addRuleResult.Success); + + var removeRuleResult = await session.Rpc.Permissions.ModifyRulesAsync( + PermissionsModifyRulesScope.Session, + remove: [rule]); + Assert.True(removeRuleResult.Success); + + var enableUrlsResult = await session.Rpc.Permissions.Urls.SetUnrestrictedModeAsync(true); + Assert.True(enableUrlsResult.Success); + + var disableUrlsResult = await session.Rpc.Permissions.Urls.SetUnrestrictedModeAsync(false); + Assert.True(disableUrlsResult.Success); + } + + [Fact] + public async Task Should_Invoke_Permission_Location_And_FolderTrust_Rpc_Apis() + { + var session = await CreateSessionAsync(); + var locationDirectory = CreateUniqueWorkDirectory("permission-location"); + var trustedDirectory = CreateUniqueWorkDirectory("folder-trust"); + var commandIdentifier = $"dotnet-permission-location-{Guid.NewGuid():N}"; + + var resolved = await session.Rpc.Permissions.Locations.ResolveAsync(locationDirectory); + Assert.Equal(PermissionLocationType.Dir, resolved.LocationType); + AssertPathEqual(locationDirectory, resolved.LocationKey); + + var addToolApprovalResult = await session.Rpc.Permissions.Locations.AddToolApprovalAsync( + resolved.LocationKey, + new PermissionsLocationsAddToolApprovalDetailsCommands + { + CommandIdentifiers = [commandIdentifier], + }); + Assert.True(addToolApprovalResult.Success); + + var applied = await session.Rpc.Permissions.Locations.ApplyAsync(locationDirectory); + Assert.Equal(resolved.LocationType, applied.LocationType); + AssertPathEqual(resolved.LocationKey, applied.LocationKey); + Assert.True(applied.AppliedRuleCount >= 1); + Assert.Contains(applied.AppliedRules, rule => + string.Equals(rule.Kind, "shell", StringComparison.Ordinal) && + string.Equals(rule.Argument, commandIdentifier, StringComparison.Ordinal)); + + var initialTrust = await session.Rpc.Permissions.FolderTrust.IsTrustedAsync(trustedDirectory); + Assert.False(initialTrust.Trusted); + + var addTrustedResult = await session.Rpc.Permissions.FolderTrust.AddTrustedAsync(trustedDirectory); + Assert.True(addTrustedResult.Success); + + var updatedTrust = await session.Rpc.Permissions.FolderTrust.IsTrustedAsync(trustedDirectory); + Assert.True(updatedTrust.Trusted); + } + + private string CreateUniqueWorkDirectory(string prefix) + { + var path = Path.Join(Ctx.WorkDir, $"{prefix}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void AssertContainsPath(IEnumerable paths, string expected) + { + Assert.Contains(paths, actual => PathsEqual(expected, actual)); + } + + private static void AssertPathEqual(string expected, string actual) + { + Assert.True( + PathsEqual(expected, actual), + $"Expected path '{expected}' to equal '{actual}'."); + } + + private static bool PathsEqual(string expected, string actual) + { + return string.Equals( + NormalizePath(expected), + NormalizePath(actual), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + private static string NormalizePath(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath) ?? string.Empty; + while (fullPath.Length > root.Length && + (fullPath[fullPath.Length - 1] == Path.DirectorySeparatorChar || + fullPath[fullPath.Length - 1] == Path.AltDirectorySeparatorChar)) + { + fullPath = fullPath.Substring(0, fullPath.Length - 1); + } + return fullPath; + } } diff --git a/dotnet/test/E2E/RpcEventLogE2ETests.cs b/dotnet/test/E2E/RpcEventLogE2ETests.cs new file mode 100644 index 000000000..23ff00841 --- /dev/null +++ b/dotnet/test/E2E/RpcEventLogE2ETests.cs @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +public class RpcEventLogE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_event_log", output) +{ + private static readonly TimeSpan EventLogTimeout = TimeSpan.FromSeconds(30); + private static readonly string[] TitleChangedEventTypes = ["session.title_changed"]; + + [Fact] + public async Task Should_Read_Persisted_Events_From_Beginning() + { + await using var session = await CreateSessionAsync(); + + await session.Rpc.Plan.UpdateAsync("# Event log E2E plan\n- persisted event"); + + EventsReadResult? read = null; + await TestHelper.WaitForConditionAsync( + async () => + { + read = await session.Rpc.EventLog.ReadAsync(max: 100, waitMs: TimeSpan.Zero); + return read.Events + .OfType() + .Any(evt => evt.Data.Operation == PlanChangedOperation.Create && evt.Ephemeral != true); + }, + timeout: EventLogTimeout, + timeoutMessage: "Timed out waiting for session.eventLog.read to return the persisted session.plan_changed event."); + + Assert.NotNull(read); + Assert.Equal(EventsCursorStatus.Ok, read.CursorStatus); + Assert.False(string.IsNullOrWhiteSpace(read.Cursor)); + Assert.Contains( + read.Events.OfType(), + evt => evt.Data.Operation == PlanChangedOperation.Create); + } + + [Fact] + public async Task Should_Return_Tail_Cursor_And_Read_Empty_When_No_New_Events() + { + await using var session = await CreateSessionAsync(); + + EventLogTailResult? tail = null; + EventsReadResult? read = null; + await TestHelper.WaitForConditionAsync( + async () => + { + tail = await session.Rpc.EventLog.TailAsync(); + read = await session.Rpc.EventLog.ReadAsync( + cursor: tail.Cursor, + max: 10, + waitMs: TimeSpan.Zero); + return read.CursorStatus == EventsCursorStatus.Ok && read.Events.Count == 0; + }, + timeout: EventLogTimeout, + timeoutMessage: "Timed out waiting for a stable event-log tail cursor with no immediately available events."); + + Assert.NotNull(tail); + Assert.False(string.IsNullOrWhiteSpace(tail.Cursor)); + Assert.NotNull(read); + Assert.Empty(read.Events); + Assert.False(read.HasMore); + } + + [Fact] + public async Task Should_Register_And_Release_Event_Interest_Idempotently() + { + await using var session = await CreateSessionAsync(); + + var registered = await session.Rpc.EventLog.RegisterInterestAsync("session.title_changed"); + Assert.False(string.IsNullOrWhiteSpace(registered.Handle)); + + var released = await session.Rpc.EventLog.ReleaseInterestAsync(registered.Handle); + Assert.True(released.Success); + + var releasedAgain = await session.Rpc.EventLog.ReleaseInterestAsync(registered.Handle); + Assert.True(releasedAgain.Success); + } + + [Fact] + public async Task Should_LongPoll_With_Types_Filter_For_TitleChanged_Event() + { + await using var session = await CreateSessionAsync(); + + EventsReadResult? read = null; + string expectedTitle = string.Empty; + await TestHelper.WaitForConditionAsync( + async () => + { + expectedTitle = $"EventLogTitle-{Guid.NewGuid():N}"; + var tail = await session.Rpc.EventLog.TailAsync(); + var readTask = session.Rpc.EventLog.ReadAsync( + cursor: tail.Cursor, + max: 10, + waitMs: TimeSpan.FromSeconds(5), + types: TitleChangedEventTypes); + + await session.Rpc.Name.SetAsync(expectedTitle); + read = await readTask; + + return read.Events + .OfType() + .Any(evt => string.Equals(evt.Data.Title, expectedTitle, StringComparison.Ordinal)); + }, + timeout: EventLogTimeout, + timeoutMessage: "Timed out waiting for filtered session.eventLog.read to return session.title_changed."); + + Assert.NotNull(read); + Assert.Equal(EventsCursorStatus.Ok, read.CursorStatus); + Assert.All(read.Events, evt => Assert.Equal("session.title_changed", evt.Type)); + Assert.Contains( + read.Events.OfType(), + evt => string.Equals(evt.Data.Title, expectedTitle, StringComparison.Ordinal)); + } +} diff --git a/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs b/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs index 0ba7d620e..c81863470 100644 --- a/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs +++ b/dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.Text.Json; using GitHub.Copilot.Rpc; using Xunit; using Xunit.Abstractions; @@ -16,10 +17,17 @@ public class RpcMcpAndSkillsE2ETests(E2ETestFixture fixture, ITestOutputHelper o private static async Task AssertFailureAsync(Func action, string expectedMessage) { var ex = await Assert.ThrowsAnyAsync(action); - Assert.Contains(expectedMessage, ex.ToString(), StringComparison.OrdinalIgnoreCase); + var message = ex.ToString(); + Assert.Contains(expectedMessage, message, StringComparison.OrdinalIgnoreCase); + AssertNotUnhandledMethod(message); return ex; } + private static void AssertNotUnhandledMethod(string message) + { + Assert.DoesNotContain("Unhandled method", message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Should_List_And_Toggle_Session_Skills() { @@ -43,6 +51,27 @@ public async Task Should_List_And_Toggle_Session_Skills() AssertSkill(disabledAgain, skillName, enabled: false); } + [Fact] + public async Task Should_Ensure_Skills_Are_Loaded_And_List_Invoked_Skills() + { + var skillName = $"ensure-rpc-skill-{Guid.NewGuid():N}"; + var skillsDir = CreateSkillDirectory(skillName, "Skill loaded explicitly by RPC."); + var session = await CreateSessionAsync(new SessionConfig + { + SkillDirectories = [skillsDir], + }); + + await session.Rpc.Skills.EnsureLoadedAsync(); + + var loaded = await session.Rpc.Skills.ListAsync(); + var skill = AssertSkill(loaded, skillName, enabled: true); + Assert.Equal("Skill loaded explicitly by RPC.", skill.Description); + + var invoked = await session.Rpc.Skills.GetInvokedAsync(); + Assert.NotNull(invoked.Skills); + Assert.Empty(invoked.Skills); + } + [Fact] public async Task Should_Reload_Session_Skills() { @@ -78,6 +107,68 @@ public async Task Should_List_Mcp_Servers_With_Configured_Server() Assert.Equal(McpServerStatus.Connected, server.Status); } + [Fact] + public async Task Should_Set_Mcp_Env_Value_Mode_And_Remove_GitHub_Server() + { + const string serverName = "github"; + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(serverName), + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + + var direct = await session.Rpc.Mcp.SetEnvValueModeAsync(McpSetEnvValueModeDetails.Direct); + Assert.Equal(McpSetEnvValueModeDetails.Direct, direct.Mode); + + var indirect = await session.Rpc.Mcp.SetEnvValueModeAsync(McpSetEnvValueModeDetails.Indirect); + Assert.Equal(McpSetEnvValueModeDetails.Indirect, indirect.Mode); + + var removeGitHub = await session.Rpc.Mcp.RemoveGitHubAsync(); + Assert.False(removeGitHub.Removed); + + var servers = await session.Rpc.Mcp.ListAsync(); + Assert.Contains(servers.Servers, server => + string.Equals(server.Name, serverName, StringComparison.Ordinal) + && server.Status == McpServerStatus.Connected); + } + + [Fact] + public async Task Should_Report_Mcp_Sampling_Failure_And_Cancel_Missing_Sampling() + { + const string serverName = "rpc-sampling-server"; + var session = await CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(serverName), + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + + var cancelMissing = await session.Rpc.Mcp.CancelSamplingExecutionAsync($"missing-{Guid.NewGuid():N}"); + Assert.False(cancelMissing.Cancelled); + + try + { + var result = await session.Rpc.Mcp.ExecuteSamplingAsync( + $"sampling-{Guid.NewGuid():N}", + serverName, + $"mcp-request-{Guid.NewGuid():N}", + new McpExecuteSamplingRequest()); + + Assert.Equal(McpSamplingExecutionAction.Failure, result.Action); + Assert.Null(result.Result); + Assert.False(string.IsNullOrWhiteSpace(result.Error)); + AssertNotUnhandledMethod(result.Error!); + AssertSamplingError(result.Error!); + } + catch (Exception ex) + { + var message = ex.ToString(); + AssertNotUnhandledMethod(message); + AssertSamplingError(message); + } + } + [Fact] public async Task Should_List_Plugins() { @@ -113,6 +204,113 @@ public async Task Should_List_Extensions() }); } + [Fact] + public async Task Should_Round_Trip_Mcp_App_Host_Context() + { + await using var client = CreateMcpAppsClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await session.Rpc.Mcp.Apps.SetHostContextAsync(new McpAppsSetHostContextDetails + { + AvailableDisplayModes = + [ + McpAppsSetHostContextDetailsAvailableDisplayMode.Inline, + McpAppsSetHostContextDetailsAvailableDisplayMode.Fullscreen, + ], + DisplayMode = McpAppsSetHostContextDetailsDisplayMode.Inline, + Locale = "en-GB", + Platform = McpAppsSetHostContextDetailsPlatform.Desktop, + Theme = McpAppsSetHostContextDetailsTheme.Dark, + TimeZone = "Etc/UTC", + UserAgent = "dotnet-sdk-e2e", + }); + + var result = await session.Rpc.Mcp.Apps.GetHostContextAsync(); + + Assert.Equal("inline", result.Context.DisplayMode?.Value); + Assert.Equal("en-GB", result.Context.Locale); + Assert.Equal("desktop", result.Context.Platform?.Value); + Assert.Equal("dark", result.Context.Theme?.Value); + Assert.Equal("Etc/UTC", result.Context.TimeZone); + Assert.Equal("dotnet-sdk-e2e", result.Context.UserAgent); + Assert.NotNull(result.Context.AvailableDisplayModes); + var displayModes = result.Context.AvailableDisplayModes!; + Assert.Equal(2, displayModes.Count); + Assert.Contains(displayModes, mode => mode.Value == "inline"); + Assert.Contains(displayModes, mode => mode.Value == "fullscreen"); + } + + [Fact] + public async Task Should_Diagnose_And_Report_Mcp_App_Capability_Errors() + { + const string serverName = "rpc-apps-server"; + const string otherServerName = "rpc-apps-other-server"; + var mcpServers = CreateTestMcpServers(serverName, otherServerName); + ((McpStdioServerConfig)mcpServers[serverName]).Env = + new Dictionary { ["MCP_APP_RPC_VALUE"] = "from-app-rpc" }; + + await using var client = CreateMcpAppsClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + McpServers = mcpServers, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + await WaitForMcpServerStatusAsync(session, otherServerName, McpServerStatus.Connected); + + var diagnose = await session.Rpc.Mcp.Apps.DiagnoseAsync(serverName); + Assert.NotNull(diagnose.Capability); + Assert.True(diagnose.Server.Connected); + Assert.True(diagnose.Server.ToolCount >= 1); + Assert.Equal(0, diagnose.Server.ToolsWithUiMeta); + Assert.Empty(diagnose.Server.SampleToolNames); + + await AssertFailureAsync( + () => session.Rpc.Mcp.Apps.ListToolsAsync(serverName, originServerName: serverName), + "mcp-apps"); + await AssertFailureAsync( + () => session.Rpc.Mcp.Apps.ListToolsAsync(serverName, originServerName: otherServerName), + "mcp-apps"); + await AssertFailureAsync( + () => session.Rpc.Mcp.Apps.CallToolAsync( + serverName, + "get_env", + originServerName: serverName, + arguments: new Dictionary + { + ["name"] = ParseJsonElement("\"MCP_APP_RPC_VALUE\""), + }), + "mcp-apps"); + } + + [Fact] + public async Task Should_Report_Error_When_Mcp_App_Resource_Is_Not_Available() + { + const string serverName = "rpc-apps-resource-server"; + await using var client = CreateMcpAppsClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + McpServers = CreateTestMcpServers(serverName), + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await WaitForMcpServerStatusAsync(session, serverName, McpServerStatus.Connected); + + var ex = await Assert.ThrowsAnyAsync( + () => session.Rpc.Mcp.Apps.ReadResourceAsync(serverName, "ui://missing-resource")); + var message = ex.ToString(); + AssertNotUnhandledMethod(message); + Assert.True( + message.Contains("resource", StringComparison.OrdinalIgnoreCase) + || message.Contains("not found", StringComparison.OrdinalIgnoreCase) + || message.Contains("Method not found", StringComparison.OrdinalIgnoreCase), + message); + } + [Fact] public async Task Should_Report_Error_When_Mcp_Host_Is_Not_Initialized() { @@ -194,6 +392,18 @@ private string CreateSkillDirectory(string skillName, string description) return skillsDir; } + private CopilotClient CreateMcpAppsClient() + { + var environment = Ctx.GetEnvironment(); + environment["COPILOT_MCP_APPS"] = "true"; + environment["MCP_APPS"] = "true"; + + return Ctx.CreateClient(options: new CopilotClientOptions + { + Environment = environment, + }); + } + private static void CreateSkill(string skillsDir, string skillName, string description) { var skillSubdir = Path.Join(skillsDir, skillName); @@ -219,4 +429,35 @@ private static RpcSkill AssertSkill(RpcSkillList list, string skillName, bool en Assert.EndsWith(Path.Join(skillName, "SKILL.md"), skill.Path); return skill; } + + private static string? GetStringProperty(IDictionary properties, string name) + { + return properties.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static void AssertToolTextContent(IDictionary result, string expectedText) + { + Assert.True(result.TryGetValue("content", out var content)); + Assert.Equal(JsonValueKind.Array, content.ValueKind); + var contentItem = Assert.Single(content.EnumerateArray()); + Assert.Equal("text", contentItem.GetProperty("type").GetString()); + Assert.Equal(expectedText, contentItem.GetProperty("text").GetString()); + } + + private static void AssertSamplingError(string message) + { + Assert.True( + message.Contains("sampling", StringComparison.OrdinalIgnoreCase) + || message.Contains("message", StringComparison.OrdinalIgnoreCase) + || message.Contains("request", StringComparison.OrdinalIgnoreCase), + message); + } } diff --git a/dotnet/test/E2E/RpcQueueE2ETests.cs b/dotnet/test/E2E/RpcQueueE2ETests.cs new file mode 100644 index 000000000..b3b383e1e --- /dev/null +++ b/dotnet/test/E2E/RpcQueueE2ETests.cs @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +public class RpcQueueE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_queue", output) +{ + [Fact] + public async Task Fresh_Queue_Is_Empty_And_Empty_Mutations_Are_Noops() + { + await using var session = await CreateSessionAsync(); + + await AssertQueueEmptyAsync(session); + + var remove = await session.Rpc.Queue.RemoveMostRecentAsync(); + Assert.False(remove.Removed); + await AssertQueueEmptyAsync(session); + + await session.Rpc.Queue.ClearAsync(); + await AssertQueueEmptyAsync(session); + + var removeAfterClear = await session.Rpc.Queue.RemoveMostRecentAsync(); + Assert.False(removeAfterClear.Removed); + await AssertQueueEmptyAsync(session); + } + + [Fact] + public async Task PendingItems_Reports_Queued_Command_And_Remove_And_Clear_Update_Queue() + { + await using var session = await CreateSessionAsync(); + var interest = await session.Rpc.EventLog.RegisterInterestAsync("command.queued"); + CommandQueuedEvent? firstEvent = null; + bool respondedToFirst = false; + + try + { + var firstCommand = $"/sdk-queue-first-{Guid.NewGuid():N}"; + var secondCommand = $"/sdk-queue-second-{Guid.NewGuid():N}"; + var thirdCommand = $"/sdk-queue-third-{Guid.NewGuid():N}"; + var firstQueued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using var subscription = session.On(evt => + { + if (evt is CommandQueuedEvent queued && + string.Equals(queued.Data.Command, firstCommand, StringComparison.Ordinal)) + { + firstQueued.TrySetResult(queued); + } + }); + + var first = await session.Rpc.Commands.EnqueueAsync(firstCommand); + Assert.True(first.Queued); + + firstEvent = await firstQueued.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + var second = await session.Rpc.Commands.EnqueueAsync(secondCommand); + Assert.True(second.Queued); + + await WaitForCommandInPendingItemsAsync(session, secondCommand); + + var remove = await session.Rpc.Queue.RemoveMostRecentAsync(); + Assert.True(remove.Removed); + await WaitForCommandNotInPendingItemsAsync(session, secondCommand); + + var third = await session.Rpc.Commands.EnqueueAsync(thirdCommand); + Assert.True(third.Queued); + + await WaitForCommandInPendingItemsAsync(session, thirdCommand); + + await session.Rpc.Queue.ClearAsync(); + await WaitForCommandNotInPendingItemsAsync(session, thirdCommand); + + var completed = await session.Rpc.Commands.RespondToQueuedCommandAsync( + firstEvent.Data.RequestId, + new QueuedCommandResult + { + Handled = true, + StopProcessingQueue = true, + }); + respondedToFirst = completed.Success; + Assert.True(completed.Success); + await WaitForQueueEmptyAsync( + session, + "Timed out waiting for queue to empty after completing the blocked command."); + } + finally + { + if (!respondedToFirst && firstEvent is not null) + { + _ = await session.Rpc.Commands.RespondToQueuedCommandAsync( + firstEvent.Data.RequestId, + new QueuedCommandResult + { + Handled = true, + StopProcessingQueue = true, + }); + } + + await session.Rpc.Queue.ClearAsync(); + if (!string.IsNullOrWhiteSpace(interest.Handle)) + { + _ = await session.Rpc.EventLog.ReleaseInterestAsync(interest.Handle); + } + } + } + + private static async Task AssertQueueEmptyAsync(CopilotSession session) + { + var pending = await session.Rpc.Queue.PendingItemsAsync(); + Assert.Empty(pending.Items); + Assert.Empty(pending.SteeringMessages); + } + + private static async Task WaitForCommandInPendingItemsAsync(CopilotSession session, string command) + { + QueuePendingItems? item = null; + await TestHelper.WaitForConditionAsync( + async () => + { + var pending = await session.Rpc.Queue.PendingItemsAsync(); + item = pending.Items.SingleOrDefault(i => IsPendingCommand(i, command)); + return item is not null; + }, + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: $"Timed out waiting for queued command '{command}' to appear in pending items."); + + Assert.NotNull(item); + Assert.Equal(QueuePendingItemsKind.Command, item.Kind); + Assert.Contains(command.TrimStart('/'), item.DisplayText, StringComparison.Ordinal); + } + + private static async Task WaitForCommandNotInPendingItemsAsync(CopilotSession session, string command) + { + await TestHelper.WaitForConditionAsync( + async () => + { + var pending = await session.Rpc.Queue.PendingItemsAsync(); + return !pending.Items.Any(i => IsPendingCommand(i, command)); + }, + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: $"Timed out waiting for queued command '{command}' to leave pending items."); + } + + private static async Task WaitForQueueEmptyAsync(CopilotSession session, string timeoutMessage) + { + await TestHelper.WaitForConditionAsync( + async () => + { + var pending = await session.Rpc.Queue.PendingItemsAsync(); + return pending.Items.Count == 0 && pending.SteeringMessages.Count == 0; + }, + timeout: TimeSpan.FromSeconds(30), + timeoutMessage: timeoutMessage); + + await AssertQueueEmptyAsync(session); + } + + private static bool IsPendingCommand(QueuePendingItems item, string command) + { + return item.Kind == QueuePendingItemsKind.Command && + (string.Equals(item.DisplayText, command, StringComparison.Ordinal) || + item.DisplayText.Contains(command.TrimStart('/'), StringComparison.Ordinal)); + } +} diff --git a/dotnet/test/E2E/RpcRemoteE2ETests.cs b/dotnet/test/E2E/RpcRemoteE2ETests.cs new file mode 100644 index 000000000..c25c501e2 --- /dev/null +++ b/dotnet/test/E2E/RpcRemoteE2ETests.cs @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +public class RpcRemoteE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_remote", output) +{ + private static readonly TimeSpan EventTimeout = TimeSpan.FromSeconds(30); + + [Fact] + public async Task Should_Treat_Remote_Off_As_No_Op_Or_Implemented_Error() + { + await using var session = await CreateSessionAsync(); + + var result = await TryInvokeImplementedAsync( + () => session.Rpc.Remote.EnableAsync(RemoteSessionMode.Off), + "session.remote.enable"); + + if (result is not null) + { + Assert.False(result.RemoteSteerable); + Assert.True(string.IsNullOrEmpty(result.Url)); + } + } + + [Fact] + public async Task Should_Treat_Remote_Disable_As_No_Op_Or_Implemented_Error() + { + await using var session = await CreateSessionAsync(); + + await TryInvokeImplementedAsync( + () => session.Rpc.Remote.DisableAsync(), + "session.remote.disable"); + } + + [Fact] + public async Task Should_Notify_Steerable_Changed_Event_And_Persist_Flag() + { + await using var session = await CreateSessionAsync(); + + await session.Rpc.Remote.NotifySteerableChangedAsync(true); + + await WaitForRemoteSteerableEventAsync(session, expected: true); + Assert.True((await Client.Rpc.Sessions.GetPersistedRemoteSteerableAsync(session.SessionId)).RemoteSteerable); + + await session.Rpc.Remote.NotifySteerableChangedAsync(false); + + await WaitForRemoteSteerableEventAsync(session, expected: false); + Assert.False((await Client.Rpc.Sessions.GetPersistedRemoteSteerableAsync(session.SessionId)).RemoteSteerable); + } + + private static async Task WaitForRemoteSteerableEventAsync(CopilotSession session, bool expected) + { + await TestHelper.WaitForConditionAsync( + async () => + { + var events = await session.GetEventsAsync(); + return events + .OfType() + .Any(evt => evt.Data.RemoteSteerable == expected); + }, + timeout: EventTimeout, + timeoutMessage: $"Timed out waiting for session.remote_steerable_changed={expected}."); + } + + private static async Task TryInvokeImplementedAsync(Func> action, string method) where T : class + { + try + { + return await action(); + } + catch (Exception ex) + { + Assert.DoesNotContain($"Unhandled method {method}", ex.ToString(), StringComparison.OrdinalIgnoreCase); + return null; + } + } + + private static async Task TryInvokeImplementedAsync(Func action, string method) + { + try + { + await action(); + } + catch (Exception ex) + { + Assert.DoesNotContain($"Unhandled method {method}", ex.ToString(), StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/dotnet/test/E2E/RpcScheduleE2ETests.cs b/dotnet/test/E2E/RpcScheduleE2ETests.cs new file mode 100644 index 000000000..03f6eaf2f --- /dev/null +++ b/dotnet/test/E2E/RpcScheduleE2ETests.cs @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class RpcScheduleE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_schedule", output) +{ + [Fact] + public async Task Should_List_No_Schedules_For_Fresh_Session() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Schedule.ListAsync(); + + Assert.NotNull(result.Entries); + Assert.Empty(result.Entries); + } + + [Fact] + public async Task Should_Return_Null_Entry_When_Stopping_Unknown_Schedule() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Schedule.StopAsync(long.MaxValue); + + Assert.Null(result.Entry); + Assert.Empty((await session.Rpc.Schedule.ListAsync()).Entries); + } +} diff --git a/dotnet/test/E2E/RpcServerE2ETests.cs b/dotnet/test/E2E/RpcServerE2ETests.cs index 39913060c..dea86df81 100644 --- a/dotnet/test/E2E/RpcServerE2ETests.cs +++ b/dotnet/test/E2E/RpcServerE2ETests.cs @@ -5,12 +5,28 @@ using GitHub.Copilot.Test.Harness; using Xunit; using Xunit.Abstractions; +using RpcSessionFsSetProviderCapabilities = GitHub.Copilot.Rpc.SessionFsSetProviderCapabilities; +using RpcSessionFsSetProviderConventions = GitHub.Copilot.Rpc.SessionFsSetProviderConventions; +using RpcSessionContext = GitHub.Copilot.Rpc.SessionContext; +using RpcSessionListFilter = GitHub.Copilot.Rpc.SessionListFilter; +using RpcSessionMetadata = GitHub.Copilot.Rpc.SessionMetadata; namespace GitHub.Copilot.Test.E2E; public class RpcServerE2ETests(E2ETestFixture fixture, ITestOutputHelper output) : E2ETestBase(fixture, "rpc_server", output) { + private static readonly TimeSpan SessionPersistenceTimeout = TimeSpan.FromSeconds(30); + + private static async Task AssertImplementedFailureAsync(Func action, string method) + { + var ex = await Assert.ThrowsAnyAsync(action); + var text = ex.ToString(); + Assert.DoesNotContain($"Unhandled method {method}", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("session", text, StringComparison.OrdinalIgnoreCase); + return ex; + } + private CopilotClient CreateAuthenticatedClient(string token) { var env = new Dictionary(Ctx.GetEnvironment()) @@ -37,6 +53,74 @@ private async Task ConfigureAuthenticatedUserAsync( QuotaSnapshots: quotaSnapshots)); } + private string CreateUniqueWorkDirectory(string prefix) + { + var directory = Path.Join(Ctx.WorkDir, $"{prefix}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + return directory; + } + + private static bool PathEquals(string? expected, string? actual) + { + if (expected is null || actual is null) + { + return expected is null && actual is null; + } + + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var normalizedExpected = Path.GetFullPath(expected).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedActual = Path.GetFullPath(actual).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return string.Equals(normalizedExpected, normalizedActual, comparison); + } + + private async Task SaveAndWaitForEventFileAsync(string sessionId) + => await SaveAndWaitForEventFileAsync(Client, sessionId); + + private static async Task SaveAndWaitForEventFileAsync(CopilotClient client, string sessionId) + { + var saveResult = await client.Rpc.Sessions.SaveAsync(sessionId); + Assert.NotNull(saveResult); + + var pathResult = await client.Rpc.Sessions.GetEventFilePathAsync(sessionId); + Assert.False(string.IsNullOrWhiteSpace(pathResult.FilePath)); + Assert.True(Path.IsPathRooted(pathResult.FilePath), $"Expected an absolute event file path, got '{pathResult.FilePath}'."); + Assert.Equal("events.jsonl", Path.GetFileName(pathResult.FilePath)); + + return pathResult.FilePath; + } + + private static async Task PersistSessionAsync(CopilotClient client, CopilotSession session, string marker) + { + await session.LogAsync(marker); + return await SaveAndWaitForEventFileAsync(client, session.SessionId); + } + + private async Task WaitForListedSessionAsync( + string sessionId, + RpcSessionListFilter? filter = null, + long? metadataLimit = null) + => await WaitForListedSessionAsync(Client, sessionId, filter, metadataLimit); + + private static async Task WaitForListedSessionAsync( + CopilotClient client, + string sessionId, + RpcSessionListFilter? filter = null, + long? metadataLimit = null) + { + RpcSessionMetadata? metadata = null; + await TestHelper.WaitForConditionAsync( + async () => + { + var list = await client.Rpc.Sessions.ListAsync(metadataLimit: metadataLimit, filter: filter); + metadata = list.Sessions.FirstOrDefault(session => string.Equals(session.SessionId, sessionId, StringComparison.Ordinal)); + return metadata is not null; + }, + timeout: SessionPersistenceTimeout, + timeoutMessage: $"Timed out waiting for session '{sessionId}' to appear in sessions.list."); + + return metadata!; + } + [Fact] public async Task Should_Call_Rpc_Ping_With_Typed_Params_And_Result() { @@ -105,6 +189,303 @@ public async Task Should_Call_Rpc_Tools_List_With_Typed_Result() Assert.All(result.Tools, tool => Assert.False(string.IsNullOrWhiteSpace(tool.Name))); } + [Fact] + public async Task Should_Call_Rpc_SessionFs_SetProvider_With_Typed_Result() + { + await using var client = Ctx.CreateClient(); + await client.StartAsync(); + + var result = await client.Rpc.SessionFs.SetProviderAsync( + initialCwd: "/", + sessionStatePath: "/session-state", + conventions: RpcSessionFsSetProviderConventions.Posix, + capabilities: new RpcSessionFsSetProviderCapabilities { Sqlite = true }); + + Assert.True(result.Success); + } + + [Fact] + public async Task Should_Add_Secret_Filter_Values() + { + var environment = Ctx.GetEnvironment(); + environment["COPILOT_ENABLE_SECRET_FILTERING"] = "true"; + await using var client = Ctx.CreateClient(options: new CopilotClientOptions + { + Environment = environment, + }); + await client.StartAsync(); + var secret = $"rpc-secret-{Guid.NewGuid():N}"; + + var result = await client.Rpc.Secrets.AddFilterValuesAsync([secret]); + + Assert.True(result.Ok); + } + + [Fact] + public async Task Should_List_Find_And_Inspect_Persisted_Session_State() + { + var token = $"rpc-server-list-token-{Guid.NewGuid():N}"; + await ConfigureAuthenticatedUserAsync(token); + await using var client = CreateAuthenticatedClient(token); + var sessionId = Guid.NewGuid().ToString(); + var workingDirectory = CreateUniqueWorkDirectory("server-rpc-list"); + var missingTaskId = $"missing-task-{Guid.NewGuid():N}"; + var missingSessionId = Guid.NewGuid().ToString(); + + var session = await client.CreateSessionAsync(new SessionConfig + { + SessionId = sessionId, + WorkingDirectory = workingDirectory, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + try + { + var eventFilePath = await SaveAndWaitForEventFileAsync(client, sessionId); + Assert.Contains(sessionId, eventFilePath, StringComparison.OrdinalIgnoreCase); + + var listed = await client.Rpc.Sessions.ListAsync( + metadataLimit: 0, + filter: new RpcSessionListFilter { Cwd = workingDirectory }); + Assert.NotNull(listed.Sessions); + Assert.DoesNotContain(listed.Sessions, session => !PathEquals(workingDirectory, session.Context?.Cwd)); + + var prefix = sessionId[..8]; + var byPrefix = await client.Rpc.Sessions.FindByPrefixAsync(prefix); + Assert.Null(byPrefix.SessionId); + + var byTaskId = await client.Rpc.Sessions.FindByTaskIdAsync(missingTaskId); + Assert.Null(byTaskId.SessionId); + + var lastForContext = await client.Rpc.Sessions.GetLastForContextAsync(new RpcSessionContext { Cwd = workingDirectory }); + Assert.Null(lastForContext.SessionId); + + var sizes = await client.Rpc.Sessions.GetSizesAsync(); + Assert.NotNull(sizes.Sizes); + if (sizes.Sizes.TryGetValue(sessionId, out var size)) + { + Assert.True(size >= 0); + } + + var inUse = await client.Rpc.Sessions.CheckInUseAsync([sessionId, missingSessionId]); + Assert.DoesNotContain(missingSessionId, inUse.InUse); + + var remoteSteerable = await client.Rpc.Sessions.GetPersistedRemoteSteerableAsync(sessionId); + Assert.Null(remoteSteerable.RemoteSteerable); + } + finally + { + await session.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Enrich_Basic_Session_Metadata() + { + var token = $"rpc-server-enrich-token-{Guid.NewGuid():N}"; + await ConfigureAuthenticatedUserAsync(token); + await using var client = CreateAuthenticatedClient(token); + var sessionId = Guid.NewGuid().ToString(); + var workingDirectory = CreateUniqueWorkDirectory("server-rpc-enrich"); + + var session = await client.CreateSessionAsync(new SessionConfig + { + SessionId = sessionId, + WorkingDirectory = workingDirectory, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + try + { + await SaveAndWaitForEventFileAsync(client, sessionId); + + var basic = new RpcSessionMetadata + { + SessionId = sessionId, + StartTime = DateTimeOffset.UtcNow.ToString("O"), + ModifiedTime = DateTimeOffset.UtcNow.ToString("O"), + IsRemote = false, + Name = "Basic metadata", + Context = new RpcSessionContext { Cwd = workingDirectory }, + }; + + var result = await client.Rpc.Sessions.EnrichMetadataAsync([ + basic, + ]); + + var enriched = Assert.Single(result.Sessions); + Assert.Equal(sessionId, enriched.SessionId); + Assert.True(PathEquals(workingDirectory, enriched.Context?.Cwd), + $"Expected enriched session cwd '{workingDirectory}', actual '{enriched.Context?.Cwd}'."); + Assert.False(enriched.IsRemote); + } + finally + { + await session.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Close_Active_Session_And_Release_Lock() + { + var token = $"rpc-server-close-token-{Guid.NewGuid():N}"; + await ConfigureAuthenticatedUserAsync(token); + await using var client = CreateAuthenticatedClient(token); + var sessionId = Guid.NewGuid().ToString(); + var workingDirectory = CreateUniqueWorkDirectory("server-rpc-close"); + var session = await client.CreateSessionAsync(new SessionConfig + { + SessionId = sessionId, + WorkingDirectory = workingDirectory, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await PersistSessionAsync(client, session, "SERVER_RPC_CLOSE_READY"); + + var closeResult = await client.Rpc.Sessions.CloseAsync(sessionId); + Assert.NotNull(closeResult); + + var releaseResult = await client.Rpc.Sessions.ReleaseLockAsync(sessionId); + Assert.NotNull(releaseResult); + + var inUse = await client.Rpc.Sessions.CheckInUseAsync([sessionId]); + Assert.DoesNotContain(sessionId, inUse.InUse); + } + + [Fact] + public async Task Should_Check_In_Use_Session_From_Another_Runtime_And_Release_Lock() + { + var sessionId = Guid.NewGuid().ToString(); + var workingDirectory = CreateUniqueWorkDirectory("server-rpc-in-use"); + await using var otherClient = Ctx.CreateClient(useStdio: true); + await using var otherSession = await otherClient.CreateSessionAsync(new SessionConfig + { + SessionId = sessionId, + WorkingDirectory = workingDirectory, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await Client.StartAsync(); + + await TestHelper.WaitForConditionAsync( + async () => + { + var result = await Client.Rpc.Sessions.CheckInUseAsync([sessionId]); + return result.InUse.Contains(sessionId); + }, + timeout: SessionPersistenceTimeout, + timeoutMessage: $"Timed out waiting for sessions.checkInUse to report '{sessionId}' as held by another runtime."); + + var releaseResult = await otherClient.Rpc.Sessions.ReleaseLockAsync(sessionId); + Assert.NotNull(releaseResult); + + await TestHelper.WaitForConditionAsync( + async () => + { + var result = await Client.Rpc.Sessions.CheckInUseAsync([sessionId]); + return !result.InUse.Contains(sessionId); + }, + timeout: SessionPersistenceTimeout, + timeoutMessage: $"Timed out waiting for sessions.releaseLock to release '{sessionId}'."); + } + + [Fact] + public async Task Should_Prune_DryRun_And_BulkDelete_Persisted_Session() + { + var token = $"rpc-server-delete-token-{Guid.NewGuid():N}"; + await ConfigureAuthenticatedUserAsync(token); + await using var client = CreateAuthenticatedClient(token); + var sessionId = Guid.NewGuid().ToString(); + var missingSessionId = Guid.NewGuid().ToString(); + var workingDirectory = CreateUniqueWorkDirectory("server-rpc-delete"); + + var session = await client.CreateSessionAsync(new SessionConfig + { + SessionId = sessionId, + WorkingDirectory = workingDirectory, + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + + await SaveAndWaitForEventFileAsync(client, sessionId); + await client.Rpc.Sessions.CloseAsync(sessionId); + + var prune = await client.Rpc.Sessions.PruneOldAsync( + olderThanDays: 0, + dryRun: true, + includeNamed: true, + excludeSessionIds: []); + + Assert.True(prune.DryRun); + Assert.DoesNotContain(missingSessionId, prune.Candidates); + Assert.DoesNotContain(sessionId, prune.Deleted); + Assert.True(prune.FreedBytes >= 0); + + var delete = await client.Rpc.Sessions.BulkDeleteAsync([sessionId, missingSessionId]); + Assert.True(delete.FreedBytes.TryGetValue(sessionId, out var freedBytes), $"Expected sessions.bulkDelete to delete '{sessionId}'."); + Assert.True(freedBytes >= 0); + if (delete.FreedBytes.TryGetValue(missingSessionId, out var missingFreedBytes)) + { + Assert.Equal(0, missingFreedBytes); + } + + await TestHelper.WaitForConditionAsync( + async () => + { + var list = await client.Rpc.Sessions.ListAsync(); + return list.Sessions.All(s => s.SessionId != sessionId); + }, + timeout: SessionPersistenceTimeout, + timeoutMessage: $"Timed out waiting for sessions.bulkDelete to remove '{sessionId}'."); + + GC.KeepAlive(session); + } + + [Fact] + public async Task Should_Set_Additional_Plugins_And_Reload_Deferred_Hooks() + { + await Client.StartAsync(); + var clearPlugins = await Client.Rpc.Sessions.SetAdditionalPluginsAsync([]); + Assert.NotNull(clearPlugins); + + var sessionId = Guid.NewGuid().ToString(); + var workingDirectory = CreateUniqueWorkDirectory("server-rpc-hooks"); + var session = await CreateSessionAsync(new SessionConfig + { + SessionId = sessionId, + WorkingDirectory = workingDirectory, + EnableConfigDiscovery = false, + }); + + try + { + var reload = await Client.Rpc.Sessions.ReloadPluginHooksAsync(sessionId, deferRepoHooks: true); + Assert.NotNull(reload); + + var loaded = await Client.Rpc.Sessions.LoadDeferredRepoHooksAsync(sessionId); + Assert.NotNull(loaded.StartupPrompts); + Assert.Equal(0, loaded.HookCount); + Assert.Empty(loaded.StartupPrompts); + } + finally + { + await Client.Rpc.Sessions.SetAdditionalPluginsAsync([]); + await session.DisposeAsync(); + } + } + + [Fact] + public async Task Should_Report_Implemented_Error_When_Connecting_Unknown_Remote_Session() + { + await Client.StartAsync(); + var remoteSessionId = $"remote-{Guid.NewGuid():N}"; + + var ex = await AssertImplementedFailureAsync( + () => Client.Rpc.Sessions.ConnectAsync(remoteSessionId), + "sessions.connect"); + + Assert.False(string.IsNullOrWhiteSpace(ex.Message)); + } + [Fact] public async Task Should_Discover_Server_Mcp_And_Skills() { diff --git a/dotnet/test/E2E/RpcSessionStateE2ETests.cs b/dotnet/test/E2E/RpcSessionStateE2ETests.cs index 04ba27f54..5f188fbff 100644 --- a/dotnet/test/E2E/RpcSessionStateE2ETests.cs +++ b/dotnet/test/E2E/RpcSessionStateE2ETests.cs @@ -38,13 +38,13 @@ public async Task Should_Call_Session_Rpc_Model_SwitchTo() await using var session = await CreateSessionAsync(new SessionConfig { Model = "claude-sonnet-4.5" }); var before = await session.Rpc.Model.GetCurrentAsync(); - Assert.NotNull(before.ModelId); + Assert.Equal("claude-sonnet-4.5", before.ModelId); var result = await session.Rpc.Model.SwitchToAsync(modelId: "gpt-4.1", reasoningEffort: "high"); var after = await session.Rpc.Model.GetCurrentAsync(); Assert.Equal("gpt-4.1", result.ModelId); - Assert.Equal(before.ModelId, after.ModelId); + Assert.True(after.ModelId is "gpt-4.1" || after.ModelId == before.ModelId, $"Unexpected current model after switch: {after.ModelId}"); } [Fact] @@ -62,6 +62,23 @@ public async Task Should_Get_And_Set_Session_Mode() Assert.Equal(SessionMode.Interactive, await session.Rpc.Mode.GetAsync()); } + [Fact] + public async Task Should_Shutdown_Session_With_Routine_Type() + { + await using var session = await CreateSessionAsync(); + + var shutdownTask = TestHelper.GetNextEventOfTypeAsync( + session, + evt => evt.Data.ShutdownType == ShutdownType.Routine, + TimeSpan.FromSeconds(15), + timeoutDescription: "session.shutdown event after shutdown RPC"); + + await session.Rpc.ShutdownAsync(ShutdownType.Routine, reason: "SDK E2E shutdown coverage"); + + var shutdown = await shutdownTask; + Assert.Equal(ShutdownType.Routine, shutdown.Data.ShutdownType); + } + [Theory] [InlineData("interactive")] [InlineData("plan")] @@ -241,6 +258,206 @@ public async Task Should_Get_And_Set_Session_Metadata() Assert.NotNull(sources.Sources); } + [Fact] + public async Task Should_Call_Metadata_Snapshot_SetWorkingDirectory_And_RecordContextChange() + { + var firstDirectory = CreateUniqueDirectory(); + var secondDirectory = CreateUniqueDirectory(); + var contextDirectory = CreateUniqueDirectory(); + var branch = $"rpc-context-{Guid.NewGuid():N}"; + await using var session = await CreateSessionAsync(new SessionConfig + { + Model = "claude-sonnet-4.5", + WorkingDirectory = firstDirectory, + }); + + var initialSnapshot = await session.Rpc.Metadata.SnapshotAsync(); + Assert.Equal(session.SessionId, initialSnapshot.SessionId); + Assert.Equal(MetadataSnapshotCurrentMode.Interactive, initialSnapshot.CurrentMode); + Assert.Equal("claude-sonnet-4.5", initialSnapshot.SelectedModel); + Assert.False(initialSnapshot.IsRemote); + Assert.False(initialSnapshot.AlreadyInUse); + Assert.NotEqual(default, initialSnapshot.StartTime); + Assert.NotEqual(default, initialSnapshot.ModifiedTime); + Assert.True(PathEquals(firstDirectory, initialSnapshot.WorkingDirectory), + $"Expected working directory '{firstDirectory}', actual '{initialSnapshot.WorkingDirectory}'."); + Assert.NotNull(initialSnapshot.Workspace); + Assert.Equal(session.SessionId, initialSnapshot.Workspace.Id); + Assert.False(string.IsNullOrWhiteSpace(initialSnapshot.WorkspacePath)); + + var setWorkingDirectory = await session.Rpc.Metadata.SetWorkingDirectoryAsync(secondDirectory); + Assert.True(PathEquals(secondDirectory, setWorkingDirectory.WorkingDirectory), + $"Expected setWorkingDirectory result '{secondDirectory}', actual '{setWorkingDirectory.WorkingDirectory}'."); + + SessionMetadataSnapshot? updatedSnapshot = null; + await TestHelper.WaitForConditionAsync( + async () => + { + updatedSnapshot = await session.Rpc.Metadata.SnapshotAsync(); + return PathEquals(secondDirectory, updatedSnapshot.WorkingDirectory); + }, + timeout: TimeSpan.FromSeconds(15), + timeoutMessage: "Timed out waiting for metadata snapshot to reflect setWorkingDirectory."); + Assert.NotNull(updatedSnapshot); + Assert.True(PathEquals(secondDirectory, updatedSnapshot!.WorkingDirectory)); + + var contextChangedTask = TestHelper.GetNextEventOfTypeAsync( + session, + evt => string.Equals(evt.Data.Branch, branch, StringComparison.Ordinal), + TimeSpan.FromSeconds(15), + timeoutDescription: "session.context_changed event after metadata.recordContextChange"); + + var context = new SessionWorkingDirectoryContext + { + Cwd = contextDirectory, + GitRoot = firstDirectory, + Branch = branch, + Repository = "github/copilot-sdk-e2e", + RepositoryHost = "github.com", + HostType = SessionWorkingDirectoryContextHostType.Github, + BaseCommit = "0000000000000000000000000000000000000000", + HeadCommit = "1111111111111111111111111111111111111111", + }; + + var recordResult = await session.Rpc.Metadata.RecordContextChangeAsync(context); + Assert.NotNull(recordResult); + + var contextChanged = await contextChangedTask; + Assert.True(PathEquals(contextDirectory, contextChanged.Data.Cwd), + $"Expected context cwd '{contextDirectory}', actual '{contextChanged.Data.Cwd}'."); + Assert.True(PathEquals(firstDirectory, contextChanged.Data.GitRoot), + $"Expected context git root '{firstDirectory}', actual '{contextChanged.Data.GitRoot}'."); + Assert.Equal(branch, contextChanged.Data.Branch); + Assert.Equal("github/copilot-sdk-e2e", contextChanged.Data.Repository); + Assert.Equal("github.com", contextChanged.Data.RepositoryHost); + Assert.Equal("github", contextChanged.Data.HostType?.Value); + Assert.Equal(context.BaseCommit, contextChanged.Data.BaseCommit); + Assert.Equal(context.HeadCommit, contextChanged.Data.HeadCommit); + } + + [Fact] + public async Task Should_Update_Options_And_Initialize_Session_Services() + { + var initialDirectory = CreateUniqueDirectory(); + var optionsDirectory = CreateUniqueDirectory(); + var featureName = $"rpc-session-state-{Guid.NewGuid():N}"; + await using var session = await CreateSessionAsync(new SessionConfig + { + WorkingDirectory = initialDirectory, + }); + + var update = await session.Rpc.Options.UpdateAsync( + clientName: "dotnet-sdk-rpc-session-state-e2e", + lspClientName: "dotnet-sdk-rpc-session-state-lsp", + integrationId: $"dotnet-sdk-{Guid.NewGuid():N}", + featureFlags: new Dictionary { [featureName] = true }, + workingDirectory: optionsDirectory, + coauthorEnabled: false, + enableStreaming: false, + askUserDisabled: true); + Assert.True(update.Success); + + await TestHelper.WaitForConditionAsync( + async () => PathEquals(optionsDirectory, (await session.Rpc.Metadata.SnapshotAsync()).WorkingDirectory), + timeout: TimeSpan.FromSeconds(15), + timeoutMessage: "Timed out waiting for options.update workingDirectory to reach metadata snapshot."); + + await session.Rpc.Lsp.InitializeAsync( + workingDirectory: optionsDirectory, + gitRoot: initialDirectory, + force: true); + + await session.Rpc.Telemetry.SetFeatureOverridesAsync(new Dictionary + { + ["rpc_session_state_feature"] = featureName, + ["rpc_session_state_value"] = "enabled", + }); + + var tools = await session.Rpc.Tools.InitializeAndValidateAsync(); + Assert.NotNull(tools); + + var snapshot = await session.Rpc.Metadata.SnapshotAsync(); + Assert.True(PathEquals(optionsDirectory, snapshot.WorkingDirectory), + $"Expected options working directory '{optionsDirectory}', actual '{snapshot.WorkingDirectory}'."); + } + + [Fact] + public async Task Should_Set_ReasoningEffort_And_Auto_Name() + { + await using var session = await CreateSessionAsync(new SessionConfig + { + Model = "claude-sonnet-4.5", + }); + + var reasoning = await session.Rpc.Model.SetReasoningEffortAsync("high"); + Assert.Equal("high", reasoning.ReasoningEffort); + + var currentModel = await session.Rpc.Model.GetCurrentAsync(); + Assert.Equal("claude-sonnet-4.5", currentModel.ModelId); + Assert.Equal("high", currentModel.ReasoningEffort); + + var autoName = $"Auto Session {Guid.NewGuid():N}"; + var titleChangedTask = TestHelper.GetNextEventOfTypeAsync( + session, + evt => string.Equals(evt.Data.Title, autoName, StringComparison.Ordinal), + TimeSpan.FromSeconds(15), + timeoutDescription: "session.title_changed event after name.setAuto"); + + var autoResult = await session.Rpc.Name.SetAutoAsync($" {autoName} "); + Assert.True(autoResult.Applied); + var titleChanged = await titleChangedTask; + Assert.Equal(autoName, titleChanged.Data.Title); + Assert.Equal(autoName, (await session.Rpc.Name.GetAsync()).Name); + + var explicitName = $"Explicit Session {Guid.NewGuid():N}"; + var explicitTitleChangedTask = TestHelper.GetNextEventOfTypeAsync( + session, + evt => string.Equals(evt.Data.Title, explicitName, StringComparison.Ordinal), + TimeSpan.FromSeconds(15), + timeoutDescription: "session.title_changed event after explicit name.set"); + await session.Rpc.Name.SetAsync(explicitName); + Assert.Equal(explicitName, (await explicitTitleChangedTask).Data.Title); + var ignoredAutoResult = await session.Rpc.Name.SetAutoAsync($"Ignored {Guid.NewGuid():N}"); + Assert.False(ignoredAutoResult.Applied); + Assert.Equal(explicitName, (await session.Rpc.Name.GetAsync()).Name); + } + + [Fact] + public async Task Should_Set_Auth_Credentials() + { + await using var client = Ctx.CreateClient(); + await using var session = await client.CreateSessionAsync(new SessionConfig + { + OnPermissionRequest = PermissionHandler.ApproveAll, + }); + var login = $"sdk-rpc-{Guid.NewGuid():N}"; + + var setCredentials = await session.Rpc.Auth.SetCredentialsAsync(new AuthInfoUser + { + CopilotUser = new CopilotUserResponse + { + AnalyticsTrackingId = "rpc-session-state-tracking-id", + ChatEnabled = true, + CopilotPlan = "individual_pro", + Endpoints = new CopilotUserResponseEndpoints + { + Api = Ctx.ProxyUrl, + Telemetry = "https://localhost:1/telemetry", + }, + Login = login, + }, + Host = "https://github.com", + Login = login, + }); + Assert.True(setCredentials.Success); + + var status = await session.Rpc.Auth.GetStatusAsync(); + Assert.True(status.IsAuthenticated); + Assert.Equal(AuthInfoType.User, status.AuthType); + Assert.Equal("https://github.com", status.Host); + Assert.Equal(login, status.Login); + } + [Fact] public async Task Should_Fork_Session_With_Persisted_Messages() { @@ -407,7 +624,33 @@ public async Task Should_Compact_Session_History_After_Messages() { await using var session = await CreateSessionAsync(); - await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + Assert.False((await session.Rpc.Metadata.IsProcessingAsync()).Processing); + + var answer = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + Assert.NotNull(answer); + Assert.Contains("4", answer!.Data.Content ?? string.Empty, StringComparison.Ordinal); + Assert.False((await session.Rpc.Metadata.IsProcessingAsync()).Processing); + + var contextInfo = await session.Rpc.Metadata.ContextInfoAsync( + promptTokenLimit: 128_000, + outputTokenLimit: 4_096, + selectedModel: "claude-sonnet-4.5"); + var context = Assert.IsType(contextInfo.ContextInfo); + Assert.Equal("claude-sonnet-4.5", context.ModelName); + Assert.Equal(128_000, context.PromptTokenLimit); + Assert.True(context.Limit >= context.PromptTokenLimit); + Assert.True(context.TotalTokens > 0); + Assert.True(context.SystemTokens > 0); + Assert.True(context.ConversationTokens > 0); + Assert.True(context.ToolDefinitionsTokens >= 0); + Assert.Equal( + context.SystemTokens + context.ConversationTokens + context.ToolDefinitionsTokens, + context.TotalTokens); + + var recomputed = await session.Rpc.Metadata.RecomputeContextTokensAsync("claude-sonnet-4.5"); + Assert.True(recomputed.SystemTokenCount > 0); + Assert.True(recomputed.MessagesTokenCount > 0); + Assert.Equal(recomputed.SystemTokenCount + recomputed.MessagesTokenCount, recomputed.TotalTokens); var result = await session.Rpc.History.CompactAsync(); @@ -437,6 +680,22 @@ public async Task Should_Compact_Session_History_After_Messages() Assert.NotNull(name); } + private string CreateUniqueDirectory() + { + var path = Path.GetFullPath(Path.Join(Ctx.WorkDir, $"rpc-session-state-{Guid.NewGuid():N}")); + Directory.CreateDirectory(path); + return path; + } + + private static bool PathEquals(string? expected, string? actual) + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return string.Equals(NormalizePath(expected), NormalizePath(actual), comparison); + } + + private static string? NormalizePath(string? path) + => path is null ? null : Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + private static List<(string Role, string Content)> GetConversationMessages(IEnumerable events) { var messages = new List<(string Role, string Content)>(); diff --git a/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs b/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs index 61c8d5878..b9d048aa4 100644 --- a/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs +++ b/dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs @@ -28,14 +28,34 @@ public async Task Should_List_Task_State_And_Return_False_For_Missing_Task_Opera Assert.NotNull(tasks.Tasks); Assert.Empty(tasks.Tasks); + var refresh = await session.Rpc.Tasks.RefreshAsync(); + Assert.NotNull(refresh); + + using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var waitForPending = await session.Rpc.Tasks.WaitForPendingAsync(waitCts.Token); + Assert.NotNull(waitForPending); + + var progress = await session.Rpc.Tasks.GetProgressAsync("missing-task"); + Assert.Null(progress.Progress); + + var currentPromotable = await session.Rpc.Tasks.GetCurrentPromotableAsync(); + Assert.Null(currentPromotable.Task); + var promote = await session.Rpc.Tasks.PromoteToBackgroundAsync("missing-task"); Assert.False(promote.Promoted); + var promoteCurrent = await session.Rpc.Tasks.PromoteCurrentToBackgroundAsync(); + Assert.Null(promoteCurrent.Task); + var cancel = await session.Rpc.Tasks.CancelAsync("missing-task"); Assert.False(cancel.Cancelled); var remove = await session.Rpc.Tasks.RemoveAsync("missing-task"); Assert.False(remove.Removed); + + var sendMessage = await session.Rpc.Tasks.SendMessageAsync("missing-task", "hello from the SDK E2E test"); + Assert.False(sendMessage.Sent); + Assert.False(string.IsNullOrWhiteSpace(sendMessage.Error)); } [Fact] @@ -157,6 +177,31 @@ public async Task Should_Return_Expected_Results_For_Missing_Pending_Handler_Req result: new UIElicitationResponse { Action = UIElicitationResponseAction.Cancel }); Assert.False(elicitation.Success); + var userInput = await session.Rpc.Ui.HandlePendingUserInputAsync( + requestId: "missing-user-input-request", + response: new UIUserInputResponse { Answer = "typed answer", WasFreeform = true }); + Assert.False(userInput.Success); + + var sampling = await session.Rpc.Ui.HandlePendingSamplingAsync( + requestId: "missing-sampling-request", + response: new UIHandlePendingSamplingResponse()); + Assert.False(sampling.Success); + + var autoModeSwitch = await session.Rpc.Ui.HandlePendingAutoModeSwitchAsync( + requestId: "missing-auto-mode-switch-request", + response: UIAutoModeSwitchResponse.No); + Assert.False(autoModeSwitch.Success); + + var exitPlanMode = await session.Rpc.Ui.HandlePendingExitPlanModeAsync( + requestId: "missing-exit-plan-mode-request", + response: new UIExitPlanModeResponse + { + Approved = false, + Feedback = "No pending plan approval", + SelectedAction = UIExitPlanModeAction.ExitOnly, + }); + Assert.False(exitPlanMode.Success); + var permission = await session.Rpc.Permissions.HandlePendingPermissionRequestAsync( requestId: "missing-permission-request", result: new PermissionDecisionReject { Feedback = "not approved" }); @@ -191,9 +236,82 @@ public async Task Should_Return_Expected_Results_For_Missing_Pending_Handler_Req Assert.False(locationApproval.Success); } + [Fact] + public async Task Should_Round_Trip_Rpc_Elicitation_Through_Config_Handler() + { + var handlerContext = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var session = await CreateSessionAsync(new SessionConfig + { + OnElicitationRequest = context => + { + handlerContext.TrySetResult(context); + return Task.FromResult(new ElicitationResult + { + Action = UIElicitationResponseAction.Accept, + Content = new Dictionary + { + ["answer"] = "from handler", + ["confirmed"] = true, + }, + }); + }, + }); + + var schema = new UIElicitationSchema + { + Type = "object", + Properties = new Dictionary + { + ["answer"] = ParseJsonElement("""{"type":"string"}"""), + ["confirmed"] = ParseJsonElement("""{"type":"boolean"}"""), + }, + Required = ["answer"], + }; + + var response = await session.Rpc.Ui.ElicitationAsync("Need details", schema); + var context = await handlerContext.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(session.SessionId, context.SessionId); + Assert.Equal("Need details", context.Message); + Assert.NotNull(context.RequestedSchema); + Assert.Equal("object", context.RequestedSchema.Type); + Assert.Contains("answer", context.RequestedSchema.Properties.Keys); + Assert.Contains("confirmed", context.RequestedSchema.Properties.Keys); + Assert.Equal(["answer"], context.RequestedSchema.Required); + + Assert.Equal(UIElicitationResponseAction.Accept, response.Action); + Assert.NotNull(response.Content); + Assert.Equal("from handler", response.Content["answer"].GetString()); + Assert.True(response.Content["confirmed"].GetBoolean()); + } + + [Fact] + public async Task Should_Register_And_Unregister_Direct_Auto_Mode_Switch_Handler() + { + var session = await CreateSessionAsync(); + + var missing = await session.Rpc.Ui.UnregisterDirectAutoModeSwitchHandlerAsync("missing-direct-auto-mode-handle"); + Assert.False(missing.Unregistered); + + var registration = await session.Rpc.Ui.RegisterDirectAutoModeSwitchHandlerAsync(); + Assert.False(string.IsNullOrWhiteSpace(registration.Handle)); + + var unregister = await session.Rpc.Ui.UnregisterDirectAutoModeSwitchHandlerAsync(registration.Handle); + Assert.True(unregister.Unregistered); + + var unregisterAgain = await session.Rpc.Ui.UnregisterDirectAutoModeSwitchHandlerAsync(registration.Handle); + Assert.False(unregisterAgain.Unregistered); + } + private static async Task FindAgentTaskAsync(CopilotSession session, string agentId) { var tasks = await session.Rpc.Tasks.ListAsync(); return tasks.Tasks.OfType().SingleOrDefault(t => string.Equals(t.Id, agentId, StringComparison.Ordinal)); } + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } } diff --git a/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs b/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs new file mode 100644 index 000000000..ad3c2ad96 --- /dev/null +++ b/dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text; +using GitHub.Copilot.Rpc; +using GitHub.Copilot.Test.Harness; +using Xunit; +using Xunit.Abstractions; + +namespace GitHub.Copilot.Test.E2E; + +public class RpcWorkspaceCheckpointsE2ETests(E2ETestFixture fixture, ITestOutputHelper output) + : E2ETestBase(fixture, "rpc_workspace_checkpoints", output) +{ + [Fact] + public async Task Should_List_No_Checkpoints_For_Fresh_Session() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Workspaces.ListCheckpointsAsync(); + + Assert.NotNull(result.Checkpoints); + Assert.Empty(result.Checkpoints); + } + + [Fact] + public async Task Should_Return_Null_Or_Empty_Content_For_Unknown_Checkpoint() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Workspaces.ReadCheckpointAsync(long.MaxValue); + + Assert.True(string.IsNullOrEmpty(result.Content)); + } + + [Fact] + public async Task Should_Return_Typed_Workspace_Diff_Result() + { + await using var session = await CreateSessionAsync(); + + var result = await session.Rpc.Workspaces.DiffAsync(WorkspaceDiffMode.Unstaged); + + Assert.Equal(WorkspaceDiffMode.Unstaged, result.RequestedMode); + Assert.Contains(result.Mode, new[] { WorkspaceDiffMode.Unstaged, WorkspaceDiffMode.Branch }); + Assert.NotNull(result.Changes); + foreach (var change in result.Changes) + { + Assert.NotEmpty(change.Path); + Assert.Contains( + change.ChangeType, + new[] + { + WorkspaceDiffFileChangeType.Added, + WorkspaceDiffFileChangeType.Modified, + WorkspaceDiffFileChangeType.Deleted, + WorkspaceDiffFileChangeType.Renamed, + }); + Assert.NotNull(change.Diff); + } + } + + [Fact] + public async Task Should_Save_Large_Paste_And_Expose_Readable_Content() + { + await using var session = await CreateSessionAsync(); + var content = string.Concat(Enumerable.Repeat("Large paste payload 🚀\n", 512)); + + var result = await session.Rpc.Workspaces.SaveLargePasteAsync(content); + var saved = result.Saved; + + Assert.NotNull(saved); + Assert.NotEmpty(saved.Filename); + Assert.NotEmpty(saved.FilePath); + Assert.Equal(Encoding.UTF8.GetByteCount(content), saved.SizeBytes); + + WorkspacesReadFileResult? read = null; + Exception? readError = null; + try + { + read = await session.Rpc.Workspaces.ReadFileAsync(saved.Filename); + } + catch (Exception ex) + { + readError = ex; + } + + if (read is not null) + { + Assert.Equal(content, read.Content); + } + else + { + Assert.True( + File.Exists(saved.FilePath), + $"Saved paste file does not exist: {saved.FilePath}. ReadFile failed: {readError}"); + Assert.Equal(content, File.ReadAllText(saved.FilePath)); + } + } +} diff --git a/go/internal/e2e/commands_and_elicitation_e2e_test.go b/go/internal/e2e/commands_and_elicitation_e2e_test.go index c38201204..68b9badd1 100644 --- a/go/internal/e2e/commands_and_elicitation_e2e_test.go +++ b/go/internal/e2e/commands_and_elicitation_e2e_test.go @@ -37,6 +37,187 @@ func TestCommandsE2E(t *testing.T) { }) t.Cleanup(func() { client2.ForceStop() }) + t.Run("session commands list returns builtins and respects client command filter", func(t *testing.T) { + session, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Commands: []copilot.CommandDefinition{ + {Name: "deploy", Description: "Deploy the app", Handler: func(_ copilot.CommandContext) error { return nil }}, + {Name: "rollback", Description: "Rollback the app", Handler: func(_ copilot.CommandContext) error { return nil }}, + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + var clientCommands *rpc.CommandList + waitForRPCCondition(t, 30*time.Second, "client commands to be listed", func() (bool, error) { + var err error + clientCommands, err = session.RPC.Commands.List(t.Context(), &rpc.CommandsListRequest{ + IncludeBuiltins: rpcPtr(false), + IncludeClientCommands: rpcPtr(true), + IncludeSkills: rpcPtr(false), + }) + if err != nil { + return false, err + } + return hasCommand(clientCommands.Commands, "deploy", rpc.SlashCommandKindClient) && + hasCommand(clientCommands.Commands, "rollback", rpc.SlashCommandKindClient), nil + }) + if hasCommandKind(clientCommands.Commands, rpc.SlashCommandKindBuiltin) { + t.Fatalf("Expected client-command-only list to exclude builtins, got %+v", clientCommands.Commands) + } + + builtinCommands, err := session.RPC.Commands.List(t.Context(), &rpc.CommandsListRequest{ + IncludeBuiltins: rpcPtr(true), + IncludeClientCommands: rpcPtr(false), + IncludeSkills: rpcPtr(false), + }) + if err != nil { + t.Fatalf("Commands.List builtins failed: %v", err) + } + if !hasKnownBuiltinCommand(builtinCommands.Commands) { + t.Fatalf("Expected a known built-in command, got %+v", builtinCommands.Commands) + } + if hasCommand(builtinCommands.Commands, "deploy", rpc.SlashCommandKindClient) { + t.Fatal("Expected builtin-command list to exclude client command deploy") + } + }) + + t.Run("session commands invoke known builtin returns expected result", func(t *testing.T) { + session, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + builtinCommands, err := session.RPC.Commands.List(t.Context(), &rpc.CommandsListRequest{ + IncludeBuiltins: rpcPtr(true), + IncludeClientCommands: rpcPtr(false), + IncludeSkills: rpcPtr(false), + }) + if err != nil { + t.Fatalf("Commands.List builtins failed: %v", err) + } + commandName := firstKnownBuiltinCommand(builtinCommands.Commands) + if commandName == "" { + t.Fatalf("Expected a known builtin command, got %+v", builtinCommands.Commands) + } + + result, err := session.RPC.Commands.Invoke(t.Context(), &rpc.CommandsInvokeRequest{Name: commandName}) + if err != nil { + t.Fatalf("Commands.Invoke(%q) failed: %v", commandName, err) + } + switch r := result.(type) { + case *rpc.SlashCommandTextResult: + if strings.TrimSpace(r.Text) == "" { + t.Fatalf("Expected non-empty text result, got %+v", r) + } + case *rpc.SlashCommandSelectSubcommandResult: + if strings.TrimSpace(r.Title) == "" || len(r.Options) == 0 { + t.Fatalf("Expected select-subcommand title and options, got %+v", r) + } + case *rpc.SlashCommandAgentPromptResult: + if strings.TrimSpace(r.DisplayPrompt) == "" || strings.TrimSpace(r.Prompt) == "" { + t.Fatalf("Expected non-empty agent prompt result, got %+v", r) + } + case *rpc.SlashCommandCompletedResult: + if r.Message != nil && strings.TrimSpace(*r.Message) == "" { + t.Fatalf("Expected nil or non-empty completed message, got %+v", r) + } + default: + t.Fatalf("Unexpected slash command result type %T", result) + } + }) + + t.Run("session commands execute runs registered command handler", func(t *testing.T) { + var captured *copilot.CommandContext + session, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Commands: []copilot.CommandDefinition{{ + Name: "deploy", + Description: "Deploy the app", + Handler: func(ctx copilot.CommandContext) error { + copy := ctx + captured = © + return nil + }, + }}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + waitForRPCCondition(t, 30*time.Second, "registered deploy command", func() (bool, error) { + commands, err := session.RPC.Commands.List(t.Context(), &rpc.CommandsListRequest{ + IncludeBuiltins: rpcPtr(false), + IncludeClientCommands: rpcPtr(true), + IncludeSkills: rpcPtr(false), + }) + if err != nil { + return false, err + } + return hasCommand(commands.Commands, "deploy", rpc.SlashCommandKindClient), nil + }) + + result, err := session.RPC.Commands.Execute(t.Context(), &rpc.ExecuteCommandParams{CommandName: "deploy", Args: "production"}) + if err != nil { + t.Fatalf("Commands.Execute failed: %v", err) + } + if result.Error != nil { + t.Fatalf("Expected command execution to succeed, got error %q", *result.Error) + } + waitForRPCCondition(t, 10*time.Second, "command handler execution", func() (bool, error) { + return captured != nil, nil + }) + if captured.SessionID != session.SessionID || captured.Command != "/deploy production" || + captured.CommandName != "deploy" || captured.Args != "production" { + t.Fatalf("Unexpected command context: %+v", captured) + } + }) + + t.Run("session commands enqueue accepts deterministic command", func(t *testing.T) { + session, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + result, err := session.RPC.Commands.Enqueue(t.Context(), &rpc.EnqueueCommandParams{Command: "/help"}) + if err != nil { + t.Fatalf("Commands.Enqueue failed: %v", err) + } + if !result.Queued { + t.Fatal("Expected /help to be accepted into the command queue") + } + }) + + t.Run("session commands respond to queued command returns false for unknown request id", func(t *testing.T) { + session, err := client1.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer session.Disconnect() + + result, err := session.RPC.Commands.RespondToQueuedCommand(t.Context(), &rpc.CommandsRespondToQueuedCommandRequest{ + RequestID: "missing-queued-command-request", + Result: rpc.QueuedCommandNotHandled{}, + }) + if err != nil { + t.Fatalf("Commands.RespondToQueuedCommand failed: %v", err) + } + if result.Success { + t.Fatal("Expected missing queued command response to report Success=false") + } + }) + t.Run("commands.changed event when another client joins with commands", func(t *testing.T) { ctx.ConfigureForTest(t) @@ -165,6 +346,39 @@ func TestCommandsE2E(t *testing.T) { }) } +var knownBuiltinCommands = []string{"help", "model", "compact"} + +func hasCommand(commands []rpc.SlashCommandInfo, name string, kind rpc.SlashCommandKind) bool { + for _, command := range commands { + if strings.EqualFold(command.Name, name) && command.Kind == kind { + return true + } + } + return false +} + +func hasCommandKind(commands []rpc.SlashCommandInfo, kind rpc.SlashCommandKind) bool { + for _, command := range commands { + if command.Kind == kind { + return true + } + } + return false +} + +func hasKnownBuiltinCommand(commands []rpc.SlashCommandInfo) bool { + return firstKnownBuiltinCommand(commands) != "" +} + +func firstKnownBuiltinCommand(commands []rpc.SlashCommandInfo) string { + for _, name := range knownBuiltinCommands { + if hasCommand(commands, name, rpc.SlashCommandKindBuiltin) { + return name + } + } + return "" +} + func TestUIElicitationE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() diff --git a/go/internal/e2e/compaction_e2e_test.go b/go/internal/e2e/compaction_e2e_test.go index 2740c5542..29ddfd0b1 100644 --- a/go/internal/e2e/compaction_e2e_test.go +++ b/go/internal/e2e/compaction_e2e_test.go @@ -192,4 +192,63 @@ func TestCompactionE2E(t *testing.T) { t.Errorf("Expected 0 compaction events when disabled, got %d", len(compactionEvents)) } }) + + t.Run("should return empty handoff summary for fresh session", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.History.SummarizeForHandoff(t.Context()) + if err != nil { + t.Fatalf("History.SummarizeForHandoff failed: %v", err) + } + if result.Summary != "" { + t.Fatalf("Expected empty handoff summary for fresh session, got %+v", result) + } + }) + + t.Run("should summarize for handoff after non ephemeral log event", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + if err := session.Log(t.Context(), "handoff summary log coverage", nil); err != nil { + t.Fatalf("Session.Log failed: %v", err) + } + + result, err := session.RPC.History.SummarizeForHandoff(t.Context()) + if err != nil { + t.Fatalf("History.SummarizeForHandoff failed: %v", err) + } + _ = result.Summary + }) + + t.Run("should report no op when cancelling compaction without in flight work", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + background, err := session.RPC.History.CancelBackgroundCompaction(t.Context()) + if err != nil { + t.Fatalf("History.CancelBackgroundCompaction failed: %v", err) + } + if background.Cancelled { + t.Fatalf("Expected CancelBackgroundCompaction Cancelled=false, got %+v", background) + } + manual, err := session.RPC.History.AbortManualCompaction(t.Context()) + if err != nil { + t.Fatalf("History.AbortManualCompaction failed: %v", err) + } + if manual.Aborted { + t.Fatalf("Expected AbortManualCompaction Aborted=false, got %+v", manual) + } + }) } diff --git a/go/internal/e2e/permissions_e2e_test.go b/go/internal/e2e/permissions_e2e_test.go index 5cbbfba1b..9d3b11da8 100644 --- a/go/internal/e2e/permissions_e2e_test.go +++ b/go/internal/e2e/permissions_e2e_test.go @@ -816,6 +816,254 @@ func TestPermissionsE2E(t *testing.T) { t.Errorf("Expected permission handler to NOT be called when SetApproveAll is enabled, got %d calls", count) } }) + + t.Run("should configure and update permission paths", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + configuredAllowed := createUniqueRPCWorkDirectory(t, ctx, "configured-allowed") + addedAllowed := createUniqueRPCWorkDirectory(t, ctx, "added-allowed") + newPrimary := createUniqueRPCWorkDirectory(t, ctx, "new-primary") + includeTemp := false + unrestricted := false + configure, err := session.RPC.Permissions.Configure(t.Context(), &rpc.PermissionsConfigureParams{ + ApproveAllToolPermissionRequests: rpcPtr(false), + ApproveAllReadPermissionRequests: rpcPtr(true), + Rules: &rpc.PermissionRulesSet{ + Approved: []rpc.PermissionRule{{Kind: "read", Argument: nil}}, + Denied: []rpc.PermissionRule{{Kind: "write", Argument: nil}}, + }, + Paths: &rpc.PermissionPathsConfig{ + WorkspacePath: &ctx.WorkDir, + AdditionalDirectories: []string{configuredAllowed}, + IncludeTempDirectory: &includeTemp, + Unrestricted: &unrestricted, + }, + Urls: &rpc.PermissionUrlsConfig{ + InitialAllowed: []string{"https://example.invalid/permissions-configure"}, + Unrestricted: &unrestricted, + }, + }) + if err != nil { + t.Fatalf("Permissions.Configure failed: %v", err) + } + if !configure.Success { + t.Fatalf("Expected Configure Success=true, got %+v", configure) + } + + configuredList, err := session.RPC.Permissions.Paths().List(t.Context()) + if err != nil { + t.Fatalf("Permissions.Paths.List failed: %v", err) + } + assertRPCPathEqual(t, ctx.WorkDir, configuredList.Primary) + assertRPCContainsPath(t, configuredList.Directories, ctx.WorkDir) + assertRPCContainsPath(t, configuredList.Directories, configuredAllowed) + + add, err := session.RPC.Permissions.Paths().Add(t.Context(), &rpc.PermissionPathsAddParams{Path: addedAllowed}) + if err != nil { + t.Fatalf("Permissions.Paths.Add failed: %v", err) + } + if !add.Success { + t.Fatalf("Expected Paths.Add Success=true, got %+v", add) + } + + allowed, err := session.RPC.Permissions.Paths().IsPathWithinAllowedDirectories(t.Context(), &rpc.PermissionPathsAllowedCheckParams{ + Path: filepath.Join(addedAllowed, "child.txt"), + }) + if err != nil { + t.Fatalf("Permissions.Paths.IsPathWithinAllowedDirectories failed: %v", err) + } + if !allowed.Allowed { + t.Fatalf("Expected path within added allowed directory to be allowed") + } + + updatePrimary, err := session.RPC.Permissions.Paths().UpdatePrimary(t.Context(), &rpc.PermissionPathsUpdatePrimaryParams{Path: newPrimary}) + if err != nil { + t.Fatalf("Permissions.Paths.UpdatePrimary failed: %v", err) + } + if !updatePrimary.Success { + t.Fatalf("Expected UpdatePrimary Success=true, got %+v", updatePrimary) + } + + updatedList, err := session.RPC.Permissions.Paths().List(t.Context()) + if err != nil { + t.Fatalf("Permissions.Paths.List after update failed: %v", err) + } + assertRPCPathEqual(t, newPrimary, updatedList.Primary) + assertRPCContainsPath(t, updatedList.Directories, newPrimary) + + workspaceCheck, err := session.RPC.Permissions.Paths().IsPathWithinWorkspace(t.Context(), &rpc.PermissionPathsWorkspaceCheckParams{ + Path: filepath.Join(newPrimary, "child.txt"), + }) + if err != nil { + t.Fatalf("Permissions.Paths.IsPathWithinWorkspace failed: %v", err) + } + if !workspaceCheck.Allowed { + t.Fatalf("Expected path within new primary workspace to be allowed") + } + }) + + t.Run("should invoke permission state rpc apis", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + pending, err := session.RPC.Permissions.PendingRequests(t.Context()) + if err != nil { + t.Fatalf("Permissions.PendingRequests failed: %v", err) + } + if len(pending.Items) != 0 { + t.Fatalf("Expected no pending permission requests, got %+v", pending.Items) + } + + setRequired, err := session.RPC.Permissions.SetRequired(t.Context(), &rpc.PermissionsSetRequiredRequest{Required: true}) + if err != nil { + t.Fatalf("Permissions.SetRequired(true) failed: %v", err) + } + if !setRequired.Success { + t.Fatalf("Expected SetRequired(true) Success=true") + } + clearRequired, err := session.RPC.Permissions.SetRequired(t.Context(), &rpc.PermissionsSetRequiredRequest{Required: false}) + if err != nil { + t.Fatalf("Permissions.SetRequired(false) failed: %v", err) + } + if !clearRequired.Success { + t.Fatalf("Expected SetRequired(false) Success=true") + } + + promptShown, err := session.RPC.Permissions.NotifyPromptShown(t.Context(), &rpc.PermissionPromptShownNotification{ + Message: "Permission prompt shown from Go SDK E2E", + }) + if err != nil { + t.Fatalf("Permissions.NotifyPromptShown failed: %v", err) + } + if !promptShown.Success { + t.Fatalf("Expected NotifyPromptShown Success=true") + } + + ruleArg := "go-permission-e2e-" + randomHex(t) + rule := rpc.PermissionRule{Kind: "commands", Argument: &ruleArg} + addRule, err := session.RPC.Permissions.ModifyRules(t.Context(), &rpc.PermissionsModifyRulesParams{ + Scope: rpc.PermissionsModifyRulesScopeSession, + Add: []rpc.PermissionRule{rule}, + }) + if err != nil { + t.Fatalf("Permissions.ModifyRules(add) failed: %v", err) + } + if !addRule.Success { + t.Fatalf("Expected ModifyRules(add) Success=true") + } + removeRule, err := session.RPC.Permissions.ModifyRules(t.Context(), &rpc.PermissionsModifyRulesParams{ + Scope: rpc.PermissionsModifyRulesScopeSession, + Remove: []rpc.PermissionRule{rule}, + }) + if err != nil { + t.Fatalf("Permissions.ModifyRules(remove) failed: %v", err) + } + if !removeRule.Success { + t.Fatalf("Expected ModifyRules(remove) Success=true") + } + + enableUrls, err := session.RPC.Permissions.Urls().SetUnrestrictedMode(t.Context(), &rpc.PermissionUrlsSetUnrestrictedModeParams{Enabled: true}) + if err != nil { + t.Fatalf("Permissions.Urls.SetUnrestrictedMode(true) failed: %v", err) + } + if !enableUrls.Success { + t.Fatalf("Expected SetUnrestrictedMode(true) Success=true") + } + disableUrls, err := session.RPC.Permissions.Urls().SetUnrestrictedMode(t.Context(), &rpc.PermissionUrlsSetUnrestrictedModeParams{Enabled: false}) + if err != nil { + t.Fatalf("Permissions.Urls.SetUnrestrictedMode(false) failed: %v", err) + } + if !disableUrls.Success { + t.Fatalf("Expected SetUnrestrictedMode(false) Success=true") + } + }) + + t.Run("should invoke permission location and folder trust rpc apis", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + locationDirectory := createUniqueRPCWorkDirectory(t, ctx, "permission-location") + trustedDirectory := createUniqueRPCWorkDirectory(t, ctx, "folder-trust") + commandIdentifier := "go-permission-location-" + randomHex(t) + + resolved, err := session.RPC.Permissions.Locations().Resolve(t.Context(), &rpc.PermissionLocationResolveParams{WorkingDirectory: locationDirectory}) + if err != nil { + t.Fatalf("Permissions.Locations.Resolve failed: %v", err) + } + if resolved.LocationType != rpc.PermissionLocationTypeDir { + t.Fatalf("Expected dir location type, got %+v", resolved) + } + assertRPCPathEqual(t, locationDirectory, resolved.LocationKey) + + addToolApproval, err := session.RPC.Permissions.Locations().AddToolApproval(t.Context(), &rpc.PermissionLocationAddToolApprovalParams{ + LocationKey: resolved.LocationKey, + Approval: &rpc.PermissionsLocationsAddToolApprovalDetailsCommands{CommandIdentifiers: []string{commandIdentifier}}, + }) + if err != nil { + t.Fatalf("Permissions.Locations.AddToolApproval failed: %v", err) + } + if !addToolApproval.Success { + t.Fatalf("Expected AddToolApproval Success=true") + } + + applied, err := session.RPC.Permissions.Locations().Apply(t.Context(), &rpc.PermissionLocationApplyParams{WorkingDirectory: locationDirectory}) + if err != nil { + t.Fatalf("Permissions.Locations.Apply failed: %v", err) + } + if applied.LocationType != resolved.LocationType { + t.Fatalf("Expected applied location type %q, got %+v", resolved.LocationType, applied) + } + assertRPCPathEqual(t, resolved.LocationKey, applied.LocationKey) + if applied.AppliedRuleCount < 1 { + t.Fatalf("Expected at least one applied rule, got %+v", applied) + } + var foundRule bool + for _, rule := range applied.AppliedRules { + if rule.Kind == "shell" && rule.Argument != nil && *rule.Argument == commandIdentifier { + foundRule = true + break + } + } + if !foundRule { + t.Fatalf("Expected applied shell rule for %q, got %+v", commandIdentifier, applied.AppliedRules) + } + + initialTrust, err := session.RPC.Permissions.FolderTrust().IsTrusted(t.Context(), &rpc.FolderTrustCheckParams{Path: trustedDirectory}) + if err != nil { + t.Fatalf("Permissions.FolderTrust.IsTrusted(initial) failed: %v", err) + } + if initialTrust.Trusted { + t.Fatalf("Expected new trusted directory to start untrusted") + } + + addTrusted, err := session.RPC.Permissions.FolderTrust().AddTrusted(t.Context(), &rpc.FolderTrustAddParams{Path: trustedDirectory}) + if err != nil { + t.Fatalf("Permissions.FolderTrust.AddTrusted failed: %v", err) + } + if !addTrusted.Success { + t.Fatalf("Expected AddTrusted Success=true") + } + updatedTrust, err := session.RPC.Permissions.FolderTrust().IsTrusted(t.Context(), &rpc.FolderTrustCheckParams{Path: trustedDirectory}) + if err != nil { + t.Fatalf("Permissions.FolderTrust.IsTrusted(updated) failed: %v", err) + } + if !updatedTrust.Trusted { + t.Fatalf("Expected trusted directory to be trusted after AddTrusted") + } + }) } // atomicBool is a tiny helper for concurrent flag updates in handler callbacks. diff --git a/go/internal/e2e/rpc_coverage_helpers_test.go b/go/internal/e2e/rpc_coverage_helpers_test.go new file mode 100644 index 000000000..8c566a6a5 --- /dev/null +++ b/go/internal/e2e/rpc_coverage_helpers_test.go @@ -0,0 +1,71 @@ +package e2e + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/github/copilot-sdk/go/internal/e2e/testharness" +) + +func rpcPtr[T any](value T) *T { + return &value +} + +func createUniqueRPCWorkDirectory(t *testing.T, ctx *testharness.TestContext, prefix string) string { + t.Helper() + dir := filepath.Join(ctx.WorkDir, prefix+"-"+randomHex(t)) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create %q: %v", dir, err) + } + return dir +} + +func rpcPathsEqual(expected, actual string) bool { + expected = filepath.Clean(expected) + actual = filepath.Clean(actual) + if runtime.GOOS == "windows" { + return strings.EqualFold(expected, actual) + } + return expected == actual +} + +func assertRPCPathEqual(t *testing.T, expected, actual string) { + t.Helper() + if !rpcPathsEqual(expected, actual) { + t.Fatalf("Expected path %q to equal %q", actual, expected) + } +} + +func assertRPCContainsPath(t *testing.T, paths []string, expected string) { + t.Helper() + for _, path := range paths { + if rpcPathsEqual(expected, path) { + return + } + } + t.Fatalf("Expected paths to contain %q, got %v", expected, paths) +} + +func waitForRPCCondition(t *testing.T, timeout time.Duration, description string, condition func() (bool, error)) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + ok, err := condition() + if err == nil && ok { + return + } + if err != nil { + lastErr = err + } + time.Sleep(100 * time.Millisecond) + } + if lastErr != nil { + t.Fatalf("Timed out waiting for %s: %v", description, lastErr) + } + t.Fatalf("Timed out waiting for %s", description) +} diff --git a/go/internal/e2e/rpc_event_log_e2e_test.go b/go/internal/e2e/rpc_event_log_e2e_test.go new file mode 100644 index 000000000..63614b4e2 --- /dev/null +++ b/go/internal/e2e/rpc_event_log_e2e_test.go @@ -0,0 +1,181 @@ +package e2e + +import ( + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +const rpcEventLogTimeout = 30 * time.Second + +// Mirrors dotnet/test/E2E/RpcEventLogE2ETests.cs (snapshot category "rpc_event_log"). +func TestRpcEventLogE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should read persisted events from beginning", func(t *testing.T) { + session := createEventLogSession(t, client) + defer session.Disconnect() + + if _, err := session.RPC.Plan.Update(t.Context(), &rpc.PlanUpdateRequest{Content: "# Event log E2E plan\n- persisted event"}); err != nil { + t.Fatalf("Plan.Update failed: %v", err) + } + + var read *rpc.EventsReadResult + waitForRPCCondition(t, rpcEventLogTimeout, "persisted session.plan_changed event", func() (bool, error) { + var err error + read, err = session.RPC.EventLog.Read(t.Context(), &rpc.EventLogReadRequest{ + Max: rpcPtr(int32(100)), + WaitMs: rpcPtr(int32(0)), + }) + if err != nil { + return false, err + } + for _, event := range read.Events { + if data, ok := event.Data.(*copilot.SessionPlanChangedData); ok && + data.Operation == copilot.PlanChangedOperationCreate && + (event.Ephemeral == nil || !*event.Ephemeral) { + return true, nil + } + } + return false, nil + }) + + if read.CursorStatus != rpc.EventsCursorStatusOk { + t.Fatalf("Expected cursor status ok, got %q", read.CursorStatus) + } + if read.Cursor == "" { + t.Fatal("Expected non-empty cursor") + } + }) + + t.Run("should return tail cursor and read empty when no new events", func(t *testing.T) { + session := createEventLogSession(t, client) + defer session.Disconnect() + + var tail *rpc.EventLogTailResult + var read *rpc.EventsReadResult + waitForRPCCondition(t, rpcEventLogTimeout, "stable empty event log tail", func() (bool, error) { + var err error + tail, err = session.RPC.EventLog.Tail(t.Context()) + if err != nil { + return false, err + } + read, err = session.RPC.EventLog.Read(t.Context(), &rpc.EventLogReadRequest{ + Cursor: &tail.Cursor, + Max: rpcPtr(int32(10)), + WaitMs: rpcPtr(int32(0)), + }) + return err == nil && read.CursorStatus == rpc.EventsCursorStatusOk && len(read.Events) == 0, err + }) + + if tail.Cursor == "" { + t.Fatal("Expected non-empty tail cursor") + } + if len(read.Events) != 0 { + t.Fatalf("Expected no events after tail cursor, got %d", len(read.Events)) + } + if read.HasMore { + t.Fatal("Expected HasMore=false for empty read") + } + }) + + t.Run("should register and release event interest idempotently", func(t *testing.T) { + session := createEventLogSession(t, client) + defer session.Disconnect() + + registered, err := session.RPC.EventLog.RegisterInterest(t.Context(), &rpc.RegisterEventInterestParams{ + EventType: string(copilot.SessionEventTypeSessionTitleChanged), + }) + if err != nil { + t.Fatalf("EventLog.RegisterInterest failed: %v", err) + } + if registered.Handle == "" { + t.Fatal("Expected non-empty event interest handle") + } + + released, err := session.RPC.EventLog.ReleaseInterest(t.Context(), &rpc.ReleaseEventInterestParams{Handle: registered.Handle}) + if err != nil { + t.Fatalf("EventLog.ReleaseInterest failed: %v", err) + } + if !released.Success { + t.Fatal("Expected first ReleaseInterest to succeed") + } + releasedAgain, err := session.RPC.EventLog.ReleaseInterest(t.Context(), &rpc.ReleaseEventInterestParams{Handle: registered.Handle}) + if err != nil { + t.Fatalf("EventLog.ReleaseInterest second call failed: %v", err) + } + if !releasedAgain.Success { + t.Fatal("Expected second ReleaseInterest to be idempotent") + } + }) + + t.Run("should long poll with types filter for title changed event", func(t *testing.T) { + session := createEventLogSession(t, client) + defer session.Disconnect() + + var read *rpc.EventsReadResult + var expectedTitle string + waitForRPCCondition(t, rpcEventLogTimeout, "filtered session.title_changed event", func() (bool, error) { + expectedTitle = "EventLogTitle-" + randomHex(t) + tail, err := session.RPC.EventLog.Tail(t.Context()) + if err != nil { + return false, err + } + resultCh := make(chan *rpc.EventsReadResult, 1) + errCh := make(chan error, 1) + go func() { + result, err := session.RPC.EventLog.Read(t.Context(), &rpc.EventLogReadRequest{ + Cursor: &tail.Cursor, + Max: rpcPtr(int32(10)), + WaitMs: rpcPtr(int32(5000)), + Types: &rpc.EventLogTypes{StringArray: []string{string(copilot.SessionEventTypeSessionTitleChanged)}}, + }) + if err != nil { + errCh <- err + return + } + resultCh <- result + }() + time.Sleep(100 * time.Millisecond) + if _, err := session.RPC.Name.Set(t.Context(), &rpc.NameSetRequest{Name: expectedTitle}); err != nil { + return false, err + } + select { + case err := <-errCh: + return false, err + case read = <-resultCh: + case <-time.After(6 * time.Second): + return false, nil + } + for _, event := range read.Events { + if event.Type() != copilot.SessionEventTypeSessionTitleChanged { + return false, nil + } + if data, ok := event.Data.(*copilot.SessionTitleChangedData); ok && data.Title == expectedTitle { + return true, nil + } + } + return false, nil + }) + + if read.CursorStatus != rpc.EventsCursorStatusOk { + t.Fatalf("Expected cursor status ok, got %q", read.CursorStatus) + } + }) +} + +func createEventLogSession(t *testing.T, client *copilot.Client) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} diff --git a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go index 6063dd162..9f358644b 100644 --- a/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go +++ b/go/internal/e2e/rpc_mcp_and_skills_e2e_test.go @@ -63,6 +63,42 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { assertSkillState(t, disabledAgain, skillName, false) }) + t.Run("should ensure skills are loaded and list invoked skills", func(t *testing.T) { + skillName := fmt.Sprintf("ensure-rpc-skill-%s", randomHex(t)) + skillsDir := createMcpSkillsRpcDirectory(t, ctx.WorkDir, "session-rpc-skills", skillName, "Skill loaded explicitly by RPC.") + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + SkillDirectories: []string{skillsDir}, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + if _, err := session.RPC.Skills.EnsureLoaded(t.Context()); err != nil { + t.Fatalf("Skills.EnsureLoaded failed: %v", err) + } + loaded, err := session.RPC.Skills.List(t.Context()) + if err != nil { + t.Fatalf("Skills.List failed: %v", err) + } + skill := assertSkillState(t, loaded, skillName, true) + if skill.Description != "Skill loaded explicitly by RPC." { + t.Errorf("Expected description to match, got %q", skill.Description) + } + + invoked, err := session.RPC.Skills.GetInvoked(t.Context()) + if err != nil { + t.Fatalf("Skills.GetInvoked failed: %v", err) + } + if invoked.Skills == nil { + t.Fatal("Expected non-nil invoked skills list") + } + if len(invoked.Skills) != 0 { + t.Fatalf("Expected no invoked skills in fresh session, got %+v", invoked.Skills) + } + }) + t.Run("should reload session skills", func(t *testing.T) { skillsDir := filepath.Join(ctx.WorkDir, "reloadable-rpc-skills", randomHex(t)) if err := os.MkdirAll(skillsDir, 0755); err != nil { @@ -134,6 +170,95 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { } }) + t.Run("should set mcp env value mode and remove github server", func(t *testing.T) { + const serverName = "github" + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: testMCPServers(t, serverName), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + waitForMCPServerStatus(t, session, serverName, rpc.McpServerStatusConnected) + direct, err := session.RPC.Mcp.SetEnvValueMode(t.Context(), &rpc.McpSetEnvValueModeParams{Mode: rpc.McpSetEnvValueModeDetailsDirect}) + if err != nil { + t.Fatalf("Mcp.SetEnvValueMode(direct) failed: %v", err) + } + if direct.Mode != rpc.McpSetEnvValueModeDetailsDirect { + t.Fatalf("Expected direct env value mode, got %+v", direct) + } + indirect, err := session.RPC.Mcp.SetEnvValueMode(t.Context(), &rpc.McpSetEnvValueModeParams{Mode: rpc.McpSetEnvValueModeDetailsIndirect}) + if err != nil { + t.Fatalf("Mcp.SetEnvValueMode(indirect) failed: %v", err) + } + if indirect.Mode != rpc.McpSetEnvValueModeDetailsIndirect { + t.Fatalf("Expected indirect env value mode, got %+v", indirect) + } + + removeGitHub, err := session.RPC.Mcp.RemoveGitHub(t.Context()) + if err != nil { + t.Fatalf("Mcp.RemoveGitHub failed: %v", err) + } + if removeGitHub.Removed { + t.Fatalf("Expected RemoveGitHub=false for explicitly configured server, got %+v", removeGitHub) + } + servers, err := session.RPC.Mcp.List(t.Context()) + if err != nil { + t.Fatalf("Mcp.List failed: %v", err) + } + var stillConnected bool + for _, server := range servers.Servers { + if server.Name == serverName && server.Status == rpc.McpServerStatusConnected { + stillConnected = true + break + } + } + if !stillConnected { + t.Fatalf("Expected %q MCP server to remain connected after RemoveGitHub, got %+v", serverName, servers.Servers) + } + }) + + t.Run("should report mcp sampling failure and cancel missing sampling", func(t *testing.T) { + const serverName = "rpc-sampling-server" + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: testMCPServers(t, serverName), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + waitForMCPServerStatus(t, session, serverName, rpc.McpServerStatusConnected) + + cancelMissing, err := session.RPC.Mcp.CancelSamplingExecution(t.Context(), &rpc.McpCancelSamplingExecutionParams{RequestID: "missing-" + randomHex(t)}) + if err != nil { + t.Fatalf("Mcp.CancelSamplingExecution failed: %v", err) + } + if cancelMissing.Cancelled { + t.Fatal("Expected cancelling missing sampling execution to report Cancelled=false") + } + + result, err := session.RPC.Mcp.ExecuteSampling(t.Context(), &rpc.McpExecuteSamplingParams{ + RequestID: "sampling-" + randomHex(t), + ServerName: "missing-sampling-server", + McpRequestID: "mcp-request-" + randomHex(t), + Request: rpc.McpExecuteSamplingRequest{}, + }) + if err != nil { + assertRpcError(t, "Mcp.ExecuteSampling", func() error { return err }, "sampling") + return + } + if result.Action != rpc.McpSamplingExecutionActionFailure { + t.Fatalf("Expected sampling failure action, got %+v", result) + } + if result.Result != nil || result.Error == nil || strings.TrimSpace(*result.Error) == "" { + t.Fatalf("Expected failure error without result, got %+v", result) + } + if strings.Contains(strings.ToLower(*result.Error), "unhandled method") { + t.Fatalf("Expected implemented sampling error, got %+v", result) + } + }) + t.Run("should list plugins", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -181,6 +306,131 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { } }) + t.Run("should round trip mcp app host context", func(t *testing.T) { + mcpAppsClient := createMcpAppsClient(ctx) + t.Cleanup(func() { mcpAppsClient.ForceStop() }) + session, err := mcpAppsClient.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + displayMode := rpc.McpAppsSetHostContextDetailsDisplayModeInline + platform := rpc.McpAppsSetHostContextDetailsPlatformDesktop + theme := rpc.McpAppsSetHostContextDetailsThemeDark + if _, err := session.RPC.Mcp.Apps().SetHostContext(t.Context(), &rpc.McpAppsSetHostContextRequest{ + Context: rpc.McpAppsSetHostContextDetails{ + AvailableDisplayModes: []rpc.McpAppsSetHostContextDetailsAvailableDisplayMode{ + rpc.McpAppsSetHostContextDetailsAvailableDisplayModeInline, + rpc.McpAppsSetHostContextDetailsAvailableDisplayModeFullscreen, + }, + DisplayMode: &displayMode, + Locale: rpcPtr("en-GB"), + Platform: &platform, + Theme: &theme, + TimeZone: rpcPtr("Etc/UTC"), + UserAgent: rpcPtr("go-sdk-e2e"), + }, + }); err != nil { + t.Fatalf("Mcp.Apps.SetHostContext failed: %v", err) + } + + result, err := session.RPC.Mcp.Apps().GetHostContext(t.Context()) + if err != nil { + t.Fatalf("Mcp.Apps.GetHostContext failed: %v", err) + } + if result.Context.DisplayMode == nil || string(*result.Context.DisplayMode) != "inline" || + result.Context.Locale == nil || *result.Context.Locale != "en-GB" || + result.Context.Platform == nil || string(*result.Context.Platform) != "desktop" || + result.Context.Theme == nil || string(*result.Context.Theme) != "dark" || + result.Context.TimeZone == nil || *result.Context.TimeZone != "Etc/UTC" || + result.Context.UserAgent == nil || *result.Context.UserAgent != "go-sdk-e2e" { + t.Fatalf("Unexpected MCP app host context: %+v", result.Context) + } + if len(result.Context.AvailableDisplayModes) != 2 { + t.Fatalf("Expected two available display modes, got %+v", result.Context.AvailableDisplayModes) + } + }) + + t.Run("should diagnose and report mcp app capability errors", func(t *testing.T) { + const serverName = "rpc-apps-server" + const otherServerName = "rpc-apps-other-server" + servers := testMCPServers(t, serverName, otherServerName) + if stdio, ok := servers[serverName].(copilot.MCPStdioServerConfig); ok { + stdio.Env = map[string]string{"MCP_APP_RPC_VALUE": "from-app-rpc"} + servers[serverName] = stdio + } + + mcpAppsClient := createMcpAppsClient(ctx) + t.Cleanup(func() { mcpAppsClient.ForceStop() }) + session, err := mcpAppsClient.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: servers, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + waitForMCPServerStatus(t, session, serverName, rpc.McpServerStatusConnected) + waitForMCPServerStatus(t, session, otherServerName, rpc.McpServerStatusConnected) + + diagnose, err := session.RPC.Mcp.Apps().Diagnose(t.Context(), &rpc.McpAppsDiagnoseRequest{ServerName: serverName}) + if err != nil { + t.Fatalf("Mcp.Apps.Diagnose failed: %v", err) + } + if !diagnose.Server.Connected || diagnose.Server.ToolCount < 1 { + t.Fatalf("Expected connected MCP app diagnose result with tools, got %+v", diagnose) + } + + assertMcpAppsResultOrImplementedError(t, "Mcp.Apps.ListTools(self)", func() (any, error) { + return session.RPC.Mcp.Apps().ListTools(t.Context(), &rpc.McpAppsListToolsRequest{ + ServerName: serverName, + OriginServerName: serverName, + }) + }) + assertMcpAppsResultOrImplementedError(t, "Mcp.Apps.ListTools(other)", func() (any, error) { + return session.RPC.Mcp.Apps().ListTools(t.Context(), &rpc.McpAppsListToolsRequest{ + ServerName: serverName, + OriginServerName: otherServerName, + }) + }) + assertMcpAppsResultOrImplementedError(t, "Mcp.Apps.CallTool", func() (any, error) { + return session.RPC.Mcp.Apps().CallTool(t.Context(), &rpc.McpAppsCallToolRequest{ + ServerName: serverName, + OriginServerName: serverName, + ToolName: "get_env", + Arguments: map[string]any{"name": "MCP_APP_RPC_VALUE"}, + }) + }) + }) + + t.Run("should report error when mcp app resource is not available", func(t *testing.T) { + const serverName = "rpc-apps-resource-server" + mcpAppsClient := createMcpAppsClient(ctx) + t.Cleanup(func() { mcpAppsClient.ForceStop() }) + session, err := mcpAppsClient.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: testMCPServers(t, serverName), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + waitForMCPServerStatus(t, session, serverName, rpc.McpServerStatusConnected) + + _, err = session.RPC.Mcp.Apps().ReadResource(t.Context(), &rpc.McpAppsReadResourceRequest{ + ServerName: serverName, + URI: "ui://missing-resource", + }) + if err == nil { + t.Fatal("Expected missing MCP app resource to fail") + } + text := strings.ToLower(err.Error()) + if strings.Contains(text, "unhandled method") || + (!strings.Contains(text, "resource") && !strings.Contains(text, "not found") && !strings.Contains(text, "method not found")) { + t.Fatalf("Expected implemented missing-resource error, got %v", err) + } + }) + t.Run("should report error when mcp host is not initialized", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -201,6 +451,52 @@ func TestRpcMcpAndSkillsE2E(t *testing.T) { _, e := session.RPC.Mcp.Reload(t.Context()) return e }, "mcp config reload not available") + assertRpcError(t, "Mcp.Oauth.Login", func() error { + _, e := session.RPC.Mcp.Oauth().Login(t.Context(), &rpc.McpOauthLoginRequest{ServerName: "missing-server"}) + return e + }, "mcp host is not available") + }) + + t.Run("should report error when mcp oauth server is not configured", func(t *testing.T) { + const serverName = "configured-stdio-server" + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: testMCPServers(t, serverName), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + waitForMCPServerStatus(t, session, serverName, rpc.McpServerStatusConnected) + + assertRpcError(t, "Mcp.Oauth.Login", func() error { + _, e := session.RPC.Mcp.Oauth().Login(t.Context(), &rpc.McpOauthLoginRequest{ServerName: "missing-server"}) + return e + }, "is not configured") + }) + + t.Run("should report error when mcp oauth server is not remote", func(t *testing.T) { + const serverName = "configured-stdio-server" + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + MCPServers: testMCPServers(t, serverName), + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + waitForMCPServerStatus(t, session, serverName, rpc.McpServerStatusConnected) + + force := true + clientName := "SDK E2E" + callback := "Done" + assertRpcError(t, "Mcp.Oauth.Login", func() error { + _, e := session.RPC.Mcp.Oauth().Login(t.Context(), &rpc.McpOauthLoginRequest{ + ServerName: serverName, + ForceReauth: &force, + ClientName: &clientName, + CallbackSuccessMessage: &callback, + }) + return e + }, "not a remote server") }) t.Run("should report error when extensions are not available", func(t *testing.T) { @@ -274,6 +570,39 @@ func assertSkillState(t *testing.T, list *rpc.SkillList, name string, enabled bo return matched } +func createMcpAppsClient(ctx *testharness.TestContext) *copilot.Client { + return ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Env = append(opts.Env, "COPILOT_MCP_APPS=true", "MCP_APPS=true") + }) +} + +func assertMcpAppsResultOrImplementedError(t *testing.T, name string, action func() (any, error)) { + t.Helper() + result, err := action() + if err == nil { + if result == nil { + t.Fatalf("%s returned nil result", name) + } + switch value := result.(type) { + case *rpc.McpAppsListToolsResult: + if value.Tools == nil { + t.Fatalf("%s returned nil Tools", name) + } + case *rpc.SessionMcpAppsCallToolResult: + if value == nil { + t.Fatalf("%s returned nil CallTool result", name) + } + } + return + } + + text := strings.ToLower(err.Error()) + if strings.Contains(text, "unhandled method") || + (!strings.Contains(text, "mcp-apps") && !strings.Contains(text, "capability") && !strings.Contains(text, "visibility")) { + t.Fatalf("Expected %s to return an implemented MCP apps error, got %v", name, err) + } +} + func assertRpcError(t *testing.T, name string, action func() error, expectedSubstring string) { t.Helper() err := action() diff --git a/go/internal/e2e/rpc_queue_e2e_test.go b/go/internal/e2e/rpc_queue_e2e_test.go new file mode 100644 index 000000000..ff567fab1 --- /dev/null +++ b/go/internal/e2e/rpc_queue_e2e_test.go @@ -0,0 +1,204 @@ +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" +) + +// Mirrors dotnet/test/E2E/RpcQueueE2ETests.cs (snapshot category "rpc_queue"). +func TestRpcQueueE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("fresh queue is empty and empty mutations are noops", func(t *testing.T) { + session := createQueueSession(t, client) + defer session.Disconnect() + + assertQueueEmpty(t, session) + + remove, err := session.RPC.Queue.RemoveMostRecent(t.Context()) + if err != nil { + t.Fatalf("Queue.RemoveMostRecent failed: %v", err) + } + if remove.Removed { + t.Fatal("Expected RemoveMostRecent Removed=false on empty queue") + } + assertQueueEmpty(t, session) + + if _, err := session.RPC.Queue.Clear(t.Context()); err != nil { + t.Fatalf("Queue.Clear failed: %v", err) + } + assertQueueEmpty(t, session) + }) + + t.Run("pending items reports queued command and remove and clear update queue", func(t *testing.T) { + session := createQueueSession(t, client) + defer session.Disconnect() + + interest, err := session.RPC.EventLog.RegisterInterest(t.Context(), &rpc.RegisterEventInterestParams{EventType: string(copilot.SessionEventTypeCommandQueued)}) + if err != nil { + t.Fatalf("EventLog.RegisterInterest failed: %v", err) + } + defer func() { + _, _ = session.RPC.EventLog.ReleaseInterest(t.Context(), &rpc.ReleaseEventInterestParams{Handle: interest.Handle}) + _, _ = session.RPC.Queue.Clear(t.Context()) + }() + + firstCommand := "/sdk-queue-first-" + randomHex(t) + secondCommand := "/sdk-queue-second-" + randomHex(t) + thirdCommand := "/sdk-queue-third-" + randomHex(t) + firstQueued := make(chan *copilot.CommandQueuedData, 1) + unsubscribe := session.On(func(event copilot.SessionEvent) { + data, ok := event.Data.(*copilot.CommandQueuedData) + if ok && data.Command == firstCommand { + select { + case firstQueued <- data: + default: + } + } + }) + defer unsubscribe() + + first, err := session.RPC.Commands.Enqueue(t.Context(), &rpc.EnqueueCommandParams{Command: firstCommand}) + if err != nil { + t.Fatalf("Commands.Enqueue(first) failed: %v", err) + } + if !first.Queued { + t.Fatal("Expected first command to be queued") + } + + var firstEvent *copilot.CommandQueuedData + select { + case firstEvent = <-firstQueued: + case <-time.After(30 * time.Second): + t.Fatalf("Timed out waiting for first command.queued event") + } + + second, err := session.RPC.Commands.Enqueue(t.Context(), &rpc.EnqueueCommandParams{Command: secondCommand}) + if err != nil { + t.Fatalf("Commands.Enqueue(second) failed: %v", err) + } + if !second.Queued { + t.Fatal("Expected second command to be queued") + } + waitForCommandInPendingItems(t, session, secondCommand) + + remove, err := session.RPC.Queue.RemoveMostRecent(t.Context()) + if err != nil { + t.Fatalf("Queue.RemoveMostRecent failed: %v", err) + } + if !remove.Removed { + t.Fatal("Expected RemoveMostRecent to remove second queued command") + } + waitForCommandNotInPendingItems(t, session, secondCommand) + + third, err := session.RPC.Commands.Enqueue(t.Context(), &rpc.EnqueueCommandParams{Command: thirdCommand}) + if err != nil { + t.Fatalf("Commands.Enqueue(third) failed: %v", err) + } + if !third.Queued { + t.Fatal("Expected third command to be queued") + } + waitForCommandInPendingItems(t, session, thirdCommand) + + if _, err := session.RPC.Queue.Clear(t.Context()); err != nil { + t.Fatalf("Queue.Clear failed: %v", err) + } + waitForCommandNotInPendingItems(t, session, thirdCommand) + + stop := true + completed, err := session.RPC.Commands.RespondToQueuedCommand(t.Context(), &rpc.CommandsRespondToQueuedCommandRequest{ + RequestID: firstEvent.RequestID, + Result: rpc.QueuedCommandHandled{StopProcessingQueue: &stop}, + }) + if err != nil { + t.Fatalf("Commands.RespondToQueuedCommand failed: %v", err) + } + if !completed.Success { + t.Fatal("Expected response to first queued command to succeed") + } + waitForQueueEmpty(t, session) + }) +} + +func createQueueSession(t *testing.T, client *copilot.Client) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} + +func assertQueueEmpty(t *testing.T, session *copilot.Session) { + t.Helper() + pending, err := session.RPC.Queue.PendingItems(t.Context()) + if err != nil { + t.Fatalf("Queue.PendingItems failed: %v", err) + } + if len(pending.Items) != 0 || len(pending.SteeringMessages) != 0 { + t.Fatalf("Expected empty queue, got %+v", pending) + } +} + +func waitForCommandInPendingItems(t *testing.T, session *copilot.Session, command string) { + t.Helper() + var matched *rpc.QueuePendingItems + waitForRPCCondition(t, 30*time.Second, "queued command "+command+" to appear", func() (bool, error) { + pending, err := session.RPC.Queue.PendingItems(t.Context()) + if err != nil { + return false, err + } + for i := range pending.Items { + if isPendingCommand(pending.Items[i], command) { + matched = &pending.Items[i] + return true, nil + } + } + return false, nil + }) + if matched.Kind != rpc.QueuePendingItemsKindCommand { + t.Fatalf("Expected command pending item, got %+v", matched) + } + if !strings.Contains(matched.DisplayText, strings.TrimPrefix(command, "/")) && matched.DisplayText != command { + t.Fatalf("Expected pending item display text to include %q, got %q", command, matched.DisplayText) + } +} + +func waitForCommandNotInPendingItems(t *testing.T, session *copilot.Session, command string) { + t.Helper() + waitForRPCCondition(t, 30*time.Second, "queued command "+command+" to leave queue", func() (bool, error) { + pending, err := session.RPC.Queue.PendingItems(t.Context()) + if err != nil { + return false, err + } + for _, item := range pending.Items { + if isPendingCommand(item, command) { + return false, nil + } + } + return true, nil + }) +} + +func waitForQueueEmpty(t *testing.T, session *copilot.Session) { + t.Helper() + waitForRPCCondition(t, 30*time.Second, "queue to empty", func() (bool, error) { + pending, err := session.RPC.Queue.PendingItems(t.Context()) + return err == nil && len(pending.Items) == 0 && len(pending.SteeringMessages) == 0, err + }) + assertQueueEmpty(t, session) +} + +func isPendingCommand(item rpc.QueuePendingItems, command string) bool { + return item.Kind == rpc.QueuePendingItemsKindCommand && + (item.DisplayText == command || strings.Contains(item.DisplayText, strings.TrimPrefix(command, "/"))) +} diff --git a/go/internal/e2e/rpc_remote_e2e_test.go b/go/internal/e2e/rpc_remote_e2e_test.go new file mode 100644 index 000000000..b1243526b --- /dev/null +++ b/go/internal/e2e/rpc_remote_e2e_test.go @@ -0,0 +1,108 @@ +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" +) + +// Mirrors dotnet/test/E2E/RpcRemoteE2ETests.cs (snapshot category "rpc_remote"). +func TestRpcRemoteE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should treat remote off as no op or implemented error", func(t *testing.T) { + session := createRemoteSession(t, client) + defer session.Disconnect() + + mode := rpc.RemoteSessionModeOff + result, err := session.RPC.Remote.Enable(t.Context(), &rpc.RemoteEnableRequest{Mode: &mode}) + if err != nil { + assertImplementedRPCError(t, err, "session.remote.enable") + return + } + if result.RemoteSteerable { + t.Fatalf("Expected remote off to report RemoteSteerable=false, got %+v", result) + } + if result.URL != nil && *result.URL != "" { + t.Fatalf("Expected remote off to return empty URL, got %q", *result.URL) + } + }) + + t.Run("should treat remote disable as no op or implemented error", func(t *testing.T) { + session := createRemoteSession(t, client) + defer session.Disconnect() + + if _, err := session.RPC.Remote.Disable(t.Context()); err != nil { + assertImplementedRPCError(t, err, "session.remote.disable") + } + }) + + t.Run("should notify steerable changed event and persist flag", func(t *testing.T) { + session := createRemoteSession(t, client) + defer session.Disconnect() + + if _, err := session.RPC.Remote.NotifySteerableChanged(t.Context(), &rpc.RemoteNotifySteerableChangedRequest{RemoteSteerable: true}); err != nil { + t.Fatalf("Remote.NotifySteerableChanged(true) failed: %v", err) + } + waitForRemoteSteerableEvent(t, session, true) + persisted, err := client.RPC.Sessions.GetPersistedRemoteSteerable(t.Context(), &rpc.SessionsGetPersistedRemoteSteerableRequest{SessionID: session.SessionID}) + if err != nil { + t.Fatalf("Sessions.GetPersistedRemoteSteerable(true) failed: %v", err) + } + if persisted.RemoteSteerable == nil || !*persisted.RemoteSteerable { + t.Fatalf("Expected persisted RemoteSteerable=true, got %+v", persisted) + } + + if _, err := session.RPC.Remote.NotifySteerableChanged(t.Context(), &rpc.RemoteNotifySteerableChangedRequest{RemoteSteerable: false}); err != nil { + t.Fatalf("Remote.NotifySteerableChanged(false) failed: %v", err) + } + waitForRemoteSteerableEvent(t, session, false) + persisted, err = client.RPC.Sessions.GetPersistedRemoteSteerable(t.Context(), &rpc.SessionsGetPersistedRemoteSteerableRequest{SessionID: session.SessionID}) + if err != nil { + t.Fatalf("Sessions.GetPersistedRemoteSteerable(false) failed: %v", err) + } + if persisted.RemoteSteerable == nil || *persisted.RemoteSteerable { + t.Fatalf("Expected persisted RemoteSteerable=false, got %+v", persisted) + } + }) +} + +func createRemoteSession(t *testing.T, client *copilot.Client) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} + +func waitForRemoteSteerableEvent(t *testing.T, session *copilot.Session, expected bool) { + t.Helper() + waitForRPCCondition(t, 30*time.Second, "session.remote_steerable_changed event", func() (bool, error) { + events, err := session.GetEvents(t.Context()) + if err != nil { + return false, err + } + for _, event := range events { + if data, ok := event.Data.(*copilot.SessionRemoteSteerableChangedData); ok && data.RemoteSteerable == expected { + return true, nil + } + } + return false, nil + }) +} + +func assertImplementedRPCError(t *testing.T, err error, method string) { + t.Helper() + if strings.Contains(strings.ToLower(err.Error()), "unhandled method "+strings.ToLower(method)) { + t.Fatalf("Expected implemented error for %s, got %v", method, err) + } +} diff --git a/go/internal/e2e/rpc_schedule_e2e_test.go b/go/internal/e2e/rpc_schedule_e2e_test.go new file mode 100644 index 000000000..359cd20e1 --- /dev/null +++ b/go/internal/e2e/rpc_schedule_e2e_test.go @@ -0,0 +1,64 @@ +package e2e + +import ( + "math" + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/e2e/testharness" + "github.com/github/copilot-sdk/go/rpc" +) + +// Mirrors dotnet/test/E2E/RpcScheduleE2ETests.cs (snapshot category "rpc_schedule"). +func TestRpcScheduleE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should list no schedules for fresh session", func(t *testing.T) { + session := createScheduleSession(t, client) + defer session.Disconnect() + + result, err := session.RPC.Schedule.List(t.Context()) + if err != nil { + t.Fatalf("Schedule.List failed: %v", err) + } + if result.Entries == nil { + t.Fatal("Expected non-nil schedule Entries") + } + if len(result.Entries) != 0 { + t.Fatalf("Expected no schedules for a fresh session, got %+v", result.Entries) + } + }) + + t.Run("should return nil entry when stopping unknown schedule", func(t *testing.T) { + session := createScheduleSession(t, client) + defer session.Disconnect() + + result, err := session.RPC.Schedule.Stop(t.Context(), &rpc.ScheduleStopRequest{ID: math.MaxInt64}) + if err != nil { + t.Fatalf("Schedule.Stop failed: %v", err) + } + if result.Entry != nil { + t.Fatalf("Expected nil entry for unknown schedule, got %+v", result.Entry) + } + list, err := session.RPC.Schedule.List(t.Context()) + if err != nil { + t.Fatalf("Schedule.List after Stop failed: %v", err) + } + if len(list.Entries) != 0 { + t.Fatalf("Expected no schedules after stopping unknown schedule, got %+v", list.Entries) + } + }) +} + +func createScheduleSession(t *testing.T, client *copilot.Client) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go index aaf9eafbc..66288aa0e 100644 --- a/go/internal/e2e/rpc_server_e2e_test.go +++ b/go/internal/e2e/rpc_server_e2e_test.go @@ -10,6 +10,7 @@ import ( copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" "github.com/github/copilot-sdk/go/rpc" + "github.com/google/uuid" ) // Mirrors dotnet/test/RpcServerTests.cs (snapshot category "rpc_server"). @@ -151,6 +152,375 @@ func TestRpcServerE2E(t *testing.T) { } }) + t.Run("should call rpc session fs set provider with typed result", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + result, err := client.RPC.SessionFs.SetProvider(t.Context(), &rpc.SessionFsSetProviderRequest{ + InitialCwd: "/", + SessionStatePath: "/session-state", + Conventions: rpc.SessionFsSetProviderConventionsPosix, + Capabilities: &rpc.SessionFsSetProviderCapabilities{Sqlite: rpcPtr(true)}, + }) + if err != nil { + t.Fatalf("SessionFs.SetProvider failed: %v", err) + } + if !result.Success { + t.Fatalf("Expected SessionFs.SetProvider Success=true, got %+v", result) + } + }) + + t.Run("should add secret filter values", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient(func(opts *copilot.ClientOptions) { + opts.Env = append(opts.Env, "COPILOT_ENABLE_SECRET_FILTERING=true") + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + secret := "rpc-secret-" + randomHex(t) + result, err := client.RPC.Secrets.AddFilterValues(t.Context(), &rpc.SecretsAddFilterValuesRequest{Values: []string{secret}}) + if err != nil { + t.Fatalf("Secrets.AddFilterValues failed: %v", err) + } + if !result.Ok { + t.Fatalf("Expected AddFilterValues Ok=true, got %+v", result) + } + }) + + t.Run("should list find and inspect persisted session state", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + token := "rpc-server-list-token-" + randomHex(t) + registerProxyUser(t, ctx, token, "rpc-user", nil) + client := newAuthenticatedClient(ctx, token) + t.Cleanup(func() { client.ForceStop() }) + + sessionID := uuid.NewString() + workingDirectory := createUniqueRPCWorkDirectory(t, ctx, "server-rpc-list") + missingSessionID := uuid.NewString() + missingTaskID := "missing-task-" + randomHex(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: sessionID, + WorkingDirectory: workingDirectory, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session.Disconnect() }) + if err := session.Log(t.Context(), "SERVER_RPC_LIST_READY", nil); err != nil { + t.Fatalf("Log failed: %v", err) + } + + eventFilePath := saveAndGetEventFilePath(t, client, sessionID) + if !strings.Contains(strings.ToLower(eventFilePath), strings.ToLower(sessionID)) { + t.Fatalf("Expected event file path %q to contain session ID %q", eventFilePath, sessionID) + } + + metadataLimit := int64(0) + filter := &rpc.SessionListFilter{Cwd: &workingDirectory} + listed, err := client.RPC.Sessions.List(t.Context(), &rpc.SessionsListRequest{ + MetadataLimit: &metadataLimit, + Filter: filter, + }) + if err != nil { + t.Fatalf("Sessions.List failed: %v", err) + } + if listed.Sessions == nil { + t.Fatal("Expected non-nil sessions list") + } + for _, metadata := range listed.Sessions { + if metadata.Context != nil { + assertRPCPathEqual(t, workingDirectory, metadata.Context.Cwd) + } + } + + byPrefix, err := client.RPC.Sessions.FindByPrefix(t.Context(), &rpc.SessionsFindByPrefixRequest{Prefix: sessionID[:8]}) + if err != nil { + t.Fatalf("Sessions.FindByPrefix failed: %v", err) + } + if byPrefix.SessionID != nil && *byPrefix.SessionID != sessionID { + t.Fatalf("Expected prefix lookup to return %q or nil, got %q", sessionID, *byPrefix.SessionID) + } + + byTask, err := client.RPC.Sessions.FindByTaskId(t.Context(), &rpc.SessionsFindByTaskIDRequest{TaskID: missingTaskID}) + if err != nil { + t.Fatalf("Sessions.FindByTaskId failed: %v", err) + } + if byTask.SessionID != nil { + t.Fatalf("Expected missing task ID lookup to return nil, got %q", *byTask.SessionID) + } + + lastForContext, err := client.RPC.Sessions.GetLastForContext(t.Context(), &rpc.SessionsGetLastForContextRequest{ + Context: &rpc.SessionContext{Cwd: workingDirectory}, + }) + if err != nil { + t.Fatalf("Sessions.GetLastForContext failed: %v", err) + } + if lastForContext.SessionID != nil && *lastForContext.SessionID != sessionID { + t.Fatalf("Expected last session for context to be %q or nil, got %q", sessionID, *lastForContext.SessionID) + } + + sizes, err := client.RPC.Sessions.GetSizes(t.Context()) + if err != nil { + t.Fatalf("Sessions.GetSizes failed: %v", err) + } + if sizes.Sizes == nil { + t.Fatal("Expected non-nil session sizes map") + } + if size, present := sizes.Sizes[sessionID]; present && size < 0 { + t.Fatalf("Expected non-negative size for %q, got %d", sessionID, size) + } + + inUse, err := client.RPC.Sessions.CheckInUse(t.Context(), &rpc.SessionsCheckInUseRequest{SessionIds: []string{sessionID, missingSessionID}}) + if err != nil { + t.Fatalf("Sessions.CheckInUse failed: %v", err) + } + if containsString(inUse.InUse, missingSessionID) { + t.Fatalf("Did not expect missing session %q to be in use: %+v", missingSessionID, inUse.InUse) + } + + remoteSteerable, err := client.RPC.Sessions.GetPersistedRemoteSteerable(t.Context(), &rpc.SessionsGetPersistedRemoteSteerableRequest{SessionID: sessionID}) + if err != nil { + t.Fatalf("Sessions.GetPersistedRemoteSteerable failed: %v", err) + } + if remoteSteerable.RemoteSteerable != nil { + t.Fatalf("Expected no persisted remote steerable flag, got %v", *remoteSteerable.RemoteSteerable) + } + }) + + t.Run("should enrich basic session metadata", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + token := "rpc-server-enrich-token-" + randomHex(t) + registerProxyUser(t, ctx, token, "rpc-user", nil) + client := newAuthenticatedClient(ctx, token) + t.Cleanup(func() { client.ForceStop() }) + + sessionID := uuid.NewString() + workingDirectory := createUniqueRPCWorkDirectory(t, ctx, "server-rpc-enrich") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: sessionID, + WorkingDirectory: workingDirectory, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session.Disconnect() }) + if err := session.Log(t.Context(), "SERVER_RPC_ENRICH_READY", nil); err != nil { + t.Fatalf("Log failed: %v", err) + } + saveAndGetEventFilePath(t, client, sessionID) + + now := time.Now().UTC().Format(time.RFC3339Nano) + result, err := client.RPC.Sessions.EnrichMetadata(t.Context(), &rpc.SessionsEnrichMetadataRequest{ + Sessions: []rpc.SessionMetadata{{ + SessionID: sessionID, + StartTime: now, + ModifiedTime: now, + IsRemote: false, + Name: rpcPtr("Basic metadata"), + Context: &rpc.SessionContext{Cwd: workingDirectory}, + }}, + }) + if err != nil { + t.Fatalf("Sessions.EnrichMetadata failed: %v", err) + } + if len(result.Sessions) != 1 { + t.Fatalf("Expected one enriched session, got %+v", result.Sessions) + } + enriched := result.Sessions[0] + if enriched.SessionID != sessionID { + t.Fatalf("Expected enriched session ID %q, got %q", sessionID, enriched.SessionID) + } + if enriched.Context == nil { + t.Fatal("Expected enriched context") + } + assertRPCPathEqual(t, workingDirectory, enriched.Context.Cwd) + if enriched.IsRemote { + t.Fatal("Expected local enriched session") + } + }) + + t.Run("should close active session and release lock", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + token := "rpc-server-close-token-" + randomHex(t) + registerProxyUser(t, ctx, token, "rpc-user", nil) + client := newAuthenticatedClient(ctx, token) + t.Cleanup(func() { client.ForceStop() }) + + sessionID := uuid.NewString() + workingDirectory := createUniqueRPCWorkDirectory(t, ctx, "server-rpc-close") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: sessionID, + WorkingDirectory: workingDirectory, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + if err := session.Log(t.Context(), "SERVER_RPC_CLOSE_READY", nil); err != nil { + t.Fatalf("Log failed: %v", err) + } + saveAndGetEventFilePath(t, client, sessionID) + + if _, err := client.RPC.Sessions.Close(t.Context(), &rpc.SessionsCloseRequest{SessionID: sessionID}); err != nil { + t.Fatalf("Sessions.Close failed: %v", err) + } + if _, err := client.RPC.Sessions.ReleaseLock(t.Context(), &rpc.SessionsReleaseLockRequest{SessionID: sessionID}); err != nil { + t.Fatalf("Sessions.ReleaseLock failed: %v", err) + } + inUse, err := client.RPC.Sessions.CheckInUse(t.Context(), &rpc.SessionsCheckInUseRequest{SessionIds: []string{sessionID}}) + if err != nil { + t.Fatalf("Sessions.CheckInUse failed: %v", err) + } + if containsString(inUse.InUse, sessionID) { + t.Fatalf("Expected %q not to be in use after close/release", sessionID) + } + }) + + t.Run("should prune dry run and bulk delete persisted session", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + token := "rpc-server-delete-token-" + randomHex(t) + registerProxyUser(t, ctx, token, "rpc-user", nil) + client := newAuthenticatedClient(ctx, token) + t.Cleanup(func() { client.ForceStop() }) + + sessionID := uuid.NewString() + missingSessionID := uuid.NewString() + workingDirectory := createUniqueRPCWorkDirectory(t, ctx, "server-rpc-delete") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: sessionID, + WorkingDirectory: workingDirectory, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + if err := session.Log(t.Context(), "SERVER_RPC_DELETE_READY", nil); err != nil { + t.Fatalf("Log failed: %v", err) + } + + saveAndGetEventFilePath(t, client, sessionID) + if _, err := client.RPC.Sessions.Close(t.Context(), &rpc.SessionsCloseRequest{SessionID: sessionID}); err != nil { + t.Fatalf("Sessions.Close failed: %v", err) + } + + prune, err := client.RPC.Sessions.PruneOld(t.Context(), &rpc.SessionsPruneOldRequest{ + OlderThanDays: 0, + DryRun: rpcPtr(true), + IncludeNamed: rpcPtr(true), + ExcludeSessionIds: []string{}, + }) + if err != nil { + t.Fatalf("Sessions.PruneOld failed: %v", err) + } + if !prune.DryRun { + t.Fatalf("Expected prune DryRun=true, got %+v", prune) + } + if containsString(prune.Deleted, sessionID) { + t.Fatalf("Dry run should not delete %q", sessionID) + } + if prune.FreedBytes < 0 { + t.Fatalf("Expected non-negative freed bytes, got %d", prune.FreedBytes) + } + + deleted, err := client.RPC.Sessions.BulkDelete(t.Context(), &rpc.SessionsBulkDeleteRequest{ + SessionIds: []string{sessionID, missingSessionID}, + }) + if err != nil { + t.Fatalf("Sessions.BulkDelete failed: %v", err) + } + freed, present := deleted.FreedBytes[sessionID] + if !present { + t.Fatalf("Expected BulkDelete to include %q in freedBytes, got %+v", sessionID, deleted.FreedBytes) + } + if freed < 0 { + t.Fatalf("Expected non-negative freed bytes for %q, got %d", sessionID, freed) + } + if missingFreed, present := deleted.FreedBytes[missingSessionID]; present && missingFreed != 0 { + t.Fatalf("Expected missing session freed bytes to be 0 when present, got %d", missingFreed) + } + + _ = session + }) + + t.Run("should set additional plugins and reload deferred hooks", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + if _, err := client.RPC.Sessions.SetAdditionalPlugins(t.Context(), &rpc.SessionsSetAdditionalPluginsRequest{Plugins: []rpc.InstalledPlugin{}}); err != nil { + t.Fatalf("Sessions.SetAdditionalPlugins(clear) failed: %v", err) + } + t.Cleanup(func() { + _, _ = client.RPC.Sessions.SetAdditionalPlugins(t.Context(), &rpc.SessionsSetAdditionalPluginsRequest{Plugins: []rpc.InstalledPlugin{}}) + }) + + sessionID := uuid.NewString() + workingDirectory := createUniqueRPCWorkDirectory(t, ctx, "server-rpc-hooks") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + SessionID: sessionID, + WorkingDirectory: workingDirectory, + EnableConfigDiscovery: false, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + t.Cleanup(func() { _ = session.Disconnect() }) + + if _, err := client.RPC.Sessions.ReloadPluginHooks(t.Context(), &rpc.SessionsReloadPluginHooksRequest{ + SessionID: sessionID, + DeferRepoHooks: rpcPtr(true), + }); err != nil { + t.Fatalf("Sessions.ReloadPluginHooks failed: %v", err) + } + loaded, err := client.RPC.Sessions.LoadDeferredRepoHooks(t.Context(), &rpc.SessionsLoadDeferredRepoHooksRequest{SessionID: sessionID}) + if err != nil { + t.Fatalf("Sessions.LoadDeferredRepoHooks failed: %v", err) + } + if loaded.StartupPrompts == nil { + t.Fatal("Expected non-nil StartupPrompts") + } + if loaded.HookCount != 0 || len(loaded.StartupPrompts) != 0 { + t.Fatalf("Expected no deferred hooks for isolated directory, got %+v", loaded) + } + }) + + t.Run("should report implemented error when connecting unknown remote session", func(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Start failed: %v", err) + } + + _, err := client.RPC.Sessions.Connect(t.Context(), &rpc.ConnectRemoteSessionParams{SessionID: "remote-" + randomHex(t)}) + if err == nil { + t.Fatal("Expected Sessions.Connect to fail for an unknown remote session") + } + text := strings.ToLower(err.Error()) + if strings.Contains(text, "unhandled method sessions.connect") { + t.Fatalf("Expected implemented error for sessions.connect, got %v", err) + } + if !strings.Contains(text, "session") { + t.Fatalf("Expected remote connect error to mention session, got %v", err) + } + }) + t.Run("should discover server mcp and skills", func(t *testing.T) { ctx := testharness.NewTestContext(t) ctx.ConfigureForTest(t) @@ -253,3 +623,24 @@ func findServerSkill(skills []rpc.ServerSkill, name string) *rpc.ServerSkill { } return nil } + +func saveAndGetEventFilePath(t *testing.T, client *copilot.Client, sessionID string) string { + t.Helper() + if _, err := client.RPC.Sessions.Save(t.Context(), &rpc.SessionsSaveRequest{SessionID: sessionID}); err != nil { + t.Fatalf("Sessions.Save failed: %v", err) + } + path, err := client.RPC.Sessions.GetEventFilePath(t.Context(), &rpc.SessionsGetEventFilePathRequest{SessionID: sessionID}) + if err != nil { + t.Fatalf("Sessions.GetEventFilePath failed: %v", err) + } + if strings.TrimSpace(path.FilePath) == "" { + t.Fatal("Expected non-empty event file path") + } + if !filepath.IsAbs(path.FilePath) { + t.Fatalf("Expected absolute event file path, got %q", path.FilePath) + } + if filepath.Base(path.FilePath) != "events.jsonl" { + t.Fatalf("Expected events.jsonl event file, got %q", path.FilePath) + } + return path.FilePath +} diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go index cb68651ae..1744cb10f 100644 --- a/go/internal/e2e/rpc_session_state_e2e_test.go +++ b/go/internal/e2e/rpc_session_state_e2e_test.go @@ -1,8 +1,10 @@ package e2e import ( + "path/filepath" "strings" "testing" + "time" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" @@ -23,11 +25,58 @@ func TestRpcSessionStateE2E(t *testing.T) { } t.Run("should call session rpc model getCurrent", func(t *testing.T) { - t.Skip("session.model.getCurrent not yet implemented in CLI") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + result, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Model.GetCurrent failed: %v", err) + } + if result.ModelID == nil || *result.ModelID != "claude-sonnet-4.5" { + t.Fatalf("Expected current model claude-sonnet-4.5, got %+v", result) + } }) t.Run("should call session rpc model switchTo", func(t *testing.T) { - t.Skip("session.model.switchTo not yet implemented in CLI") + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + before, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Model.GetCurrent before switch failed: %v", err) + } + if before.ModelID == nil { + t.Fatalf("Expected non-empty model before switch, got %+v", before) + } + + reasoningEffort := "high" + result, err := session.RPC.Model.SwitchTo(t.Context(), &rpc.ModelSwitchToRequest{ + ModelID: "gpt-4.1", + ReasoningEffort: &reasoningEffort, + }) + if err != nil { + t.Fatalf("Model.SwitchTo failed: %v", err) + } + if result.ModelID == nil || *result.ModelID != "gpt-4.1" { + t.Fatalf("Expected switch result model gpt-4.1, got %+v", result) + } + after, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Model.GetCurrent after switch failed: %v", err) + } + if after.ModelID == nil || (*after.ModelID != "gpt-4.1" && *after.ModelID != *before.ModelID) { + t.Fatalf("Unexpected current model after switch; before=%q after=%+v", *before.ModelID, after) + } }) t.Run("should get and set session mode", func(t *testing.T) { @@ -69,6 +118,56 @@ func TestRpcSessionStateE2E(t *testing.T) { } }) + t.Run("should shutdown session with routine type", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + awaitShutdown := waitForMatchingEvent( + session, + copilot.SessionEventTypeSessionShutdown, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionShutdownData) + return ok && data.ShutdownType == copilot.ShutdownTypeRoutine + }, + "session.shutdown routine event", + ) + + reason := "Go SDK E2E shutdown coverage" + shutdownType := rpc.ShutdownTypeRoutine + if _, err := session.RPC.Shutdown(t.Context(), &rpc.ShutdownRequest{Type: &shutdownType, Reason: &reason}); err != nil { + t.Fatalf("Shutdown failed: %v", err) + } + event := awaitEvent(t, awaitShutdown) + if data := event.Data.(*copilot.SessionShutdownData); data.ShutdownType != copilot.ShutdownTypeRoutine { + t.Fatalf("Expected routine shutdown event, got %+v", data) + } + }) + + t.Run("should set and get each session mode value", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + for _, mode := range []rpc.SessionMode{rpc.SessionModeInteractive, rpc.SessionModePlan, rpc.SessionModeAutopilot} { + if _, err := session.RPC.Mode.Set(t.Context(), &rpc.ModeSetRequest{Mode: mode}); err != nil { + t.Fatalf("Failed to set mode %q: %v", mode, err) + } + got, err := session.RPC.Mode.Get(t.Context()) + if err != nil { + t.Fatalf("Failed to get mode %q: %v", mode, err) + } + if got == nil || *got != mode { + t.Fatalf("Expected mode %q, got %v", mode, got) + } + } + }) + t.Run("should read update and delete plan", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -171,6 +270,115 @@ func TestRpcSessionStateE2E(t *testing.T) { } }) + t.Run("should reject workspace file path traversal", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + for _, path := range []string{"../escaped.txt", "../../escaped.txt", "nested/../../../escaped.txt"} { + _, err := session.RPC.Workspaces.CreateFile(t.Context(), &rpc.WorkspacesCreateFileRequest{ + Path: path, + Content: "should not land outside workspace", + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "workspace files directory") { + t.Fatalf("Expected CreateFile(%q) to reject traversal, got %v", path, err) + } + _, err = session.RPC.Workspaces.ReadFile(t.Context(), &rpc.WorkspacesReadFileRequest{Path: path}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "workspace files directory") { + t.Fatalf("Expected ReadFile(%q) to reject traversal, got %v", path, err) + } + } + }) + + t.Run("should create workspace file with nested path auto creating dirs", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + nestedPath := "nested-" + randomHex(t) + "/subdir/file.txt" + if _, err := session.RPC.Workspaces.CreateFile(t.Context(), &rpc.WorkspacesCreateFileRequest{Path: nestedPath, Content: "nested content"}); err != nil { + t.Fatalf("Failed to create nested workspace file: %v", err) + } + read, err := session.RPC.Workspaces.ReadFile(t.Context(), &rpc.WorkspacesReadFileRequest{Path: nestedPath}) + if err != nil { + t.Fatalf("Failed to read nested workspace file: %v", err) + } + if read.Content != "nested content" { + t.Fatalf("Expected nested content, got %q", read.Content) + } + list, err := session.RPC.Workspaces.ListFiles(t.Context()) + if err != nil { + t.Fatalf("Failed to list files: %v", err) + } + found := false + for _, file := range list.Files { + if filepath.ToSlash(file) == nestedPath { + found = true + break + } + } + if !found { + t.Fatalf("Expected list to contain nested file %q, got %v", nestedPath, list.Files) + } + }) + + t.Run("should report error reading nonexistent workspace file", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + _, err = session.RPC.Workspaces.ReadFile(t.Context(), &rpc.WorkspacesReadFileRequest{Path: "never-exists-" + randomHex(t) + ".txt"}) + if err == nil { + t.Fatal("Expected reading nonexistent workspace file to fail") + } + }) + + t.Run("should update existing workspace file with update operation", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + path := "reused-" + randomHex(t) + ".txt" + if _, err := session.RPC.Workspaces.CreateFile(t.Context(), &rpc.WorkspacesCreateFileRequest{Path: path, Content: "v1"}); err != nil { + t.Fatalf("Failed to create workspace file: %v", err) + } + awaitUpdate := waitForMatchingEvent( + session, + copilot.SessionEventTypeSessionWorkspaceFileChanged, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionWorkspaceFileChangedData) + return ok && data.Path == path && data.Operation == copilot.WorkspaceFileChangedOperationUpdate + }, + "workspace_file_changed update event", + ) + if _, err := session.RPC.Workspaces.CreateFile(t.Context(), &rpc.WorkspacesCreateFileRequest{Path: path, Content: "v2"}); err != nil { + t.Fatalf("Failed to update workspace file: %v", err) + } + event := awaitEvent(t, awaitUpdate) + if data := event.Data.(*copilot.SessionWorkspaceFileChangedData); data.Operation != copilot.WorkspaceFileChangedOperationUpdate { + t.Fatalf("Expected update operation, got %+v", data) + } + read, err := session.RPC.Workspaces.ReadFile(t.Context(), &rpc.WorkspacesReadFileRequest{Path: path}) + if err != nil { + t.Fatalf("Failed to read updated workspace file: %v", err) + } + if read.Content != "v2" { + t.Fatalf("Expected updated content v2, got %q", read.Content) + } + }) + t.Run("should get and set session metadata", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -199,6 +407,366 @@ func TestRpcSessionStateE2E(t *testing.T) { } }) + t.Run("should reject empty or whitespace session name", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + for _, name := range []string{"", " ", "\t\n \r"} { + _, err := session.RPC.Name.Set(t.Context(), &rpc.NameSetRequest{Name: name}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "empty") { + t.Fatalf("Expected setting whitespace name %q to fail with empty-name error, got %v", name, err) + } + } + }) + + t.Run("should emit title changed event each time name set is called", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + titleA := "Title-A-" + randomHex(t) + awaitFirst := waitForMatchingEvent( + session, + copilot.SessionEventTypeSessionTitleChanged, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionTitleChangedData) + return ok && data.Title == titleA + }, + "first title_changed event", + ) + if _, err := session.RPC.Name.Set(t.Context(), &rpc.NameSetRequest{Name: titleA}); err != nil { + t.Fatalf("Failed to set first session name: %v", err) + } + awaitEvent(t, awaitFirst) + + titleB := "Title-B-" + randomHex(t) + awaitSecond := waitForMatchingEvent( + session, + copilot.SessionEventTypeSessionTitleChanged, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionTitleChangedData) + return ok && data.Title == titleB + }, + "second title_changed event", + ) + if _, err := session.RPC.Name.Set(t.Context(), &rpc.NameSetRequest{Name: titleB}); err != nil { + t.Fatalf("Failed to set second session name: %v", err) + } + event := awaitEvent(t, awaitSecond) + if data := event.Data.(*copilot.SessionTitleChangedData); data.Title != titleB { + t.Fatalf("Expected title %q, got %+v", titleB, data) + } + }) + + t.Run("should call metadata snapshot set working directory and record context change", func(t *testing.T) { + firstDirectory := createUniqueRPCWorkDirectory(t, ctx, "rpc-session-state-first") + secondDirectory := createUniqueRPCWorkDirectory(t, ctx, "rpc-session-state-second") + contextDirectory := createUniqueRPCWorkDirectory(t, ctx, "rpc-session-state-context") + branch := "rpc-context-" + randomHex(t) + + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + WorkingDirectory: firstDirectory, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + initial, err := session.RPC.Metadata.Snapshot(t.Context()) + if err != nil { + t.Fatalf("Metadata.Snapshot failed: %v", err) + } + if initial.SessionID != session.SessionID || initial.CurrentMode != rpc.MetadataSnapshotCurrentModeInteractive || + initial.SelectedModel == nil || *initial.SelectedModel != "claude-sonnet-4.5" || + initial.IsRemote || initial.AlreadyInUse || initial.StartTime.IsZero() || initial.ModifiedTime.IsZero() || + initial.Workspace == nil || initial.WorkspacePath == nil || *initial.WorkspacePath == "" { + t.Fatalf("Unexpected initial metadata snapshot: %+v", initial) + } + assertRPCPathEqual(t, firstDirectory, initial.WorkingDirectory) + + setWorkingDirectory, err := session.RPC.Metadata.SetWorkingDirectory(t.Context(), &rpc.MetadataSetWorkingDirectoryRequest{WorkingDirectory: secondDirectory}) + if err != nil { + t.Fatalf("Metadata.SetWorkingDirectory failed: %v", err) + } + assertRPCPathEqual(t, secondDirectory, setWorkingDirectory.WorkingDirectory) + + waitForRPCCondition(t, 15*time.Second, "metadata snapshot working directory update", func() (bool, error) { + snapshot, err := session.RPC.Metadata.Snapshot(t.Context()) + return err == nil && rpcPathsEqual(secondDirectory, snapshot.WorkingDirectory), err + }) + + awaitContextChanged := waitForMatchingEvent( + session, + copilot.SessionEventTypeSessionContextChanged, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionContextChangedData) + return ok && data.Branch != nil && *data.Branch == branch + }, + "session.context_changed event", + ) + + repo := "github/copilot-sdk-e2e" + repoHost := "github.com" + hostType := rpc.SessionWorkingDirectoryContextHostTypeGithub + baseCommit := "0000000000000000000000000000000000000000" + headCommit := "1111111111111111111111111111111111111111" + if _, err := session.RPC.Metadata.RecordContextChange(t.Context(), &rpc.MetadataRecordContextChangeRequest{ + Context: rpc.SessionWorkingDirectoryContext{ + Cwd: contextDirectory, + GitRoot: &firstDirectory, + Branch: &branch, + Repository: &repo, + RepositoryHost: &repoHost, + HostType: &hostType, + BaseCommit: &baseCommit, + HeadCommit: &headCommit, + }, + }); err != nil { + t.Fatalf("Metadata.RecordContextChange failed: %v", err) + } + contextChanged := awaitEvent(t, awaitContextChanged) + data := contextChanged.Data.(*copilot.SessionContextChangedData) + assertRPCPathEqual(t, contextDirectory, data.Cwd) + if data.GitRoot == nil { + t.Fatal("Expected context changed git root") + } + assertRPCPathEqual(t, firstDirectory, *data.GitRoot) + if data.Branch == nil || *data.Branch != branch || + data.Repository == nil || *data.Repository != repo || + data.RepositoryHost == nil || *data.RepositoryHost != repoHost || + data.HostType == nil || string(*data.HostType) != "github" || + data.BaseCommit == nil || *data.BaseCommit != baseCommit || + data.HeadCommit == nil || *data.HeadCommit != headCommit { + t.Fatalf("Unexpected context changed payload: %+v", data) + } + }) + + t.Run("should update options and initialize session services", func(t *testing.T) { + initialDirectory := createUniqueRPCWorkDirectory(t, ctx, "rpc-session-state-initial") + optionsDirectory := createUniqueRPCWorkDirectory(t, ctx, "rpc-session-state-options") + featureName := "rpc-session-state-" + randomHex(t) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + WorkingDirectory: initialDirectory, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + update, err := session.RPC.Options.Update(t.Context(), &rpc.SessionUpdateOptionsParams{ + ClientName: rpcPtr("go-sdk-rpc-session-state-e2e"), + LspClientName: rpcPtr("go-sdk-rpc-session-state-lsp"), + IntegrationID: rpcPtr("go-sdk-" + randomHex(t)), + FeatureFlags: map[string]bool{featureName: true}, + WorkingDirectory: &optionsDirectory, + CoauthorEnabled: rpcPtr(false), + EnableStreaming: rpcPtr(false), + AskUserDisabled: rpcPtr(true), + }) + if err != nil { + t.Fatalf("Options.Update failed: %v", err) + } + if !update.Success { + t.Fatalf("Expected Options.Update Success=true, got %+v", update) + } + + waitForRPCCondition(t, 15*time.Second, "options working directory to reach metadata snapshot", func() (bool, error) { + snapshot, err := session.RPC.Metadata.Snapshot(t.Context()) + return err == nil && rpcPathsEqual(optionsDirectory, snapshot.WorkingDirectory), err + }) + + if _, err := session.RPC.Lsp.Initialize(t.Context(), &rpc.LspInitializeRequest{ + WorkingDirectory: &optionsDirectory, + GitRoot: &initialDirectory, + Force: rpcPtr(true), + }); err != nil { + t.Fatalf("Lsp.Initialize failed: %v", err) + } + if _, err := session.RPC.Telemetry.SetFeatureOverrides(t.Context(), &rpc.TelemetrySetFeatureOverridesRequest{ + Features: map[string]string{ + "rpc_session_state_feature": featureName, + "rpc_session_state_value": "enabled", + }, + }); err != nil { + t.Fatalf("Telemetry.SetFeatureOverrides failed: %v", err) + } + if _, err := session.RPC.Tools.InitializeAndValidate(t.Context()); err != nil { + t.Fatalf("Tools.InitializeAndValidate failed: %v", err) + } + snapshot, err := session.RPC.Metadata.Snapshot(t.Context()) + if err != nil { + t.Fatalf("Metadata.Snapshot after options update failed: %v", err) + } + assertRPCPathEqual(t, optionsDirectory, snapshot.WorkingDirectory) + }) + + t.Run("should set reasoning effort and auto name", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + Model: "claude-sonnet-4.5", + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + reasoning, err := session.RPC.Model.SetReasoningEffort(t.Context(), &rpc.ModelSetReasoningEffortRequest{ReasoningEffort: "high"}) + if err != nil { + t.Fatalf("Model.SetReasoningEffort failed: %v", err) + } + if reasoning.ReasoningEffort != "high" { + t.Fatalf("Expected reasoning effort high, got %+v", reasoning) + } + current, err := session.RPC.Model.GetCurrent(t.Context()) + if err != nil { + t.Fatalf("Model.GetCurrent failed: %v", err) + } + if current.ModelID == nil || *current.ModelID != "claude-sonnet-4.5" || + current.ReasoningEffort == nil || *current.ReasoningEffort != "high" { + t.Fatalf("Expected current model claude-sonnet-4.5/high, got %+v", current) + } + + autoName := "Auto Session " + randomHex(t) + awaitAutoTitle := waitForMatchingEvent( + session, + copilot.SessionEventTypeSessionTitleChanged, + func(event copilot.SessionEvent) bool { + data, ok := event.Data.(*copilot.SessionTitleChangedData) + return ok && data.Title == autoName + }, + "session.title_changed after name.setAuto", + ) + autoResult, err := session.RPC.Name.SetAuto(t.Context(), &rpc.NameSetAutoRequest{Summary: " " + autoName + " "}) + if err != nil { + t.Fatalf("Name.SetAuto failed: %v", err) + } + if !autoResult.Applied { + t.Fatalf("Expected first Name.SetAuto to apply, got %+v", autoResult) + } + awaitEvent(t, awaitAutoTitle) + name, err := session.RPC.Name.Get(t.Context()) + if err != nil { + t.Fatalf("Name.Get failed: %v", err) + } + if name.Name == nil || *name.Name != autoName { + t.Fatalf("Expected auto name %q, got %+v", autoName, name) + } + + explicitName := "Explicit Session " + randomHex(t) + if _, err := session.RPC.Name.Set(t.Context(), &rpc.NameSetRequest{Name: explicitName}); err != nil { + t.Fatalf("Name.Set explicit failed: %v", err) + } + ignoredAuto, err := session.RPC.Name.SetAuto(t.Context(), &rpc.NameSetAutoRequest{Summary: "Ignored " + randomHex(t)}) + if err != nil { + t.Fatalf("Name.SetAuto after explicit name failed: %v", err) + } + if ignoredAuto.Applied { + t.Fatal("Expected SetAuto to be ignored after explicit name") + } + name, err = session.RPC.Name.Get(t.Context()) + if err != nil { + t.Fatalf("Name.Get explicit failed: %v", err) + } + if name.Name == nil || *name.Name != explicitName { + t.Fatalf("Expected explicit name %q to remain, got %+v", explicitName, name) + } + }) + + t.Run("should set auth credentials", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + login := "sdk-rpc-" + randomHex(t) + + api := ctx.ProxyURL + telemetry := "https://localhost:1/telemetry" + setCredentials, err := session.RPC.Auth.SetCredentials(t.Context(), &rpc.SessionSetCredentialsParams{ + Credentials: &rpc.UserAuthInfo{ + CopilotUser: &rpc.CopilotUserResponse{ + AnalyticsTrackingID: rpcPtr("rpc-session-state-tracking-id"), + ChatEnabled: rpcPtr(true), + CopilotPlan: rpcPtr("individual_pro"), + Endpoints: &rpc.CopilotUserResponseEndpoints{ + API: &api, + Telemetry: &telemetry, + }, + Login: &login, + }, + Host: "https://github.com", + Login: login, + }, + }) + if err != nil { + t.Fatalf("Auth.SetCredentials failed: %v", err) + } + if !setCredentials.Success { + t.Fatalf("Expected Auth.SetCredentials Success=true, got %+v", setCredentials) + } + + status, err := session.RPC.Auth.GetStatus(t.Context()) + if err != nil { + t.Fatalf("Auth.GetStatus failed: %v", err) + } + if !status.IsAuthenticated || status.AuthType == nil || *status.AuthType != rpc.AuthInfoTypeUser || + status.Host == nil || *status.Host != "https://github.com" || + status.Login == nil || *status.Login != login { + t.Fatalf("Unexpected auth status after SetCredentials: %+v", status) + } + }) + + t.Run("should report idle processing and context token shapes", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + processing, err := session.RPC.Metadata.IsProcessing(t.Context()) + if err != nil { + t.Fatalf("Metadata.IsProcessing failed: %v", err) + } + if processing.Processing { + t.Fatal("Expected fresh session to be idle") + } + + model := "claude-sonnet-4.5" + contextInfo, err := session.RPC.Metadata.ContextInfo(t.Context(), &rpc.MetadataContextInfoRequest{ + PromptTokenLimit: 128000, + OutputTokenLimit: 4096, + SelectedModel: &model, + }) + if err != nil { + t.Fatalf("Metadata.ContextInfo failed: %v", err) + } + if contextInfo.ContextInfo != nil { + info := contextInfo.ContextInfo + if info.ModelName != model || info.PromptTokenLimit != 128000 || info.TotalTokens < 0 || + info.SystemTokens < 0 || info.ConversationTokens < 0 || info.ToolDefinitionsTokens < 0 { + t.Fatalf("Unexpected context info: %+v", info) + } + } + + recomputed, err := session.RPC.Metadata.RecomputeContextTokens(t.Context(), &rpc.MetadataRecomputeContextTokensRequest{ModelID: model}) + if err != nil { + t.Fatalf("Metadata.RecomputeContextTokens failed: %v", err) + } + if recomputed.SystemTokenCount < 0 || recomputed.MessagesTokenCount < 0 || + recomputed.TotalTokens != recomputed.SystemTokenCount+recomputed.MessagesTokenCount { + t.Fatalf("Unexpected recomputed context tokens: %+v", recomputed) + } + }) + t.Run("should fork session with persisted messages", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go b/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go index e3f3bd007..bda0f2ad3 100644 --- a/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go +++ b/go/internal/e2e/rpc_tasks_and_handlers_e2e_test.go @@ -3,6 +3,7 @@ package e2e import ( "strings" "testing" + "time" copilot "github.com/github/copilot-sdk/go" "github.com/github/copilot-sdk/go/internal/e2e/testharness" @@ -34,6 +35,29 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { t.Errorf("Expected empty Tasks list, got %d tasks", len(tasks.Tasks)) } + if _, err := session.RPC.Tasks.Refresh(t.Context()); err != nil { + t.Fatalf("Tasks.Refresh failed: %v", err) + } + if _, err := session.RPC.Tasks.WaitForPending(t.Context()); err != nil { + t.Fatalf("Tasks.WaitForPending failed: %v", err) + } + + progress, err := session.RPC.Tasks.GetProgress(t.Context(), &rpc.TasksGetProgressRequest{ID: "missing-task"}) + if err != nil { + t.Fatalf("Tasks.GetProgress failed: %v", err) + } + if progress.Progress != nil { + t.Errorf("Expected nil Progress for missing task, got %+v", progress.Progress) + } + + current, err := session.RPC.Tasks.GetCurrentPromotable(t.Context()) + if err != nil { + t.Fatalf("Tasks.GetCurrentPromotable failed: %v", err) + } + if current.Task != nil { + t.Errorf("Expected nil current promotable task, got %+v", current.Task) + } + promote, err := session.RPC.Tasks.PromoteToBackground(t.Context(), &rpc.TasksPromoteToBackgroundRequest{ID: "missing-task"}) if err != nil { t.Fatalf("PromoteToBackground failed: %v", err) @@ -42,6 +66,14 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { t.Error("Expected Promoted=false for missing task") } + promoteCurrent, err := session.RPC.Tasks.PromoteCurrentToBackground(t.Context()) + if err != nil { + t.Fatalf("Tasks.PromoteCurrentToBackground failed: %v", err) + } + if promoteCurrent.Task != nil { + t.Errorf("Expected nil task from PromoteCurrentToBackground, got %+v", promoteCurrent.Task) + } + cancel, err := session.RPC.Tasks.Cancel(t.Context(), &rpc.TasksCancelRequest{ID: "missing-task"}) if err != nil { t.Fatalf("Cancel failed: %v", err) @@ -57,6 +89,20 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { if remove.Removed { t.Error("Expected Removed=false for missing task") } + + sendMessage, err := session.RPC.Tasks.SendMessage(t.Context(), &rpc.TasksSendMessageRequest{ + ID: "missing-task", + Message: "hello from the Go SDK E2E test", + }) + if err != nil { + t.Fatalf("Tasks.SendMessage failed: %v", err) + } + if sendMessage.Sent { + t.Error("Expected Sent=false for missing task") + } + if sendMessage.Error == nil || strings.TrimSpace(*sendMessage.Error) == "" { + t.Errorf("Expected missing task SendMessage to return an error message, got %+v", sendMessage) + } }) t.Run("should report implemented error for missing task agent type", func(t *testing.T) { @@ -80,6 +126,39 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { } }) + t.Run("should report implemented error for invalid task agent model", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + description := "SDK task agent validation" + model := "not-a-real-model" + _, err = session.RPC.Tasks.StartAgent(t.Context(), &rpc.TasksStartAgentRequest{ + AgentType: "general-purpose", + Prompt: "Say hi", + Name: "sdk-test-task", + Description: &description, + Model: &model, + }) + if err == nil { + t.Fatal("Expected an error for invalid agent model") + } + if strings.Contains(strings.ToLower(err.Error()), "unhandled method session.tasks.startagent") { + t.Errorf("Expected an implemented error, but the method appears unhandled: %v", err) + } + + tasks, err := session.RPC.Tasks.List(t.Context()) + if err != nil { + t.Fatalf("Tasks.List failed: %v", err) + } + if len(tasks.Tasks) != 0 { + t.Fatalf("Expected no task to be created for invalid model, got %+v", tasks.Tasks) + } + }) + t.Run("should return expected results for missing pending handler request ids", func(t *testing.T) { session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, @@ -123,10 +202,60 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { t.Error("Expected Success=false for missing elicitation request id") } - feedback := "not approved" + userInput, err := session.RPC.UI.HandlePendingUserInput(t.Context(), &rpc.UIHandlePendingUserInputRequest{ + RequestID: "missing-user-input-request", + Response: rpc.UIUserInputResponse{Answer: "typed answer", WasFreeform: true}, + }) + if err != nil { + t.Fatalf("UI.HandlePendingUserInput failed: %v", err) + } + if userInput.Success { + t.Error("Expected Success=false for missing user input request id") + } + + sampling, err := session.RPC.UI.HandlePendingSampling(t.Context(), &rpc.UIHandlePendingSamplingRequest{ + RequestID: "missing-sampling-request", + Response: &rpc.UIHandlePendingSamplingResponse{}, + }) + if err != nil { + t.Fatalf("UI.HandlePendingSampling failed: %v", err) + } + if sampling.Success { + t.Error("Expected Success=false for missing sampling request id") + } + + autoModeSwitch, err := session.RPC.UI.HandlePendingAutoModeSwitch(t.Context(), &rpc.UIHandlePendingAutoModeSwitchRequest{ + RequestID: "missing-auto-mode-switch-request", + Response: rpc.UIAutoModeSwitchResponseNo, + }) + if err != nil { + t.Fatalf("UI.HandlePendingAutoModeSwitch failed: %v", err) + } + if autoModeSwitch.Success { + t.Error("Expected Success=false for missing auto mode switch request id") + } + + feedback := "No pending plan approval" + selectedAction := rpc.UIExitPlanModeActionExitOnly + exitPlanMode, err := session.RPC.UI.HandlePendingExitPlanMode(t.Context(), &rpc.UIHandlePendingExitPlanModeRequest{ + RequestID: "missing-exit-plan-mode-request", + Response: rpc.UIExitPlanModeResponse{ + Approved: false, + Feedback: &feedback, + SelectedAction: &selectedAction, + }, + }) + if err != nil { + t.Fatalf("UI.HandlePendingExitPlanMode failed: %v", err) + } + if exitPlanMode.Success { + t.Error("Expected Success=false for missing exit plan mode request id") + } + + permissionFeedback := "not approved" permission, err := session.RPC.Permissions.HandlePendingPermissionRequest(t.Context(), &rpc.PermissionDecisionRequest{ RequestID: "missing-permission-request", - Result: &rpc.PermissionDecisionReject{Feedback: &feedback}, + Result: &rpc.PermissionDecisionReject{Feedback: &permissionFeedback}, }) if err != nil { t.Fatalf("Permissions.HandlePendingPermissionRequest (reject) failed: %v", err) @@ -146,5 +275,136 @@ func TestRpcTasksAndHandlersE2E(t *testing.T) { if permanent.Success { t.Error("Expected Success=false for missing permanent permission request id") } + + sessionApproval, err := session.RPC.Permissions.HandlePendingPermissionRequest(t.Context(), &rpc.PermissionDecisionRequest{ + RequestID: "missing-session-approval-request", + Result: &rpc.PermissionDecisionApproveForSession{ + Approval: &rpc.PermissionDecisionApproveForSessionApprovalCustomTool{ToolName: "missing-tool"}, + }, + }) + if err != nil { + t.Fatalf("Permissions.HandlePendingPermissionRequest (approve-for-session) failed: %v", err) + } + if sessionApproval.Success { + t.Error("Expected Success=false for missing session approval request id") + } + + locationApproval, err := session.RPC.Permissions.HandlePendingPermissionRequest(t.Context(), &rpc.PermissionDecisionRequest{ + RequestID: "missing-location-approval-request", + Result: &rpc.PermissionDecisionApproveForLocation{ + Approval: &rpc.PermissionDecisionApproveForLocationApprovalCustomTool{ToolName: "missing-tool"}, + LocationKey: "missing-location", + }, + }) + if err != nil { + t.Fatalf("Permissions.HandlePendingPermissionRequest (approve-for-location) failed: %v", err) + } + if locationApproval.Success { + t.Error("Expected Success=false for missing location approval request id") + } + }) + + t.Run("should round trip rpc elicitation through config handler", func(t *testing.T) { + handlerContext := make(chan copilot.ElicitationContext, 1) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnElicitationRequest: func(ctx copilot.ElicitationContext) (copilot.ElicitationResult, error) { + handlerContext <- ctx + return copilot.ElicitationResult{ + Action: "accept", + Content: map[string]any{ + "answer": "from handler", + "confirmed": true, + }, + }, nil + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + response, err := session.RPC.UI.Elicitation(t.Context(), &rpc.UIElicitationRequest{ + Message: "Need details", + RequestedSchema: rpc.UIElicitationSchema{ + Type: rpc.UIElicitationSchemaTypeObject, + Properties: map[string]rpc.UIElicitationSchemaProperty{ + "answer": &rpc.UIElicitationSchemaPropertyString{}, + "confirmed": &rpc.UIElicitationSchemaPropertyBoolean{}, + }, + Required: []string{"answer"}, + }, + }) + if err != nil { + t.Fatalf("UI.Elicitation failed: %v", err) + } + + var ctx copilot.ElicitationContext + select { + case ctx = <-handlerContext: + case <-time.After(30 * time.Second): + t.Fatal("Timed out waiting for elicitation handler") + } + if ctx.SessionID != session.SessionID || ctx.Message != "Need details" { + t.Fatalf("Unexpected elicitation context: %+v", ctx) + } + if _, ok := ctx.RequestedSchema["properties"]; !ok { + t.Fatalf("Expected requested schema to include properties, got %+v", ctx.RequestedSchema) + } + if response.Action != rpc.UIElicitationResponseActionAccept { + t.Fatalf("Expected accept response, got %+v", response) + } + if got, ok := response.Content["answer"].(rpc.UIElicitationStringValue); !ok || string(got) != "from handler" { + t.Fatalf("Expected answer content from handler, got %+v", response.Content["answer"]) + } + if got, ok := response.Content["confirmed"].(rpc.UIElicitationBooleanValue); !ok || !bool(got) { + t.Fatalf("Expected confirmed content true, got %+v", response.Content["confirmed"]) + } + }) + + t.Run("should register and unregister direct auto mode switch handler", func(t *testing.T) { + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + missing, err := session.RPC.UI.UnregisterDirectAutoModeSwitchHandler(t.Context(), &rpc.UIUnregisterDirectAutoModeSwitchHandlerRequest{ + Handle: "missing-direct-auto-mode-handle", + }) + if err != nil { + t.Fatalf("UI.UnregisterDirectAutoModeSwitchHandler(missing) failed: %v", err) + } + if missing.Unregistered { + t.Fatal("Expected missing direct handler unregister to return false") + } + + registration, err := session.RPC.UI.RegisterDirectAutoModeSwitchHandler(t.Context()) + if err != nil { + t.Fatalf("UI.RegisterDirectAutoModeSwitchHandler failed: %v", err) + } + if strings.TrimSpace(registration.Handle) == "" { + t.Fatal("Expected non-empty direct auto mode switch handler handle") + } + + unregister, err := session.RPC.UI.UnregisterDirectAutoModeSwitchHandler(t.Context(), &rpc.UIUnregisterDirectAutoModeSwitchHandlerRequest{ + Handle: registration.Handle, + }) + if err != nil { + t.Fatalf("UI.UnregisterDirectAutoModeSwitchHandler failed: %v", err) + } + if !unregister.Unregistered { + t.Fatal("Expected registered direct handler to unregister") + } + + unregisterAgain, err := session.RPC.UI.UnregisterDirectAutoModeSwitchHandler(t.Context(), &rpc.UIUnregisterDirectAutoModeSwitchHandlerRequest{ + Handle: registration.Handle, + }) + if err != nil { + t.Fatalf("UI.UnregisterDirectAutoModeSwitchHandler second call failed: %v", err) + } + if unregisterAgain.Unregistered { + t.Fatal("Expected second direct handler unregister to return false") + } }) } diff --git a/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go b/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go new file mode 100644 index 000000000..a11a2fef2 --- /dev/null +++ b/go/internal/e2e/rpc_workspace_checkpoints_e2e_test.go @@ -0,0 +1,128 @@ +package e2e + +import ( + "os" + "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" +) + +// Mirrors dotnet/test/E2E/RpcWorkspaceCheckpointsE2ETests.cs (snapshot category "rpc_workspace_checkpoints"). +func TestRpcWorkspaceCheckpointsE2E(t *testing.T) { + ctx := testharness.NewTestContext(t) + client := ctx.NewClient() + t.Cleanup(func() { client.ForceStop() }) + + t.Run("should list no checkpoints for fresh session", func(t *testing.T) { + session := createWorkspaceRPCSession(t, client) + defer session.Disconnect() + + result, err := session.RPC.Workspaces.ListCheckpoints(t.Context()) + if err != nil { + t.Fatalf("Workspaces.ListCheckpoints failed: %v", err) + } + if result.Checkpoints == nil { + t.Fatal("Expected non-nil Checkpoints") + } + if len(result.Checkpoints) != 0 { + t.Fatalf("Expected no checkpoints for fresh session, got %+v", result.Checkpoints) + } + }) + + t.Run("should return nil or empty content for unknown checkpoint", func(t *testing.T) { + session := createWorkspaceRPCSession(t, client) + defer session.Disconnect() + + result, err := session.RPC.Workspaces.ReadCheckpoint(t.Context(), &rpc.WorkspacesReadCheckpointRequest{Number: 1<<62 - 1}) + if err != nil { + t.Fatalf("Workspaces.ReadCheckpoint failed: %v", err) + } + if result.Content != nil && *result.Content != "" { + t.Fatalf("Expected nil or empty content for unknown checkpoint, got %q", *result.Content) + } + }) + + t.Run("should return typed workspace diff result", func(t *testing.T) { + session := createWorkspaceRPCSession(t, client) + defer session.Disconnect() + + result, err := session.RPC.Workspaces.Diff(t.Context(), &rpc.WorkspacesDiffRequest{Mode: rpc.WorkspaceDiffModeUnstaged}) + if err != nil { + t.Fatalf("Workspaces.Diff failed: %v", err) + } + if result.RequestedMode != rpc.WorkspaceDiffModeUnstaged { + t.Fatalf("Expected RequestedMode=unstaged, got %q", result.RequestedMode) + } + if result.Mode != rpc.WorkspaceDiffModeUnstaged && result.Mode != rpc.WorkspaceDiffModeBranch { + t.Fatalf("Unexpected effective diff mode %q", result.Mode) + } + if result.Changes == nil { + t.Fatal("Expected non-nil Changes") + } + for _, change := range result.Changes { + if strings.TrimSpace(change.Path) == "" { + t.Fatalf("Diff change has empty path: %+v", change) + } + switch change.ChangeType { + case rpc.WorkspaceDiffFileChangeTypeAdded, + rpc.WorkspaceDiffFileChangeTypeModified, + rpc.WorkspaceDiffFileChangeTypeDeleted, + rpc.WorkspaceDiffFileChangeTypeRenamed: + default: + t.Fatalf("Unexpected diff change type %q", change.ChangeType) + } + _ = change.Diff + } + }) + + t.Run("should save large paste and expose readable content", func(t *testing.T) { + session := createWorkspaceRPCSession(t, client) + defer session.Disconnect() + content := strings.Repeat("Large paste payload 🚀\n", 512) + + result, err := session.RPC.Workspaces.SaveLargePaste(t.Context(), &rpc.WorkspacesSaveLargePasteRequest{Content: content}) + if err != nil { + t.Fatalf("Workspaces.SaveLargePaste failed: %v", err) + } + if result.Saved == nil { + t.Fatal("Expected SaveLargePaste to return saved descriptor") + } + saved := result.Saved + if strings.TrimSpace(saved.Filename) == "" || strings.TrimSpace(saved.FilePath) == "" { + t.Fatalf("Expected saved filename and filepath, got %+v", saved) + } + if saved.SizeBytes != int64(len([]byte(content))) { + t.Fatalf("Expected SizeBytes=%d, got %d", len([]byte(content)), saved.SizeBytes) + } + + read, readErr := session.RPC.Workspaces.ReadFile(t.Context(), &rpc.WorkspacesReadFileRequest{Path: saved.Filename}) + if readErr == nil { + if read.Content != content { + t.Fatalf("Expected ReadFile content to match saved paste") + } + return + } + + bytes, err := os.ReadFile(saved.FilePath) + if err != nil { + t.Fatalf("ReadFile failed (%v), and saved file %q was not readable: %v", readErr, saved.FilePath, err) + } + if string(bytes) != content { + t.Fatalf("Expected saved file content to match large paste") + } + }) +} + +func createWorkspaceRPCSession(t *testing.T, client *copilot.Client) *copilot.Session { + t.Helper() + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + return session +} diff --git a/go/test.sh b/go/test.sh index e1dd8aaac..15fc35c30 100755 --- a/go/test.sh +++ b/go/test.sh @@ -43,7 +43,7 @@ cd "$(dirname "$0")" echo "=== Running Go SDK E2E Tests ===" echo -go test -v ./... -race +go test -v ./... -race -timeout=20m echo echo "✅ All tests passed!" diff --git a/nodejs/test/e2e/commands.e2e.test.ts b/nodejs/test/e2e/commands.e2e.test.ts index dae98083c..0a4327370 100644 --- a/nodejs/test/e2e/commands.e2e.test.ts +++ b/nodejs/test/e2e/commands.e2e.test.ts @@ -4,8 +4,11 @@ import { afterAll, describe, expect, it } from "vitest"; import { CopilotClient, approveAll, RuntimeConnection } from "../../src/index.js"; -import type { SessionEvent } from "../../src/index.js"; +import type { CommandContext, SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +const KNOWN_BUILTIN_COMMANDS = ["help", "model", "compact"]; describe("Commands", async () => { // Use TCP mode so a second client can connect to the same CLI process @@ -71,6 +74,163 @@ describe("Commands", async () => { } ); + it("session commands list returns builtins and respects client command filter", async () => { + const session = await client1.createSession({ + onPermissionRequest: approveAll, + commands: [ + { name: "deploy", description: "Deploy the app", handler: async () => {} }, + { name: "rollback", description: "Rollback the app", handler: async () => {} }, + ], + }); + try { + let clientCommands: Awaited> | undefined; + await waitForCondition( + async () => { + clientCommands = await session.rpc.commands.list({ + includeBuiltins: false, + includeClientCommands: true, + includeSkills: false, + }); + return ( + clientCommands.commands.some((c) => isCommand(c, "deploy", "client")) && + clientCommands.commands.some((c) => isCommand(c, "rollback", "client")) + ); + }, + { timeoutMessage: "Timed out waiting for client commands to be listed." } + ); + + expect(clientCommands!.commands).toContainEqual( + expect.objectContaining({ name: "deploy", kind: "client" }) + ); + expect(clientCommands!.commands).toContainEqual( + expect.objectContaining({ name: "rollback", kind: "client" }) + ); + expect(clientCommands!.commands.some((c) => c.kind === "builtin")).toBe(false); + + const builtinCommands = await session.rpc.commands.list({ + includeBuiltins: true, + includeClientCommands: false, + includeSkills: false, + }); + expect(builtinCommands.commands.some(isKnownBuiltin)).toBe(true); + expect(builtinCommands.commands.some((c) => c.name.toLowerCase() === "deploy")).toBe( + false + ); + } finally { + await session.disconnect(); + } + }); + + it("session commands invoke known builtin returns expected result", async () => { + const session = await client1.createSession({ onPermissionRequest: approveAll }); + try { + const builtinCommands = await session.rpc.commands.list({ + includeBuiltins: true, + includeClientCommands: false, + includeSkills: false, + }); + const commandName = KNOWN_BUILTIN_COMMANDS.find((name) => + builtinCommands.commands.some((c) => isCommand(c, name, "builtin")) + ); + expect(commandName).toBeDefined(); + + const result = await session.rpc.commands.invoke({ name: commandName! }); + switch (result.kind) { + case "text": + expect(result.text.trim()).toBeTruthy(); + break; + case "select-subcommand": + expect(result.title.trim()).toBeTruthy(); + expect(result.options.length).toBeGreaterThan(0); + break; + case "agent-prompt": + expect(result.displayPrompt.trim()).toBeTruthy(); + expect(result.prompt.trim()).toBeTruthy(); + break; + case "completed": + expect(result.message === undefined || result.message.trim().length > 0).toBe( + true + ); + break; + default: + throw new Error(`Unexpected invocation result: ${JSON.stringify(result)}`); + } + } finally { + await session.disconnect(); + } + }); + + it("session commands execute runs registered command handler", async () => { + let capturedContext: CommandContext | undefined; + const session = await client1.createSession({ + onPermissionRequest: approveAll, + commands: [ + { + name: "deploy", + description: "Deploy the app", + handler: async (ctx) => { + capturedContext = ctx; + }, + }, + ], + }); + try { + await waitForCondition( + async () => + ( + await session.rpc.commands.list({ + includeBuiltins: false, + includeClientCommands: true, + includeSkills: false, + }) + ).commands.some((c) => isCommand(c, "deploy", "client")), + { timeoutMessage: "Timed out waiting for registered command to be listed." } + ); + + const result = await session.rpc.commands.execute({ + commandName: "deploy", + args: "production", + }); + expect(result.error).toBeUndefined(); + + await waitForCondition(() => capturedContext !== undefined, { + timeoutMs: 10_000, + timeoutMessage: "Timed out waiting for command handler execution.", + }); + expect(capturedContext).toEqual({ + sessionId: session.sessionId, + command: "/deploy production", + commandName: "deploy", + args: "production", + }); + } finally { + await session.disconnect(); + } + }); + + it("session commands enqueue accepts deterministic command", async () => { + const session = await client1.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.commands.enqueue({ command: "/help" }); + expect(result.queued).toBe(true); + } finally { + await session.disconnect(); + } + }); + + it("session commands respondToQueuedCommand returns false for unknown requestId", async () => { + const session = await client1.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.commands.respondToQueuedCommand({ + requestId: "missing-queued-command-request", + result: { handled: false }, + }); + expect(result.success).toBe(false); + } finally { + await session.disconnect(); + } + }); + it("session with commands creates successfully", async () => { const session = await client1.createSession({ onPermissionRequest: approveAll, @@ -112,3 +272,14 @@ describe("Commands", async () => { await session.disconnect(); }); }); + +function isCommand(command: { name: string; kind: string }, name: string, kind: string): boolean { + return command.name.toLowerCase() === name.toLowerCase() && command.kind === kind; +} + +function isKnownBuiltin(command: { name: string; kind: string }): boolean { + return ( + command.kind === "builtin" && + KNOWN_BUILTIN_COMMANDS.some((name) => name.toLowerCase() === command.name.toLowerCase()) + ); +} diff --git a/nodejs/test/e2e/compaction.e2e.test.ts b/nodejs/test/e2e/compaction.e2e.test.ts index 7c07d2f0e..a74878101 100644 --- a/nodejs/test/e2e/compaction.e2e.test.ts +++ b/nodejs/test/e2e/compaction.e2e.test.ts @@ -125,4 +125,38 @@ describe("Compaction", async () => { // Should not have any compaction events when disabled expect(compactionEvents.length).toBe(0); }); + + it("should return empty handoff summary for fresh session", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.history.summarizeForHandoff(); + expect(result.summary).toBe(""); + } finally { + await session.disconnect(); + } + }); + + it("should summarize for handoff after non-ephemeral log event", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await session.log("handoff summary log coverage"); + const result = await session.rpc.history.summarizeForHandoff(); + expect(typeof result.summary).toBe("string"); + } finally { + await session.disconnect(); + } + }); + + it("should report no-op when cancelling compaction without in-flight work", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const backgroundResult = await session.rpc.history.cancelBackgroundCompaction(); + const manualResult = await session.rpc.history.abortManualCompaction(); + + expect(backgroundResult.cancelled).toBe(false); + expect(manualResult.aborted).toBe(false); + } finally { + await session.disconnect(); + } + }); }); diff --git a/nodejs/test/e2e/harness/sdkTestHelper.ts b/nodejs/test/e2e/harness/sdkTestHelper.ts index a28c2ae5b..de230b133 100644 --- a/nodejs/test/e2e/harness/sdkTestHelper.ts +++ b/nodejs/test/e2e/harness/sdkTestHelper.ts @@ -126,3 +126,21 @@ export function getNextEventOfType( }); }); } + +export async function waitForCondition( + predicate: () => boolean | Promise, + { + timeoutMs = 30_000, + intervalMs = 100, + timeoutMessage = "Timed out waiting for condition.", + }: { timeoutMs?: number; intervalMs?: number; timeoutMessage?: string } = {} +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(timeoutMessage); +} diff --git a/nodejs/test/e2e/permissions.e2e.test.ts b/nodejs/test/e2e/permissions.e2e.test.ts index dcb8033b2..96a470aee 100644 --- a/nodejs/test/e2e/permissions.e2e.test.ts +++ b/nodejs/test/e2e/permissions.e2e.test.ts @@ -2,7 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ -import { readFile, writeFile } from "fs/promises"; +import { realpathSync } from "fs"; +import { mkdir, readFile, writeFile } from "fs/promises"; import { join } from "path"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -454,4 +455,217 @@ describe("Permission callbacks", async () => { await session.disconnect(); }); + + it("should configure and update permission paths", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const configuredAllowedDirectory = await createUniqueWorkDirectory( + workDir, + "configured-allowed" + ); + const addedAllowedDirectory = await createUniqueWorkDirectory(workDir, "added-allowed"); + const newPrimaryDirectory = await createUniqueWorkDirectory(workDir, "new-primary"); + try { + const configureResult = await session.rpc.permissions.configure({ + approveAllToolPermissionRequests: false, + approveAllReadPermissionRequests: true, + rules: { + approved: [{ kind: "read", argument: null }], + denied: [{ kind: "write", argument: null }], + }, + paths: { + workspacePath: workDir, + additionalDirectories: [configuredAllowedDirectory], + includeTempDirectory: false, + unrestricted: false, + }, + urls: { + initialAllowed: ["https://example.invalid/permissions-configure"], + unrestricted: false, + }, + }); + expect(configureResult.success).toBe(true); + + const configuredList = await session.rpc.permissions.paths.list(); + expectPathEqual(configuredList.primary, workDir); + expect(configuredList.directories.some((p) => pathsEqual(p, workDir))).toBe(true); + expect( + configuredList.directories.some((p) => pathsEqual(p, configuredAllowedDirectory)) + ).toBe(true); + + expect( + (await session.rpc.permissions.paths.add({ path: addedAllowedDirectory })).success + ).toBe(true); + expect( + ( + await session.rpc.permissions.paths.isPathWithinAllowedDirectories({ + path: join(addedAllowedDirectory, "child.txt"), + }) + ).allowed + ).toBe(true); + + expect( + (await session.rpc.permissions.paths.updatePrimary({ path: newPrimaryDirectory })) + .success + ).toBe(true); + const updatedList = await session.rpc.permissions.paths.list(); + expectPathEqual(updatedList.primary, newPrimaryDirectory); + expect(updatedList.directories.some((p) => pathsEqual(p, newPrimaryDirectory))).toBe( + true + ); + expect( + ( + await session.rpc.permissions.paths.isPathWithinWorkspace({ + path: join(newPrimaryDirectory, "child.txt"), + }) + ).allowed + ).toBe(true); + } finally { + await session.disconnect(); + } + }); + + it("should invoke permission state rpc apis", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + expect((await session.rpc.permissions.pendingRequests()).items).toEqual([]); + + expect((await session.rpc.permissions.setRequired({ required: true })).success).toBe( + true + ); + expect((await session.rpc.permissions.setRequired({ required: false })).success).toBe( + true + ); + expect( + ( + await session.rpc.permissions.notifyPromptShown({ + message: "Permission prompt shown from Node SDK E2E", + }) + ).success + ).toBe(true); + + const rule = { + kind: "commands", + argument: `node-permission-e2e-${Date.now()}`, + }; + expect( + ( + await session.rpc.permissions.modifyRules({ + scope: "session", + add: [rule], + }) + ).success + ).toBe(true); + expect( + ( + await session.rpc.permissions.modifyRules({ + scope: "session", + remove: [rule], + }) + ).success + ).toBe(true); + expect( + (await session.rpc.permissions.urls.setUnrestrictedMode({ unrestricted: true })) + .success + ).toBe(true); + expect( + (await session.rpc.permissions.urls.setUnrestrictedMode({ unrestricted: false })) + .success + ).toBe(true); + } finally { + await session.disconnect(); + } + }); + + it("should invoke permission location and folder trust rpc apis", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const locationDirectory = await createUniqueWorkDirectory(workDir, "permission-location"); + const trustedDirectory = await createUniqueWorkDirectory(workDir, "folder-trust"); + const commandIdentifier = `node-permission-location-${Date.now()}`; + try { + const resolved = await session.rpc.permissions.locations.resolve({ + workingDirectory: locationDirectory, + }); + expect(resolved.locationType).toBe("dir"); + expectPathEqual(resolved.locationKey, locationDirectory); + + expect( + ( + await session.rpc.permissions.locations.addToolApproval({ + locationKey: resolved.locationKey, + approval: { + kind: "commands", + commandIdentifiers: [commandIdentifier], + }, + }) + ).success + ).toBe(true); + + const applied = await session.rpc.permissions.locations.apply({ + workingDirectory: locationDirectory, + }); + expect(applied.locationType).toBe(resolved.locationType); + expectPathEqual(applied.locationKey, resolved.locationKey); + expect(applied.appliedRuleCount).toBeGreaterThanOrEqual(1); + expect( + applied.appliedRules.some( + (rule) => rule.kind === "shell" && rule.argument === commandIdentifier + ) + ).toBe(true); + + expect( + ( + await session.rpc.permissions.folderTrust.isTrusted({ + path: trustedDirectory, + }) + ).trusted + ).toBe(false); + expect( + ( + await session.rpc.permissions.folderTrust.addTrusted({ + path: trustedDirectory, + }) + ).success + ).toBe(true); + expect( + ( + await session.rpc.permissions.folderTrust.isTrusted({ + path: trustedDirectory, + }) + ).trusted + ).toBe(true); + } finally { + await session.disconnect(); + } + }); }); + +async function createUniqueWorkDirectory(baseDir: string, prefix: string): Promise { + const directory = join( + baseDir, + `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(directory, { recursive: true }); + return directory; +} + +function expectPathEqual(actual: string, expected: string): void { + expect(pathsEqual(actual, expected), `Expected path '${actual}' to equal '${expected}'.`).toBe( + true + ); +} + +function pathsEqual(left: string, right: string): boolean { + return normalizePath(left) === normalizePath(right); +} + +function normalizePath(value: string): string { + const trimmed = value.replace(/[\\/]+$/g, ""); + try { + return realpathSync + .native(trimmed) + .replace(/[\\/]+$/g, "") + .toLowerCase(); + } catch { + return trimmed.toLowerCase(); + } +} diff --git a/nodejs/test/e2e/rpc_event_log.e2e.test.ts b/nodejs/test/e2e/rpc_event_log.e2e.test.ts new file mode 100644 index 000000000..399a41de5 --- /dev/null +++ b/nodejs/test/e2e/rpc_event_log.e2e.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { approveAll, type SessionEvent } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +describe("Session event log RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should read persisted events from the beginning", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await session.rpc.plan.update({ content: "# Event log E2E plan\n- persisted event" }); + + let read: Awaited> | undefined; + await waitForCondition( + async () => { + read = await session.rpc.eventLog.read({ max: 100, waitMs: 0 }); + return read.events.some( + (event) => + event.type === "session.plan_changed" && + event.data.operation === "create" && + event.ephemeral !== true + ); + }, + { + timeoutMessage: + "Timed out waiting for session.eventLog.read to return the persisted session.plan_changed event.", + } + ); + + expect(read).toBeDefined(); + expect(read!.cursorStatus).toBe("ok"); + expect(read!.cursor.trim()).toBeTruthy(); + expect(read!.events).toContainEqual( + expect.objectContaining({ + type: "session.plan_changed", + data: expect.objectContaining({ operation: "create" }), + }) + ); + } finally { + await session.disconnect(); + } + }); + + it("should return tail cursor and read empty when no new events", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + let tail: Awaited> | undefined; + let read: Awaited> | undefined; + await waitForCondition( + async () => { + tail = await session.rpc.eventLog.tail(); + read = await session.rpc.eventLog.read({ + cursor: tail.cursor, + max: 10, + waitMs: 0, + }); + return read.cursorStatus === "ok" && read.events.length === 0; + }, + { + timeoutMessage: + "Timed out waiting for a stable event-log tail cursor with no immediately available events.", + } + ); + + expect(tail!.cursor.trim()).toBeTruthy(); + expect(read!.events).toEqual([]); + expect(read!.hasMore).toBe(false); + } finally { + await session.disconnect(); + } + }); + + it("should register and release event interest idempotently", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const registered = await session.rpc.eventLog.registerInterest({ + eventType: "session.title_changed", + }); + expect(registered.handle.trim()).toBeTruthy(); + + const released = await session.rpc.eventLog.releaseInterest({ + handle: registered.handle, + }); + expect(released.success).toBe(true); + + const releasedAgain = await session.rpc.eventLog.releaseInterest({ + handle: registered.handle, + }); + expect(releasedAgain.success).toBe(true); + } finally { + await session.disconnect(); + } + }); + + it("should long-poll with types filter for title changed event", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const expectedTitle = `EventLogTitle-${randomUUID()}`; + const tail = await session.rpc.eventLog.tail(); + const readTask = session.rpc.eventLog.read({ + cursor: tail.cursor, + max: 10, + waitMs: 5_000, + types: ["session.title_changed"], + }); + + await session.rpc.name.set({ name: expectedTitle }); + const read = await readTask; + + expect(read.cursorStatus).toBe("ok"); + expect(read.events.length).toBeGreaterThan(0); + expect( + read.events.every((event: SessionEvent) => event.type === "session.title_changed") + ).toBe(true); + expect(read.events).toContainEqual( + expect.objectContaining({ + type: "session.title_changed", + data: expect.objectContaining({ title: expectedTitle }), + }) + ); + } finally { + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts index 5d171c778..cdd64017c 100644 --- a/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts +++ b/nodejs/test/e2e/rpc_mcp_and_skills.e2e.test.ts @@ -5,8 +5,8 @@ import * as fs from "fs"; import * as path from "path"; import { fileURLToPath } from "url"; -import { describe, expect, it } from "vitest"; -import { approveAll, RuntimeConnection } from "../../src/index.js"; +import { describe, expect, it, onTestFinished } from "vitest"; +import { approveAll, CopilotClient, RuntimeConnection } from "../../src/index.js"; import type { CopilotSession, MCPServerConfig, MCPStdioServerConfig } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -20,7 +20,11 @@ const TEST_HARNESS_DIR = path.dirname(TEST_MCP_SERVER); describe("Session MCP and skills RPC", async () => { // --yolo auto-approves extension permission gates at the CLI level, // preventing breakage from new gates (e.g., extension-permission-access). - const { copilotClient: client, workDir } = await createSdkTestContext({ + const { + copilotClient: client, + workDir, + env, + } = await createSdkTestContext({ copilotClientOptions: { connection: RuntimeConnection.forStdio({ args: ["--yolo"] }) }, }); @@ -57,6 +61,27 @@ describe("Session MCP and skills RPC", async () => { ); } + function createMcpAppsClient(): CopilotClient { + const mcpAppsClient = new CopilotClient({ + workingDirectory: workDir, + env: { + ...env, + COPILOT_MCP_APPS: "true", + MCP_APPS: "true", + }, + logLevel: "error", + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + }); + onTestFinished(async () => { + try { + await mcpAppsClient.forceStop(); + } catch { + // Ignore cleanup errors + } + }); + return mcpAppsClient; + } + async function waitForMcpServerStatus( session: CopilotSession, serverName: string, @@ -150,6 +175,28 @@ describe("Session MCP and skills RPC", async () => { await session.disconnect(); }); + it("should ensure skills are loaded and list invoked skills", async () => { + const skillName = `ensure-rpc-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const skillsDir = createSkillDirectory(skillName, "Skill loaded explicitly by RPC."); + const session = await client.createSession({ + onPermissionRequest: approveAll, + skillDirectories: [skillsDir], + }); + + await session.rpc.skills.ensureLoaded(); + + const loaded = await session.rpc.skills.list(); + const skill = loaded.skills.find((s) => s.name === skillName); + expect(skill).toBeDefined(); + expect(skill!.enabled).toBe(true); + expect(skill!.description).toBe("Skill loaded explicitly by RPC."); + + const invoked = await session.rpc.skills.getInvoked(); + expect(invoked.skills).toEqual([]); + + await session.disconnect(); + }); + it("should list mcp servers with configured server", async () => { const serverName = "rpc-list-mcp-server"; const mcpServers = createTestMcpServers(serverName); @@ -168,6 +215,72 @@ describe("Session MCP and skills RPC", async () => { await session.disconnect(); }); + it("should set mcp env value mode and remove github server", async () => { + const serverName = "github"; + const mcpServers = createTestMcpServers(serverName); + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers, + }); + + await waitForMcpServerStatus(session, serverName); + + const direct = await session.rpc.mcp.setEnvValueMode({ mode: "direct" }); + expect(direct.mode).toBe("direct"); + + const indirect = await session.rpc.mcp.setEnvValueMode({ mode: "indirect" }); + expect(indirect.mode).toBe("indirect"); + + const removeGitHub = await session.rpc.mcp.removeGitHub(); + expect(removeGitHub.removed).toBe(false); + + const servers = await session.rpc.mcp.list(); + expect( + servers.servers.some( + (server) => server.name === serverName && server.status === "connected" + ) + ).toBe(true); + + await session.disconnect(); + }); + + it("should report mcp sampling failure and cancel missing sampling", async () => { + const serverName = "rpc-sampling-server"; + const mcpServers = createTestMcpServers(serverName); + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers, + }); + + await waitForMcpServerStatus(session, serverName); + + const cancelMissing = await session.rpc.mcp.cancelSamplingExecution({ + requestId: `missing-${Date.now()}`, + }); + expect(cancelMissing.cancelled).toBe(false); + + try { + const result = await session.rpc.mcp.executeSampling({ + requestId: `sampling-${Date.now()}`, + serverName, + mcpRequestId: `mcp-request-${Date.now()}`, + request: {}, + }); + + expect(result.action).toBe("failure"); + expect(result.result).toBeUndefined(); + expect(result.error?.trim()).toBeTruthy(); + expect(result.error?.toLowerCase()).not.toContain("unhandled method"); + expect(result.error?.toLowerCase()).toMatch(/sampling|message|request/); + } catch (err: unknown) { + const text = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); + expect(text.toLowerCase()).not.toContain("unhandled method"); + expect(text.toLowerCase()).toMatch(/sampling|message|request/); + } finally { + await session.disconnect(); + } + }); + it("should list plugins", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -193,6 +306,119 @@ describe("Session MCP and skills RPC", async () => { await session.disconnect(); }); + it("should round trip mcp app host context", async () => { + const mcpAppsClient = createMcpAppsClient(); + const session = await mcpAppsClient.createSession({ onPermissionRequest: approveAll }); + try { + await session.rpc.mcp.apps.setHostContext({ + context: { + availableDisplayModes: ["inline", "fullscreen"], + displayMode: "inline", + locale: "en-GB", + platform: "desktop", + theme: "dark", + timeZone: "Etc/UTC", + userAgent: "node-sdk-e2e", + }, + }); + + const result = await session.rpc.mcp.apps.getHostContext(); + expect(result.context.displayMode).toBe("inline"); + expect(result.context.locale).toBe("en-GB"); + expect(result.context.platform).toBe("desktop"); + expect(result.context.theme).toBe("dark"); + expect(result.context.timeZone).toBe("Etc/UTC"); + expect(result.context.userAgent).toBe("node-sdk-e2e"); + expect(result.context.availableDisplayModes).toEqual(["inline", "fullscreen"]); + } finally { + await session.disconnect(); + await mcpAppsClient.stop(); + } + }); + + it("should diagnose and report mcp app capability errors", async () => { + const serverName = "rpc-apps-server"; + const otherServerName = "rpc-apps-other-server"; + const mcpServers = createTestMcpServers(serverName, otherServerName); + (mcpServers[serverName] as MCPStdioServerConfig).env = { + MCP_APP_RPC_VALUE: "from-app-rpc", + }; + const mcpAppsClient = createMcpAppsClient(); + const session = await mcpAppsClient.createSession({ + onPermissionRequest: approveAll, + mcpServers, + }); + try { + await waitForMcpServerStatus(session, serverName); + await waitForMcpServerStatus(session, otherServerName); + + const diagnose = await session.rpc.mcp.apps.diagnose({ serverName }); + expect(diagnose.capability).toBeDefined(); + expect(diagnose.server.connected).toBe(true); + expect(diagnose.server.toolCount).toBeGreaterThanOrEqual(1); + expect(diagnose.server.toolsWithUiMeta).toBe(0); + expect(diagnose.server.sampleToolNames).toEqual([]); + + await expectFailure( + () => + session.rpc.mcp.apps.listTools({ + serverName, + originServerName: serverName, + }), + "mcp-apps" + ); + await expectFailure( + () => + session.rpc.mcp.apps.listTools({ + serverName, + originServerName: otherServerName, + }), + "mcp-apps" + ); + await expectFailure( + () => + session.rpc.mcp.apps.callTool({ + serverName, + toolName: "get_env", + originServerName: serverName, + arguments: { name: "MCP_APP_RPC_VALUE" }, + }), + "mcp-apps" + ); + } finally { + await session.disconnect(); + await mcpAppsClient.stop(); + } + }); + + it("should report error when mcp app resource is not available", async () => { + const serverName = "rpc-apps-resource-server"; + const mcpAppsClient = createMcpAppsClient(); + const session = await mcpAppsClient.createSession({ + onPermissionRequest: approveAll, + mcpServers: createTestMcpServers(serverName), + }); + try { + await waitForMcpServerStatus(session, serverName); + + await expect( + session.rpc.mcp.apps.readResource({ + serverName, + uri: "ui://missing-resource", + }) + ).rejects.toSatisfy((err: unknown) => { + const text = + err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); + expect(text.toLowerCase()).not.toContain("unhandled method"); + expect(text.toLowerCase()).toMatch(/resource|not found|method not found/); + return true; + }); + } finally { + await session.disconnect(); + await mcpAppsClient.stop(); + } + }); + it("should report error when mcp host is not initialized", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -205,6 +431,47 @@ describe("Session MCP and skills RPC", async () => { "No MCP host initialized" ); await expectFailure(() => session.rpc.mcp.reload(), "MCP config reload not available"); + await expectFailure( + () => session.rpc.mcp.oauth.login({ serverName: "missing-server" }), + "MCP host is not available" + ); + + await session.disconnect(); + }); + + it("should report error when mcp oauth server is not configured", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: createTestMcpServers("configured-stdio-server"), + }); + await waitForMcpServerStatus(session, "configured-stdio-server"); + + await expectFailure( + () => session.rpc.mcp.oauth.login({ serverName: "missing-server" }), + "is not configured" + ); + + await session.disconnect(); + }); + + it("should report error when mcp oauth server is not remote", async () => { + const serverName = "configured-stdio-server"; + const session = await client.createSession({ + onPermissionRequest: approveAll, + mcpServers: createTestMcpServers(serverName), + }); + await waitForMcpServerStatus(session, serverName); + + await expectFailure( + () => + session.rpc.mcp.oauth.login({ + serverName, + forceReauth: true, + clientName: "SDK E2E", + callbackSuccessMessage: "Done", + }), + "not a remote server" + ); await session.disconnect(); }); diff --git a/nodejs/test/e2e/rpc_queue.e2e.test.ts b/nodejs/test/e2e/rpc_queue.e2e.test.ts new file mode 100644 index 000000000..083105d20 --- /dev/null +++ b/nodejs/test/e2e/rpc_queue.e2e.test.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { approveAll, type SessionEvent } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +describe("Session queue RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + async function expectQueueEmpty(session: Awaited>) { + const pending = await session.rpc.queue.pendingItems(); + expect(pending.items).toEqual([]); + expect(pending.steeringMessages).toEqual([]); + } + + function isPendingCommand( + item: { kind: string; displayText: string }, + command: string + ): boolean { + return ( + item.kind === "command" && + (item.displayText === command || item.displayText.includes(command.replace(/^\//, ""))) + ); + } + + it("fresh queue is empty and empty mutations are no-ops", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expectQueueEmpty(session); + + expect((await session.rpc.queue.removeMostRecent()).removed).toBe(false); + await expectQueueEmpty(session); + + await session.rpc.queue.clear(); + await expectQueueEmpty(session); + + expect((await session.rpc.queue.removeMostRecent()).removed).toBe(false); + await expectQueueEmpty(session); + } finally { + await session.disconnect(); + } + }); + + it("pendingItems reports queued command and remove and clear update queue", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + let firstEvent: Extract | undefined; + let respondedToFirst = false; + const interest = await session.rpc.eventLog.registerInterest({ + eventType: "command.queued", + }); + try { + const firstCommand = `/sdk-queue-first-${randomUUID()}`; + const secondCommand = `/sdk-queue-second-${randomUUID()}`; + const thirdCommand = `/sdk-queue-third-${randomUUID()}`; + const firstQueued = new Promise>( + (resolve) => { + session.on((event) => { + if ( + event.type === "command.queued" && + event.data.command === firstCommand + ) { + resolve(event); + } + }); + } + ); + + expect((await session.rpc.commands.enqueue({ command: firstCommand })).queued).toBe( + true + ); + firstEvent = await firstQueued; + + expect((await session.rpc.commands.enqueue({ command: secondCommand })).queued).toBe( + true + ); + await waitForCondition( + async () => + (await session.rpc.queue.pendingItems()).items.some((item) => + isPendingCommand(item, secondCommand) + ), + { timeoutMessage: `Timed out waiting for ${secondCommand} in queue.` } + ); + + expect((await session.rpc.queue.removeMostRecent()).removed).toBe(true); + await waitForCondition( + async () => + !(await session.rpc.queue.pendingItems()).items.some((item) => + isPendingCommand(item, secondCommand) + ), + { timeoutMessage: `Timed out waiting for ${secondCommand} to leave queue.` } + ); + + expect((await session.rpc.commands.enqueue({ command: thirdCommand })).queued).toBe( + true + ); + await waitForCondition( + async () => + (await session.rpc.queue.pendingItems()).items.some((item) => + isPendingCommand(item, thirdCommand) + ), + { timeoutMessage: `Timed out waiting for ${thirdCommand} in queue.` } + ); + + await session.rpc.queue.clear(); + await waitForCondition( + async () => + !(await session.rpc.queue.pendingItems()).items.some((item) => + isPendingCommand(item, thirdCommand) + ), + { timeoutMessage: `Timed out waiting for ${thirdCommand} to leave queue.` } + ); + + const completed = await session.rpc.commands.respondToQueuedCommand({ + requestId: firstEvent.data.requestId, + result: { handled: true, stopProcessingQueue: true }, + }); + respondedToFirst = completed.success; + expect(completed.success).toBe(true); + + await waitForCondition( + async () => { + const pending = await session.rpc.queue.pendingItems(); + return pending.items.length === 0 && pending.steeringMessages.length === 0; + }, + { timeoutMessage: "Timed out waiting for queue to empty." } + ); + } finally { + if (!respondedToFirst && firstEvent) { + await session.rpc.commands.respondToQueuedCommand({ + requestId: firstEvent.data.requestId, + result: { handled: true, stopProcessingQueue: true }, + }); + } + await session.rpc.queue.clear(); + await session.rpc.eventLog.releaseInterest({ handle: interest.handle }); + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/rpc_remote.e2e.test.ts b/nodejs/test/e2e/rpc_remote.e2e.test.ts new file mode 100644 index 000000000..e0f1c68a5 --- /dev/null +++ b/nodejs/test/e2e/rpc_remote.e2e.test.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * 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"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +describe("Session remote RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + async function expectImplemented( + action: () => Promise, + method: string + ): Promise { + try { + return await action(); + } catch (err: unknown) { + const text = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); + expect(text.toLowerCase()).not.toContain(`unhandled method ${method.toLowerCase()}`); + return undefined; + } + } + + it("should treat remote off as no-op or implemented error", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = (await expectImplemented( + () => session.rpc.remote.enable({ mode: "off" }), + "session.remote.enable" + )) as Awaited> | undefined; + + if (result) { + expect(result.remoteSteerable).toBe(false); + expect(result.url ?? "").toBe(""); + } + } finally { + await session.disconnect(); + } + }); + + it("should treat remote disable as no-op or implemented error", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expectImplemented(() => session.rpc.remote.disable(), "session.remote.disable"); + } finally { + await session.disconnect(); + } + }); + + it("should notify steerable changed event and persist flag", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await session.rpc.remote.notifySteerableChanged({ remoteSteerable: true }); + await waitForCondition( + async () => + (await session.getEvents()).some( + (event) => + event.type === "session.remote_steerable_changed" && + event.data.remoteSteerable === true + ), + { timeoutMessage: "Timed out waiting for remote steerable=true event." } + ); + expect( + ( + await client.rpc.sessions.getPersistedRemoteSteerable({ + sessionId: session.sessionId, + }) + ).remoteSteerable + ).toBe(true); + + await session.rpc.remote.notifySteerableChanged({ remoteSteerable: false }); + await waitForCondition( + async () => + (await session.getEvents()).some( + (event) => + event.type === "session.remote_steerable_changed" && + event.data.remoteSteerable === false + ), + { timeoutMessage: "Timed out waiting for remote steerable=false event." } + ); + expect( + ( + await client.rpc.sessions.getPersistedRemoteSteerable({ + sessionId: session.sessionId, + }) + ).remoteSteerable + ).toBe(false); + } finally { + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/rpc_schedule.e2e.test.ts b/nodejs/test/e2e/rpc_schedule.e2e.test.ts new file mode 100644 index 000000000..9c2818b12 --- /dev/null +++ b/nodejs/test/e2e/rpc_schedule.e2e.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * 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("Session schedule RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should list no schedules for fresh session", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.schedule.list(); + expect(result.entries).toEqual([]); + } finally { + await session.disconnect(); + } + }); + + it("should return undefined entry when stopping unknown schedule", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.schedule.stop({ id: Number.MAX_SAFE_INTEGER }); + expect(result.entry).toBeUndefined(); + expect((await session.rpc.schedule.list()).entries).toEqual([]); + } finally { + await session.disconnect(); + } + }); +}); diff --git a/nodejs/test/e2e/rpc_server.e2e.test.ts b/nodejs/test/e2e/rpc_server.e2e.test.ts index 78c768ac1..9685a21d0 100644 --- a/nodejs/test/e2e/rpc_server.e2e.test.ts +++ b/nodejs/test/e2e/rpc_server.e2e.test.ts @@ -4,19 +4,33 @@ import * as fs from "fs"; import * as path from "path"; +import { randomUUID } from "node:crypto"; import { describe, expect, it, onTestFinished } from "vitest"; import { CopilotClient, RuntimeConnection } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; describe("Server-scoped RPC", async () => { const { copilotClient: client, openAiEndpoint, env, workDir } = await createSdkTestContext(); function createAuthenticatedClient(token: string): CopilotClient { + return createClientWithEnv( + { + COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, + }, + token + ); + } + + function createClientWithEnv( + extraEnv: Record, + token?: string + ): CopilotClient { const childEnv = { ...env, - COPILOT_DEBUG_GITHUB_API_URL: env.COPILOT_API_URL, + ...extraEnv, }; - const authClient = new CopilotClient({ + const extraClient = new CopilotClient({ workingDirectory: workDir, env: childEnv, logLevel: "error", @@ -25,12 +39,12 @@ describe("Server-scoped RPC", async () => { }); onTestFinished(async () => { try { - await authClient.forceStop(); + await extraClient.forceStop(); } catch { // Ignore cleanup errors } }); - return authClient; + return extraClient; } async function configureAuthenticatedUser( @@ -72,6 +86,24 @@ describe("Server-scoped RPC", async () => { return skillsDir; } + function createUniqueWorkDirectory(prefix: string): string { + const directory = path.join(workDir, `${prefix}-${randomUUID()}`); + fs.mkdirSync(directory, { recursive: true }); + return directory; + } + + async function saveAndGetEventFilePath( + targetClient: CopilotClient, + sessionId: string + ): Promise { + await expect(targetClient.rpc.sessions.save({ sessionId })).resolves.toBeDefined(); + const pathResult = await targetClient.rpc.sessions.getEventFilePath({ sessionId }); + expect(pathResult.filePath.trim()).toBeTruthy(); + expect(path.isAbsolute(pathResult.filePath)).toBe(true); + expect(path.basename(pathResult.filePath)).toBe("events.jsonl"); + return pathResult.filePath; + } + it("should call rpc ping with typed params and result", async () => { await client.start(); const result = await client.ping("typed rpc test"); @@ -130,6 +162,240 @@ describe("Server-scoped RPC", async () => { } }); + it("should call rpc sessionFs setProvider with typed result", async () => { + const fsClient = createClientWithEnv({}); + await fsClient.start(); + + const result = await fsClient.rpc.sessionFs.setProvider({ + initialCwd: "/", + sessionStatePath: "/session-state", + conventions: "posix", + capabilities: { sqlite: true }, + }); + + expect(result.success).toBe(true); + }); + + it("should add secret filter values", async () => { + const secretClient = createClientWithEnv({ COPILOT_ENABLE_SECRET_FILTERING: "true" }); + await secretClient.start(); + + const result = await secretClient.rpc.secrets.addFilterValues({ + values: [`rpc-secret-${randomUUID()}`], + }); + + expect(result.ok).toBe(true); + }); + + it("should list, find, and inspect persisted session state", async () => { + const sessionId = randomUUID(); + const missingTaskId = `missing-task-${randomUUID()}`; + const missingSessionId = randomUUID(); + const workingDirectory = createUniqueWorkDirectory("server-rpc-list"); + let closed = false; + const session = await client.createSession({ + sessionId, + workingDirectory, + }); + try { + await session.log("SERVER_RPC_LIST_READY"); + const eventFilePath = await saveAndGetEventFilePath(client, sessionId); + expect(eventFilePath.toLowerCase()).toContain(sessionId.toLowerCase()); + + await client.rpc.sessions.close({ sessionId }); + closed = true; + + const listed = await client.rpc.sessions.list({ + metadataLimit: 0, + filter: { cwd: workingDirectory }, + }); + expect(Array.isArray(listed.sessions)).toBe(true); + expect( + listed.sessions.every( + (session) => + session.context?.cwd === undefined || + pathsEqual(session.context.cwd, workingDirectory) + ) + ).toBe(true); + + const byPrefix = await client.rpc.sessions.findByPrefix({ + prefix: missingSessionId.slice(0, 8), + }); + expect(byPrefix.sessionId).toBeUndefined(); + + const byTaskId = await client.rpc.sessions.findByTaskId({ taskId: missingTaskId }); + expect(byTaskId.sessionId).toBeUndefined(); + + const lastForContext = await client.rpc.sessions.getLastForContext({ + context: { cwd: workingDirectory }, + }); + expect( + lastForContext.sessionId === undefined || lastForContext.sessionId === sessionId + ).toBe(true); + + const sizes = await client.rpc.sessions.getSizes(); + if (sizes.sizes[sessionId] !== undefined) { + expect(sizes.sizes[sessionId]).toBeGreaterThanOrEqual(0); + } + + const inUse = await client.rpc.sessions.checkInUse({ + sessionIds: [sessionId, missingSessionId], + }); + expect(inUse.inUse).not.toContain(missingSessionId); + + const remoteSteerable = await client.rpc.sessions.getPersistedRemoteSteerable({ + sessionId, + }); + expect(remoteSteerable.remoteSteerable).toBeUndefined(); + } finally { + if (closed) { + await client.rpc.sessions.bulkDelete({ sessionIds: [sessionId] }); + } else { + await session.disconnect(); + } + } + }, 60_000); + + it("should enrich basic session metadata", async () => { + const sessionId = randomUUID(); + const workingDirectory = createUniqueWorkDirectory("server-rpc-enrich"); + const session = await client.createSession({ + sessionId, + workingDirectory, + onPermissionRequest: () => ({ kind: "approve-once" }), + }); + try { + await saveAndGetEventFilePath(client, sessionId); + + const now = new Date().toISOString(); + const result = await client.rpc.sessions.enrichMetadata({ + sessions: [ + { + sessionId, + startTime: now, + modifiedTime: now, + isRemote: false, + name: "Basic metadata", + context: { cwd: workingDirectory }, + }, + ], + }); + + const enriched = result.sessions[0]; + expect(enriched.sessionId).toBe(sessionId); + expect(pathsEqual(enriched.context?.cwd ?? "", workingDirectory)).toBe(true); + expect(enriched.isRemote).toBe(false); + } finally { + await session.disconnect(); + } + }); + + it("should close active session and release lock", async () => { + const sessionId = randomUUID(); + const workingDirectory = createUniqueWorkDirectory("server-rpc-close"); + const session = await client.createSession({ + sessionId, + workingDirectory, + onPermissionRequest: () => ({ kind: "approve-once" }), + }); + + await session.log("SERVER_RPC_CLOSE_READY"); + await saveAndGetEventFilePath(client, sessionId); + + await expect(client.rpc.sessions.close({ sessionId })).resolves.toBeDefined(); + await expect(client.rpc.sessions.releaseLock({ sessionId })).resolves.toBeDefined(); + const inUse = await client.rpc.sessions.checkInUse({ sessionIds: [sessionId] }); + expect(inUse.inUse).not.toContain(sessionId); + + // The server-side close disposes the session; do not call session.disconnect(). + }); + + it("should prune dry-run and bulkDelete persisted session", async () => { + const sessionId = randomUUID(); + const missingSessionId = randomUUID(); + const workingDirectory = createUniqueWorkDirectory("server-rpc-delete"); + const session = await client.createSession({ + sessionId, + workingDirectory, + onPermissionRequest: () => ({ kind: "approve-once" }), + }); + + await saveAndGetEventFilePath(client, sessionId); + await client.rpc.sessions.close({ sessionId }); + + const prune = await client.rpc.sessions.pruneOld({ + olderThanDays: 0, + dryRun: true, + includeNamed: true, + excludeSessionIds: [], + }); + expect(prune.dryRun).toBe(true); + expect(prune.candidates).not.toContain(missingSessionId); + expect(prune.deleted).not.toContain(sessionId); + expect(prune.freedBytes).toBeGreaterThanOrEqual(0); + + const deleted = await client.rpc.sessions.bulkDelete({ + sessionIds: [sessionId, missingSessionId], + }); + expect(deleted.freedBytes[sessionId]).toBeGreaterThanOrEqual(0); + if (deleted.freedBytes[missingSessionId] !== undefined) { + expect(deleted.freedBytes[missingSessionId]).toBe(0); + } + + await waitForCondition( + async () => + !(await client.rpc.sessions.list({})).sessions.some( + (session) => session.sessionId === sessionId + ), + { timeoutMessage: `Timed out waiting for sessions.bulkDelete to remove ${sessionId}.` } + ); + + // The server-side close/deletion disposes the session; do not call session.disconnect(). + expect(session.sessionId).toBe(sessionId); + }); + + it("should set additional plugins and reload deferred hooks", async () => { + await client.start(); + await expect( + client.rpc.sessions.setAdditionalPlugins({ plugins: [] }) + ).resolves.toBeDefined(); + + const sessionId = randomUUID(); + const workingDirectory = createUniqueWorkDirectory("server-rpc-hooks"); + const session = await client.createSession({ + sessionId, + workingDirectory, + enableConfigDiscovery: false, + }); + try { + await expect( + client.rpc.sessions.reloadPluginHooks({ sessionId, deferRepoHooks: true }) + ).resolves.toBeDefined(); + + const loaded = await client.rpc.sessions.loadDeferredRepoHooks({ sessionId }); + expect(loaded.startupPrompts).toEqual([]); + expect(loaded.hookCount).toBe(0); + } finally { + await client.rpc.sessions.setAdditionalPlugins({ plugins: [] }); + await session.disconnect(); + } + }); + + it("should report implemented error when connecting unknown remote session", async () => { + await client.start(); + const remoteSessionId = `remote-${randomUUID()}`; + + await expect(client.rpc.sessions.connect({ sessionId: remoteSessionId })).rejects.toSatisfy( + (err: unknown) => { + const text = + err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); + expect(text.toLowerCase()).not.toContain("unhandled method sessions.connect"); + expect(text.toLowerCase()).toContain("session"); + return true; + } + ); + }); + it("should discover server mcp and skills", async () => { await client.start(); @@ -162,3 +428,14 @@ describe("Server-scoped RPC", async () => { } }); }); + +function pathsEqual(left: string, right: string): boolean { + return normalizePath(left) === normalizePath(right); +} + +function normalizePath(value: string): string { + return path + .resolve(value) + .replace(/[\\/]+$/g, "") + .toLowerCase(); +} diff --git a/nodejs/test/e2e/rpc_session_state.e2e.test.ts b/nodejs/test/e2e/rpc_session_state.e2e.test.ts index 2fc6ce046..295f60340 100644 --- a/nodejs/test/e2e/rpc_session_state.e2e.test.ts +++ b/nodejs/test/e2e/rpc_session_state.e2e.test.ts @@ -3,13 +3,16 @@ *--------------------------------------------------------------------------------------------*/ import { randomUUID } from "crypto"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { approveAll } from "../../src/index.js"; -import type { SessionEvent } from "../../src/index.js"; +import type { CopilotSession, SessionEvent } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; describe("Session-scoped RPC", async () => { - const { copilotClient: client } = await createSdkTestContext(); + const { copilotClient: client, workDir } = await createSdkTestContext(); async function assertImplementedFailure( action: () => Promise, @@ -67,6 +70,35 @@ describe("Session-scoped RPC", async () => { await session.disconnect(); }); + it("should shutdown session with routine type", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + const shutdownEvent = waitForEvent( + session, + (event): event is Extract => + event.type === "session.shutdown" && event.data.shutdownType === "routine", + "session.shutdown routine event" + ); + + await session.rpc.shutdown({ + type: "routine", + reason: "SDK E2E shutdown coverage", + }); + + expect((await shutdownEvent).data.shutdownType).toBe("routine"); + }); + + it("should set and get each session mode value", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + for (const mode of ["interactive", "plan", "autopilot"] as const) { + await session.rpc.mode.set({ mode }); + expect(await session.rpc.mode.get()).toBe(mode); + } + } finally { + await session.disconnect(); + } + }); + it("should get and set session mode", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -129,6 +161,127 @@ describe("Session-scoped RPC", async () => { await session.disconnect(); }); + it.each(["../escaped.txt", "../../escaped.txt", "nested/../../../escaped.txt"])( + "should reject workspace file path traversal: %s", + async (filePath) => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expect( + session.rpc.workspaces.createFile({ + path: filePath, + content: "should not land outside workspace", + }) + ).rejects.toThrow(/workspace files directory/i); + + await expect(session.rpc.workspaces.readFile({ path: filePath })).rejects.toThrow( + /workspace files directory/i + ); + } finally { + await session.disconnect(); + } + } + ); + + it("should create workspace file with nested path auto-creating dirs", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const nestedPath = `nested-${randomUUID()}/subdir/file.txt`; + await session.rpc.workspaces.createFile({ + path: nestedPath, + content: "nested content", + }); + + expect((await session.rpc.workspaces.readFile({ path: nestedPath })).content).toBe( + "nested content" + ); + expect( + (await session.rpc.workspaces.listFiles()).files.some((f) => f.endsWith("file.txt")) + ).toBe(true); + } finally { + await session.disconnect(); + } + }); + + it("should report error reading nonexistent workspace file", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expect( + session.rpc.workspaces.readFile({ + path: `never-exists-${randomUUID()}.txt`, + }) + ).rejects.toThrow(); + } finally { + await session.disconnect(); + } + }); + + it("should update existing workspace file with update operation", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const filePath = `reused-${randomUUID()}.txt`; + await session.rpc.workspaces.createFile({ path: filePath, content: "v1" }); + + const updated = waitForEvent( + session, + ( + event + ): event is Extract => + event.type === "session.workspace_file_changed" && + event.data.path === filePath && + event.data.operation === "update", + `workspace_file_changed update event for ${filePath}` + ); + await session.rpc.workspaces.createFile({ path: filePath, content: "v2" }); + + expect((await updated).data.operation).toBe("update"); + expect((await session.rpc.workspaces.readFile({ path: filePath })).content).toBe("v2"); + } finally { + await session.disconnect(); + } + }); + + it.each(["", " ", "\t\n \r"])( + "should reject empty or whitespace session name", + async (emptyOrWhitespace) => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + await expect(session.rpc.name.set({ name: emptyOrWhitespace })).rejects.toThrow( + /empty/i + ); + } finally { + await session.disconnect(); + } + } + ); + + it("should emit title changed event each time name set is called", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const titleA = `Title-A-${randomUUID()}`; + const titleB = `Title-B-${randomUUID()}`; + + const first = waitForEvent( + session, + (event): event is Extract => + event.type === "session.title_changed" && event.data.title === titleA, + "first title_changed event" + ); + await session.rpc.name.set({ name: titleA }); + expect((await first).data.title).toBe(titleA); + + const second = waitForEvent( + session, + (event): event is Extract => + event.type === "session.title_changed" && event.data.title === titleB, + "second title_changed event" + ); + await session.rpc.name.set({ name: titleB }); + expect((await second).data.title).toBe(titleB); + } finally { + await session.disconnect(); + } + }); + it("should get and set session metadata", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -142,6 +295,218 @@ describe("Session-scoped RPC", async () => { await session.disconnect(); }); + it("should call metadata snapshot, setWorkingDirectory, and recordContextChange", async () => { + const firstDirectory = createUniqueDirectory(workDir, "rpc-session-state-first"); + const secondDirectory = createUniqueDirectory(workDir, "rpc-session-state-second"); + const contextDirectory = createUniqueDirectory(workDir, "rpc-session-state-context"); + const branch = `rpc-context-${randomUUID()}`; + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + workingDirectory: firstDirectory, + }); + try { + const initialSnapshot = await session.rpc.metadata.snapshot(); + expect(initialSnapshot.sessionId).toBe(session.sessionId); + expect(initialSnapshot.currentMode).toBe("interactive"); + expect(initialSnapshot.selectedModel).toBe("claude-sonnet-4.5"); + expect(initialSnapshot.isRemote).toBe(false); + expect(initialSnapshot.alreadyInUse).toBe(false); + expect(Date.parse(initialSnapshot.startTime)).not.toBeNaN(); + expect(Date.parse(initialSnapshot.modifiedTime)).not.toBeNaN(); + expect(pathsEqual(initialSnapshot.workingDirectory, firstDirectory)).toBe(true); + expect(initialSnapshot.workspace?.id).toBe(session.sessionId); + expect(initialSnapshot.workspacePath?.trim()).toBeTruthy(); + + const setWorkingDirectory = await session.rpc.metadata.setWorkingDirectory({ + workingDirectory: secondDirectory, + }); + expect(pathsEqual(setWorkingDirectory.workingDirectory, secondDirectory)).toBe(true); + + await waitForCondition( + async () => + pathsEqual( + (await session.rpc.metadata.snapshot()).workingDirectory, + secondDirectory + ), + { timeoutMessage: "Timed out waiting for metadata snapshot to reflect cwd." } + ); + + const contextChanged = waitForEvent( + session, + (event): event is Extract => + event.type === "session.context_changed" && event.data.branch === branch, + "session.context_changed event" + ); + + const context = { + cwd: contextDirectory, + gitRoot: firstDirectory, + branch, + repository: "github/copilot-sdk-e2e", + repositoryHost: "github.com", + hostType: "github" as const, + baseCommit: "0000000000000000000000000000000000000000", + headCommit: "1111111111111111111111111111111111111111", + }; + await session.rpc.metadata.recordContextChange({ context }); + + const event = await contextChanged; + expect(pathsEqual(event.data.cwd, contextDirectory)).toBe(true); + expect(pathsEqual(event.data.gitRoot ?? "", firstDirectory)).toBe(true); + expect(event.data.branch).toBe(branch); + expect(event.data.repository).toBe("github/copilot-sdk-e2e"); + expect(event.data.repositoryHost).toBe("github.com"); + expect(event.data.hostType).toBe("github"); + expect(event.data.baseCommit).toBe(context.baseCommit); + expect(event.data.headCommit).toBe(context.headCommit); + } finally { + await session.disconnect(); + } + }); + + it("should update options and initialize session services", async () => { + const initialDirectory = createUniqueDirectory(workDir, "rpc-options-initial"); + const optionsDirectory = createUniqueDirectory(workDir, "rpc-options-updated"); + const featureName = `rpc-session-state-${randomUUID()}`; + const session = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: initialDirectory, + }); + try { + const update = await session.rpc.options.update({ + clientName: "node-sdk-rpc-session-state-e2e", + lspClientName: "node-sdk-rpc-session-state-lsp", + integrationId: `node-sdk-${randomUUID()}`, + featureFlags: { [featureName]: true }, + workingDirectory: optionsDirectory, + coauthorEnabled: false, + enableStreaming: false, + askUserDisabled: true, + }); + expect(update.success).toBe(true); + + await waitForCondition( + async () => + pathsEqual( + (await session.rpc.metadata.snapshot()).workingDirectory, + optionsDirectory + ), + { + timeoutMessage: + "Timed out waiting for options.update workingDirectory to reach metadata snapshot.", + } + ); + + await expect( + session.rpc.lsp.initialize({ + workingDirectory: optionsDirectory, + gitRoot: initialDirectory, + force: true, + }) + ).resolves.toBeNull(); + + await expect( + session.rpc.telemetry.setFeatureOverrides({ + features: { + rpc_session_state_feature: featureName, + rpc_session_state_value: "enabled", + }, + }) + ).resolves.toBeNull(); + + await expect(session.rpc.tools.initializeAndValidate()).resolves.toBeDefined(); + expect( + pathsEqual( + (await session.rpc.metadata.snapshot()).workingDirectory, + optionsDirectory + ) + ).toBe(true); + } finally { + await session.disconnect(); + } + }); + + it("should set reasoning effort and auto name", async () => { + const session = await client.createSession({ + onPermissionRequest: approveAll, + model: "claude-sonnet-4.5", + }); + try { + const reasoning = await session.rpc.model.setReasoningEffort({ + reasoningEffort: "high", + }); + expect(reasoning.reasoningEffort).toBe("high"); + + const currentModel = await session.rpc.model.getCurrent(); + expect(currentModel.modelId).toBe("claude-sonnet-4.5"); + expect(currentModel.reasoningEffort).toBe("high"); + + const autoName = `Auto Session ${randomUUID()}`; + const autoChanged = waitForEvent( + session, + (event): event is Extract => + event.type === "session.title_changed" && event.data.title === autoName, + "session.title_changed event after name.setAuto" + ); + const autoResult = await session.rpc.name.setAuto({ summary: ` ${autoName} ` }); + expect(autoResult.applied).toBe(true); + expect((await autoChanged).data.title).toBe(autoName); + expect((await session.rpc.name.get()).name).toBe(autoName); + + const explicitName = `Explicit Session ${randomUUID()}`; + const explicitChanged = waitForEvent( + session, + (event): event is Extract => + event.type === "session.title_changed" && event.data.title === explicitName, + "session.title_changed event after explicit name.set" + ); + await session.rpc.name.set({ name: explicitName }); + expect((await explicitChanged).data.title).toBe(explicitName); + + const ignoredAutoResult = await session.rpc.name.setAuto({ + summary: `Ignored ${randomUUID()}`, + }); + expect(ignoredAutoResult.applied).toBe(false); + expect((await session.rpc.name.get()).name).toBe(explicitName); + } finally { + await session.disconnect(); + } + }); + + it("should set auth credentials", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const login = `sdk-rpc-${randomUUID()}`; + const setCredentials = await session.rpc.auth.setCredentials({ + credentials: { + type: "user", + host: "https://github.com", + login, + copilotUser: { + analytics_tracking_id: "rpc-session-state-tracking-id", + chat_enabled: true, + copilot_plan: "individual_pro", + endpoints: { + api: "https://api.githubcopilot.test", + telemetry: "https://localhost:1/telemetry", + }, + login, + }, + }, + }); + expect(setCredentials.success).toBe(true); + + const status = await session.rpc.auth.getStatus(); + expect(status.isAuthenticated).toBe(true); + expect(status.authType).toBe("user"); + expect(status.host).toBe("https://github.com"); + expect(status.login).toBe(login); + } finally { + await session.disconnect(); + } + }); + it("should fork session with persisted messages", async () => { const sourcePrompt = "Say FORK_SOURCE_ALPHA exactly."; const forkPrompt = "Now say FORK_CHILD_BETA exactly."; @@ -346,11 +711,98 @@ describe("Session-scoped RPC", async () => { it("should compact session history after messages", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); + expect((await session.rpc.metadata.isProcessing()).processing).toBe(false); await session.sendAndWait({ prompt: "What is 2+2?" }); + expect((await session.rpc.metadata.isProcessing()).processing).toBe(false); + + const contextInfo = await session.rpc.metadata.contextInfo({ + promptTokenLimit: 128_000, + outputTokenLimit: 4_096, + selectedModel: "claude-sonnet-4.5", + }); + expect(contextInfo.contextInfo).not.toBeNull(); + if (contextInfo.contextInfo) { + expect(contextInfo.contextInfo.modelName).toBe("claude-sonnet-4.5"); + expect(contextInfo.contextInfo.promptTokenLimit).toBe(128_000); + expect(contextInfo.contextInfo.limit).toBeGreaterThanOrEqual( + contextInfo.contextInfo.promptTokenLimit + ); + expect(contextInfo.contextInfo.totalTokens).toBeGreaterThan(0); + expect(contextInfo.contextInfo.systemTokens).toBeGreaterThan(0); + expect(contextInfo.contextInfo.conversationTokens).toBeGreaterThan(0); + expect(contextInfo.contextInfo.toolDefinitionsTokens).toBeGreaterThanOrEqual(0); + expect(contextInfo.contextInfo.totalTokens).toBe( + contextInfo.contextInfo.systemTokens + + contextInfo.contextInfo.conversationTokens + + contextInfo.contextInfo.toolDefinitionsTokens + ); + } + + const recomputed = await session.rpc.metadata.recomputeContextTokens({ + modelId: "claude-sonnet-4.5", + }); + expect(recomputed.systemTokenCount).toBeGreaterThan(0); + expect(recomputed.messagesTokenCount).toBeGreaterThan(0); + expect(recomputed.totalTokens).toBe( + recomputed.systemTokenCount + recomputed.messagesTokenCount + ); const result = await session.rpc.history.compact(); - expect(result).toBeDefined(); + expect(result.success).toBe(true); + expect(result.messagesRemoved).toBeGreaterThanOrEqual(0); + if (result.contextWindow) { + expect(result.contextWindow.messagesLength).toBeGreaterThanOrEqual(0); + expect(result.contextWindow.currentTokens).toBeGreaterThanOrEqual(0); + if (result.contextWindow.conversationTokens != null) { + expect(result.contextWindow.conversationTokens).toBeGreaterThanOrEqual(0); + expect(result.contextWindow.conversationTokens).toBeLessThanOrEqual( + result.contextWindow.currentTokens + ); + } + } + expect(await session.rpc.name.get()).toBeDefined(); await session.disconnect(); }); }); + +function createUniqueDirectory(baseDir: string, prefix: string): string { + const directory = join(baseDir, `${prefix}-${randomUUID()}`); + mkdirSync(directory, { recursive: true }); + return directory; +} + +function pathsEqual(left: string, right: string): boolean { + return normalizePath(left) === normalizePath(right); +} + +function normalizePath(value: string): string { + return value.replace(/[\\/]+$/g, "").toLowerCase(); +} + +function waitForEvent( + session: CopilotSession, + predicate: (event: SessionEvent) => event is T, + description: string, + timeoutMs = 15_000 +): Promise { + return new Promise((resolve, reject) => { + let unsubscribe: () => void = () => {}; + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeoutMs); + + unsubscribe = session.on((event) => { + if (predicate(event)) { + clearTimeout(timeout); + unsubscribe(); + resolve(event); + } else if (event.type === "session.error") { + clearTimeout(timeout); + unsubscribe(); + reject(new Error(`${event.data.message}\n${event.data.stack ?? ""}`)); + } + }); + }); +} diff --git a/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts b/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts index 6b0e5f7bf..e7f7664c3 100644 --- a/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts +++ b/nodejs/test/e2e/rpc_tasks_and_handlers.e2e.test.ts @@ -27,15 +27,34 @@ describe("Session tasks RPC and pending handlers", async () => { expect(tasks.tasks).toBeDefined(); expect(tasks.tasks).toEqual([]); - const promote = await session.rpc.tasks.promoteToBackground({ taskId: "missing-task" }); + await expect(session.rpc.tasks.refresh()).resolves.toBeDefined(); + await expect(session.rpc.tasks.waitForPending()).resolves.toBeDefined(); + + const progress = await session.rpc.tasks.getProgress({ id: "missing-task" }); + expect(progress.progress).toBeNull(); + + const currentPromotable = await session.rpc.tasks.getCurrentPromotable(); + expect(currentPromotable.task).toBeUndefined(); + + const promote = await session.rpc.tasks.promoteToBackground({ id: "missing-task" }); expect(promote.promoted).toBe(false); - const cancel = await session.rpc.tasks.cancel({ taskId: "missing-task" }); + const promoteCurrent = await session.rpc.tasks.promoteCurrentToBackground(); + expect(promoteCurrent.task).toBeUndefined(); + + const cancel = await session.rpc.tasks.cancel({ id: "missing-task" }); expect(cancel.cancelled).toBe(false); - const remove = await session.rpc.tasks.remove({ taskId: "missing-task" }); + const remove = await session.rpc.tasks.remove({ id: "missing-task" }); expect(remove.removed).toBe(false); + const sendMessage = await session.rpc.tasks.sendMessage({ + id: "missing-task", + message: "hello from the SDK E2E test", + }); + expect(sendMessage.sent).toBe(false); + expect(sendMessage.error?.trim()).toBeTruthy(); + await session.disconnect(); }, 60_000); @@ -55,6 +74,25 @@ describe("Session tasks RPC and pending handlers", async () => { await session.disconnect(); }); + it("should report implemented error for invalid task agent model", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + await assertImplementedFailure( + () => + session.rpc.tasks.startAgent({ + agentType: "general-purpose", + prompt: "Say hi", + name: "sdk-test-task", + description: "SDK task agent validation", + model: "not-a-real-model", + }), + "session.tasks.startAgent" + ); + expect((await session.rpc.tasks.list()).tasks).toEqual([]); + + await session.disconnect(); + }); + it("should return expected results for missing pending handler requestIds", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); @@ -76,6 +114,34 @@ describe("Session tasks RPC and pending handlers", async () => { }); expect(elicitation.success).toBe(false); + const userInput = await session.rpc.ui.handlePendingUserInput({ + requestId: "missing-user-input-request", + response: { answer: "typed answer", wasFreeform: true }, + }); + expect(userInput.success).toBe(false); + + const sampling = await session.rpc.ui.handlePendingSampling({ + requestId: "missing-sampling-request", + response: {}, + }); + expect(sampling.success).toBe(false); + + const autoModeSwitch = await session.rpc.ui.handlePendingAutoModeSwitch({ + requestId: "missing-auto-mode-switch-request", + response: "no", + }); + expect(autoModeSwitch.success).toBe(false); + + const exitPlanMode = await session.rpc.ui.handlePendingExitPlanMode({ + requestId: "missing-exit-plan-mode-request", + response: { + approved: false, + feedback: "No pending plan approval", + selectedAction: "exit_only", + }, + }); + expect(exitPlanMode.success).toBe(false); + const permission = await session.rpc.permissions.handlePendingPermissionRequest({ requestId: "missing-permission-request", result: { kind: "reject", feedback: "not approved" }, @@ -88,6 +154,102 @@ describe("Session tasks RPC and pending handlers", async () => { }); expect(permanent.success).toBe(false); + const sessionApproval = await session.rpc.permissions.handlePendingPermissionRequest({ + requestId: "missing-session-approval-request", + result: { + kind: "approve-for-session", + approval: { kind: "custom-tool", toolName: "missing-tool" }, + }, + }); + expect(sessionApproval.success).toBe(false); + + const locationApproval = await session.rpc.permissions.handlePendingPermissionRequest({ + requestId: "missing-location-approval-request", + result: { + kind: "approve-for-location", + approval: { kind: "custom-tool", toolName: "missing-tool" }, + locationKey: "missing-location", + }, + }); + expect(locationApproval.success).toBe(false); + + await session.disconnect(); + }); + + it("should round trip rpc elicitation through config handler", async () => { + let resolveContext!: (value: unknown) => void; + const handlerContext = new Promise((resolve) => { + resolveContext = resolve; + }); + const session = await client.createSession({ + onPermissionRequest: approveAll, + onElicitationRequest: (context) => { + resolveContext(context); + return { + action: "accept", + content: { + answer: "from handler", + confirmed: true, + }, + }; + }, + }); + + const schema = { + type: "object" as const, + properties: { + answer: { type: "string" as const }, + confirmed: { type: "boolean" as const }, + }, + required: ["answer"], + }; + + const response = await session.rpc.ui.elicitation({ + message: "Need details", + requestedSchema: schema, + }); + const context = (await handlerContext) as { + sessionId: string; + message: string; + requestedSchema?: typeof schema; + }; + + expect(context.sessionId).toBe(session.sessionId); + expect(context.message).toBe("Need details"); + expect(context.requestedSchema?.type).toBe("object"); + expect(Object.keys(context.requestedSchema?.properties ?? {})).toEqual([ + "answer", + "confirmed", + ]); + expect(context.requestedSchema?.required).toEqual(["answer"]); + expect(response.action).toBe("accept"); + expect(response.content?.answer).toBe("from handler"); + expect(response.content?.confirmed).toBe(true); + + await session.disconnect(); + }); + + it("should register and unregister direct auto mode switch handler", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + + const missing = await session.rpc.ui.unregisterDirectAutoModeSwitchHandler({ + handle: "missing-direct-auto-mode-handle", + }); + expect(missing.unregistered).toBe(false); + + const registration = await session.rpc.ui.registerDirectAutoModeSwitchHandler(); + expect(registration.handle.trim()).toBeTruthy(); + + const unregister = await session.rpc.ui.unregisterDirectAutoModeSwitchHandler({ + handle: registration.handle, + }); + expect(unregister.unregistered).toBe(true); + + const unregisterAgain = await session.rpc.ui.unregisterDirectAutoModeSwitchHandler({ + handle: registration.handle, + }); + expect(unregisterAgain.unregistered).toBe(false); + await session.disconnect(); }); }); diff --git a/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts new file mode 100644 index 000000000..d7f478e1f --- /dev/null +++ b/nodejs/test/e2e/rpc_workspace_checkpoints.e2e.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { existsSync, readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { approveAll } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +describe("Session workspace checkpoint RPC", async () => { + const { copilotClient: client } = await createSdkTestContext(); + + it("should list no checkpoints for fresh session", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.workspaces.listCheckpoints(); + expect(result.checkpoints).toEqual([]); + } finally { + await session.disconnect(); + } + }); + + it("should return null or empty content for unknown checkpoint", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.workspaces.readCheckpoint({ + number: Number.MAX_SAFE_INTEGER, + }); + expect(result.content ?? "").toBe(""); + } finally { + await session.disconnect(); + } + }); + + it("should return typed workspace diff result", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const result = await session.rpc.workspaces.diff({ mode: "unstaged" }); + expect(result.requestedMode).toBe("unstaged"); + expect(["unstaged", "branch"]).toContain(result.mode); + expect(Array.isArray(result.changes)).toBe(true); + for (const change of result.changes) { + expect(change.path.trim()).toBeTruthy(); + expect(["added", "modified", "deleted", "renamed"]).toContain(change.changeType); + expect(typeof change.diff).toBe("string"); + } + } finally { + await session.disconnect(); + } + }); + + it("should save large paste and expose readable content", async () => { + const session = await client.createSession({ onPermissionRequest: approveAll }); + try { + const content = "Large paste payload 🚀\n".repeat(512); + const result = await session.rpc.workspaces.saveLargePaste({ content }); + const saved = result.saved; + + expect(saved).not.toBeNull(); + expect(saved!.filename.trim()).toBeTruthy(); + expect(saved!.filePath.trim()).toBeTruthy(); + expect(saved!.sizeBytes).toBe(Buffer.byteLength(content, "utf8")); + + try { + const read = await session.rpc.workspaces.readFile({ path: saved!.filename }); + expect(read.content).toBe(content); + } catch (err: unknown) { + expect(existsSync(saved!.filePath)).toBe(true); + expect(readFileSync(saved!.filePath, "utf8")).toBe(content); + expect(err).toBeDefined(); + } + } finally { + await session.disconnect(); + } + }); +}); diff --git a/python/e2e/test_rpc_commands_e2e.py b/python/e2e/test_rpc_commands_e2e.py new file mode 100644 index 000000000..2e2693237 --- /dev/null +++ b/python/e2e/test_rpc_commands_e2e.py @@ -0,0 +1,117 @@ +"""E2E coverage for session.commands RPC methods.""" + +from __future__ import annotations + +import pytest + +from copilot.generated.rpc import ( + CommandsInvokeRequest, + CommandsListRequest, + CommandsRespondToQueuedCommandRequest, + ExecuteCommandParams, + QueuedCommandHandled, + SlashCommandKind, + SlashCommandTextResult, +) +from copilot.session import CommandContext, CommandDefinition, PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestRpcCommands: + async def test_should_list_builtin_and_client_commands(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + commands=[ + CommandDefinition( + name="deploy", + description="Deploy the app", + handler=lambda _: None, + ) + ], + ) + try: + commands = await session.rpc.commands.list(CommandsListRequest()) + by_name = {command.name: command for command in commands.commands} + + builtins = [ + command for command in commands.commands if command.kind == SlashCommandKind.BUILTIN + ] + assert builtins + if "model" in by_name: + assert by_name["model"].kind == SlashCommandKind.BUILTIN + if "compact" in by_name: + assert by_name["compact"].kind == SlashCommandKind.BUILTIN + + assert "deploy" in by_name + assert by_name["deploy"].kind == SlashCommandKind.CLIENT + assert by_name["deploy"].description == "Deploy the app" + finally: + await session.disconnect() + + async def test_should_invoke_builtin_model_command(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + result = await session.rpc.commands.invoke(CommandsInvokeRequest(name="model")) + assert result is not None + if isinstance(result, SlashCommandTextResult): + assert result.text.strip() + else: + assert getattr(result, "kind", None) in { + "agent-prompt", + "completed", + "select-subcommand", + "text", + } + finally: + await session.disconnect() + + async def test_should_execute_registered_command_with_arguments(self, ctx: E2ETestContext): + calls: list[CommandContext] = [] + + def deploy(context: CommandContext) -> None: + calls.append(context) + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + commands=[ + CommandDefinition( + name="deploy", + description="Deploy the app", + handler=deploy, + ) + ], + ) + try: + result = await session.rpc.commands.execute( + ExecuteCommandParams(command_name="deploy", args="production") + ) + assert result.error is None + assert len(calls) == 1 + assert calls[0].session_id == session.session_id + assert calls[0].command_name == "deploy" + assert calls[0].args == "production" + assert calls[0].command == "/deploy production" + finally: + await session.disconnect() + + async def test_should_return_false_for_unknown_queued_command_response( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + result = await session.rpc.commands.respond_to_queued_command( + CommandsRespondToQueuedCommandRequest( + request_id="missing-queued-command", + result=QueuedCommandHandled(stop_processing_queue=True), + ) + ) + assert result.success is False + finally: + await session.disconnect() diff --git a/python/e2e/test_rpc_event_log_e2e.py b/python/e2e/test_rpc_event_log_e2e.py new file mode 100644 index 000000000..402d12790 --- /dev/null +++ b/python/e2e/test_rpc_event_log_e2e.py @@ -0,0 +1,157 @@ +"""E2E coverage for session.eventLog RPC methods.""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from collections.abc import Awaitable, Callable + +import pytest + +from copilot.generated.rpc import ( + EventLogReadRequest, + EventsCursorStatus, + NameSetRequest, + PlanUpdateRequest, + RegisterEventInterestParams, + ReleaseEventInterestParams, +) +from copilot.generated.session_events import ( + PlanChangedOperation, + SessionPlanChangedData, + SessionTitleChangedData, +) +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +async def _wait_for( + predicate: Callable[[], Awaitable[bool]], + *, + timeout: float = 30.0, + message: str, +) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if await predicate(): + return + await asyncio.sleep(0.2) + pytest.fail(message) + + +class TestRpcEventLog: + async def test_should_read_persisted_events_from_beginning(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + await session.rpc.plan.update( + PlanUpdateRequest(content="# Event log E2E plan\n- persisted event") + ) + + observed = None + + async def has_plan_event() -> bool: + nonlocal observed + observed = await session.rpc.event_log.read(EventLogReadRequest(max=100, wait_ms=0)) + return any( + isinstance(evt.data, SessionPlanChangedData) + and evt.data.operation == PlanChangedOperation.CREATE + and evt.ephemeral is not True + for evt in observed.events + ) + + await _wait_for( + has_plan_event, + message="Timed out waiting for persisted session.plan_changed event.", + ) + + assert observed is not None + assert observed.cursor_status == EventsCursorStatus.OK + assert observed.cursor + assert any( + isinstance(evt.data, SessionPlanChangedData) + and evt.data.operation == PlanChangedOperation.CREATE + for evt in observed.events + ) + finally: + await session.disconnect() + + async def test_should_return_tail_cursor_and_read_empty_when_no_new_events( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + tail = await session.rpc.event_log.tail() + read = await session.rpc.event_log.read( + EventLogReadRequest(cursor=tail.cursor, max=10, wait_ms=0) + ) + + assert tail.cursor + assert read.cursor_status == EventsCursorStatus.OK + assert read.events == [] + assert read.has_more is False + finally: + await session.disconnect() + + async def test_should_register_and_release_event_interest_idempotently( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + registered = await session.rpc.event_log.register_interest( + RegisterEventInterestParams(event_type="session.title_changed") + ) + assert registered.handle + + released = await session.rpc.event_log.release_interest( + ReleaseEventInterestParams(handle=registered.handle) + ) + assert released.success is True + + released_again = await session.rpc.event_log.release_interest( + ReleaseEventInterestParams(handle=registered.handle) + ) + assert released_again.success is True + finally: + await session.disconnect() + + async def test_should_long_poll_with_types_filter_for_title_changed_event( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + expected_title = f"EventLogTitle-{uuid.uuid4().hex}" + tail = await session.rpc.event_log.tail() + read_task = asyncio.create_task( + session.rpc.event_log.read( + EventLogReadRequest( + cursor=tail.cursor, + max=10, + wait_ms=5000, + types=["session.title_changed"], + ) + ) + ) + + await session.rpc.name.set(NameSetRequest(name=expected_title)) + read = await asyncio.wait_for(read_task, timeout=10.0) + + assert read.cursor_status == EventsCursorStatus.OK + assert all(evt.type.value == "session.title_changed" for evt in read.events) + assert any( + isinstance(evt.data, SessionTitleChangedData) and evt.data.title == expected_title + for evt in read.events + ) + finally: + await session.disconnect() diff --git a/python/e2e/test_rpc_mcp_and_skills_e2e.py b/python/e2e/test_rpc_mcp_and_skills_e2e.py index dee98b1dd..06c66f9ce 100644 --- a/python/e2e/test_rpc_mcp_and_skills_e2e.py +++ b/python/e2e/test_rpc_mcp_and_skills_e2e.py @@ -19,11 +19,26 @@ from copilot.generated.rpc import ( ExtensionsDisableRequest, ExtensionsEnableRequest, + MCPAppsCallToolRequest, + MCPAppsDiagnoseRequest, + MCPAppsDisplayMode, + MCPAppsHostContextDetailsPlatform, + MCPAppsListToolsRequest, + MCPAppsReadResourceRequest, + MCPAppsSetHostContextDetails, + MCPAppsSetHostContextRequest, + MCPCancelSamplingExecutionParams, MCPDisableRequest, MCPEnableRequest, + MCPExecuteSamplingParams, + MCPRemoveGitHubResult, + MCPSamplingExecutionAction, McpServerStatus, + MCPSetEnvValueModeDetails, + MCPSetEnvValueModeParams, SkillsDisableRequest, SkillsEnableRequest, + Theme, ) from copilot.session import PermissionHandler @@ -117,6 +132,12 @@ async def _assert_failure(awaitable, expected: str) -> None: assert expected.lower() in str(excinfo.value).lower() +async def _assert_implemented_failure(awaitable, method: str) -> None: + with pytest.raises(Exception) as excinfo: + _ = await awaitable + assert f"unhandled method {method}".lower() not in str(excinfo.value).lower() + + class TestRpcMcpAndSkills: async def test_should_list_and_toggle_session_skills(self, ctx: E2ETestContext): skill_name = f"session-rpc-skill-{uuid.uuid4().hex}" @@ -165,6 +186,28 @@ async def test_should_reload_session_skills(self, ctx: E2ETestContext): finally: await session.disconnect() + async def test_should_ensure_skills_loaded_and_report_no_invoked_skills_for_fresh_session( + self, ctx: E2ETestContext + ): + skill_name = f"ensure-rpc-skill-{uuid.uuid4().hex}" + skills_dir = _create_skill_directory( + ctx.work_dir, skill_name, "Skill loaded explicitly by RPC." + ) + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + skill_directories=[skills_dir], + ) + try: + await session.rpc.skills.ensure_loaded() + listed = await session.rpc.skills.list() + _assert_skill(listed.skills, skill_name, enabled=True) + + invoked = await session.rpc.skills.get_invoked() + assert invoked.skills == [] + finally: + await session.disconnect() + async def test_should_list_mcp_servers_with_configured_server(self, ctx: E2ETestContext): server_name = "rpc-list-mcp-server" session = await ctx.client.create_session( @@ -243,3 +286,150 @@ async def test_should_report_error_when_extensions_are_not_available(self, ctx: ) finally: await session.disconnect() + + async def test_should_set_mcp_env_mode_remove_github_and_cancel_missing_sampling( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + indirect = await session.rpc.mcp.set_env_value_mode( + MCPSetEnvValueModeParams(mode=MCPSetEnvValueModeDetails.INDIRECT) + ) + assert indirect.mode == MCPSetEnvValueModeDetails.INDIRECT + + direct = await session.rpc.mcp.set_env_value_mode( + MCPSetEnvValueModeParams(mode=MCPSetEnvValueModeDetails.DIRECT) + ) + assert direct.mode == MCPSetEnvValueModeDetails.DIRECT + + removed = await session.rpc.mcp.remove_git_hub() + assert isinstance(removed, MCPRemoveGitHubResult) + assert removed.removed in (True, False) + + cancelled = await session.rpc.mcp.cancel_sampling_execution( + MCPCancelSamplingExecutionParams(request_id="missing-sampling-request") + ) + assert cancelled.cancelled is False + finally: + await session.disconnect() + + async def test_should_report_failure_or_implemented_error_for_missing_mcp_sampling( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + try: + result = await session.rpc.mcp.execute_sampling( + MCPExecuteSamplingParams( + mcp_request_id="mcp-sampling-e2e", + request={ + "messages": [ + { + "role": "user", + "content": {"type": "text", "text": "hello"}, + } + ], + "maxTokens": 16, + }, + request_id=f"sampling-{uuid.uuid4().hex}", + server_name="missing-server", + ) + ) + except Exception as exc: + assert "unhandled method session.mcp.executesampling" not in str(exc).lower() + else: + assert result.action == MCPSamplingExecutionAction.FAILURE + assert result.error + finally: + await session.disconnect() + + async def test_should_round_trip_mcp_apps_host_context_and_diagnose_shape( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + await session.rpc.mcp.apps.set_host_context( + MCPAppsSetHostContextRequest( + context=MCPAppsSetHostContextDetails( + available_display_modes=[ + MCPAppsDisplayMode.INLINE, + MCPAppsDisplayMode.FULLSCREEN, + ], + display_mode=MCPAppsDisplayMode.INLINE, + locale="en-US", + platform=MCPAppsHostContextDetailsPlatform.DESKTOP, + theme=Theme.DARK, + time_zone="Etc/UTC", + user_agent="python-sdk-e2e", + ) + ) + ) + + host_context = await session.rpc.mcp.apps.get_host_context() + assert host_context.context.display_mode == MCPAppsDisplayMode.INLINE + assert host_context.context.locale == "en-US" + assert host_context.context.platform == MCPAppsHostContextDetailsPlatform.DESKTOP + assert host_context.context.theme == Theme.DARK + assert host_context.context.time_zone == "Etc/UTC" + assert host_context.context.user_agent == "python-sdk-e2e" + assert MCPAppsDisplayMode.FULLSCREEN in ( + host_context.context.available_display_modes or [] + ) + + diagnose = await session.rpc.mcp.apps.diagnose( + MCPAppsDiagnoseRequest(server_name="missing-mcp-app-server") + ) + assert diagnose.capability.advertised in (True, False) + assert diagnose.capability.feature_flag_enabled in (True, False) + assert diagnose.capability.session_has_mcp_apps in (True, False) + assert diagnose.server.connected is False + assert diagnose.server.tool_count >= 0 + assert diagnose.server.tools_with_ui_meta >= 0 + assert diagnose.server.sample_tool_names is not None + finally: + await session.disconnect() + + async def test_should_report_implemented_errors_for_mcp_apps_without_capability( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + await _assert_implemented_failure( + session.rpc.mcp.apps.list_tools( + MCPAppsListToolsRequest( + origin_server_name="missing-server", + server_name="missing-server", + ) + ), + "session.mcp.apps.listTools", + ) + await _assert_implemented_failure( + session.rpc.mcp.apps.call_tool( + MCPAppsCallToolRequest( + origin_server_name="missing-server", + server_name="missing-server", + tool_name="missing-tool", + arguments={}, + ) + ), + "session.mcp.apps.callTool", + ) + await _assert_implemented_failure( + session.rpc.mcp.apps.read_resource( + MCPAppsReadResourceRequest( + server_name="missing-server", + uri="ui://missing/resource.html", + ) + ), + "session.mcp.apps.readResource", + ) + finally: + await session.disconnect() diff --git a/python/e2e/test_rpc_queue_e2e.py b/python/e2e/test_rpc_queue_e2e.py new file mode 100644 index 000000000..d15a7f413 --- /dev/null +++ b/python/e2e/test_rpc_queue_e2e.py @@ -0,0 +1,168 @@ +"""E2E coverage for session.queue RPC methods.""" + +from __future__ import annotations + +import asyncio +import time +import uuid + +import pytest + +from copilot.generated.rpc import ( + CommandsRespondToQueuedCommandRequest, + EnqueueCommandParams, + QueuedCommandHandled, + QueuePendingItems, + QueuePendingItemsKind, + RegisterEventInterestParams, + ReleaseEventInterestParams, +) +from copilot.generated.session_events import CommandQueuedData +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _is_pending_command(item: QueuePendingItems, command: str) -> bool: + return item.kind == QueuePendingItemsKind.COMMAND and ( + item.display_text == command or command.lstrip("/") in item.display_text + ) + + +async def _wait_for_command_in_pending_items(session, command: str) -> QueuePendingItems: + deadline = time.monotonic() + 30.0 + last_items = [] + while time.monotonic() < deadline: + pending = await session.rpc.queue.pending_items() + last_items = pending.items + for item in pending.items: + if _is_pending_command(item, command): + assert item.kind == QueuePendingItemsKind.COMMAND + assert command.lstrip("/") in item.display_text + return item + await asyncio.sleep(0.2) + pytest.fail(f"Timed out waiting for {command!r} in pending items: {last_items!r}") + + +async def _wait_for_command_not_in_pending_items(session, command: str) -> None: + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + pending = await session.rpc.queue.pending_items() + if not any(_is_pending_command(item, command) for item in pending.items): + return + await asyncio.sleep(0.2) + pytest.fail(f"Timed out waiting for {command!r} to leave pending items.") + + +async def _assert_queue_empty(session) -> None: + pending = await session.rpc.queue.pending_items() + assert pending.items == [] + assert pending.steering_messages == [] + + +class TestRpcQueue: + async def test_fresh_queue_is_empty_and_empty_mutations_are_noops(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + await _assert_queue_empty(session) + + remove = await session.rpc.queue.remove_most_recent() + assert remove.removed is False + await _assert_queue_empty(session) + + await session.rpc.queue.clear() + await _assert_queue_empty(session) + + remove_after_clear = await session.rpc.queue.remove_most_recent() + assert remove_after_clear.removed is False + finally: + await session.disconnect() + + async def test_pending_items_reports_queued_command_and_mutations_update_queue( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + interest = None + first_event = None + responded_to_first = False + try: + interest = await session.rpc.event_log.register_interest( + RegisterEventInterestParams(event_type="command.queued") + ) + + first_command = f"/sdk-queue-first-{uuid.uuid4().hex}" + second_command = f"/sdk-queue-second-{uuid.uuid4().hex}" + third_command = f"/sdk-queue-third-{uuid.uuid4().hex}" + first_queued: asyncio.Future = asyncio.get_event_loop().create_future() + + def on_event(event): + if ( + isinstance(event.data, CommandQueuedData) + and event.data.command == first_command + and not first_queued.done() + ): + first_queued.set_result(event) + + unsubscribe = session.on(on_event) + try: + first = await session.rpc.commands.enqueue( + EnqueueCommandParams(command=first_command) + ) + assert first.queued is True + first_event = await asyncio.wait_for(first_queued, timeout=30.0) + finally: + unsubscribe() + + second = await session.rpc.commands.enqueue( + EnqueueCommandParams(command=second_command) + ) + assert second.queued is True + await _wait_for_command_in_pending_items(session, second_command) + + remove = await session.rpc.queue.remove_most_recent() + assert remove.removed is True + await _wait_for_command_not_in_pending_items(session, second_command) + + third = await session.rpc.commands.enqueue(EnqueueCommandParams(command=third_command)) + assert third.queued is True + await _wait_for_command_in_pending_items(session, third_command) + + await session.rpc.queue.clear() + await _wait_for_command_not_in_pending_items(session, third_command) + + completed = await session.rpc.commands.respond_to_queued_command( + CommandsRespondToQueuedCommandRequest( + request_id=first_event.data.request_id, + result=QueuedCommandHandled(stop_processing_queue=True), + ) + ) + responded_to_first = completed.success + assert completed.success is True + + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + pending = await session.rpc.queue.pending_items() + if pending.items == [] and pending.steering_messages == []: + break + await asyncio.sleep(0.2) + await _assert_queue_empty(session) + finally: + if not responded_to_first and first_event is not None: + await session.rpc.commands.respond_to_queued_command( + CommandsRespondToQueuedCommandRequest( + request_id=first_event.data.request_id, + result=QueuedCommandHandled(stop_processing_queue=True), + ) + ) + await session.rpc.queue.clear() + if interest is not None and interest.handle: + await session.rpc.event_log.release_interest( + ReleaseEventInterestParams(handle=interest.handle) + ) + await session.disconnect() diff --git a/python/e2e/test_rpc_remote_e2e.py b/python/e2e/test_rpc_remote_e2e.py new file mode 100644 index 000000000..b2ccfc671 --- /dev/null +++ b/python/e2e/test_rpc_remote_e2e.py @@ -0,0 +1,95 @@ +"""E2E coverage for session.remote RPC methods.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from copilot.generated.rpc import ( + RemoteEnableRequest, + RemoteNotifySteerableChangedRequest, + RemoteSessionMode, + SessionsGetPersistedRemoteSteerableRequest, +) +from copilot.generated.session_events import SessionRemoteSteerableChangedData +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +async def _wait_for_remote_steerable_event(session, expected: bool) -> None: + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + events = await session.get_events() + if any( + isinstance(evt.data, SessionRemoteSteerableChangedData) + and evt.data.remote_steerable is expected + for evt in events + ): + return + await asyncio.sleep(0.2) + pytest.fail(f"Timed out waiting for session.remote_steerable_changed={expected}.") + + +def _assert_not_unhandled(exc: Exception, method: str) -> None: + assert f"unhandled method {method}".lower() not in str(exc).lower() + + +class TestRpcRemote: + async def test_remote_off_is_noop_or_implemented_error(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + try: + result = await session.rpc.remote.enable( + RemoteEnableRequest(mode=RemoteSessionMode.OFF) + ) + except Exception as exc: + _assert_not_unhandled(exc, "session.remote.enable") + else: + assert result.remote_steerable is False + assert not result.url + finally: + await session.disconnect() + + async def test_remote_disable_is_noop_or_implemented_error(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + try: + await session.rpc.remote.disable() + except Exception as exc: + _assert_not_unhandled(exc, "session.remote.disable") + finally: + await session.disconnect() + + async def test_notify_steerable_changed_event_and_persist_flag(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + await session.rpc.remote.notify_steerable_changed( + RemoteNotifySteerableChangedRequest(remote_steerable=True) + ) + await _wait_for_remote_steerable_event(session, True) + persisted = await ctx.client.rpc.sessions.get_persisted_remote_steerable( + SessionsGetPersistedRemoteSteerableRequest(session_id=session.session_id) + ) + assert persisted.remote_steerable is True + + await session.rpc.remote.notify_steerable_changed( + RemoteNotifySteerableChangedRequest(remote_steerable=False) + ) + await _wait_for_remote_steerable_event(session, False) + persisted = await ctx.client.rpc.sessions.get_persisted_remote_steerable( + SessionsGetPersistedRemoteSteerableRequest(session_id=session.session_id) + ) + assert persisted.remote_steerable is False + finally: + await session.disconnect() diff --git a/python/e2e/test_rpc_schedule_e2e.py b/python/e2e/test_rpc_schedule_e2e.py new file mode 100644 index 000000000..83244f9d9 --- /dev/null +++ b/python/e2e/test_rpc_schedule_e2e.py @@ -0,0 +1,37 @@ +"""E2E coverage for session.schedule RPC methods.""" + +from __future__ import annotations + +import pytest + +from copilot.generated.rpc import ScheduleStopRequest +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +class TestRpcSchedule: + async def test_should_list_no_schedules_for_fresh_session(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + result = await session.rpc.schedule.list() + assert result.entries == [] + finally: + await session.disconnect() + + async def test_should_return_null_entry_when_stopping_unknown_schedule( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + result = await session.rpc.schedule.stop(ScheduleStopRequest(id=2_147_483_647)) + assert result.entry is None + assert (await session.rpc.schedule.list()).entries == [] + finally: + await session.disconnect() diff --git a/python/e2e/test_rpc_server_e2e.py b/python/e2e/test_rpc_server_e2e.py index 481e50d7b..d65286444 100644 --- a/python/e2e/test_rpc_server_e2e.py +++ b/python/e2e/test_rpc_server_e2e.py @@ -8,6 +8,7 @@ import os import uuid +from datetime import UTC, datetime from pathlib import Path import pytest @@ -15,13 +16,38 @@ from copilot import CopilotClient, RuntimeConnection from copilot.generated.rpc import ( AccountGetQuotaRequest, + ConnectRemoteSessionParams, MCPDiscoverRequest, ModelsListRequest, PingRequest, + SecretsAddFilterValuesRequest, + SessionContext, + SessionFSSetProviderCapabilities, + SessionFSSetProviderConventions, + SessionFSSetProviderRequest, + SessionListFilter, + SessionMetadata, + SessionsBulkDeleteRequest, + SessionsCheckInUseRequest, + SessionsCloseRequest, + SessionsEnrichMetadataRequest, + SessionsFindByPrefixRequest, + SessionsFindByTaskIDRequest, + SessionsGetEventFilePathRequest, + SessionsGetLastForContextRequest, + SessionsGetPersistedRemoteSteerableRequest, + SessionsListRequest, + SessionsLoadDeferredRepoHooksRequest, + SessionsPruneOldRequest, + SessionsReleaseLockRequest, + SessionsReloadPluginHooksRequest, + SessionsSaveRequest, + SessionsSetAdditionalPluginsRequest, SkillsConfigSetDisabledSkillsRequest, SkillsDiscoverRequest, ToolsListRequest, ) +from copilot.session import PermissionHandler from .testharness import E2ETestContext @@ -62,6 +88,17 @@ def _make_authed_client(ctx: E2ETestContext, token: str) -> CopilotClient: ) +def _make_client_with_env(ctx: E2ETestContext, env_overrides: dict[str, str]) -> CopilotClient: + env = ctx.get_env() + env.update(env_overrides) + return CopilotClient( + connection=RuntimeConnection.for_stdio(path=ctx.cli_path), + working_directory=ctx.work_dir, + env=env, + github_token="fake-token-for-e2e-tests", + ) + + async def _configure_user( ctx: E2ETestContext, token: str, @@ -153,6 +190,246 @@ async def test_should_call_rpc_tools_list_with_typed_result(self, ctx: E2ETestCo assert len(result.tools) > 0 assert all((tool.name or "").strip() for tool in result.tools) + async def test_should_call_rpc_session_fs_set_provider_with_typed_result( + self, ctx: E2ETestContext + ): + client = _make_client_with_env(ctx, {}) + try: + await client.start() + result = await client.rpc.session_fs.set_provider( + SessionFSSetProviderRequest( + initial_cwd="/", + session_state_path="/session-state", + conventions=SessionFSSetProviderConventions.POSIX, + capabilities=SessionFSSetProviderCapabilities(sqlite=True), + ) + ) + assert result.success is True + finally: + try: + await client.stop() + except ExceptionGroup: + pass + + async def test_should_add_secret_filter_values(self, ctx: E2ETestContext): + client = _make_client_with_env(ctx, {"COPILOT_ENABLE_SECRET_FILTERING": "true"}) + try: + await client.start() + secret = f"rpc-secret-{uuid.uuid4().hex}" + result = await client.rpc.secrets.add_filter_values( + SecretsAddFilterValuesRequest(values=[secret]) + ) + assert result.ok is True + finally: + try: + await client.stop() + except ExceptionGroup: + pass + + async def test_should_list_find_and_inspect_persisted_session_state(self, ctx: E2ETestContext): + session_id = str(uuid.uuid4()) + working_directory = Path(ctx.work_dir) / f"server-rpc-list-{uuid.uuid4().hex}" + working_directory.mkdir(parents=True, exist_ok=True) + missing_task_id = f"missing-task-{uuid.uuid4().hex}" + missing_session_id = str(uuid.uuid4()) + + session = await ctx.client.create_session( + session_id=session_id, + working_directory=str(working_directory), + on_permission_request=PermissionHandler.approve_all, + ) + try: + await session.log("SERVER_RPC_LIST_READY") + save = await ctx.client.rpc.sessions.save(SessionsSaveRequest(session_id=session_id)) + assert save is not None + + event_path = await ctx.client.rpc.sessions.get_event_file_path( + SessionsGetEventFilePathRequest(session_id=session_id) + ) + assert event_path.file_path + assert os.path.isabs(event_path.file_path) + assert os.path.basename(event_path.file_path) == "events.jsonl" + assert session_id.lower() in event_path.file_path.lower() + + listed = await ctx.client.rpc.sessions.list( + SessionsListRequest( + filter=SessionListFilter(cwd=str(working_directory)), + metadata_limit=0, + ) + ) + assert listed.sessions is not None + assert all( + item.context is None + or os.path.normcase(os.path.abspath(item.context.cwd)) + == os.path.normcase(os.path.abspath(str(working_directory))) + for item in listed.sessions + ) + + by_prefix = await ctx.client.rpc.sessions.find_by_prefix( + SessionsFindByPrefixRequest(prefix=session_id[:8]) + ) + assert by_prefix.session_id in (None, session_id) + + by_task = await ctx.client.rpc.sessions.find_by_task_id( + SessionsFindByTaskIDRequest(task_id=missing_task_id) + ) + assert by_task.session_id is None + + last_for_context = await ctx.client.rpc.sessions.get_last_for_context( + SessionsGetLastForContextRequest(context=SessionContext(cwd=str(working_directory))) + ) + assert last_for_context.session_id in (None, session_id) + + sizes = await ctx.client.rpc.sessions.get_sizes() + assert sizes.sizes is not None + if session_id in sizes.sizes: + assert sizes.sizes[session_id] >= 0 + + in_use = await ctx.client.rpc.sessions.check_in_use( + SessionsCheckInUseRequest(session_ids=[session_id, missing_session_id]) + ) + assert missing_session_id not in in_use.in_use + + remote_steerable = await ctx.client.rpc.sessions.get_persisted_remote_steerable( + SessionsGetPersistedRemoteSteerableRequest(session_id=session_id) + ) + assert remote_steerable.remote_steerable is None + finally: + await session.disconnect() + + async def test_should_enrich_basic_session_metadata(self, ctx: E2ETestContext): + session_id = str(uuid.uuid4()) + working_directory = Path(ctx.work_dir) / f"server-rpc-enrich-{uuid.uuid4().hex}" + working_directory.mkdir(parents=True, exist_ok=True) + session = await ctx.client.create_session( + session_id=session_id, + working_directory=str(working_directory), + on_permission_request=PermissionHandler.approve_all, + ) + try: + await session.log("SERVER_RPC_ENRICH_READY") + await ctx.client.rpc.sessions.save(SessionsSaveRequest(session_id=session_id)) + + now = datetime.now(UTC).isoformat() + result = await ctx.client.rpc.sessions.enrich_metadata( + SessionsEnrichMetadataRequest( + sessions=[ + SessionMetadata( + is_remote=False, + modified_time=now, + session_id=session_id, + start_time=now, + name="Basic metadata", + context=SessionContext(cwd=str(working_directory)), + ) + ] + ) + ) + + assert len(result.sessions) == 1 + enriched = result.sessions[0] + assert enriched.session_id == session_id + assert enriched.is_remote is False + assert enriched.context is not None + assert os.path.normcase(os.path.abspath(enriched.context.cwd)) == os.path.normcase( + os.path.abspath(str(working_directory)) + ) + finally: + await session.disconnect() + + async def test_should_close_release_prune_and_bulk_delete_persisted_session( + self, ctx: E2ETestContext + ): + session_id = str(uuid.uuid4()) + missing_session_id = str(uuid.uuid4()) + working_directory = Path(ctx.work_dir) / f"server-rpc-delete-{uuid.uuid4().hex}" + working_directory.mkdir(parents=True, exist_ok=True) + + session = await ctx.client.create_session( + session_id=session_id, + working_directory=str(working_directory), + on_permission_request=PermissionHandler.approve_all, + ) + await session.log("SERVER_RPC_DELETE_READY") + await ctx.client.rpc.sessions.save(SessionsSaveRequest(session_id=session_id)) + await ctx.client.rpc.sessions.close(SessionsCloseRequest(session_id=session_id)) + release = await ctx.client.rpc.sessions.release_lock( + SessionsReleaseLockRequest(session_id=session_id) + ) + assert release is not None + + prune = await ctx.client.rpc.sessions.prune_old( + SessionsPruneOldRequest( + older_than_days=0, + dry_run=True, + include_named=True, + exclude_session_ids=[], + ) + ) + assert prune.dry_run is True + assert missing_session_id not in prune.candidates + assert session_id not in prune.deleted + assert prune.freed_bytes >= 0 + + deleted = await ctx.client.rpc.sessions.bulk_delete( + SessionsBulkDeleteRequest(session_ids=[session_id, missing_session_id]) + ) + assert session_id in deleted.freed_bytes + assert deleted.freed_bytes[session_id] >= 0 + if missing_session_id in deleted.freed_bytes: + assert deleted.freed_bytes[missing_session_id] == 0 + + listed = await ctx.client.rpc.sessions.list(SessionsListRequest()) + assert all(item.session_id != session_id for item in listed.sessions) + + async def test_should_report_implemented_error_when_connecting_unknown_remote_session( + self, ctx: E2ETestContext + ): + await ctx.client.start() + remote_session_id = f"remote-{uuid.uuid4().hex}" + with pytest.raises(Exception) as excinfo: + await ctx.client.rpc.sessions.connect( + ConnectRemoteSessionParams(session_id=remote_session_id) + ) + text = str(excinfo.value).lower() + assert "unhandled method sessions.connect" not in text + assert remote_session_id.lower() in text or "session" in text + + async def test_should_set_additional_plugins_and_reload_deferred_hooks( + self, ctx: E2ETestContext + ): + await ctx.client.start() + cleared = await ctx.client.rpc.sessions.set_additional_plugins( + SessionsSetAdditionalPluginsRequest(plugins=[]) + ) + assert cleared is not None + + session_id = str(uuid.uuid4()) + working_directory = Path(ctx.work_dir) / f"server-rpc-hooks-{uuid.uuid4().hex}" + working_directory.mkdir(parents=True, exist_ok=True) + session = await ctx.client.create_session( + session_id=session_id, + working_directory=str(working_directory), + on_permission_request=PermissionHandler.approve_all, + enable_config_discovery=False, + ) + try: + reload_result = await ctx.client.rpc.sessions.reload_plugin_hooks( + SessionsReloadPluginHooksRequest(session_id=session_id, defer_repo_hooks=True) + ) + assert reload_result is not None + + loaded = await ctx.client.rpc.sessions.load_deferred_repo_hooks( + SessionsLoadDeferredRepoHooksRequest(session_id=session_id) + ) + assert loaded.hook_count == 0 + assert loaded.startup_prompts == [] + finally: + await ctx.client.rpc.sessions.set_additional_plugins( + SessionsSetAdditionalPluginsRequest(plugins=[]) + ) + await session.disconnect() + async def test_should_discover_server_mcp_and_skills(self, ctx: E2ETestContext): await ctx.client.start() diff --git a/python/e2e/test_rpc_session_state_e2e.py b/python/e2e/test_rpc_session_state_e2e.py index f5b11f6fa..62e1c1105 100644 --- a/python/e2e/test_rpc_session_state_e2e.py +++ b/python/e2e/test_rpc_session_state_e2e.py @@ -7,22 +7,53 @@ from __future__ import annotations +import asyncio +import contextlib +import os +import time +import uuid +from pathlib import Path + import pytest from copilot.generated.rpc import ( + AuthInfoType, + CopilotUserResponse, + CopilotUserResponseEndpoints, HistoryTruncateRequest, + HostType, + LspInitializeRequest, MCPOauthLoginRequest, + MetadataContextInfoRequest, + MetadataRecomputeContextTokensRequest, + MetadataRecordContextChangeRequest, + MetadataSetWorkingDirectoryRequest, + ModelSetReasoningEffortRequest, ModelSwitchToRequest, ModeSetRequest, + NameSetAutoRequest, NameSetRequest, PermissionsSetApproveAllRequest, PlanUpdateRequest, SessionMode, + SessionSetCredentialsParams, SessionsForkRequest, + SessionUpdateOptionsParams, + SessionWorkingDirectoryContext, + ShutdownRequest, + ShutdownType, + TelemetrySetFeatureOverridesRequest, + UserAuthInfo, WorkspacesCreateFileRequest, WorkspacesReadFileRequest, ) -from copilot.generated.session_events import AssistantMessageData, UserMessageData +from copilot.generated.session_events import ( + AssistantMessageData, + SessionContextChangedData, + SessionShutdownData, + SessionTitleChangedData, + UserMessageData, +) from copilot.session import PermissionHandler from .testharness import E2ETestContext @@ -41,6 +72,27 @@ def _conversation_messages(events) -> list[tuple[str, str]]: return out +def _path_equals(expected: str, actual: str | None) -> bool: + if actual is None: + return False + return os.path.normcase(os.path.abspath(expected)) == os.path.normcase(os.path.abspath(actual)) + + +def _create_unique_directory(ctx: E2ETestContext, prefix: str) -> str: + path = Path(ctx.work_dir) / f"{prefix}-{uuid.uuid4().hex}" + path.mkdir(parents=True, exist_ok=True) + return str(path) + + +async def _wait_for(condition, *, timeout: float = 15.0, message: str): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if await condition(): + return + await asyncio.sleep(0.2) + pytest.fail(message) + + async def _assert_implemented_failure(awaitable, method: str) -> None: with pytest.raises(Exception) as excinfo: _ = await awaitable @@ -74,7 +126,8 @@ async def test_should_call_session_rpc_model_switch_to(self, ctx: E2ETestContext after = await session.rpc.model.get_current() assert result.model_id == "gpt-4.1" - # SwitchToAsync does not mutate session state — it only resolves the override. + # Python's current RPC surface resolves the requested override but does + # not mutate the live session model selection. assert after.model_id == before.model_id finally: await session.disconnect() @@ -95,6 +148,35 @@ async def test_should_get_and_set_session_mode(self, ctx: E2ETestContext): finally: await session.disconnect() + async def test_should_shutdown_session_with_routine_type(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + shutdown_future: asyncio.Future = asyncio.get_event_loop().create_future() + + def on_event(event): + if ( + isinstance(event.data, SessionShutdownData) + and event.data.shutdown_type == ShutdownType.ROUTINE + and not shutdown_future.done() + ): + shutdown_future.set_result(event) + + unsubscribe = session.on(on_event) + try: + await session.rpc.shutdown( + ShutdownRequest( + type=ShutdownType.ROUTINE, + reason="SDK E2E shutdown coverage", + ) + ) + shutdown = await asyncio.wait_for(shutdown_future, timeout=15.0) + assert shutdown.data.shutdown_type == ShutdownType.ROUTINE + finally: + unsubscribe() + with contextlib.suppress(Exception): + await session.disconnect() + async def test_should_read_update_and_delete_plan(self, ctx: E2ETestContext): session = await ctx.client.create_session( on_permission_request=PermissionHandler.approve_all, @@ -159,6 +241,222 @@ async def test_should_get_and_set_session_metadata(self, ctx: E2ETestContext): finally: await session.disconnect() + async def test_should_call_metadata_snapshot_set_working_directory_and_record_context_change( + self, ctx: E2ETestContext + ): + first_dir = _create_unique_directory(ctx, "metadata-first") + second_dir = _create_unique_directory(ctx, "metadata-second") + context_dir = _create_unique_directory(ctx, "metadata-context") + branch = f"rpc-context-{uuid.uuid4().hex}" + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="claude-sonnet-4.5", + working_directory=first_dir, + ) + try: + snapshot = await session.rpc.metadata.snapshot() + assert snapshot.session_id == session.session_id + assert snapshot.selected_model == "claude-sonnet-4.5" + assert snapshot.is_remote is False + assert snapshot.already_in_use is False + assert _path_equals(first_dir, snapshot.working_directory) + assert snapshot.workspace is not None + assert snapshot.workspace.id == session.session_id + assert snapshot.workspace_path + + set_result = await session.rpc.metadata.set_working_directory( + MetadataSetWorkingDirectoryRequest(working_directory=second_dir) + ) + assert _path_equals(second_dir, set_result.working_directory) + + async def snapshot_updated() -> bool: + current = await session.rpc.metadata.snapshot() + return _path_equals(second_dir, current.working_directory) + + await _wait_for( + snapshot_updated, + message="Timed out waiting for metadata snapshot cwd update.", + ) + + context_future: asyncio.Future = asyncio.get_event_loop().create_future() + + def on_event(event): + if ( + isinstance(event.data, SessionContextChangedData) + and event.data.branch == branch + and not context_future.done() + ): + context_future.set_result(event) + + unsubscribe = session.on(on_event) + try: + result = await session.rpc.metadata.record_context_change( + MetadataRecordContextChangeRequest( + context=SessionWorkingDirectoryContext( + cwd=context_dir, + git_root=first_dir, + branch=branch, + repository="github/copilot-sdk-e2e", + repository_host="github.com", + host_type=HostType.GITHUB, + base_commit="0" * 40, + head_commit="1" * 40, + ) + ) + ) + assert result is not None + + event = await asyncio.wait_for(context_future, timeout=15.0) + assert _path_equals(context_dir, event.data.cwd) + assert _path_equals(first_dir, event.data.git_root) + assert event.data.branch == branch + assert event.data.repository == "github/copilot-sdk-e2e" + assert event.data.repository_host == "github.com" + assert event.data.host_type.value == "github" + assert event.data.base_commit == "0" * 40 + assert event.data.head_commit == "1" * 40 + finally: + unsubscribe() + finally: + await session.disconnect() + + async def test_should_update_options_initialize_services_and_set_feature_overrides( + self, ctx: E2ETestContext + ): + initial_dir = _create_unique_directory(ctx, "options-initial") + options_dir = _create_unique_directory(ctx, "options-updated") + feature_name = f"rpc-session-state-{uuid.uuid4().hex}" + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=initial_dir, + ) + try: + update = await session.rpc.options.update( + SessionUpdateOptionsParams( + client_name="python-sdk-rpc-session-state-e2e", + lsp_client_name="python-sdk-rpc-session-state-lsp", + integration_id=f"python-sdk-{uuid.uuid4().hex}", + feature_flags={feature_name: True}, + working_directory=options_dir, + coauthor_enabled=False, + enable_streaming=False, + ask_user_disabled=True, + ) + ) + assert update.success is True + + async def snapshot_updated() -> bool: + snapshot = await session.rpc.metadata.snapshot() + return _path_equals(options_dir, snapshot.working_directory) + + await _wait_for( + snapshot_updated, + message="Timed out waiting for options.update cwd to reach metadata snapshot.", + ) + + await session.rpc.lsp.initialize( + LspInitializeRequest( + working_directory=options_dir, + git_root=initial_dir, + force=True, + ) + ) + await session.rpc.telemetry.set_feature_overrides( + TelemetrySetFeatureOverridesRequest( + features={ + "rpc_session_state_feature": feature_name, + "rpc_session_state_value": "enabled", + } + ) + ) + tools = await session.rpc.tools.initialize_and_validate() + assert tools is not None + finally: + await session.disconnect() + + async def test_should_set_reasoning_effort_and_auto_name(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + model="claude-sonnet-4.5", + ) + try: + reasoning = await session.rpc.model.set_reasoning_effort( + ModelSetReasoningEffortRequest(reasoning_effort="high") + ) + assert reasoning.reasoning_effort == "high" + current = await session.rpc.model.get_current() + assert current.model_id == "claude-sonnet-4.5" + assert current.reasoning_effort == "high" + + auto_name = f"Auto Session {uuid.uuid4().hex}" + title_future: asyncio.Future = asyncio.get_event_loop().create_future() + + def on_event(event): + if ( + isinstance(event.data, SessionTitleChangedData) + and event.data.title == auto_name + and not title_future.done() + ): + title_future.set_result(event) + + unsubscribe = session.on(on_event) + try: + auto = await session.rpc.name.set_auto( + NameSetAutoRequest(summary=f" {auto_name} ") + ) + assert auto.applied is True + await asyncio.wait_for(title_future, timeout=15.0) + finally: + unsubscribe() + + assert (await session.rpc.name.get()).name == auto_name + + explicit_name = f"Explicit Session {uuid.uuid4().hex}" + await session.rpc.name.set(NameSetRequest(name=explicit_name)) + ignored = await session.rpc.name.set_auto( + NameSetAutoRequest(summary=f"Ignored {uuid.uuid4().hex}") + ) + assert ignored.applied is False + assert (await session.rpc.name.get()).name == explicit_name + finally: + await session.disconnect() + + async def test_should_set_auth_credentials(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + login = f"sdk-rpc-{uuid.uuid4().hex}" + result = await session.rpc.auth.set_credentials( + SessionSetCredentialsParams( + credentials=UserAuthInfo( + host="https://github.com", + login=login, + copilot_user=CopilotUserResponse( + analytics_tracking_id="rpc-session-state-tracking-id", + chat_enabled=True, + copilot_plan="individual_pro", + endpoints=CopilotUserResponseEndpoints( + api=ctx.proxy_url, + telemetry="https://localhost:1/telemetry", + ), + login=login, + ), + ) + ) + ) + assert result.success is True + + status = await session.rpc.auth.get_status() + assert status.is_authenticated is True + assert status.auth_type == AuthInfoType.USER + assert status.host == "https://github.com" + assert status.login == login + finally: + await session.disconnect() + async def test_should_fork_session_with_persisted_messages(self, ctx: E2ETestContext): source_prompt = "Say FORK_SOURCE_ALPHA exactly." fork_prompt = "Now say FORK_CHILD_BETA exactly." @@ -266,6 +564,15 @@ async def test_should_call_session_usage_and_permission_rpcs(self, ctx: E2ETestC for detail in model_metric.token_details.values(): assert detail.token_count >= 0 + handoff = await session.rpc.history.summarize_for_handoff() + assert isinstance(handoff.summary, str) + + cancel_background = await session.rpc.history.cancel_background_compaction() + assert cancel_background.cancelled is False + + abort_manual = await session.rpc.history.abort_manual_compaction() + assert abort_manual.aborted is False + try: approve_all = await session.rpc.permissions.set_approve_all( PermissionsSetApproveAllRequest(enabled=True) @@ -304,7 +611,42 @@ async def test_should_compact_session_history_after_messages(self, ctx: E2ETestC on_permission_request=PermissionHandler.approve_all, ) try: + assert (await session.rpc.metadata.is_processing()).processing is False await session.send_and_wait("What is 2+2?", timeout=60.0) + assert (await session.rpc.metadata.is_processing()).processing is False + + context_info = await session.rpc.metadata.context_info( + MetadataContextInfoRequest( + prompt_token_limit=128_000, + output_token_limit=4_096, + selected_model="claude-sonnet-4.5", + ) + ) + if context_info.context_info is not None: + context = context_info.context_info + assert context.model_name == "claude-sonnet-4.5" + assert context.prompt_token_limit == 128_000 + assert context.limit >= context.prompt_token_limit + assert context.total_tokens > 0 + assert context.system_tokens > 0 + assert context.conversation_tokens > 0 + assert context.tool_definitions_tokens >= 0 + assert ( + context.system_tokens + + context.conversation_tokens + + context.tool_definitions_tokens + == context.total_tokens + ) + + recomputed = await session.rpc.metadata.recompute_context_tokens( + MetadataRecomputeContextTokensRequest(model_id="claude-sonnet-4.5") + ) + assert recomputed.system_token_count > 0 + assert recomputed.messages_token_count > 0 + assert recomputed.total_tokens == ( + recomputed.system_token_count + recomputed.messages_token_count + ) + result = await session.rpc.history.compact() assert result is not None assert result.success, "Expected History.compact() to report success=True" diff --git a/python/e2e/test_rpc_tasks_and_handlers_e2e.py b/python/e2e/test_rpc_tasks_and_handlers_e2e.py index 23b9f9896..75bf5c53e 100644 --- a/python/e2e/test_rpc_tasks_and_handlers_e2e.py +++ b/python/e2e/test_rpc_tasks_and_handlers_e2e.py @@ -22,12 +22,28 @@ PermissionDecisionReject, PermissionDecisionRequest, TasksCancelRequest, + TasksGetProgressRequest, TasksPromoteToBackgroundRequest, TasksRemoveRequest, + TasksSendMessageRequest, TasksStartAgentRequest, + UIAutoModeSwitchResponse, + UIElicitationRequest, UIElicitationResponse, UIElicitationResponseAction, + UIElicitationSchema, + UIElicitationSchemaProperty, + UIElicitationSchemaPropertyType, + UIElicitationSchemaType, + UIExitPlanModeAction, + UIExitPlanModeResponse, + UIHandlePendingAutoModeSwitchRequest, UIHandlePendingElicitationRequest, + UIHandlePendingExitPlanModeRequest, + UIHandlePendingSamplingRequest, + UIHandlePendingUserInputRequest, + UIUnregisterDirectAutoModeSwitchHandlerRequest, + UIUserInputResponse, ) from copilot.session import PermissionHandler @@ -81,6 +97,29 @@ async def test_should_list_task_state_and_return_false_for_missing_task_operatio remove = await session.rpc.tasks.remove(TasksRemoveRequest(id="missing-task")) assert remove.removed is False + + refresh = await session.rpc.tasks.refresh() + assert refresh is not None + + wait = await session.rpc.tasks.wait_for_pending() + assert wait is not None + + progress = await session.rpc.tasks.get_progress( + TasksGetProgressRequest(id="missing-task") + ) + assert progress.progress is None + + promotable = await session.rpc.tasks.get_current_promotable() + assert promotable.task is None + + promote_current = await session.rpc.tasks.promote_current_to_background() + assert promote_current.task is None + + send = await session.rpc.tasks.send_message( + TasksSendMessageRequest(id="missing-task", message="hello") + ) + assert send.sent is False + assert send.error finally: await session.disconnect() @@ -135,6 +174,41 @@ async def test_should_return_expected_results_for_missing_pending_handler_reques ) assert elicitation.success is False + user_input = await session.rpc.ui.handle_pending_user_input( + UIHandlePendingUserInputRequest( + request_id="missing-user-input-request", + response=UIUserInputResponse(answer="answer", was_freeform=True), + ) + ) + assert user_input.success is False + + sampling = await session.rpc.ui.handle_pending_sampling( + UIHandlePendingSamplingRequest( + request_id="missing-sampling-request", + response={"role": "assistant", "content": {"type": "text", "text": "hi"}}, + ) + ) + assert sampling.success is False + + auto_mode = await session.rpc.ui.handle_pending_auto_mode_switch( + UIHandlePendingAutoModeSwitchRequest( + request_id="missing-auto-mode-request", + response=UIAutoModeSwitchResponse.NO, + ) + ) + assert auto_mode.success is False + + exit_plan = await session.rpc.ui.handle_pending_exit_plan_mode( + UIHandlePendingExitPlanModeRequest( + request_id="missing-exit-plan-request", + response=UIExitPlanModeResponse( + approved=True, + selected_action=UIExitPlanModeAction.INTERACTIVE, + ), + ) + ) + assert exit_plan.success is False + permission = await session.rpc.permissions.handle_pending_permission_request( PermissionDecisionRequest( request_id="missing-permission-request", @@ -178,6 +252,57 @@ async def test_should_return_expected_results_for_missing_pending_handler_reques finally: await session.disconnect() + async def test_should_round_trip_rpc_ui_elicitation_and_direct_auto_mode_switch( + self, ctx: E2ETestContext + ): + seen_contexts = [] + + async def on_elicitation(context): + seen_contexts.append(context) + assert context["message"] == "Choose deployment" + schema = context["requestedSchema"] + assert schema["properties"]["environment"]["enum"] == ["staging", "production"] + return {"action": "accept", "content": {"environment": "staging"}} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + on_elicitation_request=on_elicitation, + ) + try: + response = await session.rpc.ui.elicitation( + UIElicitationRequest( + message="Choose deployment", + requested_schema=UIElicitationSchema( + type=UIElicitationSchemaType.OBJECT, + required=["environment"], + properties={ + "environment": UIElicitationSchemaProperty( + type=UIElicitationSchemaPropertyType.STRING, + enum=["staging", "production"], + ) + }, + ), + ) + ) + assert response.action == UIElicitationResponseAction.ACCEPT + assert response.content == {"environment": "staging"} + assert len(seen_contexts) == 1 + + registered = await session.rpc.ui.register_direct_auto_mode_switch_handler() + assert registered.handle + + unregistered = await session.rpc.ui.unregister_direct_auto_mode_switch_handler( + UIUnregisterDirectAutoModeSwitchHandlerRequest(handle=registered.handle) + ) + assert unregistered.unregistered is True + + unregistered_again = await session.rpc.ui.unregister_direct_auto_mode_switch_handler( + UIUnregisterDirectAutoModeSwitchHandlerRequest(handle=registered.handle) + ) + assert unregistered_again.unregistered is False + finally: + await session.disconnect() + async def test_should_report_implemented_error_for_invalid_task_agent_model( self, ctx: E2ETestContext ): diff --git a/python/e2e/test_rpc_workspace_checkpoints_e2e.py b/python/e2e/test_rpc_workspace_checkpoints_e2e.py new file mode 100644 index 000000000..82e419570 --- /dev/null +++ b/python/e2e/test_rpc_workspace_checkpoints_e2e.py @@ -0,0 +1,133 @@ +"""E2E coverage for workspace checkpoint, diff, and large-paste RPCs.""" + +from __future__ import annotations + +import subprocess +import uuid +from pathlib import Path + +import pytest + +from copilot.generated.rpc import ( + WorkspaceDiffFileChangeType, + WorkspaceDiffMode, + WorkspacesDiffRequest, + WorkspacesReadCheckpointRequest, + WorkspacesReadFileRequest, + WorkspacesSaveLargePasteRequest, +) +from copilot.session import PermissionHandler + +from .testharness import E2ETestContext + +pytestmark = pytest.mark.asyncio(loop_scope="module") + + +def _run_git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], + cwd=repo, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def _create_repo_with_unstaged_changes(work_dir: str) -> Path: + repo = Path(work_dir) / f"workspace-diff-{uuid.uuid4().hex}" + repo.mkdir(parents=True) + _run_git(repo, "init") + _run_git(repo, "config", "user.email", "copilot-sdk-e2e@example.com") + _run_git(repo, "config", "user.name", "Copilot SDK E2E") + + (repo / "tracked.txt").write_text("before\n", encoding="utf-8", newline="\n") + (repo / "removed.txt").write_text("remove me\n", encoding="utf-8", newline="\n") + _run_git(repo, "add", "tracked.txt", "removed.txt") + _run_git(repo, "commit", "-m", "initial") + + (repo / "tracked.txt").write_text("after\n", encoding="utf-8", newline="\n") + (repo / "removed.txt").unlink() + return repo + + +class TestRpcWorkspaceCheckpoints: + async def test_should_list_no_checkpoints_for_fresh_session(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + result = await session.rpc.workspaces.list_checkpoints() + assert result.checkpoints == [] + finally: + await session.disconnect() + + async def test_should_return_null_or_empty_content_for_unknown_checkpoint( + self, ctx: E2ETestContext + ): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + result = await session.rpc.workspaces.read_checkpoint( + WorkspacesReadCheckpointRequest(number=2_147_483_647) + ) + assert not result.content + finally: + await session.disconnect() + + async def test_should_return_typed_workspace_diff_result_for_real_changes( + self, ctx: E2ETestContext + ): + repo = _create_repo_with_unstaged_changes(ctx.work_dir) + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=str(repo), + ) + try: + result = await session.rpc.workspaces.diff( + WorkspacesDiffRequest(mode=WorkspaceDiffMode.UNSTAGED) + ) + + assert result.requested_mode == WorkspaceDiffMode.UNSTAGED + assert result.mode in (WorkspaceDiffMode.UNSTAGED, WorkspaceDiffMode.BRANCH) + by_path = {change.path.replace("\\", "/"): change for change in result.changes} + + tracked = by_path.get("tracked.txt") + assert tracked is not None + assert tracked.change_type == WorkspaceDiffFileChangeType.MODIFIED + assert "after" in tracked.diff + + removed = by_path.get("removed.txt") + assert removed is not None + assert removed.change_type == WorkspaceDiffFileChangeType.DELETED + assert "remove me" in removed.diff + finally: + await session.disconnect() + + async def test_should_save_large_paste_and_expose_readable_content(self, ctx: E2ETestContext): + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + try: + content = "Large paste payload 🚀\n" * 512 + result = await session.rpc.workspaces.save_large_paste( + WorkspacesSaveLargePasteRequest(content=content) + ) + saved = result.saved + + assert saved is not None + assert saved.filename + assert saved.file_path + assert saved.size_bytes == len(content.encode("utf-8")) + + try: + read = await session.rpc.workspaces.read_file( + WorkspacesReadFileRequest(path=saved.filename) + ) + except Exception: + assert Path(saved.file_path).exists() + assert Path(saved.file_path).read_text(encoding="utf-8") == content + else: + assert read.content == content + finally: + await session.disconnect() diff --git a/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index e2267913e..a5bfee28d 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -133,7 +133,9 @@ async def configure_for_test(self, test_file: str, test_name: str): # where files (e.g., SQLite session-store.db on Windows) may still be # held open by a background process during cleanup. for base_dir in (self.home_dir, self.work_dir): - for item in Path(base_dir).iterdir(): + base_path = Path(base_dir) + base_path.mkdir(parents=True, exist_ok=True) + for item in base_path.iterdir(): if item.is_dir(): shutil.rmtree(item, ignore_errors=True) else: diff --git a/rust/tests/e2e.rs b/rust/tests/e2e.rs index 09ece6cf5..415e3f7fa 100644 --- a/rust/tests/e2e.rs +++ b/rust/tests/e2e.rs @@ -51,12 +51,20 @@ mod pre_mcp_tool_call_hook; mod rpc_additional_edge_cases; #[path = "e2e/rpc_agent.rs"] mod rpc_agent; +#[path = "e2e/rpc_event_log.rs"] +mod rpc_event_log; #[path = "e2e/rpc_event_side_effects.rs"] mod rpc_event_side_effects; #[path = "e2e/rpc_mcp_and_skills.rs"] mod rpc_mcp_and_skills; #[path = "e2e/rpc_mcp_config.rs"] mod rpc_mcp_config; +#[path = "e2e/rpc_queue.rs"] +mod rpc_queue; +#[path = "e2e/rpc_remote.rs"] +mod rpc_remote; +#[path = "e2e/rpc_schedule.rs"] +mod rpc_schedule; #[path = "e2e/rpc_server.rs"] mod rpc_server; #[path = "e2e/rpc_session_state.rs"] @@ -67,6 +75,8 @@ mod rpc_shell_and_fleet; mod rpc_shell_edge_cases; #[path = "e2e/rpc_tasks_and_handlers.rs"] mod rpc_tasks_and_handlers; +#[path = "e2e/rpc_workspace_checkpoints.rs"] +mod rpc_workspace_checkpoints; #[path = "e2e/session.rs"] mod session; #[path = "e2e/session_config.rs"] diff --git a/rust/tests/e2e/commands.rs b/rust/tests/e2e/commands.rs index 8b1378917..fccd87bf6 100644 --- a/rust/tests/e2e/commands.rs +++ b/rust/tests/e2e/commands.rs @@ -1 +1,291 @@ +use std::sync::Arc; +use async_trait::async_trait; +use github_copilot_sdk::generated::api_types::{ + CommandsInvokeRequest, CommandsListRequest, CommandsRespondToQueuedCommandRequest, + EnqueueCommandParams, ExecuteCommandParams, RegisterEventInterestParams, + ReleaseEventInterestParams, SlashCommandInvocationResult, SlashCommandKind, +}; +use github_copilot_sdk::generated::session_events::{CommandQueuedData, SessionEventType}; +use github_copilot_sdk::{CommandContext, CommandDefinition, CommandHandler, RequestId}; +use serde_json::json; +use tokio::sync::mpsc; + +use super::support::{recv_with_timeout, wait_for_event, with_e2e_context}; + +#[tokio::test] +async fn session_commands_list_returns_builtins_and_respects_client_command_filter() { + with_e2e_context( + "commands", + "session_with_commands_creates_successfully", + |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().with_commands(vec![ + CommandDefinition::new("rust-e2e-command", Arc::new(NoopCommandHandler)) + .with_description("Rust E2E command"), + ])) + .await + .expect("create session"); + + let all = session + .rpc() + .commands() + .list() + .await + .expect("list commands"); + assert_command(&all.commands, "model", SlashCommandKind::Builtin); + assert_command(&all.commands, "compact", SlashCommandKind::Builtin); + assert_command(&all.commands, "context", SlashCommandKind::Builtin); + assert_command(&all.commands, "rust-e2e-command", SlashCommandKind::Client); + + let no_builtins = session + .rpc() + .commands() + .list_with_params(CommandsListRequest { + include_builtins: Some(false), + include_client_commands: Some(true), + include_skills: Some(false), + }) + .await + .expect("list without builtins"); + assert!( + !no_builtins + .commands + .iter() + .any(|command| command.kind == SlashCommandKind::Builtin) + ); + assert_command( + &no_builtins.commands, + "rust-e2e-command", + SlashCommandKind::Client, + ); + + let client_only_disabled = session + .rpc() + .commands() + .list_with_params(CommandsListRequest { + include_builtins: Some(false), + include_client_commands: Some(false), + include_skills: Some(false), + }) + .await + .expect("list with all dynamic sources disabled"); + assert!(client_only_disabled.commands.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_commands_invoke_known_builtin_returns_expected_result() { + with_e2e_context( + "commands", + "session_with_no_commands_creates_successfully", + |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() + .commands() + .invoke(CommandsInvokeRequest { + name: "context".to_string(), + input: None, + }) + .await + .expect("invoke context"); + match result { + SlashCommandInvocationResult::Text(text) => { + assert!(!text.text.trim().is_empty()); + } + SlashCommandInvocationResult::SelectSubcommand(select) => { + assert!(!select.options.is_empty()); + } + SlashCommandInvocationResult::AgentPrompt(prompt) => { + assert!(!prompt.prompt.trim().is_empty()); + } + SlashCommandInvocationResult::Completed(_) => {} + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_commands_execute_runs_registered_command_handler() { + with_e2e_context( + "commands", + "session_with_commands_creates_successfully", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_commands(vec![ + CommandDefinition::new( + "rust-execute", + Arc::new(RecordingCommandHandler { tx }), + ) + .with_description("Records command invocations"), + ])) + .await + .expect("create session"); + + let result = session + .rpc() + .commands() + .execute(ExecuteCommandParams { + command_name: "rust-execute".to_string(), + args: "alpha beta".to_string(), + }) + .await + .expect("execute command"); + assert!(result.error.is_none()); + + let context = recv_with_timeout(&mut rx, "command context").await; + assert_eq!(context.session_id, session.id().clone()); + assert_eq!(context.command_name, "rust-execute"); + assert_eq!(context.command, "/rust-execute alpha beta"); + assert_eq!(context.args, "alpha beta"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn session_commands_enqueue_and_respond_to_queued_command() { + with_e2e_context( + "commands", + "session_with_no_commands_creates_successfully", + |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 interest = session + .rpc() + .event_log() + .register_interest(RegisterEventInterestParams { + event_type: "command.queued".to_string(), + }) + .await + .expect("register command interest") + .handle; + let queued_event = wait_for_event(session.subscribe(), "command queued", |event| { + event.parsed_type() == SessionEventType::CommandQueued + }); + + let result = session + .rpc() + .commands() + .enqueue(EnqueueCommandParams { + command: "/help".to_string(), + }) + .await + .expect("enqueue command"); + assert!(result.queued); + + let queued = queued_event + .await + .typed_data::() + .expect("command queued data"); + assert_eq!(queued.command, "/help"); + let response = session + .rpc() + .commands() + .respond_to_queued_command(CommandsRespondToQueuedCommandRequest { + request_id: queued.request_id, + result: json!({ + "handled": true, + "stopProcessingQueue": true + }), + }) + .await + .expect("respond to queued command"); + assert!(response.success); + + let missing = session + .rpc() + .commands() + .respond_to_queued_command(CommandsRespondToQueuedCommandRequest { + request_id: RequestId::from("missing-command-request"), + result: json!({ + "handled": false, + "stopProcessingQueue": false + }), + }) + .await + .expect("respond to missing queued command"); + assert!(!missing.success); + session + .rpc() + .event_log() + .release_interest(ReleaseEventInterestParams { handle: interest }) + .await + .expect("release command interest"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +struct NoopCommandHandler; + +#[async_trait] +impl CommandHandler for NoopCommandHandler { + async fn on_command(&self, _ctx: CommandContext) -> Result<(), github_copilot_sdk::Error> { + Ok(()) + } +} + +struct RecordingCommandHandler { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl CommandHandler for RecordingCommandHandler { + async fn on_command(&self, ctx: CommandContext) -> Result<(), github_copilot_sdk::Error> { + self.tx.send(ctx).expect("record command context"); + Ok(()) + } +} + +fn assert_command( + commands: &[github_copilot_sdk::generated::api_types::SlashCommandInfo], + name: &str, + kind: SlashCommandKind, +) { + let command = commands + .iter() + .find(|command| command.name == name) + .unwrap_or_else(|| panic!("missing command {name}; actual commands: {commands:?}")); + assert_eq!(command.kind, kind); + assert!(!command.description.trim().is_empty()); +} diff --git a/rust/tests/e2e/compaction.rs b/rust/tests/e2e/compaction.rs index 8b1378917..6548e13c2 100644 --- a/rust/tests/e2e/compaction.rs +++ b/rust/tests/e2e/compaction.rs @@ -1 +1,113 @@ +use github_copilot_sdk::generated::api_types::{LogRequest, SessionLogLevel}; +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_return_empty_handoff_summary_for_fresh_session() { + with_e2e_context( + "compaction", + "should_not_emit_compaction_events_when_infinite_sessions_disabled", + |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 summary = session + .rpc() + .history() + .summarize_for_handoff() + .await + .expect("summarize fresh session"); + assert!(summary.summary.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_noop_when_cancelling_compaction_without_inflight_work() { + with_e2e_context( + "compaction", + "should_not_emit_compaction_events_when_infinite_sessions_disabled", + |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 cancelled = session + .rpc() + .history() + .cancel_background_compaction() + .await + .expect("cancel background compaction"); + assert!(!cancelled.cancelled); + let aborted = session + .rpc() + .history() + .abort_manual_compaction() + .await + .expect("abort manual compaction"); + assert!(!aborted.aborted); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_summarize_for_handoff_after_non_ephemeral_log_event() { + with_e2e_context( + "compaction", + "should_not_emit_compaction_events_when_infinite_sessions_disabled", + |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 log = session + .rpc() + .log(LogRequest { + ephemeral: Some(false), + level: Some(SessionLogLevel::Info), + message: "Rust handoff summary source".to_string(), + tip: None, + r#type: Some("notification".to_string()), + url: None, + }) + .await + .expect("log handoff source"); + assert!(!log.event_id.trim().is_empty()); + let summary = session + .rpc() + .history() + .summarize_for_handoff() + .await + .expect("summarize after log"); + assert!(summary.summary.is_empty() || summary.summary.contains("Rust")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_event_log.rs b/rust/tests/e2e/rpc_event_log.rs new file mode 100644 index 000000000..a2c035f86 --- /dev/null +++ b/rust/tests/e2e/rpc_event_log.rs @@ -0,0 +1,210 @@ +use github_copilot_sdk::generated::api_types::{ + EventLogReadRequest, EventsCursorStatus, RegisterEventInterestParams, + ReleaseEventInterestParams, +}; +use github_copilot_sdk::generated::session_events::{ + PlanChangedOperation, SessionEventType, SessionPlanChangedData, SessionTitleChangedData, +}; +use serde_json::json; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_read_persisted_events_from_beginning() { + with_e2e_context( + "rpc_event_log", + "should_read_persisted_events_from_beginning", + |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() + .plan() + .update( + github_copilot_sdk::generated::api_types::PlanUpdateRequest { + content: "# event log plan".to_string(), + }, + ) + .await + .expect("write plan"); + client + .rpc() + .sessions() + .save( + github_copilot_sdk::generated::api_types::SessionsSaveRequest { + session_id: session.id().clone(), + }, + ) + .await + .expect("save session"); + + let read = session + .rpc() + .event_log() + .read(EventLogReadRequest { + agent_scope: None, + cursor: None, + max: Some(100), + types: Some(json!("*")), + wait_ms: Some(0), + }) + .await + .expect("read event log"); + assert_eq!(read.cursor_status, EventsCursorStatus::Ok); + assert!(!read.cursor.trim().is_empty()); + assert!(read.events.iter().any(|event| { + event.parsed_type() == SessionEventType::SessionPlanChanged + && event + .typed_data::() + .is_some_and(|data| data.operation == PlanChangedOperation::Create) + })); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_tail_cursor_and_read_empty_when_no_new_events() { + with_e2e_context( + "rpc_event_log", + "should_return_tail_cursor_and_read_empty_when_no_new_events", + |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 tail = session.rpc().event_log().tail().await.expect("tail"); + assert!(!tail.cursor.trim().is_empty()); + let read = session + .rpc() + .event_log() + .read(EventLogReadRequest { + agent_scope: None, + cursor: Some(tail.cursor), + max: Some(10), + types: Some(json!("*")), + wait_ms: Some(0), + }) + .await + .expect("read from tail"); + assert_eq!(read.cursor_status, EventsCursorStatus::Ok); + assert!(read.events.is_empty()); + assert!(!read.has_more); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_register_and_release_event_interest_idempotently() { + with_e2e_context( + "rpc_event_log", + "should_register_and_release_event_interest_idempotently", + |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 handle = session + .rpc() + .event_log() + .register_interest(RegisterEventInterestParams { + event_type: "session.title_changed".to_string(), + }) + .await + .expect("register interest") + .handle; + assert!(!handle.trim().is_empty()); + for _ in 0..2 { + assert!( + session + .rpc() + .event_log() + .release_interest(ReleaseEventInterestParams { + handle: handle.clone(), + }) + .await + .expect("release interest") + .success + ); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_longpoll_with_types_filter_for_titlechanged_event() { + with_e2e_context( + "rpc_event_log", + "should_longpoll_with_types_filter_for_titlechanged_event", + |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 tail = session.rpc().event_log().tail().await.expect("tail"); + let event_log = session.rpc().event_log(); + let read_future = event_log.read(EventLogReadRequest { + agent_scope: None, + cursor: Some(tail.cursor), + max: Some(10), + types: Some(json!(["session.title_changed"])), + wait_ms: Some(5_000), + }); + let write_future = async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + session + .rpc() + .name() + .set(github_copilot_sdk::generated::api_types::NameSetRequest { + name: "Rust event log title".to_string(), + }) + .await + .expect("set title"); + }; + let (read, _) = tokio::join!(read_future, write_future); + let read = read.expect("long-poll event log"); + assert_eq!(read.cursor_status, EventsCursorStatus::Ok); + assert!(read.events.iter().any(|event| { + event.parsed_type() == SessionEventType::SessionTitleChanged + && event + .typed_data::() + .is_some_and(|data| data.title == "Rust event log title") + })); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_mcp_and_skills.rs b/rust/tests/e2e/rpc_mcp_and_skills.rs index 60493d6fb..35e38072f 100644 --- a/rust/tests/e2e/rpc_mcp_and_skills.rs +++ b/rust/tests/e2e/rpc_mcp_and_skills.rs @@ -2,8 +2,15 @@ use std::collections::HashMap; use std::path::Path; use github_copilot_sdk::generated::api_types::{ - ExtensionsDisableRequest, ExtensionsEnableRequest, McpDisableRequest, McpEnableRequest, - McpOauthLoginRequest, SkillsDisableRequest, SkillsEnableRequest, + ExtensionsDisableRequest, ExtensionsEnableRequest, McpAppsCallToolRequest, + McpAppsDiagnoseRequest, McpAppsListToolsRequest, McpAppsReadResourceRequest, + McpAppsSetHostContextDetails, McpAppsSetHostContextDetailsAvailableDisplayMode, + McpAppsSetHostContextDetailsDisplayMode, McpAppsSetHostContextDetailsPlatform, + McpAppsSetHostContextDetailsTheme, McpAppsSetHostContextRequest, + McpCancelSamplingExecutionParams, McpDisableRequest, McpEnableRequest, + McpExecuteSamplingParams, McpExecuteSamplingRequest, McpOauthLoginRequest, + McpSamplingExecutionAction, McpSetEnvValueModeDetails, McpSetEnvValueModeParams, + SkillsDisableRequest, SkillsEnableRequest, }; use github_copilot_sdk::{McpServerConfig, McpStdioServerConfig}; @@ -78,6 +85,56 @@ async fn should_list_and_toggle_session_skills() { .await; } +#[tokio::test] +async fn should_ensure_skills_are_loaded_and_list_invoked_skills() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_ensure_skills_are_loaded_and_list_invoked_skills", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let skill_name = "ensure-loaded-rpc-skill-rust"; + let skills_dir = create_skill_directory( + ctx.work_dir(), + skill_name, + "Skill available to ensureLoaded tests.", + ); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_skill_directories([skills_dir]), + ) + .await + .expect("create session"); + + session + .rpc() + .skills() + .ensure_loaded() + .await + .expect("ensure loaded"); + assert_skill( + session.rpc().skills().list().await.expect("list skills"), + skill_name, + true, + ); + let invoked = session + .rpc() + .skills() + .get_invoked() + .await + .expect("get invoked skills"); + assert!(invoked.skills.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_reload_session_skills() { with_e2e_context( @@ -158,6 +215,101 @@ async fn should_list_mcp_servers_with_configured_server() { .await; } +#[tokio::test] +async fn should_set_mcp_env_value_mode_and_remove_github_server() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_set_mcp_env_value_mode_and_remove_github_server", + |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 mode = session + .rpc() + .mcp() + .set_env_value_mode(McpSetEnvValueModeParams { + mode: McpSetEnvValueModeDetails::Direct, + }) + .await + .expect("set env value mode"); + assert_eq!(mode.mode, McpSetEnvValueModeDetails::Direct); + let removed = session + .rpc() + .mcp() + .remove_git_hub() + .await + .expect("remove github mcp"); + assert!(!removed.removed); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_mcp_sampling_failure_and_cancel_missing_sampling() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_report_mcp_sampling_failure_and_cancel_missing_sampling", + |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"); + + assert!( + !session + .rpc() + .mcp() + .cancel_sampling_execution(McpCancelSamplingExecutionParams { + request_id: "missing-sampling".into(), + }) + .await + .expect("cancel missing sampling") + .cancelled + ); + match session + .rpc() + .mcp() + .execute_sampling(McpExecuteSamplingParams { + mcp_request_id: serde_json::json!("sampling-request"), + request: McpExecuteSamplingRequest {}, + request_id: "sampling-request".into(), + server_name: "missing-server".to_string(), + }) + .await + { + Ok(result) => { + assert_ne!(result.action, McpSamplingExecutionAction::Success); + assert!(result.result.is_none()); + } + Err(err) => { + assert!( + !err.to_string() + .contains("Unhandled method session.mcp.executeSampling") + ); + } + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_list_plugins() { with_e2e_context("rpc_mcp_and_skills", "should_list_plugins", |ctx| { @@ -219,6 +371,166 @@ async fn should_list_extensions() { .await; } +#[tokio::test] +async fn should_round_trip_mcp_app_host_context() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_round_trip_mcp_app_host_context", + |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() + .mcp() + .apps() + .set_host_context(McpAppsSetHostContextRequest { + context: McpAppsSetHostContextDetails { + available_display_modes: vec![ + McpAppsSetHostContextDetailsAvailableDisplayMode::Inline, + McpAppsSetHostContextDetailsAvailableDisplayMode::Fullscreen, + ], + display_mode: Some(McpAppsSetHostContextDetailsDisplayMode::Inline), + locale: Some("en-US".to_string()), + platform: Some(McpAppsSetHostContextDetailsPlatform::Desktop), + theme: Some(McpAppsSetHostContextDetailsTheme::Dark), + time_zone: Some("Etc/UTC".to_string()), + user_agent: Some("rust-e2e".to_string()), + }, + }) + .await + .expect("set host context"); + let context = session + .rpc() + .mcp() + .apps() + .get_host_context() + .await + .expect("get host context") + .context; + assert_eq!(context.locale.as_deref(), Some("en-US")); + assert_eq!(context.time_zone.as_deref(), Some("Etc/UTC")); + assert_eq!(context.user_agent.as_deref(), Some("rust-e2e")); + assert_eq!(context.available_display_modes.len(), 2); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_diagnose_and_report_mcp_app_capability_errors() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_diagnose_and_report_mcp_app_capability_errors", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let server_name = "missing-app-server"; + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let diagnose = session + .rpc() + .mcp() + .apps() + .diagnose(McpAppsDiagnoseRequest { + server_name: server_name.to_string(), + }) + .await + .expect("diagnose mcp apps"); + assert!(!diagnose.server.connected); + assert_eq!(diagnose.server.tool_count, 0.0); + assert!(diagnose.server.sample_tool_names.is_empty()); + let _capability = diagnose.capability; + + expect_err_contains( + session + .rpc() + .mcp() + .apps() + .list_tools(McpAppsListToolsRequest { + server_name: server_name.to_string(), + origin_server_name: server_name.to_string(), + }), + "mcp", + ) + .await; + expect_err_contains( + session + .rpc() + .mcp() + .apps() + .call_tool(McpAppsCallToolRequest { + arguments: HashMap::new(), + server_name: server_name.to_string(), + origin_server_name: server_name.to_string(), + tool_name: "missing-tool".to_string(), + }), + "mcp", + ) + .await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_mcp_app_resource_is_not_available() { + with_e2e_context( + "rpc_mcp_and_skills", + "should_report_error_when_mcp_app_resource_is_not_available", + |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() + .mcp() + .apps() + .read_resource(McpAppsReadResourceRequest { + server_name: "missing-app-server".to_string(), + uri: "ui://missing/resource.html".to_string(), + }) + .await + .expect_err("missing resource should fail"); + let message = err.to_string().to_ascii_lowercase(); + assert!( + message.contains("resource") + || message.contains("not found") + || message.contains("method not found") + || message.contains("mcp"), + "unexpected readResource error: {err}" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_report_error_when_mcp_host_is_not_initialized() { with_e2e_context( diff --git a/rust/tests/e2e/rpc_queue.rs b/rust/tests/e2e/rpc_queue.rs new file mode 100644 index 000000000..b5768045f --- /dev/null +++ b/rust/tests/e2e/rpc_queue.rs @@ -0,0 +1,161 @@ +use github_copilot_sdk::generated::api_types::{ + CommandsRespondToQueuedCommandRequest, EnqueueCommandParams, RegisterEventInterestParams, + ReleaseEventInterestParams, +}; +use github_copilot_sdk::generated::session_events::{CommandQueuedData, SessionEventType}; +use serde_json::json; + +use super::support::{wait_for_event, with_e2e_context}; + +#[tokio::test] +async fn fresh_queue_is_empty_and_empty_mutations_are_noops() { + with_e2e_context( + "rpc_queue", + "fresh_queue_is_empty_and_empty_mutations_are_noops", + |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 pending = session + .rpc() + .queue() + .pending_items() + .await + .expect("pending items"); + assert!(pending.items.is_empty()); + assert!(pending.steering_messages.is_empty()); + assert!( + !session + .rpc() + .queue() + .remove_most_recent() + .await + .expect("remove most recent") + .removed + ); + session.rpc().queue().clear().await.expect("clear queue"); + let after = session + .rpc() + .queue() + .pending_items() + .await + .expect("pending after clear"); + assert!(after.items.is_empty()); + assert!(after.steering_messages.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn pendingitems_reports_queued_command_and_remove_and_clear_update_queue() { + with_e2e_context( + "rpc_queue", + "pendingitems_reports_queued_command_and_remove_and_clear_update_queue", + |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 interest = session + .rpc() + .event_log() + .register_interest(RegisterEventInterestParams { + event_type: "command.queued".to_string(), + }) + .await + .expect("register command interest") + .handle; + let queued_event = wait_for_event(session.subscribe(), "command queued", |event| { + event.parsed_type() == SessionEventType::CommandQueued + }); + + let enqueue = session + .rpc() + .commands() + .enqueue(EnqueueCommandParams { + command: "/help".to_string(), + }) + .await + .expect("enqueue command"); + assert!(enqueue.queued); + let queued = queued_event + .await + .typed_data::() + .expect("command queued data"); + + let pending = session + .rpc() + .queue() + .pending_items() + .await + .expect("pending queued command"); + assert!(pending.items.is_empty()); + assert!(pending.steering_messages.is_empty()); + let removed = session + .rpc() + .queue() + .remove_most_recent() + .await + .expect("remove after command event"); + assert!(!removed.removed); + assert!( + session + .rpc() + .queue() + .pending_items() + .await + .expect("pending after remove") + .items + .is_empty() + ); + session + .rpc() + .commands() + .respond_to_queued_command(CommandsRespondToQueuedCommandRequest { + request_id: queued.request_id, + result: json!({ + "handled": true, + "stopProcessingQueue": true + }), + }) + .await + .expect("respond to removed command"); + + session.rpc().queue().clear().await.expect("clear queue"); + assert!( + session + .rpc() + .queue() + .pending_items() + .await + .expect("pending after clear") + .items + .is_empty() + ); + session + .rpc() + .event_log() + .release_interest(ReleaseEventInterestParams { handle: interest }) + .await + .expect("release command interest"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_remote.rs b/rust/tests/e2e/rpc_remote.rs new file mode 100644 index 000000000..d6a8e35fe --- /dev/null +++ b/rust/tests/e2e/rpc_remote.rs @@ -0,0 +1,126 @@ +use github_copilot_sdk::generated::api_types::{ + RemoteEnableRequest, RemoteSessionMode, SessionsGetPersistedRemoteSteerableRequest, +}; +use github_copilot_sdk::generated::session_events::{ + SessionEventType, SessionRemoteSteerableChangedData, +}; + +use super::support::{wait_for_event, with_e2e_context}; + +#[tokio::test] +async fn should_treat_remote_off_as_noop_or_implemented_error() { + with_e2e_context( + "rpc_remote", + "should_treat_remote_off_as_noop_or_implemented_error", + |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"); + + match session + .rpc() + .remote() + .enable(RemoteEnableRequest { + mode: Some(RemoteSessionMode::Off), + }) + .await + { + Ok(result) => { + assert!(!result.remote_steerable); + assert!(result.url.as_deref().unwrap_or_default().is_empty()); + } + Err(err) => assert!( + !err.to_string() + .contains("Unhandled method session.remote.enable") + ), + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_treat_remote_disable_as_noop_or_implemented_error() { + with_e2e_context( + "rpc_remote", + "should_treat_remote_disable_as_noop_or_implemented_error", + |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"); + + if let Err(err) = session.rpc().remote().disable().await { + assert!( + !err.to_string() + .contains("Unhandled method session.remote.disable") + ); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_notify_steerable_changed_event_and_persist_flag() { + with_e2e_context( + "rpc_remote", + "should_notify_steerable_changed_event_and_persist_flag", + |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 changed = wait_for_event(session.subscribe(), "remote steerable changed", |event| { + event.parsed_type() == SessionEventType::SessionRemoteSteerableChanged + && event + .typed_data::() + .is_some_and(|data| data.remote_steerable) + }); + + session + .rpc() + .remote() + .notify_steerable_changed( + github_copilot_sdk::generated::api_types::RemoteNotifySteerableChangedRequest { + remote_steerable: true, + }, + ) + .await + .expect("notify remote steerable"); + changed.await; + let persisted = client + .rpc() + .sessions() + .get_persisted_remote_steerable(SessionsGetPersistedRemoteSteerableRequest { + session_id: session.id().clone(), + }) + .await + .expect("persisted remote steerable"); + assert_eq!(persisted.remote_steerable, Some(true)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_schedule.rs b/rust/tests/e2e/rpc_schedule.rs new file mode 100644 index 000000000..32807958e --- /dev/null +++ b/rust/tests/e2e/rpc_schedule.rs @@ -0,0 +1,73 @@ +use github_copilot_sdk::generated::api_types::ScheduleStopRequest; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_list_no_schedules_for_fresh_session() { + with_e2e_context( + "rpc_schedule", + "should_list_no_schedules_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 schedules = session + .rpc() + .schedule() + .list() + .await + .expect("list schedules"); + assert!(schedules.entries.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_null_entry_when_stopping_unknown_schedule() { + with_e2e_context( + "rpc_schedule", + "should_return_null_entry_when_stopping_unknown_schedule", + |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 stopped = session + .rpc() + .schedule() + .stop(ScheduleStopRequest { id: i64::MAX }) + .await + .expect("stop missing schedule"); + assert!(stopped.entry.is_none()); + assert!( + session + .rpc() + .schedule() + .list() + .await + .expect("list schedules") + .entries + .is_empty() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} diff --git a/rust/tests/e2e/rpc_server.rs b/rust/tests/e2e/rpc_server.rs index b10f4ad96..d89d69458 100644 --- a/rust/tests/e2e/rpc_server.rs +++ b/rust/tests/e2e/rpc_server.rs @@ -1,7 +1,15 @@ use github_copilot_sdk::Client; use github_copilot_sdk::generated::api_types::{ - McpDiscoverRequest, PingRequest, SkillsConfigSetDisabledSkillsRequest, SkillsDiscoverRequest, - ToolsListRequest, + ConnectRemoteSessionParams, McpDiscoverRequest, NameSetRequest, PingRequest, + SecretsAddFilterValuesRequest, SessionContext, SessionFsSetProviderConventions, + SessionFsSetProviderRequest, SessionListFilter, SessionMetadata, SessionsBulkDeleteRequest, + SessionsCheckInUseRequest, SessionsCloseRequest, SessionsEnrichMetadataRequest, + SessionsFindByPrefixRequest, SessionsFindByTaskIDRequest, SessionsGetEventFilePathRequest, + SessionsGetLastForContextRequest, SessionsGetPersistedRemoteSteerableRequest, + SessionsListRequest, SessionsLoadDeferredRepoHooksRequest, SessionsPruneOldRequest, + SessionsReleaseLockRequest, SessionsReloadPluginHooksRequest, SessionsSaveRequest, + SessionsSetAdditionalPluginsRequest, SkillsConfigSetDisabledSkillsRequest, + SkillsDiscoverRequest, ToolsListRequest, }; use serde_json::json; @@ -205,6 +213,469 @@ async fn should_discover_server_mcp_and_skills() { .await; } +#[tokio::test] +async fn should_call_rpc_sessionfs_setprovider_with_typed_result() { + with_e2e_context( + "rpc_server", + "should_call_rpc_sessionfs_setprovider_with_typed_result", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .session_fs() + .set_provider(SessionFsSetProviderRequest { + capabilities: None, + conventions: if cfg!(windows) { + SessionFsSetProviderConventions::Windows + } else { + SessionFsSetProviderConventions::Posix + }, + initial_cwd: ctx.work_dir().display().to_string(), + session_state_path: ctx + .work_dir() + .join("session-state") + .display() + .to_string(), + }) + .await + .expect("set session fs provider"); + assert!(result.success); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_add_secret_filter_values() { + with_e2e_context("rpc_server", "should_add_secret_filter_values", |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let result = client + .rpc() + .secrets() + .add_filter_values(SecretsAddFilterValuesRequest { + values: vec!["rust-secret-value".to_string()], + }) + .await; + match result { + Ok(result) => assert!(result.ok), + Err(err) => { + let message = err.to_string(); + assert!(message.contains("COPILOT_ENABLE_SECRET_FILTERING")); + assert!(!message.contains("Unhandled method secrets.addFilterValues")); + } + } + + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_list_find_and_inspect_persisted_session_state() { + with_e2e_context( + "rpc_server", + "should_list_find_and_inspect_persisted_session_state", + |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() + .name() + .set(NameSetRequest { + name: "Rust persisted session".to_string(), + }) + .await + .expect("set session name"); + let session_id = session.id().clone(); + client + .rpc() + .sessions() + .save(SessionsSaveRequest { + session_id: session_id.clone(), + }) + .await + .expect("save session"); + session.disconnect().await.expect("disconnect session"); + + let list = client.rpc().sessions().list().await.expect("list sessions"); + assert!( + list.sessions + .iter() + .all(|metadata| !metadata.session_id.as_str().is_empty()) + ); + let filtered = client + .rpc() + .sessions() + .list_with_params(SessionsListRequest { + filter: Some(SessionListFilter { + cwd: Some(ctx.work_dir().display().to_string()), + branch: None, + git_root: None, + repository: None, + }), + metadata_limit: Some(10), + }) + .await + .expect("filtered sessions"); + assert!(filtered.sessions.iter().all(|metadata| { + metadata + .context + .as_ref() + .is_none_or(|context| context.cwd == ctx.work_dir().display().to_string()) + })); + assert!( + client + .rpc() + .sessions() + .find_by_prefix(SessionsFindByPrefixRequest { + prefix: "0000000".to_string(), + }) + .await + .expect("find missing prefix") + .session_id + .is_none() + ); + assert!( + client + .rpc() + .sessions() + .find_by_task_id(SessionsFindByTaskIDRequest { + task_id: "missing-rust-task".to_string(), + }) + .await + .expect("find by task id") + .session_id + .is_none() + ); + client + .rpc() + .sessions() + .get_last_for_context(SessionsGetLastForContextRequest { context: None }) + .await + .expect("last for context"); + assert!( + client + .rpc() + .sessions() + .get_sizes() + .await + .expect("session sizes") + .sizes + .values() + .all(|size| *size >= 0) + ); + let in_use = client + .rpc() + .sessions() + .check_in_use(SessionsCheckInUseRequest { + session_ids: vec![session_id.to_string(), "missing-session-id".to_string()], + }) + .await + .expect("check in use"); + assert!(!in_use.in_use.iter().any(|id| id == "missing-session-id")); + assert!( + client + .rpc() + .sessions() + .get_persisted_remote_steerable( + SessionsGetPersistedRemoteSteerableRequest { + session_id: session_id.clone(), + }, + ) + .await + .expect("persisted remote steerable") + .remote_steerable + .is_none() + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_enrich_basic_session_metadata() { + with_e2e_context( + "rpc_server", + "should_enrich_basic_session_metadata", + |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 session_id = session.id().clone(); + let metadata = SessionMetadata { + context: Some(SessionContext { + branch: None, + cwd: ctx.work_dir().display().to_string(), + git_root: None, + host_type: None, + repository: None, + }), + is_remote: false, + mc_task_id: None, + modified_time: "2026-01-01T00:00:00.000Z".to_string(), + name: Some("Rust metadata".to_string()), + session_id: session_id.clone(), + start_time: "2026-01-01T00:00:00.000Z".to_string(), + summary: None, + }; + + let enriched = client + .rpc() + .sessions() + .enrich_metadata(SessionsEnrichMetadataRequest { + sessions: vec![metadata], + }) + .await + .expect("enrich metadata"); + let enriched = enriched.sessions.first().expect("enriched session"); + assert_eq!(enriched.session_id, session_id); + assert!(!enriched.is_remote); + assert!(enriched.context.is_some()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_close_active_session_and_release_lock() { + with_e2e_context( + "rpc_server", + "should_close_active_session_and_release_lock", + |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 session_id = session.id().clone(); + + client + .rpc() + .sessions() + .close(SessionsCloseRequest { + session_id: session_id.clone(), + }) + .await + .expect("close session"); + client + .rpc() + .sessions() + .release_lock(SessionsReleaseLockRequest { + session_id: session_id.clone(), + }) + .await + .expect("release lock"); + assert!( + !client + .rpc() + .sessions() + .check_in_use(SessionsCheckInUseRequest { + session_ids: vec![session_id.to_string()], + }) + .await + .expect("check after release") + .in_use + .contains(&session_id.to_string()) + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_prune_dryrun_and_bulkdelete_persisted_session() { + with_e2e_context( + "rpc_server", + "should_prune_dryrun_and_bulkdelete_persisted_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 session_id = session.id().clone(); + session.disconnect().await.expect("disconnect session"); + + let prune = client + .rpc() + .sessions() + .prune_old(SessionsPruneOldRequest { + older_than_days: 0, + dry_run: Some(true), + include_named: Some(true), + exclude_session_ids: vec![session_id.to_string()], + }) + .await + .expect("dry-run prune"); + assert!(prune.dry_run); + assert!(prune.deleted.is_empty()); + assert!(!prune.candidates.iter().any(|id| id == session_id.as_str())); + let deleted = client + .rpc() + .sessions() + .bulk_delete(SessionsBulkDeleteRequest { + session_ids: vec![session_id.to_string()], + }) + .await + .expect("bulk delete"); + assert!(deleted.freed_bytes.contains_key(session_id.as_str())); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_set_additional_plugins_and_reload_deferred_hooks() { + with_e2e_context( + "rpc_server", + "should_set_additional_plugins_and_reload_deferred_hooks", + |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"); + + client + .rpc() + .sessions() + .set_additional_plugins(SessionsSetAdditionalPluginsRequest { + plugins: Vec::new(), + }) + .await + .expect("set additional plugins"); + client + .rpc() + .sessions() + .reload_plugin_hooks(SessionsReloadPluginHooksRequest { + session_id: session.id().clone(), + defer_repo_hooks: Some(true), + }) + .await + .expect("reload plugin hooks"); + let loaded = client + .rpc() + .sessions() + .load_deferred_repo_hooks(SessionsLoadDeferredRepoHooksRequest { + session_id: session.id().clone(), + }) + .await + .expect("load deferred hooks"); + assert!(loaded.startup_prompts.is_empty()); + assert_eq!(loaded.hook_count, 0); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_save_and_get_event_file_path() { + with_e2e_context("rpc_server", "should_save_and_get_event_file_path", |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"); + + client + .rpc() + .sessions() + .save(SessionsSaveRequest { + session_id: session.id().clone(), + }) + .await + .expect("save session"); + let path = client + .rpc() + .sessions() + .get_event_file_path(SessionsGetEventFilePathRequest { + session_id: session.id().clone(), + }) + .await + .expect("event file path") + .file_path; + assert!(path.ends_with("events.jsonl")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_report_implemented_error_when_connecting_unknown_remote_session() { + with_e2e_context( + "rpc_server", + "should_report_implemented_error_when_connecting_unknown_remote_session", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; + + let err = client + .rpc() + .sessions() + .connect(ConnectRemoteSessionParams { + session_id: github_copilot_sdk::SessionId::from( + "00000000-0000-0000-0000-000000000000", + ), + }) + .await + .expect_err("unknown remote session should fail"); + assert!( + !err.to_string() + .contains("Unhandled method sessions.connect") + ); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + fn create_skill_directory( work_dir: &std::path::Path, skill_name: &str, diff --git a/rust/tests/e2e/rpc_session_state.rs b/rust/tests/e2e/rpc_session_state.rs index 8b1378917..11ff768fc 100644 --- a/rust/tests/e2e/rpc_session_state.rs +++ b/rust/tests/e2e/rpc_session_state.rs @@ -1 +1,1166 @@ +use std::collections::HashMap; +use github_copilot_sdk::generated::SessionMode; +use github_copilot_sdk::generated::api_types::{ + AuthInfoType, HistoryTruncateRequest, LspInitializeRequest, MetadataContextInfoRequest, + MetadataRecomputeContextTokensRequest, MetadataRecordContextChangeRequest, + MetadataSetWorkingDirectoryRequest, MetadataSnapshotCurrentMode, ModeSetRequest, + ModelSetReasoningEffortRequest, ModelSwitchToRequest, NameSetAutoRequest, NameSetRequest, + PermissionsSetApproveAllRequest, PlanUpdateRequest, SessionSetCredentialsParams, + SessionUpdateOptionsParams, SessionWorkingDirectoryContext, + SessionWorkingDirectoryContextHostType, SessionsForkRequest, ShutdownRequest, + TelemetrySetFeatureOverridesRequest, WorkspacesCreateFileRequest, WorkspacesReadFileRequest, +}; +use github_copilot_sdk::generated::session_events::{ + SessionContextChangedData, SessionEventType, SessionShutdownData, SessionTitleChangedData, + SessionWorkspaceFileChangedData, ShutdownType, WorkspaceFileChangedOperation, +}; +use serde_json::json; + +use super::support::{ + assistant_message_content, wait_for_condition, wait_for_event, with_e2e_context, +}; + +const MODEL_ID: &str = "claude-sonnet-4.5"; + +#[tokio::test] +async fn should_call_session_rpc_model_getcurrent() { + with_e2e_context( + "rpc_session_state", + "should_call_session_rpc_model_getcurrent", + |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().with_model(MODEL_ID)) + .await + .expect("create session"); + + let current = session + .rpc() + .model() + .get_current() + .await + .expect("get current model"); + assert_eq!(current.model_id.as_deref(), Some(MODEL_ID)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_session_rpc_model_switchto() { + with_e2e_context( + "rpc_session_state", + "should_call_session_rpc_model_switchto", + |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 switched = session + .rpc() + .model() + .switch_to(ModelSwitchToRequest { + model_id: MODEL_ID.to_string(), + reasoning_effort: Some("none".to_string()), + model_capabilities: None, + reasoning_summary: None, + }) + .await + .expect("switch model"); + assert_eq!(switched.model_id.as_deref(), Some(MODEL_ID)); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_and_set_session_mode() { + with_e2e_context( + "rpc_session_state", + "should_get_and_set_session_mode", + |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"); + + assert_eq!( + session.rpc().mode().get().await.expect("get initial mode"), + SessionMode::Interactive + ); + session + .rpc() + .mode() + .set(ModeSetRequest { + mode: SessionMode::Plan, + }) + .await + .expect("set plan mode"); + assert_eq!( + session.rpc().mode().get().await.expect("get plan mode"), + SessionMode::Plan + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_shutdown_session_with_routine_type() { + with_e2e_context( + "rpc_session_state", + "should_shutdown_session_with_routine_type", + |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 shutdown = wait_for_event(session.subscribe(), "session shutdown", |event| { + event.parsed_type() == SessionEventType::SessionShutdown + }); + + session + .rpc() + .shutdown(ShutdownRequest { + reason: Some("routine rust rpc test".to_string()), + r#type: Some(ShutdownType::Routine), + }) + .await + .expect("shutdown session"); + let data = shutdown + .await + .typed_data::() + .expect("shutdown data"); + assert_eq!(data.shutdown_type, ShutdownType::Routine); + + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_set_and_get_each_session_mode_value() { + with_e2e_context( + "rpc_session_state", + "should_set_and_get_each_session_mode_value", + |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"); + + for mode in [ + SessionMode::Interactive, + SessionMode::Plan, + SessionMode::Autopilot, + ] { + session + .rpc() + .mode() + .set(ModeSetRequest { mode: mode.clone() }) + .await + .expect("set mode"); + assert_eq!(session.rpc().mode().get().await.expect("get mode"), mode); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_read_update_and_delete_plan() { + with_e2e_context( + "rpc_session_state", + "should_read_update_and_delete_plan", + |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() + .plan() + .read() + .await + .expect("read initial plan"); + assert!(!initial.exists); + assert!(initial.content.is_none()); + + let content = "# Rust RPC plan\n- verify plan state"; + session + .rpc() + .plan() + .update(PlanUpdateRequest { + content: content.to_string(), + }) + .await + .expect("update plan"); + let updated = session + .rpc() + .plan() + .read() + .await + .expect("read updated plan"); + assert!(updated.exists); + assert_eq!(updated.content.as_deref(), Some(content)); + assert!( + updated + .path + .as_deref() + .is_some_and(|path| path.ends_with("plan.md")) + ); + + session.rpc().plan().delete().await.expect("delete plan"); + let deleted = session + .rpc() + .plan() + .read() + .await + .expect("read deleted plan"); + assert!(!deleted.exists); + assert!(deleted.content.is_none()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_workspace_file_rpc_methods() { + with_e2e_context( + "rpc_session_state", + "should_call_workspace_file_rpc_methods", + |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 before = session + .rpc() + .workspaces() + .list_files() + .await + .expect("list files before"); + assert!(before.files.is_empty()); + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: "rpc-state-rust.txt".to_string(), + content: "workspace rpc content".to_string(), + }) + .await + .expect("create workspace file"); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: "rpc-state-rust.txt".to_string(), + }) + .await + .expect("read workspace file"); + assert_eq!(read.content, "workspace rpc content"); + let workspace = session + .rpc() + .workspaces() + .get_workspace() + .await + .expect("get workspace"); + let workspace = workspace.workspace.expect("workspace details"); + assert!(!workspace.id.trim().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reject_workspace_file_path_traversal() { + with_e2e_context( + "rpc_session_state", + "should_reject_workspace_file_path_traversal", + |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"); + + expect_err_contains( + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: "../escape.txt".to_string(), + content: "nope".to_string(), + }) + .await, + "workspace files directory", + ); + expect_err_contains( + session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: "../../escape.txt".to_string(), + }) + .await, + "workspace files directory", + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_create_workspace_file_with_nested_path_auto_creating_dirs() { + with_e2e_context( + "rpc_session_state", + "should_create_workspace_file_with_nested_path_auto_creating_dirs", + |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 path = "nested/rust/path/file.txt"; + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "nested content".to_string(), + }) + .await + .expect("create nested workspace file"); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: path.to_string(), + }) + .await + .expect("read nested workspace file"); + assert_eq!(read.content, "nested content"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_reading_nonexistent_workspace_file() { + with_e2e_context( + "rpc_session_state", + "should_report_error_reading_nonexistent_workspace_file", + |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"); + + assert!( + session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: "missing-rust-file.txt".to_string(), + }) + .await + .is_err() + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_update_existing_workspace_file_with_update_operation() { + with_e2e_context( + "rpc_session_state", + "should_update_existing_workspace_file_with_update_operation", + |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 path = "updated-rust.txt"; + + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "first".to_string(), + }) + .await + .expect("create workspace file"); + let updated = + wait_for_event(session.subscribe(), "workspace file updated", |event| { + if event.parsed_type() != SessionEventType::SessionWorkspaceFileChanged { + return false; + } + event + .typed_data::() + .is_some_and(|data| { + data.path == path + && data.operation == WorkspaceFileChangedOperation::Update + }) + }); + session + .rpc() + .workspaces() + .create_file(WorkspacesCreateFileRequest { + path: path.to_string(), + content: "second".to_string(), + }) + .await + .expect("update workspace file"); + updated.await; + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_reject_empty_or_whitespace_session_name() { + with_e2e_context( + "rpc_session_state", + "should_reject_empty_or_whitespace_session_name", + |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"); + + for name in ["", " \t"] { + expect_err_contains( + session + .rpc() + .name() + .set(NameSetRequest { + name: name.to_string(), + }) + .await, + "empty", + ); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_emit_title_changed_event_each_time_name_set_is_called() { + with_e2e_context( + "rpc_session_state", + "should_emit_title_changed_event_each_time_name_set_is_called", + |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"); + + for title in ["Rust RPC title", "Rust RPC title"] { + let changed = wait_for_event(session.subscribe(), "title changed", |event| { + event.parsed_type() == SessionEventType::SessionTitleChanged + && event + .typed_data::() + .is_some_and(|data| data.title == title) + }); + session + .rpc() + .name() + .set(NameSetRequest { + name: title.to_string(), + }) + .await + .expect("set title"); + changed.await; + } + assert_eq!( + session + .rpc() + .name() + .get() + .await + .expect("get title") + .name + .as_deref(), + Some("Rust RPC title") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_get_and_set_session_metadata() { + with_e2e_context( + "rpc_session_state", + "should_call_metadata_snapshot_setworkingdirectory_and_recordcontextchange", + |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() + .name() + .set(NameSetRequest { + name: "Rust metadata name".to_string(), + }) + .await + .expect("set name"); + assert_eq!( + session + .rpc() + .name() + .get() + .await + .expect("get name") + .name + .as_deref(), + Some("Rust metadata name") + ); + let sources = session + .rpc() + .instructions() + .get_sources() + .await + .expect("instruction sources"); + assert!(sources.sources.iter().all(|source| !source.id.is_empty())); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_metadata_snapshot_setworkingdirectory_and_recordcontextchange() { + with_e2e_context( + "rpc_session_state", + "should_get_and_set_session_metadata", + |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 subdir = ctx.work_dir().join("metadata-cwd"); + std::fs::create_dir_all(&subdir).expect("create metadata cwd"); + + let snapshot = session + .rpc() + .metadata() + .snapshot() + .await + .expect("metadata snapshot"); + assert_eq!(snapshot.session_id, session.id().clone()); + assert_eq!( + snapshot.current_mode, + MetadataSnapshotCurrentMode::Interactive + ); + assert!(!snapshot.start_time.is_empty()); + assert!(!snapshot.modified_time.is_empty()); + + let set = session + .rpc() + .metadata() + .set_working_directory(MetadataSetWorkingDirectoryRequest { + working_directory: subdir.display().to_string(), + }) + .await + .expect("set working directory"); + assert_paths_equal(&set.working_directory, &subdir); + + let changed = wait_for_event(session.subscribe(), "context changed", |event| { + event.parsed_type() == SessionEventType::SessionContextChanged + && event + .typed_data::() + .is_some_and(|data| { + data.repository.as_deref() == Some("github/copilot-sdk") + }) + }); + session + .rpc() + .metadata() + .record_context_change(MetadataRecordContextChangeRequest { + context: SessionWorkingDirectoryContext { + base_commit: None, + branch: Some("rust-rpc-e2e".to_string()), + cwd: subdir.display().to_string(), + git_root: Some(ctx.repo_root().display().to_string()), + head_commit: None, + host_type: Some(SessionWorkingDirectoryContextHostType::Github), + repository: Some("github/copilot-sdk".to_string()), + repository_host: Some("github.com".to_string()), + }, + }) + .await + .expect("record context change"); + let data = changed + .await + .typed_data::() + .expect("context changed data"); + assert_paths_equal(&data.cwd, &subdir); + assert_eq!(data.branch.as_deref(), Some("rust-rpc-e2e")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_update_options_and_initialize_session_services() { + with_e2e_context( + "rpc_session_state", + "should_update_options_and_initialize_session_services", + |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 options = session + .rpc() + .options() + .update(SessionUpdateOptionsParams { + ask_user_disabled: Some(true), + available_tools: vec!["view".to_string()], + client_name: Some("rust-rpc-e2e".to_string()), + enable_streaming: Some(true), + model: Some(MODEL_ID.to_string()), + working_directory: Some(ctx.work_dir().display().to_string()), + ..SessionUpdateOptionsParams::default() + }) + .await + .expect("update options"); + assert!(options.success); + session + .rpc() + .lsp() + .initialize(LspInitializeRequest { + force: Some(true), + git_root: Some(ctx.repo_root().display().to_string()), + working_directory: Some(ctx.work_dir().display().to_string()), + }) + .await + .expect("initialize lsp"); + session + .rpc() + .telemetry() + .set_feature_overrides(TelemetrySetFeatureOverridesRequest { + features: HashMap::from([( + "rust-rpc-e2e".to_string(), + "enabled".to_string(), + )]), + }) + .await + .expect("set telemetry overrides"); + session + .rpc() + .tools() + .initialize_and_validate() + .await + .expect("initialize tools"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_set_reasoningeffort_and_auto_name() { + with_e2e_context( + "rpc_session_state", + "should_set_reasoningeffort_and_auto_name", + |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 effort = session + .rpc() + .model() + .set_reasoning_effort(ModelSetReasoningEffortRequest { + reasoning_effort: "none".to_string(), + }) + .await + .expect("set reasoning effort"); + assert_eq!(effort.reasoning_effort, "none"); + let auto = session + .rpc() + .name() + .set_auto(NameSetAutoRequest { + summary: "Rust auto title".to_string(), + }) + .await + .expect("set auto name"); + assert!(auto.applied); + session + .rpc() + .name() + .set(NameSetRequest { + name: "Explicit Rust title".to_string(), + }) + .await + .expect("set explicit name"); + let not_applied = session + .rpc() + .name() + .set_auto(NameSetAutoRequest { + summary: "Ignored auto title".to_string(), + }) + .await + .expect("set ignored auto name"); + assert!(!not_applied.applied); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_set_auth_credentials() { + with_e2e_context("rpc_session_state", "should_set_auth_credentials", |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let token = "rpc-session-auth-token"; + ctx.set_copilot_user_by_token_with_login(token, "rpc-session-user"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let set = session + .rpc() + .auth() + .set_credentials(SessionSetCredentialsParams { + credentials: Some(json!({ + "type": "user", + "host": "github.com", + "login": "rpc-session-user" + })), + }) + .await + .expect("set credentials"); + assert!(set.success); + let status = session + .rpc() + .auth() + .get_status() + .await + .expect("auth status"); + assert!(status.is_authenticated); + assert_eq!(status.auth_type, Some(AuthInfoType::User)); + assert_eq!(status.host.as_deref(), Some("github.com")); + assert_eq!(status.login.as_deref(), Some("rpc-session-user")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }) + .await; +} + +#[tokio::test] +async fn should_fork_session_with_persisted_messages() { + with_e2e_context( + "rpc_session_state", + "should_fork_session_with_persisted_messages", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let answer = session + .send_and_wait("Say FORK_SOURCE_ALPHA exactly.") + .await + .expect("send") + .expect("assistant response"); + assert!(assistant_message_content(&answer).contains("FORK_SOURCE_ALPHA")); + + let fork = client + .rpc() + .sessions() + .fork(SessionsForkRequest { + session_id: session.id().clone(), + to_event_id: None, + name: Some("Rust fork".to_string()), + }) + .await + .expect("fork session"); + assert_ne!(fork.session_id, session.id().clone()); + assert_eq!(fork.name.as_deref(), Some("Rust fork")); + let forked = client + .resume_session( + github_copilot_sdk::ResumeSessionConfig::new(fork.session_id.clone()) + .with_github_token(super::support::DEFAULT_TEST_TOKEN), + ) + .await + .expect("resume fork"); + assert!( + forked + .get_events() + .await + .expect("fork events") + .iter() + .any(|event| assistant_message_content_if_present(event) + .is_some_and(|content| content.contains("FORK_SOURCE_ALPHA"))) + ); + + forked.disconnect().await.expect("disconnect fork"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_error_when_forking_session_to_unknown_event_id() { + with_e2e_context( + "rpc_session_state", + "should_report_error_when_forking_session_to_unknown_event_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 err = client + .rpc() + .sessions() + .fork(SessionsForkRequest { + session_id: session.id().clone(), + to_event_id: Some("missing-event-id".to_string()), + name: None, + }) + .await + .expect_err("unknown boundary should fail"); + let message = err.to_string(); + assert!(message.contains("missing-event-id") || message.contains("not found")); + assert!(!message.contains("Unhandled method")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_call_session_usage_and_permission_rpcs() { + with_e2e_context( + "rpc_session_state", + "should_call_session_usage_and_permission_rpcs", + |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 metrics = session.rpc().usage().get_metrics().await.expect("usage"); + assert!(!metrics.session_start_time.is_empty()); + assert_eq!(metrics.total_user_requests, 0); + assert!( + session + .rpc() + .permissions() + .set_approve_all(PermissionsSetApproveAllRequest { + enabled: true, + source: None, + }) + .await + .expect("enable approve all") + .success + ); + assert!( + session + .rpc() + .permissions() + .reset_session_approvals() + .await + .expect("reset approvals") + .success + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_report_implemented_errors_for_unsupported_session_rpc_paths() { + with_e2e_context( + "rpc_session_state", + "should_report_implemented_errors_for_unsupported_session_rpc_paths", + |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 truncate = session + .rpc() + .history() + .truncate(HistoryTruncateRequest { + event_id: "missing-event-id".to_string(), + }) + .await + .expect_err("truncate missing event should fail"); + assert!( + !truncate + .to_string() + .contains("Unhandled method session.history.truncate") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_compact_session_history_after_messages() { + with_e2e_context( + "rpc_session_state", + "should_compact_session_history_after_messages", + |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().with_model(MODEL_ID)) + .await + .expect("create session"); + + assert!( + !session + .rpc() + .metadata() + .is_processing() + .await + .expect("processing before send") + .processing + ); + session + .send("Reply with exactly: RUST_CONTEXT_INFO") + .await + .expect("send"); + wait_for_condition("session processing started", || async { + session + .rpc() + .metadata() + .is_processing() + .await + .expect("processing poll") + .processing + }) + .await; + wait_for_condition("session processing completed", || async { + !session + .rpc() + .metadata() + .is_processing() + .await + .expect("processing poll") + .processing + }) + .await; + let context = session + .rpc() + .metadata() + .context_info(MetadataContextInfoRequest { + prompt_token_limit: 200_000, + output_token_limit: 4096, + selected_model: Some(MODEL_ID.to_string()), + }) + .await + .expect("context info"); + let context_info = context.context_info.expect("context info"); + assert_eq!(context_info.model_name, MODEL_ID); + let recomputed = session + .rpc() + .metadata() + .recompute_context_tokens(MetadataRecomputeContextTokensRequest { + model_id: MODEL_ID.to_string(), + }) + .await + .expect("recompute context tokens"); + assert!(recomputed.total_tokens >= recomputed.messages_token_count); + assert!(recomputed.total_tokens >= recomputed.system_token_count); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn expect_err_contains(result: Result, expected: &str) { + let err = match result { + Ok(_) => panic!("expected error containing {expected:?}"), + Err(err) => err, + }; + assert!( + err.to_string() + .to_ascii_lowercase() + .contains(&expected.to_ascii_lowercase()), + "expected error to contain {expected:?}, got {err}" + ); +} + +fn assert_paths_equal(actual: &str, expected: &std::path::Path) { + let actual = std::path::Path::new(actual); + assert_eq!( + std::fs::canonicalize(actual).unwrap_or_else(|_| actual.to_path_buf()), + std::fs::canonicalize(expected).unwrap_or_else(|_| expected.to_path_buf()) + ); +} + +fn assistant_message_content_if_present( + event: &github_copilot_sdk::SessionEvent, +) -> Option { + if event.parsed_type() == SessionEventType::AssistantMessage { + Some(assistant_message_content(event)) + } else { + None + } +} diff --git a/rust/tests/e2e/rpc_tasks_and_handlers.rs b/rust/tests/e2e/rpc_tasks_and_handlers.rs index d98f88598..7c6668ddc 100644 --- a/rust/tests/e2e/rpc_tasks_and_handlers.rs +++ b/rust/tests/e2e/rpc_tasks_and_handlers.rs @@ -11,8 +11,13 @@ use github_copilot_sdk::generated::api_types::{ PermissionDecisionApproveOnceKind, PermissionDecisionApprovePermanently, PermissionDecisionApprovePermanentlyKind, PermissionDecisionReject, PermissionDecisionRejectKind, PermissionDecisionRequest, TasksCancelRequest, - TasksPromoteToBackgroundRequest, TasksRemoveRequest, TasksStartAgentRequest, - UIElicitationResponse, UIElicitationResponseAction, UIHandlePendingElicitationRequest, + TasksGetProgressRequest, TasksPromoteToBackgroundRequest, TasksRemoveRequest, + TasksSendMessageRequest, TasksStartAgentRequest, UIAutoModeSwitchResponse, + UIElicitationResponse, UIElicitationResponseAction, UIExitPlanModeResponse, + UIHandlePendingAutoModeSwitchRequest, UIHandlePendingElicitationRequest, + UIHandlePendingExitPlanModeRequest, UIHandlePendingSamplingRequest, + UIHandlePendingUserInputRequest, UIUnregisterDirectAutoModeSwitchHandlerRequest, + UIUserInputResponse, }; use super::support::with_e2e_context; @@ -33,6 +38,40 @@ async fn should_list_task_state_and_return_false_for_missing_task_operations() { let tasks = session.rpc().tasks().list().await.expect("list tasks"); assert!(tasks.tasks.is_empty()); + session + .rpc() + .tasks() + .refresh() + .await + .expect("refresh tasks"); + session + .rpc() + .tasks() + .wait_for_pending() + .await + .expect("wait for pending tasks"); + assert!( + session + .rpc() + .tasks() + .get_progress(TasksGetProgressRequest { + id: "missing-task".to_string(), + }) + .await + .expect("progress missing") + .progress + .is_none() + ); + assert!( + session + .rpc() + .tasks() + .get_current_promotable() + .await + .expect("current promotable") + .task + .is_none() + ); assert!( !session .rpc() @@ -44,6 +83,16 @@ async fn should_list_task_state_and_return_false_for_missing_task_operations() { .expect("promote missing") .promoted ); + assert!( + session + .rpc() + .tasks() + .promote_current_to_background() + .await + .expect("promote current missing") + .task + .is_none() + ); assert!( !session .rpc() @@ -66,6 +115,18 @@ async fn should_list_task_state_and_return_false_for_missing_task_operations() { .expect("remove missing") .removed ); + let send = session + .rpc() + .tasks() + .send_message(TasksSendMessageRequest { + id: "missing-task".to_string(), + message: "hello".to_string(), + from_agent_id: None, + }) + .await + .expect("send missing task"); + assert!(!send.sent); + assert!(send.error.is_some()); session.disconnect().await.expect("disconnect session"); client.stop().await.expect("stop client"); @@ -210,6 +271,58 @@ async fn should_return_expected_results_for_missing_pending_handler_requestids() .expect("handle missing elicitation"); assert!(!elicitation.success); + let user_input = session + .rpc() + .ui() + .handle_pending_user_input(UIHandlePendingUserInputRequest { + request_id: "missing-user-input-request".into(), + response: UIUserInputResponse { + answer: "answer".to_string(), + was_freeform: true, + }, + }) + .await + .expect("handle missing user input"); + assert!(!user_input.success); + + let sampling = session + .rpc() + .ui() + .handle_pending_sampling(UIHandlePendingSamplingRequest { + request_id: "missing-sampling-request".into(), + response: None, + }) + .await + .expect("handle missing sampling"); + assert!(!sampling.success); + + let auto_mode = session + .rpc() + .ui() + .handle_pending_auto_mode_switch(UIHandlePendingAutoModeSwitchRequest { + request_id: "missing-auto-mode-request".into(), + response: UIAutoModeSwitchResponse::No, + }) + .await + .expect("handle missing auto mode switch"); + assert!(!auto_mode.success); + + let exit_plan = session + .rpc() + .ui() + .handle_pending_exit_plan_mode(UIHandlePendingExitPlanModeRequest { + request_id: "missing-exit-plan-request".into(), + response: UIExitPlanModeResponse { + approved: false, + auto_approve_edits: None, + feedback: Some("not now".to_string()), + selected_action: None, + }, + }) + .await + .expect("handle missing exit plan"); + assert!(!exit_plan.success); + for (request_id, result) in [ ( "missing-permission-request", @@ -280,6 +393,57 @@ async fn should_return_expected_results_for_missing_pending_handler_requestids() .await; } +#[tokio::test] +async fn should_register_and_unregister_direct_auto_mode_switch_handler() { + with_e2e_context( + "rpc_tasks_and_handlers", + "should_register_and_unregister_direct_auto_mode_switch_handler", + |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 missing = session + .rpc() + .ui() + .unregister_direct_auto_mode_switch_handler( + UIUnregisterDirectAutoModeSwitchHandlerRequest { + handle: "missing-handle".to_string(), + }, + ) + .await + .expect("unregister missing handler"); + assert!(!missing.unregistered); + let handle = session + .rpc() + .ui() + .register_direct_auto_mode_switch_handler() + .await + .expect("register handler") + .handle; + assert!(!handle.trim().is_empty()); + let removed = session + .rpc() + .ui() + .unregister_direct_auto_mode_switch_handler( + UIUnregisterDirectAutoModeSwitchHandlerRequest { handle }, + ) + .await + .expect("unregister handler"); + assert!(removed.unregistered); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + fn assert_implemented_error(result: Result, method: &str) { let err = match result { Ok(_) => panic!("RPC should fail"), diff --git a/rust/tests/e2e/rpc_workspace_checkpoints.rs b/rust/tests/e2e/rpc_workspace_checkpoints.rs new file mode 100644 index 000000000..ccf70e2cd --- /dev/null +++ b/rust/tests/e2e/rpc_workspace_checkpoints.rs @@ -0,0 +1,183 @@ +use std::path::Path; +use std::process::Command; + +use github_copilot_sdk::generated::api_types::{ + WorkspaceDiffFileChangeType, WorkspaceDiffMode, WorkspacesDiffRequest, + WorkspacesReadCheckpointRequest, WorkspacesReadFileRequest, WorkspacesSaveLargePasteRequest, +}; + +use super::support::with_e2e_context; + +#[tokio::test] +async fn should_list_no_checkpoints_for_fresh_session() { + with_e2e_context( + "rpc_workspace_checkpoints", + "should_list_no_checkpoints_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 checkpoints = session + .rpc() + .workspaces() + .list_checkpoints() + .await + .expect("list checkpoints"); + assert!(checkpoints.checkpoints.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_null_or_empty_content_for_unknown_checkpoint() { + with_e2e_context( + "rpc_workspace_checkpoints", + "should_return_null_or_empty_content_for_unknown_checkpoint", + |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 checkpoint = session + .rpc() + .workspaces() + .read_checkpoint(WorkspacesReadCheckpointRequest { number: i64::MAX }) + .await + .expect("read missing checkpoint"); + assert!(checkpoint.content.as_deref().unwrap_or_default().is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_return_typed_workspace_diff_result() { + with_e2e_context( + "rpc_workspace_checkpoints", + "should_return_typed_workspace_diff_result", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + init_git_repository(ctx.work_dir()); + let changed_path = ctx.work_dir().join("rust-workspace-diff.txt"); + std::fs::write(&changed_path, "diff content\n").expect("write diff file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let diff = session + .rpc() + .workspaces() + .diff(WorkspacesDiffRequest { + mode: WorkspaceDiffMode::Unstaged, + }) + .await + .expect("workspace diff"); + assert_eq!(diff.requested_mode, WorkspaceDiffMode::Unstaged); + assert!(matches!( + diff.mode, + WorkspaceDiffMode::Unstaged | WorkspaceDiffMode::Branch + )); + if let Some(change) = diff.changes.iter().find(|change| { + normalize_path(&change.path).ends_with("rust-workspace-diff.txt") + }) { + assert_eq!(change.change_type, WorkspaceDiffFileChangeType::Added); + assert!(change.diff.contains("diff content") || change.diff.is_empty()); + } else { + assert!( + diff.changes.is_empty(), + "unexpected diff changes: {:?}", + diff.changes + ); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +#[tokio::test] +async fn should_save_large_paste_and_expose_readable_content() { + with_e2e_context( + "rpc_workspace_checkpoints", + "should_save_large_paste_and_expose_readable_content", + |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 content = "large paste rust content\n".repeat(512); + + let saved = session + .rpc() + .workspaces() + .save_large_paste(WorkspacesSaveLargePasteRequest { + content: content.clone(), + }) + .await + .expect("save large paste") + .saved + .expect("saved paste descriptor"); + assert!(saved.filename.ends_with(".txt")); + assert_eq!(saved.size_bytes, content.len() as i64); + assert_eq!( + std::fs::read_to_string(&saved.file_path).expect("read saved paste"), + content + ); + let read = session + .rpc() + .workspaces() + .read_file(WorkspacesReadFileRequest { + path: saved.filename, + }) + .await + .expect("read saved paste through workspace"); + assert_eq!(read.content, content); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") +} + +fn init_git_repository(path: &Path) { + let status = Command::new("git") + .arg("init") + .arg("--quiet") + .current_dir(path) + .status() + .expect("run git init"); + assert!(status.success(), "git init should succeed"); +}