mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
## Summary A native subagent child session gets its model only from what the child reports. The update is a `config_option_update` with the child's session id and a single `model` select option. This follows the subagents RFD, [Child configuration](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx#child-configuration) (see also agentclientprotocol/agent-client-protocol#2482). ## Relation to #1417 #1417 reports a child's model from its responses, in the `rfd` form only, with the raw model id as the name. This PR keeps that source and extends it: - The `air` form gets the same update. - History replay sends each child's model from its transcript. - The value maps to an entry of the root session's `model` option, so a client can compare the child's model with the session's. There is no model at spawn. The adapter cannot tell the model that Claude Code picks for a new child, for example with `CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, or when an alias runs on the parent's exact model. So the first update comes with the child's first real response (`message.model`, not `<synthetic>`). A new update goes only when the entry changes. The configuration hooks of #1417 (`PostModelSwitch` and the others) now also run for `air` clients. A hook names the SDK agent id, so in the `air` form its options go to the agent's current generation session (`<agent id>:generation:<n>`). In the `air` form, no configuration goes after a child's end. ## Notification ```json { "sessionId": "<child session id>", "update": { "sessionUpdate": "config_option_update", "configOptions": [{ "id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": "opus[1m]", "options": [{ "value": "opus[1m]", "name": "Opus 4.6 (1M context)" }] }] } } ``` - The client cannot change a child's configuration, so the select offers only the current value. - No field is added to `subagent_spawned` or `subagent_update`. ## Matching a model id to an entry Best first: 1. the root session's current entry, when it runs the same model up to a context hint, such as `opus[1m]` or `default` for `claude-opus-4-6`; 2. the listed entry whose value or resolved model is the id; 3. a listed entry that runs the same model up to a context hint. When several entries run the model, a concrete entry comes before `default`, unless `default` is the current entry. A model id is compared as it is, so a dated or provider id (`claude-haiku-4-5-20251001`, `eu.anthropic.claude-sonnet-5`) matches only the same id: `ModelInfo.resolvedModel` is the wire id that a response reports. A model that matches no entry goes as its full id, with its display name when the session's model list has one. A `PostModelSwitch` hook of a child uses the same matching. ## Replay - A child without responses in its transcript gets no model. - `rfd` form: the replay sends a child's model right after its announcement, from its first response, and again when a later response changes it. During a load, the session's model list can be unknown, so the replay can send the full id. After the load response, a child's model goes again only when the list maps it to another entry. - `air` form: the replay ends every child, and nothing may follow the end. So each child's latest response model goes once the session creation ended, just before the child's end, as the entry of the session's model list. ## Validation - `npm run build` - `npm run check` - `npm run test:run`, three runs: 3315 passed, 30 skipped. - `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no failure. New tests cover: - no model before the child's first response, in both forms; - an update only when the entry changes; - no update to an ended `air` child, also with an update in flight at the end; - a hook's options under the current `air` generation; - matching for `opus[1m]` and `default`, concrete entries before `default`, listed entries, dated and provider ids, and unlisted ids; - the `PostModelSwitch` entry, in both forms; - an `air` load replay that maps a child's model only once the session exists; - `rfd` replay with an unlisted full id, and no duplicate update after the load response. The scenario snapshots for both forms and v2 are updated.
2256 lines
80 KiB
TypeScript
2256 lines
80 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import {
|
|
CreateElicitationRequest,
|
|
RequestError,
|
|
SessionNotification,
|
|
} from "@agentclientprotocol/sdk/unstable";
|
|
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
|
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
|
|
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
|
import type { AcpSessionNotification } from "../acp-subagents.js";
|
|
import { ALLOW_BYPASS } from "../permissions/modes.js";
|
|
import { NativeSubagentRuntime } from "../native-subagents.js";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
|
|
let capturedOptions: Options | undefined;
|
|
let contextUsageResult: (() => Promise<{ rawMaxTokens: number; model?: string }>) | undefined;
|
|
let contextUsageCalls: Array<{ detail?: "summary" | "full" } | undefined> = [];
|
|
let sessionMessages: Record<string, unknown>[];
|
|
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
|
|
let initModels: Record<string, unknown>[] | undefined;
|
|
/** Holds the CLI initialization until the test resolves it. */
|
|
let initGate: Promise<void> | undefined;
|
|
let initError: Error | undefined;
|
|
let setModelImpl: ((model: string) => Promise<void>) | undefined;
|
|
let interruptImpl: (() => Promise<undefined>) | undefined;
|
|
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
|
|
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
|
|
let mcpAuthenticateImpl: (serverName: string) => Promise<{
|
|
authUrl?: string;
|
|
requiresUserAction: boolean;
|
|
callbackExpected: boolean;
|
|
}>;
|
|
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
|
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
|
"@anthropic-ai/claude-agent-sdk",
|
|
);
|
|
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js");
|
|
return {
|
|
...actual,
|
|
query: (args: { prompt: unknown; options: Options }) => {
|
|
capturedOptions = args.options;
|
|
return makeMockQuery({
|
|
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
|
|
close: () => closeSpy(),
|
|
initializationResult: async () => {
|
|
await initGate;
|
|
if (initError) throw initError;
|
|
return initializationResponse();
|
|
},
|
|
setModel: (model: string) => (setModelImpl ? setModelImpl(model) : Promise.resolve()),
|
|
getContextUsage: (opts?: { detail?: "summary" | "full" }) => {
|
|
contextUsageCalls.push(opts);
|
|
return contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE);
|
|
},
|
|
mcpServerStatus: () => mcpServerStatusResult(),
|
|
mcpAuthenticate: (serverName: string) => mcpAuthenticateImpl(serverName),
|
|
});
|
|
},
|
|
getSessionMessages: vi.fn(() => sessionMessagesResult()),
|
|
};
|
|
});
|
|
|
|
function initializationResponse() {
|
|
return {
|
|
models: initModels ?? [
|
|
{
|
|
value: "claude-sonnet-4-6",
|
|
displayName: "Claude Sonnet",
|
|
description: "Fast",
|
|
supportsAutoMode: true,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
vi.mock("../tools.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
|
return {
|
|
...actual,
|
|
registerHookCallback: vi.fn(),
|
|
};
|
|
});
|
|
|
|
describe("createSession options merging", () => {
|
|
let agent: ClaudeAcpAgentType;
|
|
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
|
|
|
function createMockClient(): AcpClient {
|
|
return {
|
|
sessionUpdate: async (_notification: SessionNotification) => {},
|
|
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
|
readTextFile: async () => ({ content: "" }),
|
|
writeTextFile: async () => ({}),
|
|
} as unknown as AcpClient;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
capturedOptions = undefined;
|
|
contextUsageResult = undefined;
|
|
contextUsageCalls = [];
|
|
sessionMessages = [];
|
|
sessionMessagesResult = async () => sessionMessages;
|
|
vi.mocked(getSessionMessages).mockClear();
|
|
initModels = undefined;
|
|
initGate = undefined;
|
|
initError = undefined;
|
|
setModelImpl = undefined;
|
|
interruptImpl = undefined;
|
|
closeSpy = vi.fn<() => void>();
|
|
mcpServerStatusResult = async () => [];
|
|
mcpAuthenticateImpl = async () => ({
|
|
requiresUserAction: false,
|
|
callbackExpected: false,
|
|
});
|
|
|
|
vi.resetModules();
|
|
const acpAgent = await import("../acp-agent.js");
|
|
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
|
|
|
agent = new ClaudeAcpAgent(createMockClient());
|
|
});
|
|
|
|
for (const method of ["resumeSession", "loadSession"] as const) {
|
|
for (const limit of ["maxTurns", "maxBudgetUsd"] as const) {
|
|
it(`${method} applies added, tightened and removed ${limit} to the SDK query`, async () => {
|
|
const cwd = process.cwd();
|
|
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
|
|
|
for (const value of [10, 1, undefined]) {
|
|
const previousOptions = capturedOptions;
|
|
await agent[method]({
|
|
sessionId,
|
|
cwd,
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { [limit]: value } } },
|
|
});
|
|
|
|
expect(capturedOptions).not.toBe(previousOptions);
|
|
expect(capturedOptions?.[limit]).toBe(value);
|
|
expect(capturedOptions?.resume).toBe(sessionId);
|
|
}
|
|
});
|
|
}
|
|
|
|
it(`${method} reuses the SDK query when execution limits are unchanged`, async () => {
|
|
const cwd = process.cwd();
|
|
const { sessionId } = await agent.newSession({
|
|
cwd,
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { maxTurns: 10, maxBudgetUsd: 1 } } },
|
|
});
|
|
const previousOptions = capturedOptions;
|
|
|
|
await agent[method]({
|
|
sessionId,
|
|
cwd,
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { maxBudgetUsd: 1, maxTurns: 10 } } },
|
|
});
|
|
|
|
expect(capturedOptions).toBe(previousOptions);
|
|
});
|
|
}
|
|
|
|
describe("allowDangerouslySkipPermissions", () => {
|
|
it("requests bypass capability by default and mirrors it in the mode catalog", async () => {
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.allowDangerouslySkipPermissions).toBe(ALLOW_BYPASS);
|
|
expect(response.modes!.availableModes.some((mode) => mode.id === "bypassPermissions")).toBe(
|
|
ALLOW_BYPASS,
|
|
);
|
|
});
|
|
});
|
|
|
|
it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
// A v1 client gets the input's diff, so Claude Code waits on no hook.
|
|
expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined();
|
|
|
|
const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true });
|
|
await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.hooks?.PreToolUse).toEqual([
|
|
{ matcher: "Edit|Write", hooks: [expect.any(Function)] },
|
|
]);
|
|
});
|
|
|
|
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
|
|
const updates: AcpSessionNotification[] = [];
|
|
const client = createMockClient();
|
|
client.sessionUpdate = async (notification) => {
|
|
updates.push(notification);
|
|
};
|
|
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
|
|
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
|
|
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const session = observedAgent.sessions[sessionId]!;
|
|
const parentOptions = structuredClone(session.configOptions);
|
|
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
|
"rfd",
|
|
sessionId,
|
|
session,
|
|
(notification) => client.sessionUpdate(notification),
|
|
{ log: () => {} },
|
|
));
|
|
const invoke = async (
|
|
event: "PreToolUse" | "PostToolUse" | "PostModelSwitch" | "SubagentStart" | "SubagentStop",
|
|
fields: Record<string, unknown>,
|
|
) => {
|
|
const matcher = capturedOptions!.hooks![event]!.find((matcher) => !matcher.matcher);
|
|
expect(matcher).toBeDefined();
|
|
for (const hook of matcher!.hooks) {
|
|
await expect(
|
|
hook(
|
|
{
|
|
hook_event_name: event,
|
|
session_id: sessionId,
|
|
cwd: process.cwd(),
|
|
transcript_path: "/tmp/transcript",
|
|
...fields,
|
|
} as any,
|
|
undefined,
|
|
{ signal: new AbortController().signal },
|
|
),
|
|
).resolves.toMatchObject({ continue: true });
|
|
}
|
|
};
|
|
// A main-thread --agent persona has agent_type too, but no agent_id.
|
|
await invoke("PreToolUse", {
|
|
agent_type: "reviewer",
|
|
permission_mode: "plan",
|
|
effort: { level: "max" },
|
|
});
|
|
expect(updates).toEqual([]);
|
|
await invoke("SubagentStart", {
|
|
agent_id: "child",
|
|
agent_type: "Explore",
|
|
permission_mode: "acceptEdits",
|
|
});
|
|
expect(updates).toEqual([]);
|
|
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, async () => {});
|
|
const configs = () =>
|
|
updates.flatMap(({ update }) =>
|
|
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
|
|
);
|
|
expect(
|
|
configs()
|
|
.at(-1)
|
|
?.map((option) => [option.id, option.currentValue]),
|
|
).toEqual([
|
|
["mode", "acceptEdits"],
|
|
["agent", "Explore"],
|
|
]);
|
|
await invoke("PreToolUse", {
|
|
agent_id: "child",
|
|
agent_type: "Explore",
|
|
effort: { level: "high" },
|
|
});
|
|
expect(
|
|
configs()
|
|
.at(-1)
|
|
?.find((option) => option.id === "effort"),
|
|
).toMatchObject({
|
|
category: "thought_level",
|
|
currentValue: "high",
|
|
options: [{ value: "high", name: "High" }],
|
|
});
|
|
await invoke("PostToolUse", {
|
|
agent_id: "child",
|
|
tool_name: "EnterPlanMode",
|
|
permission_mode: "plan",
|
|
});
|
|
await invoke("PostModelSwitch", {
|
|
agent_id: "child",
|
|
to_model: "child-model",
|
|
source: "auto",
|
|
effort: { level: "low" },
|
|
});
|
|
await invoke("SubagentStop", { agent_id: "child", agent_type: "Explore" });
|
|
expect(
|
|
configs()
|
|
.at(-1)
|
|
?.map((option) => [option.id, option.currentValue]),
|
|
).toEqual([
|
|
["mode", "plan"],
|
|
["model", "child-model"],
|
|
["effort", "low"],
|
|
["agent", "Explore"],
|
|
]);
|
|
expect(
|
|
configs()
|
|
.at(-1)
|
|
?.every((option) => option.type === "select" && option.options.length === 1),
|
|
).toBe(true);
|
|
// A model of the root session's list goes as that list's entry, with its name.
|
|
const rootModel = session.configOptions.find((option) => option.id === "model");
|
|
const listed =
|
|
rootModel?.type === "select"
|
|
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
|
(entry) => entry.value !== "default",
|
|
)
|
|
: undefined;
|
|
expect(listed).toBeDefined();
|
|
await invoke("PostModelSwitch", { agent_id: "child", to_model: listed!.value, source: "auto" });
|
|
expect(
|
|
configs()
|
|
.at(-1)
|
|
?.find((option) => option.id === "model"),
|
|
).toMatchObject({
|
|
currentValue: listed!.value,
|
|
options: [{ value: listed!.value, name: listed!.name }],
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(session.configOptions).toEqual(parentOptions);
|
|
expect(
|
|
updates
|
|
.filter(({ update }) => update.sessionUpdate === "config_option_update")
|
|
.every(({ sessionId: target }) => target === "child"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("reports an AIR child's model switch under its current generation", async () => {
|
|
const updates: AcpSessionNotification[] = [];
|
|
const client = createMockClient();
|
|
client.sessionUpdate = async (notification) => {
|
|
updates.push(notification);
|
|
};
|
|
const observedAgent = new ClaudeAcpAgent(client);
|
|
await observedAgent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
|
|
} as ClientCapabilities,
|
|
});
|
|
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const session = observedAgent.sessions[sessionId]!;
|
|
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
|
"air",
|
|
sessionId,
|
|
session,
|
|
(notification) => client.sessionUpdate(notification),
|
|
{ log: () => {} },
|
|
));
|
|
const matcher = capturedOptions!.hooks!.PostModelSwitch!.find((matcher) => !matcher.matcher);
|
|
const switchModel = async (fields: Record<string, unknown>) => {
|
|
for (const hook of matcher!.hooks) {
|
|
await hook(
|
|
{
|
|
hook_event_name: "PostModelSwitch",
|
|
session_id: sessionId,
|
|
cwd: process.cwd(),
|
|
transcript_path: "/tmp/transcript",
|
|
source: "auto",
|
|
...fields,
|
|
} as any,
|
|
undefined,
|
|
{ signal: new AbortController().signal },
|
|
);
|
|
}
|
|
};
|
|
const deliver = async () => {};
|
|
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, deliver);
|
|
await runtime.finishTask("child", "completed", deliver);
|
|
await runtime.taskResumed("child", deliver, "Continue");
|
|
// The hook names the SDK agent id; AIR's draft runs the resumed agent in a new session.
|
|
await switchModel({ agent_id: "child", to_model: "claude-sonnet-4-6" });
|
|
const rootModel = session.configOptions.find((option) => option.id === "model");
|
|
const rootEntryName =
|
|
rootModel?.type === "select"
|
|
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
|
(entry) => entry.value === "claude-sonnet-4-6",
|
|
)?.name
|
|
: undefined;
|
|
expect(rootEntryName).toBeDefined();
|
|
const models = updates.flatMap(({ sessionId: target, update }) =>
|
|
update.sessionUpdate === "config_option_update"
|
|
? update.configOptions.map((option) => [target, option.currentValue, option])
|
|
: [],
|
|
);
|
|
expect(models).toEqual([
|
|
[
|
|
"child:generation:2",
|
|
"claude-sonnet-4-6",
|
|
expect.objectContaining({
|
|
id: "model",
|
|
// The name of the root session's entry for the model.
|
|
options: [{ value: "claude-sonnet-4-6", name: rootEntryName }],
|
|
}),
|
|
],
|
|
]);
|
|
});
|
|
|
|
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
disallowedTools: ["WebSearch", "WebFetch"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// User-provided tools should be present
|
|
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
|
expect(capturedOptions!.disallowedTools).toContain("WebFetch");
|
|
// ACP's internal disallowed tool should also be present
|
|
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
|
});
|
|
|
|
it("works when user provides no disallowedTools", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
|
|
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
|
});
|
|
|
|
it("works when user provides empty disallowedTools", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
disallowedTools: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
|
});
|
|
|
|
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
disableBuiltInTools: true,
|
|
claudeCode: {
|
|
options: {
|
|
disallowedTools: ["CustomTool"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// disableBuiltInTools removes all built-in tools from context
|
|
expect(capturedOptions!.tools).toEqual([]);
|
|
// User-provided and ACP disallowedTools still apply
|
|
expect(capturedOptions!.disallowedTools).toContain("CustomTool");
|
|
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
|
});
|
|
|
|
it("merges user-provided hooks with ACP hooks", async () => {
|
|
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
|
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
hooks: {
|
|
PreToolUse: [userPreToolUseHook],
|
|
PostToolUse: [{ hooks: [{ command: "echo user-post" }] }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// User's PreToolUse hooks should be preserved
|
|
expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]);
|
|
// PostToolUse should contain both user and ACP hooks
|
|
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
|
|
});
|
|
|
|
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
|
|
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
|
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
|
});
|
|
|
|
it("merges user-provided env vars on top of process.env", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
env: {
|
|
CUSTOM_VAR: "custom-value",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
|
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
|
expect(capturedOptions?.env?.CUSTOM_VAR).toBe("custom-value");
|
|
});
|
|
|
|
it("allows user-provided env vars to override process.env entries", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
env: {
|
|
HOME: "/custom/home",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
|
|
});
|
|
|
|
it("asks the SDK for a predicted follow-up prompt per turn", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
|
|
expect(capturedOptions!.promptSuggestions).toBe(true);
|
|
});
|
|
|
|
it("lets a caller turn the predicted follow-up prompt off", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { promptSuggestions: false } } },
|
|
});
|
|
|
|
expect(capturedOptions!.promptSuggestions).toBe(false);
|
|
});
|
|
|
|
it("defaults tools to claude_code preset when not provided", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
|
|
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
|
|
});
|
|
|
|
it("passes through user-provided tools string array", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
tools: ["Read", "Glob"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
|
});
|
|
|
|
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
disableBuiltInTools: true,
|
|
claudeCode: {
|
|
options: {
|
|
tools: ["Read", "Glob"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
|
});
|
|
|
|
it("passes through empty tools array to disable all built-in tools", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
tools: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.tools).toEqual([]);
|
|
});
|
|
|
|
it("recreates a resumed Query with changed skills and the same session ID", async () => {
|
|
const created = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: { skills: ["pdf"] },
|
|
},
|
|
},
|
|
});
|
|
const initialQuery = agent.sessions[created.sessionId]!.query;
|
|
expect(capturedOptions!.skills).toEqual(["pdf"]);
|
|
|
|
await agent.resumeSession({
|
|
sessionId: created.sessionId,
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: { skills: ["docx"] },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(Object.keys(agent.sessions)).toEqual([created.sessionId]);
|
|
expect(agent.sessions[created.sessionId]!.query).not.toBe(initialQuery);
|
|
expect(capturedOptions!.resume).toBe(created.sessionId);
|
|
expect(capturedOptions!.skills).toEqual(["docx"]);
|
|
});
|
|
|
|
describe("subagent transcript forwarding", () => {
|
|
it("keeps the legacy default when neither the client nor caller opts in", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.forwardSubagentText).toBe(false);
|
|
});
|
|
|
|
it("preserves caller-provided legacy transcript forwarding", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { forwardSubagentText: true } } },
|
|
});
|
|
|
|
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
|
});
|
|
|
|
it("accepts the legacy transcript extension", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { _meta: { "subagent-transcript": true } },
|
|
});
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { forwardSubagentText: false } } },
|
|
});
|
|
|
|
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
|
});
|
|
|
|
it("enables SDK forwarding after native ACP negotiation", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { subagents: {} } as ClientCapabilities,
|
|
});
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { forwardSubagentText: false } } },
|
|
});
|
|
|
|
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("per-task stop affordance", () => {
|
|
function sessionFor(sessionId: string) {
|
|
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
|
|
}
|
|
|
|
// Declared, Claude Code's interrupt spares background subagents, which a
|
|
// client on the subagents RFD form stops one at a time with its child's
|
|
// `cancel`. It spares background workflows too, which that client cannot
|
|
// stop, so the session's cancel stops them.
|
|
it("declares it for a client on the subagents RFD form", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { subagents: {} } as ClientCapabilities,
|
|
});
|
|
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.perTaskStopAffordance).toBe(true);
|
|
expect(sessionFor(sessionId).stopsWorkflowsOnCancel).toBe(true);
|
|
});
|
|
|
|
it("stops workflows on a cancel only when declared and the client cannot stop them", async () => {
|
|
const { sessionId: plain } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(sessionFor(plain).stopsWorkflowsOnCancel).toBe(false);
|
|
|
|
// A caller's own declaration counts.
|
|
const { sessionId: declared } = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { perTaskStopAffordance: true } } },
|
|
});
|
|
expect(sessionFor(declared).stopsWorkflowsOnCancel).toBe(true);
|
|
|
|
// AIR stops a workflow itself, as an async task.
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
|
} as ClientCapabilities,
|
|
});
|
|
const { sessionId: air } = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { perTaskStopAffordance: true } } },
|
|
});
|
|
expect(sessionFor(air).stopsWorkflowsOnCancel).toBe(false);
|
|
});
|
|
|
|
it("does not declare it for a client that cannot cancel one subagent", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.perTaskStopAffordance).toBeUndefined();
|
|
|
|
// AIR's draft grants a child no `cancel`.
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: {
|
|
subagents: {},
|
|
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
|
|
} as ClientCapabilities,
|
|
});
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.perTaskStopAffordance).toBeUndefined();
|
|
});
|
|
|
|
it("lets a caller's perTaskStopAffordance win", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { subagents: {} } as ClientCapabilities,
|
|
});
|
|
const { sessionId } = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { perTaskStopAffordance: false } } },
|
|
});
|
|
|
|
expect(capturedOptions!.perTaskStopAffordance).toBe(false);
|
|
expect(sessionFor(sessionId).stopsWorkflowsOnCancel).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("systemPrompt via _meta", () => {
|
|
it("defaults to the claude_code preset when not provided", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
});
|
|
});
|
|
|
|
it("appends AIR custom instructions to the claude_code preset", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
jetbrains: {
|
|
air: { customInstructions: "Follow the project rules." },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "Follow the project rules.",
|
|
});
|
|
});
|
|
|
|
it("ignores malformed AIR custom instructions", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
jetbrains: {
|
|
air: { customInstructions: 42 },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
});
|
|
});
|
|
|
|
it("gives the legacy system prompt precedence over AIR custom instructions", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
systemPrompt: "legacy prompt",
|
|
jetbrains: {
|
|
air: { customInstructions: "AIR instructions" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toBe("legacy prompt");
|
|
});
|
|
|
|
it("replaces the preset when a string is provided", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { systemPrompt: "custom prompt" },
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toBe("custom prompt");
|
|
});
|
|
|
|
it("forwards append", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { systemPrompt: { append: "extra instructions" } },
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "extra instructions",
|
|
});
|
|
});
|
|
|
|
it("forwards excludeDynamicSections", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
excludeDynamicSections: true,
|
|
});
|
|
});
|
|
|
|
it("forwards append and excludeDynamicSections together", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
systemPrompt: {
|
|
append: "extra instructions",
|
|
excludeDynamicSections: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "extra instructions",
|
|
excludeDynamicSections: true,
|
|
});
|
|
});
|
|
|
|
it("ignores caller-provided type/preset overrides", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
systemPrompt: {
|
|
type: "something-else",
|
|
preset: "other-preset",
|
|
append: "extra",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "extra",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("systemPrompt", () => {
|
|
const text = (value: string) => ({ type: "text" as const, text: value });
|
|
|
|
it("appends text blocks to the claude_code preset", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
systemPrompt: { mode: "append", content: [text("First."), text("Second.")] },
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "First.\n\nSecond.",
|
|
});
|
|
});
|
|
|
|
it("replaces the claude_code preset in override mode", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
systemPrompt: { mode: "override", content: [text("You are a release bot.")] },
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toBe("You are a release bot.");
|
|
});
|
|
|
|
it("takes precedence over _meta.systemPrompt and keeps AIR custom instructions", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
systemPrompt: { mode: "append", content: [text("Client prompt.")] },
|
|
_meta: {
|
|
systemPrompt: "legacy prompt",
|
|
jetbrains: { air: { customInstructions: "AIR instructions" } },
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "Client prompt.\n\nAIR instructions",
|
|
});
|
|
});
|
|
|
|
it("rejects an unsupported mode without creating a session", async () => {
|
|
capturedOptions = undefined;
|
|
await expect(
|
|
agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
systemPrompt: { mode: "_vendor", content: [text("x")] },
|
|
}),
|
|
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
|
expect(capturedOptions).toBeUndefined();
|
|
});
|
|
|
|
it("rejects content other than text without creating a session", async () => {
|
|
capturedOptions = undefined;
|
|
await expect(
|
|
agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
systemPrompt: {
|
|
mode: "append",
|
|
content: [{ type: "image", data: "", mimeType: "image/png" }],
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
|
expect(capturedOptions).toBeUndefined();
|
|
});
|
|
|
|
it("recreates a live session when resume sends a different systemPrompt", async () => {
|
|
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
capturedOptions = undefined;
|
|
|
|
await agent.resumeSession({
|
|
sessionId,
|
|
cwd: process.cwd(),
|
|
systemPrompt: { mode: "append", content: [text("New instructions.")] },
|
|
});
|
|
|
|
expect(capturedOptions!.systemPrompt).toEqual({
|
|
type: "preset",
|
|
preset: "claude_code",
|
|
append: "New instructions.",
|
|
});
|
|
});
|
|
|
|
it("rejects an unsupported systemPrompt on resume of a live session", async () => {
|
|
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
await expect(
|
|
agent.resumeSession({
|
|
sessionId,
|
|
cwd: process.cwd(),
|
|
systemPrompt: { mode: "_vendor", content: [text("x")] },
|
|
}),
|
|
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
|
});
|
|
});
|
|
|
|
describe("CLAUDE_MODEL_CONFIG", () => {
|
|
let originalModelConfig: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalModelConfig = process.env.CLAUDE_MODEL_CONFIG;
|
|
delete process.env.CLAUDE_MODEL_CONFIG;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalModelConfig !== undefined) {
|
|
process.env.CLAUDE_MODEL_CONFIG = originalModelConfig;
|
|
} else {
|
|
delete process.env.CLAUDE_MODEL_CONFIG;
|
|
}
|
|
});
|
|
|
|
it("passes modelOverrides as settings", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
});
|
|
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.settings).toEqual({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
});
|
|
});
|
|
|
|
it("passes availableModels as settings", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
|
availableModels: ["opus", "sonnet"],
|
|
});
|
|
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.settings).toEqual({
|
|
availableModels: ["opus", "sonnet"],
|
|
});
|
|
});
|
|
|
|
it("passes both modelOverrides and availableModels", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
availableModels: ["opus"],
|
|
});
|
|
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.settings).toEqual({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
availableModels: ["opus"],
|
|
});
|
|
});
|
|
|
|
it("does not add settings when env var is not set", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.settings).toBeUndefined();
|
|
});
|
|
|
|
it("keeps model settings when session settings add hooks", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
availableModels: ["opus"],
|
|
});
|
|
const hooks = { PostToolUse: [{ hooks: [{ type: "command", command: "echo ready" }] }] };
|
|
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { settings: { hooks } } } },
|
|
});
|
|
|
|
expect(capturedOptions!.settings).toEqual({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
availableModels: ["opus"],
|
|
hooks,
|
|
});
|
|
// The CLI adds these hooks to the ones in the user and project settings.
|
|
expect(capturedOptions!.settingSources).toEqual(["user", "project", "local"]);
|
|
});
|
|
|
|
it("applies session hooks on session/load with model settings", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
|
|
const cwd = process.cwd();
|
|
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
|
const hooks = { Stop: [{ hooks: [{ type: "command", command: "echo done" }] }] };
|
|
|
|
await agent.loadSession({
|
|
sessionId,
|
|
cwd,
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { settings: { hooks } } } },
|
|
});
|
|
|
|
expect(capturedOptions!.resume).toBe(sessionId);
|
|
expect(capturedOptions!.settings).toEqual({ availableModels: ["opus"], hooks });
|
|
});
|
|
|
|
it("merges model settings with a session settings file", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ availableModels: ["opus"] });
|
|
const settingsDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-settings-"));
|
|
const settingsFile = path.join(settingsDir, "settings.json");
|
|
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { Stop: [] } }));
|
|
|
|
try {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { settings: settingsFile } } },
|
|
});
|
|
|
|
expect(capturedOptions!.settings).toEqual({
|
|
availableModels: ["opus"],
|
|
hooks: { Stop: [] },
|
|
});
|
|
} finally {
|
|
fs.rmSync(settingsDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
it("prefers session settings when both sources set the same field", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
|
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
|
availableModels: ["opus"],
|
|
});
|
|
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
settings: {
|
|
model: "claude-sonnet-4-6",
|
|
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(capturedOptions!.settings).toEqual({
|
|
model: "claude-sonnet-4-6",
|
|
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
|
availableModels: ["opus"],
|
|
});
|
|
});
|
|
|
|
it("throws on invalid JSON", async () => {
|
|
process.env.CLAUDE_MODEL_CONFIG = "not-json";
|
|
|
|
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [
|
|
{
|
|
name: "acp-server",
|
|
command: "node",
|
|
args: ["acp-server.js"],
|
|
env: [],
|
|
},
|
|
],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
mcpServers: {
|
|
"user-server": {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["server.js"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// User-provided MCP server should be present
|
|
expect(capturedOptions!.mcpServers).toHaveProperty("user-server");
|
|
// ACP-provided MCP server should also be present
|
|
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
|
});
|
|
|
|
describe("thinking config from MAX_THINKING_TOKENS", () => {
|
|
let originalMaxThinking: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
|
|
delete process.env.MAX_THINKING_TOKENS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalMaxThinking !== undefined) {
|
|
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
|
|
} else {
|
|
delete process.env.MAX_THINKING_TOKENS;
|
|
}
|
|
});
|
|
|
|
it("leaves thinking unset (SDK default) when env var is absent", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toBeUndefined();
|
|
// The deprecated option must not be set either.
|
|
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
|
|
});
|
|
|
|
it("maps 0 to disabled thinking", async () => {
|
|
process.env.MAX_THINKING_TOKENS = "0";
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
|
|
});
|
|
|
|
it("maps a positive value to a fixed thinking budget", async () => {
|
|
process.env.MAX_THINKING_TOKENS = "12000";
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 });
|
|
});
|
|
|
|
it("ignores a non-numeric value", async () => {
|
|
process.env.MAX_THINKING_TOKENS = "lots";
|
|
// The bad value is deliberate — capture the agent's warning instead of
|
|
// letting it hit the console.
|
|
const errorSpy = vi.fn();
|
|
(agent as any).logger = { log: () => {}, error: errorSpy };
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toBeUndefined();
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("MAX_THINKING_TOKENS"));
|
|
});
|
|
|
|
it("lets a user-provided thinking option override the env default", async () => {
|
|
process.env.MAX_THINKING_TOKENS = "12000";
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: {
|
|
thinking: { type: "adaptive" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
|
|
});
|
|
});
|
|
|
|
describe("cwd validation", () => {
|
|
it("rejects a relative cwd with invalidParams", async () => {
|
|
await expect(
|
|
agent.newSession({ cwd: "relative/path", mcpServers: [] }),
|
|
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
|
});
|
|
|
|
it("rejects a non-existent cwd with invalidParams", async () => {
|
|
const missing = path.join(os.tmpdir(), "claude-acp-does-not-exist-xyz");
|
|
await expect(agent.newSession({ cwd: missing, mcpServers: [] })).rejects.toMatchObject({
|
|
code: RequestError.invalidParams().code,
|
|
});
|
|
});
|
|
|
|
it("rejects a cwd that points at a file with invalidParams", async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-cwd-is-a-file-"));
|
|
const file = path.join(dir, "not-a-directory.txt");
|
|
fs.writeFileSync(file, "not a directory");
|
|
try {
|
|
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
|
|
code: RequestError.invalidParams().code,
|
|
});
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("accepts an existing absolute directory", async () => {
|
|
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("elicitation", () => {
|
|
it("keeps AskUserQuestion disabled and omits callbacks without elicitation capability", async () => {
|
|
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
|
expect(capturedOptions!.onElicitation).toBeUndefined();
|
|
});
|
|
|
|
it("enables AskUserQuestion and wires the elicitation callback when form is supported", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { form: {} } },
|
|
});
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
|
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
|
});
|
|
|
|
it("wires callbacks for url-only elicitation but keeps AskUserQuestion disabled", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { url: {} } },
|
|
});
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
|
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
|
});
|
|
|
|
it("starts MCP OAuth as an ACP URL elicitation and completes it after reconnect", async () => {
|
|
let statusCall = 0;
|
|
mcpServerStatusResult = async () =>
|
|
statusCall++ === 0
|
|
? [{ name: "linear", status: "needs-auth" }]
|
|
: [{ name: "linear", status: "connected" }];
|
|
const authenticate = vi.fn(async () => ({
|
|
authUrl: "https://example.com/oauth/authorize",
|
|
requiresUserAction: true,
|
|
callbackExpected: true,
|
|
}));
|
|
mcpAuthenticateImpl = authenticate;
|
|
const createElicitation = vi.fn(async (_request: CreateElicitationRequest) => ({
|
|
action: "accept" as const,
|
|
}));
|
|
const completeElicitation = vi.fn(async () => {});
|
|
Object.assign((agent as unknown as { client: object }).client, {
|
|
createElicitation,
|
|
completeElicitation,
|
|
});
|
|
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { url: {} } },
|
|
});
|
|
const { sessionId } = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [
|
|
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp", headers: [] },
|
|
],
|
|
});
|
|
|
|
await vi.waitFor(() => expect(completeElicitation).toHaveBeenCalledOnce());
|
|
|
|
expect(authenticate).toHaveBeenCalledWith("linear");
|
|
expect(createElicitation).toHaveBeenCalledOnce();
|
|
const request = createElicitation.mock.calls[0]![0];
|
|
expect(request.mode).toBe("url");
|
|
if (request.mode !== "url") throw new Error("Expected a URL elicitation");
|
|
expect(request).toMatchObject({
|
|
mode: "url",
|
|
sessionId,
|
|
message: "Authenticate with MCP server linear",
|
|
url: "https://example.com/oauth/authorize",
|
|
elicitationId: expect.stringMatching(/^mcp-oauth-/),
|
|
});
|
|
expect(completeElicitation).toHaveBeenCalledWith({
|
|
elicitationId: request.elicitationId,
|
|
});
|
|
});
|
|
|
|
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { form: {} } },
|
|
});
|
|
await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { disallowedTools: ["WebSearch"] } } },
|
|
});
|
|
|
|
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
|
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
|
});
|
|
});
|
|
|
|
describe("session/close right after session/new", () => {
|
|
it("answers at once and closes the query", async () => {
|
|
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
await agent.closeSession({ sessionId });
|
|
|
|
expect(closeSpy).toHaveBeenCalledOnce();
|
|
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
|
|
// SDK control requests are serialized. On a fresh session the background
|
|
// getContextUsage is still in flight, so a real CLI answers an interrupt
|
|
// only after it, which took ~3s and timed out the client's close.
|
|
contextUsageResult = () => new Promise<never>(() => {});
|
|
interruptImpl = () => new Promise<never>(() => {});
|
|
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
const closed = await Promise.race([
|
|
agent.closeSession({ sessionId }).then(() => "closed"),
|
|
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
|
|
]);
|
|
|
|
expect(closed).toBe("closed");
|
|
expect(closeSpy).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("context window seeding", () => {
|
|
function sessionFor(sessionId: string) {
|
|
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
|
|
}
|
|
|
|
it("does not wait for getContextUsage during session creation", async () => {
|
|
// SDK control requests are serialized and getContextUsage used to stall
|
|
// until the first prompt turn, so session/new only kicks it off —
|
|
// awaiting it inline is what regressed session/new latency in 0.59.0.
|
|
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
|
|
contextUsageResult = ctxSpy;
|
|
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(ctxSpy).toHaveBeenCalledOnce();
|
|
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
|
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
|
});
|
|
|
|
it("refines a guessed window from getContextUsage in the background", async () => {
|
|
// The mock model ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") carries
|
|
// no "1m" token anywhere, so inference misses and the seed is the default
|
|
// until the background getContextUsage answers.
|
|
contextUsageResult = async () => ({ rawMaxTokens: 967000 });
|
|
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
await vi.waitFor(() => expect(sessionFor(response.sessionId).contextWindowSize).toBe(967000));
|
|
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(true);
|
|
});
|
|
|
|
it("asks for the summary context usage so the CLI sends no count_tokens requests", async () => {
|
|
// The default `full` detail counts each context category with its own
|
|
// messages/count_tokens request — a burst that hit rate limits on every
|
|
// model switch — while only `rawMaxTokens` is needed here.
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
await vi.waitFor(() => expect(contextUsageCalls).toEqual([{ detail: "summary" }]));
|
|
});
|
|
|
|
it("keeps the guessed window when getContextUsage reports a non-positive size", async () => {
|
|
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 0 }));
|
|
contextUsageResult = ctxSpy;
|
|
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
await vi.waitFor(() => expect(ctxSpy).toHaveBeenCalled());
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
|
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
|
});
|
|
|
|
it("session/load restores the transcript model without waiting for getContextUsage", async () => {
|
|
// getContextUsage is a live CLI control request. On a real, large resumed
|
|
// session it can take tens of seconds before returning, so it must stay
|
|
// off the load critical path. Claude restores from this same last
|
|
// assistant model field, which is available through a local transcript
|
|
// read in milliseconds.
|
|
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
|
|
contextUsageResult = ctxSpy;
|
|
initModels = [
|
|
{
|
|
value: "default",
|
|
displayName: "Default",
|
|
description: "Default model",
|
|
resolvedModel: "claude-sonnet-4-6",
|
|
},
|
|
{
|
|
value: "haiku",
|
|
displayName: "Haiku",
|
|
description: "Fast",
|
|
resolvedModel: "claude-haiku-4-5",
|
|
},
|
|
];
|
|
sessionMessages = [
|
|
{
|
|
type: "assistant",
|
|
uuid: "assistant-uuid",
|
|
session_id: "resumed-model-probe",
|
|
parent_tool_use_id: null,
|
|
parent_agent_id: null,
|
|
message: { model: "claude-haiku-4-5", role: "assistant", content: [] },
|
|
},
|
|
];
|
|
|
|
const response = await agent.loadSession({
|
|
sessionId: "resumed-model-probe",
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
|
|
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
|
|
"haiku",
|
|
);
|
|
// Kicked off in the background, never awaited: it never answers here.
|
|
expect(sessionFor("resumed-model-probe").contextWindowAuthoritative).toBe(false);
|
|
expect(getSessionMessages).toHaveBeenCalledTimes(1);
|
|
expect(getSessionMessages).toHaveBeenCalledWith("resumed-model-probe");
|
|
});
|
|
|
|
it("resume remains best-effort when the transcript hint cannot be read", async () => {
|
|
sessionMessagesResult = async () => {
|
|
throw new Error("unreadable transcript");
|
|
};
|
|
|
|
await expect(
|
|
agent.resumeSession({
|
|
sessionId: "unreadable-resume-probe",
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
}),
|
|
).resolves.toMatchObject({ sessionId: "unreadable-resume-probe" });
|
|
});
|
|
|
|
it("restores the transcript model for direct session/new resume metadata", async () => {
|
|
initModels = [
|
|
{
|
|
value: "default",
|
|
displayName: "Default",
|
|
description: "Default model",
|
|
resolvedModel: "claude-sonnet-4-6",
|
|
},
|
|
{
|
|
value: "haiku",
|
|
displayName: "Haiku",
|
|
description: "Fast",
|
|
resolvedModel: "claude-haiku-4-5",
|
|
},
|
|
];
|
|
sessionMessages = [
|
|
{
|
|
type: "assistant",
|
|
uuid: "assistant-uuid",
|
|
session_id: "direct-resume-probe",
|
|
parent_tool_use_id: null,
|
|
parent_agent_id: null,
|
|
message: { model: "claude-haiku-4-5", role: "assistant", content: [] },
|
|
},
|
|
];
|
|
|
|
const response = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: { claudeCode: { options: { resume: "direct-resume-probe" } } },
|
|
});
|
|
|
|
expect(response.sessionId).toBe("direct-resume-probe");
|
|
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
|
|
"haiku",
|
|
);
|
|
expect(getSessionMessages).toHaveBeenCalledOnce();
|
|
expect(getSessionMessages).toHaveBeenCalledWith("direct-resume-probe");
|
|
});
|
|
|
|
it("scopes providerCacheKey by per-session env routing", async () => {
|
|
// The context-window cache key must distinguish backends exactly as the
|
|
// CLI will see them: a session routed to a proxy via _meta env shares a
|
|
// model id spelling with default-routed sessions but not a context lane,
|
|
// so it must land in its own cache bucket (and two default-routed
|
|
// sessions must share one).
|
|
const r1 = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const r2 = await agent.newSession({
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
_meta: {
|
|
claudeCode: {
|
|
options: { env: { ANTHROPIC_BASE_URL: "https://window-probe-proxy.example" } },
|
|
},
|
|
},
|
|
});
|
|
const r3 = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(sessionFor(r2.sessionId).providerCacheKey).not.toBe(
|
|
sessionFor(r1.sessionId).providerCacheKey,
|
|
);
|
|
expect(sessionFor(r3.sessionId).providerCacheKey).toBe(
|
|
sessionFor(r1.sessionId).providerCacheKey,
|
|
);
|
|
});
|
|
|
|
it("scopes providerCacheKey by provider headers: same endpoint, different headers → different buckets", async () => {
|
|
// Two providers/set configs sharing apiType+baseUrl but differing in
|
|
// headers (e.g. an `anthropic-beta: context-1m-…` routing header) can
|
|
// serve different context lanes for the same model id, so they must not
|
|
// share a window-cache bucket.
|
|
await agent.unstable_setProvider({
|
|
providerId: "main",
|
|
apiType: "anthropic",
|
|
baseUrl: "https://gw.example",
|
|
headers: {},
|
|
});
|
|
const plain = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const plainProviderCacheKey = sessionFor(plain.sessionId).providerCacheKey;
|
|
|
|
await agent.unstable_setProvider({
|
|
providerId: "main",
|
|
apiType: "anthropic",
|
|
baseUrl: "https://gw.example",
|
|
headers: { "anthropic-beta": "context-1m-2025-08-07" },
|
|
});
|
|
const beta = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(sessionFor(beta.sessionId).providerCacheKey).not.toBe(plainProviderCacheKey);
|
|
});
|
|
});
|
|
|
|
describe("refusal fallback dialog", () => {
|
|
it("declares the dialog kind and callback when the client supports form elicitation", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { form: {} } },
|
|
});
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect(capturedOptions!.supportedDialogKinds).toEqual(["refusal_fallback_prompt"]);
|
|
expect(typeof capturedOptions!.onUserDialog).toBe("function");
|
|
});
|
|
|
|
it("omits the dialog wiring without form elicitation support (url-only included)", async () => {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { url: {} } },
|
|
});
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
// The CLI fails closed on the undeclared kind, so the flow degrades to
|
|
// the classic refusal error instead of parking a dialog we can't render.
|
|
expect(capturedOptions!.supportedDialogKinds).toBeUndefined();
|
|
expect(capturedOptions!.onUserDialog).toBeUndefined();
|
|
});
|
|
|
|
/** Wire the session up with form support and return the captured dialog
|
|
* callback plus a mock the test can point the elicitation response at. */
|
|
async function setupDialog() {
|
|
await agent.initialize({
|
|
protocolVersion: 1,
|
|
clientCapabilities: { elicitation: { form: {} } },
|
|
});
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const createElicitation = vi.fn();
|
|
(agent as unknown as { client: { createElicitation: unknown } }).client.createElicitation =
|
|
createElicitation;
|
|
return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation };
|
|
}
|
|
|
|
const signal = () => ({ signal: new AbortController().signal, requestId: "1" });
|
|
|
|
it("renders the prompt as a form elicitation and maps the retry choice", async () => {
|
|
const { onUserDialog, createElicitation } = await setupDialog();
|
|
createElicitation.mockResolvedValue({
|
|
action: "accept",
|
|
content: { choice: "retry_fallback" },
|
|
});
|
|
|
|
const result = await onUserDialog(
|
|
{
|
|
dialogKind: "refusal_fallback_prompt",
|
|
payload: {
|
|
originalModel: "claude-fable-5",
|
|
fallbackModel: "claude-opus-4-8",
|
|
apiRefusalCategory: "cyber",
|
|
},
|
|
},
|
|
signal(),
|
|
);
|
|
|
|
expect(result).toEqual({ behavior: "completed", result: "retry_fallback" });
|
|
const request = createElicitation.mock.calls[0][0];
|
|
expect(request.mode).toBe("form");
|
|
expect(request.message).toContain("claude-fable-5");
|
|
expect(request.message).toContain("claude-opus-4-8");
|
|
});
|
|
|
|
it("completes with cancelled when the user keeps the refusal", async () => {
|
|
const { onUserDialog, createElicitation } = await setupDialog();
|
|
createElicitation.mockResolvedValue({ action: "cancel" });
|
|
|
|
const result = await onUserDialog(
|
|
{
|
|
dialogKind: "refusal_fallback_prompt",
|
|
payload: { originalModel: "a", fallbackModel: "b" },
|
|
},
|
|
signal(),
|
|
);
|
|
|
|
expect(result).toEqual({ behavior: "completed", result: "cancelled" });
|
|
});
|
|
|
|
it("cancels unrecognized dialog kinds without presenting anything", async () => {
|
|
const { onUserDialog, createElicitation } = await setupDialog();
|
|
|
|
const result = await onUserDialog(
|
|
{ dialogKind: "some_future_dialog", payload: {} },
|
|
signal(),
|
|
);
|
|
|
|
expect(result).toEqual({ behavior: "cancelled" });
|
|
expect(createElicitation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("cancels on a malformed payload without presenting anything", async () => {
|
|
const { onUserDialog, createElicitation } = await setupDialog();
|
|
// The malformed payload is deliberate — capture the agent's warning
|
|
// instead of letting it hit the console.
|
|
const errorSpy = vi.fn();
|
|
(agent as any).logger = { log: () => {}, error: errorSpy };
|
|
|
|
const result = await onUserDialog(
|
|
{ dialogKind: "refusal_fallback_prompt", payload: { fallbackModel: 42 } },
|
|
signal(),
|
|
);
|
|
|
|
expect(result).toEqual({ behavior: "cancelled" });
|
|
expect(createElicitation).not.toHaveBeenCalled();
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("unexpected shape"));
|
|
});
|
|
|
|
it("cancels when the elicitation request fails", async () => {
|
|
const { onUserDialog, createElicitation } = await setupDialog();
|
|
createElicitation.mockRejectedValue(new Error("client exploded"));
|
|
// The client failure is deliberate — capture the agent's warning
|
|
// instead of letting it hit the console.
|
|
const errorSpy = vi.fn();
|
|
(agent as any).logger = { log: () => {}, error: errorSpy };
|
|
|
|
const result = await onUserDialog(
|
|
{
|
|
dialogKind: "refusal_fallback_prompt",
|
|
payload: { originalModel: "a", fallbackModel: "b" },
|
|
},
|
|
signal(),
|
|
);
|
|
|
|
expect(result).toEqual({ behavior: "cancelled" });
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("client exploded"));
|
|
});
|
|
});
|
|
|
|
describe("model switches and PreModelSwitch hooks", () => {
|
|
const TWO_MODELS = [
|
|
{
|
|
value: "claude-sonnet-4-6",
|
|
displayName: "Claude Sonnet",
|
|
description: "Fast",
|
|
supportsAutoMode: true,
|
|
},
|
|
{
|
|
value: "claude-opus-4-8",
|
|
displayName: "Claude Opus",
|
|
description: "Capable",
|
|
supportsAutoMode: true,
|
|
},
|
|
];
|
|
|
|
let originalAnthropicModel: string | undefined;
|
|
let originalClaudeConfigDir: string | undefined;
|
|
let configDir: string;
|
|
beforeEach(() => {
|
|
originalAnthropicModel = process.env.ANTHROPIC_MODEL;
|
|
delete process.env.ANTHROPIC_MODEL;
|
|
// These tests assert which model a fresh session lands on with no
|
|
// override in play, so both override tiers above the SDK's default have
|
|
// to be neutralized: ANTHROPIC_MODEL and `model` from the user
|
|
// settings tier. Without the second one, a developer whose own
|
|
// ~/.claude/settings.json pins a model (e.g. "opus[1m]") sees the
|
|
// session start on that model instead of models[0]. resolveSettings
|
|
// reads the user tier from CLAUDE_CONFIG_DIR at call time, so pointing
|
|
// it at an empty directory is enough.
|
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-model-config-"));
|
|
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
});
|
|
afterEach(() => {
|
|
if (originalAnthropicModel !== undefined) {
|
|
process.env.ANTHROPIC_MODEL = originalAnthropicModel;
|
|
} else {
|
|
delete process.env.ANTHROPIC_MODEL;
|
|
}
|
|
if (originalClaudeConfigDir !== undefined) {
|
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
|
} else {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
}
|
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("tolerates a PreModelSwitch hook vetoing the fresh-session model pin", async () => {
|
|
// CLI 2.1.251+: a user-configured PreModelSwitch hook can deny (or
|
|
// 'ask', which headless sessions refuse) the pin's setModel. Terminal
|
|
// Claude Code never lets a hook veto its startup model, so the same
|
|
// config must not fail session/new over ACP — the session stays on the
|
|
// SDK's default, and we report that.
|
|
initModels = TWO_MODELS;
|
|
process.env.ANTHROPIC_MODEL = "opus";
|
|
setModelImpl = () =>
|
|
Promise.reject(new Error("Model switch blocked by a PreModelSwitch hook: pinned by IT"));
|
|
|
|
const errorSpy = vi.fn();
|
|
(agent as any).logger = { log: () => {}, error: errorSpy };
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
|
|
expect((agent as any).sessions[response.sessionId].models.currentModelId).toBe(
|
|
"claude-sonnet-4-6",
|
|
);
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("vetoed by a PreModelSwitch hook"),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("still fails session/new loudly on a non-hook setModel failure", async () => {
|
|
initModels = TWO_MODELS;
|
|
process.env.ANTHROPIC_MODEL = "opus";
|
|
setModelImpl = () => Promise.reject(new Error("transport exploded"));
|
|
|
|
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow(
|
|
"transport exploded",
|
|
);
|
|
});
|
|
|
|
it("routes the PostCompact hook's summary into the session's compaction lifecycle", async () => {
|
|
// The retained summary only reaches the SDK stream framed as the
|
|
// model-facing continuation prompt; the hook is the adapter's source for
|
|
// the ACP compaction_update summary.
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const sessionId = response.sessionId;
|
|
|
|
const matchers = capturedOptions!.hooks?.PostCompact;
|
|
expect(matchers).toBeDefined();
|
|
const callback = (matchers!.at(-1) as any).hooks[0];
|
|
// The lifecycle belongs to the stream consumer, which the first prompt
|
|
// starts; a compaction can only ever fire while it is running.
|
|
(agent as any).ensureConsumer((agent as any).sessions[sessionId], sessionId);
|
|
const lifecycle = (agent as any).sessions[sessionId].contextCompaction;
|
|
expect(lifecycle).toBeDefined();
|
|
const recordSummary = vi.spyOn(lifecycle, "recordSummary");
|
|
|
|
const base = {
|
|
hook_event_name: "PostCompact",
|
|
session_id: sessionId,
|
|
transcript_path: "",
|
|
cwd: process.cwd(),
|
|
trigger: "manual",
|
|
};
|
|
// A subagent's compaction is not the root session's entity.
|
|
await callback(
|
|
{ ...base, agent_id: "agent-1", compact_summary: "<summary>child</summary>" },
|
|
undefined,
|
|
{ signal: new AbortController().signal },
|
|
);
|
|
expect(recordSummary).not.toHaveBeenCalled();
|
|
|
|
const out = await callback(
|
|
{ ...base, compact_summary: "<analysis>x</analysis><summary>Retained.</summary>" },
|
|
undefined,
|
|
{ signal: new AbortController().signal },
|
|
);
|
|
expect(out).toEqual({ continue: true });
|
|
expect(recordSummary).toHaveBeenCalledWith(
|
|
"<analysis>x</analysis><summary>Retained.</summary>",
|
|
);
|
|
});
|
|
|
|
it("syncs adapter model state when a PostModelSwitch hook reports an external switch", async () => {
|
|
// A `/model <name>` command typed as a prompt switches the session's
|
|
// model with no refusal-fallback frame; the registered PostModelSwitch
|
|
// hook is what keeps the ACP picker truthful.
|
|
initModels = TWO_MODELS;
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const sessionId = response.sessionId;
|
|
|
|
const matchers = capturedOptions!.hooks?.PostModelSwitch;
|
|
expect(matchers).toBeDefined();
|
|
const callback = (matchers!.at(-1) as any).hooks[0];
|
|
|
|
const hookInput = {
|
|
hook_event_name: "PostModelSwitch",
|
|
session_id: sessionId,
|
|
transcript_path: "",
|
|
cwd: process.cwd(),
|
|
from_model: "claude-sonnet-4-6",
|
|
to_model: "claude-opus-4-8",
|
|
requested_model: "opus",
|
|
source: "command",
|
|
context_tokens: 0,
|
|
prompt_cache_warm: false,
|
|
cache_ttl: "5m",
|
|
estimated_cache_write_usd: 0,
|
|
pricing: "catalog",
|
|
};
|
|
const out = await callback(hookInput, undefined, {
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(out).toEqual({ continue: true });
|
|
|
|
// The sync is detached from the hook response (control requests are
|
|
// serialized); wait for it to land.
|
|
await vi.waitFor(() => {
|
|
expect((agent as any).sessions[sessionId].models.currentModelId).toBe("claude-opus-4-8");
|
|
});
|
|
});
|
|
|
|
it("ignores PostModelSwitch reports for the adapter's own setModel calls", async () => {
|
|
initModels = TWO_MODELS;
|
|
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const sessionId = response.sessionId;
|
|
const callback = (capturedOptions!.hooks!.PostModelSwitch!.at(-1) as any).hooks[0];
|
|
|
|
await callback(
|
|
{
|
|
hook_event_name: "PostModelSwitch",
|
|
session_id: sessionId,
|
|
transcript_path: "",
|
|
cwd: process.cwd(),
|
|
from_model: "claude-sonnet-4-6",
|
|
to_model: "claude-opus-4-8",
|
|
requested_model: "opus",
|
|
source: "sdk",
|
|
context_tokens: 0,
|
|
prompt_cache_warm: false,
|
|
cache_ttl: "5m",
|
|
estimated_cache_write_usd: 0,
|
|
pricing: "catalog",
|
|
},
|
|
undefined,
|
|
{ signal: new AbortController().signal },
|
|
);
|
|
|
|
// Detached-sync window: give a stray sync the chance to land, then
|
|
// assert nothing moved.
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect((agent as any).sessions[sessionId].models.currentModelId).toBe("claude-sonnet-4-6");
|
|
});
|
|
});
|
|
|
|
describe("loadSession history replay", () => {
|
|
const userMessage = (uuid: string, text: string) => ({
|
|
type: "user",
|
|
uuid,
|
|
session_id: "replay-probe",
|
|
parent_tool_use_id: null,
|
|
parent_agent_id: null,
|
|
message: { role: "user", content: [{ type: "text", text }] },
|
|
});
|
|
|
|
function capturingAgent(updates: SessionNotification[]) {
|
|
return new ClaudeAcpAgent({
|
|
...createMockClient(),
|
|
sessionUpdate: async (notification: SessionNotification) => {
|
|
updates.push(notification);
|
|
},
|
|
} as AcpClient);
|
|
}
|
|
|
|
it("replays the history while the CLI starts", async () => {
|
|
let releaseInit!: () => void;
|
|
initGate = new Promise((resolve) => (releaseInit = resolve));
|
|
sessionMessages = [userMessage("u1", "first"), userMessage("u2", "second")];
|
|
const updates: SessionNotification[] = [];
|
|
const loading = capturingAgent(updates).loadSession({
|
|
sessionId: "replay-probe",
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
let loaded = false;
|
|
void loading.then(() => (loaded = true));
|
|
|
|
await vi.waitFor(() => expect(updates).toHaveLength(2));
|
|
expect(loaded).toBe(false);
|
|
expect(updates.map((update) => update.update.sessionUpdate)).toEqual([
|
|
"user_message_chunk",
|
|
"user_message_chunk",
|
|
]);
|
|
|
|
releaseInit();
|
|
await expect(loading).resolves.toMatchObject({ sessionId: "replay-probe" });
|
|
});
|
|
|
|
it("keeps the not-found error when the CLI finds no conversation", async () => {
|
|
initError = new Error("No conversation found with session ID: replay-probe");
|
|
const updates: SessionNotification[] = [];
|
|
const loadAgent = capturingAgent(updates);
|
|
|
|
await expect(
|
|
loadAgent.loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] }),
|
|
).rejects.toMatchObject({ code: RequestError.resourceNotFound().code });
|
|
expect(updates).toEqual([]);
|
|
expect((loadAgent as any).sessions["replay-probe"]).toBeUndefined();
|
|
});
|
|
|
|
it("stops the replay when the CLI start fails", async () => {
|
|
let failInit!: () => void;
|
|
initGate = new Promise((resolve) => (failInit = resolve));
|
|
initError = new Error("No conversation found with session ID: replay-probe");
|
|
let releaseRead!: () => void;
|
|
const readGate = new Promise<void>((resolve) => (releaseRead = resolve));
|
|
sessionMessagesResult = async () => {
|
|
await readGate;
|
|
return [userMessage("u1", "first")];
|
|
};
|
|
const updates: SessionNotification[] = [];
|
|
const loading = capturingAgent(updates).loadSession({
|
|
sessionId: "replay-probe",
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
const failed = expect(loading).rejects.toMatchObject({
|
|
code: RequestError.resourceNotFound().code,
|
|
});
|
|
|
|
failInit();
|
|
// The failed start closes its query, and the load then stops the replay.
|
|
await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled());
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
releaseRead();
|
|
await failed;
|
|
expect(updates).toEqual([]);
|
|
});
|
|
|
|
it("sends no update after a load that fails while the replay runs", async () => {
|
|
let failInit!: () => void;
|
|
initGate = new Promise((resolve) => (failInit = resolve));
|
|
initError = new Error("Claude Code process exited with code 1");
|
|
sessionMessages = [
|
|
userMessage("u1", "first"),
|
|
userMessage("u2", "second"),
|
|
userMessage("u3", "third"),
|
|
];
|
|
const updates: SessionNotification[] = [];
|
|
let releaseClient!: () => void;
|
|
const clientGate = new Promise<void>((resolve) => (releaseClient = resolve));
|
|
const loading = new ClaudeAcpAgent({
|
|
...createMockClient(),
|
|
sessionUpdate: async (notification: SessionNotification) => {
|
|
updates.push(notification);
|
|
await clientGate;
|
|
},
|
|
} as AcpClient).loadSession({
|
|
sessionId: "replay-probe",
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
let updatesAtError: number | undefined;
|
|
const failed = loading.then(
|
|
() => expect.unreachable("the load must fail"),
|
|
() => (updatesAtError = updates.length),
|
|
);
|
|
|
|
// The client still handles the first replayed update when the CLI start fails.
|
|
await vi.waitFor(() => expect(updates).toHaveLength(1));
|
|
failInit();
|
|
await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled());
|
|
releaseClient();
|
|
await failed;
|
|
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(updatesAtError).toBeGreaterThan(0);
|
|
expect(updates).toHaveLength(updatesAtError!);
|
|
});
|
|
|
|
it("rejects an invalid cwd before it sends an update", async () => {
|
|
sessionMessages = [userMessage("u1", "first")];
|
|
const updates: SessionNotification[] = [];
|
|
|
|
await expect(
|
|
capturingAgent(updates).loadSession({
|
|
sessionId: "replay-probe",
|
|
cwd: path.join(os.tmpdir(), "no-such-directory-for-replay-probe"),
|
|
mcpServers: [],
|
|
}),
|
|
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
|
expect(updates).toEqual([]);
|
|
expect(getSessionMessages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("load replay end", () => {
|
|
const LOAD_REPLAY_END_CLIENT: ClientCapabilities = {
|
|
_meta: { jetbrains: { air: { version: 1, capabilities: ["loadReplayEnd"] } } },
|
|
};
|
|
|
|
async function endAgent(
|
|
updates: SessionNotification[],
|
|
sessionUpdate?: (notification: SessionNotification) => Promise<void>,
|
|
clientCapabilities: ClientCapabilities = LOAD_REPLAY_END_CLIENT,
|
|
) {
|
|
const endAgent = new ClaudeAcpAgent({
|
|
...createMockClient(),
|
|
sessionUpdate:
|
|
sessionUpdate ??
|
|
(async (notification: SessionNotification) => {
|
|
updates.push(notification);
|
|
}),
|
|
} as AcpClient);
|
|
await endAgent.initialize({ protocolVersion: 1, clientCapabilities });
|
|
return endAgent;
|
|
}
|
|
|
|
const isEnd = (notification: SessionNotification) =>
|
|
(notification.update._meta as any)?.jetbrains?.air?.loadReplayEnd === true;
|
|
|
|
/** The update kinds, with the end event as "end". */
|
|
const kinds = (updates: SessionNotification[]) =>
|
|
updates.map((update) => (isEnd(update) ? "end" : update.update.sessionUpdate));
|
|
|
|
it("sends one end event after the replay and before the response", async () => {
|
|
sessionMessages = [userMessage("u1", "first"), userMessage("u2", "second")];
|
|
const updates: SessionNotification[] = [];
|
|
const loadAgent = await endAgent(updates);
|
|
let atResponse: string[] | undefined;
|
|
await loadAgent
|
|
.loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] })
|
|
.then(() => (atResponse = kinds(updates)));
|
|
// `afterSetupResponse` sends the commands after the response.
|
|
await vi.waitFor(() => expect(kinds(updates)).toContain("available_commands_update"));
|
|
|
|
expect(atResponse).toEqual(["user_message_chunk", "user_message_chunk", "end"]);
|
|
expect(updates.filter(isEnd)).toHaveLength(1);
|
|
const end = updates.find(isEnd)!;
|
|
expect(end.sessionId).toBe("replay-probe");
|
|
// No `title` and no `updatedAt` key: a `null` would clear them.
|
|
expect(end.update).toEqual({
|
|
sessionUpdate: "session_info_update",
|
|
_meta: { jetbrains: { air: { version: 1, loadReplayEnd: true } } },
|
|
});
|
|
});
|
|
|
|
it("sends the end event before the CLI start finishes", async () => {
|
|
let releaseInit!: () => void;
|
|
initGate = new Promise((resolve) => (releaseInit = resolve));
|
|
sessionMessages = [userMessage("u1", "first")];
|
|
const updates: SessionNotification[] = [];
|
|
const loading = (await endAgent(updates)).loadSession({
|
|
sessionId: "replay-probe",
|
|
cwd: process.cwd(),
|
|
mcpServers: [],
|
|
});
|
|
|
|
await vi.waitFor(() => expect(kinds(updates)).toEqual(["user_message_chunk", "end"]));
|
|
|
|
releaseInit();
|
|
await loading;
|
|
});
|
|
|
|
it("sends the end event for an empty history", async () => {
|
|
sessionMessages = [];
|
|
const updates: SessionNotification[] = [];
|
|
let atResponse: string[] | undefined;
|
|
await (
|
|
await endAgent(updates)
|
|
)
|
|
.loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] })
|
|
.then(() => (atResponse = kinds(updates)));
|
|
|
|
expect(atResponse).toEqual(["end"]);
|
|
});
|
|
|
|
it("sends the end event after the replay of a session that is already live", async () => {
|
|
const updates: SessionNotification[] = [];
|
|
const loadAgent = await endAgent(updates);
|
|
const { sessionId } = await loadAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
sessionMessages = [userMessage("u1", "first"), userMessage("u2", "second")];
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
updates.length = 0;
|
|
|
|
let atResponse: string[] | undefined;
|
|
await loadAgent
|
|
.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
|
.then(() => (atResponse = kinds(updates)));
|
|
|
|
expect(atResponse).toEqual(["user_message_chunk", "user_message_chunk", "end"]);
|
|
});
|
|
|
|
it("sends no end event when the CLI start fails part way through the replay", async () => {
|
|
let failInit!: () => void;
|
|
initGate = new Promise((resolve) => (failInit = resolve));
|
|
initError = new Error("Claude Code process exited with code 1");
|
|
sessionMessages = [
|
|
userMessage("u1", "first"),
|
|
userMessage("u2", "second"),
|
|
userMessage("u3", "third"),
|
|
];
|
|
const updates: SessionNotification[] = [];
|
|
let releaseClient!: () => void;
|
|
const clientGate = new Promise<void>((resolve) => (releaseClient = resolve));
|
|
const loading = (
|
|
await endAgent(updates, async (notification) => {
|
|
updates.push(notification);
|
|
await clientGate;
|
|
})
|
|
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
|
|
const failed = expect(loading).rejects.toThrow("exited with code 1");
|
|
|
|
// The client still handles the first replayed update when the CLI start fails.
|
|
await vi.waitFor(() => expect(updates).toHaveLength(1));
|
|
failInit();
|
|
await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled());
|
|
releaseClient();
|
|
await failed;
|
|
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(updates.length).toBeLessThan(3);
|
|
expect(updates.some(isEnd)).toBe(false);
|
|
});
|
|
|
|
it("sends no end event when the replay fails", async () => {
|
|
const updates: SessionNotification[] = [];
|
|
const loadAgent = await endAgent(updates);
|
|
const { sessionId } = await loadAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
updates.length = 0;
|
|
sessionMessagesResult = async () => {
|
|
throw new Error("history unreadable");
|
|
};
|
|
|
|
await expect(
|
|
loadAgent.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }),
|
|
).rejects.toThrow("history unreadable");
|
|
expect(updates.some(isEnd)).toBe(false);
|
|
});
|
|
|
|
it("sends no end event to a client without the capability", async () => {
|
|
sessionMessages = [userMessage("u1", "first")];
|
|
for (const clientCapabilities of [
|
|
{},
|
|
{ _meta: { jetbrains: { air: { version: 1, capabilities: [] } } } },
|
|
] as ClientCapabilities[]) {
|
|
const updates: SessionNotification[] = [];
|
|
await (
|
|
await endAgent(updates, undefined, clientCapabilities)
|
|
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
|
|
await vi.waitFor(() => expect(kinds(updates)).toContain("available_commands_update"));
|
|
|
|
expect(kinds(updates)).toEqual(["user_message_chunk", "available_commands_update"]);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|