mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: add Copilot connection diagnostics
Add backend support for Copilot connection and account-status UX without introducing raw provider credential management. This change extends sidecar capabilities with explicit connection diagnostics, adds a refreshable capabilities IPC path, classifies common Copilot CLI failure modes, and includes a frontend handover document describing the new payload and expected UI states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -56,11 +56,21 @@ public sealed class SidecarModelCapabilityDto
|
||||
public string? DefaultReasoningEffort { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
public string Status { get; init; } = "copilot-error";
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public string? CopilotCliPath { get; init; }
|
||||
public string CheckedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SidecarCapabilitiesDto
|
||||
{
|
||||
public string Runtime { get; init; } = "dotnet-maf";
|
||||
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
|
||||
public SidecarConnectionDiagnosticsDto Connection { get; init; } = new();
|
||||
}
|
||||
|
||||
public class SidecarCommandEnvelope
|
||||
|
||||
@@ -10,6 +10,11 @@ internal static class CopilotCliPathResolver
|
||||
private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"];
|
||||
|
||||
public static CopilotClientOptions CreateClientOptions()
|
||||
{
|
||||
return CreateClientOptions(ResolveCliContext());
|
||||
}
|
||||
|
||||
internal static CopilotCliContext ResolveCliContext()
|
||||
{
|
||||
string? cliPath = Resolve(
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
@@ -28,11 +33,22 @@ internal static class CopilotCliPathResolver
|
||||
OperatingSystem.IsWindows(),
|
||||
Environment.GetEnvironmentVariable("ComSpec"));
|
||||
|
||||
return new CopilotCliContext(
|
||||
cliPath,
|
||||
launch.Path,
|
||||
launch.Args,
|
||||
ResolveCliEnvironment(GetCurrentEnvironmentVariables()));
|
||||
}
|
||||
|
||||
internal static CopilotClientOptions CreateClientOptions(CopilotCliContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
return new CopilotClientOptions
|
||||
{
|
||||
CliPath = launch.Path,
|
||||
CliArgs = launch.Args,
|
||||
Environment = ResolveCliEnvironment(GetCurrentEnvironmentVariables()),
|
||||
CliPath = context.LaunchPath,
|
||||
CliArgs = context.LaunchArgs,
|
||||
Environment = context.Environment,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,4 +178,10 @@ internal static class CopilotCliPathResolver
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CopilotCliContext(
|
||||
string CliPath,
|
||||
string LaunchPath,
|
||||
string[] LaunchArgs,
|
||||
IReadOnlyDictionary<string, string> Environment);
|
||||
|
||||
internal sealed record CopilotCliLaunch(string Path, string[] Args);
|
||||
|
||||
@@ -8,6 +8,19 @@ namespace Kopaya.AgentHost.Services;
|
||||
|
||||
public sealed class SidecarProtocolHost
|
||||
{
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"not signed in",
|
||||
"not logged in",
|
||||
"reauth",
|
||||
"credential",
|
||||
];
|
||||
|
||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
@@ -164,39 +177,67 @@ public sealed class SidecarProtocolHost
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
CopilotCliContext cliContext;
|
||||
SidecarConnectionDiagnosticsDto connection;
|
||||
|
||||
try
|
||||
{
|
||||
models = await ListAvailableModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[kopaya sidecar] {connection.Summary} {exception.Message}");
|
||||
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
models = await ListAvailableModelsAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception);
|
||||
Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
}
|
||||
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
["sequential"] = new() { Available = true },
|
||||
["concurrent"] = new() { Available = true },
|
||||
["handoff"] = new() { Available = true },
|
||||
["group-chat"] = new() { Available = true },
|
||||
["magentic"] = new()
|
||||
{
|
||||
Available = false,
|
||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||
},
|
||||
},
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
|
||||
{
|
||||
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
["sequential"] = new() { Available = true },
|
||||
["concurrent"] = new() { Available = true },
|
||||
["handoff"] = new() { Available = true },
|
||||
["group-chat"] = new() { Available = true },
|
||||
["magentic"] = new()
|
||||
{
|
||||
Available = false,
|
||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -223,4 +264,67 @@ public sealed class SidecarProtocolHost
|
||||
{
|
||||
return value is "low" or "medium" or "high" or "xhigh";
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
|
||||
{
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "copilot-cli-missing",
|
||||
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
|
||||
Detail = exception.Message,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
0 => "Connected to GitHub Copilot, but no models were reported.",
|
||||
1 => "Connected to GitHub Copilot. 1 model is available.",
|
||||
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
|
||||
};
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
? "GitHub Copilot requires authentication before Kopaya can load models."
|
||||
: "GitHub Copilot was found, but Kopaya could not load its model list.";
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyConnectionStatus(Exception exception)
|
||||
{
|
||||
string message = exception.Message;
|
||||
if (AuthenticationErrorIndicators.Any(indicator =>
|
||||
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "copilot-auth-required";
|
||||
}
|
||||
|
||||
return "copilot-error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ public sealed class SidecarProtocolHostTests
|
||||
JsonElement model = Assert.Single(models);
|
||||
Assert.Equal("gpt-5.4", model.GetProperty("id").GetString());
|
||||
Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString());
|
||||
JsonElement connection = capabilities.GetProperty("connection");
|
||||
Assert.Equal("ready", connection.GetProperty("status").GetString());
|
||||
Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString());
|
||||
|
||||
string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty;
|
||||
Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -215,6 +218,27 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
|
||||
{
|
||||
string status = SidecarProtocolHost.ClassifyConnectionStatus(
|
||||
new InvalidOperationException("Please run copilot auth login to continue."));
|
||||
|
||||
Assert.Equal("copilot-auth-required", status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto diagnostics =
|
||||
SidecarProtocolHost.CreateReadyConnectionDiagnostics(@"C:\tools\copilot\copilot.exe", 2);
|
||||
|
||||
Assert.Equal("ready", diagnostics.Status);
|
||||
Assert.Equal(@"C:\tools\copilot\copilot.exe", diagnostics.CopilotCliPath);
|
||||
Assert.Contains("2 models", diagnostics.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
|
||||
object command,
|
||||
SidecarProtocolHost? host = null)
|
||||
@@ -257,6 +281,13 @@ public sealed class SidecarProtocolHostTests
|
||||
DefaultReasoningEffort = "medium",
|
||||
},
|
||||
],
|
||||
Connection = new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = "Connected to GitHub Copilot. 1 model is available.",
|
||||
CopilotCliPath = @"C:\tools\copilot\copilot.exe",
|
||||
CheckedAt = "2026-01-01T00:00:00.0000000Z",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user