diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f58d174..05b1201 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -246,8 +246,9 @@ This is a good example of a cross-cutting concern that spans multiple layers wit Tooling is deliberately split into two levels: +- **dynamic runtime tools** reported by the Copilot CLI, with a fallback catalog for startup/offline cases - **global definitions** for MCP servers and LSP profiles -- **pattern defaults** for which known tools can bypass manual approval +- **pattern defaults** for which known runtime tools can bypass manual approval - **per-session overrides** for both tool enablement and tool auto-approval This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety. diff --git a/HANDOVER.md b/HANDOVER.md index 94cf9ae..077e70a 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -8,7 +8,7 @@ This builds on the existing approval-checkpoint system: - `final-response` approvals still work the same way - `tool-call` checkpoints still decide **whether a tool call needs approval at all** -- the new feature decides **which known tools can be auto-approved instead of surfacing a manual approval** +- the new feature decides **which known runtime tools can be auto-approved instead of surfacing a manual approval** The approval queue work from earlier is unchanged and still applies. @@ -80,21 +80,39 @@ Unknown or non-tool-specific permission requests still require manual approval w Use the shared helper in `src/shared/domain/tooling.ts`: ```ts -listApprovalToolDefinitions(workspace.settings.tooling) +listApprovalToolDefinitions(workspace.settings.tooling, sidecarCapabilities?.runtimeTools) ``` This is the canonical source for UI rendering and validation. +Important: + +- runtime tools should come from `describeSidecarCapabilities().runtimeTools` when available +- the shared helper falls back to a conservative built-in catalog until capabilities load or when the CLI cannot report tools +- the helper also merges configured MCP server tools and derived LSP tools + Each returned item includes: - `id` → the runtime tool identifier used by approvals - `label` → human-readable label for the UI -- `kind` → `mcp`, `lsp`, or `mixed` +- `kind` → `builtin`, `mcp`, `lsp`, or `mixed` - `providerIds` - `providerNames` Do **not** re-derive tool IDs in the renderer. +### Runtime tool IDs + +For Copilot CLI runtime tools, the ID is the tool name returned by the sidecar capability payload. + +Examples: + +- `web_fetch` +- `view` +- `glob` + +Descriptions come from the sidecar when available and can be surfaced in the UI. + ### MCP tool IDs For MCP tools, the runtime ID is the raw tool name from `server.tools`. @@ -157,7 +175,7 @@ The backend now enforces and maintains tool references consistently: - saving a pattern rejects unknown `autoApprovedToolNames` - updating session approval settings rejects unknown tool IDs - scratchpad sessions reject non-empty session tool auto-approval overrides -- workspace load prunes stale tool IDs from patterns and sessions +- workspace load normalizes approval state and the app service prunes stale tool IDs after runtime tools/capabilities are available - saving or deleting MCP/LSP definitions also prunes stale tool IDs from patterns and sessions This is intentional: stored approval defaults should only reference tools that still exist. @@ -180,13 +198,17 @@ Existing UI can keep working without changes, but the UX agent can now surface b - `src/shared/domain/approval.ts` - pattern defaults, session override model, effective-policy helpers, pruning/validation helpers - `src/shared/domain/tooling.ts` - - canonical `listApprovalToolDefinitions(...)` helper + - canonical merged runtime/MCP/LSP `listApprovalToolDefinitions(...)` helper +- `src/shared/contracts/sidecar.ts` + - runtime tool capability payload - `src/shared/domain/session.ts` - session approval settings + effective pattern merge helper - `src/main/EryxAppService.ts` - pattern validation, session override IPC handler, tooling cleanup, effective-pattern merge - `src/main/persistence/workspaceRepository.ts` - - load-time normalization + pruning for stale tool IDs + - load-time normalization without prematurely pruning dynamic runtime tool IDs +- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs` + - dynamic Copilot CLI runtime tool discovery via `tools.list` - `src/shared/contracts/ipc.ts` - `src/shared/contracts/channels.ts` - `src\preload\index.ts` @@ -198,55 +220,27 @@ Existing UI can keep working without changes, but the UX agent can now surface b ## UX work for the frontend agent -### 1. Pattern editor: default tool auto-approval +All UX tasks from this section have been implemented in commit `4ff5cbb`. -In `PatternEditor`, add a section that renders `listApprovalToolDefinitions(workspace.settings.tooling)`. +### 1. Pattern editor: default tool auto-approval — ✅ Done -Recommended behavior: +`PatternEditor` renders `listApprovalToolDefinitions(workspace.settings.tooling, sidecarCapabilities?.runtimeTools)` with toggles writing to `pattern.approvalPolicy.autoApprovedToolNames`. Runtime tools come from the sidecar when available and fall back to the shared built-in catalog until capabilities load. -- show every available approval tool with a toggle -- write the selected tool IDs to `pattern.approvalPolicy.autoApprovedToolNames` -- keep this separate from the existing `tool-call` / `final-response` checkpoint toggles -- if there are no tools, show an empty state instead of an interactive list +### 2. Activity panel: per-session override — ✅ Done -### 2. Activity panel: per-session override +Right-side Activity panel shows "Inheriting pattern defaults" / "Custom for this session" badge with "Reset to pattern" action. Toggle rows for each tool call `updateSessionApprovalSettings(...)`. -In the right-side Activity panel, add a per-session auto-approval section for tools. +### 3. Disable while running — ✅ Done -Recommended behavior: +Per-session override controls disabled when `session.status === 'running'`. Scratchpad shows non-interactive explanation. -- base the list on `listApprovalToolDefinitions(workspace.settings.tooling)` -- show the current **effective** state -- distinguish inherited vs overridden state in the UI -- allow resetting the session to inherit the pattern defaults -- call `updateSessionApprovalSettings(...)` when the user changes the override +### 4. Surface tool context in approval UI — ✅ Done -Suggested UX: +`approval.toolName` displayed in both the active approval banner and queued approval list when present. -- `Inheriting pattern defaults` badge/state when `session.approvalSettings` is `undefined` -- `Custom for this session` badge/state when the override object exists -- `Reset to pattern` action that calls `updateSessionApprovalSettings({ sessionId })` +### 5. Avoid ID duplication logic in the renderer — ✅ Done -### 3. Disable while running - -Match the current tools behavior: - -- disable the per-session override controls while `session.status === 'running'` -- scratchpad sessions should show a non-interactive explanation because tool auto-approval does not apply there - -### 4. Surface tool context in approval UI - -Optional but recommended: - -- show `approval.toolName` in the active approval banner / queued approval list when present -- this is now available for tool-specific approval requests - -### 5. Avoid ID duplication logic in the renderer - -Important: - -- do not manually rebuild LSP tool IDs in UI code -- use the shared helper output and persist the `id` values from it +All tool ID resolution uses `listApprovalToolDefinitions()` output exclusively. ## Validation commands diff --git a/README.md b/README.md index 6b7cd4c..1936807 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Eryx includes connection status in the app so you can quickly tell whether Copil Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow. 5. **Configure optional tooling** - If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel. You can also set pattern-level tool auto-approval defaults and override them per session. + If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel. Eryx also surfaces Copilot CLI runtime tools for tool auto-approval, and you can set pattern-level defaults and override them per session. 6. **Start working** Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later. diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index e76ab00..40f33fe 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -69,6 +69,13 @@ public sealed class SidecarModelCapabilityDto public string? DefaultReasoningEffort { get; init; } } +public sealed class SidecarRuntimeToolDto +{ + public string Id { get; init; } = string.Empty; + public string Label { get; init; } = string.Empty; + public string? Description { get; init; } +} + public sealed class SidecarConnectionDiagnosticsDto { public string Status { get; init; } = "copilot-error"; @@ -103,6 +110,7 @@ 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 IReadOnlyList RuntimeTools { get; init; } = []; public SidecarConnectionDiagnosticsDto Connection { get; init; } = new(); } diff --git a/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs index 985eb83..991e06e 100644 --- a/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; using GitHub.Copilot.SDK; +using GitHub.Copilot.SDK.Rpc; using Eryx.AgentHost.Contracts; namespace Eryx.AgentHost.Services; @@ -187,6 +188,7 @@ public sealed class SidecarProtocolHost private static async Task BuildCapabilitiesAsync(CancellationToken cancellationToken) { IReadOnlyList models = []; + IReadOnlyList runtimeTools = []; CopilotCliContext cliContext; SidecarConnectionDiagnosticsDto connection; SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; @@ -227,6 +229,14 @@ public sealed class SidecarProtocolHost cancellationToken).ConfigureAwait(false); models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false); + try + { + runtimeTools = await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[eryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}"); + } cliVersion = await cliVersionTask.ConfigureAwait(false); connection = CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account); } @@ -241,6 +251,7 @@ public sealed class SidecarProtocolHost { Modes = BuildModeCapabilities(), Models = models, + RuntimeTools = runtimeTools, Connection = connection, }; } @@ -284,6 +295,24 @@ public sealed class SidecarProtocolHost .ToList(); } + private static async Task> ListAvailableRuntimeToolsAsync( + CopilotClient client, + CancellationToken cancellationToken) + { + ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false); + return result.Tools + .Where(tool => !string.IsNullOrWhiteSpace(tool.Name)) + .Select(tool => new SidecarRuntimeToolDto + { + Id = tool.Name.Trim(), + Label = tool.Name.Trim(), + Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(), + }) + .DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase) + .OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private static bool IsReasoningEffort(string? value) { return value is "low" or "medium" or "high" or "xhigh"; diff --git a/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs index eae745d..c3adffa 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -37,6 +37,10 @@ public sealed class SidecarProtocolHostTests JsonElement model = Assert.Single(models); Assert.Equal("gpt-5.4", model.GetProperty("id").GetString()); Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString()); + JsonElement[] runtimeTools = capabilities.GetProperty("runtimeTools").EnumerateArray().ToArray(); + JsonElement runtimeTool = Assert.Single(runtimeTools); + Assert.Equal("web_fetch", runtimeTool.GetProperty("id").GetString()); + Assert.Equal("web_fetch", runtimeTool.GetProperty("label").GetString()); JsonElement connection = capabilities.GetProperty("connection"); Assert.Equal("ready", connection.GetProperty("status").GetString()); Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString()); @@ -403,6 +407,15 @@ public sealed class SidecarProtocolHostTests DefaultReasoningEffort = "medium", }, ], + RuntimeTools = + [ + new SidecarRuntimeToolDto + { + Id = "web_fetch", + Label = "web_fetch", + Description = "Fetch content from the web.", + }, + ], Connection = new SidecarConnectionDiagnosticsDto { Status = "ready", diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 174a478..8bf3ef1 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -103,6 +103,16 @@ function isBuiltinPattern(patternId: string): boolean { return patternId.startsWith('pattern-'); } +function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean { + const normalizedLeft = left ?? []; + const normalizedRight = right ?? []; + if (normalizedLeft.length !== normalizedRight.length) { + return false; + } + + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + export class EryxAppService extends EventEmitter { private readonly workspaceRepository = new WorkspaceRepository(); private readonly sidecar = new SidecarClient(); @@ -124,7 +134,8 @@ export class EryxAppService extends EventEmitter { async loadWorkspace(): Promise { if (!this.workspace) { this.workspace = await this.workspaceRepository.load(); - if (this.failInterruptedPendingApprovals(this.workspace)) { + const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace); + if (didPruneApprovalTools || this.failInterruptedPendingApprovals(this.workspace)) { await this.workspaceRepository.save(this.workspace); } } @@ -200,9 +211,10 @@ export class EryxAppService extends EventEmitter { async savePattern(pattern: PatternDefinition): Promise { const workspace = await this.loadWorkspace(); + const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace); const issues = validatePatternDefinition( pattern, - this.listKnownApprovalToolNames(workspace), + knownApprovalToolNames, ).filter((issue) => issue.level === 'error'); if (issues.length > 0) { throw new Error(issues[0].message); @@ -281,7 +293,7 @@ export class EryxAppService extends EventEmitter { workspace.settings.tooling.mcpServers.push(candidate); } - this.pruneUnavailableApprovalTools(workspace); + await this.pruneUnavailableApprovalTools(workspace); return this.persistAndBroadcast(workspace); } @@ -299,7 +311,7 @@ export class EryxAppService extends EventEmitter { }; } - this.pruneUnavailableApprovalTools(workspace); + await this.pruneUnavailableApprovalTools(workspace); return this.persistAndBroadcast(workspace); } @@ -328,7 +340,7 @@ export class EryxAppService extends EventEmitter { workspace.settings.tooling.lspProfiles.push(candidate); } - this.pruneUnavailableApprovalTools(workspace); + await this.pruneUnavailableApprovalTools(workspace); return this.persistAndBroadcast(workspace); } @@ -346,7 +358,7 @@ export class EryxAppService extends EventEmitter { }; } - this.pruneUnavailableApprovalTools(workspace); + await this.pruneUnavailableApprovalTools(workspace); return this.persistAndBroadcast(workspace); } @@ -703,7 +715,7 @@ export class EryxAppService extends EventEmitter { throw new Error('Scratchpad sessions do not support tool auto-approval settings.'); } - const knownToolNames = new Set(this.listKnownApprovalToolNames(workspace)); + const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace)); const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName)); if (unknownToolName) { throw new Error(`Unknown approval tool "${unknownToolName}".`); @@ -1172,23 +1184,42 @@ export class EryxAppService extends EventEmitter { return normalizePatternModels(patternWithApprovalSettings, modelCatalog); } - private listKnownApprovalToolNames(workspace: WorkspaceState): string[] { - return listApprovalToolNames(workspace.settings.tooling); + private async listKnownApprovalToolNames(workspace: WorkspaceState): Promise { + const capabilities = await this.loadSidecarCapabilities(); + const runtimeTools = capabilities.runtimeTools.length > 0 ? capabilities.runtimeTools : undefined; + return listApprovalToolNames(workspace.settings.tooling, runtimeTools); } - private pruneUnavailableApprovalTools(workspace: WorkspaceState): void { - const knownToolNames = this.listKnownApprovalToolNames(workspace); + private async pruneUnavailableApprovalTools(workspace: WorkspaceState): Promise { + const knownToolNames = await this.listKnownApprovalToolNames(workspace); + let changed = false; for (const pattern of workspace.patterns) { - pattern.approvalPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames); + const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, knownToolNames); + if (!equalStringArrays( + pattern.approvalPolicy?.autoApprovedToolNames, + nextPolicy?.autoApprovedToolNames, + )) { + pattern.approvalPolicy = nextPolicy; + changed = true; + } } for (const session of workspace.sessions) { - session.approvalSettings = pruneSessionApprovalSettings( + const nextSettings = pruneSessionApprovalSettings( session.approvalSettings, knownToolNames, ); + if (!equalStringArrays( + session.approvalSettings?.autoApprovedToolNames, + nextSettings?.autoApprovedToolNames, + )) { + session.approvalSettings = nextSettings; + changed = true; + } } + + return changed; } private buildRunTurnToolingConfig( diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 63834a4..1034a5d 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -5,7 +5,6 @@ import type { PatternDefinition } from '@shared/domain/pattern'; import { mergeScratchpadProject } from '@shared/domain/project'; import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; import { - listApprovalToolNames, normalizeSessionToolingSelection, normalizeWorkspaceSettings, } from '@shared/domain/tooling'; @@ -13,8 +12,6 @@ import { normalizeApprovalPolicy, normalizePendingApprovalState, normalizeSessionApprovalSettings, - pruneApprovalPolicyTools, - pruneSessionApprovalSettings, } from '@shared/domain/approval'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { nowIso } from '@shared/utils/ids'; @@ -68,26 +65,19 @@ export class WorkspaceRepository { const projects = mergeScratchpadProject(stored.projects ?? [], this.scratchpadPath); const settings = normalizeWorkspaceSettings(stored.settings); - const knownToolNames = listApprovalToolNames(settings.tooling); const workspace: WorkspaceState = { ...stored, patterns: mergePatterns(stored.patterns ?? []).map((pattern) => ({ ...pattern, - approvalPolicy: pruneApprovalPolicyTools( - normalizeApprovalPolicy(pattern.approvalPolicy), - knownToolNames, - ), + approvalPolicy: normalizeApprovalPolicy(pattern.approvalPolicy), })), projects, sessions: (stored.sessions ?? []).map((session) => ({ ...session, runs: normalizeSessionRunRecords(session.runs), tooling: normalizeSessionToolingSelection(session.tooling), - approvalSettings: pruneSessionApprovalSettings( - normalizeSessionApprovalSettings(session.approvalSettings), - knownToolNames, - ), + approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings), ...normalizePendingApprovalState({ pendingApproval: session.pendingApproval, pendingApprovalQueue: session.pendingApprovalQueue, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1c46e50..55647c0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -275,6 +275,7 @@ export default function App() { lspProfiles={workspace.settings.tooling.lspProfiles} mcpServers={workspace.settings.tooling.mcpServers} toolingSettings={workspace.settings.tooling} + runtimeTools={sidecarCapabilities?.runtimeTools} onJumpToMessage={jumpToMessage} onUpdateSessionTooling={(selection) => { void api.updateSessionTooling({ diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index e7d988d..6451cc3 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -19,6 +19,7 @@ import { import type { LspProfileDefinition, McpServerDefinition, + RuntimeToolDefinition, SessionToolingSelection, WorkspaceToolingSettings, } from '@shared/domain/tooling'; @@ -163,6 +164,7 @@ interface ActivityPanelProps { lspProfiles: LspProfileDefinition[]; mcpServers: McpServerDefinition[]; toolingSettings: WorkspaceToolingSettings; + runtimeTools?: ReadonlyArray; onJumpToMessage?: (messageId: string) => void; onUpdateSessionTooling: (selection: SessionToolingSelection) => void; onUpdateSessionApprovalSettings: (settings: { autoApprovedToolNames?: string[] }) => void; @@ -176,6 +178,7 @@ export function ActivityPanel({ lspProfiles, mcpServers, toolingSettings, + runtimeTools, onJumpToMessage, onUpdateSessionTooling, onUpdateSessionApprovalSettings, @@ -188,7 +191,10 @@ export function ActivityPanel({ [activity, pattern.agents], ); const selection = useMemo(() => resolveSessionToolingSelection(session), [session]); - const approvalTools = useMemo(() => listApprovalToolDefinitions(toolingSettings), [toolingSettings]); + const approvalTools = useMemo( + () => listApprovalToolDefinitions(toolingSettings, runtimeTools), + [runtimeTools, toolingSettings], + ); const isOverridden = session.approvalSettings !== undefined; const effectiveAutoApproved = new Set( @@ -353,7 +359,7 @@ export function ActivityPanel({

) : approvalTools.length === 0 ? (

- Add MCP servers or LSP profiles in Settings to configure tool auto-approvals. + No approval-capable runtime tools are currently available.

) : ( <> @@ -478,7 +484,13 @@ function ApprovalOverrideRow({ disabled: boolean; onToggle: () => void; }) { - const kindBadge = tool.kind === 'lsp' ? 'LSP' : tool.kind === 'mcp' ? 'MCP' : 'Mixed'; + const kindBadge = tool.kind === 'builtin' + ? 'Built-in' + : tool.kind === 'lsp' + ? 'LSP' + : tool.kind === 'mcp' + ? 'MCP' + : 'Mixed'; return ( diff --git a/src/renderer/components/PatternEditor.tsx b/src/renderer/components/PatternEditor.tsx index 6598fe3..0c0447d 100644 --- a/src/renderer/components/PatternEditor.tsx +++ b/src/renderer/components/PatternEditor.tsx @@ -32,6 +32,7 @@ import { import { listApprovalToolDefinitions, type ApprovalToolDefinition, + type RuntimeToolDefinition, type WorkspaceToolingSettings, } from '@shared/domain/tooling'; @@ -42,6 +43,7 @@ interface PatternEditorProps { pattern: PatternDefinition; isBuiltin: boolean; toolingSettings: WorkspaceToolingSettings; + runtimeTools?: ReadonlyArray; onChange: (pattern: PatternDefinition) => void; onDelete?: () => void; onSave: () => void; @@ -209,6 +211,7 @@ export function PatternEditor({ pattern, isBuiltin, toolingSettings, + runtimeTools, onChange, onDelete, onSave, @@ -262,7 +265,7 @@ export function PatternEditor({ }); } - const approvalTools = listApprovalToolDefinitions(toolingSettings); + const approvalTools = listApprovalToolDefinitions(toolingSettings, runtimeTools); const autoApprovedSet = new Set(pattern.approvalPolicy?.autoApprovedToolNames ?? []); function toggleToolAutoApproval(toolId: string) { @@ -568,7 +571,7 @@ export function PatternEditor({
{approvalTools.length === 0 ? (

- No tools available. Add MCP servers or LSP profiles in Settings to configure auto-approvals. + No approval-capable runtime tools are currently available.

) : (
@@ -719,7 +722,13 @@ function ToolApprovalToggleRow({ enabled: boolean; onToggle: () => void; }) { - const kindBadge = tool.kind === 'lsp' ? 'LSP' : tool.kind === 'mcp' ? 'MCP' : 'Mixed'; + const kindBadge = tool.kind === 'builtin' + ? 'Built-in' + : tool.kind === 'lsp' + ? 'LSP' + : tool.kind === 'mcp' + ? 'MCP' + : 'Mixed'; return (
- {tool.providerNames.length > 0 && ( -
{tool.providerNames.join(', ')}
+ {(tool.description || tool.providerNames.length > 0) && ( +
+ {tool.description ?? tool.providerNames.join(', ')} +
)}
); -} \ No newline at end of file +} diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 83effda..dfaf8b6 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -132,6 +132,7 @@ export function SettingsPanel({ setEditingPattern(null); }} pattern={editingPattern} + runtimeTools={sidecarCapabilities?.runtimeTools} toolingSettings={toolingSettings} /> diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index bfc7a4a..b2552ae 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -1,6 +1,7 @@ import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern'; import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval'; import type { ChatMessageRecord } from '@shared/domain/session'; +import type { RuntimeToolDefinition } from '@shared/domain/tooling'; export interface SidecarModeCapability { available: boolean; @@ -52,6 +53,7 @@ export interface SidecarCapabilities { runtime: 'dotnet-maf'; modes: Record; models: SidecarModelCapability[]; + runtimeTools: RuntimeToolDefinition[]; connection: SidecarConnectionDiagnostics; } diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index a9fb9ec..f34a0e8 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -52,7 +52,13 @@ export interface SessionToolingSelection { enabledLspProfileIds: string[]; } -export type ApprovalToolKind = 'mcp' | 'lsp' | 'mixed'; +export type ApprovalToolKind = 'builtin' | 'mcp' | 'lsp' | 'mixed'; + +export interface RuntimeToolDefinition { + id: string; + label: string; + description?: string; +} export interface ApprovalToolDefinition { id: string; @@ -60,6 +66,7 @@ export interface ApprovalToolDefinition { kind: ApprovalToolKind; providerIds: string[]; providerNames: string[]; + description?: string; } const lspApprovalOperations = [ @@ -70,6 +77,17 @@ const lspApprovalOperations = [ { suffix: 'references', label: 'References' }, ] as const; +// Fallback runtime tools used before sidecar capabilities are loaded or when the +// CLI cannot report its built-in tool catalog dynamically. +const fallbackRuntimeApprovalTools: ReadonlyArray = [ + { id: 'glob', label: 'glob', description: 'Match files by glob pattern.' }, + { id: 'lsp', label: 'lsp', description: 'Query configured language servers.' }, + { id: 'rg', label: 'rg', description: 'Search file contents with ripgrep.' }, + { id: 'view', label: 'view', description: 'Read files and list directories.' }, + { id: 'web_fetch', label: 'web_fetch', description: 'Fetch content from a URL.' }, + { id: 'web_search', label: 'web_search', description: 'Search the web for current information.' }, +]; + export function createWorkspaceSettings(): WorkspaceSettings { return { theme: 'dark', @@ -114,8 +132,21 @@ export function normalizeSessionToolingSelection( export function listApprovalToolDefinitions( tooling: WorkspaceToolingSettings, + runtimeTools: ReadonlyArray = fallbackRuntimeApprovalTools, ): ApprovalToolDefinition[] { const toolsById = new Map(); + const runtimeApprovalTools = runtimeTools.length > 0 ? runtimeTools : fallbackRuntimeApprovalTools; + + for (const tool of runtimeApprovalTools) { + registerApprovalTool(toolsById, { + id: tool.id, + label: tool.label, + description: tool.description, + kind: 'builtin', + providerId: `builtin:${tool.id}`, + providerName: 'Built-in', + }); + } for (const server of tooling.mcpServers) { for (const toolName of normalizeStringArray(server.tools)) { @@ -146,8 +177,11 @@ export function listApprovalToolDefinitions( left.label.localeCompare(right.label) || left.id.localeCompare(right.id)); } -export function listApprovalToolNames(tooling: WorkspaceToolingSettings): string[] { - return listApprovalToolDefinitions(tooling).map((tool) => tool.id); +export function listApprovalToolNames( + tooling: WorkspaceToolingSettings, + runtimeTools?: ReadonlyArray, +): string[] { + return listApprovalToolDefinitions(tooling, runtimeTools).map((tool) => tool.id); } export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined { @@ -276,6 +310,7 @@ function registerApprovalTool( tool: { id: string; label: string; + description?: string; kind: Exclude; providerId: string; providerName: string; @@ -286,6 +321,7 @@ function registerApprovalTool( toolsById.set(tool.id, { id: tool.id, label: tool.label, + description: tool.description, kind: tool.kind, providerIds: [tool.providerId], providerNames: [tool.providerName], @@ -299,6 +335,9 @@ function registerApprovalTool( if (!existing.providerNames.includes(tool.providerName)) { existing.providerNames.push(tool.providerName); } + if (!existing.description && tool.description) { + existing.description = tool.description; + } if (existing.kind !== tool.kind) { existing.kind = 'mixed'; } diff --git a/tests/shared/pattern.test.ts b/tests/shared/pattern.test.ts index a9e29cc..0d273ee 100644 --- a/tests/shared/pattern.test.ts +++ b/tests/shared/pattern.test.ts @@ -137,9 +137,9 @@ describe('pattern validation', () => { ...singlePattern!, approvalPolicy: { rules: [{ kind: 'tool-call' }], - autoApprovedToolNames: ['git.status', 'unknown.tool'], + autoApprovedToolNames: ['web_fetch', 'unknown.tool'], }, - }, ['git.status']); + }, ['web_fetch']); expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe( 'Approval auto-approve references unknown tool "unknown.tool".', diff --git a/tests/shared/tooling.test.ts b/tests/shared/tooling.test.ts index 0538943..e0d590b 100644 --- a/tests/shared/tooling.test.ts +++ b/tests/shared/tooling.test.ts @@ -157,7 +157,7 @@ describe('tooling settings helpers', () => { ).toBe('LSP profile "Typescript LSP" needs the "--stdio" argument.'); }); - test('lists approval tools from MCP and LSP definitions using runtime tool identifiers', () => { + test('lists builtin, MCP, and LSP approval tools using runtime tool identifiers', () => { const tools = listApprovalToolDefinitions({ mcpServers: [ { @@ -195,6 +195,14 @@ describe('tooling settings helpers', () => { ], }); + expect(tools).toContainEqual({ + id: 'web_fetch', + label: 'web_fetch', + description: 'Fetch content from a URL.', + kind: 'builtin', + providerIds: ['builtin:web_fetch'], + providerNames: ['Built-in'], + }); expect(tools).toContainEqual({ id: 'git.status', label: 'git.status', @@ -210,4 +218,27 @@ describe('tooling settings helpers', () => { providerNames: ['TypeScript'], }); }); + + test('prefers dynamically reported runtime tools over the fallback builtin catalog', () => { + const tools = listApprovalToolDefinitions( + { mcpServers: [], lspProfiles: [] }, + [ + { + id: 'fetch', + label: 'fetch', + description: 'Dynamic runtime tool from Copilot CLI.', + }, + ], + ); + + expect(tools).toContainEqual({ + id: 'fetch', + label: 'fetch', + description: 'Dynamic runtime tool from Copilot CLI.', + kind: 'builtin', + providerIds: ['builtin:fetch'], + providerNames: ['Built-in'], + }); + expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(false); + }); });