From 03e061107c91e89eb7dae34935bf38bacd9ecb66 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 10 Feb 2026 09:41:52 -0500 Subject: [PATCH 1/2] Add copy constructors and Clone methods to the various .NET config option bags Consuming libraries like Agent Framework sometimes have a need to clone the various options bags, e.g. their caller passes in options and that middle library needs to tweak the settings before passing them along (e.g. set Streaming to true or false) but it doesn't want to mutate the caller's object. Without clone methods, such libraries need to manually copy every property, which then means when new properties are added, they get ignored and options are lost. This PR adds such public Clone methods, and accomodates the non-sealed nature of the types by adding protected copy constructors that the virtual Clone methods use. (If instead we want to seal these types, that'd be viable as well.) --- dotnet/src/Types.cs | 150 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 664b35d9..4bfca92f 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -24,6 +24,34 @@ public enum ConnectionState public class CopilotClientOptions { + /// + /// Initializes a new instance of the class. + /// + public CopilotClientOptions() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected CopilotClientOptions(CopilotClientOptions? other) + { + if (other is null) return; + + AutoRestart = other.AutoRestart; + AutoStart = other.AutoStart; + CliArgs = (string[]?)other.CliArgs?.Clone(); + CliPath = other.CliPath; + CliUrl = other.CliUrl; + Cwd = other.Cwd; + Environment = other.Environment is not null ? new Dictionary(other.Environment) : null; + GithubToken = other.GithubToken; + Logger = other.Logger; + LogLevel = other.LogLevel; + Port = other.Port; + UseLoggedInUser = other.UseLoggedInUser; + UseStdio = other.UseStdio; + } + /// /// Path to the Copilot CLI executable. If not specified, uses the bundled CLI from the SDK. /// @@ -53,6 +81,15 @@ public class CopilotClientOptions /// Default: true (but defaults to false when GithubToken is provided). /// public bool? UseLoggedInUser { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Collection properties are copied into new collections so that modifications + /// to the clone do not affect the original. + /// + public virtual CopilotClientOptions Clone() => new(this); } public class ToolBinaryResult @@ -692,6 +729,40 @@ public class InfiniteSessionConfig public class SessionConfig { + /// + /// Initializes a new instance of the class. + /// + public SessionConfig() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected SessionConfig(SessionConfig? other) + { + if (other is null) return; + + AvailableTools = other.AvailableTools is not null ? [.. other.AvailableTools] : null; + ConfigDir = other.ConfigDir; + CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; + ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; + Hooks = other.Hooks; + InfiniteSessions = other.InfiniteSessions; + McpServers = other.McpServers is not null ? new Dictionary(other.McpServers) : null; + Model = other.Model; + OnPermissionRequest = other.OnPermissionRequest; + OnUserInputRequest = other.OnUserInputRequest; + Provider = other.Provider; + ReasoningEffort = other.ReasoningEffort; + SessionId = other.SessionId; + SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; + Streaming = other.Streaming; + SystemMessage = other.SystemMessage; + Tools = other.Tools is not null ? [.. other.Tools] : null; + WorkingDirectory = other.WorkingDirectory; + } + public string? SessionId { get; set; } public string? Model { get; set; } @@ -769,10 +840,53 @@ public class SessionConfig /// When enabled (default), sessions automatically manage context limits and persist state. /// public InfiniteSessionConfig? InfiniteSessions { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Collection properties are copied into new collections so that modifications + /// to the clone do not affect the original. + /// + public virtual SessionConfig Clone() => new(this); } public class ResumeSessionConfig { + /// + /// Initializes a new instance of the class. + /// + public ResumeSessionConfig() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected ResumeSessionConfig(ResumeSessionConfig? other) + { + if (other is null) return; + + AvailableTools = other.AvailableTools is not null ? [.. other.AvailableTools] : null; + ConfigDir = other.ConfigDir; + CustomAgents = other.CustomAgents is not null ? [.. other.CustomAgents] : null; + DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; + DisableResume = other.DisableResume; + ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; + Hooks = other.Hooks; + InfiniteSessions = other.InfiniteSessions; + McpServers = other.McpServers is not null ? new Dictionary(other.McpServers) : null; + Model = other.Model; + OnPermissionRequest = other.OnPermissionRequest; + OnUserInputRequest = other.OnUserInputRequest; + Provider = other.Provider; + ReasoningEffort = other.ReasoningEffort; + SkillDirectories = other.SkillDirectories is not null ? [.. other.SkillDirectories] : null; + Streaming = other.Streaming; + SystemMessage = other.SystemMessage; + Tools = other.Tools is not null ? [.. other.Tools] : null; + WorkingDirectory = other.WorkingDirectory; + } + /// /// Model to use for this session. Can change the model when resuming. /// @@ -870,13 +984,49 @@ public class ResumeSessionConfig /// Infinite session configuration for persistent workspaces and automatic compaction. /// public InfiniteSessionConfig? InfiniteSessions { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Collection properties are copied into new collections so that modifications + /// to the clone do not affect the original. + /// + public virtual ResumeSessionConfig Clone() => new(this); } public class MessageOptions { + /// + /// Initializes a new instance of the class. + /// + public MessageOptions() { } + + /// + /// Initializes a new instance of the class + /// by copying the properties of the specified instance. + /// + protected MessageOptions(MessageOptions? other) + { + if (other is null) return; + + Attachments = other.Attachments is not null ? [.. other.Attachments] : null; + Mode = other.Mode; + Prompt = other.Prompt; + } + public string Prompt { get; set; } = string.Empty; public List? Attachments { get; set; } public string? Mode { get; set; } + + /// + /// Creates a shallow clone of this instance. + /// + /// + /// Collection properties are copied into new collections so that modifications + /// to the clone do not affect the original. + /// + public virtual MessageOptions Clone() => new(this); } public delegate void SessionEventHandler(SessionEvent sessionEvent); From 2010042fde7c24203f99031f4d50ba44f498f8ea Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 10 Feb 2026 11:02:12 -0500 Subject: [PATCH 2/2] Address copilot feedback --- dotnet/src/Types.cs | 36 ++++-- dotnet/test/CloneTests.cs | 243 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 dotnet/test/CloneTests.cs diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 4bfca92f..d024071d 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -43,7 +43,7 @@ protected CopilotClientOptions(CopilotClientOptions? other) CliPath = other.CliPath; CliUrl = other.CliUrl; Cwd = other.Cwd; - Environment = other.Environment is not null ? new Dictionary(other.Environment) : null; + Environment = other.Environment; GithubToken = other.GithubToken; Logger = other.Logger; LogLevel = other.LogLevel; @@ -86,8 +86,10 @@ protected CopilotClientOptions(CopilotClientOptions? other) /// Creates a shallow clone of this instance. /// /// - /// Collection properties are copied into new collections so that modifications - /// to the clone do not affect the original. + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// 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); } @@ -749,7 +751,9 @@ protected SessionConfig(SessionConfig? other) ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; Hooks = other.Hooks; InfiniteSessions = other.InfiniteSessions; - McpServers = other.McpServers is not null ? new Dictionary(other.McpServers) : null; + McpServers = other.McpServers is not null + ? new Dictionary(other.McpServers, other.McpServers.Comparer) + : null; Model = other.Model; OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; @@ -845,8 +849,11 @@ protected SessionConfig(SessionConfig? other) /// Creates a shallow clone of this instance. /// /// - /// Collection properties are copied into new collections so that modifications - /// to the clone do not affect the original. + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// Other reference-type properties (for example provider configuration, system messages, + /// 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); } @@ -874,7 +881,9 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) ExcludedTools = other.ExcludedTools is not null ? [.. other.ExcludedTools] : null; Hooks = other.Hooks; InfiniteSessions = other.InfiniteSessions; - McpServers = other.McpServers is not null ? new Dictionary(other.McpServers) : null; + McpServers = other.McpServers is not null + ? new Dictionary(other.McpServers, other.McpServers.Comparer) + : null; Model = other.Model; OnPermissionRequest = other.OnPermissionRequest; OnUserInputRequest = other.OnUserInputRequest; @@ -989,8 +998,11 @@ protected ResumeSessionConfig(ResumeSessionConfig? other) /// Creates a shallow clone of this instance. /// /// - /// Collection properties are copied into new collections so that modifications - /// to the clone do not affect the original. + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// Other reference-type properties (for example provider configuration, system messages, + /// 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); } @@ -1023,8 +1035,10 @@ protected MessageOptions(MessageOptions? other) /// Creates a shallow clone of this instance. /// /// - /// Collection properties are copied into new collections so that modifications - /// to the clone do not affect the original. + /// Mutable collection properties are copied into new collection instances so that modifications + /// to those collections on the clone do not affect the original. + /// 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); } diff --git a/dotnet/test/CloneTests.cs b/dotnet/test/CloneTests.cs new file mode 100644 index 00000000..10ad0205 --- /dev/null +++ b/dotnet/test/CloneTests.cs @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Microsoft.Extensions.AI; +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +public class CloneTests +{ + [Fact] + public void CopilotClientOptions_Clone_CopiesAllProperties() + { + var original = new CopilotClientOptions + { + CliPath = "/usr/bin/copilot", + CliArgs = ["--verbose", "--debug"], + Cwd = "/home/user", + Port = 8080, + UseStdio = false, + CliUrl = "http://localhost:8080", + LogLevel = "debug", + AutoStart = false, + AutoRestart = false, + Environment = new Dictionary { ["KEY"] = "value" }, + GithubToken = "ghp_test", + UseLoggedInUser = false, + }; + + var clone = original.Clone(); + + Assert.Equal(original.CliPath, clone.CliPath); + Assert.Equal(original.CliArgs, clone.CliArgs); + Assert.Equal(original.Cwd, clone.Cwd); + Assert.Equal(original.Port, clone.Port); + Assert.Equal(original.UseStdio, clone.UseStdio); + Assert.Equal(original.CliUrl, clone.CliUrl); + Assert.Equal(original.LogLevel, clone.LogLevel); + Assert.Equal(original.AutoStart, clone.AutoStart); + Assert.Equal(original.AutoRestart, clone.AutoRestart); + Assert.Equal(original.Environment, clone.Environment); + Assert.Equal(original.GithubToken, clone.GithubToken); + Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser); + } + + [Fact] + public void CopilotClientOptions_Clone_CollectionsAreIndependent() + { + var original = new CopilotClientOptions + { + CliArgs = ["--verbose"], + }; + + var clone = original.Clone(); + + // Mutate clone array + clone.CliArgs![0] = "--quiet"; + + // Original is unaffected + Assert.Equal("--verbose", original.CliArgs![0]); + } + + [Fact] + public void CopilotClientOptions_Clone_EnvironmentIsShared() + { + var env = new Dictionary { ["key"] = "value" }; + var original = new CopilotClientOptions { Environment = env }; + + var clone = original.Clone(); + + Assert.Same(original.Environment, clone.Environment); + } + + [Fact] + public void SessionConfig_Clone_CopiesAllProperties() + { + var original = new SessionConfig + { + SessionId = "test-session", + Model = "gpt-4", + ReasoningEffort = "high", + ConfigDir = "/config", + AvailableTools = ["tool1", "tool2"], + ExcludedTools = ["tool3"], + WorkingDirectory = "/workspace", + Streaming = true, + McpServers = new Dictionary { ["server1"] = new object() }, + CustomAgents = [new CustomAgentConfig { Name = "agent1" }], + SkillDirectories = ["/skills"], + DisabledSkills = ["skill1"], + }; + + var clone = original.Clone(); + + Assert.Equal(original.SessionId, clone.SessionId); + Assert.Equal(original.Model, clone.Model); + Assert.Equal(original.ReasoningEffort, clone.ReasoningEffort); + Assert.Equal(original.ConfigDir, clone.ConfigDir); + Assert.Equal(original.AvailableTools, clone.AvailableTools); + Assert.Equal(original.ExcludedTools, clone.ExcludedTools); + Assert.Equal(original.WorkingDirectory, clone.WorkingDirectory); + Assert.Equal(original.Streaming, clone.Streaming); + Assert.Equal(original.McpServers.Count, clone.McpServers!.Count); + Assert.Equal(original.CustomAgents.Count, clone.CustomAgents!.Count); + Assert.Equal(original.SkillDirectories, clone.SkillDirectories); + Assert.Equal(original.DisabledSkills, clone.DisabledSkills); + } + + [Fact] + public void SessionConfig_Clone_CollectionsAreIndependent() + { + var original = new SessionConfig + { + AvailableTools = ["tool1"], + ExcludedTools = ["tool2"], + McpServers = new Dictionary { ["s1"] = new object() }, + CustomAgents = [new CustomAgentConfig { Name = "a1" }], + SkillDirectories = ["/skills"], + DisabledSkills = ["skill1"], + }; + + var clone = original.Clone(); + + // Mutate clone collections + clone.AvailableTools!.Add("tool99"); + clone.ExcludedTools!.Add("tool99"); + clone.McpServers!["s2"] = new object(); + clone.CustomAgents!.Add(new CustomAgentConfig { Name = "a2" }); + clone.SkillDirectories!.Add("/more"); + clone.DisabledSkills!.Add("skill99"); + + // Original is unaffected + Assert.Single(original.AvailableTools!); + Assert.Single(original.ExcludedTools!); + Assert.Single(original.McpServers!); + Assert.Single(original.CustomAgents!); + Assert.Single(original.SkillDirectories!); + Assert.Single(original.DisabledSkills!); + } + + [Fact] + public void SessionConfig_Clone_PreservesMcpServersComparer() + { + var servers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["server"] = new object() }; + var original = new SessionConfig { McpServers = servers }; + + var clone = original.Clone(); + + Assert.True(clone.McpServers!.ContainsKey("SERVER")); // case-insensitive lookup works + } + + [Fact] + public void ResumeSessionConfig_Clone_CollectionsAreIndependent() + { + var original = new ResumeSessionConfig + { + AvailableTools = ["tool1"], + ExcludedTools = ["tool2"], + McpServers = new Dictionary { ["s1"] = new object() }, + CustomAgents = [new CustomAgentConfig { Name = "a1" }], + SkillDirectories = ["/skills"], + DisabledSkills = ["skill1"], + }; + + var clone = original.Clone(); + + // Mutate clone collections + clone.AvailableTools!.Add("tool99"); + clone.ExcludedTools!.Add("tool99"); + clone.McpServers!["s2"] = new object(); + clone.CustomAgents!.Add(new CustomAgentConfig { Name = "a2" }); + clone.SkillDirectories!.Add("/more"); + clone.DisabledSkills!.Add("skill99"); + + // Original is unaffected + Assert.Single(original.AvailableTools!); + Assert.Single(original.ExcludedTools!); + Assert.Single(original.McpServers!); + Assert.Single(original.CustomAgents!); + Assert.Single(original.SkillDirectories!); + Assert.Single(original.DisabledSkills!); + } + + [Fact] + public void ResumeSessionConfig_Clone_PreservesMcpServersComparer() + { + var servers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["server"] = new object() }; + var original = new ResumeSessionConfig { McpServers = servers }; + + var clone = original.Clone(); + + Assert.True(clone.McpServers!.ContainsKey("SERVER")); + } + + [Fact] + public void MessageOptions_Clone_CopiesAllProperties() + { + var original = new MessageOptions + { + Prompt = "Hello", + Attachments = [new UserMessageDataAttachmentsItemFile { Path = "/test.txt", DisplayName = "test.txt" }], + Mode = "chat", + }; + + var clone = original.Clone(); + + Assert.Equal(original.Prompt, clone.Prompt); + Assert.Equal(original.Mode, clone.Mode); + Assert.Single(clone.Attachments!); + } + + [Fact] + public void MessageOptions_Clone_AttachmentsAreIndependent() + { + var original = new MessageOptions + { + Attachments = [new UserMessageDataAttachmentsItemFile { Path = "/test.txt", DisplayName = "test.txt" }], + }; + + var clone = original.Clone(); + + clone.Attachments!.Add(new UserMessageDataAttachmentsItemFile { Path = "/other.txt", DisplayName = "other.txt" }); + + Assert.Single(original.Attachments!); + } + + [Fact] + public void Clone_WithNullCollections_ReturnsNullCollections() + { + var original = new SessionConfig(); + + var clone = original.Clone(); + + Assert.Null(clone.AvailableTools); + Assert.Null(clone.ExcludedTools); + Assert.Null(clone.McpServers); + Assert.Null(clone.CustomAgents); + Assert.Null(clone.SkillDirectories); + Assert.Null(clone.DisabledSkills); + Assert.Null(clone.Tools); + } +}