From f447990d7776b6c030fa80f30c226a23cbb9d243 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:01:20 -0500 Subject: [PATCH] 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 --- docs/adapters/claude-local.md | 2 ++ .../claude-local/src/server/acp.quota.test.ts | 34 ++++++++++++++++++- .../adapters/claude-local/src/server/acp.ts | 5 ++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 72ffd1c706..43d1b0e49b 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -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 diff --git a/packages/adapters/claude-local/src/server/acp.quota.test.ts b/packages/adapters/claude-local/src/server/acp.quota.test.ts index 0142226040..051a41a287 100644 --- a/packages/adapters/claude-local/src/server/acp.quota.test.ts +++ b/packages/adapters/claude-local/src/server/acp.quota.test.ts @@ -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(); +}); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index e68a92f9ca..02e8689ef2 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -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",