mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 05:13:58 +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.
|
- 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.
|
- 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:
|
Before every commit, run the following in order:
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
List<IAsyncDisposable> disposables = [];
|
List<IAsyncDisposable> disposables = [];
|
||||||
List<AIAgent> agents = [];
|
List<AIAgent> agents = [];
|
||||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||||
bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase);
|
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||||
SessionToolingBundle? toolingBundle = isScratchpad
|
|
||||||
? null
|
? null
|
||||||
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
@@ -58,22 +57,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
Streaming = true,
|
Streaming = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isScratchpad)
|
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GitHubCopilotAgent agent = new(
|
GitHubCopilotAgent agent = new(
|
||||||
client,
|
client,
|
||||||
@@ -92,6 +76,22 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
return bundle;
|
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)
|
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||||
{
|
{
|
||||||
return pattern.Mode switch
|
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 {
|
import type {
|
||||||
AgentActivityEvent,
|
AgentActivityEvent,
|
||||||
ApprovalRequestedEvent,
|
ApprovalRequestedEvent,
|
||||||
RunTurnLspProfileConfig,
|
|
||||||
RunTurnMcpServerConfig,
|
|
||||||
RunTurnToolingConfig,
|
RunTurnToolingConfig,
|
||||||
SidecarCapabilities,
|
SidecarCapabilities,
|
||||||
TurnDeltaEvent,
|
TurnDeltaEvent,
|
||||||
@@ -89,6 +87,10 @@ import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
|||||||
import { SecretStore } from '@main/secrets/secretStore';
|
import { SecretStore } from '@main/secrets/secretStore';
|
||||||
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
||||||
import { GitService } from '@main/git/gitService';
|
import { GitService } from '@main/git/gitService';
|
||||||
|
import {
|
||||||
|
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||||
|
validateSessionToolingSelectionIds,
|
||||||
|
} from '@main/sessionToolingConfig';
|
||||||
|
|
||||||
type AppServiceEvents = {
|
type AppServiceEvents = {
|
||||||
'workspace-updated': [WorkspaceState];
|
'workspace-updated': [WorkspaceState];
|
||||||
@@ -493,7 +495,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspaceKind,
|
workspaceKind,
|
||||||
pattern: effectivePattern,
|
pattern: effectivePattern,
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
tooling: this.buildRunTurnToolingConfig(workspace, project, session),
|
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
||||||
@@ -650,7 +652,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
): Promise<WorkspaceState> {
|
): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
this.requireProject(workspace, session.projectId);
|
||||||
|
|
||||||
if (session.status === 'running') {
|
if (session.status === 'running') {
|
||||||
throw new Error('Wait for the current response to finish before changing session tools.');
|
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,
|
enabledMcpServerIds,
|
||||||
enabledLspProfileIds,
|
enabledLspProfileIds,
|
||||||
});
|
});
|
||||||
|
validateSessionToolingSelectionIds(workspace.settings.tooling, selection);
|
||||||
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]}".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
session.tooling = selection;
|
session.tooling = selection;
|
||||||
session.updatedAt = nowIso();
|
session.updatedAt = nowIso();
|
||||||
@@ -700,7 +675,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
): Promise<WorkspaceState> {
|
): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
this.requireProject(workspace, session.projectId);
|
||||||
|
|
||||||
if (session.status === 'running') {
|
if (session.status === 'running') {
|
||||||
throw new Error('Wait for the current response to finish before changing session approval settings.');
|
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 },
|
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 knownToolNames = new Set(await this.listKnownApprovalToolNames(workspace));
|
||||||
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
|
const unknownToolName = settings?.autoApprovedToolNames.find((toolName) => !knownToolNames.has(toolName));
|
||||||
if (unknownToolName) {
|
if (unknownToolName) {
|
||||||
@@ -1227,82 +1194,11 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
private buildRunTurnToolingConfig(
|
private buildRunTurnToolingConfig(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
project: ProjectRecord,
|
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
): RunTurnToolingConfig | undefined {
|
): RunTurnToolingConfig | undefined {
|
||||||
if (isScratchpadProject(project)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selection = resolveSessionToolingSelection(session);
|
const selection = resolveSessionToolingSelection(session);
|
||||||
const mcpServersById = new Map<string, McpServerDefinition>(
|
validateSessionToolingSelectionIds(workspace.settings.tooling, selection);
|
||||||
workspace.settings.tooling.mcpServers.map((server) => [server.id, server]),
|
return buildSessionToolingConfig(workspace.settings.tooling, selection);
|
||||||
);
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSessionRun(
|
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