diff --git a/src/result-end.ts b/src/result-end.ts index 9a117515..a8557598 100644 --- a/src/result-end.ts +++ b/src/result-end.ts @@ -8,9 +8,26 @@ import type { SDKMessage, SDKMessageOrigin, + SDKRateLimitInfo, SDKResultMessage, } from "@anthropic-ai/claude-agent-sdk"; +/** + * The `data` of the error that fails a turn, for clients to dispatch on + * without matching its text: the assistant message's `errorKind`, and for a + * `rate_limit` failure the turn's latest `rate_limit_event` info as + * `rateLimit`, whose `resetsAt` says when the account can run again. A turn + * refused before its first usage sends no `usage_update` to carry that info. + * `undefined` without an error kind. + */ +export function turnErrorData( + errorKind: K | undefined, + rateLimit: SDKRateLimitInfo | undefined, +): { errorKind: K; rateLimit?: SDKRateLimitInfo } | undefined { + if (!errorKind) return undefined; + return errorKind === "rate_limit" && rateLimit ? { errorKind, rateLimit } : { errorKind }; +} + export type ResultEnd = | { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" } /** diff --git a/src/tests/v1/acp-agent.test.ts b/src/tests/v1/acp-agent.test.ts index c9a064f4..bff13dae 100644 --- a/src/tests/v1/acp-agent.test.ts +++ b/src/tests/v1/acp-agent.test.ts @@ -13406,6 +13406,30 @@ describe("stop reason propagation", () => { expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" }); }); + it("adds the turn's rate limit to a rate-limit failure's data", async () => { + const agent = createMockAgent(); + const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" }; + injectSession(agent, [ + { + type: "rate_limit_event", + rate_limit_info: rateLimit, + uuid: randomUUID(), + session_id: "test-session", + }, + createAssistantError("rate_limit"), + createResultMessage({ + subtype: "success", + stop_reason: "end_turn", + is_error: true, + result: "You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)", + }), + ]); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }), + ).rejects.toMatchObject({ data: { errorKind: "rate_limit", rateLimit } }); + }); + it.each( [false, true].flatMap((typedFailures) => [undefined, "authentication_failed" as const].map((staleError) => ({ diff --git a/src/tests/v2/v2.test.ts b/src/tests/v2/v2.test.ts index ba6b7d4e..a5baf5c5 100644 --- a/src/tests/v2/v2.test.ts +++ b/src/tests/v2/v2.test.ts @@ -2467,6 +2467,29 @@ describe("ACP v2 core", () => { }); }); + it("adds the turn's rate limit to a rate-limit failure's error data", async () => { + const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" }; + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send({ + type: "rate_limit_event", + rate_limit_info: rateLimit, + uuid: "00000000-0000-4000-8000-00000000e002", + session_id: "session", + }); + claude.send({ ...assistant("msg_limited", []), error: "rate_limit" }); + claude.send(result({ is_error: true, result: "You've hit your 5-hour limit" })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ + stopReason: "error", + error: { data: { errorKind: "rate_limit", rateLimit } }, + }); + }); + it.each([ ["a notice when the turn goes on to end", { result: "" }, "end_turn", 1], ["a notice once when the result repeats its text", {}, "end_turn", 1], diff --git a/src/v1/acp-agent.ts b/src/v1/acp-agent.ts index 707898b3..77214ae4 100644 --- a/src/v1/acp-agent.ts +++ b/src/v1/acp-agent.ts @@ -76,6 +76,7 @@ import { Query, query, SDKAssistantMessageError, + SDKRateLimitInfo, SDKActiveGoalMessage, SDKMessage, SDKMessageOrigin, @@ -421,7 +422,7 @@ import { providerCacheKeyFor, readContextWindow, } from "../context-window.js"; -import { isLoginRequiredText, resultEnd } from "../result-end.js"; +import { isLoginRequiredText, resultEnd, turnErrorData } from "../result-end.js"; import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js"; import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js"; import { subagentHookOptions } from "../subagent-configuration.js"; @@ -3094,6 +3095,8 @@ export class ClaudeAcpAgent { let lastAssistantModel: string | null = null; // A rate_limit_event that came before the turn had any usage to report. let pendingRateLimit: unknown = null; + // The turn's latest rate_limit_event info, for its failure (see `turnErrorData`). + let turnRateLimit: SDKRateLimitInfo | undefined; // When the Claude SDK classifies a turn as failed (e.g. rate limit, auth // problem, billing), it sets a categorical `error` field on the // `SDKAssistantMessage` that precedes the final `result` message. We capture @@ -3461,6 +3464,7 @@ export class ClaudeAcpAgent { lastAssistantUsage = null; lastAssistantModel = null; lastAssistantError = undefined; + turnRateLimit = undefined; lastAssistantWasUsageLimit = false; lastAssistantFailureTitle = undefined; lastRefusalExplanation = null; @@ -6079,7 +6083,10 @@ export class ClaudeAcpAgent { } await failActiveWithSessionFailure( providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), - internalErrorForClient(errorKindData(lastAssistantError), ended.error), + internalErrorForClient( + turnErrorData(lastAssistantError, turnRateLimit), + ended.error, + ), lastAssistantFailureTitle ?? ended.error, ); break; @@ -6139,7 +6146,10 @@ export class ClaudeAcpAgent { if ("error" in ended) { await failActiveWithSessionFailure( providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), - internalErrorForClient(errorKindData(lastAssistantError), ended.error), + internalErrorForClient( + turnErrorData(lastAssistantError, turnRateLimit), + ended.error, + ), lastAssistantFailureTitle ?? ended.error, ); break; @@ -6152,7 +6162,10 @@ export class ClaudeAcpAgent { if ("error" in ended) { await failActiveWithSessionFailure( LIMIT_FAILURE_CATEGORY[message.subtype], - internalErrorForClient(errorKindData(lastAssistantError), ended.error), + internalErrorForClient( + turnErrorData(lastAssistantError, turnRateLimit), + ended.error, + ), ended.error, ); break; @@ -6941,6 +6954,7 @@ export class ClaudeAcpAgent { } case "rate_limit_event": { session.idleCompaction?.noteRateLimit(message.rate_limit_info); + turnRateLimit = message.rate_limit_info; // With partial messages on, the SDK sends this before the turn's // first assistant frame, while there is no usage yet to send it with. if (lastAssistantTotalUsage === null) { diff --git a/src/v2/outcome.ts b/src/v2/outcome.ts index bda779a5..f44e2859 100644 --- a/src/v2/outcome.ts +++ b/src/v2/outcome.ts @@ -12,11 +12,12 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import type { SDKAssistantMessage, SDKAssistantMessageError, + SDKRateLimitInfo, SDKResultMessage, } from "@anthropic-ai/claude-agent-sdk"; import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js"; import { isSyntheticLoginMessage } from "../message-filters.js"; -import { resultEnd } from "../result-end.js"; +import { resultEnd, turnErrorData } from "../result-end.js"; import { assistantMessageText, providerFailureCategory } from "../session-failure-extension.js"; import { getMatchingModelUsage, @@ -96,7 +97,7 @@ export function idleOutcome( // authenticate: this login reached its time limit and cannot be // renewed." RequestError.authRequired(undefined, ended.loginRequired ? undefined : ended.error) - : RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error), + : RequestError.internalError(turnErrorData(kind, report?.rateLimit), ended.error), ); } @@ -118,6 +119,8 @@ function failed(error: RequestError): IdleOutcome { */ export class TurnReport { errorKind: SDKAssistantMessageError | undefined; + /** The turn's latest `rate_limit_event` info (see `turnErrorData`). */ + rateLimit: SDKRateLimitInfo | undefined; /** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */ loginRequired = false; /** The explanation of the turn's refusal. */ @@ -181,6 +184,7 @@ export class TurnReport { reset(): void { this.errorKind = undefined; + this.rateLimit = undefined; this.loginRequired = false; this.refusalExplanation = undefined; this.apiError = undefined; diff --git a/src/v2/session.ts b/src/v2/session.ts index b4c37f6d..74fcd4ac 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -1044,6 +1044,7 @@ export class V2Session implements RequestTarget { * output (see {@link SessionReports}). */ private report(message: SDKMessage): void { + if (message.type === "rate_limit_event") this.turnReport.rateLimit = message.rate_limit_info; if (message.type === "tool_progress") { this.output.toolProgress(message); return;