From f4b99087ce6c6655381ce52b0c817f904cf2da73 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 3 Mar 2026 11:30:43 -0500 Subject: [PATCH 1/3] Improve .NET SDK build infrastructure and documentation - Add Central Package Management (Directory.Packages.props) with all package versions centralized - Add Directory.Build.props with shared properties (TargetFramework, ImplicitUsings, Nullable, TreatWarningsAsErrors) - Add nuget.config with single nuget.org source and package source mapping (required by CPM) - Add global.json specifying .NET 10 SDK (the library is still built with a net8.0 TFM) - Update all CI workflows from .NET 8.0.x to .NET 10.0.x - Enable XML documentation file generation (GenerateDocumentationFile) - Add XML doc comments to all non-generated public types and members - Add valid-value lists in XML docs for string properties with known values (e.g. PermissionRequest.Kind, ToolResultObject.ResultType) - Add #pragma warning disable CS1591 to generated files (SessionEvents.cs, Rpc.cs) and codegen scripts - Enable EmbedUntrackedSources, IncludeSymbols, SymbolPackageFormat - Enable ContinuousIntegrationBuild conditional on CI/TF_BUILD environment variables - Add PackageProjectUrl to package metadata - Add [EditorBrowsable(Never)] to obsolete GithubToken property - Upgrade analysis level and fix some diagnostics --- .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/docs-validation.yml | 2 +- .github/workflows/dotnet-sdk-tests.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/scenario-builds.yml | 2 +- .../workflows/update-copilot-dependency.yml | 2 +- dotnet/Directory.Build.props | 12 + dotnet/Directory.Packages.props | 19 + dotnet/global.json | 6 + dotnet/nuget.config | 12 + dotnet/samples/Chat.csproj | 3 - dotnet/src/Client.cs | 165 +++-- dotnet/src/Generated/Rpc.cs | 13 +- dotnet/src/Generated/SessionEvents.cs | 3 + dotnet/src/GitHub.Copilot.SDK.csproj | 23 +- dotnet/src/SdkProtocolVersion.cs | 7 +- dotnet/src/Session.cs | 36 +- dotnet/src/Types.cs | 566 +++++++++++++++++- dotnet/test/ClientTests.cs | 17 +- dotnet/test/GitHub.Copilot.SDK.Test.csproj | 14 +- dotnet/test/Harness/CapiProxy.cs | 9 +- dotnet/test/Harness/E2ETestBase.cs | 17 +- dotnet/test/Harness/E2ETestContext.cs | 22 +- dotnet/test/SessionTests.cs | 4 +- dotnet/test/ToolsTests.cs | 9 +- nodejs/scripts/update-protocol-version.ts | 2 +- scripts/codegen/csharp.ts | 9 +- 27 files changed, 808 insertions(+), 172 deletions(-) create mode 100644 dotnet/Directory.Build.props create mode 100644 dotnet/Directory.Packages.props create mode 100644 dotnet/global.json create mode 100644 dotnet/nuget.config diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index b6650cd57..afe9b03bd 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -58,7 +58,7 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" # Install just command runner - name: Install just diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index 20031af07..89d2fa2a9 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -108,7 +108,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Install validation dependencies working-directory: scripts/docs-validation diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index bbe577bc1..3ca9d1de9 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@v6.0.2 - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - uses: actions/setup-node@v6 with: node-version: "22" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b855566a5..89df96c9f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -121,7 +121,7 @@ jobs: - uses: actions/checkout@v6.0.2 - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Restore dependencies run: dotnet restore - name: Build and pack diff --git a/.github/workflows/scenario-builds.yml b/.github/workflows/scenario-builds.yml index a66ede5ec..54d7257e5 100644 --- a/.github/workflows/scenario-builds.yml +++ b/.github/workflows/scenario-builds.yml @@ -152,7 +152,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - uses: actions/cache@v4 with: diff --git a/.github/workflows/update-copilot-dependency.yml b/.github/workflows/update-copilot-dependency.yml index 34a6c97a2..b1d3cae6d 100644 --- a/.github/workflows/update-copilot-dependency.yml +++ b/.github/workflows/update-copilot-dependency.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Update @github/copilot in nodejs env: diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props new file mode 100644 index 000000000..badf8483d --- /dev/null +++ b/dotnet/Directory.Build.props @@ -0,0 +1,12 @@ + + + + net8.0 + 14 + enable + enable + 10.0-minimum + true + + + diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props new file mode 100644 index 000000000..5447fee51 --- /dev/null +++ b/dotnet/Directory.Packages.props @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/dotnet/global.json b/dotnet/global.json new file mode 100644 index 000000000..c0c9c61a0 --- /dev/null +++ b/dotnet/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "major" + } +} diff --git a/dotnet/nuget.config b/dotnet/nuget.config new file mode 100644 index 000000000..128d95e59 --- /dev/null +++ b/dotnet/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dotnet/samples/Chat.csproj b/dotnet/samples/Chat.csproj index 4121ceaef..ad90a6062 100644 --- a/dotnet/samples/Chat.csproj +++ b/dotnet/samples/Chat.csproj @@ -1,9 +1,6 @@ Exe - net8.0 - enable - enable diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index cf6c5a29d..223935e7f 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -16,6 +16,7 @@ using System.Text.Json.Serialization; using System.Text.RegularExpressions; using GitHub.Copilot.SDK.Rpc; +using System.Globalization; namespace GitHub.Copilot.SDK; @@ -51,7 +52,7 @@ namespace GitHub.Copilot.SDK; /// await session.SendAsync(new MessageOptions { Prompt = "Hello!" }); /// /// -public partial class CopilotClient : IDisposable, IAsyncDisposable +public sealed partial class CopilotClient : IDisposable, IAsyncDisposable { private readonly ConcurrentDictionary _sessions = new(); private readonly CopilotClientOptions _options; @@ -62,8 +63,8 @@ public partial class CopilotClient : IDisposable, IAsyncDisposable private readonly string? _optionsHost; private List? _modelsCache; private readonly SemaphoreSlim _modelsCacheLock = new(1, 1); - private readonly List> _lifecycleHandlers = new(); - private readonly Dictionary>> _typedLifecycleHandlers = new(); + private readonly List> _lifecycleHandlers = []; + private readonly Dictionary>> _typedLifecycleHandlers = []; private readonly object _lifecycleHandlersLock = new(); private ServerRpc? _rpc; @@ -241,7 +242,7 @@ public async Task StopAsync() } catch (Exception ex) { - errors.Add(new Exception($"Failed to destroy session {session.SessionId}: {ex.Message}", ex)); + errors.Add(new IOException($"Failed to destroy session {session.SessionId}: {ex.Message}", ex)); } } @@ -611,7 +612,7 @@ public async Task> ListModelsAsync(CancellationToken cancellatio // Check cache (already inside lock) if (_modelsCache is not null) { - return new List(_modelsCache); // Return a copy to prevent cache mutation + return [.. _modelsCache]; // Return a copy to prevent cache mutation } // Cache miss - fetch from backend while holding lock @@ -621,7 +622,7 @@ public async Task> ListModelsAsync(CancellationToken cancellatio // Update cache before releasing lock _modelsCache = response.Models; - return new List(response.Models); // Return a copy to prevent cache mutation + return [.. response.Models]; // Return a copy to prevent cache mutation } finally { @@ -820,7 +821,7 @@ public IDisposable On(string eventType, Action handler) { if (!_typedLifecycleHandlers.TryGetValue(eventType, out var handlers)) { - handlers = new List>(); + handlers = []; _typedLifecycleHandlers[eventType] = handlers; } handlers.Add(handler); @@ -846,9 +847,9 @@ private void DispatchLifecycleEvent(SessionLifecycleEvent evt) lock (_lifecycleHandlersLock) { typedHandlers = _typedLifecycleHandlers.TryGetValue(evt.Type, out var handlers) - ? new List>(handlers) - : new List>(); - wildcardHandlers = new List>(_lifecycleHandlers); + ? [.. handlers] + : []; + wildcardHandlers = [.. _lifecycleHandlers]; } foreach (var handler in typedHandlers) @@ -907,7 +908,7 @@ private Task EnsureConnectedAsync(CancellationToken cancellationToke return (Task)StartAsync(cancellationToken); } - private async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) + private static async Task VerifyProtocolVersionAsync(Connection connection, CancellationToken cancellationToken) { var expectedVersion = SdkProtocolVersion.GetVersion(); var pingResponse = await InvokeRpcAsync( @@ -950,7 +951,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } else if (options.Port > 0) { - args.AddRange(["--port", options.Port.ToString()]); + args.AddRange(["--port", options.Port.ToString(CultureInfo.InvariantCulture)]); } // Add auth-related flags @@ -1013,7 +1014,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio { stderrBuffer.AppendLine(line); } - logger.LogDebug("[CLI] {Line}", line); + + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("[CLI] {Line}", line); + } } } }, cancellationToken); @@ -1027,13 +1032,10 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio while (!cts.Token.IsCancellationRequested) { - var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token); - if (line == null) throw new Exception("CLI process exited unexpectedly"); - - var match = Regex.Match(line, @"listening on port (\d+)", RegexOptions.IgnoreCase); - if (match.Success) + var line = await cliProcess.StandardOutput.ReadLineAsync(cts.Token) ?? throw new IOException("CLI process exited unexpectedly"); + if (ListeningOnPortRegex().Match(line) is { Success: true } match) { - detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value); + detectedLocalhostTcpPort = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); break; } } @@ -1133,8 +1135,10 @@ private async Task ConnectToServerAsync(Process? cliProcess, string? [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Using happy path from https://microsoft.github.io/vs-streamjsonrpc/docs/nativeAOT.html")] - private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() => - new SystemTextJsonFormatter() { JsonSerializerOptions = SerializerOptionsForMessageFormatter }; + private static SystemTextJsonFormatter CreateSystemTextJsonFormatter() + { + return new() { JsonSerializerOptions = SerializerOptionsForMessageFormatter }; + } private static JsonSerializerOptions SerializerOptionsForMessageFormatter { get; } = CreateSerializerOptions(); @@ -1157,8 +1161,10 @@ private static JsonSerializerOptions CreateSerializerOptions() return options; } - internal CopilotSession? GetSession(string sessionId) => - _sessions.TryGetValue(sessionId, out var session) ? session : null; + internal CopilotSession? GetSession(string sessionId) + { + return _sessions.TryGetValue(sessionId, out var session) ? session : null; + } /// /// Disposes the synchronously. @@ -1168,7 +1174,7 @@ private static JsonSerializerOptions CreateSerializerOptions() /// public void Dispose() { - DisposeAsync().GetAwaiter().GetResult(); + DisposeAsync().AsTask().GetAwaiter().GetResult(); } /// @@ -1223,12 +1229,7 @@ public async Task OnToolCall(string sessionId, string toolName, object? arguments) { - var session = client.GetSession(sessionId); - if (session == null) - { - throw new ArgumentException($"Unknown session {sessionId}"); - } - + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); if (session.GetTool(toolName) is not { } tool) { return new ToolCallResponse(new ToolResultObject @@ -1335,12 +1336,7 @@ public async Task OnPermissionRequest(string sessionI public async Task OnUserInputRequest(string sessionId, string question, List? choices = null, bool? allowFreeform = null) { - var session = client.GetSession(sessionId); - if (session == null) - { - throw new ArgumentException($"Unknown session {sessionId}"); - } - + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var request = new UserInputRequest { Question = question, @@ -1354,12 +1350,7 @@ public async Task OnUserInputRequest(string sessionId, public async Task OnHooksInvoke(string sessionId, string hookType, JsonElement input) { - var session = client.GetSession(sessionId); - if (session == null) - { - throw new ArgumentException($"Unknown session {sessionId}"); - } - + var session = client.GetSession(sessionId) ?? throw new ArgumentException($"Unknown session {sessionId}"); var output = await session.HandleHooksInvokeAsync(hookType, input); return new HooksInvokeResponse(output); } @@ -1499,33 +1490,70 @@ public LoggerTraceSource(ILogger logger) : base(nameof(LoggerTraceSource), Sourc private sealed class LoggerTraceListener(ILogger logger) : TraceListener { - public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? message) => - logger.Log(MapLevel(eventType), "[{Source}] {Message}", source, message); + public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? message) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Message}", source, message); + } + } - public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? format, params object?[]? args) => - logger.Log(MapLevel(eventType), "[{Source}] {Message}", source, args is null || args.Length == 0 ? format : string.Format(format ?? "", args)); + public override void TraceEvent(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, string? format, params object?[]? args) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Message}", source, args is null || args.Length == 0 ? format : string.Format(CultureInfo.InvariantCulture, format ?? "", args)); + } + } - public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, object? data) => - logger.Log(MapLevel(eventType), "[{Source}] {Data}", source, data); + public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, object? data) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Data}", source, data); + } + } - public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, params object?[]? data) => - logger.Log(MapLevel(eventType), "[{Source}] {Data}", source, data is null ? null : string.Join(", ", data)); + public override void TraceData(TraceEventCache? eventCache, string source, TraceEventType eventType, int id, params object?[]? data) + { + LogLevel level = MapLevel(eventType); + if (logger.IsEnabled(level)) + { + logger.Log(level, "[{Source}] {Data}", source, data is null ? null : string.Join(", ", data)); + } + } - public override void Write(string? message) => - logger.LogTrace("{Message}", message); + public override void Write(string? message) + { + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace("{Message}", message); + } + } - public override void WriteLine(string? message) => - logger.LogTrace("{Message}", message); + public override void WriteLine(string? message) + { + if (logger.IsEnabled(LogLevel.Trace)) + { + logger.LogTrace("{Message}", message); + } + } - private static LogLevel MapLevel(TraceEventType eventType) => eventType switch + private static LogLevel MapLevel(TraceEventType eventType) { - TraceEventType.Critical => LogLevel.Critical, - TraceEventType.Error => LogLevel.Error, - TraceEventType.Warning => LogLevel.Warning, - TraceEventType.Information => LogLevel.Information, - TraceEventType.Verbose => LogLevel.Debug, - _ => LogLevel.Trace - }; + return eventType switch + { + TraceEventType.Critical => LogLevel.Critical, + TraceEventType.Error => LogLevel.Error, + TraceEventType.Warning => LogLevel.Warning, + TraceEventType.Information => LogLevel.Information, + TraceEventType.Verbose => LogLevel.Debug, + _ => LogLevel.Trace + }; + } } } @@ -1558,11 +1586,20 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(UserInputRequest))] [JsonSerializable(typeof(UserInputResponse))] internal partial class ClientJsonContext : JsonSerializerContext; + + [GeneratedRegex(@"listening on port ([0-9]+)", RegexOptions.IgnoreCase)] + private static partial Regex ListeningOnPortRegex(); } -// Must inherit from AIContent as a signal to MEAI to avoid JSON-serializing the -// value before passing it back to us +/// +/// Wraps a as to pass structured tool results +/// back through Microsoft.Extensions.AI without JSON serialization. +/// +/// The tool result to wrap. public class ToolResultAIContent(ToolResultObject toolResult) : AIContent { + /// + /// Gets the underlying . + /// public ToolResultObject Result => toolResult; } diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 73d6e769d..4c4bac0f3 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -5,6 +5,9 @@ // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: api.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; using StreamJsonRpc; @@ -116,7 +119,7 @@ public class ModelsListResult { /// List of available models with full metadata [JsonPropertyName("models")] - public List Models { get; set; } = new(); + public List Models { get; set; } = []; } public class Tool @@ -146,7 +149,7 @@ public class ToolsListResult { /// List of available built-in tools with metadata [JsonPropertyName("tools")] - public List Tools { get; set; } = new(); + public List Tools { get; set; } = []; } internal class ToolsListRequest @@ -186,7 +189,7 @@ public class AccountGetQuotaResult { /// Quota snapshots keyed by type (e.g., chat, completions, premium_interactions) [JsonPropertyName("quotaSnapshots")] - public Dictionary QuotaSnapshots { get; set; } = new(); + public Dictionary QuotaSnapshots { get; set; } = []; } public class SessionModelGetCurrentResult @@ -289,7 +292,7 @@ public class SessionWorkspaceListFilesResult { /// Relative file paths in the workspace files directory [JsonPropertyName("files")] - public List Files { get; set; } = new(); + public List Files { get; set; } = []; } internal class SessionWorkspaceListFilesRequest @@ -365,7 +368,7 @@ public class SessionAgentListResult { /// Available custom agents [JsonPropertyName("agents")] - public List Agents { get; set; } = new(); + public List Agents { get; set; } = []; } internal class SessionAgentListRequest diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 25a65f3f7..6de4d0ef3 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -5,6 +5,9 @@ // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: session-events.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; diff --git a/dotnet/src/GitHub.Copilot.SDK.csproj b/dotnet/src/GitHub.Copilot.SDK.csproj index 019788cfa..8ae53ca74 100644 --- a/dotnet/src/GitHub.Copilot.SDK.csproj +++ b/dotnet/src/GitHub.Copilot.SDK.csproj @@ -1,20 +1,26 @@  - net8.0 - enable - enable - true + true 0.1.0 SDK for programmatic control of GitHub Copilot CLI GitHub GitHub Copyright (c) Microsoft Corporation. All rights reserved. MIT + https://github.com/github/copilot-sdk README.md https://github.com/github/copilot-sdk github;copilot;sdk;jsonrpc;agent true + true + snupkg + true + true + + + + true @@ -22,10 +28,11 @@ - - - - + + + + + diff --git a/dotnet/src/SdkProtocolVersion.cs b/dotnet/src/SdkProtocolVersion.cs index bb47dfebf..b4c2a367f 100644 --- a/dotnet/src/SdkProtocolVersion.cs +++ b/dotnet/src/SdkProtocolVersion.cs @@ -11,10 +11,13 @@ internal static class SdkProtocolVersion /// /// The SDK protocol version. /// - public const int Version = 2; + private const int Version = 2; /// /// Gets the SDK protocol version. /// - public static int GetVersion() => Version; + public static int GetVersion() + { + return Version; + } } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 8bbee6071..68b02b7c0 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -42,7 +42,7 @@ namespace GitHub.Copilot.SDK; /// await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello, world!" }); /// /// -public partial class CopilotSession : IAsyncDisposable +public sealed partial class CopilotSession : IAsyncDisposable { /// /// Multicast delegate used as a thread-safe, insertion-ordered handler list. @@ -50,8 +50,8 @@ public partial class CopilotSession : IAsyncDisposable /// Dispatch reads the field once (inherent snapshot, no allocation). /// Expected handler count is small (typically 1–3), so Delegate.Combine/Remove cost is negligible. /// - private event SessionEventHandler? _eventHandlers; - private readonly Dictionary _toolHandlers = new(); + private event SessionEventHandler? EventHandlers; + private readonly Dictionary _toolHandlers = []; private readonly JsonRpc _rpc; private volatile PermissionRequestHandler? _permissionHandler; private volatile UserInputHandler? _userInputHandler; @@ -96,8 +96,10 @@ internal CopilotSession(string sessionId, JsonRpc rpc, string? workspacePath = n WorkspacePath = workspacePath; } - private Task InvokeRpcAsync(string method, object?[]? args, CancellationToken cancellationToken) => - CopilotClient.InvokeRpcAsync(_rpc, method, args, cancellationToken); + private Task InvokeRpcAsync(string method, object?[]? args, CancellationToken cancellationToken) + { + return CopilotClient.InvokeRpcAsync(_rpc, method, args, cancellationToken); + } /// /// Sends a message to the Copilot session and waits for the response. @@ -249,8 +251,8 @@ void Handler(SessionEvent evt) /// public IDisposable On(SessionEventHandler handler) { - _eventHandlers += handler; - return new ActionDisposable(() => _eventHandlers -= handler); + EventHandlers += handler; + return new ActionDisposable(() => EventHandlers -= handler); } /// @@ -263,7 +265,7 @@ public IDisposable On(SessionEventHandler handler) internal void DispatchEvent(SessionEvent sessionEvent) { // Reading the field once gives us a snapshot; delegates are immutable. - _eventHandlers?.Invoke(sessionEvent); + EventHandlers?.Invoke(sessionEvent); } /// @@ -288,8 +290,10 @@ internal void RegisterTools(ICollection tools) /// /// The name of the tool to retrieve. /// The tool if found; otherwise, null. - internal AIFunction? GetTool(string name) => - _toolHandlers.TryGetValue(name, out var tool) ? tool : null; + internal AIFunction? GetTool(string name) + { + return _toolHandlers.TryGetValue(name, out var tool) ? tool : null; + } /// /// Registers a handler for permission requests. @@ -348,13 +352,7 @@ internal void RegisterUserInputHandler(UserInputHandler handler) /// A task that resolves with the user's response. internal async Task HandleUserInputRequestAsync(UserInputRequest request) { - var handler = _userInputHandler; - - if (handler == null) - { - throw new InvalidOperationException("No user input handler registered"); - } - + var handler = _userInputHandler ?? throw new InvalidOperationException("No user input handler registered"); var invocation = new UserInputInvocation { SessionId = SessionId @@ -569,7 +567,7 @@ await InvokeRpcAsync( // Connection is broken or closed } - _eventHandlers = null; + EventHandlers = null; _toolHandlers.Clear(); _permissionHandler = null; @@ -595,7 +593,7 @@ internal record GetMessagesRequest internal record GetMessagesResponse { - public List Events { get; init; } = new(); + public List Events { get; init; } = []; } internal record SessionAbortRequest diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 97f5ebbbc..e294adf80 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ +using System.ComponentModel; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -9,19 +10,29 @@ namespace GitHub.Copilot.SDK; -[JsonConverter(typeof(JsonStringEnumConverter))] +/// +/// Represents the connection state of the Copilot client. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum ConnectionState { + /// The client is not connected to the server. [JsonStringEnumMemberName("disconnected")] Disconnected, + /// The client is establishing a connection to the server. [JsonStringEnumMemberName("connecting")] Connecting, + /// The client is connected and ready to communicate. [JsonStringEnumMemberName("connected")] Connected, + /// The connection is in an error state. [JsonStringEnumMemberName("error")] Error } +/// +/// Configuration options for creating a instance. +/// public class CopilotClientOptions { /// @@ -56,15 +67,45 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// Path to the Copilot CLI executable. If not specified, uses the bundled CLI from the SDK. /// public string? CliPath { get; set; } + /// + /// Additional command-line arguments to pass to the CLI process. + /// public string[]? CliArgs { get; set; } + /// + /// Working directory for the CLI process. + /// public string? Cwd { get; set; } + /// + /// Port number for the CLI server when not using stdio transport. + /// public int Port { get; set; } + /// + /// Whether to use stdio transport for communication with the CLI server. + /// public bool UseStdio { get; set; } = true; + /// + /// URL of an existing CLI server to connect to instead of starting a new one. + /// public string? CliUrl { get; set; } + /// + /// Log level for the CLI server (e.g., "info", "debug", "warn", "error"). + /// public string LogLevel { get; set; } = "info"; + /// + /// Whether to automatically start the CLI server if it is not already running. + /// public bool AutoStart { get; set; } = true; + /// + /// Whether to automatically restart the CLI server if it exits unexpectedly. + /// public bool AutoRestart { get; set; } = true; + /// + /// Environment variables to pass to the CLI process. + /// public IReadOnlyDictionary? Environment { get; set; } + /// + /// Logger instance for SDK diagnostic output. + /// public ILogger? Logger { get; set; } /// @@ -77,6 +118,7 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// /// Obsolete. Use instead. /// + [EditorBrowsable(EditorBrowsableState.Never)] [Obsolete("Use GitHubToken instead.", error: false)] public string? GithubToken { @@ -101,81 +143,188 @@ public string? GithubToken /// Other reference-type properties (for example delegates and the logger) are not /// deep-cloned; the original and the clone will share those objects. /// - public virtual CopilotClientOptions Clone() => new(this); + public virtual CopilotClientOptions Clone() + { + return new(this); + } } +/// +/// Represents a binary result returned by a tool invocation. +/// public class ToolBinaryResult { + /// + /// Base64-encoded binary data. + /// [JsonPropertyName("data")] public string Data { get; set; } = string.Empty; + /// + /// MIME type of the binary data (e.g., "image/png"). + /// [JsonPropertyName("mimeType")] public string MimeType { get; set; } = string.Empty; + /// + /// Type identifier for the binary result. + /// [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + /// + /// Optional human-readable description of the binary result. + /// [JsonPropertyName("description")] public string? Description { get; set; } } +/// +/// Represents the structured result of a tool execution. +/// public class ToolResultObject { + /// + /// Text result to be consumed by the language model. + /// [JsonPropertyName("textResultForLlm")] public string TextResultForLlm { get; set; } = string.Empty; + /// + /// Binary results (e.g., images) to be consumed by the language model. + /// [JsonPropertyName("binaryResultsForLlm")] public List? BinaryResultsForLlm { get; set; } + /// + /// Result type indicator. + /// + /// "success" — the tool executed successfully. + /// "failure" — the tool encountered an error. + /// "rejected" — the tool invocation was rejected. + /// "denied" — the tool invocation was denied by a permission check. + /// + /// [JsonPropertyName("resultType")] public string ResultType { get; set; } = "success"; + /// + /// Error message if the tool execution failed. + /// [JsonPropertyName("error")] public string? Error { get; set; } + /// + /// Log entry for the session history. + /// [JsonPropertyName("sessionLog")] public string? SessionLog { get; set; } + /// + /// Custom telemetry data associated with the tool execution. + /// [JsonPropertyName("toolTelemetry")] public Dictionary? ToolTelemetry { get; set; } } +/// +/// Contains context for a tool invocation callback. +/// public class ToolInvocation { + /// + /// Identifier of the session that triggered the tool call. + /// public string SessionId { get; set; } = string.Empty; + /// + /// Unique identifier of this specific tool call. + /// public string ToolCallId { get; set; } = string.Empty; + /// + /// Name of the tool being invoked. + /// public string ToolName { get; set; } = string.Empty; + /// + /// Arguments passed to the tool by the language model. + /// public object? Arguments { get; set; } } +/// +/// Delegate for handling tool invocations and returning a result. +/// public delegate Task ToolHandler(ToolInvocation invocation); +/// +/// Represents a permission request from the server for a tool operation. +/// public class PermissionRequest { + /// + /// Kind of permission being requested. + /// + /// "shell" — execute a shell command. + /// "write" — write to a file. + /// "read" — read a file. + /// "mcp" — invoke an MCP server tool. + /// "url" — access a URL. + /// "custom-tool" — invoke a custom tool. + /// + /// [JsonPropertyName("kind")] public string Kind { get; set; } = string.Empty; + /// + /// Identifier of the tool call that triggered the permission request. + /// [JsonPropertyName("toolCallId")] public string? ToolCallId { get; set; } + /// + /// Additional properties not explicitly modeled. + /// [JsonExtensionData] public Dictionary? ExtensionData { get; set; } } +/// +/// Result of a permission request evaluation. +/// public class PermissionRequestResult { + /// + /// Permission decision kind. + /// + /// "approved" — the operation is allowed. + /// "denied-by-rules" — denied by configured permission rules. + /// "denied-interactively-by-user" — the user explicitly denied the request. + /// "denied-no-approval-rule-and-could-not-request-from-user" — no rule matched and user approval was unavailable. + /// + /// [JsonPropertyName("kind")] public string Kind { get; set; } = string.Empty; + /// + /// Permission rules to apply for the decision. + /// [JsonPropertyName("rules")] public List? Rules { get; set; } } +/// +/// Contains context for a permission request callback. +/// public class PermissionInvocation { + /// + /// Identifier of the session that triggered the permission request. + /// public string SessionId { get; set; } = string.Empty; } +/// +/// Delegate for handling permission requests and returning a decision. +/// public delegate Task PermissionRequestHandler(PermissionRequest request, PermissionInvocation invocation); // ============================================================================ @@ -229,6 +378,9 @@ public class UserInputResponse /// public class UserInputInvocation { + /// + /// Identifier of the session that triggered the user input request. + /// public string SessionId { get; set; } = string.Empty; } @@ -246,6 +398,9 @@ public class UserInputInvocation /// public class HookInvocation { + /// + /// Identifier of the session that triggered the hook. + /// public string SessionId { get; set; } = string.Empty; } @@ -254,15 +409,27 @@ public class HookInvocation /// public class PreToolUseHookInput { + /// + /// Unix timestamp in milliseconds when the tool use was initiated. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// Name of the tool about to be executed. + /// [JsonPropertyName("toolName")] public string ToolName { get; set; } = string.Empty; + /// + /// Arguments that will be passed to the tool. + /// [JsonPropertyName("toolArgs")] public object? ToolArgs { get; set; } } @@ -273,24 +440,44 @@ public class PreToolUseHookInput public class PreToolUseHookOutput { /// - /// Permission decision: "allow", "deny", or "ask". + /// Permission decision for the pending tool call. + /// + /// "allow" — permit the tool to execute. + /// "deny" — block the tool from executing. + /// "ask" — fall through to the normal permission prompt. + /// /// [JsonPropertyName("permissionDecision")] public string? PermissionDecision { get; set; } + /// + /// Human-readable reason for the permission decision. + /// [JsonPropertyName("permissionDecisionReason")] public string? PermissionDecisionReason { get; set; } + /// + /// Modified arguments to pass to the tool instead of the original ones. + /// [JsonPropertyName("modifiedArgs")] public object? ModifiedArgs { get; set; } + /// + /// Additional context to inject into the conversation for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Whether to suppress the tool's output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } } +/// +/// Delegate invoked before a tool is executed, allowing modification or denial of the call. +/// public delegate Task PreToolUseHandler(PreToolUseHookInput input, HookInvocation invocation); /// @@ -298,18 +485,33 @@ public class PreToolUseHookOutput /// public class PostToolUseHookInput { + /// + /// Unix timestamp in milliseconds when the tool execution completed. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// Name of the tool that was executed. + /// [JsonPropertyName("toolName")] public string ToolName { get; set; } = string.Empty; + /// + /// Arguments that were passed to the tool. + /// [JsonPropertyName("toolArgs")] public object? ToolArgs { get; set; } + /// + /// Result returned by the tool execution. + /// [JsonPropertyName("toolResult")] public object? ToolResult { get; set; } } @@ -319,16 +521,28 @@ public class PostToolUseHookInput /// public class PostToolUseHookOutput { + /// + /// Modified result to replace the original tool result. + /// [JsonPropertyName("modifiedResult")] public object? ModifiedResult { get; set; } + /// + /// Additional context to inject into the conversation for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Whether to suppress the tool's output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } } +/// +/// Delegate invoked after a tool has been executed, allowing modification of the result. +/// public delegate Task PostToolUseHandler(PostToolUseHookInput input, HookInvocation invocation); /// @@ -336,12 +550,21 @@ public class PostToolUseHookOutput /// public class UserPromptSubmittedHookInput { + /// + /// Unix timestamp in milliseconds when the prompt was submitted. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// The user's prompt text. + /// [JsonPropertyName("prompt")] public string Prompt { get; set; } = string.Empty; } @@ -351,16 +574,28 @@ public class UserPromptSubmittedHookInput /// public class UserPromptSubmittedHookOutput { + /// + /// Modified prompt to use instead of the original user prompt. + /// [JsonPropertyName("modifiedPrompt")] public string? ModifiedPrompt { get; set; } + /// + /// Additional context to inject into the conversation for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Whether to suppress the prompt's output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } } +/// +/// Delegate invoked when the user submits a prompt, allowing modification of the prompt. +/// public delegate Task UserPromptSubmittedHandler(UserPromptSubmittedHookInput input, HookInvocation invocation); /// @@ -368,18 +603,32 @@ public class UserPromptSubmittedHookOutput /// public class SessionStartHookInput { + /// + /// Unix timestamp in milliseconds when the session started. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; /// - /// Source of the session start: "startup", "resume", or "new". + /// Source of the session start. + /// + /// "startup" — initial application startup. + /// "resume" — resuming a previous session. + /// "new" — starting a brand new session. + /// /// [JsonPropertyName("source")] public string Source { get; set; } = string.Empty; + /// + /// Initial prompt provided when the session was started. + /// [JsonPropertyName("initialPrompt")] public string? InitialPrompt { get; set; } } @@ -389,13 +638,22 @@ public class SessionStartHookInput /// public class SessionStartHookOutput { + /// + /// Additional context to inject into the session for the language model. + /// [JsonPropertyName("additionalContext")] public string? AdditionalContext { get; set; } + /// + /// Modified session configuration to apply at startup. + /// [JsonPropertyName("modifiedConfig")] public Dictionary? ModifiedConfig { get; set; } } +/// +/// Delegate invoked when a session starts, allowing injection of context or config changes. +/// public delegate Task SessionStartHandler(SessionStartHookInput input, HookInvocation invocation); /// @@ -403,21 +661,40 @@ public class SessionStartHookOutput /// public class SessionEndHookInput { + /// + /// Unix timestamp in milliseconds when the session ended. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; /// - /// Reason for session end: "complete", "error", "abort", "timeout", or "user_exit". + /// Reason for session end. + /// + /// "complete" — the session finished normally. + /// "error" — the session ended due to an error. + /// "abort" — the session was aborted. + /// "timeout" — the session timed out. + /// "user_exit" — the user exited the session. + /// /// [JsonPropertyName("reason")] public string Reason { get; set; } = string.Empty; + /// + /// Final message from the assistant before the session ended. + /// [JsonPropertyName("finalMessage")] public string? FinalMessage { get; set; } + /// + /// Error message if the session ended due to an error. + /// [JsonPropertyName("error")] public string? Error { get; set; } } @@ -427,16 +704,28 @@ public class SessionEndHookInput /// public class SessionEndHookOutput { + /// + /// Whether to suppress the session end output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } + /// + /// List of cleanup action identifiers to execute after the session ends. + /// [JsonPropertyName("cleanupActions")] public List? CleanupActions { get; set; } + /// + /// Summary of the session to persist for future reference. + /// [JsonPropertyName("sessionSummary")] public string? SessionSummary { get; set; } } +/// +/// Delegate invoked when a session ends, allowing cleanup actions or summary generation. +/// public delegate Task SessionEndHandler(SessionEndHookInput input, HookInvocation invocation); /// @@ -444,21 +733,39 @@ public class SessionEndHookOutput /// public class ErrorOccurredHookInput { + /// + /// Unix timestamp in milliseconds when the error occurred. + /// [JsonPropertyName("timestamp")] public long Timestamp { get; set; } + /// + /// Current working directory of the session. + /// [JsonPropertyName("cwd")] public string Cwd { get; set; } = string.Empty; + /// + /// Error message describing what went wrong. + /// [JsonPropertyName("error")] public string Error { get; set; } = string.Empty; /// - /// Context of the error: "model_call", "tool_execution", "system", or "user_input". + /// Context of the error. + /// + /// "model_call" — error during a model API call. + /// "tool_execution" — error during tool execution. + /// "system" — internal system error. + /// "user_input" — error processing user input. + /// /// [JsonPropertyName("errorContext")] public string ErrorContext { get; set; } = string.Empty; + /// + /// Whether the error is recoverable and the session can continue. + /// [JsonPropertyName("recoverable")] public bool Recoverable { get; set; } } @@ -468,22 +775,39 @@ public class ErrorOccurredHookInput /// public class ErrorOccurredHookOutput { + /// + /// Whether to suppress the error output from the conversation. + /// [JsonPropertyName("suppressOutput")] public bool? SuppressOutput { get; set; } /// - /// Error handling strategy: "retry", "skip", or "abort". + /// Error handling strategy. + /// + /// "retry" — retry the failed operation. + /// "skip" — skip the failed operation and continue. + /// "abort" — abort the session. + /// /// [JsonPropertyName("errorHandling")] public string? ErrorHandling { get; set; } + /// + /// Number of times to retry the failed operation. + /// [JsonPropertyName("retryCount")] public int? RetryCount { get; set; } + /// + /// Message to display to the user about the error. + /// [JsonPropertyName("userNotification")] public string? UserNotification { get; set; } } +/// +/// Delegate invoked when an error occurs, allowing custom error handling strategies. +/// public delegate Task ErrorOccurredHandler(ErrorOccurredHookInput input, HookInvocation invocation); /// @@ -522,32 +846,61 @@ public class SessionHooks public ErrorOccurredHandler? OnErrorOccurred { get; set; } } +/// +/// Specifies how a custom system message is applied to the session. +/// [JsonConverter(typeof(JsonStringEnumConverter))] public enum SystemMessageMode { + /// Append the custom system message to the default system message. [JsonStringEnumMemberName("append")] Append, + /// Replace the default system message entirely. [JsonStringEnumMemberName("replace")] Replace } +/// +/// Configuration for the system message used in a session. +/// public class SystemMessageConfig { + /// + /// How the system message is applied (append or replace). + /// public SystemMessageMode? Mode { get; set; } + /// + /// Content of the system message. + /// public string? Content { get; set; } } +/// +/// Configuration for a custom model provider. +/// public class ProviderConfig { + /// + /// Provider type identifier (e.g., "openai", "azure"). + /// [JsonPropertyName("type")] public string? Type { get; set; } + /// + /// Wire API format to use (e.g., "chat-completions"). + /// [JsonPropertyName("wireApi")] public string? WireApi { get; set; } + /// + /// Base URL of the provider's API endpoint. + /// [JsonPropertyName("baseUrl")] public string BaseUrl { get; set; } = string.Empty; + /// + /// API key for authenticating with the provider. + /// [JsonPropertyName("apiKey")] public string? ApiKey { get; set; } @@ -559,12 +912,21 @@ public class ProviderConfig [JsonPropertyName("bearerToken")] public string? BearerToken { get; set; } + /// + /// Azure-specific configuration options. + /// [JsonPropertyName("azure")] public AzureOptions? Azure { get; set; } } +/// +/// Azure OpenAI-specific provider options. +/// public class AzureOptions { + /// + /// Azure OpenAI API version to use (e.g., "2024-02-01"). + /// [JsonPropertyName("apiVersion")] public string? ApiVersion { get; set; } } @@ -582,7 +944,7 @@ public class McpLocalServerConfig /// List of tools to include from this server. Empty list means none. Use "*" for all. /// [JsonPropertyName("tools")] - public List Tools { get; set; } = new(); + public List Tools { get; set; } = []; /// /// Server type. Defaults to "local". @@ -606,7 +968,7 @@ public class McpLocalServerConfig /// Arguments to pass to the command. /// [JsonPropertyName("args")] - public List Args { get; set; } = new(); + public List Args { get; set; } = []; /// /// Environment variables to pass to the server. @@ -630,7 +992,7 @@ public class McpRemoteServerConfig /// List of tools to include from this server. Empty list means none. Use "*" for all. /// [JsonPropertyName("tools")] - public List Tools { get; set; } = new(); + public List Tools { get; set; } = []; /// /// Server type. Must be "http" or "sse". @@ -739,6 +1101,9 @@ public class InfiniteSessionConfig public double? BufferExhaustionThreshold { get; set; } } +/// +/// Configuration options for creating a new Copilot session. +/// public class SessionConfig { /// @@ -778,6 +1143,9 @@ protected SessionConfig(SessionConfig? other) WorkingDirectory = other.WorkingDirectory; } + /// + /// Optional session identifier; a new ID is generated if not provided. + /// public string? SessionId { get; set; } /// @@ -786,6 +1154,9 @@ protected SessionConfig(SessionConfig? other) /// public string? ClientName { get; set; } + /// + /// Model identifier to use for this session (e.g., "gpt-4o"). + /// public string? Model { get; set; } /// @@ -801,11 +1172,25 @@ protected SessionConfig(SessionConfig? other) /// public string? ConfigDir { get; set; } + /// + /// Custom tool functions available to the language model during the session. + /// public ICollection? Tools { get; set; } - + /// + /// System message configuration for the session. + /// public SystemMessageConfig? SystemMessage { get; set; } + /// + /// List of tool names to allow; only these tools will be available when specified. + /// public List? AvailableTools { get; set; } + /// + /// List of tool names to exclude from the session. + /// public List? ExcludedTools { get; set; } + /// + /// Custom model provider configuration for the session. + /// public ProviderConfig? Provider { get; set; } /// @@ -874,9 +1259,15 @@ protected SessionConfig(SessionConfig? other) /// hooks, infinite session configuration, and delegates) are not deep-cloned; the original /// and the clone will share those nested objects, and changes to them may affect both. /// - public virtual SessionConfig Clone() => new(this); + public virtual SessionConfig Clone() + { + return new(this); + } } +/// +/// Configuration options for resuming an existing Copilot session. +/// public class ResumeSessionConfig { /// @@ -927,6 +1318,9 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public string? Model { get; set; } + /// + /// Custom tool functions available to the language model during the resumed session. + /// public ICollection? Tools { get; set; } /// @@ -946,6 +1340,9 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// public List? ExcludedTools { get; set; } + /// + /// Custom model provider configuration for the resumed session. + /// public ProviderConfig? Provider { get; set; } /// @@ -1030,9 +1427,15 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// hooks, infinite session configuration, and delegates) are not deep-cloned; the original /// and the clone will share those nested objects, and changes to them may affect both. /// - public virtual ResumeSessionConfig Clone() => new(this); + public virtual ResumeSessionConfig Clone() + { + return new(this); + } } +/// +/// Options for sending a message in a Copilot session. +/// public class MessageOptions { /// @@ -1053,8 +1456,17 @@ protected MessageOptions(MessageOptions? other) Prompt = other.Prompt; } + /// + /// The prompt text to send to the assistant. + /// public string Prompt { get; set; } = string.Empty; + /// + /// File or data attachments to include with the message. + /// public List? Attachments { get; set; } + /// + /// Interaction mode for the message (e.g., "plan", "edit"). + /// public string? Mode { get; set; } /// @@ -1066,9 +1478,15 @@ protected MessageOptions(MessageOptions? other) /// Other reference-type properties (for example attachment items) are not deep-cloned; /// the original and the clone will share those nested objects. /// - public virtual MessageOptions Clone() => new(this); + public virtual MessageOptions Clone() + { + return new(this); + } } +/// +/// Delegate for handling session events emitted during a Copilot session. +/// public delegate void SessionEventHandler(SessionEvent sessionEvent); /// @@ -1101,12 +1519,30 @@ public class SessionListFilter public string? Branch { get; set; } } +/// +/// Metadata describing a Copilot session. +/// public class SessionMetadata { + /// + /// Unique identifier of the session. + /// public string SessionId { get; set; } = string.Empty; + /// + /// Time when the session was created. + /// public DateTime StartTime { get; set; } + /// + /// Time when the session was last modified. + /// public DateTime ModifiedTime { get; set; } + /// + /// Human-readable summary of the session. + /// public string? Summary { get; set; } + /// + /// Whether the session is running on a remote server. + /// public bool IsRemote { get; set; } /// Working directory context (cwd, git info) from session creation. public SessionContext? Context { get; set; } @@ -1117,10 +1553,22 @@ internal class PingRequest public string? Message { get; set; } } +/// +/// Response from a server ping request. +/// public class PingResponse { + /// + /// Echo of the ping message. + /// public string Message { get; set; } = string.Empty; + /// + /// Server timestamp when the ping was processed. + /// public long Timestamp { get; set; } + /// + /// Protocol version supported by the server. + /// public int? ProtocolVersion { get; set; } } @@ -1147,7 +1595,17 @@ public class GetAuthStatusResponse [JsonPropertyName("isAuthenticated")] public bool IsAuthenticated { get; set; } - /// Authentication type (user, env, gh-cli, hmac, api-key, token) + /// + /// Authentication type. + /// + /// "user" — authenticated via user login. + /// "env" — authenticated via environment variable. + /// "gh-cli" — authenticated via the GitHub CLI. + /// "hmac" — authenticated via HMAC signature. + /// "api-key" — authenticated via API key. + /// "token" — authenticated via explicit token. + /// + /// [JsonPropertyName("authType")] public string? AuthType { get; set; } @@ -1169,12 +1627,21 @@ public class GetAuthStatusResponse /// public class ModelVisionLimits { + /// + /// List of supported image MIME types (e.g., "image/png", "image/jpeg"). + /// [JsonPropertyName("supported_media_types")] - public List SupportedMediaTypes { get; set; } = new(); + public List SupportedMediaTypes { get; set; } = []; + /// + /// Maximum number of images allowed in a single prompt. + /// [JsonPropertyName("max_prompt_images")] public int MaxPromptImages { get; set; } + /// + /// Maximum size in bytes for a single prompt image. + /// [JsonPropertyName("max_prompt_image_size")] public int MaxPromptImageSize { get; set; } } @@ -1184,12 +1651,21 @@ public class ModelVisionLimits /// public class ModelLimits { + /// + /// Maximum number of tokens allowed in the prompt. + /// [JsonPropertyName("max_prompt_tokens")] public int? MaxPromptTokens { get; set; } + /// + /// Maximum total tokens in the context window. + /// [JsonPropertyName("max_context_window_tokens")] public int MaxContextWindowTokens { get; set; } + /// + /// Vision-specific limits for the model. + /// [JsonPropertyName("vision")] public ModelVisionLimits? Vision { get; set; } } @@ -1199,6 +1675,9 @@ public class ModelLimits /// public class ModelSupports { + /// + /// Whether this model supports image/vision inputs. + /// [JsonPropertyName("vision")] public bool Vision { get; set; } @@ -1214,9 +1693,15 @@ public class ModelSupports /// public class ModelCapabilities { + /// + /// Feature support flags for the model. + /// [JsonPropertyName("supports")] public ModelSupports Supports { get; set; } = new(); + /// + /// Token and resource limits for the model. + /// [JsonPropertyName("limits")] public ModelLimits Limits { get; set; } = new(); } @@ -1226,9 +1711,15 @@ public class ModelCapabilities /// public class ModelPolicy { + /// + /// Policy state of the model (e.g., "enabled", "disabled"). + /// [JsonPropertyName("state")] public string State { get; set; } = string.Empty; + /// + /// Terms or conditions associated with using the model. + /// [JsonPropertyName("terms")] public string Terms { get; set; } = string.Empty; } @@ -1238,6 +1729,9 @@ public class ModelPolicy /// public class ModelBilling { + /// + /// Billing cost multiplier relative to the base model rate. + /// [JsonPropertyName("multiplier")] public double Multiplier { get; set; } } @@ -1281,8 +1775,11 @@ public class ModelInfo /// public class GetModelsResponse { + /// + /// List of available models. + /// [JsonPropertyName("models")] - public List Models { get; set; } = new(); + public List Models { get; set; } = []; } // ============================================================================ @@ -1294,10 +1791,15 @@ public class GetModelsResponse /// public static class SessionLifecycleEventTypes { + /// A new session was created. public const string Created = "session.created"; + /// A session was deleted. public const string Deleted = "session.deleted"; + /// A session was updated. public const string Updated = "session.updated"; + /// A session was brought to the foreground. public const string Foreground = "session.foreground"; + /// A session was moved to the background. public const string Background = "session.background"; } @@ -1306,12 +1808,21 @@ public static class SessionLifecycleEventTypes /// public class SessionLifecycleEventMetadata { + /// + /// ISO 8601 timestamp when the session was created. + /// [JsonPropertyName("startTime")] public string StartTime { get; set; } = string.Empty; + /// + /// ISO 8601 timestamp when the session was last modified. + /// [JsonPropertyName("modifiedTime")] public string ModifiedTime { get; set; } = string.Empty; + /// + /// Human-readable summary of the session. + /// [JsonPropertyName("summary")] public string? Summary { get; set; } } @@ -1321,12 +1832,21 @@ public class SessionLifecycleEventMetadata /// public class SessionLifecycleEvent { + /// + /// Type of lifecycle event (see ). + /// [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + /// + /// Identifier of the session this event pertains to. + /// [JsonPropertyName("sessionId")] public string SessionId { get; set; } = string.Empty; + /// + /// Metadata associated with the session lifecycle event. + /// [JsonPropertyName("metadata")] public SessionLifecycleEventMetadata? Metadata { get; set; } } @@ -1336,9 +1856,15 @@ public class SessionLifecycleEvent /// public class GetForegroundSessionResponse { + /// + /// Identifier of the current foreground session, or null if none. + /// [JsonPropertyName("sessionId")] public string? SessionId { get; set; } + /// + /// Workspace path associated with the foreground session. + /// [JsonPropertyName("workspacePath")] public string? WorkspacePath { get; set; } } @@ -1348,9 +1874,15 @@ public class GetForegroundSessionResponse /// public class SetForegroundSessionResponse { + /// + /// Whether the foreground session was set successfully. + /// [JsonPropertyName("success")] public bool Success { get; set; } + /// + /// Error message if the operation failed. + /// [JsonPropertyName("error")] public string? Error { get; set; } } diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index 91b7f9241..3c3f3bdaa 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -230,14 +230,11 @@ public async Task Should_Report_Error_With_Stderr_When_CLI_Fails_To_Start() { var client = new CopilotClient(new CopilotClientOptions { - CliArgs = new[] { "--nonexistent-flag-for-testing" }, + CliArgs = ["--nonexistent-flag-for-testing"], UseStdio = true }); - var ex = await Assert.ThrowsAsync(async () => - { - await client.StartAsync(); - }); + var ex = await Assert.ThrowsAsync(() => client.StartAsync()); var errorMessage = ex.Message; // Verify we get the stderr output in the error message @@ -261,10 +258,7 @@ public async Task Should_Throw_When_CreateSession_Called_Without_PermissionHandl { using var client = new CopilotClient(new CopilotClientOptions()); - var ex = await Assert.ThrowsAsync(async () => - { - await client.CreateSessionAsync(new SessionConfig()); - }); + var ex = await Assert.ThrowsAsync(() => client.CreateSessionAsync(new SessionConfig())); Assert.Contains("OnPermissionRequest", ex.Message); Assert.Contains("is required", ex.Message); @@ -275,10 +269,7 @@ public async Task Should_Throw_When_ResumeSession_Called_Without_PermissionHandl { using var client = new CopilotClient(new CopilotClientOptions()); - var ex = await Assert.ThrowsAsync(async () => - { - await client.ResumeSessionAsync("some-session-id", new ResumeSessionConfig()); - }); + var ex = await Assert.ThrowsAsync(() => client.ResumeSessionAsync("some-session-id", new())); Assert.Contains("OnPermissionRequest", ex.Message); Assert.Contains("is required", ex.Message); diff --git a/dotnet/test/GitHub.Copilot.SDK.Test.csproj b/dotnet/test/GitHub.Copilot.SDK.Test.csproj index 654a988a0..fbc9f17c3 100644 --- a/dotnet/test/GitHub.Copilot.SDK.Test.csproj +++ b/dotnet/test/GitHub.Copilot.SDK.Test.csproj @@ -1,10 +1,6 @@ - net8.0 - enable - enable - true false @@ -19,17 +15,17 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/dotnet/test/Harness/CapiProxy.cs b/dotnet/test/Harness/CapiProxy.cs index a03502979..e6208f251 100644 --- a/dotnet/test/Harness/CapiProxy.cs +++ b/dotnet/test/Harness/CapiProxy.cs @@ -12,7 +12,7 @@ namespace GitHub.Copilot.SDK.Test.Harness; -public partial class CapiProxy : IAsyncDisposable +public sealed partial class CapiProxy : IAsyncDisposable { private Process? _process; private Task? _startupTask; @@ -129,10 +129,13 @@ public async Task> GetExchangesAsync() using var client = new HttpClient(); return await client.GetFromJsonAsync($"{url}/exchanges", CapiProxyJsonContext.Default.ListParsedHttpExchange) - ?? new List(); + ?? []; } - public async ValueTask DisposeAsync() => await StopAsync(); + public async ValueTask DisposeAsync() + { + await StopAsync(); + } private static string FindRepoRoot() { diff --git a/dotnet/test/Harness/E2ETestBase.cs b/dotnet/test/Harness/E2ETestBase.cs index dc1fa465d..e982090cb 100644 --- a/dotnet/test/Harness/E2ETestBase.cs +++ b/dotnet/test/Harness/E2ETestBase.cs @@ -40,7 +40,10 @@ public async Task InitializeAsync() await Ctx.ConfigureForTestAsync(_snapshotCategory, _testName); } - public Task DisposeAsync() => Task.CompletedTask; + public Task DisposeAsync() + { + return Task.CompletedTask; + } /// /// Creates a session with a default config that approves all permissions. @@ -64,9 +67,13 @@ protected Task ResumeSessionAsync(string sessionId, ResumeSessio return Client.ResumeSessionAsync(sessionId, config); } - protected static string GetSystemMessage(ParsedHttpExchange exchange) => - exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.Content ?? string.Empty; + protected static string GetSystemMessage(ParsedHttpExchange exchange) + { + return exchange.Request.Messages.FirstOrDefault(m => m.Role == "system")?.Content ?? string.Empty; + } - protected static List GetToolNames(ParsedHttpExchange exchange) => - exchange.Request.Tools?.Select(t => t.Function.Name).ToList() ?? new(); + protected static List GetToolNames(ParsedHttpExchange exchange) + { + return exchange.Request.Tools?.Select(t => t.Function.Name).ToList() ?? []; + } } diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index 00fc32075..a4472e1d3 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -7,7 +7,7 @@ namespace GitHub.Copilot.SDK.Test.Harness; -public class E2ETestContext : IAsyncDisposable +public sealed class E2ETestContext : IAsyncDisposable { public string HomeDir { get; } public string WorkDir { get; } @@ -74,7 +74,10 @@ public async Task ConfigureForTestAsync(string testFile, [CallerMemberName] stri await _proxy.ConfigureAsync(snapshotPath, WorkDir); } - public Task> GetExchangesAsync() => _proxy.GetExchangesAsync(); + public Task> GetExchangesAsync() + { + return _proxy.GetExchangesAsync(); + } public IReadOnlyDictionary GetEnvironment() { @@ -89,13 +92,16 @@ public IReadOnlyDictionary GetEnvironment() return env!; } - public CopilotClient CreateClient() => new(new CopilotClientOptions + public CopilotClient CreateClient() { - Cwd = WorkDir, - CliPath = GetCliPath(_repoRoot), - Environment = GetEnvironment(), - GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null, - }); + return new(new CopilotClientOptions + { + Cwd = WorkDir, + CliPath = GetCliPath(_repoRoot), + Environment = GetEnvironment(), + GitHubToken = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) ? "fake-token-for-e2e-tests" : null, + }); + } public async ValueTask DisposeAsync() { diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index eac00b06e..442cbfb0d 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -95,7 +95,7 @@ public async Task Should_Create_A_Session_With_AvailableTools() { var session = await CreateSessionAsync(new SessionConfig { - AvailableTools = new List { "view", "edit" } + AvailableTools = ["view", "edit"] }); await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); @@ -115,7 +115,7 @@ public async Task Should_Create_A_Session_With_ExcludedTools() { var session = await CreateSessionAsync(new SessionConfig { - ExcludedTools = new List { "view" } + ExcludedTools = ["view"] }); await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); diff --git a/dotnet/test/ToolsTests.cs b/dotnet/test/ToolsTests.cs index 1bb6b2cb2..469645ba0 100644 --- a/dotnet/test/ToolsTests.cs +++ b/dotnet/test/ToolsTests.cs @@ -137,7 +137,7 @@ await session.SendAsync(new MessageOptions City[] PerformDbQuery(DbQueryOptions query, AIFunctionArguments rawArgs) { Assert.Equal("cities", query.Table); - Assert.Equal(new[] { 12, 19 }, query.Ids); + Assert.Equal([12, 19], query.Ids); Assert.True(query.SortAscending); receivedInvocation = (ToolInvocation)rawArgs.Context![typeof(ToolInvocation)]!; return [new(19, "Passos", 135460), new(12, "San Lorenzo", 204356)]; @@ -200,7 +200,7 @@ await session.SendAsync(new MessageOptions Assert.Contains("yellow", assistantMessage!.Data.Content?.ToLowerInvariant() ?? string.Empty); - static ToolResultAIContent GetImage() => new ToolResultAIContent(new() + static ToolResultAIContent GetImage() => new(new() { BinaryResultsForLlm = [new() { // 2x2 yellow square @@ -256,10 +256,7 @@ public async Task Denies_Custom_Tool_When_Permission_Denied() var session = await Client.CreateSessionAsync(new SessionConfig { Tools = [AIFunctionFactory.Create(EncryptStringDenied, "encrypt_string")], - OnPermissionRequest = (request, invocation) => - { - return Task.FromResult(new PermissionRequestResult { Kind = "denied-interactively-by-user" }); - }, + OnPermissionRequest = async (request, invocation) => new() { Kind = "denied-interactively-by-user" }, }); await session.SendAsync(new MessageOptions diff --git a/nodejs/scripts/update-protocol-version.ts b/nodejs/scripts/update-protocol-version.ts index d0e3ecc66..46f6189e8 100644 --- a/nodejs/scripts/update-protocol-version.ts +++ b/nodejs/scripts/update-protocol-version.ts @@ -106,7 +106,7 @@ internal static class SdkProtocolVersion /// /// The SDK protocol version. /// - public const int Version = ${version}; + private const int Version = ${version}; /// /// Gets the SDK protocol version. diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index f2e536257..a759c1135 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -393,6 +393,9 @@ function generateSessionEventsCode(schema: JSONSchema7): string { // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: session-events.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; @@ -532,7 +535,8 @@ function emitRpcClass(className: string, schema: JSONSchema7, visibility: "publi let defaultVal = ""; if (isReq && !csharpType.endsWith("?")) { if (csharpType === "string") defaultVal = " = string.Empty;"; - else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<") || emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; + else if (csharpType.startsWith("List<") || csharpType.startsWith("Dictionary<")) defaultVal = " = [];"; + else if (emittedRpcClasses.has(csharpType)) defaultVal = " = new();"; } lines.push(` public ${csharpType} ${csharpName} { get; set; }${defaultVal}`); if (i < props.length - 1) lines.push(""); @@ -738,6 +742,9 @@ function generateRpcCode(schema: ApiSchema): string { // AUTO-GENERATED FILE - DO NOT EDIT // Generated from: api.schema.json +// Generated code does not have XML doc comments; suppress CS1591 to avoid warnings. +#pragma warning disable CS1591 + using System.Text.Json; using System.Text.Json.Serialization; using StreamJsonRpc; From 7331ae2389a5aaf3e4af6aa93f182e5d61beb2c1 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 3 Mar 2026 20:48:57 -0500 Subject: [PATCH 2/3] Exclude nodejs/scripts/ from Node.js SDK test triggers Changes to development utility scripts (codegen, protocol version updates) should not trigger the Node.js test suite, as they don't affect SDK runtime code and the workflow fails on fork PRs that lack the COPILOT_HMAC_KEY secret. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/nodejs-sdk-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 5947396d0..9e978a22f 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -12,6 +12,7 @@ on: - 'nodejs/**' - 'test/**' - '.github/workflows/nodejs-sdk-tests.yml' + - '!nodejs/scripts/**' - '!**/*.md' - '!**/LICENSE*' - '!**/.gitignore' From f3246b5316be1a579933fd4e489789ced2e51441 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 3 Mar 2026 20:56:56 -0500 Subject: [PATCH 3/3] Mock sendRequest in session.resume unit tests These tests verify that the correct parameters are forwarded to the RPC call, not that the CLI handles them. Mock sendRequest (like the setModel test already does) so the tests don't depend on CLI authentication, which is unavailable on fork PRs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index f9618b6dd..2fa8eb434 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -68,7 +68,13 @@ describe("CopilotClient", () => { onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); - const spy = vi.spyOn((client as any).connection!, "sendRequest"); + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); await client.resumeSession(session.sessionId, { clientName: "my-app", onPermissionRequest: approveAll, @@ -78,6 +84,7 @@ describe("CopilotClient", () => { "session.resume", expect.objectContaining({ clientName: "my-app", sessionId: session.sessionId }) ); + spy.mockRestore(); }); it("sends session.model.switchTo RPC with correct params", async () => { @@ -325,7 +332,13 @@ describe("CopilotClient", () => { onTestFinished(() => client.forceStop()); const session = await client.createSession({ onPermissionRequest: approveAll }); - const spy = vi.spyOn((client as any).connection!, "sendRequest"); + // Mock sendRequest to capture the call without hitting the runtime + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, tools: [ @@ -342,6 +355,7 @@ describe("CopilotClient", () => { expect(payload.tools).toEqual([ expect.objectContaining({ name: "grep", overridesBuiltInTool: true }), ]); + spy.mockRestore(); }); }); });