diff --git a/HANDOVER.md b/HANDOVER.md index 1f060ef..97f701c 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -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=""` -## 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(); -``` +- 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. diff --git a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs index 374144d..7eb67c9 100644 --- a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs @@ -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? Organizations { get; init; } +} + public sealed class SidecarCapabilitiesDto { public string Runtime { get; init; } = "dotnet-maf"; diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotConnectionMetadataResolver.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotConnectionMetadataResolver.cs new file mode 100644 index 0000000..72d4fbe --- /dev/null +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotConnectionMetadataResolver.cs @@ -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 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 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 CreateAccountDiagnosticsAsync( + GetAuthStatusResponse? authStatus, + IReadOnlyDictionary environment, + CancellationToken cancellationToken) + { + if (authStatus is null) + { + return null; + } + + string? normalizedHost = NormalizeHost(authStatus.Host); + IReadOnlyList? 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 ParseOrganizationsOutput(string? output) + { + return (output ?? string.Empty) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static async Task?> TryListOrganizationsAsync( + string login, + string host, + IReadOnlyDictionary 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 TryRunGhCommandAsync( + IReadOnlyList arguments, + IReadOnlyDictionary 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 RunProcessAsync( + string executablePath, + IReadOnlyList arguments, + IReadOnlyDictionary 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 standardOutputTask = process.StandardOutput.ReadToEndAsync(timeoutSource.Token); + Task 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 arguments, + IReadOnlyDictionary 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 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 arguments) + { + IEnumerable 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(?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z\.-]+)?)\b", RegexOptions.Compiled)] + private static partial Regex SemanticVersionPattern(); +} diff --git a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs index e8e3548..230bd37 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs @@ -179,6 +179,8 @@ public sealed class SidecarProtocolHost IReadOnlyList models = []; CopilotCliContext cliContext; SidecarConnectionDiagnosticsDto connection; + SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; + SidecarCopilotAccountDiagnosticsDto? account = null; try { @@ -197,14 +199,31 @@ public sealed class SidecarProtocolHost }; } + Task 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> 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 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"), }; } diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/CopilotConnectionMetadataResolverTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotConnectionMetadataResolverTests.cs new file mode 100644 index 0000000..4dbe16f --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/CopilotConnectionMetadataResolverTests.cs @@ -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 organizations = CopilotConnectionMetadataResolver.ParseOrganizationsOutput( + """ + github + octo-org + github + """); + + Assert.Equal(["github", "octo-org"], organizations); + } +} diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs index 94d8bd6..8d894e6 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -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", }, })); diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index aa8124c..baa8516 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -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; }