Setup linter (#12)

Fixes basic issues. For now, we're still allowing `any`, but it'd be
nice to move away from it.
This commit is contained in:
Agus Zubiaga
2025-09-02 19:06:32 -03:00
committed by GitHub
parent 28f36d7474
commit a3038d383f
12 changed files with 1935 additions and 507 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 - name: Check formatting
run: npm run format:check run: npm run format:check
- name: Run linter
run: npm run lint
- name: Build - name: Build
run: npm run build run: npm run build

4
.prettierrc.json Normal file
View File

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

1535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,11 @@
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "npm run build && npm run start", "dev": "npm run build && npm run start",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"check": "npm run lint && npm run format:check",
"test": "vitest", "test": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run", "test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
"test:run": "vitest run", "test:run": "vitest run",
@@ -46,7 +49,11 @@
"@types/minimist": "^1.2.5", "@types/minimist": "^1.2.5",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.42.0",
"@typescript-eslint/parser": "^8.42.0",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.4.0", "typescript": "^5.4.0",

View File

@@ -31,21 +31,11 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import { v7 as uuidv7 } from "uuid"; import { v7 as uuidv7 } from "uuid";
import { import { nodeToWebReadable, nodeToWebWritable, Pushable, sleep, unreachable } from "./utils.js";
nodeToWebReadable,
nodeToWebWritable,
Pushable,
sleep,
unreachable,
} from "./utils.js";
import { SessionNotification } from "@zed-industries/agent-client-protocol"; import { SessionNotification } from "@zed-industries/agent-client-protocol";
import { createMcpServer } from "./mcp-server.js"; import { createMcpServer } from "./mcp-server.js";
import { AddressInfo } from "node:net"; import { AddressInfo } from "node:net";
import { import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
type Session = { type Session = {
query: Query; query: Query;
@@ -64,14 +54,18 @@ type BackgroundTerminal =
pendingOutput: TerminalOutputResponse; pendingOutput: TerminalOutputResponse;
}; };
type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
};
// Implement the ACP Agent interface // Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent { export class ClaudeAcpAgent implements Agent {
sessions: { sessions: {
[key: string]: Session; [key: string]: Session;
}; };
client: AgentSideConnection; client: AgentSideConnection;
toolUseCache: { [key: string]: any }; toolUseCache: ToolUseCache;
fileContentCache: { [key: string]: any }; fileContentCache: { [key: string]: string };
backgroundTerminals: { [key: string]: BackgroundTerminal } = {}; backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities; clientCapabilities?: ClientCapabilities;
@@ -108,8 +102,8 @@ export class ClaudeAcpAgent implements Agent {
}; };
} }
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> { async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
let sessionId = uuidv7(); const sessionId = uuidv7();
let input = new Pushable<SDKUserMessage>(); const input = new Pushable<SDKUserMessage>();
const mcpServers: Record<string, McpServerConfig> = {}; const mcpServers: Record<string, McpServerConfig> = {};
if (Array.isArray(params.mcpServers)) { if (Array.isArray(params.mcpServers)) {
@@ -125,12 +119,8 @@ export class ClaudeAcpAgent implements Agent {
} }
} }
let server = await createMcpServer( const server = await createMcpServer(this, sessionId, this.clientCapabilities);
this, const address = server.address() as AddressInfo;
sessionId,
this.clientCapabilities,
);
let address = server.address() as AddressInfo;
mcpServers["acp"] = { mcpServers["acp"] = {
type: "http", type: "http",
url: "http://127.0.0.1:" + address.port + "/mcp", url: "http://127.0.0.1:" + address.port + "/mcp",
@@ -139,7 +129,7 @@ export class ClaudeAcpAgent implements Agent {
}, },
}; };
let options: Options = { const options: Options = {
cwd: params.cwd, cwd: params.cwd,
mcpServers, mcpServers,
permissionPromptToolName: "mcp__acp__permission", permissionPromptToolName: "mcp__acp__permission",
@@ -200,7 +190,7 @@ export class ClaudeAcpAgent implements Agent {
input.push(promptToClaude(params)); input.push(promptToClaude(params));
while (true) { while (true) {
let { value: message, done } = await query.next(); const { value: message, done } = await query.next();
if (done || !message) { if (done || !message) {
if (this.sessions[params.sessionId].cancelled) { if (this.sessions[params.sessionId].cancelled) {
return { stopReason: "cancelled" }; return { stopReason: "cancelled" };
@@ -238,8 +228,8 @@ export class ClaudeAcpAgent implements Agent {
} }
if ( if (
message.message.model == "<synthetic>" && message.message.model === "<synthetic>" &&
message.message.content.length == 1 && message.message.content.length === 1 &&
message.message.content[0].text.includes("Please run /login") message.message.content[0].text.includes("Please run /login")
) { ) {
throw RequestError.authRequired(); throw RequestError.authRequired();
@@ -269,28 +259,22 @@ export class ClaudeAcpAgent implements Agent {
await this.sessions[params.sessionId].query.interrupt(); await this.sessions[params.sessionId].query.interrupt();
} }
async readTextFile( async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
params: ReadTextFileRequest, const response = await this.client.readTextFile(params);
): Promise<ReadTextFileResponse> {
let response = await this.client.readTextFile(params);
if (!params.limit && !params.line) { if (!params.limit && !params.line) {
this.fileContentCache[params.path] = response.content; this.fileContentCache[params.path] = response.content;
} }
return response; return response;
} }
async writeTextFile( async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
params: WriteTextFileRequest, const response = await this.client.writeTextFile(params);
): Promise<WriteTextFileResponse> {
let response = await this.client.writeTextFile(params);
this.fileContentCache[params.path] = params.content; this.fileContentCache[params.path] = params.content;
return response; return response;
} }
} }
async function availableSlashCommands( async function availableSlashCommands(query: Query): Promise<AvailableCommand[]> {
query: Query,
): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [ const UNSUPPORTED_COMMANDS = [
"add-dir", "add-dir",
"agents", // Modal "agents", // Modal
@@ -342,28 +326,17 @@ async function availableSlashCommands(
]); ]);
return commands return commands
.map( .map((command: { name: string; description: string; argumentHint: string }) => {
(command: { const input = command.argumentHint ? { hint: command.argumentHint } : null;
name: string; return {
description: string; name: command.name,
argumentHint: string; description: command.description || "",
}) => { input,
const input = command.argumentHint };
? { hint: command.argumentHint } })
: null;
return {
name: command.name,
description: command.description || "",
input,
};
},
)
.filter( .filter(
(command: AvailableCommand) => (command: AvailableCommand) =>
!( !(command.name.match(/\(MCP\)/) || UNSUPPORTED_COMMANDS.includes(command.name)),
command.name.match(/\(MCP\)/) ||
UNSUPPORTED_COMMANDS.includes(command.name)
),
); );
} }
@@ -462,13 +435,11 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
export function toAcpNotifications( export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage, message: SDKAssistantMessage | SDKUserMessage,
sessionId: string, sessionId: string,
toolUseCache: { toolUseCache: ToolUseCache,
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
},
fileContentCache: { [key: string]: string }, fileContentCache: { [key: string]: string },
): SessionNotification[] { ): SessionNotification[] {
let chunks = message.message.content as ContentChunk[]; const chunks = message.message.content as ContentChunk[];
let output = []; const output = [];
// Only handle the first chunk for streaming; extend as needed for batching // Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of chunks) { for (const chunk of chunks) {
let update: SessionNotification["update"] | null = null; let update: SessionNotification["update"] | null = null;
@@ -488,8 +459,7 @@ export function toAcpNotifications(
content: { content: {
type: "image", type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "", data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType: mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined, uri: chunk.source.type === "url" ? chunk.source.url : undefined,
}, },
}; };
@@ -505,7 +475,7 @@ export function toAcpNotifications(
break; break;
case "tool_use": case "tool_use":
toolUseCache[chunk.id] = chunk; toolUseCache[chunk.id] = chunk;
if (chunk.name == "TodoWrite") { if (chunk.name === "TodoWrite") {
update = { update = {
sessionUpdate: "plan", sessionUpdate: "plan",
entries: planEntries(chunk.input), entries: planEntries(chunk.input),
@@ -562,7 +532,7 @@ export function runAcp() {
type ContentChunk = type ContentChunk =
| { type: "text"; text: string } | { 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"; type: "tool_result";
content: string; content: string;

View File

@@ -1,16 +1,13 @@
import express from "express"; import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod"; import { z } from "zod";
import { Server } from "node:http"; import { Server } from "node:http";
import { ClaudeAcpAgent } from "./acp-agent.js"; import { ClaudeAcpAgent } from "./acp-agent.js";
import { import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agent-client-protocol";
ClientCapabilities,
TerminalOutputResponse, import { sleep, unreachable } from "./utils.js";
} from "@zed-industries/agent-client-protocol";
import { tool } from "@anthropic-ai/claude-code";
import { sleep } from "./utils.js";
export const SYSTEM_REMINDER = ` export const SYSTEM_REMINDER = `
@@ -40,19 +37,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.`, In sessions with mcp__acp__read always use it instead of Read as it contains the most up-to-date contents.`,
inputSchema: { inputSchema: {
abs_path: z abs_path: z.string().describe("The absolute path to the file to read."),
.string()
.describe("The absolute path to the file to read."),
offset: z offset: z
.number() .number()
.optional() .optional()
.describe( .describe("Which line to start reading from. Omit to start from the beginning."),
"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."),
),
limit: z
.number()
.optional()
.describe("How many lines to read. Omit for the whole file."),
}, },
annotations: { annotations: {
title: "Read file", title: "Read file",
@@ -75,7 +65,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, sessionId,
path: input.abs_path, path: input.abs_path,
limit: input.limit, limit: input.limit,
@@ -114,9 +104,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 In sessions with mcp__acp__write always use it instead of Write as it will
allow the user to conveniently review changes.`, allow the user to conveniently review changes.`,
inputSchema: { inputSchema: {
abs_path: z abs_path: z.string().describe("The absolute path to the file to write"),
.string()
.describe("The absolute path to the file to write"),
content: z.string().describe("The full content to write"), content: z.string().describe("The full content to write"),
}, },
annotations: { annotations: {
@@ -140,7 +128,7 @@ allow the user to conveniently review changes.`,
], ],
}; };
} }
let content = await agent.writeTextFile({ await agent.writeTextFile({
sessionId, sessionId,
path: input.abs_path, path: input.abs_path,
content: input.content, content: input.content,
@@ -181,12 +169,8 @@ File editing instructions:
- Do not escape quotes, newlines, or other characters. - Do not escape quotes, newlines, or other characters.
- Only edit the specified file.`, - Only edit the specified file.`,
inputSchema: { inputSchema: {
abs_path: z abs_path: z.string().describe("The absolute path to the file to read."),
.string() old_string: z.string().describe("The old text to replace (must be unique in the file)"),
.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."), new_string: z.string().describe("The new text."),
}, },
annotations: { annotations: {
@@ -211,21 +195,18 @@ File editing instructions:
}; };
} }
let { content } = await agent.readTextFile({ const { content } = await agent.readTextFile({
sessionId, sessionId,
path: input.abs_path, path: input.abs_path,
}); });
const { newContent, lineNumbers } = replaceAndCalculateLocation( const { newContent, lineNumbers } = replaceAndCalculateLocation(content, [
content, {
[ oldText: input.old_string,
{ newText: input.new_string,
oldText: input.old_string, replaceAll: false,
newText: input.new_string, },
replaceAll: false, ]);
},
],
);
await agent.writeTextFile({ await agent.writeTextFile({
sessionId, sessionId,
@@ -260,9 +241,7 @@ File editing instructions:
title: "Multi Edit", title: "Multi Edit",
description: `Edit a file with multiple sequential edits.`, description: `Edit a file with multiple sequential edits.`,
inputSchema: { inputSchema: {
file_path: z file_path: z.string().describe("The absolute path to the file to modify"),
.string()
.describe("The absolute path to the file to modify"),
edits: z edits: z
.array( .array(
z.object({ z.object({
@@ -271,15 +250,11 @@ File editing instructions:
replace_all: z replace_all: z
.boolean() .boolean()
.optional() .optional()
.describe( .describe("Replace all occurrences of old_string (default false)"),
"Replace all occurrences of old_string (default false)",
),
}), }),
) )
.min(1) .min(1)
.describe( .describe("Array of edit operations to perform sequentially on the file"),
"Array of edit operations to perform sequentially on the file",
),
}, },
annotations: { annotations: {
title: "Multi Edit file", title: "Multi Edit file",
@@ -302,7 +277,7 @@ File editing instructions:
}; };
} }
let { content } = await agent.readTextFile({ const { content } = await agent.readTextFile({
sessionId, sessionId,
path: input.file_path, path: input.file_path,
}); });
@@ -341,9 +316,7 @@ File editing instructions:
title: "Bash", title: "Bash",
description: "Executes a bash command", description: "Executes a bash command",
inputSchema: { inputSchema: {
command: z command: z.string().describe("The bash command to execute as a one-liner"),
.string()
.describe("The bash command to execute as a one-liner"),
timeout_ms: z timeout_ms: z
.number() .number()
.default(2 * 60 * 1000) .default(2 * 60 * 1000)
@@ -375,10 +348,7 @@ File editing instructions:
throw new Error("No tool call ID found"); throw new Error("No tool call ID found");
} }
if ( if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
!agent.clientCapabilities?.terminal ||
!agent.client.createTerminal
) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
@@ -439,10 +409,7 @@ File editing instructions:
status, status,
pendingOutput: { pendingOutput: {
...currentOutput, ...currentOutput,
output: stripCommonPrefix( output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
),
}, },
}; };
@@ -486,9 +453,7 @@ File editing instructions:
inputSchema: { inputSchema: {
id: z id: z
.string() .string()
.describe( .describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
"The id of the background bash command as returned by `mcp__acp__Bash`",
),
}, },
}, },
async (input) => { async (input) => {
@@ -538,9 +503,7 @@ File editing instructions:
inputSchema: { inputSchema: {
id: z id: z
.string() .string()
.describe( .describe("The id of the background bash command as returned by `mcp__acp__Bash`"),
"The id of the background bash command as returned by `mcp__acp__Bash`",
),
}, },
}, },
async (input) => { async (input) => {
@@ -551,17 +514,14 @@ File editing instructions:
} }
switch (bgTerm.status) { switch (bgTerm.status) {
case "started": case "started": {
await bgTerm.handle.kill(); await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput(); const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = { agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed", status: "killed",
pendingOutput: { pendingOutput: {
...currentOutput, ...currentOutput,
output: stripCommonPrefix( output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
),
}, },
}; };
await bgTerm.handle.release(); await bgTerm.handle.release();
@@ -569,6 +529,7 @@ File editing instructions:
return { return {
content: [{ type: "text", text: "Command killed successfully." }], content: [{ type: "text", text: "Command killed successfully." }],
}; };
}
case "aborted": case "aborted":
return { return {
content: [{ type: "text", text: "Command aborted by user." }], content: [{ type: "text", text: "Command aborted by user." }],
@@ -586,69 +547,14 @@ File editing instructions:
content: [{ type: "text", text: "Command killed by timeout." }], content: [{ type: "text", text: "Command killed by timeout." }],
}; };
default: { default: {
const unreachable: never = bgTerm; return unreachable(bgTerm);
return unreachable;
} }
} }
}, },
); );
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": {
if (exitStatus?.exitCode == null) {
toolOutput += `Interrupted. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "exited":
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus?.exitCode != null && exitStatus.exitCode !== 0) {
toolOutput += `Failed with exit code ${exitStatus.exitCode}.`;
}
if (exitStatus?.signal != null) {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
toolOutput += "Output:\n\n";
toolOutput += commandOutput;
if (truncated) {
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
}
return toolOutput;
}
} }
let alwaysAllowedTools: { [key: string]: boolean } = {}; const alwaysAllowedTools: { [key: string]: boolean } = {};
server.registerTool( server.registerTool(
"permission", "permission",
{ {
@@ -688,7 +594,7 @@ File editing instructions:
], ],
}; };
} }
let response = await agent.client.requestPermission({ const response = await agent.client.requestPermission({
options: [ options: [
{ {
kind: "allow_always", kind: "allow_always",
@@ -705,11 +611,10 @@ File editing instructions:
}, },
}); });
if ( if (
response.outcome?.outcome == "selected" && response.outcome?.outcome === "selected" &&
(response.outcome.optionId == "allow" || (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
response.outcome.optionId == "allow_always")
) { ) {
if (response.outcome.optionId == "allow_always") { if (response.outcome.optionId === "allow_always") {
alwaysAllowedTools[input.tool_name] = true; alwaysAllowedTools[input.tool_name] = true;
} }
return { return {
@@ -744,10 +649,9 @@ File editing instructions:
app.post("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => {
try { try {
const transport: StreamableHTTPServerTransport = const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
new StreamableHTTPServerTransport({ sessionIdGenerator: undefined,
sessionIdGenerator: undefined, });
});
res.on("close", () => { res.on("close", () => {
transport.close(); transport.close();
server.close(); server.close();
@@ -760,7 +664,7 @@ File editing instructions:
jsonrpc: "2.0", jsonrpc: "2.0",
error: { error: {
code: -32603, code: -32603,
message: "Internal server error", message: `Internal server error: ${error}`,
}, },
id: null, id: null,
}); });
@@ -779,6 +683,64 @@ 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": {
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
toolOutput += `Interrupted. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "exited":
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus) {
if (typeof exitStatus.exitCode === "number" && exitStatus.exitCode !== 0) {
toolOutput += `Failed with exit code ${exitStatus.exitCode}.`;
}
if (typeof exitStatus.signal === "string") {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
}
toolOutput += "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. * Replace text in a file and calculate the line numbers where the edits occurred.
* *
@@ -816,7 +778,9 @@ export function replaceAndCalculateLocation(
while (true) { while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex); const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) break; if (index === -1) {
break;
}
// Add content before the match // Add content before the match
parts.push(currentContent.substring(lastIndex, index)); parts.push(currentContent.substring(lastIndex, index));

View File

@@ -14,185 +14,168 @@ import {
WriteTextFileResponse, WriteTextFileResponse,
} from "@zed-industries/agent-client-protocol"; } from "@zed-industries/agent-client-protocol";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js"; import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
markdownEscape,
toolInfoFromToolUse,
toolUpdateFromToolResult,
} from "../tools.js";
import { toAcpNotifications } from "../acp-agent.js"; import { toAcpNotifications } from "../acp-agent.js";
import { SDKAssistantMessage } from "@anthropic-ai/claude-code"; import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)( describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
"ACP subprocess integration", let child: ReturnType<typeof spawn>;
() => {
let child: ReturnType<typeof spawn>;
beforeAll(async () => { beforeAll(async () => {
let valid = spawnSync("tsc", { stdio: "inherit" }); const valid = spawnSync("tsc", { stdio: "inherit" });
if (valid.status) { if (valid.status) {
throw new Error("failed to compile"); throw new Error("failed to compile");
} }
// Start the subprocess // Start the subprocess
child = spawn("npm", ["run", "--silent", "dev"], { child = spawn("npm", ["run", "--silent", "dev"], {
stdio: ["pipe", "pipe", "inherit"], stdio: ["pipe", "pipe", "inherit"],
env: process.env, env: process.env,
});
child.on("error", (error) => {
console.error("Error starting subprocess:", error);
});
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
}); });
child.on("error", (error) => {
afterAll(() => { console.error("Error starting subprocess:", error);
child.kill();
}); });
child.on("exit", (exit) => {
console.error("Exited with", exit);
});
});
class TestClient implements Client { afterAll(() => {
agent: Agent; child.kill();
files: Map<string, string> = new Map(); });
receivedText: string = "";
constructor(agent: Agent) { class TestClient implements Client {
this.agent = agent; agent: Agent;
} files: Map<string, string> = new Map();
receivedText: string = "";
takeReceivedText() { constructor(agent: Agent) {
const text = this.receivedText; this.agent = agent;
this.receivedText = ""; }
return text;
}
async requestPermission( takeReceivedText() {
params: RequestPermissionRequest, const text = this.receivedText;
): Promise<RequestPermissionResponse> { this.receivedText = "";
const optionId = params.options.find( return text;
(p) => p.kind === "allow_once", }
)!.optionId;
return { outcome: { outcome: "selected", optionId } }; async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
} const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
async sessionUpdate(params: SessionNotification): Promise<void> { return { outcome: { outcome: "selected", optionId } };
console.error("RECEIVED", JSON.stringify(params, null, 4)); }
if ( async sessionUpdate(params: SessionNotification): Promise<void> {
params.update.sessionUpdate === "agent_message_chunk" && console.error("RECEIVED", JSON.stringify(params, null, 4));
params.update.content.type === "text"
) {
this.receivedText += params.update.content.text;
}
}
async writeTextFile( if (
params: WriteTextFileRequest, params.update.sessionUpdate === "agent_message_chunk" &&
): Promise<WriteTextFileResponse> { params.update.content.type === "text"
this.files.set(params.path, params.content); ) {
return null; this.receivedText += params.update.content.text;
}
async readTextFile(
params: ReadTextFileRequest,
): Promise<ReadTextFileResponse> {
const content = this.files.get(params.path) ?? "";
return {
content,
};
} }
} }
async function setupTestSession(cwd: string): Promise<{ async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
client: TestClient; this.files.set(params.path, params.content);
connection: ClientSideConnection; return null;
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 () => { async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
const { client, connection, newSessionResponse } = const content = this.files.get(params.path) ?? "";
await setupTestSession("./"); return {
content,
};
}
}
await connection.prompt({ async function setupTestSession(cwd: string): Promise<{
prompt: [ client: TestClient;
{ connection: ClientSideConnection;
type: "text", newSessionResponse: NewSessionResponse;
text: "Hello", }> {
}, let client;
], const connection = new ClientSideConnection(
sessionId: newSessionResponse.sessionId, (agent) => {
}); client = new TestClient(agent);
return client;
},
nodeToWebWritable(child.stdin!),
nodeToWebReadable(child.stdout!),
);
expect(client.takeReceivedText()).not.toEqual(""); await connection.initialize({
}, 30000); protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
});
it("should include available commands", async () => { const newSessionResponse = await connection.newSession({
const { client, connection, newSessionResponse } = cwd,
await setupTestSession(__dirname); mcpServers: [],
});
expect(newSessionResponse.availableCommands).toContainEqual({ return { client: client!, connection, newSessionResponse };
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({ it("should connect to the ACP subprocess", async () => {
prompt: [ const { client, connection, newSessionResponse } = await setupTestSession("./");
{
type: "text",
text: "/quick-math",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("30"); await connection.prompt({
prompt: [
{
type: "text",
text: "Hello",
},
],
sessionId: newSessionResponse.sessionId,
});
await connection.prompt({ expect(client.takeReceivedText()).not.toEqual("");
prompt: [ }, 30000);
{
type: "text",
text: "/say-hello GPT-5",
},
],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("Hello GPT-5"); it("should include available commands", async () => {
}, 30000); 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", () => { describe("tool conversions", () => {
it("should handle Bash nicely", () => { it("should handle Bash nicely", () => {
@@ -279,8 +262,7 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({ expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
kind: "search", kind: "search",
title: title: "List the `/Users/test/github/claude-code-acp` directory's contents",
"List the `/Users/test/github/claude-code-acp` directory's contents",
content: [], content: [],
locations: [], locations: [],
}); });
@@ -494,7 +476,7 @@ describe("tool conversions", () => {
}); });
it("should handle KillBash entries", () => { it("should handle KillBash entries", () => {
let tool_use = { const tool_use = {
type: "tool_use", type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT", id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillBash", name: "KillBash",
@@ -511,7 +493,7 @@ describe("tool conversions", () => {
}); });
it("should handle BashOutput entries", () => { it("should handle BashOutput entries", () => {
let tool_use = { const tool_use = {
type: "tool_use", type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN", id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput", name: "BashOutput",
@@ -568,8 +550,7 @@ describe("tool conversions", () => {
activeForm: "Adding concurrent access tests", activeForm: "Adding concurrent access tests",
}, },
{ {
content: content: "Add tests for Each function with various data types",
"Add tests for Each function with various data types",
status: "pending", status: "pending",
activeForm: "Adding Each function tests", activeForm: "Adding Each function tests",
}, },
@@ -708,7 +689,7 @@ describe("tool conversions", () => {
], ],
}, },
}; };
let fileCache: { [key: string]: string } = { const fileCache: { [key: string]: string } = {
"/Users/test/project/config.json": JSON.stringify( "/Users/test/project/config.json": JSON.stringify(
{ {
version: 1, version: 1,
@@ -724,14 +705,14 @@ describe("tool conversions", () => {
content: [ content: [
{ {
newText: `{ newText: `{
\"version\": 2, "version": 2,
\"filler\": \"filler\", "filler": "filler",
\"enabled\": true "enabled": true
}`, }`,
oldText: `{ oldText: `{
\"version\": 1, "version": 1,
\"filler\": \"filler\", "filler": "filler",
\"enabled\": false "enabled": false
}`, }`,
path: "/Users/test/project/config.json", path: "/Users/test/project/config.json",
type: "diff", type: "diff",
@@ -811,9 +792,7 @@ describe("tool conversions", () => {
const update = toolUpdateFromToolResult(toolResult, toolUse); const update = toolUpdateFromToolResult(toolResult, toolUse);
expect(update.locations).toEqual([ expect(update.locations).toEqual([{ path: "/Users/test/project/main.py", line: 42 }]);
{ path: "/Users/test/project/main.py", line: 42 },
]);
}); });
it("should handle empty locations from edit tools", () => { it("should handle empty locations from edit tools", () => {
@@ -878,8 +857,6 @@ describe("escape markdown", () => {
text = "for example:\n```markdown\nHello *world*!\n```\n"; text = "for example:\n```markdown\nHello *world*!\n```\n";
escaped = markdownEscape(text); escaped = markdownEscape(text);
expect(escaped).toEqual( expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
"````\nfor example:\n```markdown\nHello *world*!\n```\n````",
);
}); });
}); });

View File

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

View File

@@ -1,13 +1,10 @@
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import { import {
Client,
PlanEntry, PlanEntry,
ToolCallContent, ToolCallContent,
ToolCallLocation, ToolCallLocation,
ToolKind, ToolKind,
} from "@zed-industries/agent-client-protocol"; } from "@zed-industries/agent-client-protocol";
import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js"; import { replaceAndCalculateLocation, SYSTEM_REMINDER } from "./mcp-server.js";
import { tool } from "@anthropic-ai/claude-code";
interface ToolInfo { interface ToolInfo {
title: string; title: string;
@@ -47,9 +44,7 @@ export function toolInfoFromToolUse(
case "NotebookRead": case "NotebookRead":
return { return {
title: input?.notebook_path title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
? `Read Notebook ${input.notebook_path}`
: "Read Notebook",
kind: "read", kind: "read",
content: [], content: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [], locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
@@ -57,9 +52,7 @@ export function toolInfoFromToolUse(
case "NotebookEdit": case "NotebookEdit":
return { return {
title: input?.notebook_path title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
? `Edit Notebook ${input.notebook_path}`
: "Edit Notebook",
kind: "edit", kind: "edit",
content: content:
input && input.new_source input && input.new_source
@@ -76,9 +69,7 @@ export function toolInfoFromToolUse(
case "Bash": case "Bash":
case "mcp__acp__Bash": case "mcp__acp__Bash":
return { return {
title: input?.command title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
? "`" + input.command.replaceAll("`", "\\`") + "`"
: "Terminal",
kind: "execute", kind: "execute",
content: content:
input && input.description input && input.description
@@ -111,11 +102,7 @@ export function toolInfoFromToolUse(
let limit = ""; let limit = "";
if (input.limit) { if (input.limit) {
limit = limit =
" (" + " (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
((input.offset ?? 0) + 1) +
" - " +
((input.offset ?? 0) + input.limit) +
")";
} else if (input.offset) { } else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")"; limit = " (from line " + (input.offset + 1) + ")";
} }
@@ -157,8 +144,8 @@ export function toolInfoFromToolUse(
if (path && input.old_text) { if (path && input.old_text) {
try { try {
let oldContent = cachedFileContent[path] || ""; const oldContent = cachedFileContent[path] || "";
let newContent = replaceAndCalculateLocation(oldContent, [ const newContent = replaceAndCalculateLocation(oldContent, [
{ {
oldText: input.old_string, oldText: input.old_string,
newText: input.new_string, newText: input.new_string,
@@ -191,8 +178,8 @@ export function toolInfoFromToolUse(
} }
case "mcp__acp__multi-edit": case "mcp__acp__multi-edit":
case "MultiEdit": case "MultiEdit": {
let multiInput = input as { const multiInput = input as {
file_path: string; file_path: string;
edits: { edits: {
old_string: string; old_string: string;
@@ -200,18 +187,14 @@ export function toolInfoFromToolUse(
replace_all?: boolean; replace_all?: boolean;
}[]; }[];
}; };
let oldTextMulti = multiInput.edits let oldTextMulti = multiInput.edits.map((edit: any) => edit.old_string).join("\n");
.map((edit: any) => edit.old_string) let newTextMulti = multiInput.edits.map((edit: any) => edit.new_string).join("\n");
.join("\n");
let newTextMulti = multiInput.edits
.map((edit: any) => edit.new_string)
.join("\n");
try { try {
if (multiInput.edits && multiInput.file_path) { if (multiInput.edits && multiInput.file_path) {
let oldContent = const oldContent =
cachedFileContent[multiInput.file_path] || cachedFileContent[multiInput.file_path] ||
multiInput.edits.map((edit: any) => edit.oldText).join("\n"); multiInput.edits.map((edit: any) => edit.oldText).join("\n");
let newContent = replaceAndCalculateLocation( const newContent = replaceAndCalculateLocation(
oldContent, oldContent,
multiInput.edits.map((edit) => ({ multiInput.edits.map((edit) => ({
oldText: edit.old_string, oldText: edit.old_string,
@@ -240,8 +223,8 @@ export function toolInfoFromToolUse(
], ],
locations: input?.file_path ? [{ path: input.file_path }] : [], locations: input?.file_path ? [{ path: input.file_path }] : [],
}; };
}
case "mcp__acp__write": case "mcp__acp__write": {
let content: ToolCallContent[] = []; let content: ToolCallContent[] = [];
if (input && input.abs_path) { if (input && input.abs_path) {
content = [ content = [
@@ -266,6 +249,7 @@ export function toolInfoFromToolUse(
content, content,
locations: input?.abs_path ? [{ path: input.abs_path }] : [], locations: input?.abs_path ? [{ path: input.abs_path }] : [],
}; };
}
case "Write": case "Write":
return { return {
@@ -330,6 +314,7 @@ export function toolInfoFromToolUse(
label += " -c"; label += " -c";
break; break;
case "Content": case "Content":
default:
break; break;
} }
} }
@@ -439,10 +424,7 @@ export function toolInfoFromToolUse(
} }
} }
export function toolUpdateFromToolResult( export function toolUpdateFromToolResult(toolResult: any, toolUse: any | undefined): ToolUpdate {
toolResult: any,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) { switch (toolUse?.name) {
case "mcp__acp__edit": case "mcp__acp__edit":
case "edit": case "edit":
@@ -453,15 +435,14 @@ export function toolUpdateFromToolResult(
const firstContent = toolResult.content[0]; const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent); const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) { if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map( const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
(line: number) => ({ path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
path: toolUse?.input?.abs_path || toolUse?.input?.file_path, line: line,
line: line, }));
}),
);
return { locations }; return { locations };
} }
} catch (e) { } catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
// If parsing fails, return empty object // If parsing fails, return empty object
return {}; return {};
} }
@@ -477,15 +458,15 @@ export function toolUpdateFromToolResult(
const firstContent = toolResult.content[0]; const firstContent = toolResult.content[0];
const data = JSON.parse(firstContent.text || firstContent); const data = JSON.parse(firstContent.text || firstContent);
if (data.lineNumbers && Array.isArray(data.lineNumbers)) { if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
const locations: ToolCallLocation[] = data.lineNumbers.map( const locations: ToolCallLocation[] = data.lineNumbers.map((line: number) => ({
(line: number) => ({ path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
path: toolUse?.input?.file_path || toolUse?.input?.abs_path, line: line,
line: line, }));
}),
);
return { locations }; return { locations };
} }
} catch (e) { } catch (error) {
console.error(`Error parsing edit tool result: ${error}`);
// If parsing fails, return empty object // If parsing fails, return empty object
return {}; return {};
} }
@@ -504,29 +485,22 @@ export function toolUpdateFromToolResult(
content: toolResult.content.map((content: any) => ({ content: toolResult.content.map((content: any) => ({
type: "content", type: "content",
content: content:
content.type == "text" content.type === "text"
? { ? {
type: "text", type: "text",
text: markdownEscape( text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
content.text.replace(SYSTEM_REMINDER, ""),
),
} }
: content, : content,
})), })),
}; };
} else if ( } else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
typeof toolResult.content === "string" &&
toolResult.content.length > 0
) {
return { return {
content: [ content: [
{ {
type: "content", type: "content",
content: { content: {
type: "text", type: "text",
text: markdownEscape( text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
toolResult.content.replace(SYSTEM_REMINDER, ""),
),
}, },
}, },
], ],
@@ -560,10 +534,7 @@ export function toolUpdateFromToolResult(
content, content,
})), })),
}; };
} else if ( } else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
typeof toolResult.content === "string" &&
toolResult.content.length > 0
) {
return { return {
content: [ content: [
{ {
@@ -597,7 +568,7 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
export function markdownEscape(text: string): string { export function markdownEscape(text: string): string {
let escape = "```"; let escape = "```";
for (let [m] of text.matchAll(/^```+/gm)) { for (const [m] of text.matchAll(/^```+/gm)) {
while (m.length >= escape.length) { while (m.length >= escape.length) {
escape += "`"; escape += "`";
} }

View File

@@ -45,24 +45,23 @@ export class Pushable<T> implements AsyncIterable<T> {
} }
// Helper to convert Node.js streams to Web Streams // Helper to convert Node.js streams to Web Streams
export function nodeToWebWritable( export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
nodeStream: Writable,
): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({ return new WritableStream<Uint8Array>({
write(chunk) { write(chunk) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
nodeStream.write(Buffer.from(chunk), (err) => { nodeStream.write(Buffer.from(chunk), (err) => {
if (err) reject(err); if (err) {
else resolve(); reject(err);
} else {
resolve();
}
}); });
}); });
}, },
}); });
} }
export function nodeToWebReadable( export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
nodeStream: Readable,
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({ return new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
nodeStream.on("data", (chunk: Buffer) => { nodeStream.on("data", (chunk: Buffer) => {