Files
claude-agent-acp/src/tests/create-session-options.test.ts
Ben Brandt 38cf656fc9 feat: report why Claude Code could not start a session (#1508)
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>
2026-11-02 11:23:33 +00:00

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"]);
}
});
});
});
});