From e552b2445dc470db79853d8725a510d1ac3b911a Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Nov 2026 03:51:04 -0600 Subject: [PATCH] fix(runner): classify Claude's rejected selected model as an unavailable model claude-agent-acp passes the agent's model to Claude Code at session start and lists it, so ACPX admission accepts an unknown model. The first turn then ends with an AIR request failure titled "There's an issue with the selected model (). It may not exist or you may not have access to it." The runner reported it as an unknown provider failure, the server saw native_provider_terminal_failed, and the task stopped with provider_effect_inventory_unavailable instead of asking for a model. Classify that Claude title as model_unavailable (requires action), the category the native runtime already turns into native_provider_model_rejected. Co-Authored-By: Claude --- .../src/drivers/acpx/provider-failure.ts | 15 ++++++++++++++- .../src/drivers/provider-failure.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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");