From b0a5c09ace58b41849b6c72331e92a749424d64a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 17:55:06 +0200 Subject: [PATCH] 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 --- docs/custom-providers.md | 5 + docs/providers.md | 2 +- .../src/server/agent/agent-manager.test.ts | 59 ++++- .../server/src/server/agent/agent-manager.ts | 2 +- .../server/agent/providers/acp-agent.test.ts | 217 +++++++++++++++++- .../src/server/agent/providers/acp-agent.ts | 97 +++++++- .../agent/providers/cursor-acp-agent.test.ts | 9 + 7 files changed, 379 insertions(+), 12 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index bc20b35d6..9ff609eba 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -584,6 +584,11 @@ When you launch an agent with an ACP provider: 4. Paseo creates a session and sends prompts through the ACP protocol 5. The agent streams responses, tool calls, and permission requests back over stdout +Every ACP provider exposes an **Auto Accept** toggle. Enable it per session to let Paseo approve +ACP permission requests without surfacing each prompt. If the provider sends no allow option, +Paseo leaves the request for you to answer. Unattended agents enable Auto Accept unless you +explicitly disable it. + Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field: ```json diff --git a/docs/providers.md b/docs/providers.md index eb42ad312..354764782 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -48,7 +48,7 @@ Rewind accepts the canonical wire `messageId` and resolves it to the provider id Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID. -Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery. +Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2a3ec15d4..06fd21a59 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 { + this.availabilityCalls += 1; + return true; + } + + override async fetchCatalog() { + this.fetchCatalogCalls += 1; + return await super.fetchCatalog(); + } + + override async createSession(config: AgentSessionConfig): Promise { + 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 { 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 () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index e0eba7800..2ebe7794f 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -966,7 +966,7 @@ export class AgentManager { async listDraftFeatures(config: AgentSessionConfig): Promise { 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(); 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 2f5f919eb..a5677b9f7 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -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; + } = {}, logger: ReturnType = 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(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(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(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 { @@ -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", diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 399ed8942..e687d5114 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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 { + 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 { - // 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) ?? diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index 1064de79f..a38fb7712 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -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,