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 }); }