mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
@@ -65,6 +65,8 @@ import {
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Stats } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
@@ -2214,10 +2216,46 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the requested `cwd` is an absolute path that points at an existing
|
||||
* directory before we create a session. Throws an `invalidParams` error with
|
||||
* an actionable message so clients (e.g. Zed) can surface it to the user
|
||||
* instead of failing later with an opaque SDK error.
|
||||
*/
|
||||
private async validateCwd(cwd: string): Promise<void> {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ cwd },
|
||||
`\`cwd\` must be an absolute path, but received: ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
let stats: Stats;
|
||||
try {
|
||||
stats = await fs.stat(cwd);
|
||||
} catch {
|
||||
throw RequestError.invalidParams(
|
||||
{ cwd },
|
||||
`\`cwd\` does not exist on the machine running the agent: ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
creationOpts: { resume?: string; forkSession?: boolean } = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
// Validate `cwd` up front. The ACP spec requires an absolute path, and the
|
||||
// directory must actually exist on the machine running the agent. Without
|
||||
// this check a session is created against a missing directory and the
|
||||
// failure only surfaces later as a confusing "native binary failed to
|
||||
// launch" error from the SDK (see issue #749).
|
||||
await this.validateCwd(params.cwd);
|
||||
|
||||
// We want to create a new session id unless it is resume,
|
||||
// but not resume + forkSession.
|
||||
let sessionId;
|
||||
|
||||
@@ -176,7 +176,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
}
|
||||
|
||||
it("should connect to the ACP subprocess", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession("./");
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(process.cwd());
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock("../tools.js", async () => ({
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
const newSession = (meta: Record<string, unknown>, cwd = process.cwd()) =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -79,7 +79,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
@@ -89,7 +89,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
|
||||
@@ -99,7 +99,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -148,7 +148,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
@@ -65,7 +68,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -85,7 +88,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -94,7 +97,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -110,7 +113,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
@@ -133,7 +136,7 @@ describe("createSession options merging", () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -155,7 +158,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -165,7 +168,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("merges user-provided env vars on top of process.env", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -185,7 +188,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("allows user-provided env vars to override process.env entries", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -203,7 +206,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -212,7 +215,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -228,7 +231,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
@@ -245,7 +248,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("passes through empty tools array to disable all built-in tools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -261,7 +264,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
@@ -271,7 +274,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("replaces the preset when a string is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: "custom prompt" },
|
||||
});
|
||||
@@ -281,7 +284,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards append", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { append: "extra instructions" } },
|
||||
});
|
||||
@@ -295,7 +298,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards excludeDynamicSections", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
||||
});
|
||||
@@ -309,7 +312,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards append and excludeDynamicSections together", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
@@ -329,7 +332,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("ignores caller-provided type/preset overrides", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
@@ -369,7 +372,7 @@ describe("createSession options merging", () => {
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
@@ -381,7 +384,7 @@ describe("createSession options merging", () => {
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
@@ -394,7 +397,7 @@ describe("createSession options merging", () => {
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
@@ -403,7 +406,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
it("does not add settings when env var is not set", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toBeUndefined();
|
||||
});
|
||||
@@ -414,7 +417,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -438,13 +441,13 @@ describe("createSession options merging", () => {
|
||||
it("throws on invalid JSON", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = "not-json";
|
||||
|
||||
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [
|
||||
{
|
||||
name: "acp-server",
|
||||
@@ -491,7 +494,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
it("leaves thinking unset (SDK default) when env var is absent", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
// The deprecated option must not be set either.
|
||||
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
|
||||
@@ -499,26 +502,26 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("maps 0 to disabled thinking", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "0";
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
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: "/test", mcpServers: [] });
|
||||
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";
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a user-provided thinking option override the env default", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -531,4 +534,35 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cwd validation", () => {
|
||||
it("rejects a relative cwd with invalidParams", async () => {
|
||||
await expect(
|
||||
agent.newSession({ cwd: "relative/path", mcpServers: [] }),
|
||||
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
||||
});
|
||||
|
||||
it("rejects a non-existent cwd with invalidParams", async () => {
|
||||
const missing = path.join(os.tmpdir(), "claude-acp-does-not-exist-xyz");
|
||||
await expect(agent.newSession({ cwd: missing, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a cwd that points at a file with invalidParams", async () => {
|
||||
const file = path.join(os.tmpdir(), "claude-acp-cwd-is-a-file.txt");
|
||||
fs.writeFileSync(file, "not a directory");
|
||||
try {
|
||||
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an existing absolute directory", async () => {
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,13 +148,13 @@ describe("session config options", () => {
|
||||
|
||||
describe("newSession returns configOptions", () => {
|
||||
it("includes configOptions in the response", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(response.configOptions).toBeDefined();
|
||||
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
|
||||
});
|
||||
|
||||
it("includes mode and model config options", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const modeOption = response.configOptions?.find((o) => o.id === "mode");
|
||||
const modelOption = response.configOptions?.find((o) => o.id === "model");
|
||||
expect(modeOption).toBeDefined();
|
||||
@@ -173,7 +173,7 @@ describe("session config options", () => {
|
||||
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
|
||||
|
||||
const response = await agent.loadSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
sessionId: SESSION_ID,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user