Compare commits

...

7 Commits

Author SHA1 Message Date
Ben Brandt
6b69852cfc refactor: report each prompt turn through internal turn events (#1222)
First part of step 3 of opt-in ACP v2 support (step 1: #1217, step 2:
#1219). It's independent of #1219. It changes no ACP v1 behavior, apart
from one failure-only case noted below.

## Why

ACP v2 changes the prompt lifecycle. `session/prompt` is answered when
the prompt is *inserted* into the conversation, and progress and the
stop reason arrive as `state_update`s. In v1, the response comes at the
end of the turn. The agent only exposed the v1 shape: `prompt()` returns
at the end of the turn. So v2 can't be mapped from the outside.

Following the discussion on #1219, the adapter gets its own view of a
turn, and each ACP version maps to it, instead of v2 being translated
into v1.

## What

`src/turn-events.ts` defines the adapter's own turn lifecycle:

- `inserted(messageId)`: Claude Code took the prompt in as this user
message. At most once, and never after the turn ends.
- `awaitingUser()` / `resumed()`: a permission request or question is
open in the session / none is open any more.
- `ended(outcome)`: with a `TurnOutcome` (`stopReason`, `usage`,
`_meta`).
- `failed(error)`.

The agent calls these synchronously while it updates its own state, so
handlers must not throw or call back into the agent.

In the agent:

- `startTurn(params, events)` starts a turn. It resolves once the prompt
is queued for Claude Code, and rejects only if the prompt is refused
before that. Events can arrive before it resolves. `prompt()` is now the
v1 view of it: it awaits the turn's outcome.
- **inserted** is reported in `activateTurn`, the single place a queued
turn becomes active. That's when Claude Code takes the prompt in: its
echo, or for an echo-less command, its result. Two guards:
- a clear-context restart re-activates the same turn in the fresh
session, so it's reported once;
- a turn that a cancel settled during the previous turn's hand-off isn't
reported at all.
- **awaiting user / resumed** follow one session-level rule: the active
turn awaits the user exactly while any request in the session is open,
rechecked when a turn activates. A request carries no prompt id, and
Claude Code can ask for a queued prompt before the consumer reaches its
echo. So tying a request to the turn that was active when it opened
would misattribute it.
- **ended / failed** come from the existing settle points, each guarded
by `settled`, so a turn reports exactly one of them.
- `TurnOutcome` and `StopReason` are the adapter's own types. A turn's
outcome is field for field a v1 prompt response, so `PromptResponse` now
only appears at the v1 boundary.

**The one v1 difference:** a prompt that sets a goal publishes it to AIR
clients after the prompt is queued. If that notification fails,
`startTurn` now logs it rather than rejecting, because the turn is
already running. For v1 this only matters on a dead connection:
`prompt()` waits for the outcome instead of rejecting with the write
error.

## Notes for the v2 step

- A prompt sent while another turn is running is inserted only when
Claude Code takes it in, after the running turn. The open question about
queued prompts is going back to the v2 draft.
- An echo-less command (such as `/compact`) is inserted at its result,
after its output. On `msg_lifecycle_v1` CLIs, the `command_lifecycle`
"started" frame may be an earlier insertion point.
- A queued turn that a cancel ends before insertion may still be run by
Claude Code (the orphan accounting).
- Any open request counts towards `requires_action`, a background task's
included; `agentID` could refine that in the permission step.

## Testing

`src/tests/turn-events.test.ts` covers:
- a normal turn: inserted under the uuid of the message Claude Code
received, then ended;
- a permission request: awaiting user, then resumed;
- a request open from before the turn: the turn awaits the user from
insertion, through its own answered request, until the earlier one is
answered too;
- a cancel during the hand-off from the previous turn: the queued turn
ends as cancelled and is never inserted;
- cancelling while a second prompt is queued: the active turn is
inserted, then ended as cancelled, and the queued turn ends as cancelled
without being inserted;
- the stream ending with a turn queued: it fails without being inserted;
- a prompt for an unknown session: `startTurn` rejects and reports
nothing.

The existing clear-context restart test also checks that the restarted
turn is inserted once.

Each guard is checked by mutation: removing the `insertedReported`
guard, the `settled` guard, the activation recheck, or the change-only
dedupe; tying `resumed` to the request's own turn; or reporting
insertion at enqueue each fails at least one test.

The existing suite passes unchanged, including the 474 scenario tests
that compare outbound v1 traffic with recorded baselines. `npm run
build`, `npm run check`, `npm run test:run`.
2026-10-02 15:01:27 +02:00
Ben Brandt
c60fa48fd8 refactor: serve the ACP v2 session lifecycle without prompts (#1219)
Step 2 of opt-in ACP v2 support (step 1: #1217). Behind
`CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the v2 surface now serves
`session/new`, `session/list`, `session/resume` (without replay),
`session/close`, `session/delete`, and `session/set_config_option`. ACP
v1 is unchanged.

`capabilities.session` is still not advertised. Under v2, advertising it
commits the agent to the whole baseline, including `session/prompt` and
`replayFrom: start` replay, and those come in later steps. Until then
these methods are served but not advertised.

## Translation

As in step 1, each v2 request becomes the v1 request that
`ClaudeAcpAgent` already serves, and each v1 response or update becomes
its v2 form (`src/v2/session.ts`, `src/v2/session-update.ts`):

- **MCP servers:** v2 requires a `type` on every transport, but the
agent recognizes a v1 stdio server by its *missing* `type`. A v2 `type:
"stdio"` config passed through as is would be silently skipped, so the
tag is dropped. The lists v1 requires (`args`, `env`, `headers`) are
defaulted. A transport v1 can't express is rejected with invalid params
instead of being left out of the session.
- **Config options:** `id` → `configId`, a select group's `group` →
`groupId`. A select value in `session/set_config_option` drops its v2
`type: "id"`, and value types v1 can't express are rejected.
- **Modes:** responses drop `modes`, and `current_mode_update` is
dropped. The agent always lists the mode as the `mode` config option,
and every mode change also reaches the client as a
`config_option_update` or in the `set_config_option` response.
- **Commands:** `available_commands_update` inputs gain `type: "text"`.
- **Notices, session info, usage:** passed through unchanged. v2 needs
no capability for notices, so a v2 client is reported to the agent as
taking them, and advisories arrive as `notice` instead of transcript
messages.
- **Elicitation:** the same in v1 and v2, so it's forwarded. MCP OAuth
at `session/new` works.
- Update kinds that aren't translated yet (messages, tool calls, plans,
extensions) throw, so a gap shows up as an error, not a missing update.
- `replayFrom` is rejected until replayed history can be translated.

Also bumps `@agentclientprotocol/sdk`:
- to 1.6.1, whose router types `closed` as always present, so `serveAcp`
drops its `closed!`;
- to 1.7.0, which type-checks the v2 extensible-union values the adapter
builds, at any depth
([typescript-sdk#268](https://github.com/agentclientprotocol/typescript-sdk/pull/268)).
The v2 translations compile unchanged. The exception is
`createElicitation`, which relays the v1 request: v1 types a property
schema's tag as any string, and an MCP server's schema passes through
unchecked, as on v1. It gets a cast with a comment saying so.

## Testing

- `src/tests/acp-v2.test.ts` drives the router with the SDK's v2 client
app, which validates every response, session update, and elicitation
against the v2 schema. New cases cover session creation with v2 MCP
servers, config options, and commands; rejected MCP transports;
list/close/resume/delete and rejected `replayFrom`; setting the mode
with no `current_mode_update` reaching the client; notice support; MCP
OAuth through URL elicitation; and unit tests for grouped options,
rejected value types, and untranslated updates. Breaking the stdio or
command-input translation makes the tests fail.
- Ran the built binary over stdio against a real Claude Code session: v2
`initialize`, `session/new`, `list`, setting the mode, and `close` all
passed the v2 client's validation, and the 52 real commands arrived with
`type: "text"` inputs. `session/new` took ~0.7 s, the same as over v1.
- `npm run build`, `npm run check`, `npm run test:run`.
2026-10-02 15:00:11 +02:00
acp-release-bot[bot]
686c0c99b3 chore(main): release 0.85.1 (#1213)
🤖 I have created a release *beep* *boop*
---


##
[0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1)
(2026-10-01)


### Bug Fixes

* answer session/close without waiting for the interrupt reply
([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216))
([0c862e2](0c862e2949))
* fail turns with unfinished tools
([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212))
([7f8d547](7f8d5471e2))
* restore background task stops on replay instead of showing them as
prompts
([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205))
([4ffa625](4ffa625977))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-02 10:58:36 +02:00
Ben Brandt
8e893f51ea refactor: add an opt-in experimental ACP v2 handshake (#1217)
First step toward optional support for the [draft ACP v2
protocol](https://agentclientprotocol.com/protocol/v2/draft/overview).
ACP v1 stays the default and is unchanged.

## What it does

Set `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1` and the adapter routes each
connection by the protocol version the client requests in `initialize`
(via the SDK's `AgentProtocolRouter`):

- A client that requests version 2 or later gets the draft ACP v2
`initialize`, `auth/login`, and `auth/logout`, plus the
`_auth/status_update` push.
- Every other client gets ACP v1, served by the same handlers as today.

Without the variable, the adapter never builds the router and serves ACP
v1 exactly as before.

The v2 response does not advertise `capabilities.session` yet. A v2
agent that advertises it must serve the whole session baseline
(`session/new`, `list`, `resume`, `close`, `prompt`, `cancel`,
`update`), which follow-up PRs add. So for now a v2 client can
initialize and authenticate, but cannot open a session.

## How

`ClaudeAcpAgent` keeps speaking ACP v1 types. The v2 surface in
`src/v2/` is a thin translation layer around it, so v1 and v2 share one
implementation:

- `v2AgentApp` registers the v2 methods and translates each request to
the v1 request the agent already serves, and each response back. For
`initialize` that means `info`/`capabilities` instead of
`agentInfo`/`agentCapabilities`, and auth methods with `methodId` and a
required `type`, with terminal `env` as `{ name, value }` pairs. A v2
client gets no client `fs` or terminals, and boolean config options are
baseline.
- `V2ClientConnection` is the `AcpClient` of a v2 connection. It sends
the agent's v1-shaped messages in their v2 form. Only connection-level
messages can reach it so far; session-scoped methods reject until v2
sessions land.
- `v1AgentApp` is the existing v1 handler registration, pulled out of
`runAcp` so the router can reuse it. Each surface now creates the agent
of its connection in `onConnect`, because under the router the
connection opens only after `initialize` has been read. `runAcp` (a
public export) behaves as before.

Also bumps `@agentclientprotocol/sdk` to 1.6.0 (schema v2.0.0-alpha.7).
The bump changes nothing for v1: the full suite passes unchanged on it.

## Testing

- `src/tests/acp-v2.test.ts` drives the real router over in-memory
streams with the SDK's own v1 and v2 client apps (the v2 client
validates every response against the v2 schema). It covers the v1
handshake through the router, the v2 handshake, method-not-found for v2
session methods, the `_auth/status_update` push, and gateway
`auth/login` followed by `auth/logout`.
- Ran the built binary over stdio: v2 client with the flag, v1 client
with the flag, v2 client without the flag (gets the unchanged v1
response), and stdin EOF before `initialize` (exits 0).
- `npm run build`, `npm run check`, `npm run test:run`.
2026-10-01 16:46:57 +02:00
nikita-ashihmin
0c862e2949 fix: answer session/close without waiting for the interrupt reply (#1216)
## Summary

- `teardownSession` sends the interrupt but does not await its reply
- `query.close()` follows at once and ends the CLI process
- `session/cancel` still awaits the interrupt reply

On a fresh session, the background `getContextUsage` request from
`refreshContextWindowInBackground` is still in flight. The SDK
serializes control requests, so the CLI answered the interrupt only
after it. With a real CLI, `session/close` took 2.5 s in an empty
directory and 3.2 s in a large repository. ACP clients with a 3 s close
timeout timed out. With this change, close answers in about 2 ms, and
the CLI process exits.

## Tests

- `npm run test:run -- src/tests/create-session-options.test.ts`
- `npx tsc --noEmit`
- `npm run check`
2026-10-01 18:03:14 +04:00
nikita-ashihmin
4ffa625977 fix: restore background task stops on replay instead of showing them as prompts (#1205)
When a background task stops, the CLI persists a `<task-notification>`
user record. Live, the adapter hides it and handles the SDK task
lifecycle frame; replay previously showed the raw XML as a user prompt.

Replay now hides notification records and removes embedded notification
XML while preserving adjacent user text and delivered prompts with an
origin `subkind`. It restores known non-agent tasks through
`AsyncTaskRuntime`, including `killed` → `stopped` and duplicate
terminal-event suppression. Agent notifications override the launch
result's terminal state. Unknown tasks remain hidden without creating a
task card.

Task launches are recognized in `tool_use`, `server_tool_use`, and
`mcp_tool_use` blocks. Async task updates use the session that owns the
launching tool call, even when another session receives the
notification. History has no `task_started` frame, so tasks without a
persisted stop notification cannot be reconstructed.

The large Write patch test now uses a fixed clock to check patch
correctness independently of CI speed; a separate advancing-clock test
verifies the existing timeout fallback. The production 100 ms budget
remains unchanged.

Validation on current main (SDK 0.3.286):
- `npm run build`: passed.
- `npm run check`: passed.
- `env -u ANTHROPIC_BASE_URL npm run test:run`: 2119 passed, 29 skipped.
The local gateway environment otherwise changes the expected default URL
in four provider tests.
- Regression coverage includes origin/text notifications, adjacent user
text, unknown and repeated tasks, killed tasks, background Agents,
delivered subkind prompts, alternate tool block types, task session
ownership, and patch timeout fallback.
2026-10-01 17:07:49 +04:00
Mark Tkachenko
7f8d5471e2 fix: fail turns with unfinished tools (#1212)
Fixing silent agent turn completion in case of tools failure
2026-10-01 14:51:13 +02:00
18 changed files with 2534 additions and 89 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.85.0"
".": "0.85.1"
}

View File

@@ -1,5 +1,14 @@
# Changelog
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
### Bug Fixes
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.85.0",
"version": "0.85.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.85.0",
"version": "0.85.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.5.1",
"@agentclientprotocol/sdk": "1.7.0",
"@anthropic-ai/claude-agent-sdk": "0.3.286",
"diff": "9.0.0",
"zod": "4.6.5"
@@ -39,9 +39,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.5.1.tgz",
"integrity": "sha512-nSUIrC1fOR9+ppKfx9KdwjBBND2sAPzFHo4yVQrGbXZHnayUYpwL8CDVR5LSxbapY/oKMOzNP0wvwfyx3PS2KA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.7.0.tgz",
"integrity": "sha512-ZORuxsuEnLjRly7t6Ad7zAsWitjWsyprswyPUHz/yycPb+tTm3DGa8XheaHhRXqX8LS9snuxaYavXSRA50TlHw==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.85.0",
"version": "0.85.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -65,7 +65,7 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.5.1",
"@agentclientprotocol/sdk": "1.7.0",
"@anthropic-ai/claude-agent-sdk": "0.3.286",
"diff": "9.0.0",
"zod": "4.6.5"

View File

@@ -1,5 +1,6 @@
import {
agent as acpAgent,
AgentApp,
AgentContext,
AuthenticateRequest,
AuthMethod,
@@ -52,9 +53,9 @@ import {
DeleteSessionResponse,
WriteTextFileRequest,
WriteTextFileResponse,
StopReason,
ToolCallContent,
} from "@agentclientprotocol/sdk";
import type { StopReason, TurnEvents, TurnOutcome } from "./turn-events.js";
import {
AccountInfo,
CanUseTool,
@@ -105,12 +106,14 @@ import {
asSdkSessionNotification,
clientSupportsSubagents,
SubagentAwareSessionCapabilities,
type SubagentState,
} from "./acp-subagents.js";
import {
isNativeSubagentControlTool,
isNativeSubagentControlUpdate,
NativeSubagent,
NativeSubagentRuntime,
nativeSubagentState,
resumedNativeSubagentId,
sendMessageResumePrompt,
} from "./native-subagents.js";
@@ -581,15 +584,30 @@ function parseSteerRequest(params: unknown): SteerRequest {
};
}
/** One in-flight `prompt()` call. A persistent per-session consumer (see
* `runConsumer`) drains the SDK query stream for the whole session and settles
* each Turn's deferred when that turn's outcome is known, so `prompt()` itself
* holds no loop. Turns are processed FIFO: the SDK echoes queued user messages
* back in submission order, so `turnQueue[0]` is the turn currently running. */
/** One in-flight turn, started by `startTurn()`. A persistent per-session
* consumer (see `runConsumer`) drains the SDK query stream for the whole
* session and settles each Turn when that turn's outcome is known, so
* `startTurn()` itself holds no loop. Turns are processed FIFO: the SDK echoes
* queued user messages back in submission order, so `turnQueue[0]` is the turn
* currently running. */
type Turn = {
/** uuid stamped on the pushed `SDKUserMessage`; the SDK echoes it back so the
* consumer can match the replayed user message to this turn. */
* consumer can match the replayed user message to this turn. It is also the
* id of the user message the turn reports as inserted. */
promptUuid: string;
/** Where the turn reports how it progresses (see {@link TurnEvents}). */
events: TurnEvents;
/** Set once `events.inserted` has been reported. A clear-context restart
* activates the same turn again in the fresh session, which must not report
* it twice. */
insertedReported?: boolean;
/** Whether the turn last reported that it awaits the user, so that
* `syncAwaitingUser` reports only changes. */
awaitingUser?: boolean;
/** Tools surfaced during this turn, awaiting completion or an explicit
* background handoff. Intersect with emittedToolCalls at settlement so
* tool_result counts even when a presentation hook is still pending. */
foregroundToolCallIds?: Set<string>;
/** Local-only slash commands (e.g. `/clear`) return a result without an echo,
* so the consumer can't promote them via the replay; it falls back to
* promoting the queue head when the result arrives. */
@@ -626,7 +644,7 @@ type Turn = {
settling?: boolean;
/** Outcome captured before the checkpoint preview await, so cancel() can
* preserve its usage and metadata while atomically winning that race. */
settlingOutcome?: PromptResponse;
settlingOutcome?: TurnOutcome;
/** Set when a `command_lifecycle` "started" frame arrives for this turn's
* uuid (msg_lifecycle_v1 CLIs): the SDK dispatched the command into a turn.
* Read by cancel() to seed the orphan's state — a started orphan's turn may
@@ -703,7 +721,7 @@ type Turn = {
* streams — degrading to post-turn delivery for it, never worse than the
* pre-hold behavior (pending wakes are not countable: notifications can
* batch into one followup). */
deferredSettle?: PromptResponse;
deferredSettle?: TurnOutcome;
/** Uuids of `steer()`-injected messages the SDK has not replayed back yet.
*
* A steer is normally delivered at priority `now`. When it lands during
@@ -746,14 +764,14 @@ type Turn = {
steeredAwaitingResult?: boolean;
/** What a steered turn settles with once its steered work has run: the outcome
* of its latest result, so its usage covers every cycle the turn ran. */
steeredSettle?: PromptResponse;
steeredSettle?: TurnOutcome;
carriedUsage?: AccumulatedUsage;
/** `carriedUsage`'s per-model counterpart, so a turn that survives a
* clear-context restart keeps the `_meta.quota` rows it earned pre-restart. */
carriedModelUsage?: ModelTokenTally;
resolve: (response: PromptResponse) => void;
resolve: (outcome: TurnOutcome) => void;
reject: (error: unknown) => void;
/** Settles after the ACP prompt request completes, regardless of outcome. */
/** Settles once the turn has ended or failed. */
completion?: Promise<void>;
};
@@ -1202,9 +1220,7 @@ const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set
* held for background subagents it spawned (see Turn.deferredSettle). The
* single spelling of the hold predicate, shared by the consumer's settle
* lanes and cancel(). */
function isHeldOpen(
turn: Turn | null | undefined,
): turn is Turn & { deferredSettle: PromptResponse } {
function isHeldOpen(turn: Turn | null | undefined): turn is Turn & { deferredSettle: TurnOutcome } {
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
}
@@ -1219,7 +1235,7 @@ function isSteering(turn: Turn | null | undefined): turn is Turn & { steeredEcho
* (see Turn.steeredAwaitingResult), so the next idle can settle it. */
function isSteeredSettleReady(
turn: Turn | null | undefined,
): turn is Turn & { steeredEchoes: Set<string>; steeredSettle: PromptResponse } {
): turn is Turn & { steeredEchoes: Set<string>; steeredSettle: TurnOutcome } {
return (
isSteering(turn) &&
turn.steeredEchoes.size === 0 &&
@@ -1805,8 +1821,12 @@ const LOCAL_COMMAND_MARKERS = [
// whatever the user typed. Nobody wrote it and no client sees it live — the
// prompt loop's user-message skip covers the whole message — but it is
// persisted alongside that prose, so replay would hand the client a prompt
// with instructions in it the user never gave.
const INJECTED_CONTEXT_MARKERS = ["system-reminder"].map((tag) => ({
// with instructions in it the user never gave. A `task-notification` tells the
// model that a background task stopped; live, the SDK `task_notification`
// frame reports the same stop, so replay restores the task state from it (see
// taskNotificationsOf) instead of showing it.
const TASK_NOTIFICATION_TAG = "task-notification";
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
open: `<${tag}>`,
close: `</${tag}>`,
}));
@@ -1914,6 +1934,69 @@ export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}
/** The fields of a persisted `<task-notification>`, in the shape of the SDK
* `task_notification` frame that reported the same stop live. */
type PersistedTaskNotification = {
task_id: string;
status: string;
tool_use_id?: string;
summary?: string;
output_file?: string;
};
/**
* Returns each `<task-notification>` in user-message content (a string or its
* text blocks). The `<result>` body is model context that the live frame does
* not carry, so the scan stops at it and a tag quoted in it cannot match.
*/
function taskNotificationsOf(content: unknown): PersistedTaskNotification[] {
const texts =
typeof content === "string"
? [content]
: Array.isArray(content)
? content.flatMap((block) =>
block?.type === "text" && typeof block.text === "string" ? [block.text as string] : [],
)
: [];
const open = `<${TASK_NOTIFICATION_TAG}>`;
const close = `</${TASK_NOTIFICATION_TAG}>`;
const notifications: PersistedTaskNotification[] = [];
for (const text of texts) {
let start = text.indexOf(open);
while (start !== -1) {
const end = text.indexOf(close, start + open.length);
if (end === -1) break;
const body = text.slice(start + open.length, end).split("<result>", 1)[0];
const field = (tag: string) => markerTagText(body, tag)?.trim() || undefined;
const taskId = field("task-id");
const status = field("status");
if (taskId && status) {
notifications.push({
task_id: taskId,
status,
tool_use_id: field("tool-use-id"),
summary: field("summary"),
output_file: field("output-file"),
});
}
start = text.indexOf(open, end + close.length);
}
}
return notifications;
}
/**
* True for a user record that the CLI wrote to tell the model that a
* background task stopped. A `subkind` marks the delivery of a prompt, for
* example a scheduled routine or a message from another session. Such a
* delivery stays in the transcript like any other prompt.
*/
function isTaskNotificationRecord(message: unknown): boolean {
const origin = (message as { origin?: unknown }).origin as
{ kind?: unknown; subkind?: unknown } | null | undefined;
return origin?.kind === "task-notification" && origin.subkind === undefined;
}
/**
* True for the synthetic assistant message the CLI injects into the transcript
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
@@ -3060,7 +3143,32 @@ export class ClaudeAcpAgent {
}
}
/**
* Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
* outcome of a turn is field for field a v1 prompt response.
*/
async prompt(params: PromptRequest): Promise<PromptResponse> {
let events!: TurnEvents;
const outcome = new Promise<TurnOutcome>((resolve, reject) => {
events = {
inserted() {},
awaitingUser() {},
resumed() {},
ended: resolve,
failed: reject,
};
});
await this.startTurn(params, events);
return outcome;
}
/**
* Starts a turn for a prompt. Resolves once the prompt is queued for Claude
* Code, and rejects only when the prompt is refused before that. From then
* on, `events` reports how the turn goes (see {@link TurnEvents}), possibly
* before this resolves.
*/
async startTurn(params: PromptRequest, events: TurnEvents): Promise<void> {
if (this.providerUpdate) await this.providerUpdate;
let session = this.sessions[params.sessionId];
if (!session) {
@@ -3115,41 +3223,41 @@ export class ClaudeAcpAgent {
session.titles.onPrompt(params.prompt);
// Each prompt is a Turn whose deferred the persistent consumer settles once
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
// turn, pushes the user message onto the streaming input, makes sure the
// consumer is running, and awaits the deferred.
// Each prompt is a Turn that the persistent consumer settles once the
// turn's outcome is known. `startTurn()` owns no loop: it enqueues the
// turn, pushes the user message onto the streaming input, and makes sure
// the consumer is running.
let completeTurn!: () => void;
const turn: Turn = {
promptUuid,
events,
isLocalOnlyCommand,
...(isUsageCommand ? { isUsageCommand: true } : {}),
...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
...(fileChangeReport ? { fileChangeReport } : {}),
settled: false,
resolve: () => {},
reject: () => {},
completion: new Promise<void>((resolve) => {
completeTurn = resolve;
}),
resolve: (outcome) => {
events.ended(outcome);
completeTurn();
},
reject: (error) => {
events.failed(error);
completeTurn();
},
};
let completeTurn!: () => void;
turn.completion = new Promise<void>((resolve) => {
completeTurn = resolve;
});
const response = new Promise<PromptResponse>((resolve, reject) => {
turn.resolve = (result) => {
resolve(result);
completeTurn();
};
turn.reject = (error) => {
reject(error);
completeTurn();
};
});
session.turnQueue ??= [];
session.turnQueue.push(turn);
session.input.push(userMessage);
this.ensureConsumer(session, params.sessionId);
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
return response;
// The prompt is queued, so its turn goes ahead even if the client misses
// the goal it sets; the turn's events report the outcome.
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid).catch((error) =>
this.logger.error(`Session ${params.sessionId}: failed to publish the prompt's goal:`, error),
);
}
/** `--hide-claude-auth` applies only to the CLI's own login. A provider
@@ -3681,6 +3789,26 @@ export class ClaudeAcpAgent {
}
return;
}
if (
toolCallId &&
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update")
) {
if (
claudeMeta?.parentToolUseId ||
routedNotification.sessionId !== params.sessionId ||
update.status === "completed" ||
update.status === "failed"
) {
// A later stream frame can attribute an eager permission call to a
// child. It must no longer count as this turn's foreground work.
forgetForegroundToolCall(session, toolCallId);
} else if (
update.sessionUpdate === "tool_call" &&
session.emittedToolCalls.has(toolCallId)
) {
recordForegroundToolCall(session, toolCallId);
}
}
if (update.sessionUpdate === "agent_message_chunk") {
if (
!claudeMeta?.parentToolUseId &&
@@ -3899,6 +4027,17 @@ export class ClaudeAcpAgent {
}
}
resetTurnScratch();
// Activation is when Claude Code takes the prompt in: its echo, or for an
// echo-less command, its result. That inserts it into the conversation.
// A turn can be settled by now: an echo hand-off awaits settling the
// previous turn, and a cancel in that window settles the queued one.
if (!turn.insertedReported && !turn.settled) {
turn.insertedReported = true;
turn.events.inserted(turn.promptUuid);
}
// A request can already be open: Claude Code asks for a queued prompt
// before the consumer reaches its echo.
this.syncAwaitingUser(session);
};
/** Ensure there is an active turn before a user-turn result that carries no
@@ -4095,6 +4234,7 @@ export class ClaudeAcpAgent {
isSubagent: boolean,
) => {
session.liveBackgroundTasks.set(taskId, { parentToolUseId, isSubagent });
if (parentToolUseId) forgetForegroundToolCall(session, parentToolUseId);
session.resumableSubagents?.delete(taskId);
if (isSubagent && session.activeTurn && !session.activeTurn.settled) {
(session.activeTurn.spawnedTaskIds ??= new Set()).add(taskId);
@@ -4152,7 +4292,7 @@ export class ClaudeAcpAgent {
* turn that can have spawned subagents must route through here: a site
* calling settleActive directly bypasses the hold and re-opens the
* out-of-turn permission deadlock (issue #866) through its lane. */
const settleOrDefer = async (outcome: PromptResponse) => {
const settleOrDefer = async (outcome: TurnOutcome) => {
// No result ends a steered turn: the steer aborted the cycle this result
// may belong to, and the steered one is still to come. Record the outcome
// for the idle lane (see Turn.steeredEchoes); later cycles overwrite it,
@@ -4177,7 +4317,7 @@ export class ClaudeAcpAgent {
* turn exactly once, disarm the force-cancel backstop, and drop it from
* the queue. Cancellation and provider failures skip checkpoint I/O. */
const settleActive = async (
result: PromptResponse,
result: TurnOutcome,
reportReason: FileChangeReportUnavailableReason = result.stopReason === "cancelled"
? "cancelled"
: "notReported",
@@ -4186,6 +4326,62 @@ export class ClaudeAcpAgent {
if (!turn || turn.settled || turn.settling) {
return;
}
// Both a result and EOF can end the turn here. Preserve cancellation
// and existing errors instead of replacing them with this check.
if (result.stopReason === "end_turn" && reportReason === "notReported") {
// A confirmed background task is allowed to outlive the turn.
const backgroundTools = new Set(
[...session.liveBackgroundTasks.values()].map((task) => task.parentToolUseId),
);
// Check only this turn's tools. A tool_result removes its id from
// emittedToolCalls even when its PostToolUse hook has not arrived yet.
const unfinished = [...(turn.foregroundToolCallIds ?? [])].filter(
(id) => session.emittedToolCalls.has(id) && !backgroundTools.has(id),
);
if (unfinished.length > 0) {
const message = `Claude ended the turn without returning results for tool calls: ${unfinished.join(", ")}`;
this.logger.error(
`Session ${params.sessionId}, turn ${turn.promptUuid}, stopReason=${result.stopReason}: ${message}`,
);
// Fail every unfinished tool before reporting one error for the prompt.
for (const toolCallId of unfinished) {
// A late hook must not overwrite the failure we are about to send.
unregisterHookCallback(toolCallId);
session.emittedToolCalls.delete(toolCallId);
delete session.toolUseCache[toolCallId];
session.toolCallFields?.delete(toolCallId);
await sendUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: "Claude ended the turn without returning a result for this tool.",
},
},
],
},
});
// Cancellation can arrive while we await the client update.
if (turn.settled || session.activeTurn !== turn) return;
if (session.cancelled) {
await settleActive({ ...result, stopReason: "cancelled" });
return;
}
}
await failActiveWithSessionFailure(
"internal_error",
RequestError.internalError(errorKindData("incomplete_tool_call"), message),
message,
);
return;
}
}
turn.settling = true;
turn.settlingOutcome = result;
if (reportReason === "notReported") {
@@ -6679,6 +6875,18 @@ export class ClaudeAcpAgent {
}
async cancel(params: CancelNotification): Promise<void> {
await this.cancelTurns(params, { awaitInterrupt: true });
}
/** Cancel the session's turns and interrupt the SDK query. With
* `awaitInterrupt: false` the interrupt is sent, but its reply is not
* awaited. `teardownSession` uses that: it closes the query right after, and
* the reply can queue behind a slow control request, such as the background
* `getContextUsage` of a fresh session. */
private async cancelTurns(
params: CancelNotification,
options: { awaitInterrupt: boolean },
): Promise<void> {
this.exitPlan.cancel(params.sessionId);
const session = this.sessions[params.sessionId];
if (!session) {
@@ -6913,7 +7121,14 @@ export class ClaudeAcpAgent {
}, this.forceCancelGraceMs);
}
const receipt = await session.query.interrupt();
const interrupt = session.query.interrupt();
if (!options.awaitInterrupt) {
// The caller closes the query next, which rejects the pending reply.
// The receipt only adjusts orphan accounting of a live session.
Promise.resolve(interrupt).catch(() => {});
return;
}
const receipt = await interrupt;
// On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
// lists exactly which queued messages survive the interrupt and will still
// run. An orphaned turn whose uuid is absent was dropped by the interrupt
@@ -7019,7 +7234,7 @@ export class ClaudeAcpAgent {
return;
}
try {
await this.cancel({ sessionId });
await this.cancelTurns({ sessionId }, { awaitInterrupt: false });
} catch (error) {
this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
}
@@ -7213,6 +7428,9 @@ export class ClaudeAcpAgent {
reconstructable: boolean;
announced: boolean;
terminalState?: "completed" | "failed" | "cancelled";
/** The state of the last persisted task notification of the child.
* It comes after the launch tool_result, so it wins. */
notifiedState?: SubagentState;
}
>();
@@ -7333,6 +7551,55 @@ export class ClaudeAcpAgent {
? await subagentHistory(sessionId)
: { ids: new Map<string, string>() };
const replayedSubagents = new Set<string>();
// History persists no task_started frame, so a background task gets its
// spawn from the notification of its stop, with the tool call that
// started it. A task still running at the end of the history gets no
// card, because the process that ran it is gone.
// The tool cache forgets a tool use at its result, but a notification
// comes later and needs the tool use that started the task.
const replayToolUses = new Map<string, { name: string; input: unknown; sessionId: string }>();
const replayAsyncTasks = new AsyncTaskRuntime(
clientSupportsAsyncTasks(this.clientCapabilities),
sessionId,
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
{
routeOf: (toolCallId) => {
const target = replayToolUses.get(toolCallId)?.sessionId;
return target && target !== sessionId
? (notification) => ({ ...notification, sessionId: target })
: undefined;
},
},
);
// The replay counterpart of the live `task_notification` frame.
const restoreTaskNotification = async (
notification: PersistedTaskNotification,
): Promise<void> => {
const toolUseId = notification.tool_use_id;
if (!toolUseId) return;
const child = replayChildren.get(toolUseId);
const toolUse = replayToolUses.get(toolUseId);
if (child || (toolUse && isNativeSubagentControlTool(toolUse.name))) {
const state = nativeSubagentState(notification.status);
if (child && state) child.notifiedState = state;
return;
}
// Like live, a task that no tool call started stays unknown.
if (!toolUse) return;
const input = toolUse.input as { command?: unknown } | null | undefined;
await replayAsyncTasks.taskBackgrounded({
task_id: notification.task_id,
...(toolUse.name === "Bash"
? { task_type: "local_bash", description: input?.command }
: {}),
is_backgrounded: true,
output_file: notification.output_file,
tool_use_id: toolUseId,
});
await replayAsyncTasks.taskNotification(notification);
};
const replayMessage = async (message: SessionMessage): Promise<void> => {
if (isReplayHiddenMetaMessage(message)) {
return;
@@ -7402,8 +7669,25 @@ export class ClaudeAcpAgent {
}
// @ts-expect-error - untyped in SDK but we handle all of these
if (message.message.role === "user") {
content = stripLocalCommandMetadata(content);
for (const notification of taskNotificationsOf(content)) {
await restoreTaskNotification(notification);
}
// Live, the prompt loop skips this record and the SDK frame reports the stop.
content = isTaskNotificationRecord(message) ? null : stripLocalCommandMetadata(content);
if (content === null) return;
} else if (Array.isArray(content)) {
for (const block of content) {
if (
["tool_use", "server_tool_use", "mcp_tool_use"].includes(block?.type) &&
typeof block.id === "string"
) {
replayToolUses.set(block.id, {
name: String(block.name),
input: block.input,
sessionId: replayTargetSessionId,
});
}
}
}
// Claude persists the retained summary as a user message framed with
@@ -7518,7 +7802,7 @@ export class ClaudeAcpAgent {
sessionUpdate: "subagent_state_update",
subagentSessionId: child.sessionId,
state: child.reconstructable
? (child.terminalState ?? "disconnected")
? (child.notifiedState ?? child.terminalState ?? "disconnected")
: "disconnected",
},
}),
@@ -7544,18 +7828,37 @@ export class ClaudeAcpAgent {
/** Mark a client request as blocking on user input for exactly the lifetime
* of its promise. Steering consults this session-local count synchronously,
* so a message arriving while any permission/elicitation card is open uses
* non-interrupting SDK delivery. */
* non-interrupting SDK delivery. The active turn reports that it awaits the
* user while any such request is open (see `syncAwaitingUser`). */
private async withPendingUserInput<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
const session = this.sessions[sessionId];
if (!session) return request();
session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
this.syncAwaitingUser(session);
try {
return await request();
} finally {
session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
this.syncAwaitingUser(session);
}
}
/** Report whether the session's active turn awaits the user: exactly while
* a permission request or question is open in the session. A request does
* not say which prompt it is for, and Claude Code can ask for a queued
* prompt before the consumer activates it, so a request is not tied to the
* turn that was active when it opened. Called when the count changes and
* when a turn activates. */
private syncAwaitingUser(session: Session): void {
const turn = session.activeTurn;
if (!turn || turn.settled || !turn.insertedReported) return;
const waiting = (session.pendingUserInputCount ?? 0) > 0;
if (waiting === (turn.awaitingUser ?? false)) return;
turn.awaitingUser = waiting;
if (waiting) turn.events.awaitingUser();
else turn.events.resumed();
}
/** Forward a permission request to the client, wiring the tool call's
* `signal` through as a `cancellationSignal`. When the turn is cancelled
* while the client's prompt is still open the signal aborts, the SDK sends
@@ -7643,6 +7946,15 @@ export class ClaudeAcpAgent {
return;
}
session.emittedToolCalls.add(toolCallId);
// SandboxNetworkAccess is a permission-only callback, not an executing
// tool: the SDK does not send a tool_result for its synthetic id.
if (
!parentToolUseId &&
notificationSessionId === sessionId &&
toolName !== "SandboxNetworkAccess"
) {
recordForegroundToolCall(session, toolCallId);
}
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall(
{ id: toolCallId, name: toolName, input: toolInput },
@@ -9330,7 +9642,7 @@ function turnOutcome(
session: Session,
stopReason: StopReason,
extraMeta?: Record<string, unknown>,
): PromptResponse {
): TurnOutcome {
return {
stopReason,
usage: sessionUsage(session),
@@ -9495,7 +9807,7 @@ function totalTokens(usage: UsageSnapshot): number {
/** Error kinds this adapter invents itself, alongside the SDK's categorical
* `SDKAssistantMessageError` kinds: `no_result` marks a turn the SDK declared
* over without ever emitting its result (issue #825). */
type AgentErrorKind = SDKAssistantMessageError | "no_result";
type AgentErrorKind = SDKAssistantMessageError | "no_result" | "incomplete_tool_call";
/**
* Build the `data` payload attached to a `RequestError.internalError` when we
@@ -10031,6 +10343,17 @@ function shouldEmitToolCall(toolName: string): boolean {
return toolName !== "TodoWrite" && !isTaskTool(toolName);
}
/** Streamed and permission-surfaced tools can precede the SDK's user echo. */
function recordForegroundToolCall(session: Session, toolCallId: string): void {
const turn = session.activeTurn ?? session.turnQueue?.find((queued) => !queued.settled);
if (turn && !turn.settled) (turn.foregroundToolCallIds ??= new Set()).add(toolCallId);
}
function forgetForegroundToolCall(session: Session, toolCallId: string): void {
session.activeTurn?.foregroundToolCallIds?.delete(toolCallId);
for (const turn of session.turnQueue ?? []) turn.foregroundToolCallIds?.delete(toolCallId);
}
/** The tool-call field tracker of a session, created on first use. */
function toolCallFieldsOf(session: {
toolCallFields?: ToolCallFieldTracker;
@@ -10616,21 +10939,25 @@ export async function runPromptWithCancellation(
}
}
export function runAcp(logger?: Logger) {
const input = nodeToWebWritable(process.stdout);
const output = nodeToWebReadable(process.stdin);
const stream = ndJsonStream(input, output);
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
// that stays valid for the whole connection, so the agent captures it once.
// Handlers close over `agent`, which is assigned synchronously right after
// `connect()` returns — before the connection processes any inbound message.
// It cannot be `const`: its value depends on `connection.client`, which does
// not exist until `connect()` has been called.
// eslint-disable-next-line prefer-const
let agent: ClaudeAcpAgent;
const connection = acpAgent({ name: "claude-code-acp" })
/**
* The ACP v1 surface of the adapter, for one connection.
*
* The agent of the connection is created when the connection opens, from the
* connection-scoped peer handle (`connection.client`), which stays valid for
* the whole connection. Connect handlers run before the connection processes
* any inbound message, so every handler below sees the agent. `onAgent`
* receives it for the owner of the process (shutdown).
*/
export function v1AgentApp(
logger: Logger | undefined,
onAgent: (agent: ClaudeAcpAgent) => void,
): AgentApp {
let agent!: ClaudeAcpAgent;
return acpAgent({ name: "claude-code-acp" })
.onConnect((connection) => {
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
onAgent(agent);
})
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
@@ -10664,10 +10991,15 @@ export function runAcp(logger?: Logger) {
GOAL_CONTROL_METHOD,
{ parse: parseGoalRequest },
(ctx) => agent.goal(ctx.params),
)
.connect(stream);
);
}
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
/** Serves ACP v1 on stdio. */
export function runAcp(logger?: Logger) {
const stream = ndJsonStream(nodeToWebWritable(process.stdout), nodeToWebReadable(process.stdin));
let agent!: ClaudeAcpAgent;
// `connect` runs the connect handlers before it returns, so `agent` is set.
const connection = v1AgentApp(logger, (created) => (agent = created)).connect(stream);
return { connection, agent };
}

View File

@@ -1,7 +1,8 @@
#!/usr/bin/env node
import { claudeCliPath, runAcp } from "./acp-agent.js";
import { claudeCliPath } from "./acp-agent.js";
import { applyManagedPolicyEnv } from "./managed-policy.js";
import { serveAcp } from "./serve.js";
import packageJson from "../package.json" with { type: "json" };
import { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -82,10 +83,13 @@ if (process.argv.includes("--cli")) {
: undefined;
logger?.log("Claude ACP started");
const { connection, agent } = runAcp(logger);
// The draft ACP v2 is opt-in while it is a draft.
const acp = serveAcp(logger, {
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
});
async function shutdown() {
await agent.dispose().catch((err) => {
await acp.dispose().catch((err) => {
console.error("Error during cleanup:", err);
});
process.exit(0);
@@ -94,7 +98,7 @@ if (process.argv.includes("--cli")) {
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
// error). Without this, `process.stdin.resume()` keeps the event loop
// alive indefinitely, causing orphan process accumulation in oneshot mode.
connection.closed.then(shutdown);
acp.closed.then(shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

51
src/serve.ts Normal file
View File

@@ -0,0 +1,51 @@
import { ndJsonStream } from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
import { v2AgentApp } from "./v2/agent.js";
/**
* Serves ACP on stdio.
*
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
* Without it, the adapter serves ACP v1 alone and never routes by protocol
* version, exactly as before.
*/
export function serveAcp(
logger: Logger | undefined,
options: { experimentalV2: boolean },
): { closed: Promise<void>; dispose(): Promise<void> } {
const output = nodeToWebWritable(process.stdout);
const input = nodeToWebReadable(process.stdin);
// The router opens the connection of the negotiated version only when it
// reads `initialize`, so a client that leaves before then leaves no agent.
let agent: ClaudeAcpAgent | undefined;
const onAgent = (created: ClaudeAcpAgent) => {
agent = created;
};
const { closed } = options.experimentalV2
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
return {
closed,
dispose: async () => {
await agent?.dispose();
},
};
}
/**
* Routes a connection to the protocol version that the client negotiates in
* `initialize`: the experimental draft ACP v2 for a client that requests
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
* the connection once the router has opened it.
*/
export function acpProtocolRouter(
logger: Logger | undefined,
onAgent: (agent: ClaudeAcpAgent) => void,
): v2.AgentProtocolRouter {
return v2
.agentProtocolRouter()
.withV1(v1AgentApp(logger, onAgent))
.withV2(v2AgentApp(logger, onAgent));
}

View File

@@ -1949,6 +1949,17 @@ describe("stripLocalCommandMetadata", () => {
).toBeNull();
});
it("strips a persisted task notification, keeping what the user typed", () => {
expect(
stripLocalCommandMetadata(
"<task-notification>\n<task-id>bg1</task-id>\n</task-notification>\nwhat now?",
),
).toBe("\nwhat now?");
expect(
stripLocalCommandMetadata("<task-notification><task-id>bg1</task-id></task-notification>"),
).toBeNull();
});
it("drops reminder-only blocks from mixed arrays, keeping real blocks", () => {
expect(
stripLocalCommandMetadata([
@@ -2528,6 +2539,293 @@ describe("synthetic login message (issue #863)", () => {
});
});
describe("task notification replay", () => {
type History = Awaited<ReturnType<typeof getSessionMessages>>;
const record = (fields: Record<string, unknown>) =>
({
session_id: "s1",
parent_tool_use_id: null,
parent_agent_id: null,
...fields,
}) as unknown as History[number];
const notificationText = (status: string, toolUseId = "toolu_bash") =>
"<task-notification>\n<task-id>bg1</task-id>\n" +
`<tool-use-id>${toolUseId}</tool-use-id>\n` +
"<output-file>/tmp/tasks/bg1.output</output-file>\n" +
`<status>${status}</status>\n` +
'<summary>Background command "npm test" finished</summary>\n' +
"<note>A task-notification fires each time the task stops.</note>\n" +
"<result><status>failed</status></result>\n</task-notification>";
const notification = (status: string, fields: Record<string, unknown> = {}) =>
record({
type: "user",
uuid: `notification-${status}`,
message: { role: "user", content: notificationText(status) },
origin: { kind: "task-notification" },
...fields,
});
const bashLaunch = [
record({ type: "user", uuid: "u1", message: { role: "user", content: "run the tests" } }),
record({
type: "assistant",
uuid: "a1",
message: {
id: "m1",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "npm test", run_in_background: true },
},
],
},
}),
record({
type: "user",
uuid: "u2",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash",
content: "Command running in background with ID: bg1.",
},
],
},
}),
];
async function replay(
history: History,
capabilities: ClientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
},
): Promise<AcpSessionNotification[]> {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) =>
updates.push(update as AcpSessionNotification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, capabilities);
vi.mocked(getSessionMessages).mockResolvedValueOnce(history);
await (
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
).replaySessionHistory("s1");
return updates;
}
const userChunks = (updates: AcpSessionNotification[]) =>
updates.flatMap(({ update }) =>
update.sessionUpdate === "user_message_chunk" && update.content.type === "text"
? [update.content.text]
: [],
);
const asyncTaskUpdates = (updates: AcpSessionNotification[]) =>
updates.flatMap(({ update }) =>
update.sessionUpdate.startsWith("async_task_") ? [update] : [],
);
it("hides the notification and restores the task state like the live frame", async () => {
const updates = await replay([...bashLaunch, notification("completed")]);
expect(userChunks(updates)).toEqual(["run the tests"]);
expect(JSON.stringify(updates)).not.toContain("task-notification");
expect(asyncTaskUpdates(updates)).toEqual([
{
sessionUpdate: "async_task_spawned",
asyncTaskId: "bg1",
name: "npm test",
taskType: "shell",
description: "npm test",
showInTranscript: true,
canStop: true,
outputFilePath: "/tmp/tasks/bg1.output",
toolCallId: "toolu_bash",
},
{
sessionUpdate: "async_task_state_update",
asyncTaskId: "bg1",
state: "completed",
summary: 'Background command "npm test" finished',
},
]);
});
it("detects a notification without an origin by its text and maps killed to stopped", async () => {
const updates = await replay([...bashLaunch, notification("killed", { origin: undefined })]);
expect(userChunks(updates)).toEqual(["run the tests"]);
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({
sessionUpdate: "async_task_state_update",
asyncTaskId: "bg1",
state: "stopped",
});
});
it.each(["server_tool_use", "mcp_tool_use"])(
"restores a background task launched by a %s block",
async (type) => {
const launch = record({
...bashLaunch[1],
message: {
role: "assistant",
content: [{ type, id: "toolu_bash", name: "Bash", input: { command: "npm test" } }],
},
});
const updates = await replay([
bashLaunch[0],
launch,
bashLaunch[2],
notification("completed"),
]);
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
"async_task_spawned",
"async_task_state_update",
]);
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "completed" });
},
);
it("routes a task to the session of its launching tool even when the root receives the stop", async () => {
const updates = await replay(
[
...bashLaunch.map((message, index) =>
index === 0 ? message : record({ ...message, parent_tool_use_id: "toolu_agent" }),
),
notification("completed"),
],
{
subagents: {},
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
} as ClientCapabilities,
);
const tasks = updates.filter(({ update }) => update.sessionUpdate.startsWith("async_task_"));
expect(tasks).toHaveLength(2);
expect(tasks.map(({ sessionId }) => sessionId)).toEqual([
"s1:replay-subagent:toolu_agent",
"s1:replay-subagent:toolu_agent",
]);
});
it("keeps the user text next to an embedded notification", async () => {
const updates = await replay([
...bashLaunch,
record({
type: "user",
uuid: "u3",
message: {
role: "user",
content: [
{ type: "text", text: notificationText("failed") },
{ type: "text", text: "why did it fail?" },
],
},
}),
]);
expect(userChunks(updates)).toEqual(["run the tests", "why did it fail?"]);
expect(JSON.stringify(updates)).not.toContain("task-notification");
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "failed" });
});
it("sends nothing for a task that no tool call in the history started", async () => {
const updates = await replay([
record({ type: "user", uuid: "u1", message: { role: "user", content: "hi" } }),
notification("completed"),
]);
expect(userChunks(updates)).toEqual(["hi"]);
expect(asyncTaskUpdates(updates)).toEqual([]);
});
it("sends one terminal state for a repeated notification", async () => {
const updates = await replay([
...bashLaunch,
notification("completed"),
notification("completed", { uuid: "notification-again" }),
]);
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
"async_task_spawned",
"async_task_state_update",
]);
});
it("restores the notified state of a background Agent after its launch result", async () => {
const updates = await replay(
[
record({
type: "assistant",
uuid: "a1",
message: {
id: "m1",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_agent",
name: "Agent",
input: { description: "Explore", prompt: "Look around", run_in_background: true },
},
],
},
}),
record({
type: "user",
uuid: "u2",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_agent", content: "Async agent launched." },
],
},
}),
record({
type: "user",
uuid: "u3",
message: { role: "user", content: notificationText("killed", "toolu_agent") },
origin: { kind: "task-notification" },
}),
],
{
subagents: {},
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
} as ClientCapabilities,
);
expect(userChunks(updates)).toEqual([]);
expect(asyncTaskUpdates(updates)).toEqual([]);
expect(updates.at(-1)?.update).toMatchObject({
sessionUpdate: "subagent_state_update",
subagentSessionId: "s1:replay-subagent:toolu_agent",
state: "cancelled",
});
});
it("keeps a task-notification delivery with a subkind as a prompt", async () => {
const updates = await replay([
record({
type: "user",
uuid: "u1",
message: { role: "user", content: "Run the nightly report" },
origin: { kind: "task-notification", subkind: "scheduled-trigger" },
}),
]);
expect(userChunks(updates)).toEqual(["Run the nightly report"]);
});
});
describe("meta messages from other agents, sessions and channels", () => {
// The shape SDK 0.3.284 getSessionMessages returns for a host-injected peer
// message (live probe): is_meta plus origin, content is the full framing.
@@ -16385,11 +16683,25 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
},
);
// The fresh query activates the same turn again; it is inserted once.
const inserted: string[] = [];
const startTurn = agent.startTurn.bind(agent);
vi.spyOn(agent, "startTurn").mockImplementation((params, events) =>
startTurn(params, {
...events,
inserted: (messageId) => {
inserted.push(messageId);
events.inserted(messageId);
},
}),
);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "plan" }],
});
expect(inserted).toHaveLength(1);
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/test" }),
expect.objectContaining({ publicSessionId: "test-session", permissionMode: "auto" }),

