mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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: <id>". Transport failures and the rejection of an advertised model keep their original error. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
914e3ae14f
commit
35aff22dd3
2 files changed
+52
-1
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user