diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 0945a282cee2c..1cfe7fb84b292 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -56,11 +56,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 }) }, }; } @@ -133,6 +134,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 a2d7c86f4c2d3..426ca543f5ab3 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -188,6 +188,70 @@ 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("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, + 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 5b74756a1de11..a044875fe6b80 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -49,7 +49,28 @@ 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; + // Keep native choices when approval decisions cannot represent every option. + if (request.toolCall.kind === "switch_mode") return !hasAntigravityApprovalOptions(request); + return true; +} + +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 && + // 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(), + ) + ); } /** Copy truncated text so V8 cannot retain the original large string. */ diff --git a/packages/provider-acp/src/server/clientPolicy.test.ts b/packages/provider-acp/src/server/clientPolicy.test.ts index d093b3c85200d..2b3c2bc5f8646 100644 --- a/packages/provider-acp/src/server/clientPolicy.test.ts +++ b/packages/provider-acp/src/server/clientPolicy.test.ts @@ -89,6 +89,27 @@ describe("acpPermissionDisposition", () => { assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny"); }); + it("always asks before a mode switch, even when tools are auto-approved", () => { + 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", () => { 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 2add9197de2eb..1e75482f64519 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,