mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6bf33cd30 | ||
|
|
96261a5b50 | ||
|
|
003ae41d18 | ||
|
|
0ba0ec1017 | ||
|
|
f70730f1ab | ||
|
|
13cd9214d7 | ||
|
|
78975d75f5 | ||
|
|
bbd0774ae5 | ||
|
|
76331017b2 | ||
|
|
90f1447e99 | ||
|
|
dae4c9956e | ||
|
|
eb282abb50 | ||
|
|
74c0be057b | ||
|
|
e66091f723 | ||
|
|
1b99ab96a0 | ||
|
|
8d14d9dd1d | ||
|
|
98b6cd5c07 | ||
|
|
a4c9ba3c7a | ||
|
|
a08ce88f30 |
10
README.md
10
README.md
@@ -1,12 +1,14 @@
|
||||
# ACP adapter for Claude Code
|
||||
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
|
||||
|
||||
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
||||
|
||||
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
|
||||
|
||||
- Context @-mentions
|
||||
- Images
|
||||
- Tool calls
|
||||
- Tool calls (with permission requests)
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
@@ -30,6 +32,8 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
|
||||
|
||||
### Other clients
|
||||
|
||||
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
|
||||
Setup instructions for other clients are coming soon. Feel free to [submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
|
||||
#### Installation
|
||||
@@ -37,13 +41,13 @@ Setup instructions for other clients are coming soon. Feel free to [submit a PR]
|
||||
Install the adapter from `npm`:
|
||||
|
||||
```bash
|
||||
$ npm install @zed-industries/claude-code-acp
|
||||
npm install @zed-industries/claude-code-acp
|
||||
```
|
||||
|
||||
You can then use `claude-code-acp` as a regular ACP agent:
|
||||
|
||||
```
|
||||
$ ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"version": "0.1.11",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"version": "0.1.11",
|
||||
"version": "0.2.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.6",
|
||||
"diff": "^8.0.2",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
@@ -1796,9 +1797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/agent-client-protocol": {
|
||||
"version": "0.2.0-alpha.4",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.4.tgz",
|
||||
"integrity": "sha512-l/MdK0MrvEb55/U3wAtAN/MC04YK+N6vNyakNgLsK2JTcmG54n45gXa0OZ08lYUmEvLe0shdu1dDtzlOV/Luxw==",
|
||||
"version": "0.2.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.6.tgz",
|
||||
"integrity": "sha512-Mepcrp9ztLKRSkUuKJ7gnxIL1NRoGbSlY+BWr6WyhJ/sqcocaPSiINs0rt24RZGiU8E+TUwy5FNFW5T9tbIR1A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.0.0"
|
||||
@@ -2215,10 +2216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
|
||||
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -4362,6 +4362,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node/node_modules/diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.1.11",
|
||||
"version": "0.2.5",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -46,7 +46,8 @@
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.6",
|
||||
"diff": "^8.0.2",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
|
||||
@@ -31,7 +31,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, sleep, unreachable } from "./utils.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
@@ -90,7 +90,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
authMethods: [
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Login with Claude CLI",
|
||||
name: "Log in with Claude Code",
|
||||
id: "claude-login",
|
||||
},
|
||||
{
|
||||
@@ -102,6 +102,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (
|
||||
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
||||
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const sessionId = uuidv7();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
@@ -134,6 +141,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
mcpServers,
|
||||
permissionPromptToolName: "mcp__acp__permission",
|
||||
stderr: (err) => console.error(err),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
executable: process.execPath as any,
|
||||
};
|
||||
|
||||
const allowedTools = [];
|
||||
@@ -168,10 +178,18 @@ export class ClaudeAcpAgent implements Agent {
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const availableCommands = await availableSlashCommands(q);
|
||||
getAvailableSlashCommands(q).then((availableCommands) => {
|
||||
this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
availableCommands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +292,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function availableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"add-dir",
|
||||
"agents", // Modal
|
||||
@@ -310,20 +328,8 @@ async function availableSlashCommands(query: Query): Promise<AvailableCommand[]>
|
||||
"todos", // Escape Codes
|
||||
"vim", // Not needed
|
||||
];
|
||||
|
||||
const commands = await Promise.race([
|
||||
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
|
||||
(query as any).supportedCommands(),
|
||||
sleep(5000).then(() => {
|
||||
if (
|
||||
fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
|
||||
!fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
throw new Error("can't load supported slash commands");
|
||||
}),
|
||||
]);
|
||||
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
|
||||
const commands = await (query as any).supportedCommands();
|
||||
|
||||
return commands
|
||||
.map((command: { name: string; description: string; argumentHint: string }) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ 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 as runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable } from "./utils.js";
|
||||
|
||||
@@ -167,7 +168,8 @@ File editing instructions:
|
||||
- For unique lines, include only those lines.
|
||||
- For non-unique lines, include enough context to identify them.
|
||||
- Do not escape quotes, newlines, or other characters.
|
||||
- Only edit the specified file.`,
|
||||
- Only edit the specified file.
|
||||
- If the \`old_string\` value isn't found in the file, the edit won't be applied. The tool will fail and must be retried.`,
|
||||
inputSchema: {
|
||||
abs_path: z.string().describe("The absolute path to the file to read."),
|
||||
old_string: z.string().describe("The old text to replace (must be unique in the file)"),
|
||||
@@ -182,56 +184,47 @@ File editing instructions:
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
});
|
||||
|
||||
const { newContent, lineNumbers } = replaceAndCalculateLocation(content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers }),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Editing file failed: " + error.message,
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.abs_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -239,7 +232,20 @@ File editing instructions:
|
||||
"multi-edit",
|
||||
{
|
||||
title: "Multi Edit",
|
||||
description: `Edit a file with multiple sequential edits.`,
|
||||
description: `Edit a file with multiple sequential edits.
|
||||
|
||||
In sessions with mcp__acp__multi-edit always use it instead of MultiEdit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
File editing instructions:
|
||||
- The \`old_string\` param must match existing file content, including indentation.
|
||||
- The \`old_string\` param must come from the actual file, not an outline.
|
||||
- The \`old_string\` section must not be empty.
|
||||
- Be minimal with replacements:
|
||||
- For unique lines, include only those lines.
|
||||
- For non-unique lines, include enough context to identify them, unless you're using \`replace_all\`
|
||||
- Do not escape quotes, newlines, or other characters.
|
||||
- If any of the provided \`old_string\` values aren't found in the file, no edits will be applied. The tool will fail and must be retried.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z
|
||||
@@ -282,7 +288,7 @@ File editing instructions:
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent, lineNumbers } = replaceAndCalculateLocation(
|
||||
const { newContent } = replaceAndCalculateLocation(
|
||||
content,
|
||||
input.edits.map((edit) => ({
|
||||
oldText: edit.old_string,
|
||||
@@ -291,6 +297,8 @@ File editing instructions:
|
||||
})),
|
||||
);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
@@ -301,7 +309,7 @@ File editing instructions:
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers }),
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -771,7 +779,7 @@ export function replaceAndCalculateLocation(
|
||||
for (const edit of edits) {
|
||||
// Skip empty oldText
|
||||
if (edit.oldText === "") {
|
||||
continue;
|
||||
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
|
||||
}
|
||||
|
||||
if (edit.replaceAll) {
|
||||
@@ -783,6 +791,11 @@ export function replaceAndCalculateLocation(
|
||||
while (true) {
|
||||
const index = currentContent.indexOf(edit.oldText, searchIndex);
|
||||
if (index === -1) {
|
||||
if (searchIndex === 0) {
|
||||
throw new Error(
|
||||
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -804,7 +817,11 @@ export function replaceAndCalculateLocation(
|
||||
} else {
|
||||
// Replace first occurrence only
|
||||
const index = currentContent.indexOf(edit.oldText);
|
||||
if (index !== -1) {
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
|
||||
);
|
||||
} else {
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
currentContent =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
NewSessionResponse,
|
||||
@@ -47,9 +48,15 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
agent: Agent;
|
||||
files: Map<string, string> = new Map();
|
||||
receivedText: string = "";
|
||||
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
|
||||
availableCommandsPromise: Promise<AvailableCommand[]>;
|
||||
|
||||
constructor(agent: Agent) {
|
||||
this.agent = agent;
|
||||
this.resolveAvailableCommands = () => {};
|
||||
this.availableCommandsPromise = new Promise((resolve) => {
|
||||
this.resolveAvailableCommands = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
takeReceivedText() {
|
||||
@@ -67,11 +74,18 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
||||
|
||||
if (
|
||||
params.update.sessionUpdate === "agent_message_chunk" &&
|
||||
params.update.content.type === "text"
|
||||
) {
|
||||
this.receivedText += params.update.content.text;
|
||||
switch (params.update.sessionUpdate) {
|
||||
case "agent_message_chunk": {
|
||||
if (params.update.content.type === "text") {
|
||||
this.receivedText += params.update.content.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "available_commands_update":
|
||||
this.resolveAvailableCommands(params.update.availableCommands);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,12 +154,14 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
it("should include available commands", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
||||
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
const commands = await client.availableCommandsPromise;
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
name: "quick-math",
|
||||
description: "10 * 3 = 30 (project)",
|
||||
input: null,
|
||||
});
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
expect(commands).toContainEqual({
|
||||
name: "say-hello",
|
||||
description: "Say hello (project)",
|
||||
input: { hint: "[name]" },
|
||||
@@ -638,36 +654,6 @@ describe("tool conversions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract locations from edit tool results", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01ABC123",
|
||||
name: "edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/example.txt",
|
||||
old_string: "old content",
|
||||
new_string: "new content",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([
|
||||
{ path: "/Users/test/project/example.txt", line: 5 },
|
||||
{ path: "/Users/test/project/example.txt", line: 10 },
|
||||
{ path: "/Users/test/project/example.txt", line: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should show full diff for multi edit tool", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
@@ -722,110 +708,22 @@ describe("tool conversions", () => {
|
||||
locations: [
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 1,
|
||||
},
|
||||
{
|
||||
path: "/Users/test/project/config.json",
|
||||
line: 3,
|
||||
},
|
||||
],
|
||||
title: "Edit /Users/test/project/config.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract locations from multi-edit tool results", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01DEF456",
|
||||
name: "multi-edit",
|
||||
input: {
|
||||
file_path: "/Users/test/project/config.json",
|
||||
edits: [
|
||||
{
|
||||
old_string: "version: 1.0",
|
||||
new_string: "version: 2.0",
|
||||
replace_all: false,
|
||||
},
|
||||
{
|
||||
old_string: "enabled: false",
|
||||
new_string: "enabled: true",
|
||||
replace_all: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([
|
||||
{ path: "/Users/test/project/config.json", line: 2 },
|
||||
{ path: "/Users/test/project/config.json", line: 8 },
|
||||
{ path: "/Users/test/project/config.json", line: 12 },
|
||||
{ path: "/Users/test/project/config.json", line: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle mcp__acp__edit tool results", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01GHI789",
|
||||
name: "mcp__acp__edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/main.py",
|
||||
old_string: "def hello():",
|
||||
new_string: "def hello_world():",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [42] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([{ path: "/Users/test/project/main.py", line: 42 }]);
|
||||
});
|
||||
|
||||
it("should handle empty locations from edit tools", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01JKL012",
|
||||
name: "edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/notfound.txt",
|
||||
old_string: "nonexistent",
|
||||
new_string: "replacement",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ lineNumbers: [] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update.locations).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle malformed tool results gracefully", () => {
|
||||
it("should return empty update for successful edit result", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "edit",
|
||||
name: "mcp__acp__edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
@@ -840,6 +738,9 @@ describe("tool conversions", () => {
|
||||
text: "not valid json",
|
||||
},
|
||||
],
|
||||
tool_use_id: "test",
|
||||
is_error: false,
|
||||
type: "tool_result" as const,
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
@@ -847,6 +748,40 @@ describe("tool conversions", () => {
|
||||
// Should return empty object when parsing fails
|
||||
expect(update).toEqual({});
|
||||
});
|
||||
|
||||
it("should return content update for edit failure", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "mcp__acp__edit",
|
||||
input: {
|
||||
abs_path: "/Users/test/project/test.txt",
|
||||
old_string: "old",
|
||||
new_string: "new",
|
||||
},
|
||||
};
|
||||
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Failed to find `old_string`",
|
||||
},
|
||||
],
|
||||
tool_use_id: "test",
|
||||
is_error: true,
|
||||
type: "tool_result" as const,
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
// Should return empty object when parsing fails
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{ content: { type: "text", text: "Failed to find `old_string`" }, type: "content" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
|
||||
@@ -22,12 +22,9 @@ describe("replaceAndCalculateLocation", () => {
|
||||
|
||||
it("should return empty line numbers array when no match found", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "notfound", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe(content);
|
||||
expect(result.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
|
||||
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
|
||||
});
|
||||
|
||||
it("should handle Windows line endings (CRLF)", () => {
|
||||
@@ -89,35 +86,31 @@ describe("replaceAndCalculateLocation", () => {
|
||||
});
|
||||
|
||||
it("should handle empty file content", () => {
|
||||
const content = "";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("");
|
||||
expect(result.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
|
||||
}).toThrowError(
|
||||
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty search text", () => {
|
||||
// Test with replaceAll false (default)
|
||||
const content1 = "line 1\nline 2\nline 3";
|
||||
const result1 = replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
expect(result1.newContent).toBe(content1);
|
||||
expect(result1.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on single line
|
||||
const content2 = "abc";
|
||||
const result2 = replaceAndCalculateLocation(content2, [
|
||||
{ oldText: "", newText: "X", replaceAll: true },
|
||||
]);
|
||||
expect(result2.newContent).toBe(content2);
|
||||
expect(result2.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on multiline content
|
||||
const content3 = "ab\ncd";
|
||||
const result3 = replaceAndCalculateLocation(content3, [
|
||||
{ oldText: "", newText: "X", replaceAll: true },
|
||||
]);
|
||||
expect(result3.newContent).toBe(content3);
|
||||
expect(result3.lineNumbers).toEqual([]);
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
});
|
||||
|
||||
it("should handle single line content", () => {
|
||||
|
||||
148
src/tools.ts
148
src/tools.ts
@@ -140,21 +140,21 @@ export function toolInfoFromToolUse(
|
||||
const path = input?.abs_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let line = null;
|
||||
let affectedLines: number[] = [];
|
||||
|
||||
if (path && input.old_text) {
|
||||
if (path && oldText) {
|
||||
try {
|
||||
const oldContent = cachedFileContent[path] || "";
|
||||
const newContent = replaceAndCalculateLocation(oldContent, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
oldText,
|
||||
newText,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
oldText = oldContent;
|
||||
newText = newContent.newContent;
|
||||
line = newContent.lineNumbers[0];
|
||||
affectedLines = newContent.lineNumbers;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -173,7 +173,11 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: path ? [{ path, line }] : [],
|
||||
locations: path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path }))
|
||||
: [{ path }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,6 +193,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
|
||||
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
|
||||
let affectedLines: number[] = [];
|
||||
try {
|
||||
if (multiInput.edits && multiInput.file_path) {
|
||||
const oldContent =
|
||||
@@ -204,6 +209,7 @@ export function toolInfoFromToolUse(
|
||||
);
|
||||
oldTextMulti = oldContent;
|
||||
newTextMulti = newContent.newContent;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -221,7 +227,11 @@ export function toolInfoFromToolUse(
|
||||
newText: newTextMulti,
|
||||
},
|
||||
],
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
locations: input?.file_path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path: input.file_path }))
|
||||
: [{ path: input.file_path }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
case "mcp__acp__write": {
|
||||
@@ -424,60 +434,18 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefined): ToolUpdate {
|
||||
type ToolResult = {
|
||||
type: "tool_result";
|
||||
content: any;
|
||||
tool_use_id: string;
|
||||
is_error: boolean;
|
||||
};
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: ToolResult,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
switch (toolUse?.name) {
|
||||
case "mcp__acp__edit":
|
||||
case "edit":
|
||||
case "Edit": {
|
||||
// Parse the line numbers from the tool result
|
||||
if (toolResult.content && toolResult.content.length > 0) {
|
||||
try {
|
||||
const firstContent = toolResult.content[0];
|
||||
const data = JSON.parse(firstContent.text || firstContent);
|
||||
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
|
||||
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
|
||||
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
|
||||
line: line,
|
||||
}));
|
||||
return { locations };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing edit tool result: ${error}`);
|
||||
// If parsing fails, return empty object
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
case "mcp__acp__multi-edit":
|
||||
case "multi-edit":
|
||||
case "MultiEdit": {
|
||||
// Parse the line numbers from the tool result
|
||||
if (toolResult.content && toolResult.content.length > 0) {
|
||||
try {
|
||||
const firstContent = toolResult.content[0];
|
||||
const data = JSON.parse(firstContent.text || firstContent);
|
||||
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
|
||||
const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
|
||||
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
|
||||
line: line,
|
||||
}));
|
||||
return { locations };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing edit tool result: ${error}`);
|
||||
|
||||
// If parsing fails, return empty object
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "mcp__acp__write":
|
||||
case "Write":
|
||||
return {};
|
||||
|
||||
case "Read":
|
||||
case "mcp__acp__read":
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
@@ -509,8 +477,20 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
|
||||
return {};
|
||||
|
||||
case "mcp__acp__Bash":
|
||||
// The bash tool already embeds a terminal
|
||||
case "mcp__acp__edit":
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case "mcp__acp__multi-edit":
|
||||
case "multi-edit":
|
||||
case "MultiEdit":
|
||||
case "mcp__acp__write":
|
||||
case "Write": {
|
||||
if (toolResult.is_error && toolResult.content?.length > 0) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
@@ -527,31 +507,35 @@ export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefin
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
type: "content",
|
||||
content,
|
||||
})),
|
||||
};
|
||||
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: toolResult.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
return toAcpContentUpdate(toolResult.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toAcpContentUpdate(content: any): { content?: ToolCallContent[] } {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return {
|
||||
content: content.map((content: any) => ({
|
||||
type: "content",
|
||||
content,
|
||||
})),
|
||||
};
|
||||
} else if (typeof content === "string" && content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
|
||||
Reference in New Issue
Block a user