Compare commits

...

23 Commits

Author SHA1 Message Date
Agus Zubiaga
a4c9ba3c7a 0.2.0 2025-09-03 09:05:24 -03:00
Agus Zubiaga
a08ce88f30 Improve edit tool quality (#14)
- Return a unified diff after applying changes
- Fail if the `old_string` isn't found
- Fail if the `old_string` is empty
- Throw instead of returning succesful content on error

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-03 09:04:06 -03:00
Agus Zubiaga
f717175cdc 0.1.11 2025-09-02 23:16:46 -03:00
Agus Zubiaga
d1fd794ecb Add backticks around glob pattern (#13)
Looks nicer and prevents potential markdown issues
2025-09-02 23:13:27 -03:00
Agus Zubiaga
f54da50bf5 Update README.md 2025-09-02 22:35:45 -03:00
Agus Zubiaga
55c61986e8 0.1.10 2025-09-02 20:03:22 -03:00
Agus Zubiaga
46f643b9f4 Improve background terminal output 2025-09-02 20:03:14 -03:00
Agus Zubiaga
07316e2eb0 Redirect console.log et al to stderr 2025-09-02 19:46:30 -03:00
Agus Zubiaga
9dc15bd190 0.1.9 2025-09-02 19:26:40 -03:00
Agus Zubiaga
df04c41d5a Only include relevant files 2025-09-02 19:14:05 -03:00
Agus Zubiaga
a3038d383f Setup linter (#12)
Fixes basic issues. For now, we're still allowing `any`, but it'd be
nice to move away from it.
2025-09-02 19:06:32 -03:00
Agus Zubiaga
28f36d7474 Format README 2025-09-02 18:24:51 -03:00
Agus Zubiaga
a07f132a9a Improve "Other clients" section 2025-09-02 18:23:05 -03:00
Agus Zubiaga
7d969fd9ff Update README.md 2025-09-02 18:17:17 -03:00
Conrad Irwin
a4e8ba0b14 Remove more unsupported /slash commands 2025-09-02 13:18:13 -07:00
Conrad Irwin
532daf0e7e v0.1.8 2025-09-02 12:33:40 -07:00
Conrad Irwin
d6e5910631 Fix load errors when SDK is busted 2025-09-02 12:33:11 -07:00
Agus Zubiaga
0d5e78f1ac Fix info for builtin tools (#11) 2025-09-02 15:50:21 -03:00
Agus Zubiaga
02fac67f52 Do not send tool_call_update for TodoWrite (#10)
When we receive a `TodoWrite` tool use, we send a plan update instead of
a tool call. However, we were still trying to send a tool call update
when we received the `TodoWrite` tool result, which caused an ACP error.

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-09-02 11:53:54 -03:00
Agus Zubiaga
1ef3ba327d Unpin SDK now that entrypoint is fixed (#9)
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-02 11:38:45 -03:00
Agus Zubiaga
617233d693 0.1.7 2025-09-02 11:25:56 -03:00
Bennet Bo Fenner
0dc5a4b366 Add support for slash commands (#7)
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-02 10:29:45 +02:00
Agus Zubiaga
6d1711cfa4 Use ACP terminal support when available (#8)
When the `terminal` client capability is available, we'll now replace
Claude Code's bash tools with our own that go over ACP. This includes
support for background terminals.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Bennet <bennet@zed.dev>
2025-09-01 12:01:50 -03:00
16 changed files with 2596 additions and 640 deletions

6
.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.min.js
*.config.js
.github/

33
.eslintrc.json Normal file
View File

@@ -0,0 +1,33 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"env": {
"node": true,
"es2020": true
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-console": "off",
"no-constant-condition": "off",
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"curly": ["error", "all"]
},
"ignorePatterns": ["dist", "node_modules", "*.js"]
}

View File

@@ -35,6 +35,9 @@ jobs:
- name: Check formatting
run: npm run format:check
- name: Run linter
run: npm run lint
- name: Build
run: npm run build

4
.prettierrc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"printWidth": 100,
"tabWidth": 2
}

View File

@@ -1,3 +1,51 @@
An ACP-compatible coding agent powered by the Claude Code SDK
# ACP adapter for Claude Code
This wrapper lets you use the real version of Claude Code from the IDE of your choice.
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
- Following
- Edit review
- TODO lists
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
## How to use
### Zed
The latest version of Zed can already use this adapter out of the box.
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
### Other clients
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
Install the adapter from `npm`:
```bash
$ 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
```
## License
Apache-2.0

1569
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,25 @@
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.1.6",
"version": "0.2.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
"files": [
"dist/",
"README.md",
"LICENSE",
"package.json"
],
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "npm run build && npm run start",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run lint && npm run format:check",
"test": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
"test:run": "vitest run",
@@ -34,10 +43,11 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.96",
"@anthropic-ai/claude-code": "^1.0.100",
"@modelcontextprotocol/sdk": "^1.17.4",
"@types/express": "^5.0.3",
"@zed-industries/agent-client-protocol": "0.1.2",
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
"diff": "^8.0.2",
"express": "^5.1.0",
"minimist": "^1.2.8",
"uuid": "11.1.0"
@@ -46,7 +56,11 @@
"@types/minimist": "^1.2.5",
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",

View File

@@ -2,12 +2,11 @@ import {
Agent,
AgentSideConnection,
AuthenticateRequest,
AvailableCommand,
CancelNotification,
Client,
ClientCapabilities,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
@@ -15,8 +14,8 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
ToolCallContent,
ToolKind,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
@@ -26,26 +25,17 @@ import {
Query,
query,
SDKAssistantMessage,
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-code";
import * as readline from "node:readline";
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,
unreachable,
} from "./utils.js";
import { ContentBlock } from "@zed-industries/agent-client-protocol";
import { nodeToWebReadable, nodeToWebWritable, Pushable, sleep, unreachable } from "./utils.js";
import { SessionNotification } from "@zed-industries/agent-client-protocol";
import { createMcpServer } from "./mcp-server.js";
import { AddressInfo } from "node:net";
import {
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
type Session = {
query: Query;
@@ -53,29 +43,49 @@ type Session = {
cancelled: boolean;
};
type BackgroundTerminal =
| {
handle: TerminalHandle;
status: "started";
lastOutput: TerminalOutputResponse | null;
}
| {
status: "aborted" | "exited" | "killed" | "timedOut";
pendingOutput: TerminalOutputResponse;
};
type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
};
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
[key: string]: Session;
};
client: Client;
toolUseCache: { [key: string]: any };
fileContentCache: { [key: string]: any };
client: AgentSideConnection;
toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
constructor(client: Client) {
constructor(client: AgentSideConnection) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: { image: true, embeddedContext: true },
promptCapabilities: {
image: true,
embeddedContext: true,
},
},
authMethods: [
{
@@ -92,8 +102,8 @@ export class ClaudeAcpAgent implements Agent {
};
}
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
let sessionId = uuidv7();
let input = new Pushable<SDKUserMessage>();
const sessionId = uuidv7();
const input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) {
@@ -109,12 +119,8 @@ export class ClaudeAcpAgent implements Agent {
}
}
let server = await createMcpServer(
this,
sessionId,
this.clientCapabilities,
);
let address = server.address() as AddressInfo;
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
const address = server.address() as AddressInfo;
mcpServers["acp"] = {
type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp",
@@ -123,22 +129,36 @@ export class ClaudeAcpAgent implements Agent {
},
};
let options: Options = {
const options: Options = {
cwd: params.cwd,
mcpServers,
disallowedTools: [],
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
};
const allowedTools = [];
const disallowedTools = [];
if (this.clientCapabilities?.fs?.readTextFile) {
options.allowedTools = ["mcp__acp__read"];
options.disallowedTools!.push("Read");
allowedTools.push("mcp__acp__read");
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
options.disallowedTools!.push("Write", "Edit", "MultiEdit");
allowedTools.push("mcp__acp__write");
disallowedTools.push("Write", "Edit", "MultiEdit");
}
if (this.clientCapabilities?.terminal) {
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
disallowedTools.push("Bash", "BashOutput", "KillBash");
}
let q = query({
if (allowedTools.length > 0) {
options.allowedTools = allowedTools;
}
if (disallowedTools.length > 0) {
options.disallowedTools = disallowedTools;
}
const q = query({
prompt: input,
options,
});
@@ -148,12 +168,14 @@ export class ClaudeAcpAgent implements Agent {
cancelled: false,
};
const availableCommands = await availableSlashCommands(q);
return {
sessionId,
availableCommands,
};
}
async authenticate(params: AuthenticateRequest): Promise<void> {
async authenticate(_params: AuthenticateRequest): Promise<void> {
throw new Error("Method not implemented.");
}
@@ -168,7 +190,7 @@ export class ClaudeAcpAgent implements Agent {
input.push(promptToClaude(params));
while (true) {
let { value: message, done } = await query.next();
const { value: message, done } = await query.next();
if (done || !message) {
if (this.sessions[params.sessionId].cancelled) {
return { stopReason: "cancelled" };
@@ -206,8 +228,8 @@ export class ClaudeAcpAgent implements Agent {
}
if (
message.message.model == "<synthetic>" &&
message.message.content.length == 1 &&
message.message.model === "<synthetic>" &&
message.message.content.length === 1 &&
message.message.content[0].text.includes("Please run /login")
) {
throw RequestError.authRequired();
@@ -237,25 +259,87 @@ export class ClaudeAcpAgent implements Agent {
await this.sessions[params.sessionId].query.interrupt();
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
let response = await this.client.readTextFile(params);
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const response = await this.client.readTextFile(params);
if (!params.limit && !params.line) {
this.fileContentCache[params.path] = response.content;
}
return response;
}
async writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
let response = await this.client.writeTextFile(params);
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
const response = await this.client.writeTextFile(params);
this.fileContentCache[params.path] = params.content;
return response;
}
}
async function availableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
"agents", // Modal
"bashes", // Modal
"bug", // Modal
"clear", // Escape Codes
"compact", // Not supported via SDK?
"config", // Modal
"context", // Escape Codes
"cost", // Escape Codes
"doctor", // Escape Codes
"exit",
"export", // Modal
"help", // Modal
"hooks", // Modal
"ide", // Modal
"install-github-app", // Modal
"login",
"logout",
"memory",
"mcp",
"migrate-installer", // Modal
"model", // Not supported via SDK?
"output-style", // Modal
"output-style:new", // Modal
"permissions", // Modal
"privacy-settings",
"release-notes", // Escape Codes
"resume",
"status", // Not supported via SDK?
"statusline", // Not needed
"terminal-setup", // Not needed
"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");
}),
]);
return commands
.map((command: { name: string; description: string; argumentHint: string }) => {
const input = command.argumentHint ? { hint: command.argumentHint } : null;
return {
name: command.name,
description: command.description || "",
input,
};
})
.filter(
(command: AvailableCommand) =>
!(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
);
}
function formatUriAsLink(uri: string): string {
try {
if (uri.startsWith("file://")) {
@@ -351,14 +435,14 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: { [key: string]: any },
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
let chunks = message.message.content as ContentChunk[];
let output = [];
const chunks = message.message.content as ContentChunk[];
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) {
let update: SessionNotification["update"];
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
update = {
@@ -375,8 +459,7 @@ export function toAcpNotifications(
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType:
chunk.source.type === "base64" ? chunk.source.media_type : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
@@ -392,7 +475,7 @@ export function toAcpNotifications(
break;
case "tool_use":
toolUseCache[chunk.id] = chunk;
if (chunk.name == "TodoWrite") {
if (chunk.name === "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input),
@@ -408,19 +491,32 @@ export function toAcpNotifications(
}
break;
case "tool_result":
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
case "tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
if (toolUse.name !== "TodoWrite") {
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
}
break;
}
default:
throw new Error("unhandled chunk type: " + chunk.type);
}
output.push({ sessionId, update });
if (update) {
output.push({ sessionId, update });
}
}
return output;
@@ -436,7 +532,7 @@ export function runAcp() {
type ContentChunk =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: any } // input is serde_json::Value, so use any or unknown
| { type: "tool_use"; id: string; name: string; input: any }
| {
type: "tool_result";
content: string;

View File

@@ -1,5 +1,12 @@
#!/usr/bin/env node
// 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;
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();

View File

@@ -1,11 +1,14 @@
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities } from "@zed-industries/agent-client-protocol";
import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
import * as diff from "diff";
import { sleep, unreachable } from "./utils.js";
export const SYSTEM_REMINDER = `
@@ -35,19 +38,12 @@ Never attempt to read a path that hasn't been previously mentioned.
In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
inputSchema: {
abs_path: z
.string()
.describe("The absolute path to the file to read."),
abs_path: z.string().describe("The absolute path to the file to read."),
offset: z
.number()
.optional()
.describe(
"Which line to start reading from. Omit to start from the beginning.",
),
limit: z
.number()
.optional()
.describe("How many lines to read. Omit for the whole file."),
.describe("Which line to start reading from. Omit to start from the beginning."),
limit: z.number().optional().describe("How many lines to read. Omit for the whole file."),
},
annotations: {
title: "Read file",
@@ -70,7 +66,7 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
],
};
}
let content = await agent.readTextFile({
const content = await agent.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
@@ -109,9 +105,7 @@ In sessions with mcp__acp__read always use it instead of Read as it contains the
In sessions with mcp__acp__write always use it instead of Write as it will
allow the user to conveniently review changes.`,
inputSchema: {
abs_path: z
.string()
.describe("The absolute path to the file to write"),
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
},
annotations: {
@@ -135,7 +129,7 @@ allow the user to conveniently review changes.`,
],
};
}
let content = await agent.writeTextFile({
await agent.writeTextFile({
sessionId,
path: input.abs_path,
content: input.content,
@@ -167,22 +161,19 @@ In sessions with mcp__acp__edit always use it instead of Edit as it will
allow the user to conveniently review changes.
File editing instructions:
- The \`old_text\` param must match existing file content, including indentation.
- The \`old_text\` param must come from the actual file, not an outline.
- The \`old_text\` section must not be empty.
- 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.
- 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_text: z
.string()
.describe("The old text to replace (must be unique in the file)"),
new_text: z.string().describe("The new text."),
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)"),
new_string: z.string().describe("The new text."),
},
annotations: {
title: "Edit file",
@@ -193,59 +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",
},
],
};
}
let { content } = await agent.readTextFile({
sessionId,
path: input.abs_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
[
{
oldText: input.old_text,
newText: input.new_text,
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,
},
],
};
},
);
@@ -253,11 +232,22 @@ 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"),
file_path: z.string().describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
@@ -266,15 +256,11 @@ File editing instructions:
replace_all: z
.boolean()
.optional()
.describe(
"Replace all occurrences of old_string (default false)",
),
.describe("Replace all occurrences of old_string (default false)"),
}),
)
.min(1)
.describe(
"Array of edit operations to perform sequentially on the file",
),
.describe("Array of edit operations to perform sequentially on the file"),
},
annotations: {
title: "Multi Edit file",
@@ -297,12 +283,12 @@ File editing instructions:
};
}
let { content } = await agent.readTextFile({
const { content } = await agent.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(
const { newContent } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
@@ -311,6 +297,8 @@ File editing instructions:
})),
);
const patch = diff.createPatch(input.file_path, content, newContent);
await agent.writeTextFile({
sessionId,
path: input.file_path,
@@ -321,7 +309,7 @@ File editing instructions:
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
text: patch,
},
],
};
@@ -329,7 +317,253 @@ File editing instructions:
);
}
let alwaysAllowedTools: { [key: string]: boolean } = {};
if (agent.clientCapabilities?.terminal) {
server.registerTool(
"Bash",
{
title: "Bash",
description: "Executes a bash command",
inputSchema: {
command: z.string().describe("The bash command to execute as a one-liner"),
timeout_ms: z
.number()
.default(2 * 60 * 1000)
.describe("Optional timeout in milliseconds"),
run_in_background: z
.boolean()
.default(false)
.describe(
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
),
},
},
async (input, extra) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout_ms).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
},
);
server.registerTool(
"BashOutput",
{
title: "BashOutput",
description:
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
inputSchema: {
id: z
.string()
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
},
);
server.registerTool(
"KillBash",
{
title: "KillBash",
description: "Stops a background command by its id",
inputSchema: {
id: z
.string()
.describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
},
},
async (input) => {
const bgTerm = agent.backgroundTerminals[input.id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.id}`);
}
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
return unreachable(bgTerm);
}
}
},
);
}
const alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(
"permission",
{
@@ -369,7 +603,7 @@ File editing instructions:
],
};
}
let response = await agent.client.requestPermission({
const response = await agent.client.requestPermission({
options: [
{
kind: "allow_always",
@@ -386,11 +620,10 @@ File editing instructions:
},
});
if (
response.outcome?.outcome == "selected" &&
(response.outcome.optionId == "allow" ||
response.outcome.optionId == "allow_always")
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
) {
if (response.outcome.optionId == "allow_always") {
if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true;
}
return {
@@ -425,10 +658,9 @@ File editing instructions:
app.post("/mcp", async (req, res) => {
try {
const transport: StreamableHTTPServerTransport =
new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close();
server.close();
@@ -441,7 +673,7 @@ File editing instructions:
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
message: `Internal server error: ${error}`,
},
id: null,
});
@@ -460,6 +692,67 @@ File editing instructions:
});
}
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return b.slice(i);
}
function toolCommandOutput(
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
output: TerminalOutputResponse,
): string {
const { exitStatus, output: commandOutput, truncated } = output;
let toolOutput = "";
switch (status) {
case "started":
case "exited": {
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
toolOutput += `Interrupted by the user. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus) {
if (typeof exitStatus.exitCode === "number") {
toolOutput += `Exited with code ${exitStatus.exitCode}.`;
}
if (typeof exitStatus.signal === "string") {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
toolOutput += "Final output:\n\n";
} else {
toolOutput += "New output:\n\n";
}
toolOutput += commandOutput;
if (truncated) {
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
}
return toolOutput;
}
/**
* Replace text in a file and calculate the line numbers where the edits occurred.
*
@@ -486,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) {
@@ -497,7 +790,14 @@ export function replaceAndCalculateLocation(
while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) break;
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;
}
// Add content before the match
parts.push(currentContent.substring(lastIndex, index));
@@ -517,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 =

View File

@@ -0,0 +1,5 @@
---
description: 10 * 3 = 30
---
What is 10 \* 3. Only respond with the number.

View File

@@ -0,0 +1,6 @@
---
description: Say hello
argument-hint: [name]
---
Respond with "Hello $1" and nothing else.

View File

@@ -4,6 +4,7 @@ import {
Agent,
Client,
ClientSideConnection,
NewSessionResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -13,103 +14,168 @@ import {
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import {
markdownEscape,
toolInfoFromToolUse,
toolUpdateFromToolResult,
} from "../tools.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"ACP subprocess integration",
() => {
let child: ReturnType<typeof spawn>;
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
beforeAll(async () => {
let valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
beforeAll(async () => {
const valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) {
throw new Error("failed to compile");
}
// Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
afterAll(() => {
child.kill();
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
class TestClient implements Client {
agent: Agent;
afterAll(() => {
child.kill();
});
constructor(agent: Agent) {
this.agent = agent;
}
requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
throw new Error("Method not implemented.");
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEVIED", JSON.stringify(params, null, 4));
}
writeTextFile(
params: WriteTextFileRequest,
): Promise<WriteTextFileResponse> {
throw new Error("Method not implemented.");
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
return {
content:
"One\nTwo\nThree\nFour\nFive\nSix\nSeven\nEight\nNine\nTen\nEleven\nTwelve\nThirteen\nFourteen\nFifteen\nSixteen\nSeventeen\nEighteen\nNineteen\nTwenty\n",
};
class TestClient implements Client {
agent: Agent;
files: Map<string, string> = new Map();
receivedText: string = "";
constructor(agent: Agent) {
this.agent = agent;
}
takeReceivedText() {
const text = this.receivedText;
this.receivedText = "";
return text;
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
return { outcome: { outcome: "selected", optionId } };
}
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;
}
}
it("should connect to the ACP subprocess", async () => {
const connection = new ClientSideConnection(
(agent) => {
return new TestClient(agent);
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
this.files.set(params.path, params.content);
return null;
}
await connection.initialize({
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const content = this.files.get(params.path) ?? "";
return {
content,
};
}
}
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
await connection.prompt({
prompt: [
{
type: "text",
text: "Use the multi edit tool to remove all multiples of 5 from the README and replace with buzz",
},
],
sessionId: session.sessionId,
});
console.log("DONE AWAITING");
}, 20000);
},
);
async function setupTestSession(cwd: string): Promise<{
client: TestClient;
connection: ClientSideConnection;
newSessionResponse: NewSessionResponse;
}> {
let client;
const connection = new ClientSideConnection(
(agent) => {
client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
await connection.initialize({
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
const newSessionResponse = await connection.newSession({
cwd,
mcpServers: [],
});
return { client: client!, connection, newSessionResponse };
}
it("should connect to the ACP subprocess", async () => {
const { client, connection, newSessionResponse } = await setupTestSession("./");
await connection.prompt({
prompt: [
{
type: "text",
text: "Hello",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).not.toEqual("");
}, 30000);
it("should include available commands", async () => {
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
expect(newSessionResponse.availableCommands).toContainEqual({
name: "quick-math",
description: "10 * 3 = 30 (project)",
input: null,
});
expect(newSessionResponse.availableCommands).toContainEqual({
name: "say-hello",
description: "Say hello (project)",
input: { hint: "[name]" },
});
await connection.prompt({
prompt: [
{
type: "text",
text: "/quick-math",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("30");
await connection.prompt({
prompt: [
{
type: "text",
text: "/say-hello GPT-5",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("Hello GPT-5");
}, 30000);
});
describe("tool conversions", () => {
it("should handle Bash nicely", () => {
@@ -150,7 +216,7 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title: "Find */**.ts",
title: "Find `*/**.ts`",
content: [],
locations: [],
});
@@ -196,8 +262,7 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search",
title:
"List the `/Users/test/github/claude-code-acp` directory's contents",
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
content: [],
locations: [],
});
@@ -226,7 +291,7 @@ describe("tool conversions", () => {
id: "toolu_01ABC123XYZ789",
name: "Write",
input: {
abs_path: "/Users/test/project/example.txt",
file_path: "/Users/test/project/example.txt",
content: "Hello, World!\nThis is test content.",
},
};
@@ -278,7 +343,7 @@ describe("tool conversions", () => {
id: "toolu_01MNO456PQR789",
name: "Read",
input: {
abs_path: "/Users/test/project/readme.md",
file_path: "/Users/test/project/readme.md",
},
};
@@ -411,7 +476,7 @@ describe("tool conversions", () => {
});
it("should handle KillBash entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash",
@@ -428,7 +493,7 @@ describe("tool conversions", () => {
});
it("should handle BashOutput entries", () => {
let tool_use = {
const tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
@@ -485,8 +550,7 @@ describe("tool conversions", () => {
activeForm: "Adding concurrent access tests",
},
{
content:
"Add tests for Each function with various data types",
content: "Add tests for Each function with various data types",
status: "pending",
activeForm: "Adding Each function tests",
},
@@ -574,36 +638,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_text: "old content",
new_text: "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",
@@ -625,7 +659,7 @@ describe("tool conversions", () => {
],
},
};
let fileCache: { [key: string]: string } = {
const fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify(
{
version: 1,
@@ -641,14 +675,14 @@ describe("tool conversions", () => {
content: [
{
newText: `{
\"version\": 2,
\"filler\": \"filler\",
\"enabled\": true
"version": 2,
"filler": "filler",
"enabled": true
}`,
oldText: `{
\"version\": 1,
\"filler\": \"filler\",
\"enabled\": false
"version": 1,
"filler": "filler",
"enabled": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
@@ -664,110 +698,15 @@ describe("tool conversions", () => {
});
});
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_text: "def hello():",
new_text: "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_text: "nonexistent",
new_text: "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_text: "old",
new_text: "new",
old_string: "old",
new_string: "new",
},
};
@@ -778,6 +717,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);
@@ -785,6 +727,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", () => {
@@ -795,8 +771,6 @@ describe("escape markdown", () => {
text = "for example:\n```markdown\nHello *world*!\n```\n";
escaped = markdownEscape(text);
expect(escaped).toEqual(
"````\nfor example:\n```markdown\nHello *world*!\n```\n````",
);
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});

View File

@@ -4,13 +4,9 @@ import { replaceAndCalculateLocation } from "../mcp-server.js";
describe("replaceAndCalculateLocation", () => {
it("should replace first occurrence and return correct line number", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with text\nline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
});
@@ -20,20 +16,15 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\nline 3 with replaced\nline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
});
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)", () => {
@@ -54,9 +45,7 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\rline 2 with replaced\rline 3 with replaced\rline 4",
);
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
@@ -66,17 +55,13 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4",
);
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle text at the beginning of file", () => {
const content = "text at start\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
@@ -84,9 +69,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle text at the end of file", () => {
const content = "line 1\nline 2\nlast line with text";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
@@ -98,53 +81,41 @@ describe("replaceAndCalculateLocation", () => {
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\nreplaced replaced replaced\nline 3",
);
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
});
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", () => {
const content = "single line with text here";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("single line with replaced here");
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
@@ -183,9 +154,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle very long lines", () => {
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
const content = `line 1\n${longLine}\nline 3`;
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
@@ -198,9 +167,7 @@ describe("replaceAndCalculateLocation", () => {
lines[500] = "line with text";
const content = lines.join("\n");
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.lineNumbers).toEqual([500]);
expect(result.newContent).includes("line with replaced");
@@ -224,9 +191,7 @@ describe("replaceAndCalculateLocation", () => {
const result1 = replaceAndCalculateLocation(content, [
{ oldText: "short", newText: "very very long text" },
]);
expect(result1.newContent).toBe(
"very very long text\nmedium line\nlong line here",
);
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
expect(result1.lineNumbers).toEqual([0]);
// Replacing with shorter text
@@ -260,9 +225,7 @@ describe("replaceAndCalculateLocation", () => {
it("should handle replacement at exact end of line", () => {
const content = "line ends with text\nnext line";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced" },
]);
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line ends with replaced\nnext line");
expect(result.lineNumbers).toEqual([0]);
@@ -302,9 +265,7 @@ describe("replaceAndCalculateLocation", () => {
// - All "foo" occurrences (lines 1, 3, 5 in original)
// - First "bar" occurrence (line 1 after first edit)
// - "line 3" (line 2 after previous edits)
expect(result.newContent).toBe(
"line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7",
);
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -326,9 +287,7 @@ describe("replaceAndCalculateLocation", () => {
// First edit replaces first "hello" only
// Second edit replaces all "world" in the modified content
// Third edit replaces remaining "hello"s in the modified content
expect(result.newContent).toBe(
"hi earth\ngreetings greetings\nearth greetings earth",
);
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
@@ -340,17 +299,14 @@ describe("replaceAndCalculateLocation", () => {
it("should handle complex scenarios with marker-like text", () => {
// Test that our marker approach doesn't get confused by similar text
const content =
"line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
{ oldText: "text", newText: "content" },
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
]);
expect(result.newContent).toBe(
"line 1\nREPLACEDcontent\nline 3\nDONE\nline 5",
);
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
// Line numbers should be correct even with marker-like text
expect(result.lineNumbers).toEqual([

View File

@@ -1,13 +1,10 @@
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import {
Client,
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { tool } from "@anthropic-ai/claude-code";
interface ToolInfo {
title: string;
@@ -47,9 +44,7 @@ export function toolInfoFromToolUse(
case "NotebookRead":
return {
title: input?.notebook_path
? `Read Notebook ${input.notebook_path}`
: "Read Notebook",
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
kind: "read",
content: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
@@ -57,9 +52,7 @@ export function toolInfoFromToolUse(
case "NotebookEdit":
return {
title: input?.notebook_path
? `Edit Notebook ${input.notebook_path}`
: "Edit Notebook",
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
kind: "edit",
content:
input && input.new_source
@@ -74,10 +67,9 @@ export function toolInfoFromToolUse(
};
case "Bash":
case "mcp__acp__Bash":
return {
title: input?.command
? "`" + input.command.replaceAll("`", "\\`") + "`"
: "Terminal",
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
kind: "execute",
content:
input && input.description
@@ -91,6 +83,7 @@ export function toolInfoFromToolUse(
};
case "BashOutput":
case "mcp__acp__BashOutput":
return {
title: "Tail Logs",
kind: "execute",
@@ -98,6 +91,7 @@ export function toolInfoFromToolUse(
};
case "KillBash":
case "mcp__acp__KillBash":
return {
title: "Kill Process",
kind: "execute",
@@ -108,11 +102,7 @@ export function toolInfoFromToolUse(
let limit = "";
if (input.limit) {
limit =
" (" +
((input.offset ?? 0) + 1) +
" - " +
((input.offset ?? 0) + input.limit) +
")";
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
} else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")";
}
@@ -134,7 +124,7 @@ export function toolInfoFromToolUse(
title: "Read File",
kind: "read",
content: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
};
case "LS":
@@ -146,46 +136,50 @@ export function toolInfoFromToolUse(
};
case "mcp__acp__edit":
case "Edit":
let oldText = input.old_text || null;
let newText = input.new_text;
case "Edit": {
const path = input?.abs_path ?? input?.file_path;
let oldText = input.old_string ?? null;
let newText = input.new_string ?? "";
let line = null;
if (input?.abs_path && input.old_text) {
if (path && input.old_text) {
try {
let oldContent = cachedFileContent[input.abs_path] || "";
let newContent = replaceAndCalculateLocation(oldContent, [
const oldContent = cachedFileContent[path] || "";
const newContent = replaceAndCalculateLocation(oldContent, [
{
oldText: input.old_text,
newText: input.new_text,
oldText: input.old_string,
newText: input.new_string,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
line = newContent.lineNumbers[0];
} catch (e) {
console.error(e);
}
}
return {
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
content:
input && input.abs_path
input && path
? [
{
type: "diff",
path: input.abs_path,
path,
oldText,
newText,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: path ? [{ path, line }] : [],
};
}
case "mcp__acp__multi-edit":
case "MultiEdit":
let multiInput = input as {
case "MultiEdit": {
const multiInput = input as {
file_path: string;
edits: {
old_string: string;
@@ -193,18 +187,14 @@ export function toolInfoFromToolUse(
replace_all?: boolean;
}[];
};
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 oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
try {
if (multiInput.edits && multiInput.file_path) {
let oldContent =
const oldContent =
cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
let newContent = replaceAndCalculateLocation(
const newContent = replaceAndCalculateLocation(
oldContent,
multiInput.edits.map((edit) => ({
oldText: edit.old_string,
@@ -233,8 +223,8 @@ export function toolInfoFromToolUse(
],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
case "mcp__acp__write":
}
case "mcp__acp__write": {
let content: ToolCallContent[] = [];
if (input && input.abs_path) {
content = [
@@ -259,32 +249,33 @@ export function toolInfoFromToolUse(
content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
};
}
case "Write":
return {
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
title: input?.file_path ? `Write ${input.file_path}` : "Write",
kind: "edit",
content:
input && input.abs_path
input && input.file_path
? [
{
type: "diff",
path: input.abs_path,
path: input.file_path,
oldText: null,
newText: input.content,
},
]
: [],
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
case "Glob": {
let label = "Find";
if (input.path) {
label += ` ${input.path}`;
label += ` \`${input.path}\``;
}
if (input.pattern) {
label += ` ${input.pattern}`;
label += ` \`${input.pattern}\``;
}
return {
title: label,
@@ -323,6 +314,7 @@ export function toolInfoFromToolUse(
label += " -c";
break;
case "Content":
default:
break;
}
}
@@ -432,64 +424,18 @@ export function toolInfoFromToolUse(
}
}
type ToolResult = {
type: "tool_result";
content: any;
tool_use_id: string;
is_error: boolean;
};
export function toolUpdateFromToolResult(
toolResult: any,
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 (e) {
// 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 (e) {
// 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) {
@@ -497,29 +443,22 @@ export function toolUpdateFromToolResult(
content: toolResult.content.map((content: any) => ({
type: "content",
content:
content.type == "text"
content.type === "text"
? {
type: "text",
text: markdownEscape(
content.text.replace(SYSTEM_REMINDER, ""),
),
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
}
: content,
})),
};
} else if (
typeof toolResult.content === "string" &&
toolResult.content.length > 0
) {
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: markdownEscape(
toolResult.content.replace(SYSTEM_REMINDER, ""),
),
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
},
},
],
@@ -527,6 +466,22 @@ export function toolUpdateFromToolResult(
}
return {};
case "mcp__acp__Bash":
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":
case "NotebookRead":
@@ -542,34 +497,35 @@ export function toolUpdateFromToolResult(
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";
@@ -586,7 +542,7 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
export function markdownEscape(text: string): string {
let escape = "```";
for (let [m] of text.matchAll(/^```+/gm)) {
for (const [m] of text.matchAll(/^```+/gm)) {
while (m.length >= escape.length) {
escape += "`";
}

View File

@@ -45,24 +45,23 @@ export class Pushable<T> implements AsyncIterable<T> {
}
// Helper to convert Node.js streams to Web Streams
export function nodeToWebWritable(
nodeStream: Writable,
): WritableStream<Uint8Array> {
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => {
if (err) reject(err);
else resolve();
if (err) {
reject(err);
} else {
resolve();
}
});
});
},
});
}
export function nodeToWebReadable(
nodeStream: Readable,
): ReadableStream<Uint8Array> {
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Buffer) => {
@@ -77,3 +76,7 @@ export function nodeToWebReadable(
export function unreachable(value: never): never {
throw new Error(`Unexpected case: ${value}`);
}
export function sleep(time: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, time));
}