Add shared auto-accept controls to ACP sessions (#2752)
* feat(acp): auto-accept shared permission requests Some ACP agents expose permission requests without a native mode that can bypass them. Keep the opt-in at the shared adapter boundary so every ACP implementation gets the same per-session behavior. * test(acp): include the shared feature in provider expectations * fix(acp): honor unattended auto-accept intent * fix(agent): list client features without a model
This commit is contained in:
@@ -1149,10 +1149,51 @@ test("listDraftCommands uses explicit model config without default model fetchin
|
||||
]);
|
||||
});
|
||||
|
||||
test("listDraftFeatures returns no features without guessing a missing model", async () => {
|
||||
test("listDraftFeatures does not start a fallback session without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
availabilityCalls = 0;
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
this.availabilityCalls += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
override async fetchCatalog() {
|
||||
this.fetchCatalogCalls += 1;
|
||||
return await super.fetchCatalog();
|
||||
}
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createSessionCalls += 1;
|
||||
return await super.createSession(config);
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.availabilityCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("listDraftFeatures uses client feature listing without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -1176,7 +1217,7 @@ test("listDraftFeatures returns no features without guessing a missing model", a
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [createFeature({ id: "fast_mode", label: "Fast mode", value: false })];
|
||||
return [draftFeature];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -1188,12 +1229,20 @@ test("listDraftFeatures returns no features without guessing a missing model", a
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
||||
draftFeature,
|
||||
]);
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.availabilityCalls).toBe(0);
|
||||
expect(client.featureConfigs).toEqual([]);
|
||||
expect(client.availabilityCalls).toBe(1);
|
||||
expect(client.featureConfigs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
modeId: "auto-review",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
|
||||
|
||||
@@ -966,7 +966,7 @@ export class AgentManager {
|
||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
if (!normalizedConfig.model) {
|
||||
if (!normalizedConfig.model && !client.listFeatures) {
|
||||
return [];
|
||||
}
|
||||
const available = await client.isAvailable();
|
||||
|
||||
@@ -174,7 +174,12 @@ class FakeTerminator {
|
||||
}
|
||||
|
||||
function createSessionWithConfig(
|
||||
config: { provider?: string; modeId?: string | null; model?: string | null } = {},
|
||||
config: {
|
||||
provider?: string;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
featureValues?: Record<string, unknown>;
|
||||
} = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
@@ -183,6 +188,7 @@ function createSessionWithConfig(
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: config.modeId ?? undefined,
|
||||
model: config.model ?? undefined,
|
||||
featureValues: config.featureValues,
|
||||
},
|
||||
{
|
||||
provider: config.provider ?? "claude-acp",
|
||||
@@ -1152,6 +1158,90 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "cursor-acp",
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const events: Array<{ type: string }> = [];
|
||||
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
session.subscribe((event) => events.push(event as { type: string }));
|
||||
|
||||
await expect(
|
||||
session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
toolCall: {
|
||||
toolCallId: "tool-1",
|
||||
title: "Edit file",
|
||||
kind: "edit",
|
||||
status: "pending",
|
||||
},
|
||||
options: [
|
||||
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
|
||||
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
} satisfies RequestPermissionRequest),
|
||||
).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-once" },
|
||||
});
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
});
|
||||
|
||||
test("starts auto-accepting permissions when the shared feature is toggled on", async () => {
|
||||
const session = createSessionWithConfig({ provider: "generic-acp" });
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
internals.sessionId = "session-1";
|
||||
internals.connection = {};
|
||||
|
||||
await session.setFeature("auto_accept", true);
|
||||
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
|
||||
);
|
||||
await expect(
|
||||
session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
toolCall: {
|
||||
toolCallId: "tool-1",
|
||||
title: "Run command",
|
||||
kind: "execute",
|
||||
status: "pending",
|
||||
},
|
||||
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
|
||||
} satisfies RequestPermissionRequest),
|
||||
).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-always" },
|
||||
});
|
||||
});
|
||||
|
||||
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
|
||||
const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
|
||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
|
||||
|
||||
const permission = session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
toolCall: {
|
||||
toolCallId: "tool-1",
|
||||
title: "Unavailable approval",
|
||||
kind: "other",
|
||||
status: "pending",
|
||||
},
|
||||
options: [{ optionId: "reject-once", name: "Reject", kind: "reject_once" }],
|
||||
} satisfies RequestPermissionRequest);
|
||||
await Promise.resolve();
|
||||
|
||||
const requested = events.find((event) => event.type === "permission_requested");
|
||||
expect(requested?.request?.id).toEqual(expect.any(String));
|
||||
await session.respondToPermission(requested!.request!.id, { behavior: "deny" });
|
||||
await expect(permission).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "reject-once" },
|
||||
});
|
||||
});
|
||||
|
||||
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
|
||||
const setSessionConfigOption = vi.fn(async () => ({
|
||||
configOptions: [
|
||||
@@ -1278,6 +1368,11 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
internals.configOptions = [copilotAgentConfigOption("")];
|
||||
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
{
|
||||
type: "select",
|
||||
id: "agent",
|
||||
@@ -1324,6 +1419,10 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
@@ -1349,6 +1448,10 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
@@ -1506,6 +1609,113 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
});
|
||||
|
||||
describe("ACPAgentClient config features", () => {
|
||||
test("enables Auto Accept for unattended ACP creation", () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["generic-acp", "acp"],
|
||||
});
|
||||
|
||||
expect(
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
featureValues: { provider_feature: "kept" },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({
|
||||
modeId: undefined,
|
||||
featureValues: { provider_feature: "kept", auto_accept: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves an explicit Auto Accept override for unattended ACP creation", () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["generic-acp", "acp"],
|
||||
});
|
||||
|
||||
expect(
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
featureValues: { auto_accept: false },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
|
||||
});
|
||||
|
||||
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["generic-acp", "acp"],
|
||||
});
|
||||
|
||||
expect(
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "bypassPermissions",
|
||||
isUnattended: true,
|
||||
},
|
||||
unattended: false,
|
||||
availableModes: [{ id: "agent", label: "Agent" }],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("treats Auto Accept as an unattended ACP configuration", () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["generic-acp", "acp"],
|
||||
});
|
||||
|
||||
expect(
|
||||
client.isCreateConfigUnattended({
|
||||
modeId: null,
|
||||
config: {
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
availableModes: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("exposes Auto Accept for every ACP provider without starting a probe", async () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["generic-acp", "acp"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
value: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("derives features from configured ACP select options", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
@@ -1537,6 +1747,11 @@ describe("ACPAgentClient config features", () => {
|
||||
cwd: "/tmp/acp-features",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
id: "agent",
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateConfigUnattendedInput,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
@@ -90,9 +91,15 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
type McpServerConfig,
|
||||
type ProviderCatalog,
|
||||
type ResolveAgentCreateConfigInput,
|
||||
type ResolveAgentCreateConfigResult,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
isDefaultAgentCreateConfigUnattended,
|
||||
resolveDefaultAgentCreateConfig,
|
||||
} from "../create-agent-mode.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
@@ -114,6 +121,8 @@ import {
|
||||
} from "./diagnostic-utils.js";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
|
||||
const ACP_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
|
||||
|
||||
function assertChildWithPipes(
|
||||
child: ChildProcess,
|
||||
): asserts child is ChildProcessWithoutNullStreams {
|
||||
@@ -694,9 +703,61 @@ export function deriveFeaturesFromACP(
|
||||
});
|
||||
}
|
||||
|
||||
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: isACPAutoAcceptEnabled(config),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveACPCreateConfig(
|
||||
input: ResolveAgentCreateConfigInput,
|
||||
): ResolveAgentCreateConfigResult {
|
||||
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
|
||||
const featureValues =
|
||||
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.featureValues;
|
||||
|
||||
if (
|
||||
input.requestedMode === undefined &&
|
||||
isUnattendedCreate &&
|
||||
input.parent !== null &&
|
||||
input.parent.provider !== input.provider
|
||||
) {
|
||||
return { modeId: undefined, featureValues };
|
||||
}
|
||||
|
||||
return resolveDefaultAgentCreateConfig({ ...input, featureValues });
|
||||
}
|
||||
|
||||
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
|
||||
return (
|
||||
isDefaultAgentCreateConfigUnattended(input) ||
|
||||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
|
||||
input.features?.some(
|
||||
(feature) =>
|
||||
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
|
||||
feature.type === "toggle" &&
|
||||
feature.value === true,
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
export class ACPAgentClient implements AgentClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly resolveCreateConfig = resolveACPCreateConfig;
|
||||
readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
|
||||
|
||||
protected readonly logger: Logger;
|
||||
protected readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
@@ -892,8 +953,9 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
|
||||
if (this.configFeatureOptions.length === 0) {
|
||||
return [];
|
||||
return [autoAcceptFeature];
|
||||
}
|
||||
|
||||
this.assertProvider(config);
|
||||
@@ -906,7 +968,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
}),
|
||||
);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions);
|
||||
return [
|
||||
autoAcceptFeature,
|
||||
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
@@ -1562,7 +1627,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions);
|
||||
return [
|
||||
buildACPAutoAcceptFeature(this.config),
|
||||
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
}
|
||||
|
||||
private ensureCommandsReadyDeferred(): void {
|
||||
@@ -1902,6 +1970,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const featureOption = this.configFeatureOptions.find((option) => option.id === featureId);
|
||||
if (!featureOption) {
|
||||
throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
|
||||
@@ -2083,7 +2159,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Match Zed acp.rs:3189-3220: generic ACP permission requests stay pure pass-through.
|
||||
if (isACPAutoAcceptEnabled(this.config)) {
|
||||
const allowOption = selectPermissionOption(params.options, { behavior: "allow" });
|
||||
if (allowOption) {
|
||||
this.logger.info(
|
||||
{ toolCallId: params.toolCall.toolCallId, optionId: allowOption.optionId },
|
||||
"Auto-accepting ACP permission request",
|
||||
);
|
||||
return {
|
||||
outcome: { outcome: "selected", optionId: allowOption.optionId },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Match Zed acp.rs:3189-3220 when Paseo is not handling the request locally.
|
||||
const requestId = randomUUID();
|
||||
let toolSnapshot =
|
||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||
|
||||
@@ -137,6 +137,15 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
id: CURSOR_FAST_FEATURE_OPTION.id,
|
||||
|
||||
Reference in New Issue
Block a user