fix: use live copilot model availability

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-22 11:07:43 +01:00
co-authored by Copilot
parent 9799a959d6
commit 6e623b7bd6
16 changed files with 691 additions and 267 deletions
@@ -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<string> SupportedReasoningEfforts { get; init; } = [];
public string? DefaultReasoningEffort { get; init; }
}
public sealed class SidecarCapabilitiesDto
{
public string Runtime { get; init; } = "dotnet-maf";
public Dictionary<string, SidecarModeCapabilityDto> Modes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<SidecarModelCapabilityDto> Models { get; init; } = [];
}
public class SidecarCommandEnvelope
@@ -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<CancellationToken, Task<SidecarCapabilitiesDto>> _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<CancellationToken, Task<SidecarCapabilitiesDto>>? 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<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> 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<string, SidecarModeCapabilityDto>(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<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CancellationToken cancellationToken)
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
{
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";
}
}
@@ -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<string, SidecarModeCapabilityDto>(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<JsonElement> ParseEvents(string output)
{
List<JsonElement> events = [];
+54 -12
View File
@@ -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<AppServiceEvents> {
private readonly sidecar = new SidecarClient();
private readonly secretStore = new SecretStore();
private workspace?: WorkspaceState;
private sidecarCapabilities?: SidecarCapabilities;
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
if (!this.sidecarCapabilities) {
this.sidecarCapabilities = await this.sidecar.describeCapabilities();
}
return this.sidecarCapabilities;
}
async loadWorkspace(): Promise<WorkspaceState> {
if (!this.workspace) {
@@ -152,6 +170,8 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
async updateScratchpadSessionConfig(
sessionId: string,
model: string,
reasoningEffort: ReasoningEffort,
reasoningEffort?: ReasoningEffort,
): Promise<WorkspaceState> {
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<AppServiceEvents> {
}
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<AppServiceEvents> {
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<PatternDefinition> {
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);
}
+1
View File
@@ -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));
+1
View File
@@ -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),
+42 -10
View File
@@ -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<WorkspaceState>();
const [error, setError] = useState<string>();
const [sidecarCapabilities, setSidecarCapabilities] = useState<SidecarCapabilities>();
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
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 ? (
<SettingsPanel
onClose={() => setShowSettings(false)}
<SettingsPanel
availableModels={availableModels}
onClose={() => 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 });
}}
+67 -9
View File
@@ -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<ModelDefinition>;
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 (
<label className="block space-y-1.5">
@@ -79,9 +92,7 @@ export function ModelSelect({
{open && !disabled && (
<div className="absolute z-30 mt-1 max-h-72 w-full overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{providerMeta.map((providerGroup) => {
const models = modelCatalog.filter((model) => model.provider === providerGroup.id);
{groupedModels.map((providerGroup) => {
return (
<div key={providerGroup.id}>
<div className="flex items-center gap-2 px-3 pb-1 pt-2.5">
@@ -90,7 +101,7 @@ export function ModelSelect({
{providerGroup.label}
</span>
</div>
{models.map((model) => (
{providerGroup.models.map((model) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
@@ -109,6 +120,29 @@ export function ModelSelect({
</div>
);
})}
{otherModels.length > 0 && (
<div>
<div className="px-3 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
Other
</div>
{otherModels.map((model) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
}`}
key={model.id}
onClick={() => {
onChange(model.id);
setOpen(false);
}}
type="button"
>
<span className="flex-1">{model.name}</span>
<TierBadge tier={model.tier} />
</button>
))}
</div>
)}
</div>
)}
</div>
@@ -117,8 +151,9 @@ export function ModelSelect({
}
interface ReasoningEffortSelectProps {
value: ReasoningEffort;
value?: ReasoningEffort;
onChange: (value: ReasoningEffort) => void;
supportedEfforts?: ReadonlyArray<ReasoningEffort>;
label?: string;
disabled?: boolean;
}
@@ -126,20 +161,43 @@ interface ReasoningEffortSelectProps {
export function ReasoningEffortSelect({
value,
onChange,
supportedEfforts,
label = 'Reasoning',
disabled = false,
}: ReasoningEffortSelectProps) {
const options = supportedEfforts
? reasoningEffortOptions.filter((option) => supportedEfforts.includes(option.value))
: [...reasoningEffortOptions];
const selectedValue = value && options.some((option) => option.value === value) ? value : options[0]?.value;
if (supportedEfforts && supportedEfforts.length === 0) {
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-zinc-400">{label}</span>
<div className="relative">
<input
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-[13px] text-zinc-500 outline-none"
disabled
readOnly
value="Not supported for this model"
/>
<Sparkles className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 text-zinc-600" />
</div>
</label>
);
}
return (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-zinc-400">{label}</span>
<div className="relative">
<select
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 pr-9 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50 disabled:cursor-not-allowed disabled:opacity-60"
disabled={disabled}
disabled={disabled || !selectedValue}
onChange={(event) => onChange(event.target.value as ReasoningEffort)}
value={value}
value={selectedValue}
>
{reasoningEffortOptions.map((option) => (
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
+60 -207
View File
@@ -1,13 +1,16 @@
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, ChevronDown, Loader2, Sparkles, User } from 'lucide-react';
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { findModel, inferProvider, modelCatalog, providerMeta, type ModelDefinition } from '@shared/domain/models';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import { reasoningEffortOptions } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
@@ -21,171 +24,15 @@ function ThinkingDots() {
);
}
/* ── Tier badge for model dropdown ─────────────────────────── */
function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) {
const styles = {
premium: 'bg-amber-500/10 text-amber-400',
standard: 'bg-zinc-700/50 text-zinc-500',
fast: 'bg-emerald-500/10 text-emerald-400',
};
return (
<span className={`ml-auto rounded px-1.5 py-0.5 text-[9px] font-medium ${styles[tier]}`}>
{tier}
</span>
);
}
/* ── Inline model pill with dropdown ───────────────────────── */
function InlineModelPill({
value,
onChange,
disabled,
}: {
value: string;
onChange: (model: string) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const selected = findModel(value);
const provider = selected?.provider ?? inferProvider(value);
const displayName = selected?.name ?? value ?? 'Model';
return (
<div className="relative" ref={ref}>
<button
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
open
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
} disabled:cursor-not-allowed disabled:opacity-50`}
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
{provider && <ProviderIcon provider={provider} className="size-3" />}
<span className="max-w-[140px] truncate">{displayName}</span>
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
</button>
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{providerMeta.map((pg) => {
const models = modelCatalog.filter((m) => m.provider === pg.id);
return (
<div key={pg.id}>
<div className="flex items-center gap-2 px-3 pb-1 pt-2.5">
<ProviderIcon provider={pg.id} className="size-3.5" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{pg.label}
</span>
</div>
{models.map((model) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
}`}
key={model.id}
onClick={() => { onChange(model.id); setOpen(false); }}
type="button"
>
<span className="flex-1">{model.name}</span>
<TierBadge tier={model.tier} />
</button>
))}
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Inline thinking effort pill with dropdown ─────────────── */
function InlineThinkingPill({
value,
onChange,
disabled,
}: {
value: ReasoningEffort;
onChange: (effort: ReasoningEffort) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const currentLabel = reasoningEffortOptions.find((o) => o.value === value)?.label ?? value;
return (
<div className="relative" ref={ref}>
<button
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
open
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
} disabled:cursor-not-allowed disabled:opacity-50`}
disabled={disabled}
onClick={() => setOpen(!open)}
type="button"
>
<Sparkles className="size-3" />
<span>{currentLabel}</span>
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
</button>
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-36 overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 py-1 shadow-2xl">
{reasoningEffortOptions.map((option) => (
<button
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
option.value === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
}`}
key={option.value}
onClick={() => { onChange(option.value); setOpen(false); }}
type="button"
>
{option.label}
</button>
))}
</div>
)}
</div>
);
}
/* ── ChatPane ──────────────────────────────────────────────── */
interface ChatPaneProps {
project: ProjectRecord;
pattern: PatternDefinition;
session: SessionRecord;
availableModels: ReadonlyArray<ModelDefinition>;
onSend: (content: string) => Promise<void>;
onUpdateScratchpadConfig?: (config: {
model: string;
reasoningEffort: ReasoningEffort;
reasoningEffort?: ReasoningEffort;
}) => Promise<unknown>;
}
@@ -193,6 +40,7 @@ export function ChatPane({
project,
pattern,
session,
availableModels,
onSend,
onUpdateScratchpadConfig,
}: ChatPaneProps) {
@@ -205,7 +53,9 @@ export function ChatPane({
const isSessionBusy = session.status === 'running';
const isScratchpad = isScratchpadProject(project);
const primaryAgent = pattern.agents[0];
const scratchpadReasoningEffort = primaryAgent?.reasoningEffort ?? 'high';
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
const scratchpadReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
const isComposerDisabled = isSessionBusy || isUpdatingScratchpadConfig;
useEffect(() => {
@@ -229,13 +79,16 @@ export function ChatPane({
async function handleScratchpadConfigChange(config: {
model: string;
reasoningEffort: ReasoningEffort;
reasoningEffort?: ReasoningEffort;
}) {
if (!isScratchpad || !primaryAgent || isComposerDisabled || !onUpdateScratchpadConfig) {
return;
}
if (config.model === primaryAgent.model && config.reasoningEffort === scratchpadReasoningEffort) {
if (
config.model === primaryAgent.model &&
config.reasoningEffort === scratchpadReasoningEffort
) {
return;
}
@@ -251,16 +104,15 @@ export function ChatPane({
}
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSubmit();
}
}
return (
<div className="flex h-full flex-col">
{/* Header — extra top padding clears the title bar overlay zone */}
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 pb-3 pt-12">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2>
@@ -269,9 +121,7 @@ export function ChatPane({
</p>
</div>
<div className="flex items-center gap-2">
{isSessionBusy && (
<span className="size-2 animate-pulse rounded-full bg-blue-400" />
)}
{isSessionBusy && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
{session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
<AlertCircle className="size-3.5" />
@@ -286,14 +136,11 @@ export function ChatPane({
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto" ref={transcriptRef}>
{session.messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<Bot className="size-10 text-zinc-800" />
<p className="text-sm text-zinc-500">
Send a message to start the conversation
</p>
<p className="text-sm text-zinc-500">Send a message to start the conversation</p>
<p className="text-[12px] text-zinc-700">
{isScratchpad ? (
<>
@@ -332,9 +179,7 @@ export function ChatPane({
<div className="flex gap-3">
<div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
isUser
? 'bg-indigo-600 text-white'
: 'bg-zinc-800 text-zinc-400'
isUser ? 'bg-indigo-600 text-white' : 'bg-zinc-800 text-zinc-400'
}`}
>
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
@@ -373,7 +218,6 @@ export function ChatPane({
)}
</div>
{/* Input area */}
<div className="border-t border-[var(--color-border)] px-6 py-4">
{session.lastError && (
<div className="mb-3 flex items-start gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-[13px] text-red-300">
@@ -390,32 +234,41 @@ export function ChatPane({
)}
<div className="mx-auto max-w-3xl">
{/* Scratchpad config pills — inline above composer */}
{isScratchpad && primaryAgent && (
<div className="mb-2 flex items-center gap-2">
<InlineModelPill
disabled={isComposerDisabled}
onChange={(model) =>
void handleScratchpadConfigChange({
model,
reasoningEffort: scratchpadReasoningEffort,
})
}
value={primaryAgent.model}
/>
<InlineThinkingPill
disabled={isComposerDisabled}
onChange={(reasoningEffort) =>
void handleScratchpadConfigChange({
model: primaryAgent.model,
reasoningEffort,
})
}
value={scratchpadReasoningEffort}
/>
{isUpdatingScratchpadConfig && (
<Loader2 className="size-3 animate-spin text-zinc-500" />
)}
<div className="mb-3 rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<ModelSelect
disabled={isComposerDisabled}
models={availableModels}
onChange={(modelId) => {
const nextModel = findModel(modelId, availableModels);
void handleScratchpadConfigChange({
model: modelId,
reasoningEffort: resolveReasoningEffort(nextModel, scratchpadReasoningEffort),
});
}}
value={primaryAgent.model}
/>
</div>
<div className="sm:w-48">
<ReasoningEffortSelect
disabled={isComposerDisabled}
label="Thinking"
onChange={(reasoningEffort) =>
void handleScratchpadConfigChange({
model: primaryAgent.model,
reasoningEffort,
})
}
supportedEfforts={supportedEfforts}
value={scratchpadReasoningEffort}
/>
</div>
</div>
<p className="mt-2 text-[11px] text-zinc-500">
Applies to future replies in this scratchpad.
</p>
</div>
)}
@@ -423,7 +276,7 @@ export function ChatPane({
<textarea
className="auto-resize-textarea block w-full resize-none bg-transparent px-4 py-3 pr-12 text-[14px] text-zinc-100 placeholder-zinc-600 outline-none"
disabled={isComposerDisabled}
onChange={(e) => setInput(e.target.value)}
onChange={(event) => setInput(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isSessionBusy
+28 -3
View File
@@ -15,6 +15,12 @@ import {
type LucideIcon,
} from 'lucide-react';
import {
findModel,
getSupportedReasoningEfforts,
resolveReasoningEffort,
type ModelDefinition,
} from '@shared/domain/models';
import {
validatePatternDefinition,
type OrchestrationMode,
@@ -25,6 +31,7 @@ import {
import { ModelSelect, ReasoningEffortSelect } from './AgentConfigFields';
interface PatternEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
pattern: PatternDefinition;
isBuiltin: boolean;
onChange: (pattern: PatternDefinition) => void;
@@ -189,7 +196,15 @@ function InputField({
);
}
export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave, onBack }: PatternEditorProps) {
export function PatternEditor({
availableModels,
pattern,
isBuiltin,
onChange,
onDelete,
onSave,
onBack,
}: PatternEditorProps) {
const issues = validatePatternDefinition(pattern);
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
@@ -199,6 +214,14 @@ export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave,
});
}
function updateAgentModel(agent: PatternAgentDefinition, modelId: string) {
const model = findModel(modelId, availableModels);
updateAgent(agent.id, {
model: modelId,
reasoningEffort: resolveReasoningEffort(model, agent.reasoningEffort),
});
}
return (
<div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */}
@@ -408,13 +431,15 @@ export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave,
value={agent.name}
/>
<ModelSelect
onChange={(v) => updateAgent(agent.id, { model: v })}
models={availableModels}
onChange={(value) => updateAgentModel(agent, value)}
value={agent.model}
/>
<ReasoningEffortSelect
label="Reasoning"
onChange={(value) => updateAgent(agent.id, { reasoningEffort: value })}
value={agent.reasoningEffort ?? 'high'}
supportedEfforts={getSupportedReasoningEfforts(findModel(agent.model, availableModels))}
value={resolveReasoningEffort(findModel(agent.model, availableModels), agent.reasoningEffort)}
/>
</div>
<div className="mt-3">
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { ChevronRight, Plus, X } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import { PatternEditor } from '@renderer/components/PatternEditor';
interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
onClose: () => void;
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
@@ -18,6 +20,7 @@ function modeBadgeClasses(pattern: PatternDefinition) {
}
export function SettingsPanel({
availableModels,
patterns,
onClose,
onSavePattern,
@@ -31,6 +34,7 @@ export function SettingsPanel({
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<PatternEditor
availableModels={availableModels}
isBuiltin={isBuiltin}
onBack={() => setEditingPattern(null)}
onChange={setEditingPattern}
+1
View File
@@ -1,4 +1,5 @@
export const ipcChannels = {
describeSidecarCapabilities: 'sidecar:describe-capabilities',
loadWorkspace: 'workspace:load',
addProject: 'workspace:add-project',
removeProject: 'workspace:remove-project',
+3 -1
View File
@@ -1,3 +1,4 @@
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionEventRecord } from '@shared/domain/event';
@@ -20,10 +21,11 @@ export interface SendSessionMessageInput {
export interface UpdateScratchpadSessionConfigInput {
sessionId: string;
model: string;
reasoningEffort: ReasoningEffort;
reasoningEffort?: ReasoningEffort;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
loadWorkspace(): Promise<WorkspaceState>;
addProject(): Promise<WorkspaceState>;
removeProject(projectId: string): Promise<WorkspaceState>;
+9 -1
View File
@@ -1,4 +1,4 @@
import type { PatternDefinition, PatternValidationIssue } from '@shared/domain/pattern';
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
import type { ChatMessageRecord } from '@shared/domain/session';
export interface SidecarModeCapability {
@@ -6,9 +6,17 @@ export interface SidecarModeCapability {
reason?: string;
}
export interface SidecarModelCapability {
id: string;
name: string;
supportedReasoningEfforts?: ReasoningEffort[];
defaultReasoningEffort?: ReasoningEffort;
}
export interface SidecarCapabilities {
runtime: 'dotnet-maf';
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
models: SidecarModelCapability[];
}
export interface DescribeCapabilitiesCommand {
+249 -19
View File
@@ -1,37 +1,181 @@
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
export type ModelProvider = 'openai' | 'anthropic' | 'google';
export interface ModelDefinition {
id: string;
name: string;
provider: ModelProvider;
tier: 'premium' | 'standard' | 'fast';
provider?: ModelProvider;
tier?: 'premium' | 'standard' | 'fast';
supportedReasoningEfforts?: ReadonlyArray<ReasoningEffort>;
defaultReasoningEffort?: ReasoningEffort;
}
export const modelCatalog: ModelDefinition[] = [
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', provider: 'openai', tier: 'fast' },
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.2', name: 'GPT-5.2', provider: 'openai', tier: 'standard' },
{ id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai', tier: 'standard' },
{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', tier: 'fast' },
const allReasoningEfforts: ReadonlyArray<ReasoningEffort> = ['low', 'medium', 'high', 'xhigh'];
{ id: 'claude-opus-4.5', name: 'Claude Opus 4.5', provider: 'anthropic', tier: 'premium' },
{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', provider: 'anthropic', tier: 'standard' },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic', tier: 'standard' },
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', provider: 'anthropic', tier: 'fast' },
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', provider: 'google', tier: 'standard' },
export const modelCatalog: ReadonlyArray<ModelDefinition> = [
{
id: 'gpt-5.4',
name: 'GPT-5.4',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.4-mini',
name: 'GPT-5.4 mini',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'high',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1',
name: 'GPT-5.1',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1-Codex',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1-Codex-Max',
provider: 'openai',
tier: 'standard',
supportedReasoningEfforts: allReasoningEfforts,
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1-Codex-Mini',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-5-mini',
name: 'GPT-5 mini',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'gpt-4.1',
name: 'GPT-4.1',
provider: 'openai',
tier: 'fast',
supportedReasoningEfforts: [],
},
{
id: 'claude-opus-4.6',
name: 'Claude Opus 4.6',
provider: 'anthropic',
tier: 'premium',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'high',
},
{
id: 'claude-opus-4.6-1m',
name: 'Claude Opus 4.6 (1M context)(Internal only)',
provider: 'anthropic',
tier: 'premium',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'high',
},
{
id: 'claude-opus-4.5',
name: 'Claude Opus 4.5',
provider: 'anthropic',
tier: 'premium',
supportedReasoningEfforts: [],
},
{
id: 'claude-sonnet-4.6',
name: 'Claude Sonnet 4.6',
provider: 'anthropic',
tier: 'standard',
supportedReasoningEfforts: ['low', 'medium', 'high'],
defaultReasoningEffort: 'medium',
},
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
provider: 'anthropic',
tier: 'standard',
supportedReasoningEfforts: [],
},
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
provider: 'anthropic',
tier: 'standard',
supportedReasoningEfforts: [],
},
{
id: 'claude-haiku-4.5',
name: 'Claude Haiku 4.5',
provider: 'anthropic',
tier: 'fast',
supportedReasoningEfforts: [],
},
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
provider: 'google',
tier: 'standard',
supportedReasoningEfforts: [],
},
];
export const providerMeta: { id: ModelProvider; label: string }[] = [
const fallbackModelMap = new Map(modelCatalog.map((model) => [model.id, model] as const));
export const providerMeta: ReadonlyArray<{ id: ModelProvider; label: string }> = [
{ id: 'openai', label: 'OpenAI' },
{ id: 'anthropic', label: 'Anthropic' },
{ id: 'google', label: 'Google' },
];
export function findModel(id: string): ModelDefinition | undefined {
return modelCatalog.find((m) => m.id === id);
export function findModel(
id: string,
models: ReadonlyArray<ModelDefinition> = modelCatalog,
): ModelDefinition | undefined {
return models.find((model) => model.id === id);
}
export function inferProvider(modelId: string): ModelProvider | undefined {
@@ -40,3 +184,89 @@ export function inferProvider(modelId: string): ModelProvider | undefined {
if (modelId.startsWith('gemini-')) return 'google';
return undefined;
}
export function buildAvailableModelCatalog(
availableModels?: ReadonlyArray<SidecarModelCapability>,
): ReadonlyArray<ModelDefinition> {
if (!availableModels || availableModels.length === 0) {
return modelCatalog;
}
const providerOrder = new Map(providerMeta.map((provider, index) => [provider.id, index] as const));
return [...availableModels]
.map((model) => {
const fallback = fallbackModelMap.get(model.id);
const provider = fallback?.provider ?? inferProvider(model.id);
return {
id: model.id,
name: model.name || fallback?.name || model.id,
provider,
tier: fallback?.tier,
supportedReasoningEfforts: model.supportedReasoningEfforts,
defaultReasoningEffort: model.defaultReasoningEffort,
} satisfies ModelDefinition;
})
.sort((left, right) => {
const leftProviderOrder =
left.provider !== undefined
? (providerOrder.get(left.provider) ?? Number.MAX_SAFE_INTEGER)
: Number.MAX_SAFE_INTEGER;
const rightProviderOrder =
right.provider !== undefined
? (providerOrder.get(right.provider) ?? Number.MAX_SAFE_INTEGER)
: Number.MAX_SAFE_INTEGER;
const providerCompare = leftProviderOrder - rightProviderOrder;
if (providerCompare !== 0) {
return providerCompare;
}
return left.name.localeCompare(right.name);
});
}
export function getSupportedReasoningEfforts(
model: ModelDefinition | undefined,
): ReadonlyArray<ReasoningEffort> | undefined {
return model?.supportedReasoningEfforts;
}
export function resolveReasoningEffort(
model: ModelDefinition | undefined,
requested: ReasoningEffort | undefined,
): ReasoningEffort | undefined {
const supported = model?.supportedReasoningEfforts;
if (!supported) {
return requested;
}
if (supported.length === 0) {
return undefined;
}
if (requested && supported.includes(requested)) {
return requested;
}
if (model?.defaultReasoningEffort && supported.includes(model.defaultReasoningEffort)) {
return model.defaultReasoningEffort;
}
return supported[0];
}
export function normalizePatternModels(
pattern: PatternDefinition,
models: ReadonlyArray<ModelDefinition>,
): PatternDefinition {
return {
...pattern,
agents: pattern.agents.map((agent) => {
const model = findModel(agent.model, models);
const reasoningEffort = resolveReasoningEffort(model, agent.reasoningEffort);
return reasoningEffort === agent.reasoningEffort ? agent : { ...agent, reasoningEffort };
}),
};
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, test } from 'bun:test';
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModel,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
const availableModels: SidecarModelCapability[] = [
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
supportedReasoningEfforts: [],
},
{
id: 'gpt-5.4',
name: 'GPT-5.4',
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
defaultReasoningEffort: 'medium',
},
];
function createPattern(): PatternDefinition {
return {
id: 'pattern-1',
name: 'Pattern',
description: '',
mode: 'single',
availability: 'available',
maxIterations: 1,
agents: [
{
id: 'agent-1',
name: 'Primary Agent',
description: 'Helpful assistant',
instructions: 'Help the user.',
model: 'claude-sonnet-4.5',
reasoningEffort: 'high',
},
],
createdAt: '2026-03-23T00:00:00.000Z',
updatedAt: '2026-03-23T00:00:00.000Z',
};
}
describe('dynamic model catalog', () => {
test('builds the available model list from sidecar capabilities', () => {
const catalog = buildAvailableModelCatalog(availableModels);
expect(catalog.map((model) => model.id)).toEqual(['gpt-5.4', 'claude-sonnet-4.5']);
expect(findModel('claude-sonnet-4.5', catalog)?.supportedReasoningEfforts).toEqual([]);
expect(findModel('gpt-5.4', catalog)?.defaultReasoningEffort).toBe('medium');
});
test('drops unsupported reasoning effort selections for a model', () => {
const catalog = buildAvailableModelCatalog(availableModels);
const model = findModel('claude-sonnet-4.5', catalog);
expect(resolveReasoningEffort(model, 'high')).toBeUndefined();
});
test('normalizes pattern agents before runtime execution', () => {
const normalized = normalizePatternModels(
createPattern(),
buildAvailableModelCatalog(availableModels),
);
expect(normalized.agents[0].reasoningEffort).toBeUndefined();
});
});