mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 20:42:10 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa181be27 | ||
|
|
600a2db82f | ||
|
|
f2a4b3d19b | ||
|
|
9135a0f1be | ||
|
|
92c5cd84e8 | ||
|
|
8311a7a222 | ||
|
|
ba513b190b | ||
|
|
a011363320 | ||
|
|
7737878dd2 | ||
|
|
2e53f1d3e4 | ||
|
|
aa7364d49b |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,8 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## v0.4.1
|
||||
## 0.5.0
|
||||
|
||||
### What's New
|
||||
- Migrate to @anthropic-ai/claude-agent-sdk@v0.1.0
|
||||
|
||||
## v0.4.7
|
||||
|
||||
- More efficient file reads from the client.
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.128
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.124
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.3
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- Update to @anthropic-ai/claude-code@v1.0.123
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.2
|
||||
|
||||
## v0.4.3
|
||||
|
||||
- Move ACP tools over MCP from an "http" MCP server to an "sdk" one so more tool calls can stay in-memory.
|
||||
- Update to @anthropic-ai/claude-code@v1.0.119
|
||||
- Update to @zed-industries/agent-client-protocol@v0.4.0
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- Fix missing package.json metadata
|
||||
|
||||
## v0.4.1
|
||||
|
||||
- Add support for /compact command [ecfd36a](https://github.com/zed-industries/claude-code-acp/commit/ecfd36afa6c4e31f12e1daf9b8a2bdc12dda1794)
|
||||
- Add default limits to read tool [7bd1638](https://github.com/zed-industries/claude-code-acp/commit/7bd163818bb959b11fd2c933eff73ad83c57abb8)
|
||||
|
||||
@@ -32,8 +32,11 @@ Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) suppo
|
||||
|
||||
### Other clients
|
||||
|
||||
- Neovim via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
|
||||
- [marimo notebook](https://github.com/marimo-team/marimo)
|
||||
- Neovim
|
||||
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
|
||||
|
||||
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
|
||||
|
||||
651
package-lock.json
generated
651
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -6,7 +6,7 @@
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
},
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -40,26 +40,35 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-code-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "1.0.113",
|
||||
"@modelcontextprotocol/sdk": "1.18.0",
|
||||
"@zed-industries/agent-client-protocol": "0.3.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.0",
|
||||
"@modelcontextprotocol/sdk": "1.18.2",
|
||||
"@zed-industries/agent-client-protocol": "0.4.3",
|
||||
"diff": "8.0.2",
|
||||
"express": "5.1.0",
|
||||
"minimist": "1.2.8",
|
||||
"uuid": "13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "24.4.0",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.43.0",
|
||||
"@typescript-eslint/parser": "8.43.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.45.0",
|
||||
"@typescript-eslint/parser": "8.45.0",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"eslint": "9.35.0",
|
||||
"eslint": "9.36.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.4.0",
|
||||
"prettier": "3.6.2",
|
||||
|
||||
130
src/acp-agent.ts
130
src/acp-agent.ts
@@ -7,6 +7,7 @@ import {
|
||||
ClientCapabilities,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ndJsonStream,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
@@ -29,16 +30,27 @@ import {
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-code";
|
||||
SDKUserMessageReplay,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import { SessionNotification } from "@zed-industries/agent-client-protocol";
|
||||
import { createMcpServer, toolNames } from "./mcp-server.js";
|
||||
import {
|
||||
createMcpServer,
|
||||
createPermissionMcpServer,
|
||||
PERMISSION_TOOL_NAME,
|
||||
toolNames,
|
||||
} from "./mcp-server.js";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
import {
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
ClaudePlanEntry,
|
||||
} from "./tools.js";
|
||||
|
||||
type Session = {
|
||||
query: Query;
|
||||
@@ -139,9 +151,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
const server = await createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
const address = server.address() as AddressInfo;
|
||||
const server = createMcpServer(this, sessionId, this.clientCapabilities);
|
||||
mcpServers["acp"] = {
|
||||
type: "sdk",
|
||||
name: "acp",
|
||||
instance: server,
|
||||
};
|
||||
|
||||
// Ideally replace with `canUseTool`
|
||||
const permissionServer = await createPermissionMcpServer(this, sessionId);
|
||||
const address = permissionServer.address() as AddressInfo;
|
||||
mcpServers["acpPermission"] = {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:" + address.port + "/mcp",
|
||||
headers: {
|
||||
@@ -152,7 +172,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const options: Options = {
|
||||
cwd: params.cwd,
|
||||
mcpServers,
|
||||
permissionPromptToolName: toolNames.permission,
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
settingSources: ["user", "project", "local"],
|
||||
permissionPromptToolName: PERMISSION_TOOL_NAME,
|
||||
stderr: (err) => console.error(err),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
@@ -256,6 +278,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
switch (message.subtype) {
|
||||
case "init":
|
||||
break;
|
||||
case "compact_boundary":
|
||||
break;
|
||||
default:
|
||||
unreachable(message as never);
|
||||
}
|
||||
break;
|
||||
case "result": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
@@ -278,12 +308,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return { stopReason: "refusal" };
|
||||
}
|
||||
}
|
||||
case "user":
|
||||
case "assistant": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
case "user": {
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
||||
if (
|
||||
@@ -301,14 +326,24 @@ export class ClaudeAcpAgent implements Agent {
|
||||
console.error(message.message.content);
|
||||
break;
|
||||
}
|
||||
// Skip user messages for now, since they seem to just be messages we don't want in the feed
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (this.sessions[params.sessionId].cancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
message.message.model === "<synthetic>" &&
|
||||
Array.isArray(message.message.content) &&
|
||||
message.message.content.length === 1 &&
|
||||
message.message.content[0].type === "text" &&
|
||||
message.message.content[0].text.includes("Please run /login")
|
||||
) {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
message,
|
||||
params.sessionId,
|
||||
@@ -414,7 +449,6 @@ async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand
|
||||
const input = command.argumentHint ? { hint: command.argumentHint } : null;
|
||||
return {
|
||||
name: command.name,
|
||||
// @ts-expect-error type in the ts interface
|
||||
description: command.description || "",
|
||||
input,
|
||||
};
|
||||
@@ -518,20 +552,38 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
*/
|
||||
export function toAcpNotifications(
|
||||
message: SDKAssistantMessage | SDKUserMessage,
|
||||
message: SDKAssistantMessage | SDKUserMessage | SDKUserMessageReplay,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
fileContentCache: { [key: string]: string },
|
||||
): SessionNotification[] {
|
||||
const chunks = message.message.content as ContentChunk[];
|
||||
const content = message.message.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate:
|
||||
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const output = [];
|
||||
// Only handle the first chunk for streaming; extend as needed for batching
|
||||
for (const chunk of chunks) {
|
||||
for (const chunk of content) {
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
update = {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
sessionUpdate:
|
||||
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
@@ -540,7 +592,8 @@ export function toAcpNotifications(
|
||||
break;
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
sessionUpdate:
|
||||
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
||||
@@ -563,13 +616,19 @@ export function toAcpNotifications(
|
||||
if (chunk.name === "TodoWrite") {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: planEntries(chunk.input),
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
};
|
||||
} else {
|
||||
let rawInput;
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
||||
} catch {
|
||||
// ignore if we can't turn it to JSON
|
||||
}
|
||||
update = {
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput: chunk.input,
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(chunk, fileContentCache),
|
||||
};
|
||||
@@ -608,30 +667,9 @@ export function toAcpNotifications(
|
||||
}
|
||||
|
||||
export function runAcp() {
|
||||
new AgentSideConnection(
|
||||
(client) => new ClaudeAcpAgent(client),
|
||||
nodeToWebWritable(process.stdout),
|
||||
nodeToWebReadable(process.stdin),
|
||||
);
|
||||
const input = nodeToWebWritable(process.stdout);
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
const stream = ndJsonStream(input, output);
|
||||
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
||||
}
|
||||
|
||||
type ContentChunk =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: any }
|
||||
| {
|
||||
type: "tool_result";
|
||||
content: string;
|
||||
tool_use_id: string;
|
||||
is_error: boolean;
|
||||
} // content type depends on your Content definition
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "redacted_thinking" }
|
||||
| { type: "image"; source: ImageSource }
|
||||
| { type: "document" }
|
||||
| { type: "web_search_tool_result" }
|
||||
| { type: "untagged_text"; text: string };
|
||||
|
||||
// Example ImageSource type (adjust as needed)
|
||||
type ImageSource =
|
||||
| { type: "base64"; data: string; media_type: string }
|
||||
| { type: "url"; url: string };
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ClientCapabilities, TerminalOutputResponse } from "@zed-industries/agen
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-code";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
@@ -27,7 +27,6 @@ const unqualifiedToolNames = {
|
||||
bash: "Bash",
|
||||
killBash: "KillBash",
|
||||
bashOutput: "BashOutput",
|
||||
permission: "permission",
|
||||
};
|
||||
|
||||
const SERVER_PREFIX = "mcp__acp__";
|
||||
@@ -39,7 +38,6 @@ export const toolNames = {
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killBash: SERVER_PREFIX + unqualifiedToolNames.killBash,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
permission: SERVER_PREFIX + unqualifiedToolNames.permission,
|
||||
};
|
||||
|
||||
const editToolNames = [toolNames.edit, toolNames.multiEdit, toolNames.write];
|
||||
@@ -48,12 +46,9 @@ export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
clientCapabilities: ClientCapabilities | undefined,
|
||||
): Promise<Server> {
|
||||
): McpServer {
|
||||
// Create MCP server
|
||||
const server = new McpServer({
|
||||
name: "acp-mcp-server",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
@@ -107,15 +102,12 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
|
||||
const content = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.abs_path,
|
||||
line: input.offset + 1,
|
||||
limit: input.linesToRead,
|
||||
});
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
const result = extractLinesWithByteLimit(
|
||||
content.content,
|
||||
input.offset ?? 0,
|
||||
input.linesToRead,
|
||||
defaults.maxFileSize,
|
||||
);
|
||||
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
@@ -125,11 +117,11 @@ In sessions with ${toolNames.read} always use it instead of Read as it contains
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
|
||||
} else {
|
||||
readInfo += `Read lines ${(input.offset ?? 0) + 1}-${result.actualEndLine}. `;
|
||||
readInfo += `Read lines ${(input.offset ?? 0) + 1}-${result.linesRead}. `;
|
||||
}
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Continue with offset=${result.actualEndLine}.`;
|
||||
readInfo += `Continue with offset=${result.linesRead}.`;
|
||||
}
|
||||
|
||||
readInfo += "</file-read-info>";
|
||||
@@ -625,9 +617,30 @@ File editing instructions:
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const UNQUALIFIED_PERMISSION_TOOL_NAME = "permission";
|
||||
const PERMISSION_SERVER_PREFIX = "mcp__acpPermission__";
|
||||
export const PERMISSION_TOOL_NAME = PERMISSION_SERVER_PREFIX + UNQUALIFIED_PERMISSION_TOOL_NAME;
|
||||
|
||||
/**
|
||||
* Separate permission tool. Needs to be an HTTP server for now, because Claude Code doesn't
|
||||
* support permission tools via SDK servers.
|
||||
* Ideally it moves to the `canUseTool` callback in the future.
|
||||
*/
|
||||
export function createPermissionMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
): Promise<Server> {
|
||||
const server = new McpServer(
|
||||
{ name: "acpPermission", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
const alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.permission,
|
||||
UNQUALIFIED_PERMISSION_TOOL_NAME,
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
NewSessionResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||
import { toAcpNotifications } from "../acp-agent.js";
|
||||
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
|
||||
import { SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
@@ -108,14 +109,13 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
newSessionResponse: NewSessionResponse;
|
||||
}> {
|
||||
let client;
|
||||
const connection = new ClientSideConnection(
|
||||
(agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
},
|
||||
nodeToWebWritable(child.stdin!),
|
||||
nodeToWebReadable(child.stdout!),
|
||||
);
|
||||
const input = nodeToWebWritable(child.stdin!);
|
||||
const output = nodeToWebReadable(child.stdout!);
|
||||
const stream = ndJsonStream(input, output);
|
||||
const connection = new ClientSideConnection((agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
}, stream);
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -576,6 +576,7 @@ describe("tool conversions", () => {
|
||||
id: "msg_017eNosJgww7F5qD4a8BcAcx",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
container: null,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
content: [
|
||||
{
|
||||
@@ -640,7 +641,9 @@ describe("tool conversions", () => {
|
||||
},
|
||||
output_tokens: 1,
|
||||
service_tier: "standard",
|
||||
server_tool_use: null,
|
||||
},
|
||||
context_management: null,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
|
||||
@@ -778,7 +781,7 @@ describe("tool conversions", () => {
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: "text" as const,
|
||||
text: "not valid json",
|
||||
},
|
||||
],
|
||||
@@ -808,7 +811,7 @@ describe("tool conversions", () => {
|
||||
const toolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
type: "text" as const,
|
||||
text: "Failed to find `old_string`",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,66 +5,44 @@ describe("extractLinesWithByteLimit", () => {
|
||||
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
||||
|
||||
it("should extract all lines when under byte limit", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 0, 5, 1000);
|
||||
const result = extractLinesWithByteLimit(simpleContent, 1000);
|
||||
|
||||
expect(result.content).toBe(simpleContent);
|
||||
expect(result.actualEndLine).toBe(4);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(5);
|
||||
});
|
||||
|
||||
it("should extract partial lines with offset", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 2, 2, 1000);
|
||||
|
||||
expect(result.content).toBe("Line 3\nLine 4");
|
||||
expect(result.actualEndLine).toBe(3);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should limit output when exceeding byte limit", () => {
|
||||
// Create content that will exceed byte limit
|
||||
const longLine = "x".repeat(100);
|
||||
const manyLines = Array(10).fill(longLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(manyLines, 0, 10, 250);
|
||||
const result = extractLinesWithByteLimit(manyLines, 250);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.actualEndLine).toBe(1); // Should only get 2 lines
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle offset beyond file length", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 10, 5, 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.actualEndLine).toBe(10);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = extractLinesWithByteLimit("", 0, 10, 1000);
|
||||
const result = extractLinesWithByteLimit("", 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.actualEndLine).toBe(0); // Empty string splits to [""] and we process that one empty line
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1); // We read the one empty line
|
||||
});
|
||||
|
||||
it("should handle single line file", () => {
|
||||
const singleLine = "This is a single line without newline";
|
||||
const result = extractLinesWithByteLimit(singleLine, 0, 10, 1000);
|
||||
const result = extractLinesWithByteLimit(singleLine, 1000);
|
||||
|
||||
expect(result.content).toBe(singleLine);
|
||||
expect(result.actualEndLine).toBe(0);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1);
|
||||
});
|
||||
|
||||
it("should correctly count bytes with multi-byte characters", () => {
|
||||
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
|
||||
const result = extractLinesWithByteLimit(unicodeContent, 0, 10, 1000);
|
||||
const result = extractLinesWithByteLimit(unicodeContent, 1000);
|
||||
|
||||
expect(result.content).toBe(unicodeContent);
|
||||
expect(result.linesRead).toBe(4);
|
||||
@@ -77,63 +55,25 @@ describe("extractLinesWithByteLimit", () => {
|
||||
const line3 = "c".repeat(40);
|
||||
const content = `${line1}\n${line2}\n${line3}`;
|
||||
|
||||
const result = extractLinesWithByteLimit(content, 0, 3, 85);
|
||||
const result = extractLinesWithByteLimit(content, 85);
|
||||
|
||||
expect(result.content).toBe(`${line1}\n${line2}`);
|
||||
expect(result.actualEndLine).toBe(1);
|
||||
expect(result.content).toBe(`${line1}\n${line2}\n`);
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should read exactly to limit when possible", () => {
|
||||
const exactContent = "12345\n67890\n12345"; // 17 bytes total
|
||||
const result = extractLinesWithByteLimit(exactContent, 0, 3, 17);
|
||||
const result = extractLinesWithByteLimit(exactContent, 17);
|
||||
|
||||
expect(result.content).toBe(exactContent);
|
||||
expect(result.actualEndLine).toBe(2);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle reading from middle to end", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 3, 100, 1000);
|
||||
|
||||
expect(result.content).toBe("Line 4\nLine 5");
|
||||
expect(result.actualEndLine).toBe(4);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle large content with typical parameters", () => {
|
||||
const longContent = Array(2000).fill("Line content here").join("\n");
|
||||
const result = extractLinesWithByteLimit(longContent, 0, 1000, 50000);
|
||||
|
||||
expect(result.actualEndLine).toBeLessThanOrEqual(1000); // Should be limited by line count
|
||||
});
|
||||
|
||||
it("should provide correct data for partial read at start", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 0, 2, 1000);
|
||||
|
||||
expect(result.actualEndLine).toBe(1);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should provide correct data for partial read with offset", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 1, 2, 1000);
|
||||
|
||||
expect(result.actualEndLine).toBe(2);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should not add newline after last line", () => {
|
||||
const result = extractLinesWithByteLimit("Line 1\nLine 2\nLine 3", 2, 1, 1000);
|
||||
|
||||
expect(result.content).toBe("Line 3"); // No trailing newline
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
|
||||
const result = extractLinesWithByteLimit(windowsContent, 0, 3, 1000);
|
||||
const result = extractLinesWithByteLimit(windowsContent, 1000);
|
||||
|
||||
// Note: split("\n") will keep the \r characters
|
||||
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
|
||||
@@ -145,29 +85,18 @@ describe("extractLinesWithByteLimit", () => {
|
||||
const largeLine = "x".repeat(1000);
|
||||
const largeContent = Array(110).fill(largeLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(largeContent, 0, 200, 50000);
|
||||
const result = extractLinesWithByteLimit(largeContent, 50000);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.actualEndLine).toBeLessThan(50); // Should stop well before 200 lines
|
||||
});
|
||||
|
||||
it("should handle line limit of 0", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 0, 0, 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.actualEndLine).toBe(0);
|
||||
expect(result.linesRead).toBe(0);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow at least one line even if it exceeds byte limit", () => {
|
||||
const veryLongLine = "x".repeat(100000); // 100KB line
|
||||
const result = extractLinesWithByteLimit(veryLongLine, 0, 1, 50000);
|
||||
const result = extractLinesWithByteLimit(veryLongLine, 50000);
|
||||
|
||||
// Should return the line even though it exceeds the byte limit
|
||||
// because we always allow at least one line if no lines have been added yet
|
||||
expect(result.content).toBe(veryLongLine);
|
||||
expect(result.actualEndLine).toBe(0);
|
||||
expect(result.linesRead).toBe(1);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
|
||||
25
src/tools.ts
25
src/tools.ts
@@ -5,6 +5,7 @@ import {
|
||||
ToolKind,
|
||||
} from "@zed-industries/agent-client-protocol";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -410,7 +411,13 @@ export function toolInfoFromToolUse(
|
||||
: [],
|
||||
};
|
||||
|
||||
case "Other":
|
||||
case "Other": {
|
||||
let output;
|
||||
try {
|
||||
output = JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
output = typeof input === "string" ? input : "{}";
|
||||
}
|
||||
return {
|
||||
title: name || "Unknown Tool",
|
||||
kind: "other",
|
||||
@@ -419,11 +426,12 @@ export function toolInfoFromToolUse(
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
|
||||
text: `\`\`\`json\n${output}\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
@@ -434,15 +442,8 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
type ToolResult = {
|
||||
type: "tool_result";
|
||||
content: any;
|
||||
tool_use_id: string;
|
||||
is_error: boolean;
|
||||
};
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: ToolResult,
|
||||
toolResult: ToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
): ToolUpdate {
|
||||
switch (toolUse?.name) {
|
||||
@@ -485,7 +486,7 @@ export function toolUpdateFromToolResult(
|
||||
case "MultiEdit":
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
if (toolResult.is_error && toolResult.content?.length > 0) {
|
||||
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
@@ -549,7 +550,7 @@ function toAcpContentUpdate(
|
||||
return {};
|
||||
}
|
||||
|
||||
type ClaudePlanEntry = {
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
|
||||
70
src/utils.ts
70
src/utils.ts
@@ -125,7 +125,6 @@ export function applyEnvironmentSettings(settings: ManagedSettings): void {
|
||||
|
||||
export interface ExtractLinesResult {
|
||||
content: string;
|
||||
actualEndLine: number;
|
||||
wasLimited: boolean;
|
||||
linesRead: number;
|
||||
}
|
||||
@@ -134,100 +133,55 @@ export interface ExtractLinesResult {
|
||||
* Extracts lines from file content with byte limit enforcement.
|
||||
*
|
||||
* @param fullContent - The complete file content
|
||||
* @param linesToSkip - Starting line number (0-based)
|
||||
* @param linesToRead - Maximum number of lines to read
|
||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
||||
* @returns Object containing extracted content and metadata
|
||||
*/
|
||||
export function extractLinesWithByteLimit(
|
||||
fullContent: string,
|
||||
linesToSkip: number,
|
||||
linesToRead: number,
|
||||
maxContentLength: number,
|
||||
): ExtractLinesResult {
|
||||
if (fullContent === "" || linesToRead === 0) {
|
||||
if (linesToSkip === 0 && linesToRead > 0) {
|
||||
return {
|
||||
content: "",
|
||||
actualEndLine: 0,
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: "",
|
||||
actualEndLine: linesToSkip,
|
||||
wasLimited: false,
|
||||
linesRead: 0,
|
||||
};
|
||||
}
|
||||
if (fullContent === "") {
|
||||
return {
|
||||
content: "",
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
}
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
|
||||
while (linesSeen < linesToSkip) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
// There were not enough lines to skip.
|
||||
if (nextIndex < 0) {
|
||||
return {
|
||||
content: "",
|
||||
actualEndLine: linesToSkip,
|
||||
wasLimited: false,
|
||||
linesRead: 0,
|
||||
};
|
||||
}
|
||||
|
||||
linesSeen += 1;
|
||||
index = nextIndex + 1;
|
||||
}
|
||||
|
||||
// We've successfully skipped over all the lines we were supposed to.
|
||||
// Now we can actually start reading!
|
||||
const startIndex = index;
|
||||
linesSeen = 0;
|
||||
|
||||
let contentLength = 0;
|
||||
let wasLimited = false;
|
||||
|
||||
while (linesSeen < linesToRead) {
|
||||
while (true) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
if (nextIndex < 0) {
|
||||
// Last line in file (no trailing newline)
|
||||
const newContentLength = fullContent.length - startIndex;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
if (linesSeen > 0 && fullContent.length > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
contentLength = fullContent.length;
|
||||
break;
|
||||
} else {
|
||||
// Line with newline - include up to the newline
|
||||
const newContentLength = nextIndex + 1 - startIndex;
|
||||
const newContentLength = nextIndex + 1;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
index = nextIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If we ended with a newline and we stopped due to byte limit or line limit (not end of file), remove the trailing newline
|
||||
if (contentLength > 0 && fullContent[startIndex + contentLength - 1] === "\n") {
|
||||
// Check if there's more content after our current position that we didn't read
|
||||
if (startIndex + contentLength < fullContent.length) {
|
||||
contentLength -= 1;
|
||||
index = newContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent.slice(startIndex, startIndex + contentLength),
|
||||
actualEndLine: linesSeen > 0 ? linesToSkip + linesSeen - 1 : linesToSkip,
|
||||
content: fullContent.slice(0, contentLength),
|
||||
wasLimited,
|
||||
linesRead: linesSeen,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user