fix: report a failed turn with the error stop reason on the experimental ACP v2 surface (#1288)

Follow-up to #1287 (SDK 1.8.0). A v2 turn that fails after its prompt
was taken in now ends with the standard `error` stop reason from
[agent-client-protocol#2281](https://github.com/agentclientprotocol/agent-client-protocol/pull/2281),
in place of the stand-ins we used while the SDK didn't have it.

## Before and after

Before, on the v2 surface only:
1. an error `notice`, titled with the failure;
2. `idle` with the custom stop reason `_error`, and the JSON-RPC error
in `_meta.claudeCode.error`.

Now, one update:

```json
{ "sessionUpdate": "state_update", "state": "idle", "stopReason": "error",
  "error": { "code": -32603, "message": "Internal error: API Error: 529 Overloaded" } }
```

- `error` is the JSON-RPC error that v1 rejects the prompt with: same
code, message and data. A client can show `message` and handle `code` as
for an error response, so `-32000` (auth required) can start its sign-in
again, which `_error` couldn't.
- The notice is gone. The v2 draft's
[Failures](https://agentclientprotocol.com/protocol/v2/draft/prompt-lifecycle)
section reports a failure that ends the work through the stop reason,
and keeps notices for failures the agent recovers from.
- v1 and AIR are unchanged.

## Also

- `TurnEvents.failed` loses its `title` parameter. Only the v2 notice
read it, and v1's prompt already ignored it. Its pass-through in
`Turn.reject` and `failActive` goes too. AIR's session failures still
get the title, through their own path.
- The v2 scenario harness treats an `error` stop reason as a failed
turn, and reports its message.

## One visible difference

The old notice showed the failure title, for example `API Error: 529
Overloaded`. The error's message is v1's, `Internal error: API Error:
529 Overloaded`, which v1 clients already show. If we'd rather v2
clients see the shorter text, the alternative is to use the title as the
error's `message` and keep v1's code and data. That would make v2's
message differ from v1's.

## Checks

- `npm run check`, `npm run build` and all 2489 tests pass. No golden
file changes: no recorded scenario fails a turn after insertion.
- The two v2 failure tests pin the new update and the auth code. Putting
`_error` back fails both.
This commit is contained in:
Ben Brandt
2026-10-09 10:23:57 -07:00
committed by GitHub
parent 446a7217fa
commit 060bdb212a
5 changed files with 49 additions and 58 deletions

View File

@@ -825,8 +825,7 @@ type Turn = {
* clear-context restart keeps the `_meta.quota` rows it earned pre-restart. */
carriedModelUsage?: ModelTokenTally;
resolve: (outcome: TurnOutcome) => void;
/** `title` describes the failure for the user (see `TurnEvents.failed`). */
reject: (error: unknown, title?: string) => void;
reject: (error: unknown) => void;
/** Settles once the turn has ended or failed. */
completion?: Promise<void>;
};
@@ -3479,8 +3478,8 @@ export class ClaudeAcpAgent {
events.ended(outcome);
completeTurn();
},
reject: (error, title) => {
events.failed(error, title);
reject: (error) => {
events.failed(error);
completeTurn();
},
};
@@ -4727,7 +4726,7 @@ export class ClaudeAcpAgent {
/** Reject the active turn (auth required, error result, …) without tearing
* down the consumer: the stream continues to idle and later turns proceed. */
const failActive = (error: unknown, title?: string) => {
const failActive = (error: unknown) => {
disarmForceCancel(session);
const turn = session.activeTurn;
if (!turn || turn.settled) {
@@ -4746,7 +4745,7 @@ export class ClaudeAcpAgent {
// start the next stretch clean, or its stale delivery record would
// suppress the next turn's issue-#453 result-text fallback.
session.emittedAssistantText = false;
turn.reject(error, title);
turn.reject(error);
};
/** Complete a negotiated terminal failure on the prompt response itself,
@@ -4775,7 +4774,7 @@ export class ClaudeAcpAgent {
return;
}
if (!supportsAirSessionFailures(this.clientCapabilities)) {
failActive(error, title);
failActive(error);
return;
}
if (!session.activeTurn || session.activeTurn.settled) {
@@ -4787,7 +4786,7 @@ export class ClaudeAcpAgent {
}
const failure = await createSessionFailure(kind, { title });
if (!failure) {
failActive(error, title);
failActive(error);
return;
}
sessionFailures.recordActive(failure);

View File

@@ -433,8 +433,8 @@ export interface ScenarioRunV2 {
normalized: WireRecorded[];
/**
* `passes` when every prompt turn ended with a stop reason other than
* `_error`, or else why the scenario stopped: the error of a rejected
* request, or the notice of a turn that failed.
* `error`, or else why the scenario stopped: the error of a rejected
* request, or of a turn that failed.
*/
status: string;
}
@@ -462,9 +462,10 @@ export async function runScenarioV2(
const methodsOfClientRequests = new Map<string | number, string>();
let recording = true;
let sessionId: string | undefined;
// The stop reasons of the session's idle state updates, and its notices.
// The stop reasons of the session's idle state updates, and the error
// messages of the failed ones.
const idles: unknown[] = [];
const notices: string[] = [];
const failures: string[] = [];
const each = (message: v2.AnyWireMessage) =>
(Array.isArray(message) ? message : [message]) as Record<string, any>[];
@@ -498,12 +499,14 @@ export async function runScenarioV2(
sessionUpdate: string;
state?: string;
stopReason?: unknown;
title?: string;
error?: { message?: string } | null;
};
if (update.sessionUpdate === "state_update" && update.state === "idle") {
idles.push(update.stopReason);
if (update.stopReason === "error") {
failures.push(update.error?.message ?? "the turn failed");
}
}
if (update.sessionUpdate === "notice" && update.title) notices.push(update.title);
})
.onRequest(v2.methods.client.session.requestPermission, ({ params }) =>
permissionAnswer(scenario, params.options),
@@ -559,8 +562,8 @@ export async function runScenarioV2(
stop("the turn reported no idle state");
return;
}
if (idles[idlesBefore] === "_error") {
stop(notices.at(-1) ?? "the turn failed");
if (idles[idlesBefore] === "error") {
stop(failures.at(-1) ?? "the turn failed");
return;
}
await settle();

View File

@@ -1083,7 +1083,7 @@ describe("ACP v2 prompts", () => {
]);
});
it("reports a turn that fails after it was taken in as idle with _error, and shows it", async () => {
it("reports a turn that fails after it was taken in as idle with the error stop reason", async () => {
scriptTurns(async function* (options) {
yield result(options, { is_error: true, result: "API Error: 529 Overloaded" });
});
@@ -1092,21 +1092,20 @@ describe("ACP v2 prompts", () => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle _error"));
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle error"));
await client.authUpdate(1);
});
expect(turnTrace(client.sessionUpdates)).toEqual([
"user_message",
"running",
"notice",
"idle _error",
]);
expect(client.updates("notice")).toEqual([
{ sessionUpdate: "notice", severity: "error", title: "API Error: 529 Overloaded" },
]);
expect(client.updates("state_update").at(-1)).toMatchObject({
_meta: { claudeCode: { error: { code: -32603 } } },
// The stop reason carries the failure, so no notice repeats it. The error
// is the one v1 rejects the prompt with.
expect(turnTrace(client.sessionUpdates)).toEqual(["user_message", "running", "idle error"]);
expect(client.updates("notice")).toEqual([]);
const v1Error = v1.RequestError.internalError(undefined, "API Error: 529 Overloaded");
expect(client.updates("state_update").at(-1)).toEqual({
sessionUpdate: "state_update",
state: "idle",
stopReason: "error",
error: expect.objectContaining({ code: v1Error.code, message: v1Error.message }),
});
});
@@ -1119,14 +1118,18 @@ describe("ACP v2 prompts", () => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle _error"));
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle error"));
await client.authUpdate(1);
});
// The client owns the login UI, so the notice does not say to run /login.
expect(client.updates("notice")[0]).toMatchObject({ title: "Authentication required" });
// The code starts the client's sign-in, which owns the login UI, so the
// message does not say to run /login.
expect(client.updates("state_update").at(-1)).toMatchObject({
_meta: { claudeCode: { error: { code: v1.RequestError.authRequired().code } } },
stopReason: "error",
error: {
code: v1.RequestError.authRequired().code,
message: "Authentication required",
},
});
});

View File

@@ -41,11 +41,11 @@ export interface TurnEvents {
*/
ended(outcome: TurnOutcome): void;
/**
* The turn failed. Without {@link inserted} before, Claude Code was not seen
* taking the prompt in. `title`, when the agent has one, describes the
* failure for the user in plain text, such as "API Error: 529 Overloaded".
* The turn failed with `error`, the JSON-RPC error that v1 answers the
* prompt with. Without {@link inserted} before, Claude Code was not seen
* taking the prompt in.
*/
failed(error: unknown, title?: string): void;
failed(error: unknown): void;
}
/** Why a turn ended. Every ACP version has these reasons. */

View File

@@ -13,7 +13,6 @@ import {
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import { splitNoticeText } from "../session-notices.js";
import type { StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
/**
@@ -26,10 +25,10 @@ import type { StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
* - Waiting on a permission request or a question: `requires_action`, and
* `running` once none is open.
* - Ended: `idle` with the stop reason, usage, and `_meta` of the turn.
* - Failed after it was taken in: `idle` with {@link FAILED_STOP_REASON} and
* the JSON-RPC error that v1 answers with in `_meta.claudeCode.error`, and
* an error `notice` before it that shows the failure. v2 has no error on
* `idle`, and a notice must not be the only report of a failure.
* - Failed after it was taken in: `idle` with the `error` stop reason and the
* JSON-RPC error that v1 answers the prompt with, so a client can show its
* message and handle its code as for an error response (for example,
* `-32000` starts the client's sign-in).
* - Ended or failed before it was taken in: the answer is a JSON-RPC error,
* `-32800` for a prompt that a cancel ended in the queue.
*
@@ -74,22 +73,16 @@ export function v2Prompt(
);
}
},
failed(error, title) {
failed(error) {
if (!inserted) {
reject(error);
return;
}
const failure = jsonRpcError(error);
send({
sessionUpdate: "notice",
severity: "error",
...splitNoticeText(title ?? failure.message, "The turn failed"),
});
send({
sessionUpdate: "state_update",
state: "idle",
stopReason: FAILED_STOP_REASON,
_meta: { claudeCode: { error: failure } },
stopReason: "error",
error: jsonRpcError(error),
});
},
};
@@ -104,13 +97,6 @@ export function v2Prompt(
});
}
/**
* The stop reason of a turn that failed after it was taken in. v2's stop
* reasons all describe work that ended without an error, and a custom one
* starts with `_`; clients show it as a generic stop.
*/
export const FAILED_STOP_REASON = "_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) {