mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40d7bc30f | ||
|
|
152fe91152 | ||
|
|
45de3d2847 | ||
|
|
c7ae07da6b | ||
|
|
0af467c400 | ||
|
|
f4ce899223 | ||
|
|
31539b4ee9 | ||
|
|
61a39ea801 | ||
|
|
c0b7752b45 | ||
|
|
3002539e4a | ||
|
|
87a0886a34 | ||
|
|
2193a13a3b | ||
|
|
c13edf3b02 | ||
|
|
a217891e6c | ||
|
|
867de85f0b | ||
|
|
af11cdbba5 | ||
|
|
f7af07758c | ||
|
|
3524606555 | ||
|
|
9ecb222807 | ||
|
|
dd9fe3a98e | ||
|
|
1c641cd6a7 | ||
|
|
a02ef184d3 | ||
|
|
3beddee766 | ||
|
|
52d5b74aa7 | ||
|
|
edf6486c44 | ||
|
|
232a8cd8e2 | ||
|
|
37e3f389f6 | ||
|
|
f513a14a46 | ||
|
|
c0e2cee265 | ||
|
|
b4f56b17fc | ||
|
|
c2e45e638d | ||
|
|
5c1863d56e | ||
|
|
6253b1611e | ||
|
|
6ff856c94f | ||
|
|
1b057923e8 | ||
|
|
3b3ce6f287 |
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
bun build src/static-entry.ts \
|
||||
bun build src/index.ts \
|
||||
--compile \
|
||||
--target ${{ matrix.target }} \
|
||||
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \
|
||||
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.agent-shell
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## 0.22.0
|
||||
|
||||
- Use stable list sessions method (#429)
|
||||
- Use correct Claude CLI path for static binaries (#428)
|
||||
- Update claude-agent-sdk to 0.2.76 (#427)
|
||||
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
|
||||
- Reuse existing sessions for load/resume if possible (#426)
|
||||
- Remove interrupt flag from deny responses (#425)
|
||||
- Allow Bypass permissions mode after Exiting plan (#410)
|
||||
- Don't get out of sync when background task creates new init/result (try 2) (#400)
|
||||
- Add session/close support (#409)
|
||||
|
||||
## 0.21.0
|
||||
|
||||
- Update to claude-agent-sdk 0.2.71
|
||||
- show project-relative paths in tool call titles
|
||||
- Lib: pass through tools array to control built-in tool availability
|
||||
- fix: skip user replay
|
||||
- fix: handle renamed Agent tool in toolInfoFromToolUse
|
||||
|
||||
## 0.20.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.68
|
||||
|
||||
## 0.20.1
|
||||
|
||||
- fix: inherit process.env when spawning agent subprocess
|
||||
|
||||
## 0.20.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.63
|
||||
- Respect user settings for permission mode and model selection
|
||||
- Better handling of concurrent prompts
|
||||
- Support --cli for node as well
|
||||
- Propagate max_tokens stop reason instead of throwing internal error
|
||||
- fix: throw resourceNotFound when loadSession fails to resume
|
||||
- fix: add missing zod dependency
|
||||
- Surface better error message when Claude Code process exits unexpectedly
|
||||
|
||||
## 0.19.2
|
||||
|
||||
- Fix for broken notifications when reloading session messages
|
||||
|
||||
1601
package-lock.json
generated
1601
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.19.2",
|
||||
"version": "0.22.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,21 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.62"
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.76",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.78.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.3.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"eslint": "10.0.2",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.3.0",
|
||||
"globals": "17.4.0",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
1181
src/acp-agent.ts
1181
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
45
src/index.ts
45
src/index.ts
@@ -2,25 +2,30 @@
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
if (process.argv.includes("--cli")) {
|
||||
process.argv = process.argv.filter((arg) => arg !== "--cli");
|
||||
await import(await claudeCliPath());
|
||||
} else {
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
|
||||
@@ -15,7 +15,12 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
|
||||
@@ -14,7 +14,12 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
*/
|
||||
|
||||
export interface PermissionSettings {
|
||||
defaultMode?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
}
|
||||
@@ -162,6 +167,13 @@ export class SettingsManager {
|
||||
if (settings.model) {
|
||||
merged.model = settings.model;
|
||||
}
|
||||
|
||||
if (settings.permissions?.defaultMode !== undefined) {
|
||||
merged.permissions = {
|
||||
...merged.permissions,
|
||||
defaultMode: settings.permissions.defaultMode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
if (process.argv.includes("--cli")) {
|
||||
process.argv = process.argv.filter((arg) => arg !== "--cli");
|
||||
// @ts-expect-error -- no types
|
||||
await import("@anthropic-ai/claude-agent-sdk/cli.js");
|
||||
} else {
|
||||
await import("./index.js");
|
||||
}
|
||||
220
src/tests/acp-agent-settings.test.ts
Normal file
220
src/tests/acp-agent-settings.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
|
||||
return {
|
||||
...actual,
|
||||
query: querySpy,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ClaudeAcpAgent settings", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return { getCapturedOptions: () => capturedOptions, setModelSpy };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-settings-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
querySpy.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalClaudeConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode for new sessions", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "dontAsk",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("dontAsk");
|
||||
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
|
||||
expect(response.modes.currentModeId).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("supports acceptEdits mode defaults", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "acceptEdits",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("throws when permissions.defaultMode is not a string", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: 123,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
await expect(
|
||||
(agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
}),
|
||||
).rejects.toThrow("Invalid permissions.defaultMode");
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: "opus[1m]",
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options: _options }: any) => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-opus-4-6",
|
||||
displayName: "Claude Opus 4.6",
|
||||
description: "Base",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-6-1m",
|
||||
displayName: "Claude Opus 4.6 (1M)",
|
||||
description: "Long context",
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m");
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
@@ -20,10 +20,12 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import {
|
||||
markdownEscape,
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
} from "../tools.js";
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
import type {
|
||||
@@ -226,7 +228,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toBe("");
|
||||
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
@@ -247,7 +249,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("");
|
||||
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -562,6 +564,20 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use relative path in title when cwd is provided", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01READ_CWD",
|
||||
name: "Read",
|
||||
input: { file_path: "/Users/test/project/src/main.ts" },
|
||||
};
|
||||
|
||||
const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project");
|
||||
expect(result.title).toBe("Read src/main.ts");
|
||||
// locations.path stays absolute for navigation
|
||||
expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]);
|
||||
});
|
||||
|
||||
it("should handle plan entries", () => {
|
||||
const received: SDKAssistantMessage = {
|
||||
type: "assistant",
|
||||
@@ -968,6 +984,22 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDisplayPath", () => {
|
||||
it("should relativize paths inside cwd and keep absolute paths outside", () => {
|
||||
expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe(
|
||||
"src/main.ts",
|
||||
);
|
||||
expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts");
|
||||
expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe(
|
||||
"/Users/test/project/src/main.ts",
|
||||
);
|
||||
// Partial directory name match should not be treated as inside cwd
|
||||
expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe(
|
||||
"/Users/test/project-other/file.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUpdateFromEditToolResponse", () => {
|
||||
it("should return empty for non-object input", () => {
|
||||
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
|
||||
@@ -1139,6 +1171,11 @@ describe("prompt conversion", () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
it("finds vendored cli path", async () => {
|
||||
const path = await claudeCliPath();
|
||||
expect(path).toContain("@anthropic-ai/claude-agent-sdk/cli.js");
|
||||
});
|
||||
|
||||
it("query has a 'default' model", async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
@@ -1228,3 +1265,340 @@ describe("permission requests", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop reason propagation", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function createResultMessage(overrides: {
|
||||
subtype: "success" | "error_during_execution";
|
||||
stop_reason: string | null;
|
||||
is_error: boolean;
|
||||
result?: string;
|
||||
errors?: string[];
|
||||
}) {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: overrides.subtype,
|
||||
stop_reason: overrides.stop_reason,
|
||||
is_error: overrides.is_error,
|
||||
result: overrides.result ?? "",
|
||||
errors: overrides.errors ?? [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
// Wait for the prompt to push its user message so we can replay it
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
}
|
||||
|
||||
it("should return max_tokens when success result has stop_reason max_tokens", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }),
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("max_tokens");
|
||||
});
|
||||
|
||||
it("should return max_tokens when success result has stop_reason max_tokens and is_error true", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "max_tokens",
|
||||
is_error: true,
|
||||
result: "Token limit reached",
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("max_tokens");
|
||||
});
|
||||
|
||||
it("should return max_tokens when error_during_execution has stop_reason max_tokens", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({
|
||||
subtype: "error_during_execution",
|
||||
stop_reason: "max_tokens",
|
||||
is_error: true,
|
||||
errors: ["some error"],
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("max_tokens");
|
||||
});
|
||||
|
||||
it("should return end_turn for success with null stop_reason", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({ subtype: "success", stop_reason: null, is_error: false }),
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
});
|
||||
|
||||
it("should consume background task results and return the prompt's own result", async () => {
|
||||
const agent = createMockAgent();
|
||||
const input = new Pushable<any>();
|
||||
|
||||
const backgroundTaskResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
});
|
||||
// Background task used some tokens
|
||||
backgroundTaskResult.usage.input_tokens = 100;
|
||||
backgroundTaskResult.usage.output_tokens = 50;
|
||||
|
||||
const promptResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
});
|
||||
|
||||
async function* messageGenerator() {
|
||||
// Background task init + result arrive before our prompt's replay
|
||||
yield { type: "system", subtype: "init", session_id: "test-session" };
|
||||
yield backgroundTaskResult;
|
||||
|
||||
// Now the prompt's user message replay arrives
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iter.next();
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
|
||||
// Then the prompt's own result
|
||||
yield promptResult;
|
||||
}
|
||||
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cwd: "/tmp/test",
|
||||
cancelled: false,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
abortController: new AbortController(),
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
// Usage should include both background task and prompt result tokens
|
||||
expect(response.usage?.inputTokens).toBe(
|
||||
backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens,
|
||||
);
|
||||
expect(response.usage?.outputTokens).toBe(
|
||||
backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw internal error for success with is_error true and no max_tokens", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "Something went wrong",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
}),
|
||||
).rejects.toThrow("Internal error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), { interrupt: vi.fn() });
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
|
||||
it("should close an existing session and remove it", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-1");
|
||||
|
||||
expect(agent.sessions["session-1"]).toBeDefined();
|
||||
|
||||
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(agent.sessions["session-1"]).toBeUndefined();
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should abort the session's abort controller", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-2");
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-2" });
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw when closing a non-existent session", async () => {
|
||||
const agent = createMockAgent();
|
||||
|
||||
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
|
||||
"Session not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect other sessions when closing one", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, "session-a");
|
||||
injectSession(agent, "session-b");
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-a" });
|
||||
|
||||
expect(agent.sessions["session-a"]).toBeUndefined();
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
158
src/tests/authorization.test.ts
Normal file
158
src/tests/authorization.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
|
||||
describe("authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
//await all pending events like
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [{ value: "id", displayName: "name", description: "description" }],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
|
||||
const agent = new ClaudeAcpAgent(connectionMock);
|
||||
|
||||
return [agent, mockQuery];
|
||||
}
|
||||
|
||||
it("gateway auth not offered without capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true, _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
|
||||
await agent.authenticate({
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example", headers: { "x-api-key": "test" } } },
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
userEnv: "userEnv",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
userEnv: "userEnv",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude authentication without terminal-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide terminal auth even when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("includes both user and built-in disallowed tools when disableBuiltInTools is true", async () => {
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
@@ -116,15 +116,11 @@ describe("createSession options merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const disallowed = capturedOptions!.disallowedTools!;
|
||||
// User-provided
|
||||
expect(disallowed).toContain("CustomTool");
|
||||
// ACP internal
|
||||
expect(disallowed).toContain("AskUserQuestion");
|
||||
// Built-in tools disabled by disableBuiltInTools
|
||||
expect(disallowed).toContain("Read");
|
||||
expect(disallowed).toContain("Write");
|
||||
expect(disallowed).toContain("Bash");
|
||||
// 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 () => {
|
||||
@@ -151,6 +147,112 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
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: "/test",
|
||||
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: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
HOME: "/custom/home",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
|
||||
});
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
|
||||
});
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
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: "/test",
|
||||
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: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
|
||||
43
src/tests/resolve-permission-mode.test.ts
Normal file
43
src/tests/resolve-permission-mode.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolvePermissionMode } from "../acp-agent.js";
|
||||
|
||||
describe("resolvePermissionMode", () => {
|
||||
it("returns 'default' when no mode is provided", () => {
|
||||
expect(resolvePermissionMode()).toBe("default");
|
||||
expect(resolvePermissionMode(undefined)).toBe("default");
|
||||
});
|
||||
|
||||
it("resolves exact canonical modes", () => {
|
||||
expect(resolvePermissionMode("default")).toBe("default");
|
||||
expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("dontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("plan")).toBe("plan");
|
||||
expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("resolves case-insensitive aliases", () => {
|
||||
expect(resolvePermissionMode("DontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("DONTASK")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("throws on non-string values", () => {
|
||||
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
|
||||
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
|
||||
expect(() => resolvePermissionMode({})).toThrow("expected a string");
|
||||
});
|
||||
|
||||
it("throws on empty string", () => {
|
||||
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
|
||||
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
|
||||
});
|
||||
|
||||
it("throws on unknown mode", () => {
|
||||
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,61 @@ describe("session config options", () => {
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves model alias 'opus' to full model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "opus",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
|
||||
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("resolves model alias 'sonnet' to full model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("resolves display name to model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "Claude Sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("still works with exact model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("throws for completely invalid model value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "gpt-4",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option model: gpt-4");
|
||||
});
|
||||
|
||||
it("returns full configOptions in the response", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
|
||||
@@ -52,5 +52,42 @@ describe("SettingsManager", () => {
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
|
||||
it("should merge permissions.defaultMode with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "dontAsk",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.defaultMode).toBe("dontAsk");
|
||||
|
||||
// Local settings override the mode
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "plan",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager.dispose();
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.defaultMode).toBe("plan");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
70
src/tools.ts
70
src/tools.ts
@@ -5,6 +5,19 @@ import {
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
@@ -14,29 +27,31 @@ import {
|
||||
WebSearchToolResultError,
|
||||
} from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionResultBlock,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultError,
|
||||
BetaImageBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaTextEditorCodeExecutionCreateResultBlock,
|
||||
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultError,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaToolSearchToolResultError,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaWebFetchBlock,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebFetchToolResultErrorBlock,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
BetaImageBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import path from "node:path";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
@@ -61,20 +76,6 @@ type ToolResultContent =
|
||||
| BetaTextEditorCodeExecutionCreateResultBlock
|
||||
| BetaTextEditorCodeExecutionStrReplaceResultBlock
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -103,13 +104,29 @@ interface ToolUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute file path to a project-relative path for display.
|
||||
* Returns the original path if it's outside the project directory or if no cwd is provided.
|
||||
*/
|
||||
export function toDisplayPath(filePath: string, cwd?: string): string {
|
||||
if (!cwd) return filePath;
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
|
||||
return path.relative(resolvedCwd, resolvedFile);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
|
||||
switch (name) {
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
return {
|
||||
@@ -153,8 +170,9 @@ export function toolInfoFromToolUse(
|
||||
} else if (input.offset) {
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
return {
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
title: "Read " + displayPath + limit,
|
||||
kind: "read",
|
||||
locations: input.file_path
|
||||
? [
|
||||
@@ -188,8 +206,9 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -209,8 +228,9 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||
title: displayPath ? `Edit ${displayPath}` : "Edit",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
|
||||
Reference in New Issue
Block a user