fix: relay a rate_limit_event that arrives before the turn's first assistant frame (#1534)

## Problem

`rate_limit_event` is relayed to the client as
`usage_update._meta["_claude/rateLimit"]`, but only when
`lastAssistantTotalUsage !== null`. With partial messages on, the SDK
emits `rate_limit_event` before the turn's first `assistant` message,
while `lastAssistantTotalUsage` is still null (reset at each turn). The
SDK emits it once per turn, so in practice clients never receive
`_claude/rateLimit`.

Observed with a real subscription session (SDK 0.3.316): the SDK sends
`stream_event`s, then `rate_limit_event`, then the first `assistant`.
The adapter dropped the event and sent only `usage_update`s with
`_claude/model`.

## Fix

When the event arrives before the turn has any usage, keep its
`rate_limit_info` and attach it to the next `usage_update`. That happens
in `attachUsageModel`, which every `usage_update` already goes through.
A rate limit that the update already carries wins over the pending one.
When usage is already known, the event is sent right away, as before.

## Tests

- New test in `acp-agent.test.ts`: a `rate_limit_event` before the first
assistant frame ends up on the turn's first `usage_update`, and on no
later one. It fails without the fix.
- `npm run check`, `npm run build` and `npm run test:run` pass.
- Checked by hand over stdio with a real session: the turn's first
`usage_update` now carries `_claude/rateLimit`, including
`unifiedWindows.seven_day`.
This commit is contained in:
Alexis Rouillard
2026-11-06 23:07:33 +01:00
committed by GitHub
parent 6597626d86
commit 54392e144d
2 changed files with 71 additions and 2 deletions

View File

@@ -4660,6 +4660,8 @@ export class ClaudeAcpAgent {
let lastAssistantTotalUsage: number | null = null;
let lastAssistantUsage: UsageSnapshot | null = null;
let lastAssistantModel: string | null = null;
// A rate_limit_event that came before the turn had any usage to report.
let pendingRateLimit: unknown = null;
// 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
@@ -4967,7 +4969,8 @@ export class ClaudeAcpAgent {
};
// ACP's usage_update has no model field, so the model the usage belongs
// to rides in `_meta` alongside the other `_claude/*` keys.
// to rides in `_meta` alongside the other `_claude/*` keys, as does a
// pending rate limit.
const attachUsageModel = <
T extends {
sessionUpdate: "usage_update";
@@ -4978,6 +4981,13 @@ export class ClaudeAcpAgent {
>(
update: T,
): T => {
if (pendingRateLimit !== null) {
update = {
...update,
_meta: { "_claude/rateLimit": pendingRateLimit, ...(update._meta ?? {}) },
};
pendingRateLimit = null;
}
if (!lastAssistantModel) return update;
return {
...update,
@@ -8688,7 +8698,11 @@ export class ClaudeAcpAgent {
}
case "rate_limit_event": {
session.idleCompaction?.noteRateLimit(message.rate_limit_info);
if (lastAssistantTotalUsage !== null) {
// 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) {
pendingRateLimit = message.rate_limit_info;
} else {
await sendUpdate({
sessionId: params.sessionId,
update: attachUsageModel({

View File

@@ -30206,6 +30206,61 @@ describe("agent selection config option", () => {
});
});
describe("rate_limit_event", () => {
it("rides on the next usage_update when it comes before the turn's first assistant frame", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
const rateLimit = { status: "allowed", resetsAt: 1700000000, rateLimitType: "seven_day" };
const usage = {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
injectGeneratorSession(agent, async function* (input) {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield {
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: randomUUID(),
session_id: "test-session",
};
yield {
type: "assistant",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
message: {
id: "msg_rate",
role: "assistant",
model: "claude-sonnet-4-20250514",
content: [{ type: "text", text: "ok" }],
usage,
},
};
yield successfulResultMessage({ usage });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
const usageMetas = updates
.map((notification) => notification.update)
.filter((update) => update.sessionUpdate === "usage_update")
.map((update) => update._meta?.["_claude/rateLimit"]);
expect(usageMetas[0]).toEqual(rateLimit);
expect(usageMetas.slice(1)).toEqual(usageMetas.slice(1).map(() => undefined));
});
});
describe("the SDK session id of a fresh Claude context", () => {
// After a clear-context restart the SDK runs under a new session id, and
// every SDK message carries it. The client knows only the ACP session id.