mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-05 08:51:16 +00:00
Compare commits
7 Commits
v0.85.0
...
v0.85.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.85.0"
|
||||
".": "0.85.1"
|
||||
}
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
466
src/acp-agent.ts
466
src/acp-agent.ts
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -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
51
src/serve.ts
Normal 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));
|
||||
}
|
||||
@@ -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
513
src/tests/acp-v2.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
321
src/tests/incomplete-tools.test.ts
Normal file
321
src/tests/incomplete-tools.test.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
400
src/tests/turn-events.test.ts
Normal file
400
src/tests/turn-events.test.ts
Normal 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
67
src/turn-events.ts
Normal 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
132
src/v2/agent.ts
Normal 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
82
src/v2/initialize.ts
Normal 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
38
src/v2/session-update.ts
Normal 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
131
src/v2/session.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user