fix(claude): recognize ACP quota fallback errors (#13831)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Claude adapter reports failures to the recovery system.
> - Recovery must distinguish account quota from other provider limits.
> - The Claude ACP bridge has a default message for an account with no
quota.
> - The current classifier misses that message and returns
`acpx_turn_failed`.
> - This pull request recognizes that exact typed message and uses the
existing quota wait.

## Linked Issues or Issue Description

Refs #13651. This is a narrow follow-up to its typed quota
classification.
Related PRs #13549 and #10276 address broader quota classification and
reset handling. This change covers the bridge's exact default quota
message.

**What happened?**

A typed Claude ACP `limit` failure with the title `The Claude account
has no available quota.` returns `acpx_turn_failed`. The recovery result
has no quota label.

**Expected behavior**

Return `provider_quota`. Use the existing one-hour quota backoff when
the provider gives no reset time. Keep context, turn, rate, and
configured budget limits out of the quota path.

**Steps to reproduce**

1. Use the local ACP fixture to return the exact title above with
category `limit` and severity `error`.
2. Run the Claude adapter in oneshot or persistent mode.
3. Before this change, the new regression cases receive
`acpx_turn_failed` instead of `provider_quota` on ACPX 0.12.0 and
0.13.1.

**Paperclip version or commit**

Reproduced on `a959e4750`. Rebased onto current `master` before
submission.

**Deployment mode**

Local source checkout with isolated ACP child-process fixtures. No live
provider calls or customer-stack changes.

## What Changed

- Recognize the exact Claude bridge quota fallback only for typed
`limit` failures.
- Add real-process regression cases for both pinned ACPX versions and
both session modes.
- Verify that unrelated categories, positive quota wording, and
historical generic limit errors do not imply quota exhaustion.
- Document the fallback and its existing recovery backoff.

## Verification

- Red/green reproduction: all four new fallback cases failed before the
classifier change and passed afterward.
- 108 targeted tests passed across Claude ACP, quota, parser, and server
recovery suites.
- Claude adapter typecheck and build passed.
- Real-process tests verify that provider text stays out of results and
logs.
- Full local `pnpm -r typecheck` and `pnpm build` passed.
- The full local `pnpm test:run` attempt stopped after embedded
PostgreSQL could not load a missing library symlink. The dependency
setup was repaired in the worktree. The isolated database test then
passed. The complete test suites passed in GitHub CI.
- All 54 latest-head checks passed on
`3d4d4e65386c5b6023ba34e6a2abcd94cff9a9bc`. Two optional Storybook jobs
were skipped.
- Greptile: 5/5, with no inline review threads or requested changes.

## Risks

- Low risk. An exact match is required inside an existing typed `limit`
failure.
- If upstream changes this wording, this fallback can stop matching.
Existing quota-message detection remains in place.
- No schema, credential, or permission changes. Historical generic
errors remain ambiguous and are not reclassified.

## Model Used

OpenAI Codex, GPT-6. The session identifies the model family as GPT-6
but does not expose a more specific model ID or context-window size.
Used reasoning, repository inspection, code editing, and terminal test
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-22 16:01:20 -05:00
1 parent a10702a878
commit f447990d77
3 files changed
+39 -2

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
Claude ACP runs that end with a typed provider-quota error retain the quota
classification and any parsed reset time. Recovery waits until that time, or
uses its existing one-hour quota backoff when no reset time is available.
This includes the Claude bridge's typed “The Claude account has no available
quota.” fallback, which carries no reset timestamp.
The adapter inspects the terminal provider message in memory; the run result
and run log retain only the generic failure message, recovery labels, and reset
timestamp. Context, turn, rate, and configured budget limits are not treated as
@@ -4,7 +4,7 @@ import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { createClaudeAcpExecutor } from "./acp.js";
import { classifyClaudeTerminalSessionFailure, createClaudeAcpExecutor } from "./acp.js";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
@@ -82,12 +82,37 @@ it("classifies quota without a reset time for the existing recovery backoff", as
expect(result.retryNotBefore).toBeUndefined();
});
it.each([
["0.12.0", "oneshot"],
["0.12.0", "persistent"],
["0.13.1", "oneshot"],
["0.13.1", "persistent"],
])("recognizes the Claude bridge quota fallback with ACPX %s in %s mode", async (version, mode) => {
// @agentclientprotocol/claude-agent-acp's quota_exhausted fallback title.
const title = "The Claude account has no available quota.";
const { result, logs } = await executeFailure(
title, "limit", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined,
);
expect(result).toMatchObject({
exitCode: 1,
errorMessage: "ACP agent reported a terminal limit failure.",
errorCode: "provider_quota",
errorFamily: "provider_quota",
resultJson: { errorFamily: "provider_quota" },
});
expect(result.retryNotBefore).toBeUndefined();
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
it.each([
["Context window limit exceeded", "limit"],
["Maximum number of turns reached", "limit"],
["Configured budget limit reached", "limit"],
["Rate limit exceeded; retry later", "limit"],
["You've hit your session limit", "request"],
["The Claude account has no available quota.", "request"],
["The Claude account has available quota.", "limit"],
["The worker connection closed", "connection"],
])("keeps a non-quota typed failure out of quota recovery: %s", async (title, category) => {
const { result, logs } = await executeFailure(title, category);
@@ -97,3 +122,10 @@ it.each([
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
it("does not infer quota from the historical generic terminal-limit error", () => {
expect(classifyClaudeTerminalSessionFailure({
category: "limit",
title: "ACP agent reported a terminal limit failure.",
}, now)).toBeNull();
});
@@ -324,7 +324,10 @@ export function classifyClaudeTerminalSessionFailure(
// Only the provider's quota wording qualifies for a quota wait.
if (failure.category !== "limit") return null;
const surface = { errorMessage: [failure.title, failure.details].filter(Boolean).join("\n") };
if (!isClaudeProviderQuotaError(surface)) return null;
// claude-agent-acp uses this exact quota_exhausted fallback when no provider
// title is available. It does not match the CLI's usage-limit wording.
const isQuotaFallback = failure.title === "The Claude account has no available quota.";
if (!isQuotaFallback && !isClaudeProviderQuotaError(surface)) return null;
const retryNotBefore = extractClaudeRetryNotBefore(surface, now)?.toISOString();
return {
errorCode: "provider_quota",