mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
|
*.config.js
|
||||||
|
.github/
|
||||||
33
.eslintrc.json
Normal file
33
.eslintrc.json
Normal 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"]
|
||||||
|
}
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -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
4
.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
1535
package-lock.json
generated
1535
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
102
src/acp-agent.ts
102
src/acp-agent.ts
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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````",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
101
src/tools.ts
101
src/tools.ts
@@ -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 += "`";
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/utils.ts
15
src/utils.ts
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user