mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 20:01:11 +00:00
When Claude Code refuses to start (invalid proxy URL, wrong organization sign-in, a required sandbox that can't run, …), `session/new`, `session/load`, `session/resume` and `session/fork` used to fail with a generic `Claude Code process exited with code 1. stderr: …` error. This sets `CLAUDE_CODE_STARTUP_FAILURE_RESULTS=1` on the query env, so the CLI writes an error result with a `startup_failure_reason` before exiting on a known startup failure. The SDK then rejects `initializationResult()` with an error that carries `startupFailureReason`. The new `src/startup-failure.ts` maps it to an ACP error: | `startupFailureReason` | ACP error | |---|---| | `gateway_signin_required`, `org_pin_mismatch`, `org_config_refused` | `authRequired` (signing in again, or with another account, fixes it) | | `cwd_unavailable` | `invalidParams` (matches the existing cwd validation) | | any other reason | `internalError` | The message is the CLI's own text, which says what to fix (e.g. `Invalid proxy URL in HTTPS_PROXY: … Fix or unset HTTPS_PROXY and restart Claude Code.`), and `data.startupFailureReason` carries the reason. Errors without a reason (older CLIs, unknown causes) pass through unchanged, and the existing `sessionNotFound` mapping for resumes is untouched. Checked against the real CLI (SDK 0.3.316): with `HTTPS_PROXY="not a url"`, `initializationResult()` rejects with `startupFailureReason: "proxy_invalid"` when the variable is set, and with a plain exit-code error without it. ## Testing - v1: `create-session-options.test.ts` covers the env var, each mapping, and pass-through of errors without a reason. - v2: `acp-v2.test.ts` covers the `session/new` error over the wire. - `npm run build`, `npm run check`, `npm run test:run` pass. The subprocess integration tests also passed locally with this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
2463 lines
88 KiB
TypeScript
2463 lines
88 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 { startupFailure } from "./helpers.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 a worktree child's cwd from its SubagentStart hook, and none for a child in the session's cwd", 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: { subagents: {} } });
|
|
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
const session = observedAgent.sessions[sessionId]!;
|
|
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
|
"rfd",
|
|
sessionId,
|
|
session,
|
|
(notification) => client.sessionUpdate(notification),
|
|
{ log: () => {} },
|
|
));
|
|
const subagentStart = async (agentId: string, cwd: string) => {
|
|
const matcher = capturedOptions!.hooks!.SubagentStart!.find((matcher) => !matcher.matcher);
|
|
for (const hook of matcher!.hooks) {
|
|
await hook(
|
|
{
|
|
hook_event_name: "SubagentStart",
|
|
session_id: sessionId,
|
|
cwd,
|
|
transcript_path: "/tmp/transcript",
|
|
agent_id: agentId,
|
|
agent_type: "general-purpose",
|
|
},
|
|
undefined,
|
|
{ signal: new AbortController().signal },
|
|
);
|
|
}
|
|
};
|
|
const worktree = path.join(process.cwd(), ".claude", "worktrees", "agent-isolated");
|
|
await subagentStart("isolated", worktree);
|
|
await subagentStart("shared", process.cwd());
|
|
await runtime.taskStarted(
|
|
{ taskId: "isolated", subagentType: "general-purpose" },
|
|
async () => {},
|
|
);
|
|
await runtime.taskStarted(
|
|
{ taskId: "shared", subagentType: "general-purpose" },
|
|
async () => {},
|
|
);
|
|
const announcements = updates.flatMap(({ update }) =>
|
|
update.sessionUpdate === "subagent_update" ? [update] : [],
|
|
);
|
|
expect(announcements).toMatchObject([
|
|
{ sessionId: "isolated", cwd: worktree },
|
|
{ sessionId: "shared" },
|
|
]);
|
|
expect(announcements[1]).not.toHaveProperty("cwd");
|
|
});
|
|
|
|
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("thinking display from CLAUDE_CODE_THINKING_DISPLAY", () => {
|
|
let originalMaxThinking: string | undefined;
|
|
let originalDisplay: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
|
|
originalDisplay = process.env.CLAUDE_CODE_THINKING_DISPLAY;
|
|
delete process.env.MAX_THINKING_TOKENS;
|
|
delete process.env.CLAUDE_CODE_THINKING_DISPLAY;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalMaxThinking !== undefined) {
|
|
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
|
|
} else {
|
|
delete process.env.MAX_THINKING_TOKENS;
|
|
}
|
|
if (originalDisplay !== undefined) {
|
|
process.env.CLAUDE_CODE_THINKING_DISPLAY = originalDisplay;
|
|
} else {
|
|
delete process.env.CLAUDE_CODE_THINKING_DISPLAY;
|
|
}
|
|
});
|
|
|
|
it("becomes explicit adaptive thinking when only display is set", async () => {
|
|
process.env.CLAUDE_CODE_THINKING_DISPLAY = "omitted";
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toEqual({ type: "adaptive", display: "omitted" });
|
|
});
|
|
|
|
it("attaches display to a fixed thinking budget", async () => {
|
|
process.env.MAX_THINKING_TOKENS = "12000";
|
|
process.env.CLAUDE_CODE_THINKING_DISPLAY = "summarized";
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toEqual({
|
|
type: "enabled",
|
|
budgetTokens: 12000,
|
|
display: "summarized",
|
|
});
|
|
});
|
|
|
|
it("has no effect when thinking is disabled", async () => {
|
|
process.env.MAX_THINKING_TOKENS = "0";
|
|
process.env.CLAUDE_CODE_THINKING_DISPLAY = "summarized";
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
|
|
});
|
|
|
|
it("ignores an invalid value", async () => {
|
|
process.env.CLAUDE_CODE_THINKING_DISPLAY = "verbose";
|
|
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("CLAUDE_CODE_THINKING_DISPLAY"),
|
|
);
|
|
});
|
|
});
|
|
|
|
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("startup failures", () => {
|
|
it("asks Claude Code to report why it could not start", async () => {
|
|
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
|
expect(capturedOptions?.env?.CLAUDE_CODE_STARTUP_FAILURE_RESULTS).toBe("1");
|
|
});
|
|
|
|
it.each([
|
|
["proxy_invalid", RequestError.internalError().code, "Internal error"],
|
|
["gateway_signin_required", RequestError.authRequired().code, "Authentication required"],
|
|
["org_pin_mismatch", RequestError.authRequired().code, "Authentication required"],
|
|
["org_config_refused", RequestError.authRequired().code, "Authentication required"],
|
|
["cwd_unavailable", RequestError.invalidParams().code, "Invalid params"],
|
|
])("rejects with %s as code %i", async (reason, code, prefix) => {
|
|
initError = startupFailure(reason, "Fix the setting and restart Claude Code.");
|
|
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toMatchObject({
|
|
code,
|
|
message: `${prefix}: Fix the setting and restart Claude Code.`,
|
|
data: { startupFailureReason: reason },
|
|
});
|
|
expect(closeSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes on an exit error that names no reason", async () => {
|
|
initError = new Error("Claude Code process exited with code 1. stderr: boom");
|
|
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toBe(
|
|
initError,
|
|
);
|
|
});
|
|
});
|
|
|
|
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: -32002, data: { sessionId: "replay-probe" } });
|
|
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: -32002,
|
|
data: { sessionId: "replay-probe" },
|
|
});
|
|
|
|
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.each([
|
|
["without subagents", [] as Record<string, unknown>[]],
|
|
[
|
|
"with a subagent",
|
|
[
|
|
{
|
|
type: "assistant",
|
|
uuid: "child-message",
|
|
session_id: "replay-probe",
|
|
parent_tool_use_id: "agent-call",
|
|
message: {
|
|
id: "api-child-message",
|
|
model: "claude-sonnet-4-6",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "child report" }],
|
|
},
|
|
},
|
|
],
|
|
],
|
|
])(
|
|
"sends the end event before the CLI start finishes for native subagent sessions (%s)",
|
|
async (_label, history) => {
|
|
let releaseInit!: () => void;
|
|
initGate = new Promise((resolve) => (releaseInit = resolve));
|
|
sessionMessages = [userMessage("u1", "first"), ...history];
|
|
const updates: SessionNotification[] = [];
|
|
const loading = (
|
|
await endAgent(updates, undefined, {
|
|
_meta: {
|
|
jetbrains: {
|
|
air: {
|
|
version: 1,
|
|
capabilities: ["loadReplayEnd", "nativeSubagentSessions"],
|
|
},
|
|
},
|
|
},
|
|
} as ClientCapabilities)
|
|
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
|
|
|
|
// The child's model goes as the replay reaches it, with no model list yet.
|
|
await vi.waitFor(() =>
|
|
expect(kinds(updates)).toEqual(
|
|
history.length === 0
|
|
? ["user_message_chunk", "end"]
|
|
: [
|
|
"user_message_chunk",
|
|
"subagent_spawned",
|
|
"config_option_update",
|
|
"agent_message_chunk",
|
|
"subagent_state_update",
|
|
"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"]);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|