From 28122e28bee05713cc8effb495a4d3cef8e00c1d Mon Sep 17 00:00:00 2001 From: Kareem Abdo <142717280+kareemsetl@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:15:55 -0400 Subject: [PATCH 1/5] fix(provider-acp): ask before an agent switches out of plan mode An ACP agent leaving plan mode asks for confirmation with a session/request_permission whose toolCall.kind is "switch_mode". That request went through the generic tool-permission policy, so runtime modes that auto-approve tools (full access) answered it for the user and the agent implemented the plan with no approval ever shown. Always ask for mode switches; tool permissions keep their existing disposition. --- .../src/server/clientPolicy.test.ts | 17 +++++++++++++++++ .../provider-acp/src/server/clientPolicy.ts | 2 ++ 2 files changed, 19 insertions(+) diff --git a/packages/provider-acp/src/server/clientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts index 630ebc979e753..6a9e1d1c98350 100644 --- a/packages/provider-acp/src/server/clientPolicy.test.ts +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -89,6 +89,23 @@ describe("acpPermissionDisposition", () => { assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); }); + it("always asks before a mode switch, even when tools are auto-approved", () => { + const bypass: AcpRuntimePolicy = { + runtimeMode: "full-access", + cwd, + sandboxPolicy: { type: "dangerFullAccess" }, + }; + assert.equal(acpPermissionDisposition(bypass, permissionRequest("switch_mode")), "ask"); + assert.equal(acpPermissionDisposition(policy, permissionRequest("switch_mode")), "ask"); + assert.equal( + acpPermissionDisposition( + { runtimeMode: "approval-required", cwd }, + permissionRequest("switch_mode"), + ), + "ask", + ); + }); + it("auto-accept-edits approves file changes without locations and asks for the rest", () => { const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd }; for (const kind of ["edit", "delete", "move"] as const) { diff --git a/packages/provider-acp/src/server/clientPolicy.ts b/packages/provider-acp/src/server/clientPolicy.ts index d85b0bc9ed4d5..138ca3df47259 100644 --- a/packages/provider-acp/src/server/clientPolicy.ts +++ b/packages/provider-acp/src/server/clientPolicy.ts @@ -193,6 +193,8 @@ export function acpPermissionDisposition( runtimePolicy: AcpRuntimePolicy, request: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionDisposition { + // Switching modes is the user's decision; auto-approving tools must not answer it. + if (request.toolCall.kind === "switch_mode") return "ask"; return acpOperationDisposition(runtimePolicy, { kind: request.toolCall.kind, locations: request.toolCall.locations, From c223cfc975eadae6c4ab9fd0c8d9f36f257533df Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 00:37:55 +0000 Subject: [PATCH 2/5] test(provider-acp): cover mode switches across runtime policies --- .../src/server/clientPolicy.test.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/provider-acp/src/server/clientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts index 6a9e1d1c98350..d47cb082993bf 100644 --- a/packages/provider-acp/src/server/clientPolicy.test.ts +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -90,20 +90,24 @@ describe("acpPermissionDisposition", () => { }); it("always asks before a mode switch, even when tools are auto-approved", () => { - const bypass: AcpRuntimePolicy = { - runtimeMode: "full-access", - cwd, - sandboxPolicy: { type: "dangerFullAccess" }, - }; - assert.equal(acpPermissionDisposition(bypass, permissionRequest("switch_mode")), "ask"); - assert.equal(acpPermissionDisposition(policy, permissionRequest("switch_mode")), "ask"); - assert.equal( - acpPermissionDisposition( - { runtimeMode: "approval-required", cwd }, - permissionRequest("switch_mode"), - ), - "ask", - ); + for (const runtimePolicy of [ + policy, + { runtimeMode: "full-access", cwd }, + { runtimeMode: "full-access", cwd, sandboxPolicy: { type: "dangerFullAccess" } }, + { + runtimeMode: "full-access", + cwd, + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }, + { runtimeMode: "auto-accept-edits", cwd }, + { runtimeMode: "approval-required", cwd }, + ] satisfies ReadonlyArray) { + assert.equal( + acpPermissionDisposition(runtimePolicy, permissionRequest("switch_mode")), + "ask", + ); + } }); it("auto-accept-edits approves file changes without locations and asks for the rest", () => { From bb2c72771a41dafd668785c468169134d28a4c76 Mon Sep 17 00:00:00 2001 From: Kareem Abdo <142717280+kareemsetl@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:52:56 -0400 Subject: [PATCH 3/5] fix(server): route Antigravity mode switches through approval Antigravity labels agent questions with an `interaction_` tool-call id, and the adapter treats every such request as a native question before the runtime policy runs. A `session/request_permission` with `toolCall.kind` of `switch_mode` therefore skipped `acpPermissionDisposition` entirely whenever the agent reused that id prefix, so the always-ask rule for mode switches was not what answered it. Treat a `switch_mode` request as an approval even under a question-shaped id. Keep the question path when the request carries no allow option, since the approval card would then have nothing to accept with and the user could no longer answer at all. --- .../Adapters/AntigravityAdapterV2.test.ts | 19 +++++++++- .../provider/acp/AntigravityProtocol.test.ts | 36 +++++++++++++++++++ .../src/provider/acp/AntigravityProtocol.ts | 14 +++++++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index c991487b8d0c2..4a160dbfd0bb9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -55,11 +55,12 @@ const flavor = makeAntigravityAcpAdapterFlavor({ function permissionRequest( toolCallId: string, options: ReadonlyArray, + kind?: EffectAcpSchema.ToolKind, ): EffectAcpSchema.RequestPermissionRequest { return { sessionId: "session-1", options, - toolCall: { toolCallId, title: "Which branch?" }, + toolCall: { toolCallId, title: "Which branch?", ...(kind === undefined ? {} : { kind }) }, }; } @@ -117,6 +118,22 @@ describe("AntigravityAdapterV2 flavor", () => { outcome: { outcome: "selected", optionId: "opt-dev" }, }); }); + + it("routes a mode switch to approval rather than the question card", () => { + const request = permissionRequest( + "interaction_3", + [ + { optionId: "switch-to-build", name: "Yes", kind: "allow_once" }, + { optionId: "stay-in-plan", name: "No", kind: "reject_once" }, + ], + "switch_mode", + ); + assert.isUndefined(flavor.extractPermissionQuestion?.(request)); + assert.deepEqual( + flavor.approvalOptions?.(request)?.map((option) => option.decision), + ["accept", "decline", "cancel"], + ); + }); }); const layerSession = Layer.mergeAll( diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 20d084c4bf81b..38ce7474f5f33 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -227,6 +227,42 @@ describe("Antigravity permissions and questions", () => { expect(extractAntigravityUserInputQuestion(permissionRequest)).toBeUndefined(); }); + it("routes a mode switch to approval even under a native-question id", () => { + const modeSwitch = { + ...questionRequest, + toolCall: { ...questionRequest.toolCall, kind: "switch_mode" as const }, + options: [ + { optionId: "switch-to-build", name: "Yes", kind: "allow_once" as const }, + { optionId: "stay-in-plan", name: "No", kind: "reject_once" as const }, + ], + }; + expect(isAntigravityUserInputRequest(modeSwitch)).toBe(false); + expect(extractAntigravityUserInputQuestion(modeSwitch)).toBeUndefined(); + expect( + makeAntigravityUserInputResponse(modeSwitch, { interaction_9960062f: "switch-to-build" }), + ).toBeUndefined(); + expect(antigravityApprovalOptions(modeSwitch)).toEqual([ + { decision: "accept", label: "Allow once" }, + { decision: "decline", label: "Deny" }, + { decision: "cancel", label: "Cancel" }, + ]); + }); + + it("keeps a mode switch as a question when it offers no allow option", () => { + const modeSwitch = { + ...questionRequest, + toolCall: { ...questionRequest.toolCall, kind: "switch_mode" as const }, + options: [ + { optionId: "proceed", name: "Yes", kind: "other" as const }, + { optionId: "stay", name: "No", kind: "other" as const }, + ], + }; + expect(isAntigravityUserInputRequest(modeSwitch)).toBe(true); + expect( + extractAntigravityUserInputQuestion(modeSwitch)?.options.map((option) => option.value), + ).toEqual(["proceed", "stay"]); + }); + it("returns exact opaque IDs and accepts a unique label from an older client", () => { for (const answer of ["1", ["1"], "Verified"]) { expect( diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 1ca5f7fc91bb3..a989ba95b8e58 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -43,7 +43,19 @@ const decodeToolCallContent = Schema.decodeUnknownOption(AcpWireSchema.ToolCallC export function isAntigravityUserInputRequest( request: EffectAcpSchema.RequestPermissionRequest, ): boolean { - return request.toolCall.toolCallId.startsWith("interaction_"); + if (!request.toolCall.toolCallId.startsWith("interaction_")) return false; + // A mode switch is approval-gated even under a native-question id, unless the + // agent offered no allow option for the approval card to answer with. + if (request.toolCall.kind === "switch_mode") return !hasAntigravityApprovalOption(request); + return true; +} + +function hasAntigravityApprovalOption(request: EffectAcpSchema.RequestPermissionRequest): boolean { + return request.options.some( + (option) => + (option.kind === "allow_once" || option.kind === "allow_always") && + option.optionId.trim().length > 0, + ); } export function selectAntigravityPermissionOptionId( From dcb47d11b5094f4937e457327a623968e4edd232 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 01:03:30 +0000 Subject: [PATCH 4/5] fix(antigravity): preserve every mode-switch choice --- .../provider/acp/AntigravityProtocol.test.ts | 13 +++++++++++ .../src/provider/acp/AntigravityProtocol.ts | 22 ++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 38ce7474f5f33..32645b5b1f438 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -263,6 +263,19 @@ describe("Antigravity permissions and questions", () => { ).toEqual(["proceed", "stay"]); }); + it("keeps every mode-switch choice when permission kinds repeat", () => { + const modeSwitch = { + ...questionRequest, + toolCall: { ...questionRequest.toolCall, kind: "switch_mode" as const }, + }; + expect(isAntigravityUserInputRequest(modeSwitch)).toBe(true); + for (const option of modeSwitch.options) { + expect( + makeAntigravityUserInputResponse(modeSwitch, { interaction_9960062f: option.optionId }), + ).toEqual({ outcome: { outcome: "selected", optionId: option.optionId } }); + } + }); + it("returns exact opaque IDs and accepts a unique label from an older client", () => { for (const answer of ["1", ["1"], "Verified"]) { expect( diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index a989ba95b8e58..02b6e7976e164 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -44,17 +44,23 @@ export function isAntigravityUserInputRequest( request: EffectAcpSchema.RequestPermissionRequest, ): boolean { if (!request.toolCall.toolCallId.startsWith("interaction_")) return false; - // A mode switch is approval-gated even under a native-question id, unless the - // agent offered no allow option for the approval card to answer with. - if (request.toolCall.kind === "switch_mode") return !hasAntigravityApprovalOption(request); + // Keep native choices when approval decisions cannot represent every option. + if (request.toolCall.kind === "switch_mode") return !hasAntigravityApprovalOptions(request); return true; } -function hasAntigravityApprovalOption(request: EffectAcpSchema.RequestPermissionRequest): boolean { - return request.options.some( - (option) => - (option.kind === "allow_once" || option.kind === "allow_always") && - option.optionId.trim().length > 0, +function hasAntigravityApprovalOptions(request: EffectAcpSchema.RequestPermissionRequest): boolean { + const kinds = new Set(request.options.map((option) => option.kind)); + return ( + kinds.size === request.options.length && + (kinds.has("allow_once") || kinds.has("allow_always")) && + request.options.every( + (option) => + (option.kind === "allow_once" || + option.kind === "allow_always" || + option.kind === "reject_once") && + option.optionId.trim().length > 0, + ) ); } From c5d8548aa4f7e9afa689d284de38bf6da6bcc7a0 Mon Sep 17 00:00:00 2001 From: Kareem Abdo <142717280+kareemsetl@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:36:19 -0500 Subject: [PATCH 5/5] fix(antigravity): keep padded option ids on the question path The approval response sends `optionId.trim()`, so routing a mode switch whose ids are padded to the approval card would hand the agent an option id it never offered. Treat a padded id as unrepresentable and keep the request on the question path, which returns the exact id. --- .../src/provider/acp/AntigravityProtocol.test.ts | 15 +++++++++++++++ .../src/provider/acp/AntigravityProtocol.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index ee3b7c1c3bf00..fe8a5480fa27a 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -223,6 +223,21 @@ describe("Antigravity permissions and questions", () => { ).toEqual(["proceed", "stay"]); }); + it("keeps a mode switch as a question when option ids are padded", () => { + const modeSwitch = { + ...questionRequest, + toolCall: { ...questionRequest.toolCall, kind: "switch_mode" as const }, + options: [ + { optionId: " switch-to-build ", name: "Yes", kind: "allow_once" as const }, + { optionId: "stay-in-plan", name: "No", kind: "reject_once" as const }, + ], + }; + expect(isAntigravityUserInputRequest(modeSwitch)).toBe(true); + expect( + makeAntigravityUserInputResponse(modeSwitch, { interaction_9960062f: " switch-to-build " }), + ).toEqual({ outcome: { outcome: "selected", optionId: " switch-to-build " } }); + }); + it("keeps every mode-switch choice when permission kinds repeat", () => { const modeSwitch = { ...questionRequest, diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index aeeae1a97a3c6..6eed820c44681 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -58,7 +58,10 @@ function hasAntigravityApprovalOptions(request: EffectAcpSchema.RequestPermissio (option.kind === "allow_once" || option.kind === "allow_always" || option.kind === "reject_once") && - option.optionId.trim().length > 0, + option.optionId.trim().length > 0 && + // The approval response sends the trimmed id, so a padded id would come + // back to the agent as an option it never offered. + option.optionId === option.optionId.trim(), ) ); }