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