feat: enable scratchpad tooling backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 21:14:19 +01:00
co-authored by Copilot
parent ab4e217d74
commit 4ecfa5bff6
5 changed files with 209 additions and 133 deletions
+8 -1
View File
@@ -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:
@@ -28,8 +28,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
List<IAsyncDisposable> disposables = [];
List<AIAgent> 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<string, object>? mcpServers,
IReadOnlyList<AIFunction>? 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
@@ -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<string, object> 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";
}
}
+10 -114
View File
@@ -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<AppServiceEvents> {
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<AppServiceEvents> {
): Promise<WorkspaceState> {
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<AppServiceEvents> {
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<AppServiceEvents> {
): Promise<WorkspaceState> {
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<AppServiceEvents> {
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<AppServiceEvents> {
private buildRunTurnToolingConfig(
workspace: WorkspaceState,
project: ProjectRecord,
session: SessionRecord,
): RunTurnToolingConfig | undefined {
if (isScratchpadProject(project)) {
return undefined;
}
const selection = resolveSessionToolingSelection(session);
const mcpServersById = new Map<string, McpServerDefinition>(
workspace.settings.tooling.mcpServers.map((server) => [server.id, server]),
);
const lspProfilesById = new Map<string, LspProfileDefinition>(
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(
+100
View File
@@ -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();
});
});