mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-14 21:31:52 +00:00
feat: say the cause of an api retry in the AIR warning details (#1594)
## Summary The AIR warning for an SDK `api_retry` message had only a title, such as `Retrying Claude, attempt 2 of 10.`. The user did not see why Claude retries or how long the next attempt waits. The warning now carries `details` with the cause and the wait. ## Changes - `apiRetryWarning(message)` in `src/session-failure-extension.ts` builds the title and the details. The title text does not change. The `api_retry` case of `src/v1/acp-agent.ts` only calls it. - The cause: - `HTTP 529 (overloaded).` when the CLI sends a status; - `Connection error: no HTTP response.` for a null status with the `unknown` kind; - `No HTTP status (overloaded).` for a null status with a kind. The CLI sends `overloaded` with no status for an SSE `overloaded_error` event after HTTP 200, so "no HTTP response" would be wrong there. - The wait: `Next attempt now.` for 0 ms (a `no_response` retry), `Next attempt in 8s.`, and whole minutes from 120 s (a background rate-limit wait), e.g. `Next attempt in 30m.`. - The `no_response` wait stays in the title only. - The kind label map has the type `Record<Exclude<SDKAssistantMessageError, "unknown">, string>`, so a new SDK kind fails typecheck. A missing `error` reads as `unknown`. - The 401 skip, the actions and the terminal failures do not change. The subagent retry path sends a `tool_progress` update and no session failure, so it does not change. v2 has no `api_retry` session failure. ## Tests - `src/tests/session-failure-extension.test.ts` (new): 0 ms, minutes, the 119 s / 2 m boundary, a null status with `cloud_credential_error`, and a missing `error`. - `src/tests/v1/acp-agent.test.ts`: the warning details on the wire, revision 2 replaces the details, and the terminal revision has none. The test doubles use status and kind pairs and delays that the bundled CLI really sends. - `npm run build`, `npm run check`: clean. - `npx vitest run src/tests/session-failure-extension.test.ts src/tests/v1/acp-agent.test.ts`: 766 passed, 9 skipped. - `npm run test:run`: 17 tests in `create-session-options.test.ts`, `providers.test.ts` and `acp-v2.test.ts` fail on `main` without this change too. They read the model list of the local environment.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
getSessionMessages,
|
||||
type SDKAPIRetryMessage,
|
||||
type SDKAssistantMessageError,
|
||||
USAGE_LIMIT_ERROR_PREFIXES,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
@@ -446,6 +447,84 @@ export class SessionFailureController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a wait in whole seconds, at least 1s: `8000` → `"8s"`. */
|
||||
function retrySeconds(ms: number): string {
|
||||
return `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
}
|
||||
|
||||
/** The wait before the next attempt: `0` → `"now"`, `8000` → `"in 8s"`, and
|
||||
* from 120 s whole minutes rounded up: `1_800_000` → `"in 30m"`. The CLI sends
|
||||
* `0` for a `no_response` retry and minutes for a background rate-limit wait. */
|
||||
function nextAttemptDelay(ms: number): string {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds <= 0) return "now";
|
||||
if (seconds < 120) return `in ${seconds}s`;
|
||||
return `in ${Math.ceil(ms / 60_000)}m`;
|
||||
}
|
||||
|
||||
/** Plain-English names of the SDK error kinds. `unknown` has none: it adds
|
||||
* nothing to the HTTP status or the connection error. */
|
||||
const RETRY_ERROR_KIND_LABELS: Record<Exclude<SDKAssistantMessageError, "unknown">, string> = {
|
||||
authentication_failed: "authentication failed",
|
||||
oauth_org_not_allowed: "organization not allowed",
|
||||
account_on_hold: "account on hold",
|
||||
verification_required: "verification required",
|
||||
billing_error: "billing error",
|
||||
rate_limit: "rate limited",
|
||||
overloaded: "overloaded",
|
||||
invalid_request: "invalid request",
|
||||
model_not_found: "model not found",
|
||||
server_error: "server error",
|
||||
max_output_tokens: "output token limit",
|
||||
cloud_credential_error: "cloud credential error",
|
||||
};
|
||||
|
||||
/** The `api_retry` message fields the warning reads. `error` is optional
|
||||
* because an older CLI can omit it; the warning then treats it as `unknown`. */
|
||||
type ApiRetryWarningInput = Pick<
|
||||
SDKAPIRetryMessage,
|
||||
"attempt" | "max_retries" | "error_status" | "retry_delay_ms" | "no_response"
|
||||
> & { error?: SDKAssistantMessageError };
|
||||
|
||||
/** The cause of a retry. A null `error_status` with a kind is not always a
|
||||
* lost connection: the CLI maps an SSE `overloaded_error` event after HTTP 200
|
||||
* to `overloaded` with no status. */
|
||||
function apiRetryCause(message: ApiRetryWarningInput): string {
|
||||
const kind = message.error ?? "unknown";
|
||||
const label =
|
||||
kind === "unknown"
|
||||
? undefined
|
||||
: (RETRY_ERROR_KIND_LABELS[kind] ?? String(kind).replaceAll("_", " "));
|
||||
if (message.error_status !== null) {
|
||||
return `HTTP ${message.error_status}${label ? ` (${label})` : ""}.`;
|
||||
}
|
||||
return label ? `No HTTP status (${label}).` : "Connection error: no HTTP response.";
|
||||
}
|
||||
|
||||
/** The title and the `details` of an `api_retry` warning. The title says the
|
||||
* attempt and, for a `no_response` retry, how long the CLI waited and will
|
||||
* wait. The details say the cause and the wait before the next attempt, e.g.
|
||||
* "HTTP 529 (overloaded). Next attempt in 8s." The details do not repeat the
|
||||
* `no_response` wait. */
|
||||
export function apiRetryWarning(message: ApiRetryWarningInput): {
|
||||
title: string;
|
||||
details: string;
|
||||
} {
|
||||
// `no_response` (SDK 0.3.261+): the API sent no response headers
|
||||
// within the first-byte window, so this retry waits longer for
|
||||
// them. Say so — "attempt 1 of 1" alone reads like a final
|
||||
// failure, and the wait is what the user is about to sit through.
|
||||
const noResponse = message.no_response
|
||||
? ` No response after ${retrySeconds(message.no_response.waited_ms)}; waiting up to ${retrySeconds(message.no_response.retry_wait_ms)}.`
|
||||
: "";
|
||||
const title =
|
||||
message.error_status === null
|
||||
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`
|
||||
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`;
|
||||
const delay = nextAttemptDelay(message.retry_delay_ms);
|
||||
return { title, details: `${apiRetryCause(message)} Next attempt ${delay}.` };
|
||||
}
|
||||
|
||||
export function providerFailureCategory(
|
||||
errorKind?: SDKAssistantMessageError,
|
||||
isUsageLimit = false,
|
||||
|
||||
50
src/tests/session-failure-extension.test.ts
Normal file
50
src/tests/session-failure-extension.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiRetryWarning } from "../session-failure-extension.js";
|
||||
|
||||
describe("apiRetryWarning", () => {
|
||||
// Each input is one the bundled CLI sends: the CLI derives `error` from the
|
||||
// status (`IIt`), and an error with no HTTP response has a null status.
|
||||
it.each([
|
||||
{
|
||||
name: "a no_response retry, which the CLI sends with a 0 ms delay",
|
||||
message: {
|
||||
error_status: null,
|
||||
error: "unknown" as const,
|
||||
retry_delay_ms: 0,
|
||||
no_response: { waited_ms: 180_000, retry_wait_ms: 600_000 },
|
||||
},
|
||||
details: "Connection error: no HTTP response. Next attempt now.",
|
||||
},
|
||||
{
|
||||
name: "a background rate-limit wait in minutes, rounded up",
|
||||
message: { error_status: 429, error: "rate_limit" as const, retry_delay_ms: 1_740_500 },
|
||||
details: "HTTP 429 (rate limited). Next attempt in 30m.",
|
||||
},
|
||||
{
|
||||
name: "a wait just under 120 s in seconds",
|
||||
message: { error_status: 529, error: "overloaded" as const, retry_delay_ms: 119_000 },
|
||||
details: "HTTP 529 (overloaded). Next attempt in 119s.",
|
||||
},
|
||||
{
|
||||
name: "a wait of 120 s in minutes",
|
||||
message: { error_status: 529, error: "overloaded" as const, retry_delay_ms: 120_000 },
|
||||
details: "HTTP 529 (overloaded). Next attempt in 2m.",
|
||||
},
|
||||
{
|
||||
name: "a credential error, which has no HTTP status",
|
||||
message: {
|
||||
error_status: null,
|
||||
error: "cloud_credential_error" as const,
|
||||
retry_delay_ms: 1_100,
|
||||
},
|
||||
details: "No HTTP status (cloud credential error). Next attempt in 1s.",
|
||||
},
|
||||
{
|
||||
name: "a message with no error kind as unknown",
|
||||
message: { error_status: 503, error: undefined, retry_delay_ms: 600 },
|
||||
details: "HTTP 503. Next attempt in 1s.",
|
||||
},
|
||||
])("describes $name", ({ message, details }) => {
|
||||
expect(apiRetryWarning({ attempt: 1, max_retries: 10, ...message }).details).toBe(details);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
PermissionUpdate,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
type SDKAssistantMessageError,
|
||||
type Options,
|
||||
type SDKControlGetUsageResponse,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
@@ -11900,7 +11901,7 @@ describe("stop reason propagation", () => {
|
||||
subtype: "api_retry",
|
||||
attempt: 1,
|
||||
max_retries: 5,
|
||||
retry_delay_ms: 100,
|
||||
retry_delay_ms: 600,
|
||||
error_status: 429,
|
||||
error: "rate_limit",
|
||||
uuid: randomUUID(),
|
||||
@@ -11930,6 +11931,7 @@ describe("stop reason propagation", () => {
|
||||
severity: "warning",
|
||||
revision: 1,
|
||||
title: "Retrying Claude, attempt 1 of 5.",
|
||||
details: "HTTP 429 (rate limited). Next attempt in 1s.",
|
||||
actions: [],
|
||||
}),
|
||||
);
|
||||
@@ -11943,6 +11945,144 @@ describe("stop reason propagation", () => {
|
||||
actions: ["retry"],
|
||||
}),
|
||||
);
|
||||
expect(terminal).not.toHaveProperty("details");
|
||||
});
|
||||
|
||||
it("keeps the api retries of one turn and its failure in one row before the echo", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) => updates.push(update),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, airSessionFailureCapabilities);
|
||||
const retry = (attempt: number) => ({
|
||||
type: "system" as const,
|
||||
subtype: "api_retry" as const,
|
||||
attempt,
|
||||
max_retries: 10,
|
||||
retry_delay_ms: 1_000,
|
||||
error_status: 503,
|
||||
error: "server_error" as const,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
const input = new Pushable<any>();
|
||||
async function* cli() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
for (const turn of [1, 2]) {
|
||||
const { value: userMessage } = await iter.next();
|
||||
const echo = {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
// The CLI holds the echo back until the first assistant frame or
|
||||
// stream event of the turn. A request that gets a 503 has neither, so
|
||||
// its retries come before the echo.
|
||||
if (turn === 1) {
|
||||
yield retry(1);
|
||||
yield retry(2);
|
||||
yield echo;
|
||||
yield createAssistantError("server_error", "API Error: 503");
|
||||
yield createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "API Error: 503",
|
||||
});
|
||||
} else {
|
||||
yield retry(1);
|
||||
yield echo;
|
||||
yield createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "ok",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(cli()), input });
|
||||
|
||||
const first = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
});
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
|
||||
const warnings = updates
|
||||
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
|
||||
.filter((failure) => failure?.severity === "warning");
|
||||
|
||||
expect(warnings.map((warning) => warning.revision)).toEqual([1, 2, 1]);
|
||||
expect(warnings[1].id).toBe(warnings[0].id);
|
||||
expect(warnings[0].id).toMatch(/:error$/);
|
||||
expect(sessionFailureFromResponse(first)).toEqual(
|
||||
expect.objectContaining({ id: warnings[0].id, revision: 3, severity: "error" }),
|
||||
);
|
||||
expect(warnings[2].id).not.toBe(warnings[0].id);
|
||||
});
|
||||
|
||||
it("replaces the cause of an api retry warning on the next retry", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) => updates.push(update),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, airSessionFailureCapabilities);
|
||||
const retry = (
|
||||
attempt: number,
|
||||
error_status: number | null,
|
||||
error: SDKAssistantMessageError,
|
||||
retry_delay_ms: number,
|
||||
) => ({
|
||||
type: "system" as const,
|
||||
subtype: "api_retry" as const,
|
||||
attempt,
|
||||
max_retries: 10,
|
||||
retry_delay_ms,
|
||||
error_status,
|
||||
error,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
injectSession(agent, [
|
||||
retry(1, 529, "overloaded", 8_000),
|
||||
// An SSE `overloaded_error` event after HTTP 200: the CLI reports
|
||||
// `overloaded` with no status.
|
||||
retry(2, null, "overloaded", 1_100),
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "ok",
|
||||
}),
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
const warnings = updates
|
||||
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
revision: 1,
|
||||
title: "Retrying Claude, attempt 1 of 10.",
|
||||
details: "HTTP 529 (overloaded). Next attempt in 8s.",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: warnings[0].id,
|
||||
revision: 2,
|
||||
title: "Reconnecting to Claude, attempt 2 of 10.",
|
||||
details: "No HTTP status (overloaded). Next attempt in 1s.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("says how long a no-response retry waited and will wait", async () => {
|
||||
@@ -12006,7 +12146,7 @@ describe("stop reason propagation", () => {
|
||||
subtype: "api_retry",
|
||||
attempt: 2,
|
||||
max_retries: 5,
|
||||
retry_delay_ms: 100,
|
||||
retry_delay_ms: 1_100,
|
||||
error_status: null,
|
||||
error: "unknown",
|
||||
uuid: randomUUID(),
|
||||
@@ -12588,7 +12728,7 @@ describe("stop reason propagation", () => {
|
||||
subtype: "api_retry",
|
||||
attempt: 1,
|
||||
max_retries: 10,
|
||||
retry_delay_ms: 100,
|
||||
retry_delay_ms: 600,
|
||||
error_status: 401,
|
||||
error: "authentication_failed",
|
||||
uuid: randomUUID(),
|
||||
|
||||
@@ -229,6 +229,7 @@ import { decodeClaudePermissionResponse } from "../permissions/response.js";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
import {
|
||||
activeUsageLimitMessage,
|
||||
apiRetryWarning,
|
||||
airSessionFailureCapabilityMeta,
|
||||
assistantMessageText,
|
||||
type ClaudeFailureKind,
|
||||
@@ -3605,12 +3606,17 @@ export class ClaudeAcpAgent {
|
||||
kind: ClaudeFailureKind,
|
||||
options: {
|
||||
turnScoped?: boolean;
|
||||
/** The turn the failure belongs to, when it is not `activeTurn`. */
|
||||
turnId?: string;
|
||||
title?: string;
|
||||
details?: string;
|
||||
severity?: "warning" | "error";
|
||||
} = {},
|
||||
) => {
|
||||
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
||||
const turnId =
|
||||
options.turnScoped === false
|
||||
? undefined
|
||||
: (options.turnId ?? session.activeTurn?.promptUuid);
|
||||
await sessionFailures.publish(kind, {
|
||||
turnId,
|
||||
sessionScoped: options.turnScoped === false,
|
||||
@@ -5589,19 +5595,20 @@ export class ClaudeAcpAgent {
|
||||
// here would outlive the `auth_required` rejection with no
|
||||
// action to clear it (issue #1072).
|
||||
if (kind === "auth_required") break;
|
||||
// `no_response` (SDK 0.3.261+): the API sent no response headers
|
||||
// within the first-byte window, so this retry waits longer for
|
||||
// them. Say so — "attempt 1 of 1" alone reads like a final
|
||||
// failure, and the wait is what the user is about to sit through.
|
||||
const seconds = (ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
const noResponse = message.no_response
|
||||
? ` No response after ${seconds(message.no_response.waited_ms)}; waiting up to ${seconds(message.no_response.retry_wait_ms)}.`
|
||||
: "";
|
||||
const title =
|
||||
message.error_status === null
|
||||
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`
|
||||
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`;
|
||||
await publishSessionFailure(kind, { title, severity: "warning" });
|
||||
// The CLI holds the echo of a prompt back until the first
|
||||
// assistant frame or stream event of the turn, so the retries
|
||||
// of a request that gets no response come before the echo
|
||||
// activates the turn. Give them the queue head, the prompt
|
||||
// being sent: all retries and the terminal failure of one turn
|
||||
// then share its `${turnId}:error` row.
|
||||
const retryTurn =
|
||||
session.activeTurn ??
|
||||
(asyncTasks.autonomousTurnOpen ? undefined : firstUnsettledQueuedTurn());
|
||||
await publishSessionFailure(kind, {
|
||||
turnId: retryTurn?.promptUuid,
|
||||
...apiRetryWarning(message),
|
||||
severity: "warning",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "model_refusal_fallback": {
|
||||
|
||||
Reference in New Issue
Block a user