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"); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 78f65266595..be4537caf08 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -8210,6 +8210,11 @@ const NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION = const NATIVE_STARTUP_MODEL_REJECTED_ACTION = "The provider could not start the selected model before any provider work began. Choose a supported model for this agent, then retry the task. Automatic retries are stopped."; +// The provider session opened, then the provider refused the selected model on +// the turn. The task needs a model choice, not a reconciliation of session state. +const NATIVE_TURN_MODEL_REJECTED_ACTION = + "The provider rejected the selected model. Choose a supported model for this agent, then retry the task. Automatic retries are stopped."; + const NATIVE_CLEANUP_OPERATOR_RECOVERY_MESSAGE = "Send a new message to continue after Paperclip verifies that the previous provider and its tools have stopped. Clearing a task session does not resolve this quarantine. Automatic retries are stopped."; @@ -11913,6 +11918,8 @@ async function executePaperclipNativeSessionAttempt( ? "The work and waiting state are saved. Confirm shutdown of the retained execution and settle that same handoff without repeating provider work." : sourceFailureCode === "native_provider_model_rejected" && !recoveryEvidence.providerSessionEstablished ? NATIVE_STARTUP_MODEL_REJECTED_ACTION + : sourceFailureCode === "native_provider_model_rejected" + ? NATIVE_TURN_MODEL_REJECTED_ACTION : sourceFailureCode === NATIVE_PROVIDER_STATE_INCOMPATIBLE ? NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION : sourceFailureCode === NATIVE_PROVIDER_SIDE_EFFECT_PENDING @@ -12170,6 +12177,8 @@ async function executePaperclipNativeSessionAttempt( ? "Verify that the failed provider stopped and reconcile its action outcomes. A linked continuation can proceed only after these checks succeed." : sourceFailureCode === "native_provider_model_rejected" && !recoveryEvidence.providerSessionEstablished ? NATIVE_STARTUP_MODEL_REJECTED_ACTION + : sourceFailureCode === "native_provider_model_rejected" + ? NATIVE_TURN_MODEL_REJECTED_ACTION : sourceFailureCode === NATIVE_PROVIDER_STATE_INCOMPATIBLE ? NATIVE_PROVIDER_STATE_INCOMPATIBLE_ACTION : sourceFailureCode === NATIVE_PROVIDER_SIDE_EFFECT_PENDING diff --git a/server/src/services/native-runtime/native-startup-retry.integration.test.ts b/server/src/services/native-runtime/native-startup-retry.integration.test.ts index 936a705a4c3..4d75db6db41 100644 --- a/server/src/services/native-runtime/native-startup-retry.integration.test.ts +++ b/server/src/services/native-runtime/native-startup-retry.integration.test.ts @@ -33,7 +33,7 @@ import { executePaperclipNativeSession, prepareNativeSessionOpenTimeoutRetry, sc import { buildNativeCompletionContract } from "./completion-contracts.js"; import { prepareNativeHeartbeatRun } from "./prepare-native-run.js"; import { claimNativeSessionResumptions } from "./native-finalization-reconciler.js"; -import { DurablePrpControlPlane } from "../../vendor/paperclip-runner/index.js"; +import { DurablePrpControlPlane, NativeProviderTerminalFailure } from "../../vendor/paperclip-runner/index.js"; import { createNativeRunnerExecutionDriver } from "./native-runner-execution-driver.js"; import { currentNativeControllerIdentity } from "./native-restart-recovery.js"; import { loadNativeRunnerCheckpoint } from "./native-runner-checkpoint.js"; @@ -785,6 +785,41 @@ describe("native provider startup retry", () => { }, ); + it("asks for a model choice when the provider rejects the model on an opened session's turn (Claude ACPX)", async () => { + const seeded = await seedRun(await createAgent()); + provider.execute.mockReset().mockImplementation(async () => { + await db.insert(heartbeatRunEvents).values({ + companyId, runId: seeded.runId, agentId: seeded.agentId, seq: 900, + eventType: "turn.started", stream: "system", + }); + // What the native runtime throws for Claude's AIR "selected model" request failure. + throw new NativeProviderTerminalFailure("RUNTIME", false, + "native_provider_model_rejected: ACP agent reported a terminal request failure."); + }); + await expect(executePaperclipNativeSession({ + db, execution: seeded.execution, runnerInstanceId: seeded.runnerInstanceId, + backend: { + descriptor: async () => { throw new Error("Unexpected provider descriptor call"); }, + openSession: async () => { throw new Error("Unexpected provider session call"); }, + }, + })).rejects.toThrow("native_provider_model_rejected"); + + const state = await read(seeded); + expect(state.coordinator).toMatchObject({ + phase: "terminal_failure", + failureCode: "native_provider_model_rejected", + nextAttemptAt: null, + failureDetail: expect.objectContaining({ providerSessionEstablished: true }), + }); + expect(String(state.coordinator.failureDetail?.nextAction)).toContain("Choose a supported model"); + expect(String(state.coordinator.failureDetail?.nextAction)).not.toContain("ambiguous"); + expect(state.run).toMatchObject({ status: "failed", errorCode: "native_provider_model_rejected" }); + expect(state.issue.status).toBe("blocked"); + expect(state.action).toMatchObject({ ownerType: "board", cause: "native_provider_model_rejected", wakePolicy: null }); + expect(state.action!.nextAction).toContain("Choose a supported model"); + expect(state.action!.nextAction).not.toContain("ambiguous"); + }); + it("blocks with a clear reason once the bounded startup budget is exhausted", async () => { const seeded = await seedRun(await createAgent()); await failStartup(seeded, sessionOpenFailure());