Merge a5863bd5e7 into c9e3bb7ca4
This commit is contained in:
commit
3a40fbb595
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,11 @@ import {
|
|||
prepareClaudeConfigSeed,
|
||||
prepareSandboxClaudeProbeRuntime,
|
||||
} from "./claude-config.js";
|
||||
import {
|
||||
extractClaudeRetryNotBefore,
|
||||
isClaudeProviderQuotaError,
|
||||
isClaudeTransientUpstreamError,
|
||||
} from "./parse.js";
|
||||
import {
|
||||
buildAdapterTestTargetCheck,
|
||||
buildClaudeLoginRequiredHint,
|
||||
|
|
@ -354,6 +359,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) => {
|
||||
|
|
@ -371,7 +427,7 @@ export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}):
|
|||
...ctx,
|
||||
config: buildClaudeAcpConfig(ctx.config, target?.kind === "remote" ? {} : process.env),
|
||||
});
|
||||
return mapClaudeAcpAuthErrorCode(result);
|
||||
return mapClaudeAcpLimitErrorCode(mapClaudeAcpAuthErrorCode(result));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue