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

@@ -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",