Compare commits

..

1 Commits

Author SHA1 Message Date
Cole Miller
abae47de80 read output in a code block 2025-08-28 21:08:26 -04:00
9 changed files with 1359 additions and 638 deletions

View File

@@ -1,42 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: "ci-${{ github.ref }}"
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: latest
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run

View File

@@ -1,23 +0,0 @@
name: Publish Package to npmjs
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- run: npm ci
# Add back provenance once repo is public
- run: npm publish # --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

1052
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,24 +3,19 @@
"publishConfig": {
"access": "public"
},
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.1.6",
"version": "0.1.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"main": "dist/acp-agent.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "npm run build && npm run start",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint src --ext .ts",
"test": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build"
"test:coverage": "vitest run --coverage"
},
"keywords": [
"claude",
@@ -47,7 +42,7 @@
"@types/node": "^24.3.0",
"@types/uuid": "^10.0.0",
"@vitest/ui": "^3.2.4",
"prettier": "^3.6.2",
"eslint": "^8.56.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"vitest": "^3.2.4"

View File

@@ -4,7 +4,6 @@ import {
AuthenticateRequest,
CancelNotification,
Client,
ClientCapabilities,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
@@ -12,17 +11,12 @@ import {
NewSessionResponse,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
ToolCallContent,
ToolKind,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import {
McpServerConfig,
Options,
Query,
query,
SDKAssistantMessage,
@@ -60,17 +54,13 @@ export class ClaudeAcpAgent implements Agent {
};
client: Client;
toolUseCache: { [key: string]: any };
fileContentCache: { [key: string]: any };
clientCapabilities?: ClientCapabilities;
constructor(client: Client) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.fileContentCache = {};
}
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
this.clientCapabilities = request.clientCapabilities;
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
return {
protocolVersion: 1,
// todo!()
@@ -109,11 +99,8 @@ export class ClaudeAcpAgent implements Agent {
}
}
let server = await createMcpServer(
this,
sessionId,
this.clientCapabilities,
);
// todo!() auth
let server = await createMcpServer(this, sessionId);
let address = server.address() as AddressInfo;
mcpServers["acp"] = {
type: "http",
@@ -123,24 +110,17 @@ export class ClaudeAcpAgent implements Agent {
},
};
let options: Options = {
cwd: params.cwd,
mcpServers,
disallowedTools: [],
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
};
if (this.clientCapabilities?.fs?.readTextFile) {
options.allowedTools = ["mcp__acp__read"];
options.disallowedTools!.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
options.disallowedTools!.push("Write", "Edit", "MultiEdit");
}
let q = query({
prompt: input,
options,
options: {
cwd: params.cwd,
mcpServers,
allowedTools: ["mcp__acp__read"],
disallowedTools: ["Read", "Write", "Edit", "MultiEdit"],
strictMcpConfig: true,
permissionPromptToolName: "mcp__acp__permission",
stderr: (err) => console.error(err),
},
});
this.sessions[sessionId] = {
query: q,
@@ -216,7 +196,6 @@ export class ClaudeAcpAgent implements Agent {
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
@@ -236,24 +215,6 @@ export class ClaudeAcpAgent implements Agent {
this.sessions[params.sessionId].cancelled = true;
await this.sessions[params.sessionId].query.interrupt();
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
let 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);
this.fileContentCache[params.path] = params.content;
return response;
}
}
function formatUriAsLink(uri: string): string {
@@ -352,7 +313,6 @@ export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage,
sessionId: string,
toolUseCache: { [key: string]: any },
fileContentCache: { [key: string]: string },
): SessionNotification[] {
let chunks = message.message.content as ContentChunk[];
let output = [];
@@ -403,7 +363,7 @@ export function toAcpNotifications(
sessionUpdate: "tool_call",
rawInput: chunk.input,
status: "pending",
...toolInfoFromToolUse(chunk, fileContentCache),
...toolInfoFromToolUse(chunk),
};
}
break;

View File

@@ -1,5 +1,3 @@
#!/usr/bin/env node
import { runAcp as runAcp } from "./acp-agent.js";
runAcp();

View File

@@ -5,18 +5,10 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { z } from "zod";
import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js";
import { ClientCapabilities } from "@zed-industries/agent-client-protocol";
export const SYSTEM_REMINDER = `
<system-reminder>
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`;
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): Promise<Server> {
// Create MCP server
const server = new McpServer({
@@ -24,144 +16,143 @@ export function createMcpServer(
version: "1.0.0",
});
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
"read",
{
title: "Read",
description: `Reads the content of the given file in the project.
server.registerTool(
"read",
{
title: "Read",
description: `Reads the content of the given file in the project.
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."),
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."),
},
annotations: {
title: "Read file",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
inputSchema: {
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."),
},
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,
limit: input.limit,
line: input.offset,
});
annotations: {
title: "Read file",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: content.content + SYSTEM_REMINDER,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
text: "The user has left the building",
},
],
};
}
},
);
}
let content = await agent.client.readTextFile({
sessionId,
path: input.abs_path,
limit: input.limit,
line: input.offset,
});
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
"write",
{
title: "Write",
description: `Writes content to the specified file in the project.
return {
content: [
{
type: "text",
text:
content.content +
`
<system-reminder>
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Reading file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"write",
{
title: "Write",
description: `Writes content to the specified file in the project.
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"),
content: z.string().describe("The full content to write"),
},
annotations: {
title: "Write file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
inputSchema: {
abs_path: z.string().describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"),
},
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.writeTextFile({
sessionId,
path: input.abs_path,
content: input.content,
});
return {
content: [],
};
} catch (error: any) {
annotations: {
title: "Write file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
text: "The user has left the building",
},
],
};
}
},
);
let content = await agent.client.writeTextFile({
sessionId,
path: input.abs_path,
content: input.content,
});
server.registerTool(
"edit",
{
title: "Edit",
description: `Edit a file.
return {
content: [],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Writing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"edit",
{
title: "Edit",
description: `Edit a file.
In sessions with mcp__acp__edit always use it instead of Edit as it will
allow the user to conveniently review changes.
@@ -175,116 +166,23 @@ File editing instructions:
- For non-unique lines, include enough context to identify them.
- Do not escape quotes, newlines, or other characters.
- Only edit the specified file.`,
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."),
},
annotations: {
title: "Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
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."),
},
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,
});
return {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + error.message,
},
],
};
}
annotations: {
title: "Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
);
server.registerTool(
"multi-edit",
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z
.boolean()
.optional()
.describe(
"Replace all occurrences of old_string (default false)",
),
}),
)
.min(1)
.describe(
"Array of edit operations to perform sequentially on the file",
),
},
annotations: {
title: "Multi Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
},
async (input) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
@@ -297,23 +195,25 @@ File editing instructions:
};
}
let { content } = await agent.readTextFile({
let { content } = await agent.client.readTextFile({
sessionId,
path: input.file_path,
path: input.abs_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
[
{
oldText: input.old_text,
newText: input.new_text,
replaceAll: false,
},
],
);
await agent.writeTextFile({
await agent.client.writeTextFile({
sessionId,
path: input.file_path,
path: input.abs_path,
content: newContent,
});
@@ -325,9 +225,97 @@ File editing instructions:
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text",
text: "Editing file failed: " + error.message,
},
],
};
}
},
);
server.registerTool(
"multi-edit",
{
title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to modify"),
edits: z
.array(
z.object({
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z
.boolean()
.optional()
.describe(
"Replace all occurrences of old_string (default false)",
),
}),
)
.min(1)
.describe(
"Array of edit operations to perform sequentially on the file",
),
},
);
}
annotations: {
title: "Multi Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input) => {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
let { content } = await agent.client.readTextFile({
sessionId,
path: input.file_path,
});
const { newContent, lineNumbers } = replaceAndCalculateLocation(
content,
input.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all ?? false,
})),
);
await agent.client.writeTextFile({
sessionId,
path: input.file_path,
content: newContent,
});
return {
content: [
{
type: "text",
text: JSON.stringify({ lineNumbers }),
},
],
};
},
);
let alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool(

View File

@@ -13,11 +13,7 @@ import {
WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import {
markdownEscape,
toolInfoFromToolUse,
toolUpdateFromToolResult,
} from "../tools.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
@@ -60,7 +56,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
throw new Error("Method not implemented.");
}
async sessionUpdate(params: SessionNotification): Promise<void> {
console.error("RECEVIED", JSON.stringify(params, null, 4));
console.error("RECEVIED", params);
}
writeTextFile(
params: WriteTextFileRequest,
@@ -72,7 +68,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
): 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",
"This project attempts to take over the world by solving the Goldbach conjecture.",
};
}
}
@@ -86,22 +82,12 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
nodeToWebReadable(child.stdout!),
);
await connection.initialize({
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
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",
text: "What does this project do? Read the README.md file.",
},
],
sessionId: session.sessionId,
@@ -123,7 +109,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
content: [
@@ -148,7 +134,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "Find */**.ts",
content: [],
@@ -169,7 +155,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "think",
title: "Handle user's work request",
content: [
@@ -190,16 +176,15 @@ describe("tool conversions", () => {
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/test/github/claude-code-acp",
path: "/Users/benbrandt/github/claude-code-acp",
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title:
"List the `/Users/test/github/claude-code-acp` directory's contents",
title: "/Users/benbrandt/github/claude-code-acp",
content: [],
locations: [],
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
});
});
@@ -213,7 +198,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: 'grep ".*"',
content: [],
@@ -231,7 +216,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/example.txt",
content: [
@@ -257,7 +242,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Write /Users/test/project/config.json",
content: [
@@ -282,10 +267,15 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
content: [],
content: [
{
type: "content",
content: { type: "text", text: "/Users/test/project/readme.md" },
},
],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
});
});
@@ -300,7 +290,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
@@ -319,7 +309,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
@@ -339,7 +329,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
content: [],
@@ -358,7 +348,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
content: [],
@@ -378,7 +368,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "fetch",
title: "Fetch https://agentclientprotocol.com",
content: [
@@ -403,7 +393,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "fetch",
title: '"agentclientprotocol.com"',
content: [],
@@ -420,7 +410,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
@@ -437,7 +427,7 @@ describe("tool conversions", () => {
},
};
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
@@ -522,7 +512,7 @@ describe("tool conversions", () => {
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
expect(toAcpNotifications(received, "test", {})).toStrictEqual([
{
sessionId: "test",
update: {
@@ -604,66 +594,6 @@ describe("tool conversions", () => {
]);
});
it("should show full diff for multi edit tool", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01DEF456",
name: "mcp__acp__multi-edit",
input: {
file_path: "/Users/test/project/config.json",
edits: [
{
old_string: 'version": 1',
new_string: 'version": 2',
replace_all: false,
},
{
old_string: 'enabled": false',
new_string: 'enabled": true',
replace_all: true,
},
],
},
};
let fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify(
{
version: 1,
filler: "filler",
enabled: false,
},
null,
4,
),
};
expect(toolInfoFromToolUse(toolUse, fileCache)).toEqual({
content: [
{
newText: `{
\"version\": 2,
\"filler\": \"filler\",
\"enabled\": true
}`,
oldText: `{
\"version\": 1,
\"filler\": \"filler\",
\"enabled\": false
}`,
path: "/Users/test/project/config.json",
type: "diff",
},
],
kind: "edit",
locations: [
{
path: "/Users/test/project/config.json",
},
],
title: "Edit /Users/test/project/config.json",
});
});
it("should extract locations from multi-edit tool results", () => {
const toolUse = {
type: "tool_use",
@@ -786,17 +716,3 @@ describe("tool conversions", () => {
expect(update).toEqual({});
});
});
describe("escape markdown", () => {
it("should escape markdown characters", () => {
let text = "Hello *world*!";
let escaped = markdownEscape(text);
expect(escaped).toEqual("```\nHello *world*!\n```");
text = "for example:\n```markdown\nHello *world*!\n```\n";
escaped = markdownEscape(text);
expect(escaped).toEqual(
"````\nfor example:\n```markdown\nHello *world*!\n```\n````",
);
});
});

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;
@@ -22,10 +19,7 @@ interface ToolUpdate {
locations?: ToolCallLocation[];
}
export function toolInfoFromToolUse(
toolUse: any,
cachedFileContent: { [key: string]: string },
): ToolInfo {
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
@@ -51,7 +45,18 @@ export function toolInfoFromToolUse(
? `Read Notebook ${input.notebook_path}`
: "Read Notebook",
kind: "read",
content: [],
content:
input && input.notebook_path
? [
{
type: "content",
content: {
type: "text",
text: input.notebook_path.toString(),
},
},
]
: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
@@ -80,7 +85,7 @@ export function toolInfoFromToolUse(
: "Terminal",
kind: "execute",
content:
input && input.description
input && input.command
? [
{
type: "content",
@@ -133,39 +138,28 @@ export function toolInfoFromToolUse(
return {
title: "Read File",
kind: "read",
content: [],
content:
input && input.abs_path
? [
{
type: "content",
content: { type: "text", text: input.abs_path.toString() },
},
]
: [],
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
};
case "LS":
return {
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
title: input?.path ? input.path : "Current Directory",
kind: "search",
content: [],
locations: [],
locations: input?.path ? [{ path: input.path }] : [],
};
case "mcp__acp__edit":
case "Edit":
let oldText = input.old_text || null;
let newText = input.new_text;
if (input?.abs_path && input.old_text) {
try {
let oldContent = cachedFileContent[input.abs_path] || "";
let newContent = replaceAndCalculateLocation(oldContent, [
{
oldText: input.old_text,
newText: input.new_text,
replaceAll: false,
},
]);
oldText = oldContent;
newText = newContent.newContent;
} catch (e) {
console.error(e);
}
}
return {
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
kind: "edit",
@@ -175,8 +169,8 @@ export function toolInfoFromToolUse(
{
type: "diff",
path: input.abs_path,
oldText,
newText,
oldText: input.old_text || null,
newText: input.new_text,
},
]
: [],
@@ -185,52 +179,20 @@ export function toolInfoFromToolUse(
case "mcp__acp__multi-edit":
case "MultiEdit":
let multiInput = input as {
file_path: string;
edits: {
old_string: string;
new_string: string;
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");
try {
if (multiInput.edits && multiInput.file_path) {
let oldContent =
cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n");
let newContent = replaceAndCalculateLocation(
oldContent,
multiInput.edits.map((edit) => ({
oldText: edit.old_string,
newText: edit.new_string,
replaceAll: edit.replace_all,
})),
);
oldTextMulti = oldContent;
newTextMulti = newContent.newContent;
}
} catch (e) {
console.error(e);
}
// Display it as a normal edit, because end users don't care about
// the distinction between edits and multi-edits.
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
kind: "edit",
content: [
{
type: "diff" as const,
path: input.file_path,
oldText: oldTextMulti,
newText: newTextMulti,
},
],
content:
input && input.edits && input.edits.length > 0
? input.edits.map((edit: any) => ({
type: "diff" as const,
path: input.file_path,
oldText: edit.old_string || null,
newText: edit.new_string,
}))
: [],
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
@@ -485,51 +447,11 @@ export function toolUpdateFromToolResult(
}
return {};
}
case "mcp__acp__write":
case "Write":
return {};
case "Read":
case "mcp__acp__read":
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content:
content.type == "text"
? {
type: "text",
text: markdownEscape(
content.text.replace(SYSTEM_REMINDER, ""),
),
}
: content,
})),
};
} else if (
typeof toolResult.content === "string" &&
toolResult.content.length > 0
) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: markdownEscape(
toolResult.content.replace(SYSTEM_REMINDER, ""),
),
},
},
],
};
}
return {};
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "mcp__acp__write":
case "Write":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
@@ -541,26 +463,33 @@ export function toolUpdateFromToolResult(
case "WebFetch":
case "WebSearch":
case "Other":
case "mcp__acp__read":
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content: {
type: "text",
text: `\`\`\`${toolUse.input?.abs_path ?? ""}\n${content.text ?? ""}\n\`\`\``,
},
})),
};
}
return {};
default: {
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
if (Array.isArray(toolResult.content)) {
return {
content: toolResult.content.map((content: any) => ({
type: "content",
content,
})),
};
} else if (
typeof toolResult.content === "string" &&
toolResult.content.length > 0
) {
} else if (typeof toolResult.content === "string") {
return {
content: [
{
type: "content",
content: {
type: "text",
text: toolResult.content,
},
content: { type: "text", text: toolResult.content },
},
],
};
@@ -583,13 +512,3 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
priority: "medium",
}));
}
export function markdownEscape(text: string): string {
let escape = "```";
for (let [m] of text.matchAll(/^```+/gm)) {
while (m.length >= escape.length) {
escape += "`";
}
}
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}