diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 07c3bd731633f..094b5eb14181e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4433,6 +4433,140 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect.each([ + { + label: "answers Always allow with the agent's allow-always option", + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow-always", name: "Always allow", kind: "allow_always" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + optionId: "allow-always", + }, + { + label: "answers Always allow with allow-once when the agent offers no allow-always option", + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + optionId: "allow-once", + }, + ] as const)("$label", ({ options, optionId }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let requestPermission: Parameters[0] | undefined; + let runtimeInput: AcpAdapterV2RuntimeInput | undefined; + const makeRuntime = makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + wrapRuntime: (runtime) => ({ + ...runtime, + handleRequestPermission: (handler) => + Effect.sync(() => { + requestPermission = handler; + }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))), + }), + }); + const instanceId = ProviderInstanceId.make("acp-test-always-allow"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + interruptPromptOnCancel: true, + makeRuntime: (input) => + Effect.sync(() => { + runtimeInput = input; + }).pipe(Effect.andThen(makeRuntime(input))), + }, + selfInvocation, + clientTerminals: {}, + }); + const threadId = ThreadId.make("thread-acp-always-allow"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-always-allow"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const turnFiber = yield* runtime + .startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ) + .pipe(Effect.forkScoped); + if (requestPermission === undefined) { + return yield* Effect.die("ACP runtime must register a permission handler"); + } + + const permissionFiber = yield* requestPermission( + { + sessionId: "mock-session-1", + toolCall: { toolCallId: "always-allow-tool", title: "Run tests", kind: "execute" }, + options, + }, + { requestId: "always-allow-request", method: "session/request_permission" }, + ).pipe(Effect.forkScoped); + const pending = Option.getOrThrow( + yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ), + Stream.runHead, + ), + ); + if ( + pending.type !== "runtime_request.updated" || + pending.runtimeRequest.providerTurnId === null + ) { + return yield* Effect.die("Expected an ACP approval request"); + } + const responseFiber = yield* runtime + .respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + decision: "acceptForSession", + }) + .pipe(Effect.forkScoped); + const { outcome } = yield* Fiber.join(permissionFiber); + if (runtimeInput?.onOutgoingResponse === undefined) { + return yield* Effect.die("ACP runtime must expose native response acknowledgements"); + } + yield* runtimeInput.onOutgoingResponse("always-allow-request"); + yield* Fiber.join(responseFiber); + + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: pending.runtimeRequest.providerTurnId, + }); + yield* Fiber.join(turnFiber); + assert.deepEqual(outcome, { outcome: "selected", optionId }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("replaces the ACP session and clears conversation state on rollback", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b7c77c4049591..d74fdb2ec4f3a 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1129,13 +1129,13 @@ function selectPermissionOptionId( request: EffectAcpSchema.RequestPermissionRequest, decision: Exclude, ): string | undefined { - const kind = - decision === "acceptForSession" - ? "allow_always" - : decision === "accept" - ? "allow_once" - : "reject_once"; - return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; + const optionIdOfKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => + request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; + if (decision === "acceptForSession") { + // Agents may offer only allow_once, and answering "cancelled" would stop the turn the user approved. + return optionIdOfKind("allow_always") ?? optionIdOfKind("allow_once"); + } + return optionIdOfKind(decision === "accept" ? "allow_once" : "reject_once"); } // Prefer allow-once; allow-always can persist beyond the session.