fix: keep the usage-limit error when it arrives after compaction (#1377)

Fixes #1370

After a compaction in the same turn, the synthetic usage-limit frame is
dropped by the stale-frame suppression before its failure details are
read. The prompt then ends with a generic internal error instead of the
quota error, so clients can't show the limit UI.

The suppression branch now records the usage-limit title and error
before it skips the frame. Only recognized usage-limit frames are read
there, so a stale replayed frame still can't overwrite the failure of
the current turn.

Added a test for a usage-limit frame after a completed compaction, for
both the legacy `errorKind` and the typed session failure, plus a stale
frame arriving after it. It fails without the change. `npm run check`,
`npm run build` and `npm run test:run` pass.
This commit is contained in:
Kerekes Dániel
2026-10-26 11:26:18 +01:00
committed by GitHub
parent 54f1e21bc4
commit 2a1525e57c
2 changed files with 57 additions and 0 deletions

View File

@@ -8047,6 +8047,13 @@ export class ClaudeAcpAgent {
message.message.model === "<synthetic>" && message.message.model === "<synthetic>" &&
compaction.hasDeliveredOutput compaction.hasDeliveredOutput
) { ) {
if (isSyntheticUsageLimitMessage(message.message)) {
lastAssistantWasUsageLimit = true;
lastAssistantFailureTitle = assistantMessageText(message.message);
if (message.error) {
lastAssistantError = message.error;
}
}
break; break;
} }

View File

@@ -12631,6 +12631,56 @@ describe("stop reason propagation", () => {
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" }); expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
}); });
it.each(
[false, true].flatMap((typedFailures) =>
[undefined, "authentication_failed" as const].map((staleError) => ({
typedFailures,
staleError,
})),
),
)(
"preserves a usage-limit failure after compaction (typed: $typedFailures, stale error: $staleError)",
async ({ typedFailures, staleError }) => {
const agent = createMockAgent();
if (typedFailures) await initializeClient(agent, airSessionFailureCapabilities);
const staleFrame = createAssistantError(staleError, "stale compaction output");
staleFrame.message.model = "<synthetic>";
injectSession(agent, [
{
type: "system",
subtype: "status",
status: null,
compact_result: "success",
uuid: "compact-completed",
session_id: "test-session",
},
createUsageLimitAssistantError(),
staleFrame,
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "private provider detail",
}),
]);
const prompt = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
if (typedFailures) {
expect(sessionFailureFromResponse(await prompt)).toMatchObject({
category: "limit",
actions: [],
title:
"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
});
} else {
await expect(prompt).rejects.toMatchObject({ data: { errorKind: "rate_limit" } });
}
},
);
it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => { it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => {
const agent = createMockAgent(); const agent = createMockAgent();
injectSession(agent, [ injectSession(agent, [