From 35aff22dd3e7971547370029a6e4a6d0ee3fd5ac Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Nov 2026 03:26:17 -0600 Subject: [PATCH] fix(runner): name an ACP agent's rejection of an unadvertised model When the requested model is not in the ACP agent's advertised list and selecting it fails with an ACP error response, ACPX admission now throws ACP_MODEL_UNSUPPORTED (cause kept). The sidecar already maps that code to requested_model_unsupported, which the server classifies as native_provider_model_rejected, so the task asks the user to choose a model instead of failing as a generic session.open rejection. claude-agent-acp 0.73.0 answers session/set_config_option for an unknown model with JSON-RPC -32603 "Internal error", data.details "Invalid value for config option model: ". Transport failures and the rejection of an advertised model keep their original error. Co-Authored-By: Claude --- .../drivers/acpx/model-verification.test.ts | 28 +++++++++++++++++++ .../src/drivers/acpx/model-verification.ts | 25 ++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts b/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts index 6c36e3a0774..d816bf7fe67 100644 --- a/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/model-verification.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { requireVerifiedAcpxModel } from "./model-verification.js"; +import { acpxSidecarErrorCode } from "../../cli/acpx-sidecar-input.js"; describe("ACPX requested model verification", () => { it.each(["claude", "codex", "pi", "grok", "cursor", "copilot"] as const)("selects and verifies an unlisted model unchanged with %s", async agent => { @@ -29,6 +30,33 @@ describe("ACPX requested model verification", () => { expect(getStatus).toHaveBeenCalledTimes(1); }); + it.each(["claude", "codex", "pi", "grok", "cursor", "copilot"] as const)("names an ACP rejection of an unadvertised model as an unsupported model with %s", async agent => { + const model = "chaos-missing-model-abc12345"; + // claude-agent-acp 0.73.0 answers session/set_config_option for an unknown model with this + // JSON-RPC error; ACPX wraps it as below (live probe, 2026-11-10). + const acp = Object.assign(new Error("Internal error"), { + code: -32603, data: { details: `Invalid value for config option model: ${model}` }, + }); + const wrapped = new Error(`Failed session/set_config_option for model "${model}": Invalid value for config option model: ${model} (ACP -32603, adapter reported "Internal error")`, { cause: acp }); + const getStatus = vi.fn(async () => ({ models: { currentModelId: "default", availableModelIds: ["default", "opus", "sonnet"] } })); + const setModel = vi.fn(async () => { throw wrapped; }); + const failure = await requireVerifiedAcpxModel({ getStatus, setModel }, resolveQualifiedAcpxProfile(agent, model)) + .then(() => null, (error: unknown) => error as Error & { code?: unknown; retryable?: unknown }); + expect(failure).toMatchObject({ code: "ACP_MODEL_UNSUPPORTED", retryable: false }); + expect(failure?.cause).toBe(wrapped); + expect(acpxSidecarErrorCode(failure!)).toBe("ACP_MODEL_UNSUPPORTED"); + }); + + it("keeps a transport failure or an advertised model's rejection unchanged", async () => { + const acp = Object.assign(new Error("Internal error"), { code: -32603, data: { details: "busy" } }); + for (const [rejected, available] of [[new Error("socket closed"), ["default"]], [acp, ["default", "listed-model"]]] as const) { + const getStatus = vi.fn(async () => ({ models: { currentModelId: "default", availableModelIds: [...available] } })); + const setModel = vi.fn(async () => { throw rejected; }); + await expect(requireVerifiedAcpxModel({ getStatus, setModel }, resolveQualifiedAcpxProfile("claude", "listed-model"))) + .rejects.toBe(rejected); + } + }); + it("accepts an exact model already reported by the provider", async () => { const getStatus = vi.fn(async () => ({ models: { diff --git a/packages/paperclip-runner/src/drivers/acpx/model-verification.ts b/packages/paperclip-runner/src/drivers/acpx/model-verification.ts index 337d2612c77..1b0afdd4a8e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/model-verification.ts +++ b/packages/paperclip-runner/src/drivers/acpx/model-verification.ts @@ -38,7 +38,22 @@ export async function requireVerifiedAcpxModel( ); } // Catalogs may be incomplete. Let the provider accept or reject the exact ID. - await control.setModel(providerModel); + try { + await control.setModel(providerModel); + } catch (error) { + // The provider answered the selection of a model it does not advertise with + // an ACP error: it rejected that model. Name it as such so the task asks the + // user to choose a model. Transport failures and an advertised model's + // rejection keep their original error. + const advertised = status.models?.availableModelIds ?? []; + if (!advertised.includes(providerModel) && acpErrorResponse(error)) { + throw Object.assign( + new Error(`ACP agent rejected the requested model ${requestedModel}`, { cause: error }), + { code: "ACP_MODEL_UNSUPPORTED", retryable: false }, + ); + } + throw error; + } status = await control.getStatus(); } if (status.models?.currentModelId !== providerModel) { @@ -50,6 +65,14 @@ export async function requireVerifiedAcpxModel( return normalizeVerifiedModelStatus(status, profile); } +/** A JSON-RPC error response from the ACP agent, found on the error or its causes. */ +function acpErrorResponse(error: unknown, depth = 0): boolean { + if (!error || typeof error !== "object" || depth > 5) return false; + const entry = error as Record; + if (typeof entry.code === "number" && Number.isInteger(entry.code) && typeof entry.message === "string") return true; + return ["acp", "cause", "error"].some(key => acpErrorResponse(entry[key], depth + 1)); +} + function acpxModelVerificationError(code: string, message: string): Error { return Object.assign(new Error(message), { code }); }