mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 23:41:23 +00:00
Compare commits
1 Commits
fix/slash-
...
feat/v2-au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6475c3e96 |
138
src/acp-agent.ts
138
src/acp-agent.ts
@@ -56,7 +56,7 @@ import {
|
||||
WriteTextFileResponse,
|
||||
ToolCallContent,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { StopReason, TurnEvents, TurnOutcome } from "./turn-events.js";
|
||||
import type { CycleEvents, StopReason, TurnEvents, TurnOutcome } from "./turn-events.js";
|
||||
import {
|
||||
AccountInfo,
|
||||
AgentInfo,
|
||||
@@ -868,6 +868,11 @@ export type Session = {
|
||||
/** The turn whose messages the consumer is currently attributing output to
|
||||
* (the head of `turnQueue` once its user message has been echoed). */
|
||||
activeTurn?: Turn | null;
|
||||
/** The model cycle that Claude Code started on its own while the session had
|
||||
* no turn, and that has not ended yet (see {@link CycleEvents}). Only set
|
||||
* when the agent reports such cycles (`ClaudeAcpAgent` option
|
||||
* `autonomousCycle`). */
|
||||
autonomousCycle?: { events: CycleEvents; awaitingUser?: boolean };
|
||||
/** Session-owned native checkpoint reporter. Turn state stays on each Turn. */
|
||||
fileChangeReporter?: NativeFileChangeReporter;
|
||||
/** Optimistic goal state published for a submitted `/goal` command whose
|
||||
@@ -2540,6 +2545,8 @@ export class ClaudeAcpAgent {
|
||||
* express: see {@link ToolCallClientCapabilities.v2}.
|
||||
*/
|
||||
private readonly v2: boolean;
|
||||
/** See the `autonomousCycle` constructor option. */
|
||||
private readonly autonomousCycleEvents?: (sessionId: string) => CycleEvents;
|
||||
/** The session index (docs/air-extensions.md, "Session index"). */
|
||||
private readonly sessionIndex = new sessionIndex.SessionIndexConnection({
|
||||
agent: this,
|
||||
@@ -2548,8 +2555,23 @@ export class ClaudeAcpAgent {
|
||||
teardownSession: (sessionId) => this.teardownSession(sessionId),
|
||||
});
|
||||
|
||||
constructor(client: AcpClient, logger?: Logger, options: { v2?: boolean } = {}) {
|
||||
constructor(
|
||||
client: AcpClient,
|
||||
logger?: Logger,
|
||||
options: {
|
||||
v2?: boolean;
|
||||
/**
|
||||
* Called when Claude Code starts a model cycle on its own while a session
|
||||
* has no turn, before the cycle's first output goes out. It returns where
|
||||
* the agent reports that cycle (see {@link CycleEvents}). Without it, the
|
||||
* cycle's output still goes out, and nothing reports the cycle itself: a
|
||||
* v1 client has no state to report it with.
|
||||
*/
|
||||
autonomousCycle?: (sessionId: string) => CycleEvents;
|
||||
} = {},
|
||||
) {
|
||||
this.v2 = options.v2 ?? false;
|
||||
this.autonomousCycleEvents = options.autonomousCycle;
|
||||
this.sessions = {};
|
||||
this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client);
|
||||
this.logger = logger ?? console;
|
||||
@@ -4282,6 +4304,9 @@ export class ClaudeAcpAgent {
|
||||
* result isn't wrongly skipped. */
|
||||
const activateTurn = (turn: Turn) => {
|
||||
session.activeTurn = turn;
|
||||
// A prompt that Claude Code takes in while a cycle it started on its own
|
||||
// runs takes that cycle over (see CycleEvents): the turn reports the rest.
|
||||
session.autonomousCycle = undefined;
|
||||
session.cancelled = false;
|
||||
compaction.resume();
|
||||
if (turn.localCommand?.startsAtActivation) ensureLocalCommandMarkdown(turn);
|
||||
@@ -4836,6 +4861,7 @@ export class ClaudeAcpAgent {
|
||||
/** Reject every in-flight turn — used when the stream dies. */
|
||||
const failAllTurns = (error: unknown) => {
|
||||
disarmForceCancel(session);
|
||||
this.endAutonomousCycle(session, { error });
|
||||
const turns = session.activeTurn
|
||||
? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
|
||||
: [...(session.turnQueue ?? [])];
|
||||
@@ -5010,6 +5036,12 @@ export class ClaudeAcpAgent {
|
||||
session.cancelled ? "stopped" : "failed",
|
||||
"at end of stream",
|
||||
);
|
||||
this.endAutonomousCycle(
|
||||
session,
|
||||
session.cancelled
|
||||
? { outcome: { stopReason: "cancelled" } }
|
||||
: { error: RequestError.internalError(undefined, SESSION_ENDED_MESSAGE) },
|
||||
);
|
||||
await settleActive(
|
||||
session.cancelled
|
||||
? cancelledOutcome(session, inFlight)
|
||||
@@ -5161,6 +5193,8 @@ export class ClaudeAcpAgent {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.openAutonomousCycle(params.sessionId, session, message);
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
@@ -5310,6 +5344,17 @@ export class ClaudeAcpAgent {
|
||||
session.owedTrailingIdles = 0;
|
||||
}
|
||||
if (message.state === "idle") {
|
||||
// A model cycle that Claude Code started on its own (see
|
||||
// CycleEvents) normally ends at its result. An idle that no
|
||||
// earlier result owes means that result will not come (the
|
||||
// stream dropped mid-cycle, as for a turn in issue #825), so
|
||||
// the cycle ends here. An owed idle belongs to an earlier
|
||||
// result: it can lag past the cycle's first output.
|
||||
if (session.autonomousCycle && session.owedTrailingIdles === 0) {
|
||||
this.endAutonomousCycle(session, {
|
||||
outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" },
|
||||
});
|
||||
}
|
||||
// A non-cancelled turn normally settled at its terminal
|
||||
// `result` already (issue #773), and that result recorded an
|
||||
// owed trailing idle — absorbed here via the decrement. We
|
||||
@@ -6175,6 +6220,17 @@ export class ClaudeAcpAgent {
|
||||
});
|
||||
}
|
||||
|
||||
// A model cycle that Claude Code started on its own (see
|
||||
// CycleEvents) ends at its result, after its usage, also after a
|
||||
// cancel. A turn that activates takes the cycle over, so a cycle
|
||||
// still open here owns this result, whatever its origin says. A
|
||||
// placeholder result (`num_turns: 0`, see the autonomous lane
|
||||
// below) comes before the shared followup runs, so it ends
|
||||
// nothing.
|
||||
if (session.autonomousCycle && !(isAutonomousResult && message.num_turns === 0)) {
|
||||
this.endAutonomousCycle(session, autonomousCycleEnd(message, session.cancelled));
|
||||
}
|
||||
|
||||
if (session.cancelled) {
|
||||
session.pendingExitPlanModeInterruption = undefined;
|
||||
session.pendingExitPlanContextReset = undefined;
|
||||
@@ -8372,12 +8428,46 @@ export class ClaudeAcpAgent {
|
||||
* when a turn activates. */
|
||||
private syncAwaitingUser(session: Session): void {
|
||||
const turn = session.activeTurn;
|
||||
if (!turn || turn.settled || !turn.insertedReported) return;
|
||||
// With no turn, a model cycle that Claude Code started on its own reports
|
||||
// it (see CycleEvents).
|
||||
const work =
|
||||
turn && !turn.settled ? (turn.insertedReported ? turn : undefined) : session.autonomousCycle;
|
||||
if (!work) 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();
|
||||
if (waiting === (work.awaitingUser ?? false)) return;
|
||||
work.awaitingUser = waiting;
|
||||
if (waiting) work.events.awaitingUser();
|
||||
else work.events.resumed();
|
||||
}
|
||||
|
||||
/** Open a model cycle that Claude Code started on its own (see
|
||||
* {@link CycleEvents}) when `message` is its first output: model output of
|
||||
* the main conversation (not a subagent's) while the session has no turn,
|
||||
* active or queued. A queued turn's output can come before its echo
|
||||
* activates it, so output while a turn is queued stays that turn's. Output
|
||||
* after a cancel is dropped, so it opens nothing. */
|
||||
private openAutonomousCycle(sessionId: string, session: Session, message: SDKMessage): void {
|
||||
if (!this.autonomousCycleEvents || session.autonomousCycle || session.cancelled) return;
|
||||
if (message.type !== "assistant" && message.type !== "stream_event") return;
|
||||
if (message.parent_tool_use_id !== null) return;
|
||||
if (session.activeTurn && !session.activeTurn.settled) return;
|
||||
if ((session.turnQueue ?? []).some((turn) => !turn.settled)) return;
|
||||
session.autonomousCycle = { events: this.autonomousCycleEvents(sessionId) };
|
||||
// A request can already be open, for example a background subagent's.
|
||||
this.syncAwaitingUser(session);
|
||||
}
|
||||
|
||||
/** End the session's open model cycle (see {@link CycleEvents}) with
|
||||
* `outcome`, or as failed with `error`. */
|
||||
private endAutonomousCycle(
|
||||
session: Session,
|
||||
end: { outcome: TurnOutcome } | { error: unknown },
|
||||
): void {
|
||||
const cycle = session.autonomousCycle;
|
||||
if (!cycle) return;
|
||||
session.autonomousCycle = undefined;
|
||||
if ("outcome" in end) cycle.events.ended(end.outcome);
|
||||
else cycle.events.failed(end.error);
|
||||
}
|
||||
|
||||
/** Forward a permission request to the client, wiring the tool call's
|
||||
@@ -10368,6 +10458,40 @@ function turnOutcome(
|
||||
};
|
||||
}
|
||||
|
||||
/** How a model cycle that Claude Code started on its own ended (see
|
||||
* CycleEvents), read from its result as a turn's is: a refusal, the token
|
||||
* limit, and the turn and budget limits are their stop reasons, an error
|
||||
* result fails the cycle, and a cancel ends it `cancelled`. The usage is the
|
||||
* result's own, since autonomous results stay out of the turn tally. */
|
||||
function autonomousCycleEnd(
|
||||
result: Extract<SDKMessage, { type: "result" }>,
|
||||
cancelled: boolean,
|
||||
): { outcome: TurnOutcome } | { error: unknown } {
|
||||
// Typed non-null, but third-party backends have been observed omitting
|
||||
// usage token fields (see snapshotFromUsage).
|
||||
const tally: AccumulatedUsage = {
|
||||
inputTokens: result.usage.input_tokens ?? 0,
|
||||
outputTokens: result.usage.output_tokens ?? 0,
|
||||
cachedReadTokens: result.usage.cache_read_input_tokens ?? 0,
|
||||
cachedWriteTokens: result.usage.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
const usage = { ...tally, totalTokens: tallyTotal(tally) };
|
||||
const ended = (stopReason: StopReason) => ({ outcome: { stopReason, usage } });
|
||||
if (cancelled) return ended("cancelled");
|
||||
if (result.stop_reason === "refusal") return ended("refusal");
|
||||
if (result.stop_reason === "max_tokens") return ended("max_tokens");
|
||||
if (result.is_error) {
|
||||
const text =
|
||||
result.subtype === "success" ? result.result : result.errors.join(", ") || result.subtype;
|
||||
return { error: RequestError.internalError(undefined, text) };
|
||||
}
|
||||
return ended(
|
||||
result.subtype === "success" || result.subtype === "error_during_execution"
|
||||
? "end_turn"
|
||||
: "max_turn_requests",
|
||||
);
|
||||
}
|
||||
|
||||
/** The outcome of `turn` when a cancel ends it. A turn that already has its
|
||||
* result, because it is held open for its background subagents (see
|
||||
* Turn.deferredSettle) or is settling (see Turn.settlingOutcome), keeps the
|
||||
|
||||
@@ -1551,6 +1551,360 @@ describe("ACP v2 prompts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Claude Code runs model cycles that no prompt started, for example its
|
||||
// follow-up after a background command finished. v2 reports them as
|
||||
// foreground work.
|
||||
describe("ACP v2 model cycles that Claude Code starts on its own", () => {
|
||||
/** The result of a cycle that a background task's notification started. */
|
||||
const followupResult = (options: Options, overrides: Record<string, unknown> = {}) =>
|
||||
result(options, { origin: { kind: "task-notification" }, ...overrides });
|
||||
|
||||
/** What the client saw of the turn of the prompt "hi" that ended. */
|
||||
const endedTurn = ["user_message", "running", "agent_message_chunk", "idle end_turn"];
|
||||
|
||||
/**
|
||||
* Sends the prompt "hi", runs `during`, waits until the client saw `idles`
|
||||
* idle states, and returns the client.
|
||||
*/
|
||||
async function promptAndWait(
|
||||
idles: number,
|
||||
during?: (
|
||||
agent: v2.ClientContext,
|
||||
sessionId: string,
|
||||
client: ReturnType<typeof v2Client>,
|
||||
) => Promise<void>,
|
||||
capabilities: v2.ClientCapabilities = {},
|
||||
) {
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent, capabilities);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await during?.(agent, sessionId, client);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
turnTrace(client.sessionUpdates).filter((state) => state.startsWith("idle")),
|
||||
).toHaveLength(idles),
|
||||
);
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
it("reports a cycle after the turn ended as running, then idle with the cycle's usage", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
// The build finishes, and Claude Code wakes the model to report it.
|
||||
yield assistantText(options, "The build passed.", "msg_followup");
|
||||
yield followupResult(options, {
|
||||
usage: {
|
||||
input_tokens: 7,
|
||||
output_tokens: 4,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
});
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2);
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle end_turn",
|
||||
]);
|
||||
expect(client.updates("state_update").at(-1)).toMatchObject({
|
||||
state: "idle",
|
||||
stopReason: "end_turn",
|
||||
usage: { inputTokens: 7, outputTokens: 4, totalTokens: 11 },
|
||||
});
|
||||
});
|
||||
|
||||
it("ends a cycle that the client cancels with idle cancelled", async () => {
|
||||
const interrupted = Promise.withResolvers<void>();
|
||||
sdk.interrupt = async () => interrupted.resolve();
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield stream(options, {
|
||||
type: "message_start",
|
||||
message: { id: "msg_followup", role: "assistant", content: [], usage: {} },
|
||||
});
|
||||
yield stream(options, {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "The build" },
|
||||
});
|
||||
await interrupted.promise;
|
||||
yield followupResult(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2, async (agent, sessionId, client) => {
|
||||
await vi.waitFor(() =>
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
]),
|
||||
);
|
||||
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
|
||||
});
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle cancelled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets a prompt that Claude Code takes in during a cycle take the cycle over", async () => {
|
||||
const echoed = scriptTurns(
|
||||
async function* (options) {
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
// The follow-up still runs when the next prompt comes in.
|
||||
yield assistantText(options, "The build passed.", "msg_followup");
|
||||
},
|
||||
async function* (options) {
|
||||
yield assistantText(options, "Deployed.", "msg_deploy");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
},
|
||||
);
|
||||
const client = await promptAndWait(2, async (agent, sessionId, client) => {
|
||||
await vi.waitFor(() =>
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("deploy") }),
|
||||
).toEqual({ messageId: echoed[1] });
|
||||
});
|
||||
|
||||
// No idle between the cycle and the turn that took it over.
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"user_message",
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle end_turn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a cycle at its own result, not at a placeholder result or an idle owed to the turn", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started two builds in the background.");
|
||||
yield result(options);
|
||||
// The turn's idle comes late, after the follow-up started.
|
||||
yield assistantText(options, "The first build passed.", "msg_first");
|
||||
yield idleState(options);
|
||||
// Claude Code answers queued notifications with one model call, and
|
||||
// gives each but the last a placeholder result.
|
||||
yield followupResult(options, { num_turns: 0 });
|
||||
yield assistantText(options, "The second build passed too.", "msg_second");
|
||||
yield followupResult(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2);
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"agent_message_chunk",
|
||||
"idle end_turn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a cycle at an idle when its result never comes", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield assistantText(options, "The build", "msg_followup");
|
||||
// The stream lost the cycle's result.
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2);
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle end_turn",
|
||||
]);
|
||||
expect(client.updates("state_update").at(-1)).not.toHaveProperty("usage");
|
||||
});
|
||||
|
||||
it("reports a cycle that waits on a question as requires_action", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield assistantText(options, "The build failed. One question first.", "msg_followup");
|
||||
await options.onElicitation!(
|
||||
{
|
||||
serverName: "linear",
|
||||
message: "Which team?",
|
||||
mode: "form",
|
||||
requestedSchema: { type: "object", properties: { team: { type: "string" } } },
|
||||
},
|
||||
{ signal: new AbortController().signal } as Parameters<
|
||||
NonNullable<Options["onElicitation"]>
|
||||
>[1],
|
||||
);
|
||||
yield followupResult(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2, undefined, { elicitation: { form: {} } });
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"requires_action",
|
||||
"running",
|
||||
"idle end_turn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a cycle whose result is an error as idle with the error stop reason", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield assistantText(options, "Looking at the build.", "msg_followup");
|
||||
yield followupResult(options, { is_error: true, result: "API Error: 529 Overloaded" });
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2);
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle error",
|
||||
]);
|
||||
expect(client.updates("state_update").at(-1)).toMatchObject({
|
||||
error: { code: -32603, message: expect.stringContaining("API Error: 529 Overloaded") },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ends", false],
|
||||
["fails", true],
|
||||
])("ends a cycle with an error when the stream %s during it", async (_, fails) => {
|
||||
sdk.run = async function* (prompts, options) {
|
||||
const { value } = await prompts[Symbol.asyncIterator]().next();
|
||||
yield {
|
||||
type: "user",
|
||||
message: value!.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value!.uuid,
|
||||
session_id: options.sessionId,
|
||||
isReplay: true,
|
||||
};
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield assistantText(options, "The build", "msg_followup");
|
||||
// Claude Code exits, or the stream breaks.
|
||||
if (fails) throw new Error("stream broke");
|
||||
};
|
||||
const client = await promptAndWait(2);
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle error",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves output that comes before a queued prompt's echo to that prompt", async () => {
|
||||
sdk.run = async function* (prompts, options) {
|
||||
const input = prompts[Symbol.asyncIterator]();
|
||||
const echo = (value: SDKUserMessage) => ({
|
||||
type: "user",
|
||||
message: value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value.uuid,
|
||||
session_id: options.sessionId,
|
||||
isReplay: true,
|
||||
});
|
||||
yield echo((await input.next()).value);
|
||||
yield assistantText(options, "Started the build in the background.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
// The next prompt's answer starts streaming before its echo.
|
||||
const next = (await input.next()).value;
|
||||
yield stream(options, {
|
||||
type: "message_start",
|
||||
message: { id: "msg_deploy", role: "assistant", content: [], usage: {} },
|
||||
});
|
||||
yield stream(options, {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Deploying" },
|
||||
});
|
||||
yield echo(next);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
while (!(await input.next()).done);
|
||||
};
|
||||
const client = await promptAndWait(2, async (agent, sessionId, client) => {
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toEqual(endedTurn));
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("deploy") });
|
||||
});
|
||||
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"agent_message_chunk",
|
||||
"user_message",
|
||||
"running",
|
||||
"idle end_turn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a subagent's output after the turn to the session's background", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield assistantText(options, "Started a background review.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield {
|
||||
...assistantText(options, "Reviewing.", "msg_subagent"),
|
||||
parent_tool_use_id: "toolu_agent",
|
||||
};
|
||||
// Claude Code goes idle again while the subagent works. That idle would
|
||||
// end a cycle that the subagent's output had opened.
|
||||
yield idleState(options);
|
||||
yield assistantText(options, "The review is done.", "msg_followup");
|
||||
yield followupResult(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptAndWait(2);
|
||||
|
||||
// The cycle starts at the main conversation's output, not the subagent's.
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
...endedTurn,
|
||||
"running",
|
||||
"agent_message_chunk",
|
||||
"idle end_turn",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 session translation", () => {
|
||||
it("renames the group id of grouped select options", () => {
|
||||
expect(
|
||||
|
||||
@@ -48,6 +48,16 @@ export interface TurnEvents {
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A model cycle that Claude Code started on its own while the session had no
|
||||
* turn: for example, the model's follow-up after a background command
|
||||
* finished, or its answer to a message from another session. No prompt
|
||||
* started it, so it reports like a turn after {@link TurnEvents.inserted}: it
|
||||
* may wait for the user, and it ends or fails once. A prompt that Claude Code
|
||||
* takes in while it runs takes it over, and the cycle reports nothing more.
|
||||
*/
|
||||
export type CycleEvents = Omit<TurnEvents, "inserted">;
|
||||
|
||||
/** Why a turn ended. Every ACP version has these reasons. */
|
||||
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import type { AcpPermissionRequest } from "../permissions/presentation.js";
|
||||
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
|
||||
import { v1PermissionResponse, v2PermissionRequest } from "./permission.js";
|
||||
import { v2Prompt } from "./prompt.js";
|
||||
import { v2AutonomousCycle, v2Prompt } from "./prompt.js";
|
||||
import {
|
||||
v1ForkSessionRequests,
|
||||
v1NewSessionRequest,
|
||||
@@ -52,7 +52,13 @@ export function v2AgentApp(
|
||||
.agent({ name: "claude-code-acp" })
|
||||
.onConnect((connection) => {
|
||||
client = new V2ClientConnection(connection.client, logger ?? console);
|
||||
agent = new ClaudeAcpAgent(client, logger, { v2: true });
|
||||
agent = new ClaudeAcpAgent(client, logger, {
|
||||
v2: true,
|
||||
autonomousCycle: (sessionId) =>
|
||||
v2AutonomousCycle((update) => {
|
||||
void client.send({ sessionId, update });
|
||||
}),
|
||||
});
|
||||
onAgent(agent);
|
||||
})
|
||||
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
|
||||
import type { CycleEvents, StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
|
||||
|
||||
/**
|
||||
* Starts a turn for `params`, and answers when Claude Code takes the prompt
|
||||
@@ -45,6 +45,7 @@ export function v2Prompt(
|
||||
send: (update: v2.SessionUpdate) => void,
|
||||
): Promise<v2.PromptResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const work = foregroundWork(send);
|
||||
let inserted = false;
|
||||
const events: TurnEvents = {
|
||||
inserted(messageId) {
|
||||
@@ -53,15 +54,11 @@ export function v2Prompt(
|
||||
send({ sessionUpdate: "state_update", state: "running" });
|
||||
resolve({ messageId });
|
||||
},
|
||||
awaitingUser() {
|
||||
send({ sessionUpdate: "state_update", state: "requires_action" });
|
||||
},
|
||||
resumed() {
|
||||
send({ sessionUpdate: "state_update", state: "running" });
|
||||
},
|
||||
awaitingUser: work.awaitingUser,
|
||||
resumed: work.resumed,
|
||||
ended(outcome) {
|
||||
if (inserted) {
|
||||
send(idle(outcome));
|
||||
work.ended(outcome);
|
||||
} else if (outcome.stopReason === "cancelled") {
|
||||
reject(RequestError.requestCancelled());
|
||||
} else {
|
||||
@@ -74,16 +71,8 @@ export function v2Prompt(
|
||||
}
|
||||
},
|
||||
failed(error) {
|
||||
if (!inserted) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
send({
|
||||
sessionUpdate: "state_update",
|
||||
state: "idle",
|
||||
stopReason: "error",
|
||||
error: jsonRpcError(error),
|
||||
});
|
||||
if (inserted) work.failed(error);
|
||||
else reject(error);
|
||||
},
|
||||
};
|
||||
let request: PromptRequest;
|
||||
@@ -97,6 +86,44 @@ export function v2Prompt(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a model cycle that Claude Code started on its own while the session
|
||||
* had no turn (see {@link CycleEvents}), such as its follow-up after a
|
||||
* background command finished. v2 reports foreground work whether or not a
|
||||
* prompt started it: `running` now, then the cycle's states as for a prompt's
|
||||
* turn after it was taken in.
|
||||
*/
|
||||
export function v2AutonomousCycle(send: (update: v2.SessionUpdate) => void): CycleEvents {
|
||||
send({ sessionUpdate: "state_update", state: "running" });
|
||||
return foregroundWork(send);
|
||||
}
|
||||
|
||||
/**
|
||||
* The states of foreground work once it runs: `requires_action` while it
|
||||
* waits for the user, `running` again after, and `idle` when it ends or fails.
|
||||
*/
|
||||
function foregroundWork(send: (update: v2.SessionUpdate) => void): CycleEvents {
|
||||
return {
|
||||
awaitingUser() {
|
||||
send({ sessionUpdate: "state_update", state: "requires_action" });
|
||||
},
|
||||
resumed() {
|
||||
send({ sessionUpdate: "state_update", state: "running" });
|
||||
},
|
||||
ended(outcome) {
|
||||
send(idle(outcome));
|
||||
},
|
||||
failed(error) {
|
||||
send({
|
||||
sessionUpdate: "state_update",
|
||||
state: "idle",
|
||||
stopReason: "error",
|
||||
error: jsonRpcError(error),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The JSON-RPC error that v1 answers a failed prompt with. */
|
||||
function jsonRpcError(error: unknown): { code: number; message: string; data?: unknown } {
|
||||
if (error instanceof RequestError) {
|
||||
|
||||
Reference in New Issue
Block a user