fix: use system copilot in development

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot CLI
2026-03-21 09:32:17 +01:00
co-authored by Copilot
parent c8d23f91d1
commit 3e1165fa3a
4 changed files with 191 additions and 1 deletions
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Kopaya.AgentHost.Tests")]
@@ -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<string, bool> 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<string, bool> 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<string> GetCandidateFileNames(string? pathExtValue, bool isWindows)
{
yield return CopilotCommandName;
if (!isWindows)
{
yield break;
}
HashSet<string> 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);
@@ -170,10 +170,11 @@ public sealed class CopilotWorkflowRunner
{
List<IAsyncDisposable> disposables = [];
List<AIAgent> 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()
@@ -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<string> 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);
}
}