mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-31 06:22:06 +00:00
Compare commits
3 Commits
fix/slash-
...
fix/config
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8749dd76f | ||
|
|
9f543dd1bf | ||
|
|
fb97d576fb |
@@ -2190,31 +2190,6 @@ const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start
|
||||
// message and without invoking the model.
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
// Built-in commands that never use attached context. Clients like Air attach
|
||||
// editor context (resources, extra text chunks) to every prompt, but Claude
|
||||
// Code runs a slash command only when the user message is a single text block.
|
||||
const CONTEXT_FREE_COMMANDS = new Set([
|
||||
...LOCAL_ONLY_COMMANDS,
|
||||
"/compact",
|
||||
"/usage",
|
||||
"/cost",
|
||||
"/stats",
|
||||
"/status",
|
||||
"/model",
|
||||
"/mcp",
|
||||
"/remote-control",
|
||||
]);
|
||||
|
||||
/** Drops the chunks after a context-free built-in command (see
|
||||
* `CONTEXT_FREE_COMMANDS`), keeping the command and its arguments. Returns
|
||||
* `prompt` itself when it isn't such a command. */
|
||||
export function withoutCommandContext(prompt: PromptRequest["prompt"]): PromptRequest["prompt"] {
|
||||
const first = prompt[0];
|
||||
if (prompt.length < 2 || first?.type !== "text") return prompt;
|
||||
const command = first.text.trimStart().split(/\s/, 1)[0];
|
||||
return CONTEXT_FREE_COMMANDS.has(command) ? [first] : prompt;
|
||||
}
|
||||
|
||||
// Commands whose marker-only transcript records are ACP/client-local UI noise,
|
||||
// not model-visible user prompts. Marker-only custom slash skills are not in
|
||||
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
|
||||
@@ -3926,9 +3901,6 @@ export class ClaudeAcpAgent {
|
||||
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
||||
}
|
||||
|
||||
const prompt = withoutCommandContext(params.prompt);
|
||||
if (prompt !== params.prompt) params = { ...params, prompt };
|
||||
|
||||
const remoteControlCommand = parseRemoteControlCommand(params.prompt);
|
||||
if (
|
||||
remoteControlCommand &&
|
||||
@@ -12246,15 +12218,15 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user's `availableModels` allowlist from settings.json before any
|
||||
// Apply the effective `availableModels` allowlist before any
|
||||
// downstream model handling. `initializationResult.models` is already
|
||||
// policy-filtered by the CLI; we rebuild the picker from the allowlist
|
||||
// so the user's exact spellings (and `modelOverrides` targets) are the
|
||||
// values passed to `setModel`, keeping configOptions, the current-model
|
||||
// resolver, and the stored modelInfos consistent with what the user
|
||||
// configured. Managed `deniedModels` drops entries the CLI would refuse.
|
||||
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
||||
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
|
||||
const { availableModels: settingsAvailableModels, modelOverrides: settingsModelOverrides } =
|
||||
settingsManager.getModelSelectionSettings(configuredSettingsObject);
|
||||
const settingsAllowedModels = Array.isArray(settingsAvailableModels)
|
||||
? applyAvailableModelsAllowlist(
|
||||
initializationResult.models,
|
||||
|
||||
@@ -50,6 +50,7 @@ export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private managedModelSettings: Pick<Settings, "availableModels" | "modelOverrides"> = {};
|
||||
private managedApiKeyHelper?: string;
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
@@ -112,6 +113,15 @@ export class SettingsManager {
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
this.managedModelSettings = Object.assign(
|
||||
{},
|
||||
...resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.map((entry) => ({
|
||||
availableModels: entry.settings.availableModels,
|
||||
modelOverrides: entry.settings.modelOverrides,
|
||||
})),
|
||||
);
|
||||
this.managedApiKeyHelper =
|
||||
resolved.provenance.apiKeyHelper?.source === "managed"
|
||||
? resolved.effective.apiKeyHelper
|
||||
@@ -120,6 +130,7 @@ export class SettingsManager {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
this.managedModelSettings = {};
|
||||
this.managedApiKeyHelper = undefined;
|
||||
}
|
||||
}
|
||||
@@ -193,6 +204,28 @@ export class SettingsManager {
|
||||
);
|
||||
}
|
||||
|
||||
/** Model selection settings including the inline SDK settings tier. */
|
||||
getModelSelectionSettings(
|
||||
override?: Settings,
|
||||
): Pick<Settings, "availableModels" | "modelOverrides"> {
|
||||
// The SDK unions non-managed allowlists, but a managed list owns the
|
||||
// whole allowlist. Keep that policy when adding
|
||||
// models the SDK did not include in its initialization result.
|
||||
const lists = [this.effective.availableModels, override?.availableModels].filter(
|
||||
(list): list is string[] => Array.isArray(list),
|
||||
);
|
||||
const managed = this.managedModelSettings;
|
||||
return {
|
||||
availableModels:
|
||||
managed.availableModels ?? (lists.length > 0 ? [...new Set(lists.flat())] : undefined),
|
||||
modelOverrides: {
|
||||
...this.effective.modelOverrides,
|
||||
...override?.modelOverrides,
|
||||
...managed.modelOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
|
||||
@@ -800,6 +800,136 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
return { setModelSpy };
|
||||
}
|
||||
|
||||
it.each(["environment", "caller object", "caller file"])(
|
||||
"includes configured models omitted by the SDK from %s settings",
|
||||
async (source) => {
|
||||
const configured = {
|
||||
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
|
||||
modelOverrides: {
|
||||
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"claude-haiku-5-5": "global.anthropic.claude-haiku-5-5",
|
||||
},
|
||||
};
|
||||
let settings: object | string | undefined;
|
||||
if (source === "environment") {
|
||||
vi.stubEnv("CLAUDE_MODEL_CONFIG", JSON.stringify(configured));
|
||||
} else if (source === "caller file") {
|
||||
settings = "caller-settings.json";
|
||||
await fs.promises.writeFile(path.join(tempDir, settings), JSON.stringify(configured));
|
||||
} else {
|
||||
settings = configured;
|
||||
}
|
||||
const { setModelSpy } = mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
]);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
try {
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings } } },
|
||||
});
|
||||
expect(querySpy.mock.calls[0][0].options.settings).toEqual(configured);
|
||||
const ids = ["default", ...Object.values(configured.modelOverrides)];
|
||||
expect(
|
||||
agent.sessions[response.sessionId].models.availableModels.map(
|
||||
(model: any) => model.modelId,
|
||||
),
|
||||
).toEqual(ids);
|
||||
const modelOption = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(modelOption.options.map((option: any) => option.value)).toEqual(ids);
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "model",
|
||||
value: ids[2],
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenLastCalledWith(ids[2]);
|
||||
expect(agent.sessions[response.sessionId].modelInfos.map((model) => model.value)).toEqual(
|
||||
ids,
|
||||
);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("merges file settings with caller settings after caller keys replace the env config", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-sonnet-4-6", "claude-haiku-4-5"],
|
||||
modelOverrides: {
|
||||
"claude-sonnet-4-6": "file-sonnet",
|
||||
"claude-haiku-4-5": "file-haiku",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubEnv(
|
||||
"CLAUDE_MODEL_CONFIG",
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6"],
|
||||
modelOverrides: { "claude-sonnet-4-6": "env-sonnet", "claude-haiku-4-5": "env-haiku" },
|
||||
}),
|
||||
);
|
||||
mockQueryWithModels([{ value: "default", displayName: "Default", description: "" }]);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
const settings = {
|
||||
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "caller-haiku" },
|
||||
};
|
||||
try {
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings } } },
|
||||
});
|
||||
expect(querySpy.mock.calls[0][0].options.settings).toEqual(settings);
|
||||
const modelOption = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(modelOption.options.map((option: any) => option.value)).toEqual([
|
||||
"default",
|
||||
"file-sonnet",
|
||||
"caller-haiku",
|
||||
"claude-haiku-5-5",
|
||||
]);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([[], undefined])(
|
||||
"preserves empty and absent configured allowlists: %j",
|
||||
async (availableModels) => {
|
||||
vi.stubEnv(
|
||||
"CLAUDE_MODEL_CONFIG",
|
||||
JSON.stringify({
|
||||
availableModels,
|
||||
modelOverrides: { "claude-haiku-4-5": "provider-haiku" },
|
||||
}),
|
||||
);
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
]);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
try {
|
||||
const response = await (agent as any).createSession({ cwd: tempDir, mcpServers: [] });
|
||||
const modelOption = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(modelOption.options.map((option: any) => option.value)).toEqual(
|
||||
availableModels ? ["default"] : ["default", "sonnet"],
|
||||
);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("restricts configOptions to the user's allowlist using their exact IDs", async () => {
|
||||
// Reproduces the scenario from
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/620:
|
||||
|
||||
@@ -36,7 +36,6 @@ import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
withoutCommandContext,
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
@@ -4717,50 +4716,6 @@ describe("escape markdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("withoutCommandContext", () => {
|
||||
const link = { type: "resource_link", uri: "file:///a.ts", name: "a.ts" } as const;
|
||||
const resource = {
|
||||
type: "resource",
|
||||
resource: { uri: "file:///a.ts", text: "const a = 1;" },
|
||||
} as const;
|
||||
|
||||
/** `/compact` with attached editor context keeps only the text chunk. */
|
||||
it("drops context after /compact", () => {
|
||||
const text = { type: "text", text: "/compact" } as const;
|
||||
expect(withoutCommandContext([text, link, resource])).toEqual([text]);
|
||||
});
|
||||
|
||||
/** Arguments in the command chunk survive; the attached resource goes. */
|
||||
it("keeps command arguments and drops context", () => {
|
||||
const text = { type: "text", text: "/compact focus on tests" } as const;
|
||||
expect(withoutCommandContext([text, resource])).toEqual([text]);
|
||||
});
|
||||
|
||||
/** An extra text chunk after `/usage` is dropped too. */
|
||||
it("drops trailing text chunks after /usage", () => {
|
||||
const text = { type: "text", text: "/usage" } as const;
|
||||
expect(withoutCommandContext([text, { type: "text", text: "editor context" }])).toEqual([text]);
|
||||
});
|
||||
|
||||
/** Skills and custom commands use attached context, so the prompt is untouched. */
|
||||
it("leaves custom commands unchanged", () => {
|
||||
const prompt = [{ type: "text", text: "/code-review" } as const, resource];
|
||||
expect(withoutCommandContext(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
/** A plain text prompt with a resource is untouched. */
|
||||
it("leaves plain prompts unchanged", () => {
|
||||
const prompt = [{ type: "text", text: "explain this" } as const, resource];
|
||||
expect(withoutCommandContext(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
/** `/mcp:server:cmd` is an MCP prompt, not the `/mcp` built-in. */
|
||||
it("leaves MCP prompt commands unchanged", () => {
|
||||
const prompt = [{ type: "text", text: "/mcp:server:cmd" } as const, resource];
|
||||
expect(withoutCommandContext(prompt)).toBe(prompt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt conversion", () => {
|
||||
it("should not change built-in slash commands", () => {
|
||||
const message = promptToClaude({
|
||||
|
||||
@@ -635,33 +635,25 @@ describe("/mcp", () => {
|
||||
expect(text()).toBe(formatMcpStatus(SERVERS));
|
||||
});
|
||||
|
||||
/** A client like Air attaches editor context to `/mcp`; the adapter drops it and still shows the status. */
|
||||
it("shows the status for /mcp with attached context", async () => {
|
||||
const { agent, sdkQuery, forwarded, text } = setup();
|
||||
it("leaves the text of other /mcp prompts unchanged", async () => {
|
||||
const { agent, sdkQuery, forwarded, text, prompt } = setup();
|
||||
|
||||
await prompt("/mcp:github:prompt");
|
||||
await prompt("/mcp help");
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [
|
||||
{ type: "text", text: "/mcp" },
|
||||
{ type: "resource", resource: { uri: "file:///a.ts", text: "const a = 1;" } },
|
||||
{ type: "text", text: "more" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(forwarded).toEqual(["/mcp"]);
|
||||
expect(text()).toBe(formatMcpStatus(SERVERS));
|
||||
expect(sdkQuery.mcpServerStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the text of other /mcp prompts unchanged", async () => {
|
||||
const { sdkQuery, forwarded, text, prompt } = setup();
|
||||
|
||||
await prompt("/mcp:github:prompt");
|
||||
await prompt("/mcp help");
|
||||
|
||||
// The adapter rewrites an MCP prompt command into the Claude Code form.
|
||||
expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help"]);
|
||||
expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help", "/mcp more"]);
|
||||
expect(text()).toBe(
|
||||
["/github:prompt (MCP)", "/mcp help"].map((command) => cliText(command, SERVERS)).join(""),
|
||||
["/github:prompt (MCP)", "/mcp help", "/mcp more"]
|
||||
.map((command) => cliText(command, SERVERS))
|
||||
.join(""),
|
||||
);
|
||||
expect(sdkQuery.mcpServerStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
92
src/tests/model-selection-settings.test.ts
Normal file
92
src/tests/model-selection-settings.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>()),
|
||||
resolveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("model selection settings policy", () => {
|
||||
let manager: SettingsManager;
|
||||
afterEach(() => manager?.dispose());
|
||||
|
||||
it.each([{ availableModels: ["claude-haiku-4-5"] }, { availableModels: [] }])(
|
||||
"keeps the managed allowlist %j and mappings above inline settings",
|
||||
async ({ availableModels }) => {
|
||||
const managed = {
|
||||
availableModels,
|
||||
modelOverrides: { "claude-haiku-4-5": "managed-haiku" },
|
||||
deniedModels: ["claude-opus-5"],
|
||||
};
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: managed,
|
||||
provenance: {},
|
||||
sources: [{ source: "managed", settings: managed }],
|
||||
});
|
||||
manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
expect(
|
||||
manager.getModelSelectionSettings({
|
||||
availableModels: ["claude-opus-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
|
||||
}),
|
||||
).toEqual({
|
||||
availableModels: managed.availableModels,
|
||||
modelOverrides: managed.modelOverrides,
|
||||
});
|
||||
expect(manager.getManagedDeniedModels()).toEqual(managed.deniedModels);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps managed override keys above inline keys without discarding other inline mappings", async () => {
|
||||
const managed = { modelOverrides: { "claude-haiku-4-5": "managed-haiku" } };
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: managed,
|
||||
provenance: {},
|
||||
sources: [{ source: "managed", settings: managed }],
|
||||
});
|
||||
manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
expect(
|
||||
manager.getModelSelectionSettings({
|
||||
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
|
||||
modelOverrides: {
|
||||
"claude-haiku-4-5": "inline-haiku",
|
||||
"claude-sonnet-4-6": "inline-sonnet",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
|
||||
modelOverrides: { "claude-haiku-4-5": "managed-haiku", "claude-sonnet-4-6": "inline-sonnet" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves file and inline mappings when the managed allowlist supplies no mappings", async () => {
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: {
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
|
||||
},
|
||||
provenance: {},
|
||||
sources: [
|
||||
{ source: "user", settings: { modelOverrides: { "claude-haiku-4-5": "file-haiku" } } },
|
||||
{ source: "managed", settings: { availableModels: ["claude-haiku-4-5"] } },
|
||||
],
|
||||
});
|
||||
manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
expect(manager.getModelSelectionSettings()).toEqual({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
|
||||
});
|
||||
expect(
|
||||
manager.getModelSelectionSettings({
|
||||
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
|
||||
}),
|
||||
).toEqual({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user