mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-30 06:27:13 +02:00
feat: add Copilot connection diagnostics
Add backend support for Copilot connection and account-status UX without introducing raw provider credential management. This change extends sidecar capabilities with explicit connection diagnostics, adds a refreshable capabilities IPC path, classifies common Copilot CLI failure modes, and includes a frontend handover document describing the new payload and expected UI states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+222
@@ -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<SidecarCapabilities>();
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
@@ -56,11 +56,21 @@ public sealed class SidecarModelCapabilityDto
|
|||||||
public string? DefaultReasoningEffort { get; init; }
|
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 sealed class SidecarCapabilitiesDto
|
||||||
{
|
{
|
||||||
public string Runtime { get; init; } = "dotnet-maf";
|
public string Runtime { get; init; } = "dotnet-maf";
|
||||||
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
|
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
|
||||||
|
public SidecarConnectionDiagnosticsDto Connection { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SidecarCommandEnvelope
|
public class SidecarCommandEnvelope
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ internal static class CopilotCliPathResolver
|
|||||||
private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"];
|
private static readonly string[] BlockedCliEnvironmentPrefixes = ["BUN_", "COPILOT_", "ELECTRON_", "NODE_", "NPM_"];
|
||||||
|
|
||||||
public static CopilotClientOptions CreateClientOptions()
|
public static CopilotClientOptions CreateClientOptions()
|
||||||
|
{
|
||||||
|
return CreateClientOptions(ResolveCliContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static CopilotCliContext ResolveCliContext()
|
||||||
{
|
{
|
||||||
string? cliPath = Resolve(
|
string? cliPath = Resolve(
|
||||||
Environment.GetEnvironmentVariable("PATH"),
|
Environment.GetEnvironmentVariable("PATH"),
|
||||||
@@ -28,11 +33,22 @@ internal static class CopilotCliPathResolver
|
|||||||
OperatingSystem.IsWindows(),
|
OperatingSystem.IsWindows(),
|
||||||
Environment.GetEnvironmentVariable("ComSpec"));
|
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
|
return new CopilotClientOptions
|
||||||
{
|
{
|
||||||
CliPath = launch.Path,
|
CliPath = context.LaunchPath,
|
||||||
CliArgs = launch.Args,
|
CliArgs = context.LaunchArgs,
|
||||||
Environment = ResolveCliEnvironment(GetCurrentEnvironmentVariables()),
|
Environment = context.Environment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,4 +178,10 @@ internal static class CopilotCliPathResolver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record CopilotCliContext(
|
||||||
|
string CliPath,
|
||||||
|
string LaunchPath,
|
||||||
|
string[] LaunchArgs,
|
||||||
|
IReadOnlyDictionary<string, string> Environment);
|
||||||
|
|
||||||
internal sealed record CopilotCliLaunch(string Path, string[] Args);
|
internal sealed record CopilotCliLaunch(string Path, string[] Args);
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ namespace Kopaya.AgentHost.Services;
|
|||||||
|
|
||||||
public sealed class SidecarProtocolHost
|
public sealed class SidecarProtocolHost
|
||||||
{
|
{
|
||||||
|
private static readonly string[] AuthenticationErrorIndicators =
|
||||||
|
[
|
||||||
|
"login",
|
||||||
|
"log in",
|
||||||
|
"sign in",
|
||||||
|
"authenticate",
|
||||||
|
"authentication",
|
||||||
|
"not signed in",
|
||||||
|
"not logged in",
|
||||||
|
"reauth",
|
||||||
|
"credential",
|
||||||
|
];
|
||||||
|
|
||||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||||
private readonly PatternValidator _patternValidator;
|
private readonly PatternValidator _patternValidator;
|
||||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||||
@@ -164,19 +177,48 @@ public sealed class SidecarProtocolHost
|
|||||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||||
|
CopilotCliContext cliContext;
|
||||||
|
SidecarConnectionDiagnosticsDto connection;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
models = await ListAvailableModelsAsync(cancellationToken).ConfigureAwait(false);
|
cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
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}");
|
Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SidecarCapabilitiesDto
|
return new SidecarCapabilitiesDto
|
||||||
{
|
{
|
||||||
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
Modes = BuildModeCapabilities(),
|
||||||
|
Models = models,
|
||||||
|
Connection = connection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["single"] = new() { Available = true },
|
["single"] = new() { Available = true },
|
||||||
["sequential"] = new() { Available = true },
|
["sequential"] = new() { Available = true },
|
||||||
@@ -188,15 +230,14 @@ public sealed class SidecarProtocolHost
|
|||||||
Available = false,
|
Available = false,
|
||||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||||
},
|
},
|
||||||
},
|
|
||||||
Models = models,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||||
|
CopilotCliContext cliContext,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||||
|
|
||||||
await using CopilotClient client = new(clientOptions);
|
await using CopilotClient client = new(clientOptions);
|
||||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -223,4 +264,67 @@ public sealed class SidecarProtocolHost
|
|||||||
{
|
{
|
||||||
return value is "low" or "medium" or "high" or "xhigh";
|
return value is "low" or "medium" or "high" or "xhigh";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
|
||||||
|
{
|
||||||
|
return new SidecarConnectionDiagnosticsDto
|
||||||
|
{
|
||||||
|
Status = "copilot-cli-missing",
|
||||||
|
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
|
||||||
|
Detail = exception.Message,
|
||||||
|
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||||
|
string cliPath,
|
||||||
|
int modelCount)
|
||||||
|
{
|
||||||
|
string summary = modelCount switch
|
||||||
|
{
|
||||||
|
0 => "Connected to GitHub Copilot, but no models were reported.",
|
||||||
|
1 => "Connected to GitHub Copilot. 1 model is available.",
|
||||||
|
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new SidecarConnectionDiagnosticsDto
|
||||||
|
{
|
||||||
|
Status = "ready",
|
||||||
|
Summary = summary,
|
||||||
|
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||||
|
CopilotCliPath = cliPath,
|
||||||
|
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||||
|
string? cliPath,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
string status = ClassifyConnectionStatus(exception);
|
||||||
|
string summary = status == "copilot-auth-required"
|
||||||
|
? "GitHub Copilot requires authentication before Kopaya can load models."
|
||||||
|
: "GitHub Copilot was found, but Kopaya could not load its model list.";
|
||||||
|
|
||||||
|
return new SidecarConnectionDiagnosticsDto
|
||||||
|
{
|
||||||
|
Status = status,
|
||||||
|
Summary = summary,
|
||||||
|
Detail = exception.Message,
|
||||||
|
CopilotCliPath = cliPath,
|
||||||
|
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ClassifyConnectionStatus(Exception exception)
|
||||||
|
{
|
||||||
|
string message = exception.Message;
|
||||||
|
if (AuthenticationErrorIndicators.Any(indicator =>
|
||||||
|
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return "copilot-auth-required";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "copilot-error";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public sealed class SidecarProtocolHostTests
|
|||||||
JsonElement model = Assert.Single(models);
|
JsonElement model = Assert.Single(models);
|
||||||
Assert.Equal("gpt-5.4", model.GetProperty("id").GetString());
|
Assert.Equal("gpt-5.4", model.GetProperty("id").GetString());
|
||||||
Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").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;
|
string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty;
|
||||||
Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -215,6 +218,27 @@ public sealed class SidecarProtocolHostTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
|
||||||
|
{
|
||||||
|
string status = SidecarProtocolHost.ClassifyConnectionStatus(
|
||||||
|
new InvalidOperationException("Please run copilot auth login to continue."));
|
||||||
|
|
||||||
|
Assert.Equal("copilot-auth-required", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
|
||||||
|
{
|
||||||
|
SidecarConnectionDiagnosticsDto diagnostics =
|
||||||
|
SidecarProtocolHost.CreateReadyConnectionDiagnostics(@"C:\tools\copilot\copilot.exe", 2);
|
||||||
|
|
||||||
|
Assert.Equal("ready", diagnostics.Status);
|
||||||
|
Assert.Equal(@"C:\tools\copilot\copilot.exe", diagnostics.CopilotCliPath);
|
||||||
|
Assert.Contains("2 models", diagnostics.Summary, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
|
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
|
||||||
object command,
|
object command,
|
||||||
SidecarProtocolHost? host = null)
|
SidecarProtocolHost? host = null)
|
||||||
@@ -257,6 +281,13 @@ public sealed class SidecarProtocolHostTests
|
|||||||
DefaultReasoningEffort = "medium",
|
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",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
private sidecarCapabilities?: SidecarCapabilities;
|
private sidecarCapabilities?: SidecarCapabilities;
|
||||||
|
|
||||||
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
||||||
if (!this.sidecarCapabilities) {
|
return this.loadSidecarCapabilities();
|
||||||
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.sidecarCapabilities;
|
async refreshSidecarCapabilities(): Promise<SidecarCapabilities> {
|
||||||
|
return this.loadSidecarCapabilities(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadWorkspace(): Promise<WorkspaceState> {
|
async loadWorkspace(): Promise<WorkspaceState> {
|
||||||
@@ -494,4 +494,12 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
private emitSessionEvent(event: SessionEventRecord): void {
|
private emitSessionEvent(event: SessionEventRecord): void {
|
||||||
this.emit('session-event', event);
|
this.emit('session-event', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadSidecarCapabilities(forceRefresh = false): Promise<SidecarCapabilities> {
|
||||||
|
if (forceRefresh || !this.sidecarCapabilities) {
|
||||||
|
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sidecarCapabilities;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { KopayaAppService } from '@main/KopayaAppService';
|
|||||||
|
|
||||||
export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void {
|
export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void {
|
||||||
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
|
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
|
||||||
|
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
|
||||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||||
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
||||||
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ElectronApi } from '@shared/contracts/ipc';
|
|||||||
|
|
||||||
const api: ElectronApi = {
|
const api: ElectronApi = {
|
||||||
describeSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.describeSidecarCapabilities),
|
describeSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.describeSidecarCapabilities),
|
||||||
|
refreshSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.refreshSidecarCapabilities),
|
||||||
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
||||||
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
||||||
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const ipcChannels = {
|
export const ipcChannels = {
|
||||||
describeSidecarCapabilities: 'sidecar:describe-capabilities',
|
describeSidecarCapabilities: 'sidecar:describe-capabilities',
|
||||||
|
refreshSidecarCapabilities: 'sidecar:refresh-capabilities',
|
||||||
loadWorkspace: 'workspace:load',
|
loadWorkspace: 'workspace:load',
|
||||||
addProject: 'workspace:add-project',
|
addProject: 'workspace:add-project',
|
||||||
removeProject: 'workspace:remove-project',
|
removeProject: 'workspace:remove-project',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface UpdateScratchpadSessionConfigInput {
|
|||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||||
|
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||||
loadWorkspace(): Promise<WorkspaceState>;
|
loadWorkspace(): Promise<WorkspaceState>;
|
||||||
addProject(): Promise<WorkspaceState>;
|
addProject(): Promise<WorkspaceState>;
|
||||||
removeProject(projectId: string): Promise<WorkspaceState>;
|
removeProject(projectId: string): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ export interface SidecarModeCapability {
|
|||||||
reason?: string;
|
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 {
|
export interface SidecarModelCapability {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,6 +31,7 @@ export interface SidecarCapabilities {
|
|||||||
runtime: 'dotnet-maf';
|
runtime: 'dotnet-maf';
|
||||||
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
|
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
|
||||||
models: SidecarModelCapability[];
|
models: SidecarModelCapability[];
|
||||||
|
connection: SidecarConnectionDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DescribeCapabilitiesCommand {
|
export interface DescribeCapabilitiesCommand {
|
||||||
|
|||||||
Reference in New Issue
Block a user