mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-14 21:31:52 +00:00
feat: report the turn's rate limit in rate-limit failure errors (#1610)
When a turn fails with `errorKind: "rate_limit"`, the error's `data` now
also carries the turn's latest `rate_limit_event` info as `rateLimit`:
```json
"data": {
"errorKind": "rate_limit",
"rateLimit": { "status": "rejected", "rateLimitType": "five_hour", "resetsAt": 1763002200 }
}
```
## Why
ACP clients may want to handle a rate-limit failure better than showing
its message, for example by showing when the limit resets or offering to
continue the turn then. That needs the reset time as data. Today the
adapter forwards `rate_limit_event` info only on a `usage_update`
(`_meta["_claude/rateLimit"]`). A message sent while the account is
already over its limit is refused before its first usage, so no
`usage_update` goes out and the client gets only:
```json
"error": { "code": -32603, "message": "Internal error: You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)", "data": { "errorKind": "rate_limit" } }
```
Without this change, the only way to get the reset time is parsing that
text.
## What changed
- `turnErrorData` in `src/result-end.ts` builds the `data` of the error
that fails a turn. It adds `rateLimit` only for a `rate_limit` failure
in a turn that saw a `rate_limit_event`. Other failures keep their
current data.
- v2: `TurnReport` records the turn's latest `rate_limit_event` info and
clears it on `reset()`. `idleOutcome` passes it to `turnErrorData`.
- v1: the prompt loop tracks `turnRateLimit` alongside
`lastAssistantError`, resets it with the other per-turn scratch state,
and passes it at the three result-failure sites that used
`errorKindData(lastAssistantError)`.
## Testing
- New v1 and v2 tests: a `rate_limit_event` followed by a `rate_limit`
failure puts `rateLimit` in the error data.
- `npm run build`, `npm run check` and `npm run test:run` pass.
- Manually tested with a locally built binary: a client received
`rateLimit` in the error for a message refused at the 5-hour limit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<K extends string>(
|
||||
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" }
|
||||
/**
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user