diff --git a/packages/adapters/claude-local/src/server/acp.limit.test.ts b/packages/adapters/claude-local/src/server/acp.limit.test.ts new file mode 100644 index 0000000000..1a0c7dd027 --- /dev/null +++ b/packages/adapters/claude-local/src/server/acp.limit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { mapClaudeAcpLimitErrorCode } from "./acp.js"; + +function turnFailure(overrides: Partial = {}): AdapterExecutionResult { + return { + exitCode: 1, + signal: null, + timedOut: false, + errorCode: "acpx_turn_failed", + errorMessage: null, + resultJson: { phase: "turn" }, + ...overrides, + }; +} + +describe("mapClaudeAcpLimitErrorCode", () => { + it("classifies a session-limit turn failure as provider_quota with the parsed reset time", () => { + const now = new Date("2026-09-10T03:18:53.000Z"); // 10:18 in Asia/Bangkok (UTC+7) + const result = mapClaudeAcpLimitErrorCode( + turnFailure({ + errorMessage: "Internal error: You've hit your session limit · resets 1:30pm (Asia/Bangkok)", + }), + now, + ); + expect(result.errorCode).toBe("provider_quota"); + expect(result.errorFamily).toBe("provider_quota"); + // 1:30pm Asia/Bangkok = 06:30 UTC, same day (still ahead of `now`). + expect(result.retryNotBefore).toBe("2026-09-10T06:30:00.000Z"); + expect(result.resultJson).toMatchObject({ + phase: "turn", + errorFamily: "provider_quota", + retryNotBefore: "2026-09-10T06:30:00.000Z", + transientRetryNotBefore: "2026-09-10T06:30:00.000Z", + providerQuotaRetryNotBefore: "2026-09-10T06:30:00.000Z", + }); + }); + + it("classifies a weekly-limit turn failure as provider_quota", () => { + const result = mapClaudeAcpLimitErrorCode( + turnFailure({ + errorMessage: "Internal error: You've hit your weekly limit · resets 4pm (America/Chicago)", + }), + new Date("2026-09-10T03:00:00.000Z"), + ); + expect(result.errorCode).toBe("provider_quota"); + expect(result.retryNotBefore).toBe("2026-09-10T21:00:00.000Z"); // 4pm CDT = 21:00 UTC + }); + + it("keeps the quota classification when the message states no reset time", () => { + const result = mapClaudeAcpLimitErrorCode( + turnFailure({ errorMessage: "Claude usage limit reached." }), + ); + expect(result.errorCode).toBe("provider_quota"); + expect(result.retryNotBefore).toBeUndefined(); + expect(result.resultJson).toMatchObject({ errorFamily: "provider_quota" }); + }); + + it("leaves non-limit turn failures untouched", () => { + const input = turnFailure({ errorMessage: "Internal error: something else entirely" }); + expect(mapClaudeAcpLimitErrorCode(input)).toBe(input); + }); + + it("leaves other error codes untouched even when the message mentions a limit", () => { + const input = turnFailure({ + errorCode: "acpx_runtime_error", + errorMessage: "You've hit your session limit · resets 2pm (Asia/Bangkok)", + }); + expect(mapClaudeAcpLimitErrorCode(input)).toBe(input); + }); + + it("never classifies from transcript noise: only the error surface is read", () => { + const input = turnFailure({ + errorMessage: "Internal error: unrelated failure", + resultJson: { + phase: "turn", + stdout: "the agent transcript casually mentions: you've hit your session limit · resets 9am", + }, + }); + expect(mapClaudeAcpLimitErrorCode(input)).toBe(input); + }); +}); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index b9f8724215..96b91e9096 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -42,6 +42,11 @@ import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime, } from "./claude-config.js"; +import { + extractClaudeRetryNotBefore, + isClaudeProviderQuotaError, + isClaudeTransientUpstreamError, +} from "./parse.js"; import { buildAdapterTestTargetCheck, buildClaudeLoginRequiredHint, @@ -339,6 +344,57 @@ export function mapClaudeAcpAuthErrorCode( return { ...result, errorCode: CLAUDE_AUTH_REQUIRED_ERROR_CODE }; } +/** + * The generic error code the shared acpx engine emits when the provider turn + * fails. Claude limit exhaustion ("You've hit your session limit · resets + * 1:30pm (Asia/Bangkok)") surfaces this way on the ACP lane, so without a + * Claude-specific translation the recovery sweep cannot classify it as a + * quota wait and strands the issue in `blocked` ("No live execution path"). + */ +const ACPX_TURN_FAILED_ERROR_CODE = "acpx_turn_failed"; + +/** + * Translate a generic acpx turn failure into the same limit classification the + * Claude CLI lane already emits (`execute.ts`): `provider_quota` / + * `claude_transient_upstream` plus the parsed `retryNotBefore` reset time, in + * both the result fields and the `resultJson` keys the server recovery sweep + * reads (`retryNotBefore`, `transientRetryNotBefore`, + * `providerQuotaRetryNotBefore`). Classification deliberately reads only the + * run's error surface (`errorMessage`), never the transcript/stdout, which + * routinely *mentions* limits it is merely talking about. + */ +export function mapClaudeAcpLimitErrorCode( + result: AdapterExecutionResult, + now = new Date(), +): AdapterExecutionResult { + if (result.errorCode !== ACPX_TURN_FAILED_ERROR_CODE) return result; + const surface = { errorMessage: result.errorMessage ?? null }; + const providerQuota = isClaudeProviderQuotaError(surface); + const transientUpstream = !providerQuota && isClaudeTransientUpstreamError(surface); + if (!providerQuota && !transientUpstream) return result; + const retryNotBefore = extractClaudeRetryNotBefore(surface, now); + const iso = retryNotBefore ? retryNotBefore.toISOString() : null; + const errorFamily = providerQuota ? "provider_quota" as const : "transient_upstream" as const; + const priorResultJson = parseObject(result.resultJson); + return { + ...result, + errorCode: providerQuota ? "provider_quota" : "claude_transient_upstream", + errorFamily, + ...(iso ? { retryNotBefore: iso } : {}), + resultJson: { + ...priorResultJson, + errorFamily, + ...(iso + ? { + retryNotBefore: iso, + transientRetryNotBefore: iso, + ...(providerQuota ? { providerQuotaRetryNotBefore: iso } : {}), + } + : {}), + }, + }; +} + export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ClaudeAcpExecutor { let executor: ClaudeAcpExecutor | null = null; return async (ctx) => { @@ -356,7 +412,7 @@ export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ...ctx, config: buildClaudeAcpConfig(ctx.config, target?.kind === "remote" ? {} : process.env), }); - return mapClaudeAcpAuthErrorCode(result); + return mapClaudeAcpLimitErrorCode(mapClaudeAcpAuthErrorCode(result)); }; }