From d68b1b99f3e27d6199f006428b89545c577d4fd7 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 22 Mar 2026 12:58:40 +0100 Subject: [PATCH] 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> --- HANDOVER.md | 222 ++++++++++++++++++ .../Contracts/ProtocolModels.cs | 10 + .../Services/CopilotCliPathResolver.cs | 28 ++- .../Services/SidecarProtocolHost.cs | 134 +++++++++-- .../SidecarProtocolHostTests.cs | 31 +++ src/main/KopayaAppService.ts | 16 +- src/main/ipc/registerIpcHandlers.ts | 1 + src/preload/index.ts | 1 + src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 1 + src/shared/contracts/sidecar.ts | 15 ++ 11 files changed, 438 insertions(+), 22 deletions(-) create mode 100644 HANDOVER.md diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..1f060ef --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,222 @@ +# Handover: Copilot connection status 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. + +## Goal + +Build UI for the roadmap's highest-priority backend work: + +- 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 + +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. + +## Backend changes already implemented + +### 1. `SidecarCapabilities` now includes connection diagnostics + +File: `src/shared/contracts/sidecar.ts` + +New field: + +```ts +connection: { + status: 'ready' | 'copilot-cli-missing' | 'copilot-auth-required' | 'copilot-error'; + summary: string; + detail?: string; + copilotCliPath?: string; + checkedAt: string; +} +``` + +This comes from the .NET sidecar and is included alongside `runtime`, `modes`, and `models`. + +### 2. There is now a refreshable backend API + +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/SidecarProtocolHost.cs` +- `sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs` + +The backend now reports: + +- `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 + +### 4. 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 + +## Important architecture note + +Kopaya currently does **not** manage provider credentials directly for this path. + +The sidecar resolves the system-installed `copilot` command and uses that runtime. That means the near-term UI should be about: + +- install state +- login state +- connection health +- model availability +- refresh / retry + +It should **not** ask the user for OpenAI, Anthropic, or Google secrets as part of this specific Copilot status flow. + +## Current renderer integration point + +File: `src/renderer/App.tsx` + +On mount, the renderer already does: + +```ts +api.describeSidecarCapabilities().then(setSidecarCapabilities) +``` + +This state is already stored in: + +```ts +const [sidecarCapabilities, setSidecarCapabilities] = useState(); +``` + +So the frontend work is mostly about: + +1. reading `sidecarCapabilities.connection` +2. rendering the right UX state +3. calling `api.refreshSidecarCapabilities()` when the user retries + +## Recommended UI states + +### `ready` + +Show: + +- positive connected state +- connection summary +- model count (`sidecarCapabilities.models.length`) +- optional advanced details like CLI path and last checked time + +Good copy direction: + +- "Connected to GitHub Copilot" +- "19 models available" + +### `copilot-cli-missing` + +Show: + +- an actionable install state +- summary from backend +- detail text in an expandable "technical details" area +- a Refresh button that calls `refreshSidecarCapabilities()` + +Good copy direction: + +- "GitHub Copilot CLI not found" +- "Install the `copilot` CLI and make sure it is available on PATH" + +### `copilot-auth-required` + +Show: + +- an actionable login state +- summary from backend +- technical details if available +- a Refresh button after the user logs in externally + +Good copy direction: + +- "GitHub Copilot needs sign-in" +- "Finish login in the Copilot CLI, then refresh" + +### `copilot-error` + +Show: + +- a generic error state +- summary from backend +- `detail` in a technical details area +- Refresh button + +Good copy direction: + +- "Kopaya found Copilot, but could not load models" + +## Recommended frontend implementation shape + +### 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 diff --git a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs index ff74c79..374144d 100644 --- a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs @@ -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 Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase); public IReadOnlyList Models { get; init; } = []; + public SidecarConnectionDiagnosticsDto Connection { get; init; } = new(); } public class SidecarCommandEnvelope diff --git a/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs b/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs index 18d5b06..4c68d01 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/CopilotCliPathResolver.cs @@ -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 Environment); + internal sealed record CopilotCliLaunch(string Path, string[] Args); diff --git a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs index 2da40c2..e8e3548 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs @@ -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> _capabilitiesProvider; private readonly PatternValidator _patternValidator; private readonly ITurnWorkflowRunner _workflowRunner; @@ -164,39 +177,67 @@ public sealed class SidecarProtocolHost private static async Task BuildCapabilitiesAsync(CancellationToken cancellationToken) { IReadOnlyList 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(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 BuildModeCapabilities() + { + return new Dictionary(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> 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"; + } } diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs index e22fad5..94d8bd6 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -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> 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", + }, })); } diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index 05ee40b..64bdc93 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -54,11 +54,11 @@ export class KopayaAppService extends EventEmitter { private sidecarCapabilities?: SidecarCapabilities; async describeSidecarCapabilities(): Promise { - if (!this.sidecarCapabilities) { - this.sidecarCapabilities = await this.sidecar.describeCapabilities(); - } + return this.loadSidecarCapabilities(); + } - return this.sidecarCapabilities; + async refreshSidecarCapabilities(): Promise { + return this.loadSidecarCapabilities(true); } async loadWorkspace(): Promise { @@ -494,4 +494,12 @@ export class KopayaAppService extends EventEmitter { private emitSessionEvent(event: SessionEventRecord): void { this.emit('session-event', event); } + + private async loadSidecarCapabilities(forceRefresh = false): Promise { + if (forceRefresh || !this.sidecarCapabilities) { + this.sidecarCapabilities = await this.sidecar.describeCapabilities(); + } + + return this.sidecarCapabilities; + } } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index ce8e533..4f23e9f 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -12,6 +12,7 @@ import { KopayaAppService } from '@main/KopayaAppService'; export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void { ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities()); + ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities()); ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace()); ipcMain.handle(ipcChannels.addProject, () => service.addProject()); ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId)); diff --git a/src/preload/index.ts b/src/preload/index.ts index 902cafa..33c8f97 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { ElectronApi } from '@shared/contracts/ipc'; const api: ElectronApi = { describeSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.describeSidecarCapabilities), + refreshSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.refreshSidecarCapabilities), loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace), addProject: () => ipcRenderer.invoke(ipcChannels.addProject), removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index eef302d..2751177 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -1,5 +1,6 @@ export const ipcChannels = { describeSidecarCapabilities: 'sidecar:describe-capabilities', + refreshSidecarCapabilities: 'sidecar:refresh-capabilities', loadWorkspace: 'workspace:load', addProject: 'workspace:add-project', removeProject: 'workspace:remove-project', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index a02aa9e..5dda2b7 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -26,6 +26,7 @@ export interface UpdateScratchpadSessionConfigInput { export interface ElectronApi { describeSidecarCapabilities(): Promise; + refreshSidecarCapabilities(): Promise; loadWorkspace(): Promise; addProject(): Promise; removeProject(projectId: string): Promise; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index f5b1df1..aa8124c 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -6,6 +6,20 @@ export interface SidecarModeCapability { reason?: string; } +export type SidecarConnectionStatus = + | 'ready' + | 'copilot-cli-missing' + | 'copilot-auth-required' + | 'copilot-error'; + +export interface SidecarConnectionDiagnostics { + status: SidecarConnectionStatus; + summary: string; + detail?: string; + copilotCliPath?: string; + checkedAt: string; +} + export interface SidecarModelCapability { id: string; name: string; @@ -17,6 +31,7 @@ export interface SidecarCapabilities { runtime: 'dotnet-maf'; modes: Record; models: SidecarModelCapability[]; + connection: SidecarConnectionDiagnostics; } export interface DescribeCapabilitiesCommand {