mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user