Compare commits

..

26 Commits

Author SHA1 Message Date
Ben Brandt
847d62dacb Revert entrypoint changes (#127) 2025-10-27 12:45:58 +00:00
Ben Brandt
becf1d4f0a Update to claude agent sdk 0.1.27 (#125)
Also simplifies the permission mode workaround so we don't have to
change the profile immediately, which could have raced with client
changes anwyay.
2025-10-27 12:34:43 +01:00
Guillaume Sabran
5491bb8dcc Allow for the package to be used as a dependency, while preserving existing behavior when run (#124)
This change will allow for this package to be used directly, without
launching a subprocess and communicating over stdio.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-27 11:10:41 +00:00
Ben Brandt
17aad5b7a1 Provide agentInfo on initialization response (#123)
- **Provide agentInfo on initialization**
- **Prep 0.6.10**
2025-10-24 17:02:07 +00:00
Steffen Deusch
dadc7f9f40 prevent invalid locations when read call has no path (#121)
The ACP spec has the path as a required field for tool call locations,
but I've seen Claude generate read calls without an input path, which
would generate:

```json
{
  "content": [],
  "kind": "read",
  "locations": [
    {
      "line": 0
    }
  ],
  "rawInput": {},
  "sessionUpdate": "tool_call",
  "status": "pending",
  "title": "Read File",
  "toolCallId": "toolu_012PzeR5whUjWN4ij2HBubJw"
}
```

And then fail to parse as SessionNotification in the SDK.
2025-10-24 12:49:43 +00:00
Ben Brandt
552f0a286d Fix job name 2025-10-24 13:05:27 +02:00
Ben Brandt
589e1b5fc9 Update claude-agent-sdk to 0.1.26 (#120) 2025-10-24 11:03:18 +00:00
Ben Brandt
0d97bb4753 Fix type holes in switch statements (#119)
We were relying on unreachable to catch stuff for us, but it wasn't
erroring because we were manually casting a few of the types to never,
leading to a bug when a hook response was encountered.

We'll revisit showing these hook responses to the user once ACP supports
multiple types of status messages.
2025-10-24 10:19:41 +00:00
Ben Brandt
e3119b81f9 Prep v0.6.8 (#114) 2025-10-21 09:19:37 +00:00
Ben Brandt
cd3c9f7b13 Update to claude-agent-sdk v0.1.23 (#113)
- **Don't emit content from message start**
- **Update to claude-agent-sdk v0.1.23**
2025-10-21 11:16:35 +02:00
Ben Brandt
8aea8fd0e1 Remove extra console.log 2025-10-17 13:35:36 +02:00
Ben Brandt
81a541c644 Fix for invalid plan input in the model (#110)
Seems to have been introduced in the latest claude agent sdk.

Closes #109
2025-10-17 11:33:46 +00:00
Ben Brandt
b49154bef8 Don't allow bypassPermissions to be enabled if in root/sudo mode, as Claude Code won't start (#108)
Closes #104

Turns out this was the root cause, not the order of messages after all.
2025-10-17 10:58:38 +00:00
Ben Brandt
5fa453a33a Reorganize order of loading config from the query. (#107) 2025-10-17 12:36:58 +02:00
Ben Brandt
c2ab2da5ca Remove duplicated text content after streaming (#106) 2025-10-17 10:12:58 +00:00
Ben Brandt
373d940eb8 Update to claude-agent-sdk 0.1.21 and support streaming partial messages (#105) 2025-10-17 11:58:36 +02:00
Ben Brandt
2c16a41fa4 prep 0.6.3 (#102) 2025-10-16 12:35:47 +00:00
Ben Brandt
656cbe5921 Fix issue where slash commands were loaded before initialization was complete (#101) 2025-10-16 14:34:17 +02:00
Ben Brandt
2f811f8a85 Update to claude-agent-sdk 0.1.19 (#100) 2025-10-16 12:22:31 +00:00
Ben Brandt
6aa6537f67 Fix initialization error (#99)
Closes #98

It turns out we can't always call this right away, as on some machines
the process hasn't initialized yet.
Since available models waits for the process to start, as a workaround
we wait for that first, and then set the permission mode again once we
know that it is ready.
2025-10-16 12:17:32 +00:00
Ben Brandt
1b72ac7c8a Fix for allowing bypassPermissions to work (#97)
It seems that unless you start the session with bypassPermissions, you
can't change to it later.

So we start with it on, and immediately change it back to default. This
allows people to change it later however.

Closes https://github.com/zed-industries/zed/issues/39392
2025-10-15 13:23:26 +00:00
Ben Brandt
6ae2c05ddb Prep v0.6.0 (#96) 2025-10-15 10:25:43 +00:00
Bennet Fenner
8f0fc59a48 Add model selection support (#93)
This part of ACP is not yet stabilized, so marking as Draft for now

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-10-15 12:21:30 +02:00
Ben Brandt
c25150523a Update claude-agent-sdk to 0.1.15 (#95)
- **Update claude-agent-sdk to 0.1.15**
- **v0.5.6**
2025-10-15 09:05:16 +00:00
WhiskeyJack96
ea796f3cbd Add support for appending or overriding the system prompt (#91)
resolves #90 

Tested both the `append` and `override` flows by sending:
```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: "you are an obsidian assistant agent, you should be helpful to the user when working with their notes"
			},
			mcpServers: []
		});
```

```javascript
const resp = await this.connection.newSession({
			cwd: basePath,
			_meta: {
				systemPrompt: {
					append: "always speak in spanish"
				}
			},
			mcpServers: []
		});
```

and confirmed that in both cases those prompts are what gets sent to the
agent!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:48:06 +02:00
WhiskeyJack96
7f5ebca3fb Make write and edit permissions consistent (#92)
Currently edit requires a permissions call and write does not. This
means that if clients want to allow users to approve `Write` calls they
need to add handling to `HandleFileWrite` but if they do that then edits
get _two_ permissions checks.

Alternatively we could remove the write tool from approved tools (which
would make more sense to me) but I'm assuming its there for a good
reason, if not happy to change to remove it instead!

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-10-15 10:34:49 +02:00
10 changed files with 809 additions and 615 deletions

View File

@@ -4,7 +4,7 @@ on:
tags:
- "v*.*.*"
jobs:
build:
publish-npm:
runs-on: ubuntu-latest
environment: release # Optional: for enhanced security
permissions:
@@ -22,3 +22,15 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm publish
release:
runs-on: ubuntu-latest
needs: [publish-npm]
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
draft: true

View File

@@ -1,5 +1,71 @@
# Changelog
## 0.8.0
- Revert changes to filename for cli entrypoint
- Provide library entrypoint via lib.ts
## 0.7.0
- Allow importing from this package as a library in addition to running it as a CLI. Allows for easier integration into existing node applications.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.27
## 0.6.10
- Provide `agentInfo` on initialization response.
- Update to @agentclientprotocol/sdk@0.5.1
- Fix crash when receiving a hook_response event
- Fix for invalid locations when read call has no path
## 0.6.9
- Update to @anthropic-ai/claude-agent-sdk@v0.1.26
- Update to @agentclientprotocol/sdk@0.5.0
## 0.6.8
- Fix for duplicate tokens appearing in thread with streaming enabled
- Update to @anthropic-ai/claude-agent-sdk@v0.1.23
- Update to @agentclientprotocol/sdk@0.4.9
## 0.6.7
- Fix for invalid plan input from the model introduced in latest agent-sdk
## 0.6.6
- Do not enable bypassPermissions mode if in root/sudo mode, because Claude Code will not start
## 0.6.5
- Fix for duplicated text content after streaming
## 0.6.4
- Support streaming partial messages!
- Update to @anthropic-ai/claude-agent-sdk@v0.1.21
## 0.6.3
- Fix issue where slash commands were loaded before initialization was complete.
## 0.6.2
- Fix bug where mode selection would sometimes fire before initialization was complete.
- Update to @anthropic-ai/claude-agent-sdk@v0.1.19
## 0.6.1
- Fix to allow bypassPermissions mode to be selected (it wasn't permitted previously)
## 0.6.0
- Provide a model selector. We use the "default" model by default, and the user can change it via the client.
- Make sure writes require permissions when necessary: https://github.com/zed-industries/claude-code-acp/pull/92
- Add support for appending or overriding the system prompt: https://github.com/zed-industries/claude-code-acp/pull/91
- Update to @anthropic-ai/claude-agent-sdk@v0.1.15
- Update to @agentclientprotocol/sdk@0.4.8
## 0.5.5
- Migrate to @agentclientprotocol/sdk@0.4.5

957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,12 +3,12 @@
"publishConfig": {
"access": "public"
},
"version": "0.8.0",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/lib.js",
"bin": {
"claude-code-acp": "./dist/index.js"
},
"version": "0.5.5",
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
"main": "dist/index.js",
"type": "module",
"files": [
"dist/",
@@ -51,30 +51,30 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.4.5",
"@anthropic-ai/claude-agent-sdk": "0.1.13",
"@anthropic-ai/claude-code": "2.0.13",
"@modelcontextprotocol/sdk": "1.20.0",
"@agentclientprotocol/sdk": "0.5.1",
"@anthropic-ai/claude-agent-sdk": "0.1.27",
"@anthropic-ai/claude-code": "2.0.27",
"@modelcontextprotocol/sdk": "1.20.2",
"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",
"@anthropic-ai/sdk": "0.67.0",
"@types/express": "5.0.4",
"@types/minimist": "1.2.5",
"@types/node": "24.7.1",
"@types/node": "24.9.1",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.46.0",
"@typescript-eslint/parser": "8.46.0",
"@vitest/ui": "3.2.4",
"eslint": "9.37.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"@vitest/ui": "4.0.3",
"eslint": "9.38.0",
"eslint-config-prettier": "10.1.8",
"globals": "16.4.0",
"prettier": "3.6.2",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "3.2.4"
"vitest": "4.0.3"
}
}

View File

@@ -15,6 +15,9 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
SessionModelState,
SetSessionModelRequest,
SetSessionModelResponse,
SetSessionModeRequest,
SetSessionModeResponse,
TerminalHandle,
@@ -28,9 +31,8 @@ import {
PermissionMode,
Query,
query,
SDKAssistantMessage,
SDKPartialAssistantMessage,
SDKUserMessage,
SDKUserMessageReplay,
} from "@anthropic-ai/claude-agent-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
@@ -51,6 +53,9 @@ import {
toolUpdateFromToolResult,
ClaudePlanEntry,
} from "./tools.js";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
import packageJson from "../package.json" with { type: "json" };
type Session = {
query: Query;
@@ -71,9 +76,19 @@ type BackgroundTerminal =
};
type ToolUseCache = {
[key: string]: { type: "tool_use"; id: string; name: string; input: any };
[key: string]: {
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
id: string;
name: string;
input: any;
};
};
const DEFAULT_MODEL_ID = "default";
// Bypass Permissions doesn't work if we are a root/sudo user
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
@@ -96,7 +111,6 @@ export class ClaudeAcpAgent implements Agent {
this.clientCapabilities = request.clientCapabilities;
return {
protocolVersion: 1,
// todo!()
agentCapabilities: {
promptCapabilities: {
image: true,
@@ -107,6 +121,11 @@ export class ClaudeAcpAgent implements Agent {
sse: true,
},
},
agentInfo: {
name: packageJson.name,
title: "Claude Code",
version: packageJson.version,
},
authMethods: [
{
description: "Run `claude /login` in the terminal",
@@ -169,11 +188,32 @@ export class ClaudeAcpAgent implements Agent {
},
};
let systemPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code" };
if (params._meta?.systemPrompt) {
const customPrompt = params._meta.systemPrompt;
if (typeof customPrompt === "string") {
systemPrompt = customPrompt;
} else if (
typeof customPrompt === "object" &&
"append" in customPrompt &&
typeof customPrompt.append === "string"
) {
systemPrompt.append = customPrompt.append;
}
}
const permissionMode = "default";
const options: Options = {
cwd: params.cwd,
includePartialMessages: true,
mcpServers,
systemPrompt: { type: "preset", preset: "claude_code" },
systemPrompt,
settingSources: ["user", "project", "local"],
// If we want bypassPermissions to be an option, we have to allow it here.
// But it doesn't work in root mode, so we only activate it if it will work.
allowDangerouslySkipPermissions: !IS_ROOT,
permissionMode,
permissionPromptToolName: PERMISSION_TOOL_NAME,
stderr: (err) => console.error(err),
// note: although not documented by the types, passing an absolute path
@@ -191,7 +231,6 @@ export class ClaudeAcpAgent implements Agent {
disallowedTools.push("Read");
}
if (this.clientCapabilities?.fs?.writeTextFile) {
allowedTools.push(toolNames.write);
disallowedTools.push("Write", "Edit");
}
if (this.clientCapabilities?.terminal) {
@@ -210,14 +249,19 @@ export class ClaudeAcpAgent implements Agent {
prompt: input,
options,
});
this.sessions[sessionId] = {
query: q,
input: input,
cancelled: false,
permissionMode: "default",
permissionMode,
};
getAvailableSlashCommands(q).then((availableCommands) => {
const availableCommands = await getAvailableSlashCommands(q);
const models = await getAvailableModels(q);
// Needs to happen after we return the session
setTimeout(() => {
this.client.sessionUpdate({
sessionId,
update: {
@@ -225,34 +269,40 @@ export class ClaudeAcpAgent implements Agent {
availableCommands,
},
});
});
}, 0);
const availableModes = [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
];
// Only works in non-root mode
if (!IS_ROOT) {
availableModes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
});
}
return {
sessionId,
models,
modes: {
currentModeId: "default",
availableModes: [
{
id: "default",
name: "Always Ask",
description: "Prompts for permission on first use of each tool",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Automatically accepts file edit permissions for the session",
},
{
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skips all permission prompts",
},
{
id: "plan",
name: "Plan Mode",
description: "Claude can analyze but not modify files or execute commands",
},
],
currentModeId: permissionMode,
availableModes,
},
};
}
@@ -286,8 +336,11 @@ export class ClaudeAcpAgent implements Agent {
break;
case "compact_boundary":
break;
case "hook_response":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
unreachable(message as never);
unreachable(message);
}
break;
case "result": {
@@ -311,6 +364,17 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "refusal" };
}
}
case "stream_event": {
for (const notification of streamEventToAcpNotifications(
message,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
)) {
await this.client.sessionUpdate(notification);
}
break;
}
case "user":
case "assistant": {
if (this.sessions[params.sessionId].cancelled) {
@@ -350,8 +414,15 @@ export class ClaudeAcpAgent implements Agent {
throw RequestError.authRequired();
}
const content =
message.type === "assistant"
? // Handled by stream events above
message.message.content.filter((item) => !["text", "thinking"].includes(item.type))
: message.message.content;
for (const notification of toAcpNotifications(
message,
content,
message.message.role,
params.sessionId,
this.toolUseCache,
this.fileContentCache,
@@ -361,7 +432,7 @@ export class ClaudeAcpAgent implements Agent {
break;
}
default:
unreachable(message as never);
unreachable(message);
}
}
throw new Error("Session did not end in result");
@@ -375,6 +446,13 @@ export class ClaudeAcpAgent implements Agent {
await this.sessions[params.sessionId].query.interrupt();
}
async setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
await this.sessions[params.sessionId].query.setModel(params.modelId);
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
@@ -415,6 +493,26 @@ export class ClaudeAcpAgent implements Agent {
}
}
async function getAvailableModels(query: Query): Promise<SessionModelState> {
const models = await query.supportedModels();
// Query doesn't give us access to the currently selected model, so we default to "default" if it is there,
// otherwise we just choose the first model in the list.
const currentModel = models.find((model) => model.value === DEFAULT_MODEL_ID) || models[0];
await query.setModel(currentModel.value);
const availableModels = models.map((model) => ({
modelId: model.value,
name: model.displayName,
description: model.description,
}));
return {
availableModels,
currentModelId: currentModel.value,
};
}
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
const UNSUPPORTED_COMMANDS = [
"add-dir",
@@ -560,20 +658,18 @@ function promptToClaude(prompt: PromptRequest): SDKUserMessage {
* Only handles text, image, and thinking chunks for now.
*/
export function toAcpNotifications(
message: SDKAssistantMessage | SDKUserMessage | SDKUserMessageReplay,
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
role: "assistant" | "user",
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
const content = message.message.content;
if (typeof content === "string") {
return [
{
sessionId,
update: {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
@@ -589,9 +685,9 @@ export function toAcpNotifications(
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
case "text_delta":
update = {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: chunk.text,
@@ -600,8 +696,7 @@ export function toAcpNotifications(
break;
case "image":
update = {
sessionUpdate:
message.type === "assistant" ? "agent_message_chunk" : "user_message_chunk",
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
@@ -611,6 +706,7 @@ export function toAcpNotifications(
};
break;
case "thinking":
case "thinking_delta":
update = {
sessionUpdate: "agent_thought_chunk",
content: {
@@ -620,12 +716,17 @@ export function toAcpNotifications(
};
break;
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
// @ts-expect-error - sometimes input is empty object
if (Array.isArray(chunk.input.todos)) {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
}
} else {
let rawInput;
try {
@@ -642,8 +743,15 @@ export function toAcpNotifications(
};
}
break;
}
case "tool_result": {
case "tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
case "mcp_tool_result": {
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
console.error(
@@ -656,15 +764,24 @@ export function toAcpNotifications(
update = {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update",
status: chunk.is_error ? "failed" : "completed",
status: "is_error" in chunk && chunk.is_error ? "failed" : "completed",
...toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id]),
};
}
break;
}
case "document":
case "search_result":
case "redacted_thinking":
case "input_json_delta":
case "citations_delta":
case "signature_delta":
case "container_upload":
break;
default:
throw new Error("unhandled chunk type: " + chunk.type);
unreachable(chunk);
}
if (update) {
output.push({ sessionId, update });
@@ -674,6 +791,42 @@ export function toAcpNotifications(
return output;
}
export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
fileContentCache: { [key: string]: string },
): SessionNotification[] {
const event = message.event;
switch (event.type) {
case "content_block_start":
return toAcpNotifications(
[event.content_block],
"assistant",
sessionId,
toolUseCache,
fileContentCache,
);
case "content_block_delta":
return toAcpNotifications(
[event.delta],
"assistant",
sessionId,
toolUseCache,
fileContentCache,
);
// No content
case "message_start":
case "message_delta":
case "message_stop":
case "content_block_stop":
return [];
default:
unreachable(event);
}
}
export function runAcp() {
const input = nodeToWebWritable(process.stdout);
const output = nodeToWebReadable(process.stdin);

View File

@@ -19,7 +19,7 @@ process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp as runAcp } from "./acp-agent.js";
import { runAcp } from "./acp-agent.js";
runAcp();
// Keep process alive

25
src/lib.ts Normal file
View File

@@ -0,0 +1,25 @@
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
} from "./acp-agent.js";
export {
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
export {
createMcpServer,
createPermissionMcpServer,
PERMISSION_TOOL_NAME,
toolNames,
} from "./mcp-server.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

View File

@@ -18,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-agent-sdk";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
let child: ReturnType<typeof spawn>;
@@ -649,7 +649,9 @@ describe("tool conversions", () => {
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
};
expect(toAcpNotifications(received, "test", {}, {})).toStrictEqual([
expect(
toAcpNotifications(received.message.content, received.message.role, "test", {}, {}),
).toStrictEqual([
{
sessionId: "test",
update: {
@@ -780,3 +782,16 @@ describe("escape markdown", () => {
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
});
});
describe("SDK behavior", () => {
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"query has a 'default' model",
async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
const defaultModel = models.find((m) => m.value === "default");
expect(defaultModel).toBeDefined();
},
10000,
);
});

View File

@@ -1,6 +1,14 @@
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaWebFetchToolResultBlockParam,
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
interface ToolInfo {
title: string;
@@ -105,12 +113,14 @@ export function toolInfoFromToolUse(
return {
title: "Read " + (input.file_path ?? "File") + limit,
kind: "read",
locations: [
{
path: input.file_path,
line: input.offset ?? 0,
},
],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
content: [],
};
}
@@ -336,7 +346,7 @@ export function toolInfoFromToolUse(
case "TodoWrite":
return {
title: input?.todos
title: Array.isArray(input?.todos)
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
: "Update TODOs",
kind: "think",
@@ -385,7 +395,15 @@ export function toolInfoFromToolUse(
}
export function toolUpdateFromToolResult(
toolResult: ToolResultBlockParam,
toolResult:
| ToolResultBlockParam
| BetaWebSearchToolResultBlockParam
| BetaWebFetchToolResultBlockParam
| WebSearchToolResultBlockParam
| BetaCodeExecutionToolResultBlockParam
| BetaBashCodeExecutionToolResultBlockParam
| BetaTextEditorCodeExecutionToolResultBlockParam
| BetaRequestMCPToolResultBlockParam,
toolUse: any | undefined,
): ToolUpdate {
switch (toolUse?.name) {
@@ -425,7 +443,12 @@ export function toolUpdateFromToolResult(
case toolNames.edit:
case toolNames.write:
case "Write": {
if (toolResult.is_error && toolResult.content && toolResult.content.length > 0) {
if (
"is_error" in toolResult &&
toolResult.is_error &&
toolResult.content &&
toolResult.content.length > 0
) {
// Only return errors
return toAcpContentUpdate(toolResult.content, true);
}
@@ -451,7 +474,10 @@ export function toolUpdateFromToolResult(
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(toolResult.content, toolResult.is_error);
return toAcpContentUpdate(
toolResult.content,
"is_error" in toolResult ? toolResult.is_error : false,
);
}
}
}

View File

@@ -76,7 +76,13 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
}
export function unreachable(value: never): never {
throw new Error(`Unexpected case: ${value}`);
let valueAsString;
try {
valueAsString = JSON.stringify(value);
} catch {
valueAsString = value;
}
throw new Error(`Unexpected case: ${valueAsString}`);
}
export function sleep(time: number): Promise<void> {