mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-30 08:28:48 +02:00
feat: add Copilot version and account diagnostics
- report Copilot CLI version freshness as latest, outdated, or unknown - expose active Copilot account metadata from the SDK auth status - add best-effort GitHub organization context when gh is available - document the frontend follow-up in HANDOVER.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+139
-152
@@ -1,24 +1,24 @@
|
||||
# Handover: Copilot connection status UI
|
||||
# Handover: Copilot version + account context UI
|
||||
|
||||
This document is for the frontend/UX model that will build the user-facing experience on top of the backend changes implemented in this task.
|
||||
This document is for the frontend/UX agent that will extend the existing Copilot connection UI.
|
||||
|
||||
## Goal
|
||||
|
||||
Build UI for the roadmap's highest-priority backend work:
|
||||
Build on top of the current Settings → `AI Provider` → `Connection` view so it can show:
|
||||
|
||||
- show whether Kopaya can reach GitHub Copilot successfully
|
||||
- explain why it cannot when something is wrong
|
||||
- let the user refresh capability status after installing or logging into Copilot CLI
|
||||
- whether the installed Copilot CLI is current or outdated
|
||||
- which GitHub account Copilot is currently using
|
||||
- organization context when it can be discovered
|
||||
|
||||
Do **not** build raw provider-secret management UI for this flow. Kopaya currently relies on the system-installed GitHub Copilot CLI rather than storing provider API keys itself.
|
||||
Do **not** add provider-secret management for this flow. Kopaya still relies on the system-installed GitHub Copilot CLI.
|
||||
|
||||
## Backend changes already implemented
|
||||
## Backend changes now implemented
|
||||
|
||||
### 1. `SidecarCapabilities` now includes connection diagnostics
|
||||
### 1. `SidecarCapabilities.connection` has new structured fields
|
||||
|
||||
File: `src/shared/contracts/sidecar.ts`
|
||||
|
||||
New field:
|
||||
New payload shape:
|
||||
|
||||
```ts
|
||||
connection: {
|
||||
@@ -27,196 +27,183 @@ connection: {
|
||||
detail?: string;
|
||||
copilotCliPath?: string;
|
||||
checkedAt: string;
|
||||
copilotCliVersion?: {
|
||||
status: 'latest' | 'outdated' | 'unknown';
|
||||
installedVersion?: string;
|
||||
latestVersion?: string;
|
||||
detail?: string;
|
||||
};
|
||||
account?: {
|
||||
authenticated: boolean;
|
||||
login?: string;
|
||||
host?: string;
|
||||
authType?: string;
|
||||
statusMessage?: string;
|
||||
organizations?: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This comes from the .NET sidecar and is included alongside `runtime`, `modes`, and `models`.
|
||||
|
||||
### 2. There is now a refreshable backend API
|
||||
### 2. Version state is now resolved by the sidecar
|
||||
|
||||
Files:
|
||||
|
||||
- `src/shared/contracts/ipc.ts`
|
||||
- `src/shared/contracts/channels.ts`
|
||||
- `src/preload/index.ts`
|
||||
- `src/main/ipc/registerIpcHandlers.ts`
|
||||
- `src/main/KopayaAppService.ts`
|
||||
|
||||
New renderer-callable method:
|
||||
|
||||
```ts
|
||||
window.kopayaApi.refreshSidecarCapabilities()
|
||||
```
|
||||
|
||||
This bypasses the cached capability result in `KopayaAppService` and fetches a fresh capability payload from the sidecar. Use this for Retry / Refresh buttons after the user fixes CLI install or login state.
|
||||
|
||||
### 3. Sidecar diagnostics now distinguish common failure modes
|
||||
|
||||
Files:
|
||||
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs`
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/CopilotConnectionMetadataResolver.cs`
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs`
|
||||
|
||||
The sidecar now runs `copilot version` and classifies the result as:
|
||||
|
||||
- `latest`: the CLI explicitly reported that the install is current
|
||||
- `outdated`: the CLI reported that a newer version is available
|
||||
- `unknown`: the version command timed out, failed, or did not return a recognizable update state
|
||||
|
||||
Important nuance:
|
||||
|
||||
- this **does not** change `connection.status`
|
||||
- `connection.status` still means install/auth/model-list health
|
||||
- version freshness is a separate concern and should be rendered separately in the UI
|
||||
|
||||
### 3. Account context is now resolved by the sidecar
|
||||
|
||||
Files:
|
||||
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/CopilotConnectionMetadataResolver.cs`
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs`
|
||||
|
||||
The sidecar now uses the Copilot SDK auth-status call to populate:
|
||||
|
||||
- `account.authenticated`
|
||||
- `account.login`
|
||||
- `account.host`
|
||||
- `account.authType`
|
||||
- `account.statusMessage`
|
||||
|
||||
### 4. Organization context is best-effort
|
||||
|
||||
When the Copilot auth status provides a login + host, the sidecar also makes a best-effort `gh` CLI lookup for org memberships:
|
||||
|
||||
- it verifies the `gh` authenticated user matches the Copilot login on the same host
|
||||
- if that succeeds, it fetches `user/orgs`
|
||||
- those org logins are returned as `account.organizations`
|
||||
|
||||
Important nuance:
|
||||
|
||||
- `organizations` may be missing entirely
|
||||
- that can mean `gh` is not installed, not authenticated, lacks `read:org`, timed out, or the accounts did not match
|
||||
- the frontend should treat organizations as optional enhancement data, not a guaranteed field
|
||||
|
||||
## Files changed
|
||||
|
||||
Backend / shared contracts:
|
||||
|
||||
- `src/shared/contracts/sidecar.ts`
|
||||
- `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs`
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs`
|
||||
- `sidecar/src/Kopaya.AgentHost/Services/CopilotConnectionMetadataResolver.cs`
|
||||
|
||||
The backend now reports:
|
||||
Tests:
|
||||
|
||||
- `ready`: Copilot CLI was found and model listing succeeded
|
||||
- `copilot-cli-missing`: Kopaya could not find the `copilot` command on `PATH`
|
||||
- `copilot-auth-required`: Copilot CLI exists, but the error looks like login/auth is required
|
||||
- `copilot-error`: Copilot CLI exists, but model loading failed for another reason
|
||||
- `sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs`
|
||||
- `sidecar/tests/Kopaya.AgentHost.Tests/CopilotConnectionMetadataResolverTests.cs`
|
||||
|
||||
### 4. Validation status
|
||||
## Validation status
|
||||
|
||||
Validated successfully after the change:
|
||||
|
||||
- `bun test`
|
||||
- `dotnet test sidecar\Kopaya.AgentHost.slnx` using an alternate build output path to avoid a locked local executable in the shared environment
|
||||
- `dotnet test sidecar\Kopaya.AgentHost.slnx -p:BaseOutputPath="<session-sidecar-build-path>"`
|
||||
|
||||
## Important architecture note
|
||||
## Current renderer state
|
||||
|
||||
Kopaya currently does **not** manage provider credentials directly for this path.
|
||||
There is already a Copilot settings surface:
|
||||
|
||||
The sidecar resolves the system-installed `copilot` command and uses that runtime. That means the near-term UI should be about:
|
||||
- `src/renderer/components/SettingsPanel.tsx`
|
||||
- `src/renderer/components/CopilotStatusCard.tsx`
|
||||
|
||||
- install state
|
||||
- login state
|
||||
- connection health
|
||||
- model availability
|
||||
- refresh / retry
|
||||
Current behavior:
|
||||
|
||||
It should **not** ask the user for OpenAI, Anthropic, or Google secrets as part of this specific Copilot status flow.
|
||||
- shows connection readiness / missing CLI / auth required / generic error
|
||||
- shows model count, CLI path, and checked timestamp
|
||||
- **does not yet render** the new `copilotCliVersion` or `account` fields
|
||||
|
||||
## Current renderer integration point
|
||||
## Recommended frontend follow-up
|
||||
|
||||
File: `src/renderer/App.tsx`
|
||||
### 1. Show the active GitHub account prominently
|
||||
|
||||
On mount, the renderer already does:
|
||||
Good options:
|
||||
|
||||
```ts
|
||||
api.describeSidecarCapabilities().then(setSidecarCapabilities)
|
||||
```
|
||||
- a compact identity row like `davidkaya · github.com`
|
||||
- a small pill/chip under the connection status
|
||||
- a labeled metadata grid in the expanded details area
|
||||
|
||||
This state is already stored in:
|
||||
Suggested fallback behavior:
|
||||
|
||||
```ts
|
||||
const [sidecarCapabilities, setSidecarCapabilities] = useState<SidecarCapabilities>();
|
||||
```
|
||||
- if `account.login` exists, show it
|
||||
- if `account.host` exists, show it next to the login
|
||||
- if `account.statusMessage` exists but login is missing, use that as secondary text
|
||||
|
||||
So the frontend work is mostly about:
|
||||
### 2. Show CLI freshness as a separate badge/state
|
||||
|
||||
1. reading `sidecarCapabilities.connection`
|
||||
2. rendering the right UX state
|
||||
3. calling `api.refreshSidecarCapabilities()` when the user retries
|
||||
Recommended mapping:
|
||||
|
||||
## Recommended UI states
|
||||
- `latest` → subtle positive/neutral badge like `Up to date`
|
||||
- `outdated` → amber warning badge like `Update available`
|
||||
- `unknown` → low-emphasis muted label like `Version unknown`
|
||||
|
||||
### `ready`
|
||||
Good details to show in the expanded area:
|
||||
|
||||
Show:
|
||||
- installed version
|
||||
- latest version (when known)
|
||||
- raw `detail` text only as secondary / technical detail
|
||||
|
||||
- positive connected state
|
||||
- connection summary
|
||||
- model count (`sidecarCapabilities.models.length`)
|
||||
- optional advanced details like CLI path and last checked time
|
||||
### 3. Show organizations only when present
|
||||
|
||||
Good copy direction:
|
||||
If `account.organizations` exists and has values:
|
||||
|
||||
- "Connected to GitHub Copilot"
|
||||
- "19 models available"
|
||||
- render them as small pills/tags
|
||||
- cap the visible count if the list is long
|
||||
- consider `+N more` if there are many
|
||||
|
||||
### `copilot-cli-missing`
|
||||
If it is missing:
|
||||
|
||||
Show:
|
||||
- do not show an empty placeholder
|
||||
- fall back to host/account info instead
|
||||
|
||||
- an actionable install state
|
||||
- summary from backend
|
||||
- detail text in an expandable "technical details" area
|
||||
- a Refresh button that calls `refreshSidecarCapabilities()`
|
||||
### 4. Keep account/version separate from the main connection summary
|
||||
|
||||
Good copy direction:
|
||||
Do **not** overload the existing top-level connection state.
|
||||
|
||||
- "GitHub Copilot CLI not found"
|
||||
- "Install the `copilot` CLI and make sure it is available on PATH"
|
||||
Recommended mental model:
|
||||
|
||||
### `copilot-auth-required`
|
||||
- main status row = “Can Kopaya use Copilot right now?”
|
||||
- version badge = “Should the CLI be updated?”
|
||||
- account info = “Who is Kopaya connected as?”
|
||||
- org pills = “What org context is discoverable?”
|
||||
|
||||
Show:
|
||||
## Suggested UI copy
|
||||
|
||||
- an actionable login state
|
||||
- summary from backend
|
||||
- technical details if available
|
||||
- a Refresh button after the user logs in externally
|
||||
For account:
|
||||
|
||||
Good copy direction:
|
||||
- `Signed in as davidkaya`
|
||||
- `github.com`
|
||||
|
||||
- "GitHub Copilot needs sign-in"
|
||||
- "Finish login in the Copilot CLI, then refresh"
|
||||
For version:
|
||||
|
||||
### `copilot-error`
|
||||
- `Copilot CLI is up to date`
|
||||
- `Copilot CLI update available`
|
||||
- `Could not determine Copilot CLI version`
|
||||
|
||||
Show:
|
||||
For orgs:
|
||||
|
||||
- a generic error state
|
||||
- summary from backend
|
||||
- `detail` in a technical details area
|
||||
- Refresh button
|
||||
- `Organizations`
|
||||
|
||||
Good copy direction:
|
||||
## Non-goals of this backend change
|
||||
|
||||
- "Kopaya found Copilot, but could not load models"
|
||||
Still not implemented:
|
||||
|
||||
## Recommended frontend implementation shape
|
||||
- an in-app `copilot update` action
|
||||
- an in-app `copilot login` action
|
||||
- direct provider credentials for OpenAI / Anthropic / Google
|
||||
- guaranteed authoritative “active organization” selection semantics
|
||||
|
||||
### Suggested state additions in `App.tsx`
|
||||
|
||||
- keep using the existing `sidecarCapabilities` state
|
||||
- add a small `isRefreshingCapabilities` boolean state
|
||||
- create a refresh handler that calls `api.refreshSidecarCapabilities()`
|
||||
|
||||
Example shape:
|
||||
|
||||
```ts
|
||||
const refreshCapabilities = async () => {
|
||||
setIsRefreshingCapabilities(true);
|
||||
try {
|
||||
const capabilities = await api.refreshSidecarCapabilities();
|
||||
setSidecarCapabilities(capabilities);
|
||||
} finally {
|
||||
setIsRefreshingCapabilities(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Suggested component split
|
||||
|
||||
Any of these would be reasonable:
|
||||
|
||||
- a new `CopilotStatusCard` in settings
|
||||
- a small status section near model/pattern settings
|
||||
- a dedicated connectivity block in the settings panel
|
||||
|
||||
### Suggested UX details
|
||||
|
||||
- treat `detail` as secondary, not primary
|
||||
- show the backend `summary` prominently
|
||||
- expose CLI path only in advanced details
|
||||
- keep the empty/loading state graceful
|
||||
- do not block the rest of the app if capabilities are degraded
|
||||
|
||||
## What is intentionally not implemented yet
|
||||
|
||||
These are still future enhancements, not part of this backend change:
|
||||
|
||||
- actual GitHub account or org identity reporting
|
||||
- CLI version reporting
|
||||
- an in-app "log in to Copilot" action
|
||||
- push-based status updates when the external CLI state changes
|
||||
- direct provider credential storage for OpenAI / Anthropic / Google
|
||||
|
||||
## Suggested follow-up order for the frontend model
|
||||
|
||||
1. Add a settings/status UI for `sidecarCapabilities.connection`
|
||||
2. Add a Refresh button using `refreshSidecarCapabilities()`
|
||||
3. Surface model count and summary in the connected state
|
||||
4. Add expandable technical details for `detail` and `copilotCliPath`
|
||||
5. Keep future direct-provider credential UX separate from this Copilot flow
|
||||
The new org data is “best effort discovered context,” not a firm entitlement model.
|
||||
|
||||
@@ -62,9 +62,29 @@ public sealed class SidecarConnectionDiagnosticsDto
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public string? CopilotCliPath { get; init; }
|
||||
public SidecarCopilotCliVersionDiagnosticsDto? CopilotCliVersion { get; init; }
|
||||
public SidecarCopilotAccountDiagnosticsDto? Account { get; init; }
|
||||
public string CheckedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
public string Status { get; init; } = "unknown";
|
||||
public string? InstalledVersion { get; init; }
|
||||
public string? LatestVersion { get; init; }
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
public bool Authenticated { get; init; }
|
||||
public string? Login { get; init; }
|
||||
public string? Host { get; init; }
|
||||
public string? AuthType { get; init; }
|
||||
public string? StatusMessage { get; init; }
|
||||
public IReadOnlyList<string>? Organizations { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SidecarCapabilitiesDto
|
||||
{
|
||||
public string Runtime { get; init; } = "dotnet-maf";
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Kopaya.AgentHost.Contracts;
|
||||
|
||||
namespace Kopaya.AgentHost.Services;
|
||||
|
||||
internal static partial class CopilotConnectionMetadataResolver
|
||||
{
|
||||
private static readonly string[] LatestVersionIndicators =
|
||||
[
|
||||
"running the latest version",
|
||||
"already up to date",
|
||||
"is up to date",
|
||||
];
|
||||
|
||||
private static readonly string[] OutdatedVersionIndicators =
|
||||
[
|
||||
"newer version",
|
||||
"new version available",
|
||||
"update available",
|
||||
"download link",
|
||||
];
|
||||
|
||||
private static readonly TimeSpan CopilotVersionTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan GitHubContextTimeout = TimeSpan.FromSeconds(4);
|
||||
|
||||
internal static async Task<SidecarCopilotCliVersionDiagnosticsDto> GetCliVersionDiagnosticsAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
CommandResult result = await RunProcessAsync(
|
||||
executablePath: cliContext.CliPath,
|
||||
arguments: ["version"],
|
||||
environment: cliContext.Environment,
|
||||
timeout: CopilotVersionTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ParseCliVersionOutput(result.StandardOutput, result.StandardError, result.ExitCode);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "unknown",
|
||||
Detail = "Timed out while checking the installed GitHub Copilot CLI version.",
|
||||
};
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "unknown",
|
||||
Detail = $"Failed to check the installed GitHub Copilot CLI version: {exception.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static SidecarCopilotCliVersionDiagnosticsDto ParseCliVersionOutput(
|
||||
string? standardOutput,
|
||||
string? standardError = null,
|
||||
int exitCode = 0)
|
||||
{
|
||||
string output = NormalizeOutput(standardOutput, standardError);
|
||||
string? installedVersion = ExtractInstalledVersion(output);
|
||||
string status = ClassifyCliVersionStatus(output, exitCode);
|
||||
string? latestVersion = status switch
|
||||
{
|
||||
"latest" => installedVersion,
|
||||
"outdated" => ExtractLatestVersion(output, installedVersion),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
string? detail = string.IsNullOrWhiteSpace(output)
|
||||
? null
|
||||
: output;
|
||||
|
||||
if (detail is null && status == "unknown")
|
||||
{
|
||||
detail = exitCode == 0
|
||||
? "GitHub Copilot CLI version could not be determined."
|
||||
: $"GitHub Copilot CLI version check exited with code {exitCode}.";
|
||||
}
|
||||
|
||||
return new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
InstalledVersion = installedVersion,
|
||||
LatestVersion = latestVersion,
|
||||
Detail = detail,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyCliVersionStatus(string output, int exitCode = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (LatestVersionIndicators.Any(indicator =>
|
||||
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "latest";
|
||||
}
|
||||
|
||||
if (OutdatedVersionIndicators.Any(indicator =>
|
||||
output.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "outdated";
|
||||
}
|
||||
|
||||
return exitCode == 0 && ExtractInstalledVersion(output) is not null
|
||||
? "unknown"
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
internal static string? ExtractInstalledVersion(string output)
|
||||
{
|
||||
Match match = SemanticVersionPattern().Match(output);
|
||||
return match.Success ? match.Groups["version"].Value : null;
|
||||
}
|
||||
|
||||
internal static string? ExtractLatestVersion(string output, string? installedVersion)
|
||||
{
|
||||
return SemanticVersionPattern()
|
||||
.Matches(output)
|
||||
.Select(match => match.Groups["version"].Value)
|
||||
.FirstOrDefault(version =>
|
||||
!string.Equals(version, installedVersion, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static async Task<GetAuthStatusResponse?> TryGetAuthStatusAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await client.GetAuthStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[kopaya sidecar] Failed to inspect Copilot auth status: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<SidecarCopilotAccountDiagnosticsDto?> CreateAccountDiagnosticsAsync(
|
||||
GetAuthStatusResponse? authStatus,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (authStatus is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? normalizedHost = NormalizeHost(authStatus.Host);
|
||||
IReadOnlyList<string>? organizations = null;
|
||||
|
||||
if (authStatus.IsAuthenticated
|
||||
&& !string.IsNullOrWhiteSpace(authStatus.Login)
|
||||
&& !string.IsNullOrWhiteSpace(normalizedHost))
|
||||
{
|
||||
organizations = await TryListOrganizationsAsync(
|
||||
authStatus.Login,
|
||||
normalizedHost,
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
Authenticated = authStatus.IsAuthenticated,
|
||||
Login = authStatus.Login,
|
||||
Host = normalizedHost,
|
||||
AuthType = authStatus.AuthType,
|
||||
StatusMessage = authStatus.StatusMessage,
|
||||
Organizations = organizations,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? NormalizeHost(string? host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string trimmed = host.Trim();
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
|
||||
{
|
||||
return uri.Host;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.TrimEnd('/');
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> ParseOrganizationsOutput(string? output)
|
||||
{
|
||||
return (output ?? string.Empty)
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>?> TryListOrganizationsAsync(
|
||||
string login,
|
||||
string host,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CommandResult? loginResult = await TryRunGhCommandAsync(
|
||||
["api", "--hostname", host, "user", "--jq", ".login"],
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string? resolvedLogin = loginResult is { ExitCode: 0 }
|
||||
? loginResult.StandardOutput.Trim()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedLogin)
|
||||
|| !string.Equals(resolvedLogin, login, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
CommandResult? organizationsResult = await TryRunGhCommandAsync(
|
||||
["api", "--hostname", host, "user/orgs", "--jq", ".[].login"],
|
||||
environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return organizationsResult is { ExitCode: 0 }
|
||||
? ParseOrganizationsOutput(organizationsResult.StandardOutput)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static async Task<CommandResult?> TryRunGhCommandAsync(
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await RunProcessAsync(
|
||||
executablePath: OperatingSystem.IsWindows() ? "gh.exe" : "gh",
|
||||
arguments,
|
||||
environment,
|
||||
GitHubContextTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunProcessAsync(
|
||||
string executablePath,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
|
||||
using Process process = new()
|
||||
{
|
||||
StartInfo = CreateProcessStartInfo(executablePath, arguments, environment),
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to start command '{executablePath}'.");
|
||||
}
|
||||
|
||||
Task<string> standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token);
|
||||
Task<string> standardErrorTask = process.StandardError.ReadToEndAsync(timeoutSource.Token);
|
||||
|
||||
await process.WaitForExitAsync(timeoutSource.Token).ConfigureAwait(false);
|
||||
|
||||
return new CommandResult(
|
||||
process.ExitCode,
|
||||
await standardOutputTask.ConfigureAwait(false),
|
||||
await standardErrorTask.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateProcessStartInfo(
|
||||
string executablePath,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string> environment)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows() && RequiresWindowsShell(executablePath))
|
||||
{
|
||||
startInfo.FileName = string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ComSpec"))
|
||||
? "cmd.exe"
|
||||
: Environment.GetEnvironmentVariable("ComSpec");
|
||||
|
||||
startInfo.ArgumentList.Add("/d");
|
||||
startInfo.ArgumentList.Add("/s");
|
||||
startInfo.ArgumentList.Add("/c");
|
||||
startInfo.ArgumentList.Add(BuildWindowsShellCommand(executablePath, arguments));
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = executablePath;
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
}
|
||||
|
||||
startInfo.Environment.Clear();
|
||||
foreach (KeyValuePair<string, string> entry in environment)
|
||||
{
|
||||
startInfo.Environment[entry.Key] = entry.Value;
|
||||
}
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static bool RequiresWindowsShell(string executablePath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(executablePath);
|
||||
return extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase)
|
||||
|| extension.Equals(".bat", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildWindowsShellCommand(string executablePath, IReadOnlyList<string> arguments)
|
||||
{
|
||||
IEnumerable<string> tokens = [executablePath, .. arguments];
|
||||
return string.Join(" ", tokens.Select(QuoteWindowsShellToken));
|
||||
}
|
||||
|
||||
private static string QuoteWindowsShellToken(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
bool needsQuotes = value.Any(character =>
|
||||
char.IsWhiteSpace(character)
|
||||
|| character is '&' or '(' or ')' or '[' or ']' or '{' or '}' or '^' or '=' or ';' or '!' or '+' or ',' or '`' or '~');
|
||||
|
||||
if (!needsQuotes)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
|
||||
}
|
||||
|
||||
private static string NormalizeOutput(string? standardOutput, string? standardError)
|
||||
{
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
new[] { standardOutput, standardError }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!.Trim()));
|
||||
}
|
||||
|
||||
private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError);
|
||||
|
||||
[GeneratedRegex(@"\b(?<version>\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)]
|
||||
private static partial Regex SemanticVersionPattern();
|
||||
}
|
||||
@@ -179,6 +179,8 @@ public sealed class SidecarProtocolHost
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
CopilotCliContext cliContext;
|
||||
SidecarConnectionDiagnosticsDto connection;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -197,14 +199,31 @@ public sealed class SidecarProtocolHost
|
||||
};
|
||||
}
|
||||
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
models = await ListAvailableModelsAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count);
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
GetAuthStatusResponse? authStatus =
|
||||
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
|
||||
authStatus,
|
||||
cliContext.Environment,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
connection = CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account);
|
||||
Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
}
|
||||
|
||||
@@ -234,14 +253,9 @@ public sealed class SidecarProtocolHost
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return models
|
||||
.Select(model => new SidecarModelCapabilityDto
|
||||
@@ -278,7 +292,9 @@ public sealed class SidecarProtocolHost
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount)
|
||||
int modelCount,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
@@ -293,13 +309,17 @@ public sealed class SidecarProtocolHost
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception)
|
||||
Exception exception,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
@@ -312,6 +332,8 @@ public sealed class SidecarProtocolHost
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Kopaya.AgentHost.Contracts;
|
||||
using Kopaya.AgentHost.Services;
|
||||
|
||||
namespace Kopaya.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotConnectionMetadataResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseCliVersionOutput_ReturnsLatestStatusForCurrentInstall()
|
||||
{
|
||||
SidecarCopilotCliVersionDiagnosticsDto diagnostics =
|
||||
CopilotConnectionMetadataResolver.ParseCliVersionOutput(
|
||||
"""
|
||||
GitHub Copilot CLI 1.0.10
|
||||
You are running the latest version.
|
||||
""");
|
||||
|
||||
Assert.Equal("latest", diagnostics.Status);
|
||||
Assert.Equal("1.0.10", diagnostics.InstalledVersion);
|
||||
Assert.Equal("1.0.10", diagnostics.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCliVersionOutput_ReturnsOutdatedStatusWhenNewerVersionIsAvailable()
|
||||
{
|
||||
SidecarCopilotCliVersionDiagnosticsDto diagnostics =
|
||||
CopilotConnectionMetadataResolver.ParseCliVersionOutput(
|
||||
"""
|
||||
GitHub Copilot CLI 1.0.9
|
||||
A newer version 1.0.10 is available.
|
||||
Run 'copilot update' to install it.
|
||||
""");
|
||||
|
||||
Assert.Equal("outdated", diagnostics.Status);
|
||||
Assert.Equal("1.0.9", diagnostics.InstalledVersion);
|
||||
Assert.Equal("1.0.10", diagnostics.LatestVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeHost_StripsSchemeAndTrailingSlash()
|
||||
{
|
||||
string? host = CopilotConnectionMetadataResolver.NormalizeHost("https://github.example.com/");
|
||||
|
||||
Assert.Equal("github.example.com", host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseOrganizationsOutput_ReturnsDistinctOrganizations()
|
||||
{
|
||||
IReadOnlyList<string> organizations = CopilotConnectionMetadataResolver.ParseOrganizationsOutput(
|
||||
"""
|
||||
github
|
||||
octo-org
|
||||
github
|
||||
""");
|
||||
|
||||
Assert.Equal(["github", "octo-org"], organizations);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ public sealed class SidecarProtocolHostTests
|
||||
JsonElement connection = capabilities.GetProperty("connection");
|
||||
Assert.Equal("ready", connection.GetProperty("status").GetString());
|
||||
Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString());
|
||||
JsonElement cliVersion = connection.GetProperty("copilotCliVersion");
|
||||
Assert.Equal("latest", cliVersion.GetProperty("status").GetString());
|
||||
Assert.Equal("1.0.10", cliVersion.GetProperty("installedVersion").GetString());
|
||||
JsonElement account = connection.GetProperty("account");
|
||||
Assert.True(account.GetProperty("authenticated").GetBoolean());
|
||||
Assert.Equal("octocat", account.GetProperty("login").GetString());
|
||||
|
||||
string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty;
|
||||
Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -231,11 +237,29 @@ public sealed class SidecarProtocolHostTests
|
||||
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto diagnostics =
|
||||
SidecarProtocolHost.CreateReadyConnectionDiagnostics(@"C:\tools\copilot\copilot.exe", 2);
|
||||
SidecarProtocolHost.CreateReadyConnectionDiagnostics(
|
||||
@"C:\tools\copilot\copilot.exe",
|
||||
2,
|
||||
new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "outdated",
|
||||
InstalledVersion = "1.0.9",
|
||||
LatestVersion = "1.0.10",
|
||||
},
|
||||
new SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
Authenticated = true,
|
||||
Login = "octocat",
|
||||
Host = "github.com",
|
||||
Organizations = ["github"],
|
||||
});
|
||||
|
||||
Assert.Equal("ready", diagnostics.Status);
|
||||
Assert.Equal(@"C:\tools\copilot\copilot.exe", diagnostics.CopilotCliPath);
|
||||
Assert.Contains("2 models", diagnostics.Summary, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("outdated", diagnostics.CopilotCliVersion?.Status);
|
||||
Assert.Equal("octocat", diagnostics.Account?.Login);
|
||||
Assert.Equal(["github"], diagnostics.Account?.Organizations);
|
||||
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
|
||||
}
|
||||
|
||||
@@ -286,6 +310,19 @@ public sealed class SidecarProtocolHostTests
|
||||
Status = "ready",
|
||||
Summary = "Connected to GitHub Copilot. 1 model is available.",
|
||||
CopilotCliPath = @"C:\tools\copilot\copilot.exe",
|
||||
CopilotCliVersion = new SidecarCopilotCliVersionDiagnosticsDto
|
||||
{
|
||||
Status = "latest",
|
||||
InstalledVersion = "1.0.10",
|
||||
LatestVersion = "1.0.10",
|
||||
},
|
||||
Account = new SidecarCopilotAccountDiagnosticsDto
|
||||
{
|
||||
Authenticated = true,
|
||||
Login = "octocat",
|
||||
Host = "github.com",
|
||||
Organizations = ["github", "mona"],
|
||||
},
|
||||
CheckedAt = "2026-01-01T00:00:00.0000000Z",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -12,11 +12,31 @@ export type SidecarConnectionStatus =
|
||||
| 'copilot-auth-required'
|
||||
| 'copilot-error';
|
||||
|
||||
export type SidecarCopilotCliVersionStatus = 'latest' | 'outdated' | 'unknown';
|
||||
|
||||
export interface SidecarCopilotCliVersionDiagnostics {
|
||||
status: SidecarCopilotCliVersionStatus;
|
||||
installedVersion?: string;
|
||||
latestVersion?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface SidecarCopilotAccountDiagnostics {
|
||||
authenticated: boolean;
|
||||
login?: string;
|
||||
host?: string;
|
||||
authType?: string;
|
||||
statusMessage?: string;
|
||||
organizations?: string[];
|
||||
}
|
||||
|
||||
export interface SidecarConnectionDiagnostics {
|
||||
status: SidecarConnectionStatus;
|
||||
summary: string;
|
||||
detail?: string;
|
||||
copilotCliPath?: string;
|
||||
copilotCliVersion?: SidecarCopilotCliVersionDiagnostics;
|
||||
account?: SidecarCopilotAccountDiagnostics;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user