mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user