mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 19:32:21 +00:00
## Summary ACP v2 was served by a bridge (`src/v2/agent.ts`) that converted each v2 request to v1 and ran it through `ClaudeAcpAgent`. Every v2 session therefore ran the v1 core's turn machinery: held prompts, echo matching, trailing-idle debt and the hold for background subagents. v2 asks for none of it. This PR serves v2 with its own agent in `src/v2/`, which follows Claude Code's own events. A prompt is answered when Claude Code takes it in, and `state_update` reports foreground work. Background subagents and tasks are listed in `background` and do not hold the turn. - **New v2 agent (`src/v2/`).** `V2Agent` and `V2Session` cover: - the prompt lifecycle and cancellation - subagents as child sessions, following the subagents RFD, plus background lists - replay, restarts for provider and route changes, auth and providers - elicitation, config options, `/remote-control`, and the `--hide-claude-auth` guard - **Bridge removed.** `ClaudeAcpAgent` serves ACP v1 only: its `v2` option, foreground/background hooks and `this.v2` branches are gone. v1 behavior is unchanged, and no v1 test expectation or golden file changed. The control flow of `acp-agent.ts` is simpler and the file is about 450 lines shorter. - **Shared modules.** Protocol-neutral logic moved out of `acp-agent.ts` into modules both versions use, including: - `session-model.ts`, `session-effort.ts`, `session-config-options.ts` and `session-env.ts` - `providers.ts`, `auth-status-reporter.ts` and `context-window.ts` - `result-end.ts`: one set of rules for reading a result in both cores - `prompt-suggestions.ts` and `mcp-servers.ts` - **JetBrains AIR on v2.** An AIR v2 client gets the AIR extensions that ACP v2 does not cover: - `recommendedValue` - the permission presentation - question custom answers - prompt suggestions - `agentFileChangeReport`, sent at the end of the turn, before its idle ACP v2 itself covers subagent sessions, background subagents, async tasks, plan files, diff patches, compaction, custom instructions and session failures. See "ACP v2" in `docs/air-extensions.md`. - **Ported from main.** Main's v2 changes made on the bridge are ported to the new agent, and main's v2 tests run against it: - workflow cards and phases (#1550, #1552) - task tool calls (#1556) - plan removal on reset (#1554) - subagent checklist plan ids (#1558) - the Fable alias (#1548) ## Behavior changes for v2 clients Each one follows the v2 draft spec, where the bridge did not: - **Background subagents.** They no longer hold the prompt open. They are listed in `background`, and their work is reported in their child session's state. - **`requires_action`.** While the root waits on foreground subagents, it reports `requires_action` only when every one of them waits on the user. - **Resume of an active session.** Its work keeps running, and the new MCP servers replace the old ones (`setMcpServers`). The bridge tore the session down. - **Cancel.** The idle after a cancel waits for Claude Code's aborted result and carries its usage. A stopped child's open tool calls end before its idle. - **`/remote-control`.** It runs as its own turn. Sent while Claude works, it waits for that work to end. - **Accepted URL elicitation.** It keeps the work `requires_action` until Claude Code reports the elicitation complete. - **Exited Claude Code.** A session whose Claude Code process exited stays active and answers -32603 with what happened, until it is resumed. - **Provider change.** `providers/set`/`disable` restarts idle sessions before it answers. A busy session restarts at its next turn boundary, and its prompts are not held meanwhile, which the provider RFD allows. ## Workarounds - **`/remote-control` waits outside the prompt queue.** The adapter's own `/clear` and plan prompts, which continue a turn in a fresh context, use their own push queue (`ownPushes`). They must not wait behind a `/remote-control` that waits for that same turn to end. - **Restart on additional directories.** A resume with other additional directories restarts the query at its next turn boundary. The SDK cannot change the directories of a running query. ## Rollback `CLAUDE_AGENT_ACP_PROTOCOL=v1` negotiates every client down to v1. The bridge and its `CLAUDE_AGENT_ACP_V2_CORE` switch are removed. ## Not served on v2 yet - AIR's tool call keys (`commandTitle`, `subagent`, `skill`, `mcp`), the session index, and API retry warnings. - A `replayFrom` cursor other than `start`, which is rejected with -32602, and steering, which is v1 only. ## Packaging - `lib.js` exports are unchanged. - `acp-agent.js` exports no name fewer than before. It adds `AUTONOMOUS_RESULT_ORIGINS`, `getAvailableSlashCommands` and `isAbortedResult`, which the v2 agent uses, and the export pin test is updated. - Internal `dist/v2/*` modules changed. They are reachable only through the `"./*"` export and are not documented. ## Testing - `npm run format:check`, `lint`, `build` and `test:run` pass: 3935 passed, 33 skipped. - `npm run test:integration` against the real CLI passes: 3960 passed, 8 skipped. This includes `src/tests/v2-integration.test.ts`. - The v2 golden scenarios (`acp-scenarios-v2.test.ts`) match without being regenerated. - The binary builds and `scripts/smoke-binary.mjs` passes. - Two tests unchanged from main can time out under heavy load: the session-index file watcher test and v1's "lagged trailer" test. Both pass on rerun. ## Follow-up - Split `src/` into v1, v2 and shared modules. - Move the SDK-to-ACP mapping that `src/v2/` still imports from `acp-agent.ts` into a shared module. - Add a lint rule that keeps the boundary. --------- Co-authored-by: Claude <noreply@anthropic.com>
184 lines
7.1 KiB
TypeScript
184 lines
7.1 KiB
TypeScript
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk/unstable";
|
|
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
|
|
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
|
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
|
|
|
/**
|
|
* A permission request as the agent builds it. For a v2 client it also
|
|
* carries the heading and the explanation of the prompt, which ACP v2 sends as
|
|
* the request's `title` and `description`. v1 has no field for them; AIR gets
|
|
* them in `_meta`.
|
|
*/
|
|
export type AcpPermissionRequest = RequestPermissionRequest & {
|
|
/** The heading of the prompt. It is not the title of the tool call. */
|
|
title?: string;
|
|
/** Why the permission is asked. */
|
|
description?: string;
|
|
};
|
|
|
|
export interface ClaudePermissionPresentationInput {
|
|
toolName: string;
|
|
input: Record<string, unknown>;
|
|
toolUseID: string;
|
|
cwd?: string;
|
|
capabilities?: ClientCapabilities;
|
|
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
|
|
previewContent?: ToolCallContent[];
|
|
blockedPath?: string;
|
|
title?: string;
|
|
displayName?: string;
|
|
description?: string;
|
|
decisionReason?: string;
|
|
defaultToNo?: boolean;
|
|
}
|
|
|
|
function humanText(value: unknown, maxLength: number, singleLine = false): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const withoutControls = Array.from(value)
|
|
.filter((character) => {
|
|
const code = character.charCodeAt(0);
|
|
return !(
|
|
code <= 0x08 ||
|
|
code === 0x0b ||
|
|
code === 0x0c ||
|
|
(code >= 0x0e && code <= 0x1f) ||
|
|
code === 0x7f
|
|
);
|
|
})
|
|
.join("");
|
|
const normalized = singleLine
|
|
? withoutControls.replace(/\s+/gu, " ").trim()
|
|
: withoutControls.trim();
|
|
return normalized && normalized.length <= maxLength ? normalized : undefined;
|
|
}
|
|
|
|
function compactText(value: unknown): string | undefined {
|
|
return humanText(value, 160, true);
|
|
}
|
|
|
|
/**
|
|
* The permission request presentation.
|
|
*
|
|
* The request `toolCall` carries `toolCallId`, `title`, and `rawInput`. The
|
|
* adapter emits the `tool_call` before the request, so the client already
|
|
* holds the rest. The request adds only what it shows new: the exact preview
|
|
* patch, and the blocked path when the tool call has no such location.
|
|
*
|
|
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
|
|
* v2 client as `title` and `description`.
|
|
*/
|
|
export function buildClaudePermissionPresentation(
|
|
value: ClaudePermissionPresentationInput,
|
|
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
|
|
const capabilities = value.capabilities ?? new ClientCapabilities();
|
|
const renderer = new AcpToolCallRenderer(capabilities);
|
|
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
|
|
const facts = renderer.facts(toolUse, value.cwd);
|
|
const host =
|
|
value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined;
|
|
const isComputerUse = value.toolName.startsWith("mcp__computer-use__");
|
|
const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined);
|
|
// Reuse the exact standard tool-call heading as the permission heading so
|
|
// the approval never maintains a second, divergent name for the operation.
|
|
// decisionReason is temporarily exposed as the permission description so
|
|
// its actual SDK values can be inspected; it remains diagnostic policy text.
|
|
const toolCallTitle = subjectTitle ?? facts.title;
|
|
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
|
|
// Shell titles are executable input: compacting whitespace changes quoted
|
|
// arguments and comment boundaries, and length limits can hide the command.
|
|
const title =
|
|
value.toolName === "Bash" || value.toolName === "PowerShell"
|
|
? permissionTitle
|
|
: (humanText(permissionTitle, 4_000, true) ?? "Use tool?");
|
|
const decisionReason = humanText(value.decisionReason, 4_000);
|
|
const description = decisionReason ? `Reason: ${decisionReason}` : undefined;
|
|
const blockedPath = humanText(value.blockedPath, 4_096, true);
|
|
const extraLocations =
|
|
blockedPath && !(facts.locations ?? []).some((location) => location.path === blockedPath)
|
|
? [{ path: blockedPath }]
|
|
: undefined;
|
|
return {
|
|
toolCall: renderer.permissionToolCall(toolUse, {
|
|
cwd: value.cwd,
|
|
title: toolCallTitle,
|
|
previewContent: value.previewContent,
|
|
extraLocations,
|
|
// The upstream request shows the input of a network or computer-use
|
|
// request that has no content of its own.
|
|
fallbackContent:
|
|
host || isComputerUse
|
|
? [
|
|
{
|
|
type: "content" as const,
|
|
content: {
|
|
type: "text" as const,
|
|
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
|
},
|
|
},
|
|
]
|
|
: undefined,
|
|
}),
|
|
// Only AIR gets the permission presentation.
|
|
...(title && capabilities.air.client
|
|
? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) }
|
|
: {}),
|
|
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The `_meta.jetbrains.air.permission` of a permission request, from the
|
|
* heading and explanation that {@link buildClaudePermissionPresentation}
|
|
* built. The v2 core adds it for an AIR client, whose tool calls it
|
|
* renders without the AIR tool call contract.
|
|
*/
|
|
export function airPermissionMeta({
|
|
title,
|
|
description,
|
|
defaultToNo,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
defaultToNo?: boolean;
|
|
}): Record<string, unknown> {
|
|
return withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
|
version: 1,
|
|
title,
|
|
...(description ? { description } : {}),
|
|
// The CLI's own hint, forwarded so a client that can pre-select an
|
|
// option keeps the decline focused; the option order already leads with
|
|
// the reject options when this is set.
|
|
...(defaultToNo === true ? { defaultToNo: true } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The `_meta.claudeCode` of a permission request's tool call: which MCP
|
|
* server serves an `mcp__*` tool and where its definition came from
|
|
* (`mcpServer`, SDK 0.3.274+), so a client can key trust on `source`, and,
|
|
* for clients other than AIR, the tool name and the Agent or Task call of a
|
|
* subagent's request. Undefined when there is nothing to add.
|
|
*/
|
|
export function permissionToolCallMeta({
|
|
toolName,
|
|
parentToolUseId,
|
|
mcpServer,
|
|
airClient,
|
|
}: {
|
|
toolName: string;
|
|
parentToolUseId: string | undefined;
|
|
mcpServer: { name: string; source: string } | undefined;
|
|
airClient: boolean;
|
|
}): { claudeCode: Record<string, unknown> } | undefined {
|
|
if (!mcpServer && !(parentToolUseId && !airClient)) return undefined;
|
|
// The name is the config key as authored: untrusted text, so it rides
|
|
// `_meta` rather than the title.
|
|
return {
|
|
claudeCode: {
|
|
...(airClient ? {} : { toolName }),
|
|
...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
|
|
...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
|
|
},
|
|
};
|
|
}
|