mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
feat: enable scratchpad tooling backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user