mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 15:31:50 +00:00
Compare commits
1 Commits
main
...
fix/reject
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef225233f2 |
@@ -64,4 +64,4 @@ When an ACP client routes the agent through a gateway with `providers/set`, the
|
||||
|
||||
The model picker then offers only the SDK models whose resolved model the gateway lists, matched without dates, version suffixes (`-v1:0`), `-fast`, `@` suffixes, context hints (`[1m]`) and provider prefixes, after the `availableModels` allowlist above. On Bedrock, a model the CLI resolved to another region prefix is dropped as well. When the gateway does not list the model a session runs, that is the default model or a resumed session's last model, the session switches to the first remaining picker row.
|
||||
|
||||
A 4xx answer means the gateway has no listing: `providers/set` succeeds and nothing is narrowed. A failed listing (network error, a status other than 2xx or 4xx, unexpected body, the 10 s timeout, an invalid header, or a base URL with credentials) fails `providers/set` with a JSON-RPC internal error, and the previous provider stays in effect. A listing that matches no SDK model leaves the session with no model to offer: there is no model config option. Gateways set through `authenticate`, `vertex` providers, and native routing are not listed. Setting the same gateway again (the same protocol, base URL and headers) reuses its listing, unless it answered 4xx; `providers/set` and `providers/disable` apply in the order they arrive, and logout clears the routing after `claude auth logout`, including a `providers/set` still listing; `providers/disable` and logout drop it.
|
||||
A 4xx answer means the gateway has no listing: `providers/set` succeeds and nothing is narrowed. A failed listing (network error, a status other than 2xx or 4xx, unexpected body, the 10 s timeout, an invalid header, or a base URL with credentials) fails `providers/set` with a JSON-RPC internal error, and the previous provider stays in effect. If an authoritative listing removes every model from a nonempty allowed SDK catalog with concrete resolved model identities, session setup fails with an explanation to choose a different provider. An SDK catalog that is already empty, or has any unresolved identities among its allowed models, keeps its existing behavior. Gateways set through `authenticate`, `vertex` providers, and native routing are not listed. Setting the same gateway again (the same protocol, base URL and headers) reuses its listing, unless it answered 4xx; `providers/set` and `providers/disable` apply in the order they arrive, and logout clears the routing after `claude auth logout`, including a `providers/set` still listing; `providers/disable` and logout drop it.
|
||||
|
||||
@@ -12379,6 +12379,19 @@ export class ClaudeAcpAgent {
|
||||
resolvedProvider.bedrockRegionPrefix,
|
||||
)
|
||||
: settingsAllowedModels;
|
||||
// Alias-only catalogs do not identify every model the provider must serve.
|
||||
if (
|
||||
resolvedProvider?.listedModels !== undefined &&
|
||||
initializationResult.models.length > 0 &&
|
||||
settingsAllowedModels.length > 0 &&
|
||||
settingsAllowedModels.every((model) => Boolean(model.resolvedModel?.trim())) &&
|
||||
allowedModels.length === 0
|
||||
) {
|
||||
throw RequestError.internalError(
|
||||
{ providerId: PROVIDER_ID },
|
||||
"The selected provider offers no compatible Claude models. Choose a different provider and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const models = await getAvailableModels(
|
||||
q,
|
||||
|
||||
@@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
|
||||
import {
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
AIR_SESSION_FAILURE_CAPABILITY,
|
||||
} from "../air-extension.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import { initializeClient, makeMockQuery } from "./helpers.js";
|
||||
import { successfulResultMessage, userEcho } from "./session-doubles.js";
|
||||
@@ -516,6 +519,54 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
|
||||
},
|
||||
);
|
||||
|
||||
it("cleans up an incompatible provider restart and can create a session after recovery", async () => {
|
||||
await initializeClient(agent, {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: [AIR_SESSION_FAILURE_CAPABILITY] } } },
|
||||
});
|
||||
const { sessionId } = await newSession();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ data: [{ id: "gpt-5" }] })),
|
||||
);
|
||||
|
||||
await agent.unstable_setProvider({ ...PROVIDER, baseUrl: "https://incompatible.example" });
|
||||
|
||||
expect(agent.sessions[sessionId]).toBeUndefined();
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries.every((query) => query.close.mock.calls.length === 1)).toBe(true);
|
||||
expect(updates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({
|
||||
_meta: expect.objectContaining({
|
||||
jetbrains: expect.objectContaining({
|
||||
air: expect.objectContaining({
|
||||
sessionFailure: expect.objectContaining({
|
||||
details: expect.stringContaining(
|
||||
"The selected provider offers no compatible Claude models.",
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({
|
||||
data: MODELS.map((model) => ({ id: model.resolvedModel })),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
const recovered = await newSession();
|
||||
expect(agent.sessions[recovered.sessionId]).toBeDefined();
|
||||
expect(current(recovered.sessionId, "model")).toBe("sonnet");
|
||||
});
|
||||
|
||||
it("removes the old session when provider recreation fails", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
const held = deferred();
|
||||
@@ -748,6 +799,38 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
|
||||
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example");
|
||||
});
|
||||
|
||||
it("reports incompatible provider setup through the v2 protocol router", async () => {
|
||||
await agent.dispose();
|
||||
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ data: [{ id: "gpt-5" }] })),
|
||||
);
|
||||
await v2
|
||||
.client({ name: "provider-test" })
|
||||
.connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => {
|
||||
await client.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "provider-test", version: "1" },
|
||||
capabilities: {},
|
||||
});
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
await expect(client.request(v2.methods.agent.session.new, { cwd })).rejects.toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining(
|
||||
"The selected provider offers no compatible Claude models.",
|
||||
),
|
||||
});
|
||||
expect(Object.keys(agent.sessions)).toEqual([]);
|
||||
expect(lastQuery().close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the restored configuration through the v2 protocol router", async () => {
|
||||
await agent.dispose();
|
||||
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
|
||||
@@ -797,9 +797,49 @@ describe("providers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no model when the listing succeeds but matches none", async () => {
|
||||
it.each([{ listedModels: [] }, { listedModels: ["gpt-5"] }])(
|
||||
"rejects incompatible provider models $listedModels and recovers after a switch",
|
||||
async ({ listedModels }) => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
const setModel = mockQueryWithModels(mockQuery);
|
||||
stubListing(listedModels);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
await expect(
|
||||
agent.newSession({ cwd: process.cwd(), mcpServers: [] }),
|
||||
).rejects.toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining(
|
||||
"The selected provider offers no compatible Claude models.",
|
||||
),
|
||||
data: { providerId: "main" },
|
||||
});
|
||||
|
||||
expect(Object.keys(agent.sessions)).toEqual([]);
|
||||
const rejectedQuery = mockQuery.mock.results.at(-1)!.value;
|
||||
expect(rejectedQuery.close).toHaveBeenCalledOnce();
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
|
||||
stubListing(["claude-sonnet-5"]);
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://compatible-provider.example",
|
||||
});
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(modelOptionValues(response)).toEqual(["sonnet"]);
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves an empty SDK catalog when the provider has an authoritative listing", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
const setModel = mockQueryWithModels(mockQuery);
|
||||
mockQueryWithModels(mockQuery, []);
|
||||
stubListing(["gpt-5"]);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
@@ -811,7 +851,49 @@ describe("providers", () => {
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")).toBeUndefined();
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "alias-only", models: GATEWAY_MODELS.map(({ resolvedModel: _, ...model }) => model) },
|
||||
{
|
||||
name: "mixed",
|
||||
models: [GATEWAY_MODELS[0], { value: "custom", displayName: "Custom", description: "" }],
|
||||
},
|
||||
])("preserves a $name SDK catalog with unresolved model identities", async ({ models }) => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
mockQueryWithModels(mockQuery, models);
|
||||
stubListing(["claude-sonnet-5"]);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves an empty SDK catalog with configured model choices", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
mockQueryWithModels(mockQuery, []);
|
||||
stubListing(["gpt-5"]);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings: { availableModels: ["sonnet"] } } } },
|
||||
});
|
||||
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets a gateway that has no listing and narrows nothing", async () => {
|
||||
|
||||
@@ -3,14 +3,12 @@ import { beforeEach, vi } from "vitest";
|
||||
// `providers/set` lists the gateway's models with `fetch` (gateway-models.ts),
|
||||
// and a failed listing fails the call. Tests that set an `anthropic` or
|
||||
// `bedrock` provider would otherwise send that request to their example
|
||||
// gateway URLs, so every test gets an empty listing instead. An empty listing
|
||||
// narrows the picker to no model: a test that checks the picker after
|
||||
// `providers/set` stubs `fetch` with the models it expects. This default is
|
||||
// deliberate: listing fixture models for every test would hide which tests
|
||||
// depend on a listing.
|
||||
// gateway URLs, so every test gets a 404 instead. That means the gateway has
|
||||
// no listing and leaves the SDK catalog unchanged. Tests of model discovery
|
||||
// supply their own listing, including authoritative empty results.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ data: [], modelSummaries: [] }))),
|
||||
vi.fn(async () => new Response("No model listing", { status: 404 })),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user