diff --git a/dotnet/README.md b/dotnet/README.md index c518a40326..1adc23e510 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -783,6 +783,66 @@ var session = await client.CreateSessionAsync(new SessionConfig }); ``` +### In-memory skills (experimental) + +Use `SessionConfig.SkillProvider` to supply text-only skills from application storage +without writing skill files or replacing the native `skill` tool. The runtime requests +metadata through `ListAsync` and reads the complete Markdown lazily through `ReadAsync`. + +```csharp +#pragma warning disable GHCP001 // Experimental native skill-provider API. +using GitHub.Copilot; + +await using var client = new CopilotClient(); +await using var session = await client.CreateSessionAsync(new SessionConfig +{ + SkillProvider = new ApplicationSkills(), + EnableSkills = true, + OnPermissionRequest = PermissionHandler.ApproveAll, +}); + +sealed class ApplicationSkills : SkillProvider +{ + public override Task> ListAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult>( + [ + new() { Name = "release-review", Description = "Review a release checklist." } + ]); + } + + public override Task ReadAsync( + string name, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (name != "release-review") + throw new KeyNotFoundException(name); + + return Task.FromResult(""" + --- + name: release-review + description: Review a release checklist. + --- + Check the version, changelog, and test results before recommending a release. + """); + } +} +``` + +- The provider uses experimental native callbacks; it does not expose supporting files or assets. +- Cloud sessions do not support skill providers, even with an explicit session ID. +- Markdown frontmatter must agree with the catalog descriptor. Names must be unique + case-insensitively and match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`. +- Catalogs are limited to 1,024 descriptors and 1 MiB of aggregate metadata. Each + complete Markdown response is limited to 1 MiB of UTF-8 text. +- Implementations must support concurrent calls and honor cancellation. +- Re-supply `SkillProvider` on resume. Its binding is not serialized or persisted. +- `EnableSkills = false` leaves the provider bound but dormant. Set it to `true` + explicitly when using `CopilotClientMode.Empty`. +- Existing `SkillDirectories` can be used alongside provider skills. + ### Multiple Sessions ```csharp diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index dff4681a80..51e7649a7e 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -872,6 +872,7 @@ private CopilotSession InitializeSession( session.RegisterElicitationHandler(config.OnElicitationRequest); session.RegisterExitPlanModeHandler(config.OnExitPlanModeRequest); session.RegisterAutoModeSwitchHandler(config.OnAutoModeSwitchRequest); + session.RegisterSkillProvider(config.SkillProvider); if (config.OnUserInputRequest != null) { session.RegisterUserInputHandler(config.OnUserInputRequest); @@ -1170,6 +1171,12 @@ public async Task CreateSessionAsync(SessionConfig config, Cance { ArgumentNullException.ThrowIfNull(config); ValidateGitHubTokenConfig(config); + if (config.SkillProvider is not null && config.Cloud is not null) + { + throw new ArgumentException( + "SkillProvider is not supported for cloud sessions, including those with an explicit session ID.", + nameof(config)); + } var connection = await EnsureConnectedAsync(cancellationToken); var totalTimestamp = Stopwatch.GetTimestamp(); @@ -1302,7 +1309,8 @@ public async Task CreateSessionAsync(SessionConfig config, Cance GitHubMcpToolConfig: config.GitHubMcpToolConfig, ManagedSettings: config.ManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null, - AdditionalDirectories: config.AdditionalDirectories); + AdditionalDirectories: config.AdditionalDirectories, + HasSkillProvider: config.SkillProvider is not null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); @@ -1545,7 +1553,8 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes GitHubMcpToolConfig: config.GitHubMcpToolConfig, ManagedSettings: config.ManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null, - AdditionalDirectories: config.AdditionalDirectories); + AdditionalDirectories: config.AdditionalDirectories, + HasSkillProvider: config.SkillProvider is not null ? true : null); var rpcTimestamp = Stopwatch.GetTimestamp(); var response = await InvokeRpcAsync( @@ -1758,6 +1767,7 @@ public async Task DeleteSessionAsync(string sessionId, CancellationToken cancell if (_sessions.TryRemove(sessionId, out var session)) { session.ReleaseGitHubTokenProviderRegistration(); + session.RegisterSkillProvider(null); } } @@ -2689,6 +2699,9 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? rpc.SetLocalRpcMethod("autoModeSwitch.request", handler.OnAutoModeSwitchRequest); rpc.SetLocalRpcMethod("hooks.invoke", handler.OnHooksInvoke); rpc.SetLocalRpcMethod("systemMessage.transform", handler.OnSystemMessageTransform); + // Internal native callbacks are intentionally absent from the generated client-session API. + rpc.SetLocalRpcMethod("skillProvider.list", handler.OnSkillProviderList, singleObjectParam: true); + rpc.SetLocalRpcMethod("skillProvider.read", handler.OnSkillProviderRead, singleObjectParam: true); ClientSessionApiRegistration.RegisterClientSessionApiHandlers(rpc, sessionId => { var session = GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); @@ -2931,6 +2944,20 @@ public async ValueTask OnSystemMessageTransfo return await session.HandleSystemMessageTransformAsync(sections); } + public async ValueTask OnSkillProviderList(SkillProviderListRequest request, CancellationToken cancellationToken) + { + var session = client.GetSession(request.SessionId) ?? throw new ArgumentException($"Unknown session {request.SessionId}"); + var skills = await session.HandleSkillProviderListAsync(cancellationToken).ConfigureAwait(false); + return new SkillProviderListResponse(skills); + } + + public async ValueTask OnSkillProviderRead(SkillProviderReadRequest request, CancellationToken cancellationToken) + { + var session = client.GetSession(request.SessionId) ?? throw new ArgumentException($"Unknown session {request.SessionId}"); + var markdown = await session.HandleSkillProviderReadAsync(request.Name, cancellationToken).ConfigureAwait(false); + return new SkillProviderReadResponse(markdown); + } + } private class Connection( @@ -3087,7 +3114,8 @@ internal record CreateSessionRequest( [property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null, [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null, - IList? AdditionalDirectories = null); + IList? AdditionalDirectories = null, + bool? HasSkillProvider = null); #pragma warning restore GHCP001 internal record ToolDefinition( @@ -3207,7 +3235,8 @@ internal record ResumeSessionRequest( [property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null, bool? EnableGitHubTelemetryForwarding = null, [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null, - IList? AdditionalDirectories = null); + IList? AdditionalDirectories = null, + bool? HasSkillProvider = null); #pragma warning restore GHCP001 internal record ResumeSessionResponse( @@ -3300,6 +3329,14 @@ internal record AutoModeSwitchRequestResponse( internal record HooksInvokeResponse( object? Output); + internal record SkillProviderListRequest(string SessionId); + + internal record SkillProviderListResponse(IReadOnlyList Skills); + + internal record SkillProviderReadRequest(string SessionId, string Name); + + internal record SkillProviderReadResponse(string Markdown); + [JsonSourceGenerationOptions( JsonSerializerDefaults.Web, AllowOutOfOrderMetadataProperties = true, @@ -3338,6 +3375,10 @@ internal record HooksInvokeResponse( [JsonSerializable(typeof(SessionUiCapabilities))] [JsonSerializable(typeof(SessionMetadata))] [JsonSerializable(typeof(SetForegroundSessionRequest))] + [JsonSerializable(typeof(SkillProviderListRequest))] + [JsonSerializable(typeof(SkillProviderListResponse))] + [JsonSerializable(typeof(SkillProviderReadRequest))] + [JsonSerializable(typeof(SkillProviderReadResponse))] [JsonSerializable(typeof(SystemMessageConfig))] [JsonSerializable(typeof(SystemMessageTransformRpcResponse))] [JsonSerializable(typeof(CommandWireDefinition))] diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 404c0054b7..87e74dbe54 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -73,6 +73,7 @@ public sealed partial class CopilotSession : IAsyncDisposable private volatile Func>? _elicitationHandler; private volatile Func>? _exitPlanModeHandler; private volatile Func>? _autoModeSwitchHandler; + private volatile SkillProvider? _skillProvider; private ImmutableArray _eventHandlers = ImmutableArray.Empty; private sealed record EventSubscription(Type EventType, Action Handler); @@ -206,6 +207,7 @@ internal CopilotSession( internal void RemoveFromClient() { ((ICollection>)_parentClient._sessions).Remove(new(SessionId, this)); + _skillProvider = null; } /// @@ -1229,6 +1231,24 @@ internal void RegisterAutoModeSwitchHandler(Func> HandleSkillProviderListAsync(CancellationToken cancellationToken) + { + var provider = _skillProvider ?? throw new InvalidOperationException($"No skill provider registered for session {SessionId}"); + return provider.ListAsync(cancellationToken); + } + + internal Task HandleSkillProviderReadAsync(string name, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(name); + var provider = _skillProvider ?? throw new InvalidOperationException($"No skill provider registered for session {SessionId}"); + return provider.ReadAsync(name, cancellationToken); + } + /// /// Registers per-provider BearerTokenProvider callbacks for BYOK /// providers configured with managed-identity / on-demand bearer-token auth. diff --git a/dotnet/src/SkillProvider.cs b/dotnet/src/SkillProvider.cs new file mode 100644 index 0000000000..c2259de591 --- /dev/null +++ b/dotnet/src/SkillProvider.cs @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Diagnostics.CodeAnalysis; + +namespace GitHub.Copilot; + +/// +/// Supplies in-memory, text-only skills to the native runtime's built-in skill tool. +/// +/// +/// +/// This experimental API uses internal native runtime callbacks. The runtime requests catalog +/// metadata first and reads a skill's Markdown lazily when it is invoked. No skill files are +/// written, and supporting files, scripts, and other filesystem assets are not provided. +/// +/// +/// Set before creating or resuming a session. +/// The binding is not persisted and must be supplied again on resume. Implementations should +/// support concurrent callbacks and honor the supplied cancellation token. +/// +/// +[Experimental(Diagnostics.Experimental)] +public abstract class SkillProvider +{ + /// + /// Lists skill metadata without loading the skills' Markdown bodies. + /// + /// Cancellation token for the runtime callback. + /// + /// The skill catalog, or an empty list when no skills are available. Names must be unique + /// using a case-insensitive comparison and satisfy . + /// The native runtime limits catalogs to 1,024 descriptors and 1 MiB of aggregate metadata. + /// + public abstract Task> ListAsync(CancellationToken cancellationToken = default); + + /// + /// Reads the complete SKILL.md-format Markdown for a catalog entry. + /// + /// The name of the skill to read. + /// Cancellation token for the runtime callback. + /// + /// The complete Markdown, including YAML frontmatter matching the metadata returned by + /// . The native runtime rejects inconsistent metadata. + /// The complete response text must not exceed 1 MiB when encoded as UTF-8. + /// + /// Throw if the named skill is unavailable; do not return a file path. + public abstract Task ReadAsync(string name, CancellationToken cancellationToken = default); +} + +/// +/// Catalog metadata for an experimental, text-only native skill supplied by . +/// +/// +/// The native runtime validates the catalog and its agreement with each skill's Markdown +/// frontmatter. Provider skills are pathless: the runtime assigns source sdk and an empty path. +/// +[Experimental(Diagnostics.Experimental)] +public sealed class SkillProviderDescriptor +{ + /// + /// Gets the pathless skill name. Must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ + /// and be unique in the catalog using a case-insensitive comparison. + /// + public required string Name { get; init; } + + /// Gets the description advertised before the skill's Markdown is read. + public required string Description { get; init; } + + /// + /// Gets whether the skill is user-invocable. When omitted, the native runtime default applies. + /// Must agree with the Markdown frontmatter's user-invocable value. + /// + public bool? UserInvocable { get; init; } + + /// + /// Gets whether model invocation is disabled. When omitted, the native runtime default applies. + /// Must agree with the Markdown frontmatter's disable-model-invocation value. + /// + public bool? DisableModelInvocation { get; init; } + + /// + /// Gets the optional argument hint. Must agree with the Markdown frontmatter's + /// argument-hint value. + /// + public string? ArgumentHint { get; init; } +} diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 1cc4919093..9f0e16bc85 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2883,8 +2883,9 @@ public sealed class CustomAgentConfig /// /// List of skill names to preload into this agent's context. /// When set, the full content of each listed skill is eagerly injected into - /// the agent's context at startup. Skills are resolved by name from the - /// session's configured skill directories (). + /// the agent's context at startup. Skills are resolved by name from the session's native + /// catalog, including and + /// . /// When omitted, no skills are injected (opt-in model). /// [JsonPropertyName("skills")] @@ -3371,6 +3372,7 @@ protected SessionConfigBase(SessionConfigBase? other) CanvasHandler = other.CanvasHandler; #pragma warning restore GHCP001 SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; + SkillProvider = other.SkillProvider; PluginDirectories = other.PluginDirectories is not null ? [.. other.PluginDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; SessionLimits = other.SessionLimits; @@ -3497,9 +3499,9 @@ protected SessionConfigBase(SessionConfigBase? other) /// /// When , enables skill loading, including built-in - /// skills and discovered skill directories. When , no - /// skills are loaded regardless of or - /// . + /// skills, discovered skill directories, and . + /// When , no skills are loaded regardless of + /// , , or . /// public bool? EnableSkills { get; set; } @@ -3747,6 +3749,20 @@ protected SessionConfigBase(SessionConfigBase? other) /// Directories to load skills from. public IList? SkillDirectories { get; set; } + /// + /// Gets or sets an experimental, in-memory, text-only provider for the native skill tool. + /// + /// + /// The provider is registered before the create/resume request and is not serialized or persisted. + /// Supply it again on resume. Setting to + /// keeps the provider bound but prevents the runtime from loading skills. File-based skills may + /// coexist with provider skills. Cloud sessions do not support providers, regardless of whether + /// the caller supplies a session ID. + /// + [Experimental(Diagnostics.Experimental)] + [JsonIgnore] + public SkillProvider? SkillProvider { get; set; } + /// /// Local filesystem paths to Open Plugins-format directories /// (https://open-plugins.com/) to load for this session. diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.SkillProvider.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.SkillProvider.cs new file mode 100644 index 0000000000..75a83e1def --- /dev/null +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.SkillProvider.cs @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using System.Text.Json; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public sealed partial class ClientSessionLifetimeTests +{ + private const string SkillMarkdown = """ + --- + name: native-skill + description: A text-only test skill + user-invocable: false + disable-model-invocation: false + argument-hint: "[topic]" + --- + # Native skill + Use résumé examples. + """; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SkillProvider_Is_Bound_Before_Session_Open_And_Reads_Lazily(bool resume) + { + await using var server = await FakeCopilotServer.StartAsync(); + Task? listDuringOpen = null; + server.BeforeResponseAsync = (request, _) => + { + if (request.Method is "session.create" or "session.resume") + { + // Send the callback before the open response without blocking the response read loop. + listDuringOpen = server.SendRequestAsync("skillProvider.list", new Dictionary + { + ["sessionId"] = request.Params.GetProperty("sessionId").GetString() + }); + } + return Task.CompletedTask; + }; + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var provider = new TestSkillProvider + { + Skills = + [ + new SkillProviderDescriptor + { + Name = "native-skill", + Description = "A text-only test skill", + UserInvocable = false, + DisableModelInvocation = false, + ArgumentHint = "[topic]" + }, + new SkillProviderDescriptor { Name = "minimal-skill", Description = "Only required metadata" } + ] + }; + + await using var session = await OpenSessionWithSkillsAsync(client, resume, provider); + + var request = Assert.Single(server.Requests, request => request.Method == (resume ? "session.resume" : "session.create")); + Assert.True(request.Params.GetProperty("hasSkillProvider").GetBoolean()); + Assert.False(request.Params.TryGetProperty("skillProvider", out _)); + Assert.False(request.Params.TryGetProperty("skillDirectories", out _)); + Assert.False(request.Params.TryGetProperty("tools", out _)); + Assert.False(string.IsNullOrEmpty(request.Params.GetProperty("sessionId").GetString())); + var callback = listDuringOpen; + Assert.NotNull(callback); + var listed = await callback.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Single(listed.EnumerateObject()); + var skills = listed.GetProperty("skills"); + Assert.Equal(2, skills.GetArrayLength()); + Assert.Equal("native-skill", skills[0].GetProperty("name").GetString()); + Assert.Equal("A text-only test skill", skills[0].GetProperty("description").GetString()); + Assert.False(skills[0].GetProperty("userInvocable").GetBoolean()); + Assert.False(skills[0].GetProperty("disableModelInvocation").GetBoolean()); + Assert.Equal("[topic]", skills[0].GetProperty("argumentHint").GetString()); + Assert.Equal(5, skills[0].EnumerateObject().Count()); + Assert.Equal(2, skills[1].EnumerateObject().Count()); + Assert.Equal(1, provider.ListCalls); + Assert.Equal(0, provider.ReadCalls); + + var read = await server.SendRequestAsync("skillProvider.read", SkillRequest(session.SessionId)); + + Assert.Single(read.EnumerateObject()); + Assert.Equal(SkillMarkdown, read.GetProperty("markdown").GetString()); + Assert.Equal("native-skill", provider.LastReadName); + Assert.Equal(1, provider.ReadCalls); + Assert.True(provider.ListCancellationToken.CanBeCanceled); + Assert.Equal(provider.ListCancellationToken, provider.ReadCancellationToken); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SkillProvider_Disabled_Skills_Keep_The_Provider_Bound(bool resume) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var provider = new TestSkillProvider { Skills = [] }; + + await using var session = await OpenSessionWithSkillsAsync(client, resume, provider, enableSkills: false); + + var request = Assert.Single(server.Requests, request => request.Method == (resume ? "session.resume" : "session.create")); + Assert.True(request.Params.GetProperty("hasSkillProvider").GetBoolean()); + Assert.False(request.Params.GetProperty("enableSkills").GetBoolean()); + Assert.Equal(0, provider.ListCalls); + Assert.Equal(0, provider.ReadCalls); + + // The runtime controls skill loading; the SDK must not discard a dormant binding. + var listed = await server.SendRequestAsync("skillProvider.list", SkillRequest(session.SessionId)); + Assert.Empty(listed.GetProperty("skills").EnumerateArray()); + Assert.Equal(1, provider.ListCalls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SkillProvider_Absence_Omits_Flag_And_Rejects_Callbacks(bool resume) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + await using var session = await OpenSessionWithSkillsAsync(client, resume, provider: null); + + var request = Assert.Single(server.Requests, request => request.Method == (resume ? "session.resume" : "session.create")); + Assert.False(request.Params.TryGetProperty("hasSkillProvider", out _)); + Assert.False(request.Params.TryGetProperty("skillProvider", out _)); + foreach (var method in new[] { "skillProvider.list", "skillProvider.read" }) + { + var error = await Assert.ThrowsAsync(() => + server.SendRequestAsync(method, SkillRequest(session.SessionId))); + Assert.Contains($"No skill provider registered for session {session.SessionId}", error.Message); + } + } + + [Fact] + public async Task SkillProvider_Callbacks_Are_Session_Scoped_And_Rebound_On_Resume() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var firstProvider = new TestSkillProvider { Markdown = SkillMarkdown + "\nFirst session." }; + var secondProvider = new TestSkillProvider { Markdown = SkillMarkdown + "\nSecond session." }; + await using var first = await client.CreateSessionAsync(new SessionConfig { SkillProvider = firstProvider }); + await using var second = await client.CreateSessionAsync(new SessionConfig { SkillProvider = secondProvider }); + + var firstRead = await server.SendRequestAsync("skillProvider.read", SkillRequest(first.SessionId)); + var secondRead = await server.SendRequestAsync("skillProvider.read", SkillRequest(second.SessionId)); + Assert.Equal(firstProvider.Markdown, firstRead.GetProperty("markdown").GetString()); + Assert.Equal(secondProvider.Markdown, secondRead.GetProperty("markdown").GetString()); + + foreach (var method in new[] { "skillProvider.list", "skillProvider.read" }) + { + var error = await Assert.ThrowsAsync(() => + server.SendRequestAsync(method, SkillRequest("unknown-session"))); + Assert.Contains("Unknown session unknown-session", error.Message); + } + Assert.Equal(0, firstProvider.ListCalls); + Assert.Equal(0, secondProvider.ListCalls); + Assert.Equal(1, firstProvider.ReadCalls); + Assert.Equal(1, secondProvider.ReadCalls); + + await first.DisposeAsync(); + var disposedError = await Assert.ThrowsAsync(() => + server.SendRequestAsync("skillProvider.read", SkillRequest(first.SessionId))); + Assert.Contains($"Unknown session {first.SessionId}", disposedError.Message); + + var replacementProvider = new TestSkillProvider { Markdown = SkillMarkdown + "\nResumed session." }; + await using var resumed = await client.ResumeSessionAsync(first.SessionId, new ResumeSessionConfig + { + SkillProvider = replacementProvider + }); + var resumedRead = await server.SendRequestAsync("skillProvider.read", SkillRequest(resumed.SessionId)); + Assert.Equal(replacementProvider.Markdown, resumedRead.GetProperty("markdown").GetString()); + Assert.Equal(1, firstProvider.ReadCalls); + Assert.Equal(1, replacementProvider.ReadCalls); + + await resumed.DisposeAsync(); + await using var unbound = await client.ResumeSessionAsync(first.SessionId, new ResumeSessionConfig()); + var unboundError = await Assert.ThrowsAsync(() => + server.SendRequestAsync("skillProvider.read", SkillRequest(unbound.SessionId))); + Assert.Contains("No skill provider registered", unboundError.Message); + Assert.Equal(1, replacementProvider.ReadCalls); + Assert.Equal(1, secondProvider.ReadCalls); + } + + [Fact] + public async Task SkillProvider_Is_Unregistered_After_Failed_Creation() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + server.FailSessionCreate(); + var provider = new TestSkillProvider(); + + await Assert.ThrowsAsync(() => client.CreateSessionAsync(new SessionConfig + { + SessionId = "failed-skill-session", + SkillProvider = provider + })); + + foreach (var method in new[] { "skillProvider.list", "skillProvider.read" }) + { + var error = await Assert.ThrowsAsync(() => + server.SendRequestAsync(method, SkillRequest("failed-skill-session"))); + Assert.Contains("Unknown session failed-skill-session", error.Message); + } + Assert.Equal(0, provider.ListCalls); + Assert.Equal(0, provider.ReadCalls); + } + + [Fact] + public async Task SkillProvider_Is_Unregistered_After_Deletion() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var provider = new TestSkillProvider(); + await using var session = await client.CreateSessionAsync(new SessionConfig { SkillProvider = provider }); + + await client.DeleteSessionAsync(session.SessionId); + + var error = await Assert.ThrowsAsync(() => + server.SendRequestAsync("skillProvider.list", SkillRequest(session.SessionId))); + Assert.Contains($"Unknown session {session.SessionId}", error.Message); + Assert.Equal(0, provider.ListCalls); + } + + [Theory] + [InlineData("skillProvider.list")] + [InlineData("skillProvider.read")] + public async Task SkillProvider_Exceptions_Are_Returned_As_Rpc_Errors(string method) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var provider = new TestSkillProvider { Error = new InvalidOperationException("skill provider failed") }; + await using var session = await client.CreateSessionAsync(new SessionConfig { SkillProvider = provider }); + + var error = await Assert.ThrowsAsync(() => + server.SendRequestAsync(method, SkillRequest(session.SessionId))); + + Assert.Contains("skill provider failed", error.Message); + } + + [Fact] + public async Task SkillProvider_Receives_The_Rpc_Lifetime_Cancellation_Token() + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + var provider = new TestSkillProvider(); + await using var session = await client.CreateSessionAsync(new SessionConfig { SkillProvider = provider }); + await server.SendRequestAsync("skillProvider.list", SkillRequest(session.SessionId)); + await server.SendRequestAsync("skillProvider.read", SkillRequest(session.SessionId)); + Assert.True(provider.ListCancellationToken.CanBeCanceled); + Assert.True(provider.ReadCancellationToken.CanBeCanceled); + Assert.False(provider.ListCancellationToken.IsCancellationRequested); + Assert.False(provider.ReadCancellationToken.IsCancellationRequested); + + await client.ForceStopAsync(); + + Assert.True(provider.ListCancellationToken.IsCancellationRequested); + Assert.True(provider.ReadCancellationToken.IsCancellationRequested); + } + + [Theory] + [InlineData(null)] + [InlineData("explicit-skill-session")] + public async Task SkillProvider_Rejects_Cloud_Sessions_Before_Connecting(string? sessionId) + { + await using var server = await FakeCopilotServer.StartAsync(); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + var error = await Assert.ThrowsAsync(() => client.CreateSessionAsync(new SessionConfig + { + Cloud = new CloudSessionOptions(), + SessionId = sessionId, + SkillProvider = new TestSkillProvider() + })); + + Assert.Equal("config", error.ParamName); + Assert.Contains("SkillProvider is not supported for cloud sessions", error.Message); + Assert.Empty(server.Requests); + } + + private static Task OpenSessionWithSkillsAsync( + CopilotClient client, bool resume, SkillProvider? provider, bool? enableSkills = null) + => resume + ? client.ResumeSessionAsync("resumed-skill-session", new ResumeSessionConfig + { + SkillProvider = provider, + EnableSkills = enableSkills + }) + : client.CreateSessionAsync(new SessionConfig + { + SkillProvider = provider, + EnableSkills = enableSkills + }); + + private static Dictionary SkillRequest(string sessionId) + => new() { ["sessionId"] = sessionId, ["name"] = "native-skill" }; + + private sealed class TestSkillProvider : SkillProvider + { + public IReadOnlyList Skills { get; init; } = + [ + new SkillProviderDescriptor + { + Name = "native-skill", + Description = "A text-only test skill", + UserInvocable = false, + DisableModelInvocation = false, + ArgumentHint = "[topic]" + } + ]; + + public string Markdown { get; init; } = SkillMarkdown; + public Exception? Error { get; init; } + public int ListCalls { get; private set; } + public int ReadCalls { get; private set; } + public string? LastReadName { get; private set; } + public CancellationToken ListCancellationToken { get; private set; } + public CancellationToken ReadCancellationToken { get; private set; } + + public override Task> ListAsync(CancellationToken cancellationToken = default) + { + ListCalls++; + ListCancellationToken = cancellationToken; + return Error is { } error + ? Task.FromException>(error) + : Task.FromResult(Skills); + } + + public override Task ReadAsync(string name, CancellationToken cancellationToken = default) + { + ReadCalls++; + LastReadName = name; + ReadCancellationToken = cancellationToken; + return Error is { } error ? Task.FromException(error) : Task.FromResult(Markdown); + } + } +} +#endif diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 88de6abd0d..c32969739f 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -18,7 +18,7 @@ namespace GitHub.Copilot.Test.Unit; -public sealed class ClientSessionLifetimeTests +public sealed partial class ClientSessionLifetimeTests { private sealed record RpcRequestRecord(string Method, JsonElement Params); diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index 20d5a1c296..2fca4dd06f 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -8,6 +8,42 @@ namespace GitHub.Copilot.Test.Unit; public class CloneTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SessionConfig_Clone_PreservesSkillProviderByReference(bool resume) + { + var provider = new EmptySkillProvider(); + SessionConfigBase original = resume + ? new ResumeSessionConfig { SkillProvider = provider } + : new SessionConfig { SkillProvider = provider }; + + SessionConfigBase clone = original is ResumeSessionConfig resumeConfig + ? resumeConfig.Clone() + : ((SessionConfig)original).Clone(); + + Assert.Same(provider, clone.SkillProvider); + clone.SkillProvider = null; + Assert.Same(provider, original.SkillProvider); + Assert.Null(clone.SkillProvider); + } + + [Fact] + public void SessionConfig_Clone_PreservesAbsentSkillProvider() + { + Assert.Null(new SessionConfig().Clone().SkillProvider); + Assert.Null(new ResumeSessionConfig().Clone().SkillProvider); + } + + private sealed class EmptySkillProvider : SkillProvider + { + public override Task> ListAsync(CancellationToken cancellationToken = default) + => Task.FromResult>([]); + + public override Task ReadAsync(string name, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + [Fact] public void CopilotClientOptions_Clone_CopiesAllProperties() {