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:
Mohamed Boudra
2026-08-01 23:55:06 +08:00
committed by GitHub
parent 47c18a9213
commit b0a5c09ace
7 changed files with 379 additions and 12 deletions
@@ -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,