feat(providers): expose custom agent selection (#1700)

This commit is contained in:
Mohamed Boudra
2026-06-24 16:39:48 +08:00
committed by GitHub
parent 8c67415fdb
commit 0748149ec9
4 changed files with 336 additions and 1 deletions

View File

@@ -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.

View File

@@ -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<string, unknown>,
): 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<ACPSessionInternals>(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<SpawnedACPProcess> {
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<void> {}
}
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<SpawnedACPProcess> {

View File

@@ -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<SessionConfigOption, { type: "select" }>;
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<AgentFeature[]> {
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<ImportableProviderSession[]> {
@@ -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<void> {
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,

View File

@@ -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<SessionConfigOption, { type: "select" }>;
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,