mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
5 Commits
v0.1.6
...
enable-cla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02fac67f52 | ||
|
|
1ef3ba327d | ||
|
|
617233d693 | ||
|
|
0dc5a4b366 | ||
|
|
6d1711cfa4 |
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.96",
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.1.2",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
@@ -32,9 +32,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-code": {
|
||||
"version": "1.0.96",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.96.tgz",
|
||||
"integrity": "sha512-xnxhYzuh6PYlMcw56REMQiGMW20WaLLOvG8L8TObq70zhNKs3dro7nhYwHRe1c2ubTr20oIJK0aSkyD2BpO8nA==",
|
||||
"version": "1.0.100",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.100.tgz",
|
||||
"integrity": "sha512-b4FRo3t46kPawzE8pT7nxtDc9NdYPTFaPoGGns/ZNqsqRu2jKMiSjjt5DiFvfaBZA8Tnt2qXbv4nd1vqR9Ru8Q==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"bin": {
|
||||
"claude": "cli.js"
|
||||
@@ -1353,9 +1353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zed-industries/agent-client-protocol": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.1.2.tgz",
|
||||
"integrity": "sha512-A1RCN7Uf09vngbhBVLiZENenKYOYD2OGiILLGXPaSBloDbem8xmLaDmrKQuYl0kjLJ1DcWmVaHUv97n1l5vJ6w==",
|
||||
"version": "0.2.0-alpha.4",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/agent-client-protocol/-/agent-client-protocol-0.2.0-alpha.4.tgz",
|
||||
"integrity": "sha512-l/MdK0MrvEb55/U3wAtAN/MC04YK+N6vNyakNgLsK2JTcmG54n45gXa0OZ08lYUmEvLe0shdu1dDtzlOV/Luxw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"zod": "^3.0.0"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -34,10 +34,10 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "^1.0.96",
|
||||
"@anthropic-ai/claude-code": "^1.0.100",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/express": "^5.0.3",
|
||||
"@zed-industries/agent-client-protocol": "0.1.2",
|
||||
"@zed-industries/agent-client-protocol": "0.2.0-alpha.4",
|
||||
"express": "^5.1.0",
|
||||
"minimist": "^1.2.8",
|
||||
"uuid": "11.1.0"
|
||||
|
||||
154
src/acp-agent.ts
154
src/acp-agent.ts
@@ -2,12 +2,11 @@ import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
AvailableCommand,
|
||||
CancelNotification,
|
||||
Client,
|
||||
ClientCapabilities,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
@@ -15,8 +14,8 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
ToolCallContent,
|
||||
ToolKind,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
@@ -26,10 +25,8 @@ import {
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-code";
|
||||
import * as readline from "node:readline";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
nodeToWebReadable,
|
||||
@@ -37,7 +34,6 @@ import {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
import { ContentBlock } from "@zed-industries/agent-client-protocol";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
@@ -53,29 +49,45 @@ type Session = {
|
||||
cancelled: boolean;
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
status: "started";
|
||||
lastOutput: TerminalOutputResponse | null;
|
||||
}
|
||||
| {
|
||||
status: "aborted" | "exited" | "killed" | "timedOut";
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
[key: string]: Session;
|
||||
};
|
||||
client: Client;
|
||||
client: AgentSideConnection;
|
||||
toolUseCache: { [key: string]: any };
|
||||
fileContentCache: { [key: string]: any };
|
||||
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
|
||||
constructor(client: Client) {
|
||||
constructor(client: AgentSideConnection) {
|
||||
this.sessions = {};
|
||||
this.client = client;
|
||||
this.toolUseCache = {};
|
||||
this.fileContentCache = {};
|
||||
}
|
||||
|
||||
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = request.clientCapabilities;
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
// todo!()
|
||||
agentCapabilities: {
|
||||
promptCapabilities: { image: true, embeddedContext: true },
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
@@ -126,16 +138,30 @@ export class ClaudeAcpAgent implements Agent {
|
||||
let options: Options = {
|
||||
cwd: params.cwd,
|
||||
mcpServers,
|
||||
disallowedTools: [],
|
||||
permissionPromptToolName: "mcp__acp__permission",
|
||||
stderr: (err) => console.error(err),
|
||||
};
|
||||
|
||||
const allowedTools = [];
|
||||
const disallowedTools = [];
|
||||
if (this.clientCapabilities?.fs?.readTextFile) {
|
||||
options.allowedTools = ["mcp__acp__read"];
|
||||
options.disallowedTools!.push("Read");
|
||||
allowedTools.push("mcp__acp__read");
|
||||
disallowedTools.push("Read");
|
||||
}
|
||||
if (this.clientCapabilities?.fs?.writeTextFile) {
|
||||
options.disallowedTools!.push("Write", "Edit", "MultiEdit");
|
||||
allowedTools.push("mcp__acp__write");
|
||||
disallowedTools.push("Write", "Edit", "MultiEdit");
|
||||
}
|
||||
if (this.clientCapabilities?.terminal) {
|
||||
allowedTools.push("mcp__acp__BashOutput", "mcp__acp__KillBash");
|
||||
disallowedTools.push("Bash", "BashOutput", "KillBash");
|
||||
}
|
||||
|
||||
if (allowedTools.length > 0) {
|
||||
options.allowedTools = allowedTools;
|
||||
}
|
||||
if (disallowedTools.length > 0) {
|
||||
options.disallowedTools = disallowedTools;
|
||||
}
|
||||
|
||||
let q = query({
|
||||
@@ -148,12 +174,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const availableCommands = await availableSlashCommands(q);
|
||||
return {
|
||||
sessionId,
|
||||
availableCommands,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(params: AuthenticateRequest): Promise<void> {
|
||||
async authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@@ -256,6 +284,67 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function availableSlashCommands(
|
||||
query: Query,
|
||||
): Promise<AvailableCommand[]> {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"agents", // Modal
|
||||
"bashes", // Modal
|
||||
"bug", // Modal
|
||||
"clear", // Escape Codes
|
||||
"compact", // Not supported via SDK?
|
||||
"config", // Modal
|
||||
"context", // Escape Codes
|
||||
"cost", // Escape Codes
|
||||
"doctor", // Escape Codes
|
||||
"exit",
|
||||
"export", // Modal
|
||||
"help", // Modal
|
||||
"hooks", // Modal
|
||||
"ide", // Modal
|
||||
"install-github-app", // Modal
|
||||
"login",
|
||||
"logout",
|
||||
"mcp",
|
||||
"migrate-installer", // Modal
|
||||
"model", // Not supported via SDK?
|
||||
"output-style", // Modal
|
||||
"output-style:new", // Modal
|
||||
"permissions", // Modal
|
||||
"release-notes", // Escape Codes
|
||||
"resume",
|
||||
"status", // Not supported via SDK?
|
||||
"statusline", // Not needed
|
||||
"terminal-setup", // Not needed
|
||||
"todos", // Escape Codes
|
||||
"vim", // Not needed
|
||||
];
|
||||
|
||||
//todo: Do not use `as any` once `supportedCommands` is exposed via the typescript interface
|
||||
const commands = await (query as any).supportedCommands();
|
||||
return commands
|
||||
.map(
|
||||
(command: {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
}) => {
|
||||
const input = command.argumentHint
|
||||
? { hint: command.argumentHint }
|
||||
: null;
|
||||
return {
|
||||
name: command.name,
|
||||
description: command.description || "",
|
||||
input,
|
||||
};
|
||||
},
|
||||
)
|
||||
.filter(
|
||||
(command: AvailableCommand) =>
|
||||
!UNSUPPORTED_COMMANDS.includes(command.name),
|
||||
);
|
||||
}
|
||||
|
||||
function formatUriAsLink(uri: string): string {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
@@ -351,14 +440,16 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
export function toAcpNotifications(
|
||||
message: SDKAssistantMessage | SDKUserMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: { [key: string]: any },
|
||||
toolUseCache: {
|
||||
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
|
||||
},
|
||||
fileContentCache: { [key: string]: string },
|
||||
): SessionNotification[] {
|
||||
let chunks = message.message.content as ContentChunk[];
|
||||
let output = [];
|
||||
// Only handle the first chunk for streaming; extend as needed for batching
|
||||
for (const chunk of chunks) {
|
||||
let update: SessionNotification["update"];
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
update = {
|
||||
@@ -408,19 +499,32 @@ export function toAcpNotifications(
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_result":
|
||||
update = {
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: chunk.is_error ? "failed" : "completed",
|
||||
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
|
||||
};
|
||||
case "tool_result": {
|
||||
const toolUse = toolUseCache[chunk.tool_use_id];
|
||||
if (!toolUse) {
|
||||
console.error(
|
||||
`[claude-code-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (toolUse.name !== "TodoWrite") {
|
||||
update = {
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: chunk.is_error ? "failed" : "completed",
|
||||
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error("unhandled chunk type: " + chunk.type);
|
||||
}
|
||||
output.push({ sessionId, update });
|
||||
if (update) {
|
||||
output.push({ sessionId, update });
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
|
||||
@@ -5,7 +5,11 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
||||
import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities } from "@zed-industries/agent-client-protocol";
|
||||
import {
|
||||
ClientCapabilities,
|
||||
TerminalOutputResponse,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { tool } from "@anthropic-ai/claude-code";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -329,6 +333,324 @@ File editing instructions:
|
||||
);
|
||||
}
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
"Bash",
|
||||
{
|
||||
title: "Bash",
|
||||
description: "Executes a bash command",
|
||||
inputSchema: {
|
||||
command: z
|
||||
.string()
|
||||
.describe("The bash command to execute as a one-liner"),
|
||||
timeout_ms: z
|
||||
.number()
|
||||
.default(2 * 60 * 1000)
|
||||
.describe("Optional timeout in milliseconds"),
|
||||
run_in_background: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"When set to true, the command is started in the background. The tool returns an `id` that can be used with the `mcp__acp__BashOutput` tool to retrieve the current output, or the `mcp__acp__KillBash` tool to stop it early.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, extra) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const toolCallId = extra._meta?.["claudecode/toolUseId"];
|
||||
|
||||
if (typeof toolCallId !== "string") {
|
||||
throw new Error("No tool call ID found");
|
||||
}
|
||||
|
||||
if (
|
||||
!agent.clientCapabilities?.terminal ||
|
||||
!agent.client.createTerminal
|
||||
) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const handle = await agent.client.createTerminal({
|
||||
command: input.command,
|
||||
sessionId,
|
||||
outputByteLimit: 32_000,
|
||||
});
|
||||
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
content: [{ type: "terminal", terminalId: handle.id }],
|
||||
},
|
||||
});
|
||||
|
||||
const abortPromise = new Promise((resolve) => {
|
||||
if (extra.signal.aborted) {
|
||||
resolve(null);
|
||||
} else {
|
||||
extra.signal.addEventListener("abort", () => {
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then(() => ({ status: "exited" as const })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const })),
|
||||
sleep(input.timeout_ms).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
return { status: "timedOut" as const };
|
||||
}),
|
||||
]);
|
||||
|
||||
if (input.run_in_background) {
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
handle,
|
||||
lastOutput: null,
|
||||
status: "started",
|
||||
};
|
||||
|
||||
statusPromise.then(async ({ status }) => {
|
||||
const bgTerm = agent.backgroundTerminals[handle.id];
|
||||
|
||||
if (bgTerm.status !== "started") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOutput = await handle.currentOutput();
|
||||
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
status,
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(
|
||||
bgTerm.lastOutput?.output ?? "",
|
||||
currentOutput.output,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return handle.release();
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Command started in background with id: ${handle.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
await using terminal = handle;
|
||||
|
||||
const { status } = await statusPromise;
|
||||
|
||||
if (status === "aborted") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool cancelled by user" }],
|
||||
};
|
||||
}
|
||||
|
||||
const output = await terminal.currentOutput();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: toolCommandOutput(status, output) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"BashOutput",
|
||||
{
|
||||
title: "BashOutput",
|
||||
description:
|
||||
"Returns the current output and exit status of a background bash command by its id. Includes only new output since last invocation.",
|
||||
inputSchema: {
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
"The id of the background bash command as returned by `mcp__acp__Bash`",
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.id}`);
|
||||
}
|
||||
|
||||
if (bgTerm.status === "started") {
|
||||
const newOutput = await bgTerm.handle.currentOutput();
|
||||
const strippedOutput = stripCommonPrefix(
|
||||
bgTerm.lastOutput?.output ?? "",
|
||||
newOutput.output,
|
||||
);
|
||||
bgTerm.lastOutput = newOutput;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(bgTerm.status, {
|
||||
...newOutput,
|
||||
output: strippedOutput,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"KillBash",
|
||||
{
|
||||
title: "KillBash",
|
||||
description: "Stops a background command by its id",
|
||||
inputSchema: {
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
"The id of the background bash command as returned by `mcp__acp__Bash`",
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.id}`);
|
||||
}
|
||||
|
||||
switch (bgTerm.status) {
|
||||
case "started":
|
||||
await bgTerm.handle.kill();
|
||||
const currentOutput = await bgTerm.handle.currentOutput();
|
||||
agent.backgroundTerminals[bgTerm.handle.id] = {
|
||||
status: "killed",
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(
|
||||
bgTerm.lastOutput?.output ?? "",
|
||||
currentOutput.output,
|
||||
),
|
||||
},
|
||||
};
|
||||
await bgTerm.handle.release();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed successfully." }],
|
||||
};
|
||||
case "aborted":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command aborted by user." }],
|
||||
};
|
||||
case "exited":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command had already exited." }],
|
||||
};
|
||||
case "killed":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command was already killed." }],
|
||||
};
|
||||
case "timedOut":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed by timeout." }],
|
||||
};
|
||||
default: {
|
||||
const unreachable: never = 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 sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
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 } = {};
|
||||
server.registerTool(
|
||||
"permission",
|
||||
|
||||
5
src/tests/.claude/commands/quick-math.md
Normal file
5
src/tests/.claude/commands/quick-math.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: 10 * 3 = 30
|
||||
---
|
||||
|
||||
What is 10 \* 3. Only respond with the number.
|
||||
6
src/tests/.claude/commands/say-hello.md
Normal file
6
src/tests/.claude/commands/say-hello.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Say hello
|
||||
argument-hint: [name]
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Agent,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
NewSessionResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
@@ -50,37 +51,67 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
|
||||
class TestClient implements Client {
|
||||
agent: Agent;
|
||||
files: Map<string, string> = new Map();
|
||||
receivedText: string = "";
|
||||
|
||||
constructor(agent: Agent) {
|
||||
this.agent = agent;
|
||||
}
|
||||
requestPermission(
|
||||
|
||||
takeReceivedText() {
|
||||
const text = this.receivedText;
|
||||
this.receivedText = "";
|
||||
return text;
|
||||
}
|
||||
|
||||
async requestPermission(
|
||||
params: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
throw new Error("Method not implemented.");
|
||||
const optionId = params.options.find(
|
||||
(p) => p.kind === "allow_once",
|
||||
)!.optionId;
|
||||
|
||||
return { outcome: { outcome: "selected", optionId } };
|
||||
}
|
||||
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
console.error("RECEVIED", JSON.stringify(params, null, 4));
|
||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
||||
|
||||
if (
|
||||
params.update.sessionUpdate === "agent_message_chunk" &&
|
||||
params.update.content.type === "text"
|
||||
) {
|
||||
this.receivedText += params.update.content.text;
|
||||
}
|
||||
}
|
||||
writeTextFile(
|
||||
|
||||
async writeTextFile(
|
||||
params: WriteTextFileRequest,
|
||||
): Promise<WriteTextFileResponse> {
|
||||
throw new Error("Method not implemented.");
|
||||
this.files.set(params.path, params.content);
|
||||
return null;
|
||||
}
|
||||
|
||||
async readTextFile(
|
||||
params: ReadTextFileRequest,
|
||||
): Promise<ReadTextFileResponse> {
|
||||
const content = this.files.get(params.path) ?? "";
|
||||
return {
|
||||
content:
|
||||
"One\nTwo\nThree\nFour\nFive\nSix\nSeven\nEight\nNine\nTen\nEleven\nTwelve\nThirteen\nFourteen\nFifteen\nSixteen\nSeventeen\nEighteen\nNineteen\nTwenty\n",
|
||||
content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
it("should connect to the ACP subprocess", async () => {
|
||||
async function setupTestSession(cwd: string): Promise<{
|
||||
client: TestClient;
|
||||
connection: ClientSideConnection;
|
||||
newSessionResponse: NewSessionResponse;
|
||||
}> {
|
||||
let client;
|
||||
const connection = new ClientSideConnection(
|
||||
(agent) => {
|
||||
return new TestClient(agent);
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
},
|
||||
nodeToWebWritable(child.stdin!),
|
||||
nodeToWebReadable(child.stdout!),
|
||||
@@ -96,18 +127,70 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
},
|
||||
});
|
||||
|
||||
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
|
||||
const newSessionResponse = await connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
return { client: client!, connection, newSessionResponse };
|
||||
}
|
||||
|
||||
it("should connect to the ACP subprocess", async () => {
|
||||
const { client, connection, newSessionResponse } =
|
||||
await setupTestSession("./");
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Use the multi edit tool to remove all multiples of 5 from the README and replace with buzz",
|
||||
text: "Hello",
|
||||
},
|
||||
],
|
||||
sessionId: session.sessionId,
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
console.log("DONE AWAITING");
|
||||
}, 20000);
|
||||
|
||||
expect(client.takeReceivedText()).not.toEqual("");
|
||||
}, 30000);
|
||||
|
||||
it("should include available commands", async () => {
|
||||
const { client, connection, newSessionResponse } =
|
||||
await setupTestSession(__dirname);
|
||||
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
name: "quick-math",
|
||||
description: "10 * 3 = 30 (project)",
|
||||
input: null,
|
||||
});
|
||||
expect(newSessionResponse.availableCommands).toContainEqual({
|
||||
name: "say-hello",
|
||||
description: "Say hello (project)",
|
||||
input: { hint: "[name]" },
|
||||
});
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/quick-math",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("30");
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/say-hello GPT-5",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("Hello GPT-5");
|
||||
}, 30000);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case "mcp__acp__Bash":
|
||||
return {
|
||||
title: input?.command
|
||||
? "`" + input.command.replaceAll("`", "\\`") + "`"
|
||||
@@ -91,6 +92,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "BashOutput":
|
||||
case "mcp__acp__BashOutput":
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
@@ -98,6 +100,7 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
|
||||
case "KillBash":
|
||||
case "mcp__acp__KillBash":
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
@@ -527,6 +530,10 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case "mcp__acp__Bash":
|
||||
// The bash tool already embeds a terminal
|
||||
return {};
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
case "NotebookRead":
|
||||
|
||||
Reference in New Issue
Block a user