From 4ecfa5bff662b34ed5de7ef91350940cae512b8e Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 25 Mar 2026 21:14:19 +0100 Subject: [PATCH] feat: enable scratchpad tooling backend Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 9 +- .../Services/CopilotAgentBundle.cs | 36 ++--- .../CopilotAgentBundleTests.cs | 73 +++++++++++ src/main/EryxAppService.ts | 124 ++---------------- tests/main/sessionToolingConfig.test.ts | 100 ++++++++++++++ 5 files changed, 209 insertions(+), 133 deletions(-) create mode 100644 sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs create mode 100644 tests/main/sessionToolingConfig.test.ts diff --git a/AGENTS.md b/AGENTS.md index d74cbdc..3c75001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,14 @@ Every interactive component must include basic accessibility: - Always commit completed repository changes before handing work off. If unrelated pre-existing changes are present in the worktree, stop and ask the user how to proceed before creating the commit. - Do not mark work as done until both the implementation and its verification are complete. -## 8. Validation checklist +## 8. Planning requirements + +- If a task spans both backend and frontend work, the implementation plan must be split into **Part 1 — Backend** and **Part 2 — Frontend**. +- Backend work must be planned and executed first. +- Before frontend work begins, backend work must produce a handover artifact in the session workspace `files\` directory. Do not put this handover document in the repository. +- The frontend phase must consume that backend handover artifact and build on it rather than rediscovering backend contracts from scratch. + +## 9. Validation checklist Before every commit, run the following in order: diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs index d594760..aeddaaf 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotAgentBundle.cs @@ -28,8 +28,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable List disposables = []; List agents = []; CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(); - bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase); - SessionToolingBundle? toolingBundle = isScratchpad + SessionToolingBundle? toolingBundle = command.Tooling is null ? null : await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken) .ConfigureAwait(false); @@ -58,22 +57,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable Streaming = true, }; - if (isScratchpad) - { - sessionConfig.AvailableTools = []; - } - else if (toolingBundle is not null) - { - if (toolingBundle.McpServers.Count > 0) - { - sessionConfig.McpServers = toolingBundle.McpServers; - } - - if (toolingBundle.Tools.Count > 0) - { - sessionConfig.Tools = toolingBundle.Tools.ToList(); - } - } + ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools); GitHubCopilotAgent agent = new( client, @@ -92,6 +76,22 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable return bundle; } + internal static void ApplySessionTooling( + SessionConfig sessionConfig, + Dictionary? mcpServers, + IReadOnlyList? tools) + { + if (mcpServers is { Count: > 0 }) + { + sessionConfig.McpServers = mcpServers; + } + + if (tools is { Count: > 0 }) + { + sessionConfig.Tools = tools.ToList(); + } + } + public Workflow BuildWorkflow(PatternDefinitionDto pattern) { return pattern.Mode switch diff --git a/sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs new file mode 100644 index 0000000..0d0b456 --- /dev/null +++ b/sidecar/tests/Eryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -0,0 +1,73 @@ +using System.Reflection; +using Eryx.AgentHost.Services; +using GitHub.Copilot.SDK; +using Microsoft.Extensions.AI; + +namespace Eryx.AgentHost.Tests; + +public sealed class CopilotAgentBundleTests +{ + [Fact] + public void ApplySessionTooling_MapsMcpServersAndToolsOntoTheSessionConfig() + { + SessionConfig sessionConfig = new() + { + AvailableTools = ["glob"], + }; + Dictionary mcpServers = new(StringComparer.OrdinalIgnoreCase) + { + ["Git MCP"] = new McpLocalServerConfig + { + Type = "local", + Command = "node", + Args = ["server.js"], + Tools = ["git.status"], + }, + }; + AIFunction tool = CreateTool(); + + CopilotAgentBundle.ApplySessionTooling(sessionConfig, mcpServers, [tool]); + + Assert.Same(mcpServers, sessionConfig.McpServers); + Assert.NotNull(sessionConfig.Tools); + AIFunction configuredTool = Assert.Single(sessionConfig.Tools); + Assert.Same(tool, configuredTool); + Assert.Equal(["glob"], sessionConfig.AvailableTools); + } + + [Fact] + public void ApplySessionTooling_LeavesSessionConfigUnsetWhenNoToolingIsProvided() + { + SessionConfig sessionConfig = new() + { + AvailableTools = ["glob", "view"], + }; + + CopilotAgentBundle.ApplySessionTooling(sessionConfig, null, []); + + Assert.Null(sessionConfig.McpServers); + Assert.Null(sessionConfig.Tools); + Assert.Equal(["glob", "view"], sessionConfig.AvailableTools); + } + + private static AIFunction CreateTool() + { + ToolTarget target = new(); + MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo)) + ?? throw new InvalidOperationException("Expected test method to exist."); + + return AIFunctionFactory.Create( + method, + target, + new AIFunctionFactoryOptions + { + Name = "echo", + Description = "Echo test tool", + }); + } + + private sealed class ToolTarget + { + public string Echo() => "ok"; + } +} diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 3ff7413..eff3a07 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -6,8 +6,6 @@ import { dialog } from 'electron'; import type { AgentActivityEvent, ApprovalRequestedEvent, - RunTurnLspProfileConfig, - RunTurnMcpServerConfig, RunTurnToolingConfig, SidecarCapabilities, TurnDeltaEvent, @@ -89,6 +87,10 @@ import { WorkspaceRepository } from '@main/persistence/workspaceRepository'; import { SecretStore } from '@main/secrets/secretStore'; import { SidecarClient } from '@main/sidecar/sidecarProcess'; import { GitService } from '@main/git/gitService'; +import { + buildRunTurnToolingConfig as buildSessionToolingConfig, + validateSessionToolingSelectionIds, +} from '@main/sessionToolingConfig'; type AppServiceEvents = { 'workspace-updated': [WorkspaceState]; @@ -493,7 +495,7 @@ export class EryxAppService extends EventEmitter { workspaceKind, pattern: effectivePattern, messages: session.messages, - tooling: this.buildRunTurnToolingConfig(workspace, project, session), + tooling: this.buildRunTurnToolingConfig(workspace, session), }, async (event) => { await this.applyTurnDelta(workspace, session.id, requestId, event); @@ -650,7 +652,7 @@ export class EryxAppService extends EventEmitter { ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); - const project = this.requireProject(workspace, session.projectId); + this.requireProject(workspace, session.projectId); if (session.status === 'running') { throw new Error('Wait for the current response to finish before changing session tools.'); @@ -660,34 +662,7 @@ export class EryxAppService extends EventEmitter { enabledMcpServerIds, enabledLspProfileIds, }); - - if ( - isScratchpadProject(project) - && (selection.enabledMcpServerIds.length > 0 || selection.enabledLspProfileIds.length > 0) - ) { - throw new Error('Scratchpad sessions do not support MCP or LSP tools.'); - } - - const knownMcpServerIds = new Set( - workspace.settings.tooling.mcpServers.map((server) => server.id), - ); - const knownLspProfileIds = new Set( - workspace.settings.tooling.lspProfiles.map((profile) => profile.id), - ); - - const unknownMcpServerIds = selection.enabledMcpServerIds.filter( - (id) => !knownMcpServerIds.has(id), - ); - if (unknownMcpServerIds.length > 0) { - throw new Error(`Unknown MCP server "${unknownMcpServerIds[0]}".`); - } - - const unknownLspProfileIds = selection.enabledLspProfileIds.filter( - (id) => !knownLspProfileIds.has(id), - ); - if (unknownLspProfileIds.length > 0) { - throw new Error(`Unknown LSP profile "${unknownLspProfileIds[0]}".`); - } + validateSessionToolingSelectionIds(workspace.settings.tooling, selection); session.tooling = selection; session.updatedAt = nowIso(); @@ -700,7 +675,7 @@ export class EryxAppService extends EventEmitter { ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); - const project = this.requireProject(workspace, session.projectId); + this.requireProject(workspace, session.projectId); if (session.status === 'running') { throw new Error('Wait for the current response to finish before changing session approval settings.'); @@ -710,14 +685,6 @@ export class EryxAppService extends EventEmitter { autoApprovedToolNames === undefined ? undefined : { autoApprovedToolNames }, ); - if ( - isScratchpadProject(project) - && settings - && settings.autoApprovedToolNames.length > 0 - ) { - throw new Error('Scratchpad sessions do not support tool auto-approval settings.'); - } - const knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace)); const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName)); if (unknownToolName) { @@ -1227,82 +1194,11 @@ export class EryxAppService extends EventEmitter { private buildRunTurnToolingConfig( workspace: WorkspaceState, - project: ProjectRecord, session: SessionRecord, ): RunTurnToolingConfig | undefined { - if (isScratchpadProject(project)) { - return undefined; - } - const selection = resolveSessionToolingSelection(session); - const mcpServersById = new Map( - workspace.settings.tooling.mcpServers.map((server) => [server.id, server]), - ); - const lspProfilesById = new Map( - workspace.settings.tooling.lspProfiles.map((profile) => [profile.id, profile]), - ); - - const mcpServers = selection.enabledMcpServerIds.flatMap((id): RunTurnMcpServerConfig[] => { - const server = mcpServersById.get(id); - if (!server) { - return []; - } - - if (server.transport === 'local') { - return [ - { - id: server.id, - name: server.name, - transport: 'local', - tools: [...server.tools], - timeoutMs: server.timeoutMs, - command: server.command, - args: [...server.args], - cwd: server.cwd, - }, - ]; - } - - return [ - { - id: server.id, - name: server.name, - transport: server.transport, - tools: [...server.tools], - timeoutMs: server.timeoutMs, - url: server.url, - }, - ]; - }); - - const lspProfiles = selection.enabledLspProfileIds.flatMap( - (id): RunTurnLspProfileConfig[] => { - const profile = lspProfilesById.get(id); - if (!profile) { - return []; - } - - return [ - { - id: profile.id, - name: profile.name, - command: profile.command, - args: [...profile.args], - languageId: profile.languageId, - fileExtensions: [...profile.fileExtensions], - }, - ]; - }, - ); - - if (mcpServers.length === 0 && lspProfiles.length === 0) { - return undefined; - } - - return { - mcpServers, - lspProfiles, - }; + validateSessionToolingSelectionIds(workspace.settings.tooling, selection); + return buildSessionToolingConfig(workspace.settings.tooling, selection); } private updateSessionRun( diff --git a/tests/main/sessionToolingConfig.test.ts b/tests/main/sessionToolingConfig.test.ts new file mode 100644 index 0000000..6e01bea --- /dev/null +++ b/tests/main/sessionToolingConfig.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildRunTurnToolingConfig, + validateSessionToolingSelectionIds, +} from '@main/sessionToolingConfig'; +import type { SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling'; + +const TIMESTAMP = '2026-03-25T00:00:00.000Z'; + +const TOOLING: WorkspaceToolingSettings = { + mcpServers: [ + { + id: 'mcp-git', + name: 'Git MCP', + transport: 'local', + command: 'node', + args: ['server.js'], + cwd: 'C:\\workspace\\repo', + tools: ['git.status'], + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }, + ], + lspProfiles: [ + { + id: 'lsp-ts', + name: 'TypeScript', + command: 'typescript-language-server', + args: ['--stdio'], + languageId: 'typescript', + fileExtensions: ['.ts', '.tsx'], + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }, + ], +}; + +describe('session tooling config helpers', () => { + test('validates selected tooling ids against configured workspace tooling', () => { + const selection: SessionToolingSelection = { + enabledMcpServerIds: ['mcp-git'], + enabledLspProfileIds: ['lsp-ts'], + }; + + expect(() => validateSessionToolingSelectionIds(TOOLING, selection)).not.toThrow(); + expect(() => + validateSessionToolingSelectionIds(TOOLING, { + enabledMcpServerIds: ['missing-mcp'], + enabledLspProfileIds: [], + }), + ).toThrow('Unknown MCP server "missing-mcp".'); + expect(() => + validateSessionToolingSelectionIds(TOOLING, { + enabledMcpServerIds: [], + enabledLspProfileIds: ['missing-lsp'], + }), + ).toThrow('Unknown LSP profile "missing-lsp".'); + }); + + test('builds a run-turn tooling config from selected MCP and LSP ids', () => { + expect( + buildRunTurnToolingConfig(TOOLING, { + enabledMcpServerIds: ['mcp-git'], + enabledLspProfileIds: ['lsp-ts'], + }), + ).toEqual({ + mcpServers: [ + { + id: 'mcp-git', + name: 'Git MCP', + transport: 'local', + tools: ['git.status'], + command: 'node', + args: ['server.js'], + cwd: 'C:\\workspace\\repo', + }, + ], + lspProfiles: [ + { + id: 'lsp-ts', + name: 'TypeScript', + command: 'typescript-language-server', + args: ['--stdio'], + languageId: 'typescript', + fileExtensions: ['.ts', '.tsx'], + }, + ], + }); + }); + + test('returns undefined when no session tooling is selected', () => { + expect( + buildRunTurnToolingConfig(TOOLING, { + enabledMcpServerIds: [], + enabledLspProfileIds: [], + }), + ).toBeUndefined(); + }); +});