513
src/tests/acp-v2.test.ts Normal file
View File

@@ -0,0 +1,513 @@
/**
* The experimental draft ACP v2 surface, driven through the protocol router
* with the SDK's own v1 and v2 client apps. The v2 client app validates every
* response, session update, and elicitation it receives against the v2
* schema, so a malformed v2 message fails the test.
*/
import { mkdtemp, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as v1 from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import type { AuthStatusUpdateNotification } from "../auth-status.js";
import { acpProtocolRouter } from "../serve.js";
import { clientSupportsNotices } from "../session-notices.js";
import { v2SessionUpdate } from "../v2/session-update.js";
import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js";
import packageJson from "../../package.json" with { type: "json" };
const execFileSpy = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: execFileSpy };
});
/** What the mocked Claude Agent SDK saw and answers. Reset before each test. */
const sdk = vi.hoisted(() => ({
queryOptions: [] as Options[],
mcpServerStatus: async (): Promise<Array<{ name: string; status: string }>> => [],
mcpAuthenticate: async (
_serverName: string,
): Promise<{ authUrl?: string; requiresUserAction: boolean; callbackExpected: boolean }> => ({
requiresUserAction: false,
callbackExpected: false,
}),
}));
const sdkDefaults = { ...sdk };
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
return {
...actual,
query: ({ options }: { options: Options }) => {
sdk.queryOptions.push(options);
return makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet",
description: "Fast",
supportsAutoMode: true,
},
],
}),
supportedCommands: async () => [
{ name: "review", description: "Review a change", argumentHint: "<pull request>" },
],
mcpServerStatus: () => sdk.mcpServerStatus(),
mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName),
});
},
listSessions: vi.fn(async () => [
{
sessionId: "11111111-2222-4333-8444-555555555555",
cwd: "/workspace/project",
summary: "Fix the build",
lastModified: Date.UTC(2026, 9, 1),
},
]),
getSessionMessages: vi.fn(async () => []),
deleteSession: vi.fn(async () => {}),
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
const CLI_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "user@example.com",
subscriptionType: "max",
});
const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" };
const agents: ClaudeAcpAgent[] = [];
/** An empty Claude config directory and a session cwd, so no test reads the real ones. */
let tempDir: string;
let cwd: string;
beforeEach(async () => {
// `claude auth status --json` reports a subscription, `claude auth logout` succeeds.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
callback(null, { stdout: args[1] === "status" ? CLI_SUBSCRIPTION : "", stderr: "" });
});
tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-"));
cwd = await mkdtemp(path.join(tempDir, "project-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude"));
Object.assign(sdk, sdkDefaults, { queryOptions: [] });
});
afterEach(async () => {
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
execFileSpy.mockReset();
vi.unstubAllEnvs();
await rm(tempDir, { recursive: true, force: true });
});
/** Connects a fresh router to a client, and returns the client end of the stream. */
function connectRouter(): v2.Stream {
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({
readable: toAgent.readable,
writable: toClient.writable,
});
return { readable: toClient.readable, writable: toAgent.writable };
}
/**
* A v2 client app that collects every `_auth/status_update`, session update,
* and elicitation it receives, and accepts every elicitation.
*/
function v2Client() {
const authUpdates: AuthStatusUpdateNotification[] = [];
const sessionUpdates: v2.UpdateSessionNotification[] = [];
const elicitations: v2.CreateElicitationRequest[] = [];
const completedElicitations: v2.CompleteElicitationNotification[] = [];
let notifyAuthUpdate = () => {};
const app = v2
.client({ name: V2_CLIENT_INFO.name })
.onNotification(
"_auth/status_update",
(params) => params as AuthStatusUpdateNotification,
({ params }) => {
authUpdates.push(params);
notifyAuthUpdate();
},
)
.onNotification(v2.methods.client.session.update, ({ params }) => {
sessionUpdates.push(params);
})
.onRequest(v2.methods.client.elicitation.create, ({ params }) => {
elicitations.push(params);
return { action: "accept" };
})
.onNotification(v2.methods.client.elicitation.complete, ({ params }) => {
completedElicitations.push(params);
});
/** Resolves once the client has received `count` auth status updates. */
const authUpdate = (count: number) =>
new Promise<AuthStatusUpdateNotification>((resolve) => {
notifyAuthUpdate = () => {
if (authUpdates.length >= count) {
resolve(authUpdates[count - 1]);
}
};
notifyAuthUpdate();
});
/** The session updates of one kind that the client has received. */
const updates = <Kind extends string>(kind: Kind) =>
sessionUpdates
.map((notification) => notification.update)
.filter(
(update): update is Extract<v2.SessionUpdate, { sessionUpdate: Kind }> =>
update.sessionUpdate === kind,
);
return { app, authUpdate, sessionUpdates, updates, elicitations, completedElicitations };
}
async function initializeV2(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) {
return agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities,
});
}
describe("ACP protocol routing", () => {
it("serves the unchanged v1 handshake to a v1 client", async () => {
let authUpdated!: () => void;
const authUpdate = new Promise<void>((resolve) => (authUpdated = resolve));
const response = await v1
.client({ name: "v1-test-client" })
.onNotification(
"_auth/status_update",
(params) => params,
() => authUpdated(),
)
.connectWith(connectRouter() as unknown as v1.Stream, async (agent) => {
const response = await agent.request(v1.methods.agent.initialize, {
protocolVersion: v1.PROTOCOL_VERSION,
clientCapabilities: { auth: { terminal: true } },
});
await authUpdate;
return response;
});
expect(response.protocolVersion).toBe(1);
expect(response.agentCapabilities?.loadSession).toBe(true);
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
expect(response.authMethods?.length).toBeGreaterThan(0);
for (const method of response.authMethods ?? []) {
expect(method).toHaveProperty("id");
}
});
it("serves the v2 handshake to a v2 client", async () => {
const { app, authUpdate } = v2Client();
const response = await app.connectWith(connectRouter(), async (agent) => {
const response = await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities: { auth: { terminal: {} } },
});
await authUpdate(1);
return response;
});
expect(response.protocolVersion).toBe(2);
expect(response.info).toEqual({
name: packageJson.name,
title: "Claude Agent",
version: packageJson.version,
});
// The whole v2 session baseline and the providers methods are not served yet.
expect(response.capabilities?.session).toBeUndefined();
expect(response.capabilities?.providers).toBeUndefined();
expect(response.authMethods?.length).toBeGreaterThan(0);
for (const method of response.authMethods ?? []) {
expect(method).not.toHaveProperty("id");
expect(method).toMatchObject({ methodId: expect.any(String), type: "terminal" });
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
}
});
it("rejects v2 prompts, which the v2 surface does not serve yet", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await expect(
agent.request(v2.methods.agent.session.prompt, {
sessionId,
prompt: [{ type: "text", text: "hello" }],
}),
).rejects.toMatchObject({ code: -32601 });
await authUpdate(1);
});
});
});
describe("ACP v2 sessions", () => {
it("creates a session with v2 MCP servers, config options, and commands", async () => {
const client = v2Client();
const response = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const response = await agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [
{ type: "stdio", name: "files", command: "/usr/local/bin/mcp-files" },
{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" },
],
});
await vi.waitFor(() => expect(client.updates("available_commands_update")).toHaveLength(1));
await client.authUpdate(1);
return response;
});
// A v2 stdio server has a `type`, which v1 omits, and may omit its lists.
expect(sdk.queryOptions[0].mcpServers).toMatchObject({
files: { type: "stdio", command: "/usr/local/bin/mcp-files", args: [] },
linear: { type: "http", url: "https://mcp.linear.app/mcp" },
});
expect(response).not.toHaveProperty("modes");
expect(response.configOptions?.map((option) => option.configId)).toEqual(["mode", "model"]);
for (const option of response.configOptions ?? []) {
expect(option).not.toHaveProperty("id");
}
expect(response.configOptions?.[0]).toMatchObject({ category: "mode", type: "select" });
expect(client.updates("available_commands_update")[0].availableCommands).toEqual([
{
name: "review",
description: "Review a change",
input: { type: "text", hint: "<pull request>" },
},
]);
});
it("rejects an MCP transport that v1 cannot express", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await expect(
agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "_custom", name: "custom" }],
}),
).rejects.toMatchObject({ code: -32602 });
await authUpdate(1);
});
expect(sdk.queryOptions).toHaveLength(0);
});
it("lists, resumes, closes, and deletes sessions", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
expect(await agent.request(v2.methods.agent.session.list, {})).toEqual({
sessions: [
{
sessionId: "11111111-2222-4333-8444-555555555555",
cwd: "/workspace/project",
title: "Fix the build",
updatedAt: "2026-10-01T00:00:00.000Z",
},
],
});
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.close, { sessionId });
// A closed session is resumed from its transcript.
const resumed = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
expect(sdk.queryOptions.at(-1)?.resume).toBe(sessionId);
expect(resumed).not.toHaveProperty("modes");
expect(resumed.configOptions?.[0].configId).toBe("mode");
await expect(
agent.request(v2.methods.agent.session.resume, {
sessionId,
cwd,
replayFrom: { type: "start" },
}),
).rejects.toMatchObject({ code: -32602 });
await agent.request(v2.methods.agent.session.delete, { sessionId });
await authUpdate(1);
});
});
it("sets a config option with a v2 value and reports modes only as config options", async () => {
const client = v2Client();
const response = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const response = await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "mode",
type: "id",
value: "plan",
});
await client.authUpdate(1);
return response;
});
expect(response.configOptions[0]).toMatchObject({ configId: "mode", currentValue: "plan" });
expect(client.updates("current_mode_update")).toEqual([]);
});
it("tells the agent that a v2 client takes notices", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await authUpdate(1);
});
expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true);
});
it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => {
let statusCall = 0;
sdk.mcpServerStatus = async () =>
statusCall++ === 0
? [{ name: "linear", status: "needs-auth" }]
: [{ name: "linear", status: "connected" }];
sdk.mcpAuthenticate = async () => ({
authUrl: "https://example.com/oauth/authorize",
requiresUserAction: true,
callbackExpected: true,
});
const client = v2Client();
const sessionId = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent, { elicitation: { url: {} } });
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
});
await vi.waitFor(() => expect(client.completedElicitations).toHaveLength(1));
await client.authUpdate(1);
return sessionId;
});
expect(client.elicitations).toEqual([
{
mode: "url",
sessionId,
message: "Authenticate with MCP server linear",
url: "https://example.com/oauth/authorize",
elicitationId: expect.stringMatching(/^mcp-oauth-/),
},
]);
expect(client.completedElicitations).toEqual([
{ elicitationId: (client.elicitations[0] as { elicitationId: string }).elicitationId },
]);
});
});
describe("ACP v2 session translation", () => {
it("renames the group id of grouped select options", () => {
expect(
v2ConfigOptions([
{
id: "model",
name: "Model",
type: "select",
currentValue: "sonnet",
options: [{ group: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
},
]),
).toEqual([
{
configId: "model",
name: "Model",
type: "select",
currentValue: "sonnet",
options: [{ groupId: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
},
]);
});
it("rejects a config option value type that v1 cannot express", () => {
expect(() =>
v1SetSessionConfigOptionRequest({
sessionId: "s",
configId: "c",
type: "_range",
value: 3,
}),
).toThrow("Config option values of type _range are not supported");
});
it("fails on session updates that it does not translate yet", () => {
expect(() =>
v2SessionUpdate({ sessionUpdate: "tool_call", toolCallId: "t", title: "Read" }),
).toThrow("does not translate tool_call session updates yet");
});
});
describe("ACP v2 auth", () => {
it("pushes the auth status of the connection after initialize", async () => {
const { app, authUpdate } = v2Client();
const update = await app.connectWith(connectRouter(), async (agent) => {
await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
});
return authUpdate(1);
});
expect(update.authStatus).toMatchObject({
kind: "account",
account: { email: "user@example.com" },
});
});
it("logs in to a gateway with auth/login and out with auth/logout", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
const response = await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities: { auth: { _meta: { gateway: true } } },
});
expect(response.authMethods).toContainEqual({
methodId: "gateway",
type: "agent",
name: "Custom model gateway",
description: "Use a custom gateway to authenticate and access models",
_meta: { gateway: { protocol: "anthropic" } },
});
await authUpdate(1);
await agent.request(v2.methods.agent.auth.login, {
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gateway.example.com", headers: {} } },
});
expect((await authUpdate(2)).authStatus).toMatchObject({
kind: "gateway",
detail: "gateway.example.com",
});
await agent.request(v2.methods.agent.auth.logout, {});
expect(execFileSpy).toHaveBeenCalledWith(
expect.any(String),
["auth", "logout"],
expect.any(Function),
);
expect((await authUpdate(3)).authStatus).toMatchObject({ kind: "account" });
});
});
});

