From 3e1165fa3a94807b13ce855046fe009b7f547d96 Mon Sep 17 00:00:00 2001 From: Copilot CLI Date: Sat, 21 Mar 2026 09:32:17 +0100 Subject: [PATCH] fix: use system copilot in development Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sidecar/src/Kopaya.AgentHost/AssemblyInfo.cs | 3 + .../Services/CopilotCliPathResolver.cs | 132 ++++++++++++++++++ .../Services/CopilotWorkflowRunner.cs | 3 +- .../CopilotCliPathResolverTests.cs | 54 +++++++ 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 sidecar/src/Kopaya.AgentHost/AssemblyInfo.cs create mode 100644 sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs create mode 100644 sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs diff --git a/sidecar/src/Kopaya.AgentHost/AssemblyInfo.cs b/sidecar/src/Kopaya.AgentHost/AssemblyInfo.cs new file mode 100644 index 0000000..67db4e6 --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Kopaya.AgentHost.Tests")] diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs new file mode 100644 index 0000000..f1dac8b --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs @@ -0,0 +1,132 @@ +using System.Runtime.InteropServices; +using GitHub.Copilot.SDK; + +namespace Kopaya.AgentHost.Services; + +internal static class CopilotCliPathResolver +{ + private const string CopilotCommandName = "copilot"; + private const string DefaultWindowsPathExtensions = ".COM;.EXE;.BAT;.CMD"; + + public static CopilotClientOptions? CreateClientOptions() + { + CopilotCliResolution resolution = Resolve( + Environment.ProcessPath, + Environment.GetEnvironmentVariable("PATH"), + Environment.GetEnvironmentVariable("PATHEXT"), + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + File.Exists); + + if (!resolution.ShouldOverrideCliPath) + { + return null; + } + + if (string.IsNullOrWhiteSpace(resolution.CliPath)) + { + throw new InvalidOperationException( + "Development sidecar could not find the system-installed 'copilot' command on PATH. Install the GitHub Copilot CLI or provide an explicit CliPath."); + } + + return new CopilotClientOptions + { + CliPath = resolution.CliPath, + }; + } + + internal static CopilotCliResolution Resolve( + string? processPath, + string? pathValue, + string? pathExtValue, + bool isWindows, + Func fileExists) + { + ArgumentNullException.ThrowIfNull(fileExists); + + if (!IsDevelopmentHost(processPath)) + { + return default; + } + + return new CopilotCliResolution( + ShouldOverrideCliPath: true, + CliPath: ResolveCliPath(pathValue, pathExtValue, isWindows, fileExists)); + } + + private static bool IsDevelopmentHost(string? processPath) + { + if (string.IsNullOrWhiteSpace(processPath)) + { + return false; + } + + return string.Equals( + Path.GetFileNameWithoutExtension(processPath), + "dotnet", + StringComparison.OrdinalIgnoreCase); + } + + private static string? ResolveCliPath( + string? pathValue, + string? pathExtValue, + bool isWindows, + Func fileExists) + { + if (string.IsNullOrWhiteSpace(pathValue)) + { + return null; + } + + StringComparer comparer = isWindows ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + foreach (string directory in pathValue + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(segment => segment.Trim('"')) + .Where(segment => !string.IsNullOrWhiteSpace(segment)) + .Distinct(comparer)) + { + foreach (string candidateName in GetCandidateFileNames(pathExtValue, isWindows)) + { + string candidatePath = Path.Combine(directory, candidateName); + if (fileExists(candidatePath)) + { + return candidatePath; + } + } + } + + return null; + } + + private static IEnumerable GetCandidateFileNames(string? pathExtValue, bool isWindows) + { + yield return CopilotCommandName; + + if (!isWindows) + { + yield break; + } + + HashSet yielded = new(StringComparer.OrdinalIgnoreCase) + { + CopilotCommandName, + }; + + string extensions = string.IsNullOrWhiteSpace(pathExtValue) + ? DefaultWindowsPathExtensions + : pathExtValue; + + foreach (string extension in extensions + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(extension => extension.StartsWith('.'))) + { + string candidateName = CopilotCommandName + extension; + if (yielded.Add(candidateName)) + { + yield return candidateName; + } + } + } +} + +internal readonly record struct CopilotCliResolution(bool ShouldOverrideCliPath, string? CliPath); diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs index 8977470..9574acf 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotWorkflowRunner.cs @@ -170,10 +170,11 @@ public sealed class CopilotWorkflowRunner { List disposables = []; List agents = []; + CopilotClientOptions? clientOptions = CopilotCliPathResolver.CreateClientOptions(); foreach (PatternAgentDefinitionDto definition in pattern.Agents) { - CopilotClient client = new(); + CopilotClient client = clientOptions is null ? new() : new(clientOptions); await client.StartAsync(cancellationToken).ConfigureAwait(false); SessionConfig sessionConfig = new() diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs new file mode 100644 index 0000000..4ce75ac --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotCliPathResolverTests.cs @@ -0,0 +1,54 @@ +using Kopaya.AgentHost.Services; + +namespace Kopaya.AgentHost.Tests; + +public sealed class CopilotCliPathResolverTests +{ + [Fact] + public void Resolve_UsesCopilotFromPathDuringDevelopment() + { + string copilotDirectory = @"C:\tools\copilot"; + HashSet existingFiles = new(StringComparer.OrdinalIgnoreCase) + { + Path.Combine(copilotDirectory, "copilot.exe"), + }; + + CopilotCliResolution resolution = CopilotCliPathResolver.Resolve( + processPath: @"C:\Program Files\dotnet\dotnet.exe", + pathValue: $"C:\\other;\"{copilotDirectory}\"", + pathExtValue: ".COM;.EXE;.BAT;.CMD", + isWindows: true, + fileExists: existingFiles.Contains); + + Assert.True(resolution.ShouldOverrideCliPath); + Assert.Equal(Path.Combine(copilotDirectory, "copilot.exe"), resolution.CliPath, ignoreCase: true); + } + + [Fact] + public void Resolve_LeavesPackagedRuntimeOnBundledCli() + { + CopilotCliResolution resolution = CopilotCliPathResolver.Resolve( + processPath: @"C:\Program Files\Kopaya\Kopaya.AgentHost.exe", + pathValue: @"C:\tools", + pathExtValue: ".COM;.EXE;.BAT;.CMD", + isWindows: true, + fileExists: _ => true); + + Assert.False(resolution.ShouldOverrideCliPath); + Assert.Null(resolution.CliPath); + } + + [Fact] + public void Resolve_ReportsMissingCopilotWhenDevelopmentPathDoesNotContainIt() + { + CopilotCliResolution resolution = CopilotCliPathResolver.Resolve( + processPath: @"C:\Program Files\dotnet\dotnet.exe", + pathValue: @"C:\tools;C:\other", + pathExtValue: ".COM;.EXE;.BAT;.CMD", + isWindows: true, + fileExists: _ => false); + + Assert.True(resolution.ShouldOverrideCliPath); + Assert.Null(resolution.CliPath); + } +}