diff --git a/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts b/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts index 51cd3ae111e..d66fb3c27cf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts +++ b/packages/paperclip-runner/src/drivers/acpx/provider-failure.ts @@ -48,6 +48,18 @@ function sessionLimitCategory(failure: AcpxTerminalSessionFailure | undefined): return undefined; } +/** + * Claude Code reports a model it cannot run (unknown, retired, or not on the + * account) as an AIR `request` failure with this fixed title. The user must + * choose another model, so it is an unavailable model, not a generic request. + */ +const CLAUDE_SELECTED_MODEL_REJECTED = + /^There's an issue with the selected model \([^()\n]{1,200}\)\. It may not exist or you may not have access to it\./; +function sessionModelCategory(agent: string, failure: AcpxTerminalSessionFailure | undefined): ProviderFailure["category"] | undefined { + return agent === "claude" && failure?.category === "request" && typeof failure.title === "string" && + CLAUDE_SELECTED_MODEL_REJECTED.test(failure.title.trim()) ? "model_unavailable" : undefined; +} + /** * `ownMessage` keeps an error's own message when it carries no ACP error. Use * it only where every rejection belongs to the provider: a failed terminal @@ -72,7 +84,8 @@ export function acpxProviderFailure(error: unknown, agent: string, phase: Provid const code = detail.type ?? detail.code ?? data.errorType ?? data.errorKind; const category = (typeof code === "string" ? CATEGORIES.get(code) : undefined) ?? (acp?.code === ACP_AUTH_REQUIRED ? "authentication" : undefined) - ?? sessionLimitCategory(sessionFailure); + ?? sessionLimitCategory(sessionFailure) + ?? sessionModelCategory(agent, sessionFailure); return createProviderFailure({ provider: agent === "claude" ? "anthropic" : agent === "codex" ? "openai" : agent, phase, message, httpStatus: status, providerCode: code, requestId: data.request_id, category, environment }); } diff --git a/packages/paperclip-runner/src/drivers/provider-failure.test.ts b/packages/paperclip-runner/src/drivers/provider-failure.test.ts index f0e48ca19a4..3f832b3a1cd 100644 --- a/packages/paperclip-runner/src/drivers/provider-failure.test.ts +++ b/packages/paperclip-runner/src/drivers/provider-failure.test.ts @@ -79,6 +79,23 @@ describe("provider failure boundary", () => { .toMatchObject({ category: "rate_limit", message: `${generic}\nClaude is temporarily rate limited.` }); }); + it("classifies Claude's rejection of the selected model as an unavailable model", () => { + const generic = "ACP agent reported a terminal request failure."; + const failed = { status: "failed", error: { code: "RUNTIME", message: generic } }; + // The AIR sessionFailure claude-agent-acp 0.73.0 returns for an unknown model (live probe, 2026-11-10). + const title = "There's an issue with the selected model (chaos-missing-model-abc12345). It may not exist or you may not have access to it. Run --model to pick a different model."; + expect(acpxTerminalResult(failed, "claude", {}, { category: "request", title })).toMatchObject({ status: "failed", error: { providerFailure: { + provider: "anthropic", phase: "turn", category: "model_unavailable", retryability: "requires_action", message: `${generic}\n${title}` } } }); + // The thrown form of the same terminal is classified the same way. + expect(acpxProviderFailure(new Error(generic), "claude", "turn", {}, false, { category: "request", title })) + .toMatchObject({ category: "model_unavailable", retryability: "requires_action" }); + // Another category, another agent, or another request failure stays unknown. + for (const [agent, category, text] of [["claude", "service", title], ["codex", "request", title], + ["claude", "request", "Claude could not process this request."]] as const) { + expect(acpxTerminalResult(failed, agent, {}, { category, title: text })).toMatchObject({ error: { providerFailure: { category: "unknown" } } }); + } + }); + it("keeps the ACPX terminal cause when the raw result exceeds its payload limit", () => { const terminal = acpxTerminalResult({ status: "failed", error: { ...acp, data: { ...acp.data, responseBody: "private-body".repeat(7000) } } }, "claude");