From 6e623b7bd6eb1b8a9b3dc415e1e62ab82d4dbee0 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 22 Mar 2026 11:07:43 +0100 Subject: [PATCH] fix: use live copilot model availability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/ProtocolModels.cs | 9 + .../Services/SidecarProtocolHost.cs | 55 +++- .../SidecarProtocolHostTests.cs | 40 ++- src/main/KopayaAppService.ts | 66 ++++- src/main/ipc/registerIpcHandlers.ts | 1 + src/preload/index.ts | 1 + src/renderer/App.tsx | 52 +++- src/renderer/components/AgentConfigFields.tsx | 76 ++++- src/renderer/components/ChatPane.tsx | 267 ++++------------- src/renderer/components/PatternEditor.tsx | 31 +- src/renderer/components/SettingsPanel.tsx | 4 + src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 4 +- src/shared/contracts/sidecar.ts | 10 +- src/shared/domain/models.ts | 268 ++++++++++++++++-- tests/shared/models.test.ts | 73 +++++ 16 files changed, 691 insertions(+), 267 deletions(-) create mode 100644 tests/shared/models.test.ts diff --git a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs index 5622642..ff74c79 100644 --- a/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Kopaya.AgentHost/Contracts/ProtocolModels.cs @@ -48,10 +48,19 @@ public sealed class SidecarModeCapabilityDto public string? Reason { get; init; } } +public sealed class SidecarModelCapabilityDto +{ + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public IReadOnlyList SupportedReasoningEfforts { get; init; } = []; + public string? DefaultReasoningEffort { get; init; } +} + 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 class SidecarCommandEnvelope diff --git a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs index 0c9caff..2da40c2 100644 --- a/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Kopaya.AgentHost/Services/SidecarProtocolHost.cs @@ -1,12 +1,14 @@ using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; +using GitHub.Copilot.SDK; using Kopaya.AgentHost.Contracts; namespace Kopaya.AgentHost.Services; public sealed class SidecarProtocolHost { + private readonly Func> _capabilitiesProvider; private readonly PatternValidator _patternValidator; private readonly ITurnWorkflowRunner _workflowRunner; private readonly JsonSerializerOptions _jsonOptions; @@ -18,10 +20,14 @@ public sealed class SidecarProtocolHost { } - public SidecarProtocolHost(PatternValidator patternValidator, ITurnWorkflowRunner? workflowRunner = null) + public SidecarProtocolHost( + PatternValidator patternValidator, + ITurnWorkflowRunner? workflowRunner = null, + Func>? capabilitiesProvider = null) { _patternValidator = patternValidator; _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator); + _capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync; _jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, @@ -82,7 +88,7 @@ public sealed class SidecarProtocolHost { Type = "capabilities", RequestId = envelope.RequestId, - Capabilities = BuildCapabilities(), + Capabilities = await _capabilitiesProvider(cancellationToken).ConfigureAwait(false), }, cancellationToken).ConfigureAwait(false); break; @@ -155,8 +161,19 @@ public sealed class SidecarProtocolHost } } - private static SidecarCapabilitiesDto BuildCapabilities() + private static async Task BuildCapabilitiesAsync(CancellationToken cancellationToken) { + IReadOnlyList models = []; + + try + { + models = await ListAvailableModelsAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[kopaya sidecar] Failed to list available Copilot models: {exception.Message}"); + } + return new SidecarCapabilitiesDto { Modes = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -172,6 +189,38 @@ public sealed class SidecarProtocolHost Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.", }, }, + Models = models, }; } + + private static async Task> ListAvailableModelsAsync( + CancellationToken cancellationToken) + { + CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); + + 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 + { + Id = model.Id, + Name = model.Name, + SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? []) + .Where(IsReasoningEffort) + .Distinct(StringComparer.Ordinal) + .ToList(), + DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort) + ? model.DefaultReasoningEffort + : null, + }) + .OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool IsReasoningEffort(string? value) + { + return value is "low" or "medium" or "high" or "xhigh"; + } } diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs index eff26ed..e22fad5 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -18,7 +18,7 @@ public sealed class SidecarProtocolHostTests { Type = "describe-capabilities", RequestId = "cap-1", - }); + }, CreateHostForTests()); Assert.Collection( events, @@ -33,6 +33,10 @@ public sealed class SidecarProtocolHostTests JsonElement modes = capabilities.GetProperty("modes"); Assert.True(modes.GetProperty("single").GetProperty("available").GetBoolean()); Assert.False(modes.GetProperty("magentic").GetProperty("available").GetBoolean()); + JsonElement[] models = capabilities.GetProperty("models").EnumerateArray().ToArray(); + JsonElement model = Assert.Single(models); + Assert.Equal("gpt-5.4", model.GetProperty("id").GetString()); + Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString()); string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty; Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase); @@ -220,10 +224,42 @@ public sealed class SidecarProtocolHostTests using StringReader reader = new(input); using StringWriter writer = new(); - await (host ?? new SidecarProtocolHost()).RunAsync(reader, writer, CancellationToken.None); + await (host ?? CreateHostForTests()).RunAsync(reader, writer, CancellationToken.None); return ParseEvents(writer.ToString()); } + private static SidecarProtocolHost CreateHostForTests() + { + return new SidecarProtocolHost( + new PatternValidator(), + capabilitiesProvider: _ => Task.FromResult(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#.", + }, + }, + Models = + [ + new SidecarModelCapabilityDto + { + Id = "gpt-5.4", + Name = "GPT-5.4", + SupportedReasoningEfforts = ["low", "medium", "high", "xhigh"], + DefaultReasoningEffort = "medium", + }, + ], + })); + } + private static IReadOnlyList ParseEvents(string output) { List events = []; diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index 98498e7..05ee40b 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -3,8 +3,17 @@ import { basename } from 'node:path'; import { dialog } from 'electron'; -import type { AgentActivityEvent, TurnDeltaEvent } from '@shared/contracts/sidecar'; -import { findModel } from '@shared/domain/models'; +import type { + AgentActivityEvent, + SidecarCapabilities, + TurnDeltaEvent, +} from '@shared/contracts/sidecar'; +import { + buildAvailableModelCatalog, + findModel, + normalizePatternModels, + resolveReasoningEffort, +} from '@shared/domain/models'; import { buildSessionTitle, isReasoningEffort, @@ -42,6 +51,15 @@ export class KopayaAppService extends EventEmitter { private readonly sidecar = new SidecarClient(); private readonly secretStore = new SecretStore(); private workspace?: WorkspaceState; + private sidecarCapabilities?: SidecarCapabilities; + + async describeSidecarCapabilities(): Promise { + if (!this.sidecarCapabilities) { + this.sidecarCapabilities = await this.sidecar.describeCapabilities(); + } + + return this.sidecarCapabilities; + } async loadWorkspace(): Promise { if (!this.workspace) { @@ -152,6 +170,8 @@ export class KopayaAppService extends EventEmitter { const workspace = await this.loadWorkspace(); const project = this.requireProject(workspace, projectId); const pattern = this.requirePattern(workspace, patternId); + const modelCatalog = await this.loadAvailableModelCatalog(); + const normalizedPattern = normalizePatternModels(pattern, modelCatalog); const session: SessionRecord = { id: createId('session'), @@ -162,7 +182,9 @@ export class KopayaAppService extends EventEmitter { updatedAt: nowIso(), status: 'idle', messages: [], - scratchpadConfig: isScratchpadProject(project) ? createScratchpadSessionConfig(pattern) : undefined, + scratchpadConfig: isScratchpadProject(project) + ? createScratchpadSessionConfig(normalizedPattern) + : undefined, }; workspace.sessions.unshift(session); @@ -177,9 +199,7 @@ export class KopayaAppService extends EventEmitter { const session = this.requireSession(workspace, sessionId); const project = this.requireProject(workspace, session.projectId); const pattern = this.requirePattern(workspace, session.patternId); - const effectivePattern = isScratchpadProject(project) - ? applyScratchpadSessionConfig(pattern, session) - : pattern; + const effectivePattern = await this.buildEffectivePattern(project, pattern, session); const trimmed = content.trim(); if (!trimmed) { @@ -248,12 +268,12 @@ export class KopayaAppService extends EventEmitter { async updateScratchpadSessionConfig( sessionId: string, model: string, - reasoningEffort: ReasoningEffort, + reasoningEffort?: ReasoningEffort, ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); const project = this.requireProject(workspace, session.projectId); - const pattern = this.requirePattern(workspace, session.patternId); + const modelCatalog = await this.loadAvailableModelCatalog(); if (!isScratchpadProject(project)) { throw new Error('Only scratchpad sessions can change model settings in chat.'); @@ -264,17 +284,17 @@ export class KopayaAppService extends EventEmitter { } const normalizedModel = model.trim(); - if (!normalizedModel || !findModel(normalizedModel)) { + const selectedModel = normalizedModel ? findModel(normalizedModel, modelCatalog) : undefined; + if (!selectedModel) { throw new Error(`Model "${model}" is not available.`); } - if (!isReasoningEffort(reasoningEffort)) { + if (reasoningEffort && !isReasoningEffort(reasoningEffort)) { throw new Error(`Reasoning effort "${reasoningEffort}" is not supported.`); } session.scratchpadConfig = { - ...(createScratchpadSessionConfig(pattern) ?? {}), model: normalizedModel, - reasoningEffort, + reasoningEffort: resolveReasoningEffort(selectedModel, reasoningEffort), }; session.updatedAt = nowIso(); @@ -449,6 +469,28 @@ export class KopayaAppService extends EventEmitter { return workspace; } + private async loadAvailableModelCatalog() { + try { + const capabilities = await this.describeSidecarCapabilities(); + return buildAvailableModelCatalog(capabilities.models); + } catch { + return buildAvailableModelCatalog(); + } + } + + private async buildEffectivePattern( + project: ProjectRecord, + pattern: PatternDefinition, + session: SessionRecord, + ): Promise { + const patternWithSessionConfig = isScratchpadProject(project) + ? applyScratchpadSessionConfig(pattern, session) + : pattern; + + const modelCatalog = await this.loadAvailableModelCatalog(); + return normalizePatternModels(patternWithSessionConfig, modelCatalog); + } + private emitSessionEvent(event: SessionEventRecord): void { this.emit('session-event', event); } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 7dd3211..ce8e533 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -11,6 +11,7 @@ import type { import { KopayaAppService } from '@main/KopayaAppService'; export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void { + ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities()); 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 c283bff..902cafa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { ipcChannels } from '@shared/contracts/channels'; import type { ElectronApi } from '@shared/contracts/ipc'; const api: ElectronApi = { + describeSidecarCapabilities: () => ipcRenderer.invoke(ipcChannels.describeSidecarCapabilities), loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace), addProject: () => ipcRenderer.invoke(ipcChannels.addProject), removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6a5dcfb..0589708 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,13 +14,20 @@ import { import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { WelcomePane } from '@renderer/components/WelcomePane'; import { getElectronApi } from '@renderer/lib/electronApi'; +import type { SidecarCapabilities } from '@shared/contracts/sidecar'; +import { + buildAvailableModelCatalog, + findModel, + normalizePatternModels, + resolveReasoningEffort, +} from '@shared/domain/models'; import type { PatternDefinition } from '@shared/domain/pattern'; import { isScratchpadProject } from '@shared/domain/project'; import { applyScratchpadSessionConfig } from '@shared/domain/session'; import type { WorkspaceState } from '@shared/domain/workspace'; import { createId, nowIso } from '@shared/utils/ids'; -function createDraftPattern(): PatternDefinition { +function createDraftPattern(defaultModelId: string, defaultReasoningEffort: PatternDefinition['agents'][0]['reasoningEffort']): PatternDefinition { const timestamp = nowIso(); return { id: createId('custom-pattern'), @@ -35,8 +42,8 @@ function createDraftPattern(): PatternDefinition { name: 'Primary Agent', description: 'General-purpose assistant.', instructions: 'You are a helpful coding assistant working inside the selected project.', - model: 'gpt-5.4', - reasoningEffort: 'high', + model: defaultModelId, + reasoningEffort: defaultReasoningEffort, }, ], createdAt: timestamp, @@ -48,6 +55,7 @@ export default function App() { const api = getElectronApi(); const [workspace, setWorkspace] = useState(); const [error, setError] = useState(); + const [sidecarCapabilities, setSidecarCapabilities] = useState(); const [sessionActivities, setSessionActivities] = useState({}); const [showSettings, setShowSettings] = useState(false); @@ -61,6 +69,14 @@ export default function App() { .loadWorkspace() .then((ws) => !disposed && setWorkspace(ws)) .catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e))); + void api + .describeSidecarCapabilities() + .then((capabilities) => !disposed && setSidecarCapabilities(capabilities)) + .catch((e) => { + if (!disposed) { + console.warn('Failed to load sidecar capabilities', e); + } + }); const offWorkspace = api.onWorkspaceUpdated((ws) => { setWorkspace(ws); @@ -97,6 +113,10 @@ export default function App() { : undefined, [selectedSession, workspace?.projects], ); + const availableModels = useMemo( + () => buildAvailableModelCatalog(sidecarCapabilities?.models), + [sidecarCapabilities?.models], + ); const patternForSession = useMemo(() => { if (!selectedSession) { return undefined; @@ -107,10 +127,13 @@ export default function App() { return undefined; } - return projectForSession && isScratchpadProject(projectForSession) - ? applyScratchpadSessionConfig(basePattern, selectedSession) - : basePattern; - }, [projectForSession, selectedSession, workspace?.patterns]); + const patternWithSessionConfig = + projectForSession && isScratchpadProject(projectForSession) + ? applyScratchpadSessionConfig(basePattern, selectedSession) + : basePattern; + + return normalizePatternModels(patternWithSessionConfig, availableModels); + }, [availableModels, projectForSession, selectedSession, workspace?.patterns]); const activityForSession = useMemo( () => (selectedSession ? sessionActivities[selectedSession.id] : undefined), [selectedSession, sessionActivities], @@ -152,6 +175,7 @@ export default function App() { reasoningEffort: config.reasoningEffort, }) } + availableModels={availableModels} pattern={patternForSession} project={projectForSession} session={selectedSession} @@ -177,12 +201,20 @@ export default function App() { // Settings overlay const overlay = showSettings ? ( - setShowSettings(false)} + setShowSettings(false)} onDeletePattern={async (id) => { await api.deletePattern(id); }} - onNewPattern={createDraftPattern} + onNewPattern={() => { + const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4'); + + return createDraftPattern( + defaultModel?.id ?? 'gpt-5.4', + resolveReasoningEffort(defaultModel, 'high'), + ); + }} onSavePattern={async (pattern) => { await api.savePattern({ pattern }); }} diff --git a/src/renderer/components/AgentConfigFields.tsx b/src/renderer/components/AgentConfigFields.tsx index b36e1d6..c7bfd36 100644 --- a/src/renderer/components/AgentConfigFields.tsx +++ b/src/renderer/components/AgentConfigFields.tsx @@ -13,6 +13,10 @@ import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pat import { ProviderIcon } from './ProviderIcons'; function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) { + if (!tier) { + return null; + } + const styles = { premium: 'bg-amber-500/10 text-amber-400', standard: 'bg-zinc-700/50 text-zinc-500', @@ -29,6 +33,7 @@ function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) { interface ModelSelectProps { value: string; onChange: (model: string) => void; + models?: ReadonlyArray; label?: string; disabled?: boolean; } @@ -36,6 +41,7 @@ interface ModelSelectProps { export function ModelSelect({ value, onChange, + models = modelCatalog, label = 'Model', disabled = false, }: ModelSelectProps) { @@ -57,8 +63,15 @@ export function ModelSelect({ return () => document.removeEventListener('mousedown', handleClick); }, [open]); - const selected = findModel(value); + const selected = findModel(value, models); const provider = selected?.provider ?? inferProvider(value); + const groupedModels = providerMeta + .map((providerGroup) => ({ + ...providerGroup, + models: models.filter((model) => model.provider === providerGroup.id), + })) + .filter((providerGroup) => providerGroup.models.length > 0); + const otherModels = models.filter((model) => !model.provider); return (