View File

@@ -18,6 +18,8 @@ let sessionMessages: Record<string, unknown>[];
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
let initModels: Record<string, unknown>[] | undefined;
let setModelImpl: ((model: string) => Promise<void>) | undefined;
let interruptImpl: (() => Promise<undefined>) | undefined;
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
let mcpAuthenticateImpl: (serverName: string) => Promise<{
authUrl?: string;
@@ -34,8 +36,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.options;
return makeMockQuery({
interrupt: async () => undefined,
close: () => {},
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
close: () => closeSpy(),
initializationResult: async () => ({
models: initModels ?? [
{
@@ -86,6 +88,8 @@ describe("createSession options merging", () => {
vi.mocked(getSessionMessages).mockClear();
initModels = undefined;
setModelImpl = undefined;
interruptImpl = undefined;
closeSpy = vi.fn<() => void>();
mcpServerStatusResult = async () => [];
mcpAuthenticateImpl = async () => ({
requiresUserAction: false,
@@ -847,6 +851,36 @@ describe("createSession options merging", () => {
});
});
describe("session/close right after session/new", () => {
it("answers at once and closes the query", async () => {
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.closeSession({ sessionId });
expect(closeSpy).toHaveBeenCalledOnce();
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
undefined,
);
});
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
// SDK control requests are serialized. On a fresh session the background
// getContextUsage is still in flight, so a real CLI answers an interrupt
// only after it, which took ~3s and timed out the client's close.
contextUsageResult = () => new Promise<never>(() => {});
interruptImpl = () => new Promise<never>(() => {});
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const closed = await Promise.race([
agent.closeSession({ sessionId }).then(() => "closed"),
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
]);
expect(closed).toBe("closed");
expect(closeSpy).toHaveBeenCalledOnce();
});
});
describe("context window seeding", () => {
function sessionFor(sessionId: string) {
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];

View File

@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { applyPatch } from "diff";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -260,13 +260,32 @@ describe("approval patch previews", () => {
.join("");
const filePath = await temporaryFile(oldText);
// 200 changed lines. A diff that runs out of its time budget gives no
// patch, and patchText then throws.
const patch = patchText(
await previewPatchContent("Write", { file_path: filePath, content: newText }),
);
// Verify the 200-line patch independently of the runner's speed. The
// timeout fallback is exercised separately with an advancing clock.
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
try {
const patch = patchText(
await previewPatchContent("Write", { file_path: filePath, content: newText }),
);
expect(applyPatch(oldText, patch)).toBe(newText);
} finally {
clock.mockRestore();
}
});
expect(applyPatch(oldText, patch)).toBe(newText);
it("falls back to the standard Write diff when the patch budget expires", async () => {
const oldText = "before\n";
const newText = "after\n";
const filePath = await temporaryFile(oldText);
let now = 0;
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000));
try {
expect(await previewPatchContent("Write", { file_path: filePath, content: newText })).toEqual(
[{ type: "diff", path: filePath, oldText, newText }],
);
} finally {
clock.mockRestore();
}
});
it("builds patches that turn the old text into the new text", async () => {

View File

@@ -0,0 +1,321 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
import { Pushable } from "../utils.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
const sessionId = "test-session";
const toolCallId = "unfinished-bash";
afterEach(() => clearHookCallbacks(sessionId));
describe("incomplete foreground tools", () => {
it.each(["result", "EOF"] as const)(
"fails the tool and prompt at %s even after the input stream closes",
async (ending) => {
const { prompt, updates, logError } = createTestSession((input) =>
unfinishedToolMessages(input, ending),
);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates).toContainEqual(
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId,
status: "failed",
}),
);
expect(logError).toHaveBeenCalledExactlyOnceWith(
expect.stringMatching(
/Session test-session, turn .+, stopReason=end_turn:.*unfinished-bash/,
),
);
const failed = updates.find((u) => u.toolCallId === toolCallId && u.status === "failed");
expect(failed.content[0].content.text).toContain("without returning a result");
expect(failed).not.toHaveProperty("rawOutput");
expect(hasHookCallback(toolCallId)).toBe(false);
},
);
it("detects a tool emitted before the user echo", async () => {
const { prompt } = createTestSession(toolBeforeUserEchoMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
});
it("detects a permission tool whose streamed tool_use never arrived", async () => {
const { prompt, updates } = createTestSession(permissionOnlyToolMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates).toContainEqual(expect.objectContaining({ toolCallId, status: "failed" }));
});
it("allows a completed tool with a late PostToolUse hook", async () => {
const { prompt, logError } = createTestSession(completedToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(hasHookCallback(toolCallId)).toBe(true);
await createPostToolUseHook()(
{
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command: "echo test" },
tool_response: { stdout: "test", stderr: "", interrupted: false },
} as any,
toolCallId,
{ signal: new AbortController().signal },
);
expect(logError).not.toHaveBeenCalled();
});
it("allows a tool explicitly handed off to a background task", async () => {
const { prompt, updates } = createTestSession(backgroundToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
});
it("does not claim a subagent's tool as a foreground tool", async () => {
const { prompt } = createTestSession(subagentToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
});
it("does not treat a request to run in the background as a confirmed handoff", async () => {
const { prompt } = createTestSession(unconfirmedBackgroundToolMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
});
it("waits for deferred settlement before checking unfinished tools", async () => {
const { prompt, logError } = createTestSession(deferredSettlementMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(logError).not.toHaveBeenCalled();
});
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates.filter((u) => u.status === "failed").map((u) => u.toolCallId)).toEqual([
toolCallId,
"second-tool",
]);
expect(agent.sessions[sessionId].emittedToolCalls.size).toBe(0);
expect(agent.sessions[sessionId].toolUseCache).toEqual({});
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
});
it("preserves cancellation and does not attribute its unfinished tool to the next turn", async () => {
const { prompt, updates } = createTestSession(cancelledThenSuccessfulTurnMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "cancelled" });
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
});
it("preserves an existing SDK failure", async () => {
const { prompt } = createTestSession(sdkFailureMessages);
await expect(prompt()).rejects.toThrow("original failure");
});
});
function toolStart(id = toolCallId, parent: string | null = null) {
return {
type: "stream_event",
session_id: sessionId,
uuid: "stream-message",
parent_tool_use_id: parent,
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id, name: "Bash", input: {} },
},
};
}
function toolResult(id = toolCallId) {
return {
type: "user",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: id, content: "done" }],
},
};
}
function createTestSession(
createSdkMessages: (input: Pushable<any>, agent: ClaudeAcpAgent) => AsyncGenerator<any>,
) {
const updates: any[] = [];
const logError = vi.fn();
const input = new Pushable<any>();
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => {
updates.push(notification.update);
},
requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } }),
} as unknown as AcpClient,
{ log: () => {}, error: logError },
);
agent.sessions[sessionId] = mockSessionState({
input,
// The real consumer reads the scripted SDK events from this generator.
query: wrapQuery(createSdkMessages(input, agent)),
});
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] });
return { agent, updates, logError, prompt };
}
// Wait for prompt() to submit input, then echo it as the SDK would.
async function* echoNextPrompt(input: Pushable<any>) {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
}
async function* unfinishedToolMessages(input: Pushable<any>, ending: "result" | "EOF") {
yield* echoNextPrompt(input);
yield toolStart();
yield {
...toolStart(),
event: { type: "content_block_stop", index: 0 },
};
// The input block closed, but no tool_result arrived. Returning ends the stream.
if (ending === "result") yield successfulResultMessage();
}
async function* toolBeforeUserEchoMessages(input: Pushable<any>) {
const { value } = await input[Symbol.asyncIterator]().next();
yield toolStart();
yield userEcho(value);
yield successfulResultMessage();
}
async function* permissionOnlyToolMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
yield* echoNextPrompt(input);
await agent.canUseTool(sessionId)("Bash", { command: "echo test" }, {
toolUseID: toolCallId,
signal: new AbortController().signal,
suggestions: [],
} as any);
yield successfulResultMessage();
}
async function* completedToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield toolResult();
yield successfulResultMessage();
}
async function* backgroundToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield {
type: "system",
subtype: "task_started",
session_id: sessionId,
task_id: "background-shell",
tool_use_id: toolCallId,
description: "dev server",
};
yield successfulResultMessage();
}
async function* subagentToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart("child-tool", "parent-agent");
yield successfulResultMessage();
}
async function* unconfirmedBackgroundToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield {
type: "assistant",
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [
{
type: "tool_use",
id: toolCallId,
name: "Bash",
input: { command: "sleep 100", run_in_background: true },
},
],
},
};
yield successfulResultMessage();
}
async function* deferredSettlementMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield {
type: "system",
subtype: "task_started",
session_id: sessionId,
task_id: "child",
tool_use_id: "parent-agent",
subagent_type: "Explore",
description: "investigate",
};
// The result arrives while the subagent is still running, so settlement waits.
yield successfulResultMessage();
yield toolResult();
yield {
type: "system",
subtype: "task_notification",
session_id: sessionId,
task_id: "child",
tool_use_id: "parent-agent",
status: "completed",
summary: "done",
};
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
const messages = input[Symbol.asyncIterator]();
yield userEcho((await messages.next()).value);
yield toolStart();
yield toolStart("second-tool");
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
// Keep the SDK stream open for the next prompt in the same session.
yield userEcho((await messages.next()).value);
yield successfulResultMessage();
}
async function* cancelledThenSuccessfulTurnMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
const messages = input[Symbol.asyncIterator]();
yield userEcho((await messages.next()).value);
yield toolStart();
await agent.cancel({ sessionId });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
yield userEcho((await messages.next()).value);
yield successfulResultMessage();
}
async function* sdkFailureMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield successfulResultMessage({
subtype: "error_during_execution",
is_error: true,
errors: ["original failure"],
});
}

