Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,12 @@ const flavor = makeAntigravityAcpAdapterFlavor({
function permissionRequest(
toolCallId: string,
options: ReadonlyArray<EffectAcpSchema.PermissionOption>,
kind?: EffectAcpSchema.ToolKind,
): EffectAcpSchema.RequestPermissionRequest {
return {
sessionId: "session-1",
options,
toolCall: { toolCallId, title: "Which branch?" },
toolCall: { toolCallId, title: "Which branch?", ...(kind === undefined ? {} : { kind }) },
};
}

Expand Down Expand Up @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/provider/acp/AntigravityProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/provider/acp/AntigravityProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
21 changes: 21 additions & 0 deletions packages/provider-acp/src/server/clientPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcpRuntimePolicy>) {
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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-acp/src/server/clientPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return acpOperationDisposition(runtimePolicy, {
kind: request.toolCall.kind,
locations: request.toolCall.locations,
Expand Down
Loading