mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 11:51:14 +00:00
## What This PR adds the AIR client capability `backgroundSubagents`. A client declares it in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`. With the capability, an ACP v1 prompt turn ends at its result. This is also true while background subagents of the turn still run, including a subagent that SendMessage resumed. Subagent updates, permission requests and elicitations then go out after the prompt response. Their routing does not change. The agent advertises the capability only to ACP v1 AIR clients. ## Why The hold of #864/#866 keeps `session/prompt` open until the subagents finish. The Claude CLI ends the model turn at `result`, and only `idle` waits for the background agents. In AIR the hold hides the end of the answer, and the next user message steers into an old turn. One SendMessage-resumed subagent kept a turn open for about two hours. ## Unchanged - A client without the capability keeps the hold exactly. So #866 stays fixed for clients that stop listening at the prompt response. - ACP v2 keeps the hold too. ## Related changes - Out of a turn, the ExitPlanMode request of such a client does not offer the clear-context option. The fresh query must continue an ACP turn, and there is none. - An ExitPlanMode interruption of an autonomous cycle with no held turn now clears its markers. ## Docs `docs/air-extensions.md` has a new "Background subagents" section. ## Tests New capability cases: - a fresh subagent; - a SendMessage-resumed subagent; - a permission request after the prompt response, with the `parentToolUseId` attribution; - an autonomous followup that does not end the queued next prompt; - ExitPlanMode out of a turn, with and without a reset; - the exclusion of a running subagent from the incomplete-tool check. The existing tests cover the path without the capability. When the gate is disabled, 7 of the 8 new cases fail. The eighth is the in-turn control case and passes in both configurations. `npm run build`, `npm run check` and `npm run test:run` pass. The exception is 4 `providers.test.ts` cases. They depend on the environment (a local proxy `baseUrl`) and fail on `main` too.
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import type { PermissionOption } from "@agentclientprotocol/sdk/unstable";
|
|
import type { DurablePermissionChangeSet } from "../normalization.js";
|
|
|
|
export const PERMISSION_OPTION_ID = {
|
|
allowOnce: "allow-once",
|
|
allowWithUpdates: "allow-with-updates",
|
|
allowSkillExact: "allow-skill-exact",
|
|
allowSkillPrefix: "allow-skill-prefix",
|
|
exitPlanBypass: "exit-plan-bypass",
|
|
exitPlanAuto: "exit-plan-auto",
|
|
exitPlanAcceptEdits: "exit-plan-accept-edits",
|
|
exitPlanDefault: "exit-plan-default",
|
|
exitPlanClearAuto: "exit-plan-clear-auto",
|
|
exitPlanClearBypass: "exit-plan-clear-bypass",
|
|
exitPlanClearAcceptEdits: "exit-plan-clear-accept-edits",
|
|
reject: "reject",
|
|
} as const;
|
|
|
|
export interface PermissionOptionContext {
|
|
toolName: string;
|
|
displayName?: string;
|
|
input: Record<string, unknown>;
|
|
cwd: string;
|
|
durableChangeSet?: DurablePermissionChangeSet;
|
|
allowPersistentOptions?: boolean;
|
|
/** The CLI's `defaultToNo` hint (SDK 0.3.268+): the ask must not be
|
|
* approvable by a stray keystroke, so the decline option leads. */
|
|
defaultToNo?: boolean;
|
|
availableModes?: readonly string[];
|
|
/** The mode the session left when it entered plan mode. */
|
|
prePlanMode?: string;
|
|
contextUsedPercent?: number;
|
|
/** False when no ACP turn can continue an accepted plan in a fresh
|
|
* context. Then an ExitPlanMode request offers no clear-context option. */
|
|
allowContextReset?: boolean;
|
|
}
|
|
|
|
export function allowOnce(name = "Yes"): PermissionOption {
|
|
return { optionId: PERMISSION_OPTION_ID.allowOnce, name, kind: "allow_once" };
|
|
}
|
|
|
|
export function allowWithUpdates(name: string): PermissionOption {
|
|
return { optionId: PERMISSION_OPTION_ID.allowWithUpdates, name, kind: "allow_always" };
|
|
}
|
|
|
|
export function reject(name = "No"): PermissionOption {
|
|
return { optionId: PERMISSION_OPTION_ID.reject, name, kind: "reject_once" };
|
|
}
|
|
|
|
export function withOptionalUpdate(
|
|
changeSet: DurablePermissionChangeSet | undefined,
|
|
updateName: string | undefined,
|
|
allowName = "Yes",
|
|
rejectName = "No",
|
|
): PermissionOption[] {
|
|
const options = [allowOnce(allowName)];
|
|
if (changeSet && updateName) options.push(allowWithUpdates(updateName));
|
|
options.push(reject(rejectName));
|
|
return options;
|
|
}
|
|
|
|
export function plainString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
export function exactLocalAllowRule(
|
|
changeSet: DurablePermissionChangeSet | undefined,
|
|
toolName: string,
|
|
ruleContent?: string,
|
|
): boolean {
|
|
if (!changeSet || changeSet.updates.length !== 1) return false;
|
|
const update = changeSet.updates[0];
|
|
if (
|
|
update?.type !== "addRules" ||
|
|
update.behavior !== "allow" ||
|
|
update.destination !== "localSettings" ||
|
|
update.rules.length !== 1
|
|
) {
|
|
return false;
|
|
}
|
|
const rule = update.rules[0];
|
|
return (
|
|
rule?.toolName === toolName &&
|
|
(ruleContent === undefined
|
|
? rule.ruleContent === undefined
|
|
: plainString(rule.ruleContent) === ruleContent)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An MCP "don't ask again" option is only truthful when every provider update
|
|
* adds an unrestricted allow rule for the tool currently being prompted.
|
|
*/
|
|
export function isMcpAllowChangeSet(
|
|
changeSet: DurablePermissionChangeSet | undefined,
|
|
toolName: string,
|
|
): boolean {
|
|
return (
|
|
!!changeSet &&
|
|
changeSet.updates.length > 0 &&
|
|
changeSet.updates.every(
|
|
(update) =>
|
|
update.type === "addRules" &&
|
|
update.behavior === "allow" &&
|
|
update.rules.length > 0 &&
|
|
update.rules.every((rule) => rule.toolName === toolName && rule.ruleContent === undefined),
|
|
)
|
|
);
|
|
}
|
|
|
|
export function withGeneratedUpdate(name: string, rejectName = "No"): PermissionOption[] {
|
|
return [allowOnce(), allowWithUpdates(name), reject(rejectName)];
|
|
}
|