View File

@@ -0,0 +1,400 @@
/**
* The turn lifecycle that the agent reports through `TurnEvents`, which each
* ACP version maps to its own messages. A scripted SDK query stands in for
* Claude Code: it echoes each prompt, then runs the turn's script.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import type { TurnEvents } from "../turn-events.js";
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
import { randomUUID } from "node:crypto";
const mockQuery = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: mockQuery,
}));
// The agent reads the CLI's auth status on each prompt; keep it off the real CLI.
vi.mock("node:child_process", async () => ({
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
execFile: (...args: unknown[]) =>
(args[args.length - 1] as (error: Error) => void)(new Error("no CLI in tests")),
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
type QueryOptions = {
sessionId?: string;
canUseTool: (
toolName: string,
input: Record<string, unknown>,
extra: Record<string, unknown>,
) => Promise<unknown>;
};
/** The SDK side of one turn, after the echo of its prompt. */
type TurnScript = (options: QueryOptions) => AsyncGenerator<Record<string, unknown>>;
function result(sessionId: string) {
return {
type: "result",
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "done",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: sessionId,
};
}
/** Scripts the query of the next session: each prompt runs the next turn script. */
function scriptTurns(turns: TurnScript[], end: "stay open" | "end stream" = "stay open") {
const promptUuids: string[] = [];
mockQuery.mockImplementation(
({ prompt, options }: { prompt: AsyncIterable<any>; options: QueryOptions }) => {
const sessionId = options.sessionId ?? "session";
async function* run() {
const input = prompt[Symbol.asyncIterator]();
for (const turn of turns) {
const { value, done } = await input.next();
if (done) return;
promptUuids.push(value.uuid);
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: sessionId,
isReplay: true,
};
for await (const message of turn(options)) yield message;
}
if (end === "stay open") await input.next();
}
return Object.assign(run(), {
initializationResult: async () => ({
models: [{ value: "default", displayName: "Default", description: "" }],
}),
setModel: async () => {},
setPermissionMode: async () => {},
supportedCommands: async () => [],
mcpServerStatus: async () => [],
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
close: () => {},
interrupt: async () => {},
stopTask: async () => {},
});
},
);
return promptUuids;
}
/** Records the events of one turn as readable strings. */
function recordEvents() {
const log: string[] = [];
const done = Promise.withResolvers<void>();
const events: TurnEvents = {
inserted: (messageId) => log.push(`inserted ${messageId}`),
awaitingUser: () => log.push("awaitingUser"),
resumed: () => log.push("resumed"),
ended: (outcome) => {
log.push(`ended ${outcome.stopReason}`);
done.resolve();
},
failed: (error) => {
log.push(`failed ${error instanceof Error ? error.message : String(error)}`);
done.resolve();
},
};
return { log, events, done: done.promise };
}
describe("turn events", () => {
let agent: ClaudeAcpAgent;
let permissionRequests: RequestPermissionRequest[];
/** The user answers a request for this tool call only once it resolves. */
let backgroundAnswer: PromiseWithResolvers<void>;
/** Sees each session update; the agent waits until it returns. */
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
beforeEach(async () => {
vi.resetModules();
permissionRequests = [];
backgroundAnswer = Promise.withResolvers<void>();
onSessionUpdate = () => {};
const { ClaudeAcpAgent } = await import("../acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
extNotification: async () => {},
requestPermission: async (request: RequestPermissionRequest) => {
permissionRequests.push(request);
await new Promise((resolve) => setTimeout(resolve, 1));
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
const allow = request.options.find((option) => option.kind === "allow_once");
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
},
} as unknown as AcpClient);
});
afterEach(async () => {
await agent.dispose();
});
async function newSession() {
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
return sessionId;
}
const prompt = (sessionId: string, text: string) => ({
sessionId,
prompt: [{ type: "text" as const, text }],
});
it("reports a turn as inserted under the uuid of its user message, then ended", async () => {
const promptUuids = scriptTurns([
async function* (options) {
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "hello"), turn.events);
await turn.done;
expect(turn.log).toEqual([`inserted ${promptUuids[0]}`, "ended end_turn"]);
});
it("reports waiting on a permission request, then resuming", async () => {
scriptTurns([
async function* (options) {
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(permissionRequests).toHaveLength(1);
expect(turn.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
});
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
const first = recordEvents();
const second = recordEvents();
let backgroundRequest: Promise<unknown> | undefined;
let atStart: string[] = [];
let afterOwnAnswer: string[] | undefined;
scriptTurns([
async function* (options) {
yield result(options.sessionId!);
await first.done;
// A request that no active turn opened, such as a background task's.
backgroundRequest = options.canUseTool(
"Bash",
{ command: "sleep 60" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
},
async function* (options) {
atStart = [...second.log];
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
afterOwnAnswer = [...second.log];
await backgroundRequest;
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
await agent.startTurn(prompt(sessionId, "start a background task"), first.events);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "list files"), second.events);
await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined());
backgroundAnswer.resolve();
await second.done;
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
// Waiting from insertion, through its own answered request, until the
// earlier request is answered too.
expect(atStart.slice(1)).toEqual(["awaitingUser"]);
expect(afterOwnAnswer!.slice(1)).toEqual(["awaitingUser"]);
expect(second.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
});
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {
const release = Promise.withResolvers<void>();
scriptTurns([
async function* (options) {
// Claude Code is busy with the first prompt when the cancel arrives.
yield {
type: "assistant",
message: {
id: "msg_working",
type: "message",
role: "assistant",
model: "default",
content: [{ type: "text", text: "Working on it" }],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
await release.promise;
},
]);
agent.forceCancelGraceMs = 10;
const sessionId = await newSession();
const first = recordEvents();
const queued = recordEvents();
await agent.startTurn(prompt(sessionId, "first"), first.events);
await vi.waitFor(() => expect(first.log).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "second"), queued.events);
await agent.cancel({ sessionId });
await Promise.all([first.done, queued.done]);
release.resolve();
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended cancelled"]);
expect(queued.log).toEqual(["ended cancelled"]);
});
it("does not insert a turn that a cancel ended while the previous turn was handed off", async () => {
scriptTurns([
async function* (options) {
// The turn ends with a tool call that never got its result.
yield {
type: "assistant",
message: {
id: "msg_tool",
type: "message",
role: "assistant",
model: "default",
content: [
{ type: "tool_use", id: "toolu_open", name: "Bash", input: { command: "ls" } },
],
stop_reason: "tool_use",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
},
async function* () {},
]);
agent.forceCancelGraceMs = 10;
// The hand-off to the second turn fails the open tool call; hold that update.
const failedToolCall = Promise.withResolvers<void>();
const releaseUpdate = Promise.withResolvers<void>();
onSessionUpdate = async ({ update }) => {
if (update.sessionUpdate === "tool_call_update" && update.status === "failed") {
failedToolCall.resolve();
await releaseUpdate.promise;
}
};
const sessionId = await newSession();
const first = recordEvents();
const second = recordEvents();
await agent.startTurn(prompt(sessionId, "first"), first.events);
await vi.waitFor(() => expect(first.log).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "second"), second.events);
await failedToolCall.promise;
const cancelling = agent.cancel({ sessionId });
await vi.waitFor(() => expect(second.log).toEqual(["ended cancelled"]));
releaseUpdate.resolve();
await Promise.all([cancelling, first.done]);
// The hand-off then activates the second turn in promise continuations,
// which all run before a timer.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(second.log).toEqual(["ended cancelled"]);
});
it("fails a queued turn without inserting it when the query ends", async () => {
scriptTurns(
[
async function* (options) {
yield result(options.sessionId!);
},
],
"end stream",
);
const sessionId = await newSession();
const first = recordEvents();
const queued = recordEvents();
await agent.startTurn(prompt(sessionId, "first"), first.events);
await agent.startTurn(prompt(sessionId, "second"), queued.events);
await Promise.all([first.done, queued.done]);
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended end_turn"]);
expect(queued.log).toEqual([expect.stringMatching(/^failed /)]);
});
it("rejects a prompt that it refuses before starting a turn, and reports nothing", async () => {
const turn = recordEvents();
await expect(agent.startTurn(prompt("no-such-session", "hello"), turn.events)).rejects.toThrow(
"Session not found",
);
expect(turn.log).toEqual([]);
});
});

67
src/turn-events.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* A prompt turn as the adapter understands it, independent of the ACP version.
*
* The agent reports each turn through {@link TurnEvents}, and each ACP version
* maps the events to its own messages. ACP v1 answers `session/prompt` when the
* turn ends or fails. ACP v2 answers it when the prompt is inserted, and
* reports the rest as session state.
*
* The agent calls these synchronously while it updates its own state, so a
* handler must not throw, nor call back into the agent before it returns.
*/
export interface TurnEvents {
/**
* Claude Code took the prompt in as the user message `messageId` of the
* conversation. Reported at most once per turn, and never after
* {@link ended} or {@link failed}. For a prompt Claude Code echoes, that is
* the echo; for a command it does not echo (such as `/compact`), it is the
* command's result, after any output the command streamed.
*/
inserted(messageId: string): void;
/**
* The turn waits for the user: a permission request or a question is open in
* the session. Requests do not say which prompt they are for, so this
* includes one opened before the turn was inserted. Only reported after
* {@link inserted}.
*/
awaitingUser(): void;
/**
* The turn works again: no request is open any more. A turn that ends or
* fails while it waits reports that instead.
*/
resumed(): void;
/**
* The turn ended. A queued turn that is cancelled before it is inserted
* ends too, without {@link inserted}; Claude Code may still run its prompt.
*/
ended(outcome: TurnOutcome): void;
/**
* The turn failed. Without {@link inserted} before, Claude Code was not seen
* taking the prompt in.
*/
failed(error: unknown): void;
}
/** Why a turn ended. Every ACP version has these reasons. */
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
/** How a turn ended. */
export type TurnOutcome = {
stopReason: StopReason;
/** The tokens that the main agent loop of the turn used. */
usage?: TurnUsage;
/**
* Extension data for the client, such as the quota breakdown of the turn
* and a terminal session failure.
*/
_meta?: Record<string, unknown>;
};
export type TurnUsage = {
totalTokens: number;
inputTokens: number;
outputTokens: number;
thoughtTokens?: number;
cachedReadTokens?: number;
cachedWriteTokens?: number;
};

132
src/v2/agent.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* The experimental draft ACP v2 surface of the adapter.
*
* `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2
* request into the v1 request that the agent serves, and each v1 message that
* the agent sends into its v2 form, so v1 and v2 share one implementation.
*/
import type {
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
import {
v1NewSessionRequest,
v1ResumeSessionRequest,
v1SetSessionConfigOptionRequest,
v2ConfigOptions,
v2NewSessionResponse,
v2ResumeSessionResponse,
} from "./session.js";
import { v2SessionUpdate } from "./session-update.js";
/**
* The ACP v2 surface, for one connection.
*
* As in `v1AgentApp`, the agent of the connection is created when the
* connection opens, before the connection processes any inbound message.
* `onAgent` receives it for the owner of the process (shutdown).
*/
export function v2AgentApp(
logger: Logger | undefined,
onAgent: (agent: ClaudeAcpAgent) => void,
): v2.AgentApp {
let agent!: ClaudeAcpAgent;
return v2
.agent({ name: "claude-code-acp" })
.onConnect((connection) => {
agent = new ClaudeAcpAgent(new V2ClientConnection(connection.client), logger);
onAgent(agent);
})
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))),
)
.onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params))
.onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params))
.onRequest(v2.methods.agent.session.new, async ({ params }) =>
v2NewSessionResponse(await agent.newSession(v1NewSessionRequest(params))),
)
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
.onRequest(v2.methods.agent.session.resume, async ({ params }) =>
v2ResumeSessionResponse(await agent.resumeSession(v1ResumeSessionRequest(params))),
)
.onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params))
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
.onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => {
const { configOptions, ...response } = await agent.setSessionConfigOption(
v1SetSessionConfigOptionRequest(params),
);
return { ...response, configOptions: v2ConfigOptions(configOptions) };
});
}
/**
* The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of
* the agent as v2 messages.
*
* The v2 surface does not serve prompts yet, so permission requests, which
* only a prompt turn makes, still reject.
*/
class V2ClientConnection implements AcpClient {
constructor(private readonly ctx: v2.AgentContext) {}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
if (!isExtensionMethod(method)) {
return Promise.reject(new Error(`${method} is not an ACP extension method`));
}
return this.ctx.notify(method, params);
}
async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise<void> {
const v2Update = v2SessionUpdate(update);
if (v2Update) {
await this.ctx.notify(v2.methods.client.session.update, {
...notification,
update: v2Update,
});
}
}
requestPermission(): Promise<never> {
return Promise.reject(
new Error("The ACP v2 surface does not serve session/request_permission yet"),
);
}
// Elicitation is the same in v1 and v2.
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(
v2.methods.client.elicitation.create,
// The v1 types accept a property schema with any tag; the v2 types accept
// one only in a received value. As on v1, an MCP server's schema is
// relayed here unchanged, so this cast does not check it.
params as v2.CreateElicitationRequest,
{ cancellationSignal: signal },
);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
}
// v2 has no client file system. The agent never calls these on v2, because
// `v1InitializeRequest` reports no `fs` capability.
readTextFile(): Promise<never> {
return Promise.reject(new Error("ACP v2 has no client file system"));
}
writeTextFile(): Promise<never> {
return Promise.reject(new Error("ACP v2 has no client file system"));
}
}
function isExtensionMethod(method: string): method is v2.ExtensionMethod {
return method.startsWith("_");
}

82
src/v2/initialize.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* The ACP v2 `initialize` handshake, expressed through the v1 handshake of
* `ClaudeAcpAgent`.
*
* The agent decides its capabilities and auth methods in v1 terms. The v2
* request becomes the v1 request that the agent reads, and the v1 response of
* the agent becomes the v2 response, so both protocol versions advertise from
* one source.
*/
import {
PROTOCOL_VERSION as V1_PROTOCOL_VERSION,
type AuthMethod,
type InitializeRequest,
type InitializeResponse,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
/** The v1 `initialize` request that a v2 client means. */
export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest {
const capabilities = request.capabilities;
return {
protocolVersion: V1_PROTOCOL_VERSION,
clientInfo: request.info,
clientCapabilities: {
// v2 has no client file system and no client terminals: the agent
// reads, writes, and runs commands itself.
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
// Every v2 client handles boolean config options and notices; v1 asks
// for a marker. With notices, the agent reports advisories as notices
// rather than as transcript messages.
session: { configOptions: { boolean: {} }, notices: {} },
// v1 marks terminal auth support with a boolean, v2 with an object.
auth: {
terminal: capabilities?.auth?.terminal != null,
...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}),
},
...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}),
...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}),
},
...(request._meta != null ? { _meta: request._meta } : {}),
};
}
/**
* The v2 `initialize` response for the v1 response of the agent.
*
* It advertises no `session` capability yet. A v2 agent that advertises
* `session` must serve the whole session baseline (`session/new`, `list`,
* `resume`, `close`, `prompt`, `cancel`, and `update`), and the v2 surface of
* the adapter does not serve it yet. Nor does it advertise the unstable
* `providers` methods, which the v2 surface does not serve yet either.
*/
export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse {
if (!response.agentInfo) {
throw new Error("ACP v2 requires agentInfo, and the agent reported none");
}
const meta = response.agentCapabilities?._meta;
return {
protocolVersion: v2.PROTOCOL_VERSION,
info: response.agentInfo,
// v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth
// methods must serve `auth/logout`.
capabilities: meta != null ? { _meta: meta } : {},
authMethods: (response.authMethods ?? []).map(v2AuthMethod),
...(response._meta != null ? { _meta: response._meta } : {}),
};
}
/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */
function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
if ("type" in method && method.type === "terminal") {
const { id, env, ...terminal } = method;
return {
...terminal,
methodId: id,
...(env ? { env: Object.entries(env).map(([name, value]) => ({ name, value })) } : {}),
};
}
const { id, ...agent } = method;
return { ...agent, methodId: id, type: "agent" };
}

38
src/v2/session-update.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* The v2 form of the session updates that `ClaudeAcpAgent` sends as v1
* updates.
*/
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
/**
* The v2 form of a v1 session update, or `undefined` when v2 has no
* counterpart and the update tells a v2 client nothing new.
*
* Update kinds that the v2 surface does not translate yet throw, so a gap is
* an error rather than a silently missing update.
*/
export function v2SessionUpdate(
update: AcpSessionNotification["update"],
): v2.SessionUpdate | undefined {
switch (update.sessionUpdate) {
case "available_commands_update":
return { ...update, availableCommands: v2AvailableCommands(update.availableCommands) };
case "config_option_update":
return { ...update, configOptions: v2ConfigOptions(update.configOptions) };
case "session_info_update":
case "usage_update":
case "notice":
return update;
case "current_mode_update":
// v2 has no modes: the mode is the `mode` config option. Every mode
// change also reaches the client as a `config_option_update` or in the
// `configOptions` of a `session/set_config_option` response.
return undefined;
default:
throw new Error(
`The ACP v2 surface does not translate ${update.sessionUpdate} session updates yet`,
);
}
}

131
src/v2/session.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* The ACP v2 session lifecycle, expressed through the v1 session methods of
* `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves,
* and its v1 responses become v2 responses.
*/
import {
RequestError,
type AvailableCommand,
type McpServer,
type NewSessionRequest,
type NewSessionResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionConfigOption,
type SessionConfigSelectGroup,
type SessionConfigSelectOptions,
type SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
const { mcpServers, ...request } = params;
// v1 requires the list; v2 treats an omitted list as an empty one.
return { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) };
}
/**
* `replayFrom` is rejected until the v2 surface can translate the replayed
* history, which needs the message and tool call updates.
*/
export function v1ResumeSessionRequest(params: v2.ResumeSessionRequest): ResumeSessionRequest {
const { replayFrom, mcpServers, ...request } = params;
if (replayFrom != null) {
throw RequestError.invalidParams(
{ replayFrom },
"The ACP v2 surface does not replay session history yet",
);
}
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
}
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse {
return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) };
}
export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse {
return {
...v2ConfigOptionsField(response.configOptions),
...(response._meta != null ? { _meta: response._meta } : {}),
};
}
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
export function v1SetSessionConfigOptionRequest(
params: v2.SetSessionConfigOptionRequest,
): SetSessionConfigOptionRequest {
const request = {
sessionId: params.sessionId,
configId: params.configId,
...(params._meta != null ? { _meta: params._meta } : {}),
};
if (v2.SetSessionConfigOptionRequest.isId(params)) {
return { ...request, value: params.value };
}
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
return { ...request, type: "boolean", value: params.value };
}
throw RequestError.invalidParams(
{ type: params.type },
`Config option values of type ${params.type} are not supported`,
);
}
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
return options.map((option) => {
const { id, ...rest } = option;
if (rest.type === "select") {
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
}
return { ...rest, configId: id };
});
}
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
if (!isGrouped(options)) return options;
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
}
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
return options.some((entry) => "group" in entry);
}
/** v2 tags the free-text input of a command with `type: "text"`. */
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
return commands.map(({ input, ...command }) => ({
...command,
...(input != null ? { input: { type: "text", ...input } } : {}),
}));
}
/** v1 responses may send `null`; v2 omits the field instead. */
function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): {
configOptions?: v2.SessionConfigOption[];
} {
return options != null ? { configOptions: v2ConfigOptions(options) } : {};
}
/**
* v2 requires a `type` on every transport and makes the empty lists optional.
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
* is dropped. A transport that v1 cannot express is rejected rather than
* silently left out of the session.
*/
function v1McpServer(server: v2.McpServer): McpServer {
if (v2.McpServer.isStdio(server)) {
const { name, command, args, env, _meta } = server;
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
}
if (v2.McpServer.isHttp(server)) {
return { ...server, headers: server.headers ?? [] };
}
if (v2.McpServer.isAcp(server)) {
return server;
}
throw RequestError.invalidParams(
{ type: server.type },
`MCP servers of type ${server.type} are not supported`,
);
}