From 0748149ec96ca1f7e644f561bf64fdf0cc4f38ab Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 24 Jun 2026 16:39:48 +0800 Subject: [PATCH] feat(providers): expose custom agent selection (#1700) --- docs/providers.md | 2 + .../server/agent/providers/acp-agent.test.ts | 158 ++++++++++++++++- .../src/server/agent/providers/acp-agent.ts | 165 ++++++++++++++++++ .../agent/providers/copilot-acp-agent.ts | 12 ++ 4 files changed, 336 insertions(+), 1 deletion(-) diff --git a/docs/providers.md b/docs/providers.md index 40869f9b8..6d080ba33 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,6 +10,8 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md). +Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent. + ### Direct Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch. diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 5423e9af2..da74bcaa5 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -30,6 +30,7 @@ import { } from "./acp-agent.js"; import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js"; import { + COPILOT_AGENT_FEATURE_OPTION, COPILOT_ALLOW_ALL_MODE_ID, COPILOT_MODES, CopilotACPAgentClient, @@ -205,12 +206,16 @@ function selectConfigOption( }; } -function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession { +function createCopilotSessionWithConfig( + modeId?: string | null, + featureValues?: Record, +): ACPAgentSession { return new ACPAgentSession( { provider: "copilot", cwd: "/tmp/paseo-acp-test", modeId: modeId ?? undefined, + ...(featureValues ? { featureValues } : {}), }, { provider: "copilot", @@ -219,6 +224,7 @@ function createCopilotSessionWithConfig(modeId?: string | null): ACPAgentSession defaultModes: COPILOT_MODES, sessionResponseTransformer: transformCopilotSessionResponse, configOptionsTransformer: transformCopilotConfigOptions, + configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION], modeIdTransformer: transformCopilotModeId, providerModeWriter: writeCopilotProviderMode, beforeModeWriter: beforeCopilotModeWriter, @@ -272,6 +278,27 @@ function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigO }; } +function copilotAgentConfigOption(currentValue: string): SessionConfigOption { + return { + id: "agent", + name: "Agent", + category: "_agent", + type: "select", + currentValue, + options: [ + { + value: "", + name: "", + }, + { + value: "Probe Agent", + name: "Probe Agent", + description: "Temporary probe agent", + }, + ], + }; +} + function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string { if (category === "mode") { return "Mode"; @@ -1137,6 +1164,90 @@ describe("ACPAgentSession Zed parity", () => { ]); await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID); }); + + test("exposes Copilot custom agents as a select feature", () => { + const session = createCopilotSessionWithConfig(); + const internals = asInternals(session); + internals.configOptions = [copilotAgentConfigOption("")]; + + expect(session.features).toEqual([ + { + type: "select", + id: "agent", + label: "Agent", + description: "Use a Copilot custom agent profile", + tooltip: "Select Copilot agent", + icon: undefined, + value: "", + options: [ + { + id: "", + label: "Default", + description: undefined, + isDefault: true, + metadata: undefined, + }, + { + id: "Probe Agent", + label: "Probe Agent", + description: "Temporary probe agent", + isDefault: false, + metadata: undefined, + }, + ], + }, + ]); + }); + + test("applies configured Copilot custom agent before the first turn", async () => { + const setSessionConfigOption = vi.fn(async () => ({ + configOptions: [copilotAgentConfigOption("Probe Agent")], + })); + const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" }); + const { internals } = prepareConfiguredOverrideSession(session, { + configOptions: [copilotAgentConfigOption("")], + connection: { setSessionConfigOption }, + }); + + await internals.applyConfiguredOverrides(); + + expect(setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: "session-1", + configId: "agent", + value: "Probe Agent", + }); + expect(session.features).toEqual([ + expect.objectContaining({ + id: "agent", + value: "Probe Agent", + }), + ]); + }); + + test("sets Copilot custom agent through ACP config options", async () => { + const setSessionConfigOption = vi.fn(async () => ({ + configOptions: [copilotAgentConfigOption("Probe Agent")], + })); + const session = createCopilotSessionWithConfig(); + prepareConfiguredOverrideSession(session, { + configOptions: [copilotAgentConfigOption("")], + connection: { setSessionConfigOption }, + }); + + await session.setFeature("agent", "Probe Agent"); + + expect(setSessionConfigOption).toHaveBeenCalledWith({ + sessionId: "session-1", + configId: "agent", + value: "Probe Agent", + }); + expect(session.features).toEqual([ + expect.objectContaining({ + id: "agent", + value: "Probe Agent", + }), + ]); + }); }); describe("deriveModelDefinitionsFromACP", () => { @@ -1285,6 +1396,51 @@ describe("ACPAgentClient modelTransformer", () => { }); }); +describe("ACPAgentClient config features", () => { + test("derives features from configured ACP select options", async () => { + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + configOptions: [copilotAgentConfigOption("Probe Agent")], + }), + }, + initialize: { agentCapabilities: {} }, + } as SpawnedACPProcess; + } + + protected override async closeProbe(): Promise {} + } + + const client = new TestACPAgentClient({ + provider: "copilot", + logger: createTestLogger(), + defaultCommand: ["copilot", "--acp"], + configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION], + }); + + await expect( + client.listFeatures({ + provider: "copilot", + cwd: "/tmp/acp-features", + }), + ).resolves.toEqual([ + expect.objectContaining({ + type: "select", + id: "agent", + value: "Probe Agent", + options: [ + expect.objectContaining({ id: "", label: "Default", isDefault: false }), + expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }), + ], + }), + ]); + }); +}); + describe("ACPAgentClient sessionResponseTransformer", () => { class TestACPAgentClient extends ACPAgentClient { protected override async spawnProcess(): Promise { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index ed09b3918..c130c5559 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -62,6 +62,7 @@ import { getAgentStreamEventTurnId, type AgentCapabilityFlags, type AgentClient, + type AgentFeature, type AgentLaunchContext, type AgentMetadata, type AgentMode, @@ -318,6 +319,7 @@ interface ACPAgentClientOptions { modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; + configFeatureOptions?: ACPConfigFeatureOption[]; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; providerModeWriter?: ( @@ -344,6 +346,7 @@ interface ACPAgentSessionOptions { modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[]; sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; + configFeatureOptions?: ACPConfigFeatureOption[]; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; providerModeWriter?: ( @@ -426,6 +429,17 @@ interface ConfigOptionSelector { metadata?: AgentMetadata; } +export interface ACPConfigFeatureOption { + id: string; + configId: string; + category: string; + label: string; + description?: string; + tooltip?: string; + icon?: string; + emptyOptionLabel?: string; +} + type SelectConfigOption = Extract; interface SelectConfigChoice { value: string; @@ -585,6 +599,31 @@ export function deriveModelDefinitionsFromACP( })); } +export function deriveFeaturesFromACP( + configOptions: SessionConfigOption[] | null | undefined, + featureOptions: ACPConfigFeatureOption[], +): AgentFeature[] { + return featureOptions.flatMap((featureOption) => { + const option = findSelectConfigFeatureOption(configOptions, featureOption); + if (!option) { + return []; + } + + return [ + { + type: "select", + id: featureOption.id, + label: featureOption.label, + description: featureOption.description, + tooltip: featureOption.tooltip, + icon: featureOption.icon, + value: option.currentValue ?? null, + options: deriveConfigFeatureSelectOptions(option, featureOption), + }, + ]; + }); +} + export class ACPAgentClient implements AgentClient { readonly provider: string; readonly capabilities: AgentCapabilityFlags; @@ -600,6 +639,7 @@ export class ACPAgentClient implements AgentClient { private readonly configOptionsTransformer?: ( configOptions: SessionConfigOption[], ) => SessionConfigOption[]; + private readonly configFeatureOptions: ACPConfigFeatureOption[]; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly providerModeWriter?: ( @@ -631,6 +671,7 @@ export class ACPAgentClient implements AgentClient { this.modelTransformer = options.modelTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; + this.configFeatureOptions = options.configFeatureOptions ?? []; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.providerModeWriter = options.providerModeWriter; @@ -656,6 +697,7 @@ export class ACPAgentClient implements AgentClient { modelTransformer: this.modelTransformer, sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, + configFeatureOptions: this.configFeatureOptions, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, @@ -702,6 +744,7 @@ export class ACPAgentClient implements AgentClient { modelTransformer: this.modelTransformer, sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, + configFeatureOptions: this.configFeatureOptions, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, @@ -748,6 +791,27 @@ export class ACPAgentClient implements AgentClient { } } + async listFeatures(config: AgentSessionConfig): Promise { + if (this.configFeatureOptions.length === 0) { + return []; + } + + this.assertProvider(config); + const probe = await this.spawnProcess(PROBE_ENV); + try { + const response = await this.runACPRequest(() => + probe.connection.newSession({ + cwd: config.cwd, + mcpServers: [], + }), + ); + const transformed = this.transformSessionResponse(response); + return deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions); + } finally { + await this.closeProbe(probe); + } + } + async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { @@ -965,6 +1029,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly configOptionsTransformer?: ( configOptions: SessionConfigOption[], ) => SessionConfigOption[]; + private readonly configFeatureOptions: ACPConfigFeatureOption[]; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; private readonly providerModeWriter?: ( @@ -1027,6 +1092,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.modelTransformer = options.modelTransformer; this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; + this.configFeatureOptions = options.configFeatureOptions ?? []; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; this.providerModeWriter = options.providerModeWriter; @@ -1211,6 +1277,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { return this.currentMode; } + get features(): AgentFeature[] { + return deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions); + } + private ensureCommandsReadyDeferred(): void { if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) { return; @@ -1543,6 +1613,44 @@ export class ACPAgentSession implements AgentSession, ACPClient { }); } + async setFeature(featureId: string, value: unknown): Promise { + if (!this.connection || !this.sessionId) { + throw new Error("ACP session not initialized"); + } + + const featureOption = this.configFeatureOptions.find((option) => option.id === featureId); + if (!featureOption) { + throw new Error(`Unknown ${this.provider} feature: ${featureId}`); + } + + const option = findSelectConfigFeatureOption(this.configOptions, featureOption); + if (!option) { + throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`); + } + + const requestedValue = normalizeConfigFeatureValue(value); + const choice = findSelectConfigChoice({ option, value: requestedValue }); + if (!choice) { + throw new Error( + `${this.provider} feature '${featureId}' does not include option '${requestedValue}'`, + ); + } + + const response = await this.connection.setSessionConfigOption({ + sessionId: this.sessionId, + configId: option.id, + value: requestedValue, + }); + const currentValue = this.applyConfigOptionResponse({ + response, + configId: option.id, + category: featureOption.category, + requestedValue, + label: featureOption.label, + }); + this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue }; + } + private applyConfigOptionResponse({ response, configId, @@ -2003,6 +2111,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) { await this.setThinkingOption(this.config.thinkingOptionId); } + const configuredFeatureValues = this.config.featureValues ?? {}; + for (const featureOption of this.configFeatureOptions) { + if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) { + continue; + } + await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]); + } } private warnInvalidSelection(value: string, message: string): void { @@ -2362,6 +2477,19 @@ function findSelectConfigOption({ return option ?? null; } +function findSelectConfigFeatureOption( + configOptions: SessionConfigOption[] | null | undefined, + featureOption: ACPConfigFeatureOption, +): SelectConfigOption | null { + const option = configOptions?.find( + (entry): entry is SelectConfigOption => + entry.type === "select" && + entry.id === featureOption.configId && + entry.category === featureOption.category, + ); + return option ?? null; +} + function findSelectConfigChoice({ option, value, @@ -2389,6 +2517,43 @@ function flattenSelectOptions(options: SelectConfigOption["options"]): SelectCon return flattened; } +function deriveConfigFeatureSelectOptions( + option: SelectConfigOption, + featureOption: ACPConfigFeatureOption, +): ConfigOptionSelector[] { + return flattenSelectOptions(option.options).map((choice) => ({ + id: choice.value, + label: normalizeConfigFeatureOptionLabel(choice, featureOption), + description: choice.description ?? undefined, + isDefault: choice.value === option.currentValue, + metadata: choice.group ? { group: choice.group } : undefined, + })); +} + +function normalizeConfigFeatureOptionLabel( + choice: SelectConfigChoice, + featureOption: ACPConfigFeatureOption, +): string { + const name = choice.name.trim(); + if (name) { + return name; + } + if (choice.value === "" && featureOption.emptyOptionLabel) { + return featureOption.emptyOptionLabel; + } + return choice.value; +} + +function normalizeConfigFeatureValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value === null) { + return ""; + } + throw new Error(`ACP feature value must be a string`); +} + function deriveSelectorOptions( configOptions: SessionConfigOption[] | null | undefined, category: string, diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index 8107ce1cf..b9497d910 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -9,6 +9,7 @@ import { } from "../provider-launch-config.js"; import { ACPAgentClient, + type ACPConfigFeatureOption, type ACPBeforeModeWriteResult, type ACPProviderModeWriteResult, type ACPProviderModeWriterContext, @@ -44,6 +45,16 @@ const COPILOT_ALLOW_ALL_ON = "on"; const COPILOT_ALLOW_ALL_OFF = "off"; type SelectConfigOption = Extract; +export const COPILOT_AGENT_FEATURE_OPTION: ACPConfigFeatureOption = { + id: "agent", + configId: "agent", + category: "_agent", + label: "Agent", + description: "Use a Copilot custom agent profile", + tooltip: "Select Copilot agent", + emptyOptionLabel: "Default", +}; + export const COPILOT_MODES: AgentMode[] = [ { id: COPILOT_AGENT_MODE_ID, @@ -77,6 +88,7 @@ export class CopilotACPAgentClient extends ACPAgentClient { defaultModes: COPILOT_MODES, sessionResponseTransformer: transformCopilotSessionResponse, configOptionsTransformer: transformCopilotConfigOptions, + configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION], modeIdTransformer: transformCopilotModeId, providerModeWriter: writeCopilotProviderMode, beforeModeWriter: beforeCopilotModeWriter,