mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 04:51:50 +00:00
Compare commits
1 Commits
fix-plan-m
...
code-block
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abae47de80 |
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -1,45 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: "ci-${{ github.ref }}"
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
|
||||||
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
|
||||||
with:
|
|
||||||
node-version: latest
|
|
||||||
cache: "npm"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: npm run format:check
|
|
||||||
|
|
||||||
- name: Run linter
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm run test:run
|
|
||||||
36
.github/workflows/publish.yml
vendored
36
.github/workflows/publish.yml
vendored
@@ -1,36 +0,0 @@
|
|||||||
name: Publish Package to npmjs
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
jobs:
|
|
||||||
publish-npm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: release # Optional: for enhanced security
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
|
||||||
# Setup .npmrc file to publish to npm
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
|
||||||
with:
|
|
||||||
node-version: "lts/*"
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
- name: Update npm
|
|
||||||
run: npm install -g npm@latest
|
|
||||||
- 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
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.DS_Store
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
216
CHANGELOG.md
216
CHANGELOG.md
@@ -1,216 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## 0.12.3
|
|
||||||
|
|
||||||
- Allow agent to write plans and todos to its config directory
|
|
||||||
- Fix experimental resume ids
|
|
||||||
|
|
||||||
## 0.12.2
|
|
||||||
|
|
||||||
- Fix duplicate tool use IDs error
|
|
||||||
|
|
||||||
## 0.12.1
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
|
|
||||||
- Update to @agentclientprotocol/sdk@0.8.0
|
|
||||||
|
|
||||||
## 0.12.0
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
|
|
||||||
- Brings Opus to Claude Pro plans
|
|
||||||
- Support "Don't Ask" profile
|
|
||||||
- Unify ACP + Claude Code session ids
|
|
||||||
|
|
||||||
## 0.11.0
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
|
|
||||||
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
|
|
||||||
|
|
||||||
## 0.10.10
|
|
||||||
|
|
||||||
- Update to @agentclientprotocol/sdk@0.7.0
|
|
||||||
|
|
||||||
## 0.10.9
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
|
|
||||||
- Allow defining a custom logger when used as a library
|
|
||||||
- Allow specifying custom options when used as a library
|
|
||||||
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
|
|
||||||
|
|
||||||
## 0.10.8
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
|
|
||||||
|
|
||||||
## 0.10.7
|
|
||||||
|
|
||||||
- Fix read/edit tool error handling so upstream errors surface
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
|
|
||||||
|
|
||||||
## 0.10.6
|
|
||||||
|
|
||||||
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
|
|
||||||
|
|
||||||
## 0.10.5
|
|
||||||
|
|
||||||
- Better error messages at end of turn if there were any
|
|
||||||
- Add experimental support for disabling built-in tools via \_meta flag
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
|
|
||||||
|
|
||||||
## 0.10.4
|
|
||||||
|
|
||||||
- Fix tool call titles not appearing during approval in some cases
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
|
|
||||||
|
|
||||||
## 0.10.3
|
|
||||||
|
|
||||||
- Fix for experimental terminal auth support
|
|
||||||
|
|
||||||
## 0.10.2
|
|
||||||
|
|
||||||
- Fix incorrect stop reason for tool call refusals
|
|
||||||
|
|
||||||
## 0.10.1
|
|
||||||
|
|
||||||
- Add additional structured metadata to tool calls
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
|
|
||||||
|
|
||||||
## 0.10.0
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
|
|
||||||
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
|
|
||||||
|
|
||||||
## 0.9.0
|
|
||||||
|
|
||||||
- Support slash commands coming from MCP servers (Prompts)
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.13
|
|
||||||
|
|
||||||
## 0.5.4
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.11
|
|
||||||
- Enable setting CLAUDE_CODE_EXECUTABLE to override the executable used by the SDK https://github.com/zed-industries/claude-code-acp/pull/86
|
|
||||||
|
|
||||||
## 0.5.3
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.8
|
|
||||||
- Update to @zed-industries/agent-client-protocol@v0.4.5
|
|
||||||
|
|
||||||
## 0.5.2
|
|
||||||
|
|
||||||
- Add back @anthropic-ai/claude-code@2.0.1 as runtime dependency
|
|
||||||
|
|
||||||
## 0.5.1
|
|
||||||
|
|
||||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.1
|
|
||||||
- Make improvements to ACP tools provided to the model
|
|
||||||
|
|
||||||
## 0.5.0
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
- Better rendering of Tool errors [491efe3](https://github.com/zed-industries/claude-code-acp/commit/491efe32e8547075842e448d873fc01b2ffabf3a)
|
|
||||||
- Load managed-settings.json [f691024](https://github.com/zed-industries/claude-code-acp/commit/f691024350362858e00b97248ac68e356d2331c2)
|
|
||||||
- Update to @anthropic-ai/claude-code@v1.0.113
|
|
||||||
- Update to @zed-industries/agent-client-protocol@v0.3.1
|
|
||||||
60
README.md
60
README.md
@@ -1,59 +1,3 @@
|
|||||||
# ACP adapter for Claude Code
|
An ACP-compatible coding agent powered by the Claude Code SDK
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
|
This wrapper lets you use the real version of Claude Code from the IDE of your choice.
|
||||||
|
|
||||||
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
|
||||||
|
|
||||||
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
|
|
||||||
|
|
||||||
- Context @-mentions
|
|
||||||
- Images
|
|
||||||
- Tool calls (with permission requests)
|
|
||||||
- Following
|
|
||||||
- Edit review
|
|
||||||
- TODO lists
|
|
||||||
- Interactive (and background) terminals
|
|
||||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
|
||||||
- Client MCP servers
|
|
||||||
|
|
||||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
|
||||||
|
|
||||||
## How to use
|
|
||||||
|
|
||||||
### Zed
|
|
||||||
|
|
||||||
The latest version of Zed can already use this adapter out of the box.
|
|
||||||
|
|
||||||
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
|
|
||||||
|
|
||||||
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
|
|
||||||
|
|
||||||
### Other clients
|
|
||||||
|
|
||||||
- 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!
|
|
||||||
|
|
||||||
#### Installation
|
|
||||||
|
|
||||||
Install the adapter from `npm`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @zed-industries/claude-code-acp
|
|
||||||
```
|
|
||||||
|
|
||||||
You can then use `claude-code-acp` as a regular ACP agent:
|
|
||||||
|
|
||||||
```
|
|
||||||
ANTHROPIC_API_KEY=sk-... claude-code-acp
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Apache-2.0
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
## How to publish a new release
|
|
||||||
|
|
||||||
1. Create a new branch prep-vX.X.X
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout -b prep-vX.X.X
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Bump the version in package.json
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm version vX.X.X
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Add a new entry in CHANGELOG.md
|
|
||||||
4. Create a PR for `prev-vX.X.X` & merge it
|
|
||||||
5. Create tag and push it
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag vX.X.X
|
|
||||||
git push origin vX.X.X
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Paste CHANGELOG into release notes and publish the version on GitHub
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
|
||||||
import tsparser from "@typescript-eslint/parser";
|
|
||||||
import prettierConfig from "eslint-config-prettier";
|
|
||||||
import globals from "globals";
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
|
||||||
},
|
|
||||||
js.configs.recommended,
|
|
||||||
{
|
|
||||||
files: ["src/**/*.ts"],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: "module",
|
|
||||||
parser: tsparser,
|
|
||||||
parserOptions: {
|
|
||||||
project: "./tsconfig.json",
|
|
||||||
},
|
|
||||||
globals: {
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
"@typescript-eslint": tseslint,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...tseslint.configs.recommended.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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
prettierConfig,
|
|
||||||
];
|
|
||||||
2439
package-lock.json
generated
2439
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@@ -3,33 +3,19 @@
|
|||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"version": "0.12.3",
|
"version": "0.1.0",
|
||||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||||
"main": "dist/lib.js",
|
"main": "dist/acp-agent.js",
|
||||||
"bin": {
|
|
||||||
"claude-code-acp": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
|
||||||
"dist/",
|
|
||||||
"README.md",
|
|
||||||
"LICENSE",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"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": "eslint src --ext .ts",
|
||||||
"lint:fix": "eslint src --ext .ts --fix",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"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",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage"
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
@@ -40,33 +26,25 @@
|
|||||||
"sdk",
|
"sdk",
|
||||||
"code"
|
"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",
|
"author": "Zed Industries",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.8.0",
|
"@anthropic-ai/claude-code": "^1.0.96",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.1.61",
|
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||||
"@modelcontextprotocol/sdk": "1.24.3",
|
"@types/express": "^5.0.3",
|
||||||
"diff": "8.0.2"
|
"@zed-industries/agent-client-protocol": "0.1.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"uuid": "11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/sdk": "0.71.2",
|
"@types/minimist": "^1.2.5",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "^24.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "8.48.1",
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/parser": "8.48.1",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint": "9.39.1",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"ts-node": "^10.9.2",
|
||||||
"globals": "16.5.0",
|
"typescript": "^5.4.0",
|
||||||
"prettier": "3.7.4",
|
"vitest": "^3.2.4"
|
||||||
"ts-node": "10.9.2",
|
|
||||||
"typescript": "5.9.3",
|
|
||||||
"vitest": "4.0.15"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
995
src/acp-agent.ts
995
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
23
src/index.ts
23
src/index.ts
@@ -1,25 +1,4 @@
|
|||||||
#!/usr/bin/env node
|
import { runAcp as runAcp } from "./acp-agent.js";
|
||||||
|
|
||||||
// Load managed settings and apply environment variables
|
|
||||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
|
||||||
|
|
||||||
const managedSettings = loadManagedSettings();
|
|
||||||
if (managedSettings) {
|
|
||||||
applyEnvironmentSettings(managedSettings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// stdout is used to send messages to the client
|
|
||||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
|
||||||
console.log = console.error;
|
|
||||||
console.info = console.error;
|
|
||||||
console.warn = console.error;
|
|
||||||
console.debug = console.error;
|
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason, promise) => {
|
|
||||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
import { runAcp } from "./acp-agent.js";
|
|
||||||
runAcp();
|
runAcp();
|
||||||
|
|
||||||
// Keep process alive
|
// Keep process alive
|
||||||
|
|||||||
22
src/lib.ts
22
src/lib.ts
@@ -1,22 +0,0 @@
|
|||||||
// Export the main agent class and utilities for library usage
|
|
||||||
export {
|
|
||||||
ClaudeAcpAgent,
|
|
||||||
runAcp,
|
|
||||||
toAcpNotifications,
|
|
||||||
streamEventToAcpNotifications,
|
|
||||||
type ToolUpdateMeta,
|
|
||||||
type NewSessionMeta,
|
|
||||||
} from "./acp-agent.js";
|
|
||||||
export {
|
|
||||||
loadManagedSettings,
|
|
||||||
applyEnvironmentSettings,
|
|
||||||
nodeToWebReadable,
|
|
||||||
nodeToWebWritable,
|
|
||||||
Pushable,
|
|
||||||
unreachable,
|
|
||||||
} from "./utils.js";
|
|
||||||
export { createMcpServer, toolNames } from "./mcp-server.js";
|
|
||||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
|
||||||
|
|
||||||
// Export types
|
|
||||||
export type { ClaudePlanEntry } from "./tools.js";
|
|
||||||
1059
src/mcp-server.ts
1059
src/mcp-server.ts
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description: 10 * 3 = 30
|
|
||||||
---
|
|
||||||
|
|
||||||
What is 10 \* 3. Only respond with the number.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
description: Say hello
|
|
||||||
argument-hint: [name]
|
|
||||||
---
|
|
||||||
|
|
||||||
Respond with "Hello $1" and nothing else.
|
|
||||||
@@ -2,12 +2,8 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|||||||
import { spawn, spawnSync } from "child_process";
|
import { spawn, spawnSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
Agent,
|
Agent,
|
||||||
AgentSideConnection,
|
|
||||||
AvailableCommand,
|
|
||||||
Client,
|
Client,
|
||||||
ClientSideConnection,
|
ClientSideConnection,
|
||||||
ndJsonStream,
|
|
||||||
NewSessionResponse,
|
|
||||||
ReadTextFileRequest,
|
ReadTextFileRequest,
|
||||||
ReadTextFileResponse,
|
ReadTextFileResponse,
|
||||||
RequestPermissionRequest,
|
RequestPermissionRequest,
|
||||||
@@ -15,229 +11,91 @@ import {
|
|||||||
SessionNotification,
|
SessionNotification,
|
||||||
WriteTextFileRequest,
|
WriteTextFileRequest,
|
||||||
WriteTextFileResponse,
|
WriteTextFileResponse,
|
||||||
} from "@agentclientprotocol/sdk";
|
} from "@zed-industries/agent-client-protocol";
|
||||||
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||||
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
|
||||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
import { toAcpNotifications } from "../acp-agent.js";
|
||||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
import { SDKAssistantMessage } from "@anthropic-ai/claude-code";
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||||
let child: ReturnType<typeof spawn>;
|
"ACP subprocess integration",
|
||||||
|
() => {
|
||||||
|
let child: ReturnType<typeof spawn>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const valid = spawnSync("tsc", { stdio: "inherit" });
|
let 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
child.kill();
|
|
||||||
});
|
|
||||||
|
|
||||||
class TestClient implements Client {
|
|
||||||
agent: Agent;
|
|
||||||
files: Map<string, string> = new Map();
|
|
||||||
receivedText: string = "";
|
|
||||||
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
|
|
||||||
availableCommandsPromise: Promise<AvailableCommand[]>;
|
|
||||||
|
|
||||||
constructor(agent: Agent) {
|
|
||||||
this.agent = agent;
|
|
||||||
this.resolveAvailableCommands = () => {};
|
|
||||||
this.availableCommandsPromise = new Promise((resolve) => {
|
|
||||||
this.resolveAvailableCommands = resolve;
|
|
||||||
});
|
});
|
||||||
}
|
child.on("error", (error) => {
|
||||||
|
console.error("Error starting subprocess:", error);
|
||||||
|
});
|
||||||
|
child.on("exit", (exit) => {
|
||||||
|
console.error("Exited with", exit);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
takeReceivedText() {
|
afterAll(() => {
|
||||||
const text = this.receivedText;
|
child.kill();
|
||||||
this.receivedText = "";
|
});
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
class TestClient implements Client {
|
||||||
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
|
agent: Agent;
|
||||||
|
|
||||||
return { outcome: { outcome: "selected", optionId } };
|
constructor(agent: Agent) {
|
||||||
}
|
this.agent = agent;
|
||||||
|
}
|
||||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
requestPermission(
|
||||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
params: RequestPermissionRequest,
|
||||||
|
): Promise<RequestPermissionResponse> {
|
||||||
switch (params.update.sessionUpdate) {
|
throw new Error("Method not implemented.");
|
||||||
case "agent_message_chunk": {
|
}
|
||||||
if (params.update.content.type === "text") {
|
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||||
this.receivedText += params.update.content.text;
|
console.error("RECEVIED", params);
|
||||||
}
|
}
|
||||||
break;
|
writeTextFile(
|
||||||
}
|
params: WriteTextFileRequest,
|
||||||
case "available_commands_update":
|
): Promise<WriteTextFileResponse> {
|
||||||
this.resolveAvailableCommands(params.update.availableCommands);
|
throw new Error("Method not implemented.");
|
||||||
break;
|
}
|
||||||
default:
|
async readTextFile(
|
||||||
break;
|
params: ReadTextFileRequest,
|
||||||
|
): Promise<ReadTextFileResponse> {
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
"This project attempts to take over the world by solving the Goldbach conjecture.",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
it("should connect to the ACP subprocess", async () => {
|
||||||
this.files.set(params.path, params.content);
|
const connection = new ClientSideConnection(
|
||||||
return {};
|
(agent) => {
|
||||||
}
|
return new TestClient(agent);
|
||||||
|
|
||||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
|
||||||
const content = this.files.get(params.path) ?? "";
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupTestSession(cwd: string): Promise<{
|
|
||||||
client: TestClient;
|
|
||||||
connection: ClientSideConnection;
|
|
||||||
newSessionResponse: NewSessionResponse;
|
|
||||||
}> {
|
|
||||||
let client;
|
|
||||||
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,
|
|
||||||
clientCapabilities: {
|
|
||||||
fs: {
|
|
||||||
readTextFile: true,
|
|
||||||
writeTextFile: true,
|
|
||||||
},
|
},
|
||||||
},
|
nodeToWebWritable(child.stdin!),
|
||||||
});
|
nodeToWebReadable(child.stdout!),
|
||||||
|
);
|
||||||
|
|
||||||
const newSessionResponse = await connection.newSession({
|
let session = await connection.newSession({ cwd: "./", mcpServers: [] });
|
||||||
cwd,
|
await connection.prompt({
|
||||||
mcpServers: [],
|
prompt: [
|
||||||
});
|
{
|
||||||
|
type: "text",
|
||||||
return { client: client!, connection, newSessionResponse };
|
text: "What does this project do? Read the README.md file.",
|
||||||
}
|
},
|
||||||
|
],
|
||||||
it("should connect to the ACP subprocess", async () => {
|
sessionId: session.sessionId,
|
||||||
const { client, connection, newSessionResponse } = await setupTestSession("./");
|
});
|
||||||
|
console.log("DONE AWAITING");
|
||||||
await connection.prompt({
|
}, 20000);
|
||||||
prompt: [
|
},
|
||||||
{
|
);
|
||||||
type: "text",
|
|
||||||
text: "Hello",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sessionId: newSessionResponse.sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.takeReceivedText()).not.toEqual("");
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it("should include available commands", async () => {
|
|
||||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
|
||||||
|
|
||||||
const commands = await client.availableCommandsPromise;
|
|
||||||
|
|
||||||
expect(commands).toContainEqual({
|
|
||||||
name: "quick-math",
|
|
||||||
description: "10 * 3 = 30 (project)",
|
|
||||||
input: null,
|
|
||||||
});
|
|
||||||
expect(commands).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);
|
|
||||||
|
|
||||||
it("/compact works", async () => {
|
|
||||||
const { client, connection, newSessionResponse } = await setupTestSession(__dirname);
|
|
||||||
|
|
||||||
const commands = await client.availableCommandsPromise;
|
|
||||||
|
|
||||||
expect(commands).toContainEqual({
|
|
||||||
description:
|
|
||||||
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
|
|
||||||
input: {
|
|
||||||
hint: "<optional custom summarization instructions>",
|
|
||||||
},
|
|
||||||
name: "compact",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error case (no previous message)
|
|
||||||
await connection.prompt({
|
|
||||||
prompt: [{ type: "text", text: "/compact" }],
|
|
||||||
sessionId: newSessionResponse.sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.takeReceivedText()).toBe("");
|
|
||||||
|
|
||||||
// Send something
|
|
||||||
await connection.prompt({
|
|
||||||
prompt: [{ type: "text", text: "Hi" }],
|
|
||||||
sessionId: newSessionResponse.sessionId,
|
|
||||||
});
|
|
||||||
// Clear response
|
|
||||||
client.takeReceivedText();
|
|
||||||
|
|
||||||
// Test with instruction
|
|
||||||
await connection.prompt({
|
|
||||||
prompt: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "/compact greeting",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sessionId: newSessionResponse.sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.takeReceivedText()).toContain("");
|
|
||||||
}, 30000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("tool conversions", () => {
|
describe("tool conversions", () => {
|
||||||
it("should handle Bash nicely", () => {
|
it("should handle Bash nicely", () => {
|
||||||
@@ -251,7 +109,7 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
title: "`rm README.md.rm`",
|
title: "`rm README.md.rm`",
|
||||||
content: [
|
content: [
|
||||||
@@ -276,9 +134,9 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "search",
|
kind: "search",
|
||||||
title: "Find `*/**.ts`",
|
title: "Find */**.ts",
|
||||||
content: [],
|
content: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
});
|
});
|
||||||
@@ -297,7 +155,7 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "think",
|
kind: "think",
|
||||||
title: "Handle user's work request",
|
title: "Handle user's work request",
|
||||||
content: [
|
content: [
|
||||||
@@ -318,15 +176,15 @@ describe("tool conversions", () => {
|
|||||||
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
|
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
|
||||||
name: "LS",
|
name: "LS",
|
||||||
input: {
|
input: {
|
||||||
path: "/Users/test/github/claude-code-acp",
|
path: "/Users/benbrandt/github/claude-code-acp",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "search",
|
kind: "search",
|
||||||
title: "List the `/Users/test/github/claude-code-acp` directory's contents",
|
title: "/Users/benbrandt/github/claude-code-acp",
|
||||||
content: [],
|
content: [],
|
||||||
locations: [],
|
locations: [{ path: "/Users/benbrandt/github/claude-code-acp" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,7 +198,7 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "search",
|
kind: "search",
|
||||||
title: 'grep ".*"',
|
title: 'grep ".*"',
|
||||||
content: [],
|
content: [],
|
||||||
@@ -353,12 +211,12 @@ describe("tool conversions", () => {
|
|||||||
id: "toolu_01ABC123XYZ789",
|
id: "toolu_01ABC123XYZ789",
|
||||||
name: "Write",
|
name: "Write",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/example.txt",
|
abs_path: "/Users/test/project/example.txt",
|
||||||
content: "Hello, World!\nThis is test content.",
|
content: "Hello, World!\nThis is test content.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
title: "Write /Users/test/project/example.txt",
|
title: "Write /Users/test/project/example.txt",
|
||||||
content: [
|
content: [
|
||||||
@@ -373,18 +231,18 @@ describe("tool conversions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mcp__acp__Write tool calls", () => {
|
it("should handle mcp__acp__write tool calls", () => {
|
||||||
const tool_use = {
|
const tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01GHI789JKL456",
|
id: "toolu_01GHI789JKL456",
|
||||||
name: "mcp__acp__Write",
|
name: "mcp__acp__write",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/config.json",
|
abs_path: "/Users/test/project/config.json",
|
||||||
content: '{"version": "1.0.0"}',
|
content: '{"version": "1.0.0"}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
title: "Write /Users/test/project/config.json",
|
title: "Write /Users/test/project/config.json",
|
||||||
content: [
|
content: [
|
||||||
@@ -405,29 +263,34 @@ describe("tool conversions", () => {
|
|||||||
id: "toolu_01MNO456PQR789",
|
id: "toolu_01MNO456PQR789",
|
||||||
name: "Read",
|
name: "Read",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/readme.md",
|
abs_path: "/Users/test/project/readme.md",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "read",
|
kind: "read",
|
||||||
title: "Read File",
|
title: "Read File",
|
||||||
content: [],
|
content: [
|
||||||
|
{
|
||||||
|
type: "content",
|
||||||
|
content: { type: "text", text: "/Users/test/project/readme.md" },
|
||||||
|
},
|
||||||
|
],
|
||||||
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
|
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mcp__acp__Read tool calls", () => {
|
it("should handle mcp__acp__read tool calls", () => {
|
||||||
const tool_use = {
|
const tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01YZA789BCD123",
|
id: "toolu_01YZA789BCD123",
|
||||||
name: "mcp__acp__Read",
|
name: "mcp__acp__read",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/data.json",
|
abs_path: "/Users/test/project/data.json",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "read",
|
kind: "read",
|
||||||
title: "Read /Users/test/project/data.json",
|
title: "Read /Users/test/project/data.json",
|
||||||
content: [],
|
content: [],
|
||||||
@@ -435,18 +298,18 @@ describe("tool conversions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mcp__acp__Read with limit", () => {
|
it("should handle mcp__acp__read with limit", () => {
|
||||||
const tool_use = {
|
const tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01EFG456HIJ789",
|
id: "toolu_01EFG456HIJ789",
|
||||||
name: "mcp__acp__Read",
|
name: "mcp__acp__read",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/large.txt",
|
abs_path: "/Users/test/project/large.txt",
|
||||||
limit: 100,
|
limit: 100,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "read",
|
kind: "read",
|
||||||
title: "Read /Users/test/project/large.txt (1 - 100)",
|
title: "Read /Users/test/project/large.txt (1 - 100)",
|
||||||
content: [],
|
content: [],
|
||||||
@@ -454,19 +317,19 @@ describe("tool conversions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mcp__acp__Read with offset and limit", () => {
|
it("should handle mcp__acp__read with offset and limit", () => {
|
||||||
const tool_use = {
|
const tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01KLM789NOP456",
|
id: "toolu_01KLM789NOP456",
|
||||||
name: "mcp__acp__Read",
|
name: "mcp__acp__read",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/large.txt",
|
abs_path: "/Users/test/project/large.txt",
|
||||||
offset: 50,
|
offset: 50,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "read",
|
kind: "read",
|
||||||
title: "Read /Users/test/project/large.txt (51 - 150)",
|
title: "Read /Users/test/project/large.txt (51 - 150)",
|
||||||
content: [],
|
content: [],
|
||||||
@@ -474,18 +337,18 @@ describe("tool conversions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle mcp__acp__Read with only offset", () => {
|
it("should handle mcp__acp__read with only offset", () => {
|
||||||
const tool_use = {
|
const tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01QRS123TUV789",
|
id: "toolu_01QRS123TUV789",
|
||||||
name: "mcp__acp__Read",
|
name: "mcp__acp__read",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/large.txt",
|
abs_path: "/Users/test/project/large.txt",
|
||||||
offset: 200,
|
offset: 200,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "read",
|
kind: "read",
|
||||||
title: "Read /Users/test/project/large.txt (from line 201)",
|
title: "Read /Users/test/project/large.txt (from line 201)",
|
||||||
content: [],
|
content: [],
|
||||||
@@ -505,7 +368,7 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "fetch",
|
kind: "fetch",
|
||||||
title: "Fetch https://agentclientprotocol.com",
|
title: "Fetch https://agentclientprotocol.com",
|
||||||
content: [
|
content: [
|
||||||
@@ -530,7 +393,7 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "fetch",
|
kind: "fetch",
|
||||||
title: '"agentclientprotocol.com"',
|
title: '"agentclientprotocol.com"',
|
||||||
content: [],
|
content: [],
|
||||||
@@ -538,16 +401,16 @@ describe("tool conversions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle KillBash entries", () => {
|
it("should handle KillBash entries", () => {
|
||||||
const tool_use = {
|
let tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
|
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
|
||||||
name: "KillShell",
|
name: "KillBash",
|
||||||
input: {
|
input: {
|
||||||
shell_id: "bash_1",
|
shell_id: "bash_1",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
title: `Kill Process`,
|
title: `Kill Process`,
|
||||||
content: [],
|
content: [],
|
||||||
@@ -555,7 +418,7 @@ describe("tool conversions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle BashOutput entries", () => {
|
it("should handle BashOutput entries", () => {
|
||||||
const tool_use = {
|
let tool_use = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
|
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
|
||||||
name: "BashOutput",
|
name: "BashOutput",
|
||||||
@@ -564,7 +427,7 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(toolInfoFromToolUse(tool_use, {})).toStrictEqual({
|
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
title: `Tail Logs`,
|
title: `Tail Logs`,
|
||||||
content: [],
|
content: [],
|
||||||
@@ -578,7 +441,6 @@ describe("tool conversions", () => {
|
|||||||
id: "msg_017eNosJgww7F5qD4a8BcAcx",
|
id: "msg_017eNosJgww7F5qD4a8BcAcx",
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
container: null,
|
|
||||||
model: "claude-sonnet-4-20250514",
|
model: "claude-sonnet-4-20250514",
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -613,7 +475,8 @@ describe("tool conversions", () => {
|
|||||||
activeForm: "Adding concurrent access tests",
|
activeForm: "Adding concurrent access tests",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: "Add tests for Each function with various data types",
|
content:
|
||||||
|
"Add tests for Each function with various data types",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
activeForm: "Adding Each function tests",
|
activeForm: "Adding Each function tests",
|
||||||
},
|
},
|
||||||
@@ -643,25 +506,13 @@ describe("tool conversions", () => {
|
|||||||
},
|
},
|
||||||
output_tokens: 1,
|
output_tokens: 1,
|
||||||
service_tier: "standard",
|
service_tier: "standard",
|
||||||
server_tool_use: null,
|
|
||||||
},
|
},
|
||||||
context_management: null,
|
|
||||||
},
|
},
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
|
session_id: "d056596f-e328-41e9-badd-b07122ae5227",
|
||||||
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
|
uuid: "b7c3330c-de8f-4bba-ac53-68c7f76ffeb5",
|
||||||
};
|
};
|
||||||
expect(
|
expect(toAcpNotifications(received, "test", {})).toStrictEqual([
|
||||||
toAcpNotifications(
|
|
||||||
received.message.content,
|
|
||||||
received.message.role,
|
|
||||||
"test",
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{} as AgentSideConnection,
|
|
||||||
console,
|
|
||||||
),
|
|
||||||
).toStrictEqual([
|
|
||||||
{
|
{
|
||||||
sessionId: "test",
|
sessionId: "test",
|
||||||
update: {
|
update: {
|
||||||
@@ -713,28 +564,150 @@ describe("tool conversions", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty update for successful edit result", () => {
|
it("should extract locations from edit tool results", () => {
|
||||||
const toolUse = {
|
const toolUse = {
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "toolu_01MNO345",
|
id: "toolu_01ABC123",
|
||||||
name: "mcp__acp__Edit",
|
name: "edit",
|
||||||
input: {
|
input: {
|
||||||
file_path: "/Users/test/project/test.txt",
|
abs_path: "/Users/test/project/example.txt",
|
||||||
old_string: "old",
|
old_text: "old content",
|
||||||
new_string: "new",
|
new_text: "new content",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolResult = {
|
const toolResult = {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text",
|
||||||
|
text: JSON.stringify({ lineNumbers: [5, 10, 15] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||||
|
|
||||||
|
expect(update.locations).toEqual([
|
||||||
|
{ path: "/Users/test/project/example.txt", line: 5 },
|
||||||
|
{ path: "/Users/test/project/example.txt", line: 10 },
|
||||||
|
{ path: "/Users/test/project/example.txt", line: 15 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract locations from multi-edit tool results", () => {
|
||||||
|
const toolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_01DEF456",
|
||||||
|
name: "multi-edit",
|
||||||
|
input: {
|
||||||
|
file_path: "/Users/test/project/config.json",
|
||||||
|
edits: [
|
||||||
|
{
|
||||||
|
old_string: "version: 1.0",
|
||||||
|
new_string: "version: 2.0",
|
||||||
|
replace_all: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
old_string: "enabled: false",
|
||||||
|
new_string: "enabled: true",
|
||||||
|
replace_all: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ lineNumbers: [2, 8, 12, 20] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||||
|
|
||||||
|
expect(update.locations).toEqual([
|
||||||
|
{ path: "/Users/test/project/config.json", line: 2 },
|
||||||
|
{ path: "/Users/test/project/config.json", line: 8 },
|
||||||
|
{ path: "/Users/test/project/config.json", line: 12 },
|
||||||
|
{ path: "/Users/test/project/config.json", line: 20 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mcp__acp__edit tool results", () => {
|
||||||
|
const toolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_01GHI789",
|
||||||
|
name: "mcp__acp__edit",
|
||||||
|
input: {
|
||||||
|
abs_path: "/Users/test/project/main.py",
|
||||||
|
old_text: "def hello():",
|
||||||
|
new_text: "def hello_world():",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ lineNumbers: [42] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||||
|
|
||||||
|
expect(update.locations).toEqual([
|
||||||
|
{ path: "/Users/test/project/main.py", line: 42 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty locations from edit tools", () => {
|
||||||
|
const toolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_01JKL012",
|
||||||
|
name: "edit",
|
||||||
|
input: {
|
||||||
|
abs_path: "/Users/test/project/notfound.txt",
|
||||||
|
old_text: "nonexistent",
|
||||||
|
new_text: "replacement",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({ lineNumbers: [] }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||||
|
|
||||||
|
expect(update.locations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed tool results gracefully", () => {
|
||||||
|
const toolUse = {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_01MNO345",
|
||||||
|
name: "edit",
|
||||||
|
input: {
|
||||||
|
abs_path: "/Users/test/project/test.txt",
|
||||||
|
old_text: "old",
|
||||||
|
new_text: "new",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolResult = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
text: "not valid json",
|
text: "not valid json",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tool_use_id: "test",
|
|
||||||
is_error: false,
|
|
||||||
type: "tool_result" as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||||
@@ -742,173 +715,4 @@ describe("tool conversions", () => {
|
|||||||
// Should return empty object when parsing fails
|
// Should return empty object when parsing fails
|
||||||
expect(update).toEqual({});
|
expect(update).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return content update for edit failure", () => {
|
|
||||||
const toolUse = {
|
|
||||||
type: "tool_use",
|
|
||||||
id: "toolu_01MNO345",
|
|
||||||
name: "mcp__acp__Edit",
|
|
||||||
input: {
|
|
||||||
file_path: "/Users/test/project/test.txt",
|
|
||||||
old_string: "old",
|
|
||||||
new_string: "new",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolResult = {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: "Failed to find `old_string`",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tool_use_id: "test",
|
|
||||||
is_error: true,
|
|
||||||
type: "tool_result" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
|
||||||
|
|
||||||
// Should return empty object when parsing fails
|
|
||||||
expect(update).toEqual({
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
content: { type: "text", text: "```\nFailed to find `old_string`\n```" },
|
|
||||||
type: "content",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("escape markdown", () => {
|
|
||||||
it("should escape markdown characters", () => {
|
|
||||||
let text = "Hello *world*!";
|
|
||||||
let escaped = markdownEscape(text);
|
|
||||||
expect(escaped).toEqual("```\nHello *world*!\n```");
|
|
||||||
|
|
||||||
text = "for example:\n```markdown\nHello *world*!\n```\n";
|
|
||||||
escaped = markdownEscape(text);
|
|
||||||
expect(escaped).toEqual("````\nfor example:\n```markdown\nHello *world*!\n```\n````");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("prompt conversion", () => {
|
|
||||||
it("should not change built-in slash commands", () => {
|
|
||||||
const message = promptToClaude({
|
|
||||||
sessionId: "test",
|
|
||||||
prompt: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "/compact args",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(message.message.content).toEqual([
|
|
||||||
{
|
|
||||||
text: "/compact args",
|
|
||||||
type: "text",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove MCP prefix from MCP slash commands", () => {
|
|
||||||
const message = promptToClaude({
|
|
||||||
sessionId: "test",
|
|
||||||
prompt: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "/mcp:server:name args",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(message.message.content).toEqual([
|
|
||||||
{
|
|
||||||
text: "/server:name (MCP) args",
|
|
||||||
type: "text",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
|
||||||
it("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);
|
|
||||||
|
|
||||||
it("custom session id", async () => {
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
const q = query({
|
|
||||||
prompt: "hi",
|
|
||||||
options: {
|
|
||||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
||||||
extraArgs: { "session-id": sessionId },
|
|
||||||
settingSources: ["user", "project", "local"],
|
|
||||||
includePartialMessages: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { value } = await q.next();
|
|
||||||
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("permission requests", () => {
|
|
||||||
it("should include title field in tool permission request structure", () => {
|
|
||||||
// Test various tool types to ensure title is correctly generated
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
toolUse: {
|
|
||||||
type: "tool_use" as const,
|
|
||||||
id: "test-1",
|
|
||||||
name: "Write",
|
|
||||||
input: { file_path: "/test/file.txt", content: "test" },
|
|
||||||
},
|
|
||||||
expectedTitlePart: "/test/file.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
toolUse: {
|
|
||||||
type: "tool_use" as const,
|
|
||||||
id: "test-2",
|
|
||||||
name: "Bash",
|
|
||||||
input: { command: "ls -la", description: "List files" },
|
|
||||||
},
|
|
||||||
expectedTitlePart: "`ls -la`",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
toolUse: {
|
|
||||||
type: "tool_use" as const,
|
|
||||||
id: "test-3",
|
|
||||||
name: "mcp__acp__Read",
|
|
||||||
input: { file_path: "/test/data.json" },
|
|
||||||
},
|
|
||||||
expectedTitlePart: "/test/data.json",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
// Get the tool info that would be used in requestPermission
|
|
||||||
const toolInfo = toolInfoFromToolUse(testCase.toolUse, {});
|
|
||||||
|
|
||||||
// Verify toolInfo has a title
|
|
||||||
expect(toolInfo.title).toBeDefined();
|
|
||||||
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
|
|
||||||
|
|
||||||
// Verify the structure that our fix creates for requestPermission
|
|
||||||
const requestStructure = {
|
|
||||||
toolCall: {
|
|
||||||
toolCallId: testCase.toolUse.id,
|
|
||||||
rawInput: testCase.toolUse.input,
|
|
||||||
title: toolInfo.title, // This is what commit 1785d86 adds
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure the title field is present and populated
|
|
||||||
expect(requestStructure.toolCall.title).toBeDefined();
|
|
||||||
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { extractLinesWithByteLimit } from "../utils.js";
|
|
||||||
|
|
||||||
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, 1000);
|
|
||||||
|
|
||||||
expect(result.content).toBe(simpleContent);
|
|
||||||
expect(result.wasLimited).toBe(false);
|
|
||||||
expect(result.linesRead).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
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, 250);
|
|
||||||
|
|
||||||
expect(result.wasLimited).toBe(true);
|
|
||||||
expect(result.linesRead).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty content", () => {
|
|
||||||
const result = extractLinesWithByteLimit("", 1000);
|
|
||||||
|
|
||||||
expect(result.content).toBe("");
|
|
||||||
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, 1000);
|
|
||||||
|
|
||||||
expect(result.content).toBe(singleLine);
|
|
||||||
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, 1000);
|
|
||||||
|
|
||||||
expect(result.content).toBe(unicodeContent);
|
|
||||||
expect(result.linesRead).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should stop at byte limit even with one more line available", () => {
|
|
||||||
// Create lines where adding one more would exceed limit
|
|
||||||
const line1 = "a".repeat(40);
|
|
||||||
const line2 = "b".repeat(40);
|
|
||||||
const line3 = "c".repeat(40);
|
|
||||||
const content = `${line1}\n${line2}\n${line3}`;
|
|
||||||
|
|
||||||
const result = extractLinesWithByteLimit(content, 85);
|
|
||||||
|
|
||||||
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, 17);
|
|
||||||
|
|
||||||
expect(result.content).toBe(exactContent);
|
|
||||||
expect(result.wasLimited).toBe(false);
|
|
||||||
expect(result.linesRead).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle Windows-style line endings", () => {
|
|
||||||
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
|
|
||||||
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");
|
|
||||||
expect(result.linesRead).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle very large files efficiently", () => {
|
|
||||||
// Create a 100KB file
|
|
||||||
const largeLine = "x".repeat(1000);
|
|
||||||
const largeContent = Array(110).fill(largeLine).join("\n");
|
|
||||||
|
|
||||||
const result = extractLinesWithByteLimit(largeContent, 50000);
|
|
||||||
|
|
||||||
expect(result.wasLimited).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow at least one line even if it exceeds byte limit", () => {
|
|
||||||
const veryLongLine = "x".repeat(100000); // 100KB line
|
|
||||||
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.linesRead).toBe(1);
|
|
||||||
expect(result.wasLimited).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,9 +4,13 @@ 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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ oldText: "text", newText: "replaced" },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
|
expect(result.newContent).toBe(
|
||||||
|
"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)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -16,15 +20,20 @@ describe("replaceAndCalculateLocation", () => {
|
|||||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
|
expect(result.newContent).toBe(
|
||||||
|
"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)
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return empty line numbers array when no match found", () => {
|
it("should return empty line numbers array when no match found", () => {
|
||||||
const content = "line 1\nline 2\nline 3";
|
const content = "line 1\nline 2\nline 3";
|
||||||
expect(() => {
|
const result = replaceAndCalculateLocation(content, [
|
||||||
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
|
{ oldText: "notfound", newText: "replaced" },
|
||||||
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
|
]);
|
||||||
|
|
||||||
|
expect(result.newContent).toBe(content);
|
||||||
|
expect(result.lineNumbers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle Windows line endings (CRLF)", () => {
|
it("should handle Windows line endings (CRLF)", () => {
|
||||||
@@ -45,7 +54,9 @@ describe("replaceAndCalculateLocation", () => {
|
|||||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
|
expect(result.newContent).toBe(
|
||||||
|
"line 1\rline 2 with replaced\rline 3 with replaced\rline 4",
|
||||||
|
);
|
||||||
expect(result.lineNumbers).toEqual([1, 2]);
|
expect(result.lineNumbers).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,13 +66,17 @@ describe("replaceAndCalculateLocation", () => {
|
|||||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
|
expect(result.newContent).toBe(
|
||||||
|
"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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ 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)
|
||||||
@@ -69,7 +84,9 @@ 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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ 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)
|
||||||
@@ -81,41 +98,53 @@ describe("replaceAndCalculateLocation", () => {
|
|||||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
|
expect(result.newContent).toBe(
|
||||||
|
"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", () => {
|
||||||
expect(() => {
|
const content = "";
|
||||||
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
}).toThrowError(
|
{ oldText: "text", newText: "replaced" },
|
||||||
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
|
]);
|
||||||
);
|
|
||||||
|
expect(result.newContent).toBe("");
|
||||||
|
expect(result.lineNumbers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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";
|
||||||
expect(() => {
|
const result1 = replaceAndCalculateLocation(content1, [
|
||||||
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
{ oldText: "", newText: "replaced" },
|
||||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
]);
|
||||||
|
expect(result1.newContent).toBe(content1);
|
||||||
|
expect(result1.lineNumbers).toEqual([]);
|
||||||
|
|
||||||
// Test with replaceAll true on single line
|
// Test with replaceAll true on single line
|
||||||
const content2 = "abc";
|
const content2 = "abc";
|
||||||
expect(() => {
|
const result2 = replaceAndCalculateLocation(content2, [
|
||||||
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
|
{ oldText: "", newText: "X", replaceAll: true },
|
||||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
]);
|
||||||
|
expect(result2.newContent).toBe(content2);
|
||||||
|
expect(result2.lineNumbers).toEqual([]);
|
||||||
|
|
||||||
// Test with replaceAll true on multiline content
|
// Test with replaceAll true on multiline content
|
||||||
const content3 = "ab\ncd";
|
const content3 = "ab\ncd";
|
||||||
expect(() => {
|
const result3 = replaceAndCalculateLocation(content3, [
|
||||||
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
|
{ oldText: "", newText: "X", replaceAll: true },
|
||||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
]);
|
||||||
|
expect(result3.newContent).toBe(content3);
|
||||||
|
expect(result3.lineNumbers).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ 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)
|
||||||
@@ -154,7 +183,9 @@ 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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ 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`,
|
||||||
@@ -167,7 +198,9 @@ 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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ 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");
|
||||||
@@ -191,7 +224,9 @@ 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("very very long text\nmedium line\nlong line here");
|
expect(result1.newContent).toBe(
|
||||||
|
"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
|
||||||
@@ -225,7 +260,9 @@ 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, [{ oldText: "text", newText: "replaced" }]);
|
const result = replaceAndCalculateLocation(content, [
|
||||||
|
{ 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]);
|
||||||
@@ -265,7 +302,9 @@ 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("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
|
expect(result.newContent).toBe(
|
||||||
|
"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([
|
||||||
@@ -287,7 +326,9 @@ 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("hi earth\ngreetings greetings\nearth greetings earth");
|
expect(result.newContent).toBe(
|
||||||
|
"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([
|
||||||
@@ -299,14 +340,17 @@ 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 = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
|
const content =
|
||||||
|
"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("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
|
expect(result.newContent).toBe(
|
||||||
|
"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([
|
||||||
|
|||||||
438
src/tools.ts
438
src/tools.ts
@@ -1,17 +1,10 @@
|
|||||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
import { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
|
||||||
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
|
||||||
import {
|
import {
|
||||||
BetaBashCodeExecutionToolResultBlockParam,
|
PlanEntry,
|
||||||
BetaCodeExecutionToolResultBlockParam,
|
ToolCallContent,
|
||||||
BetaRequestMCPToolResultBlockParam,
|
ToolCallLocation,
|
||||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
ToolKind,
|
||||||
BetaToolSearchToolResultBlockParam,
|
} from "@zed-industries/agent-client-protocol";
|
||||||
BetaWebFetchToolResultBlockParam,
|
|
||||||
BetaWebSearchToolResultBlockParam,
|
|
||||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
|
||||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import { Logger } from "./acp-agent.js";
|
|
||||||
|
|
||||||
interface ToolInfo {
|
interface ToolInfo {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -26,11 +19,7 @@ interface ToolUpdate {
|
|||||||
locations?: ToolCallLocation[];
|
locations?: ToolCallLocation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolInfoFromToolUse(
|
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||||
toolUse: any,
|
|
||||||
cachedFileContent: { [key: string]: string },
|
|
||||||
logger: Logger = console,
|
|
||||||
): ToolInfo {
|
|
||||||
const name = toolUse.name;
|
const name = toolUse.name;
|
||||||
const input = toolUse.input;
|
const input = toolUse.input;
|
||||||
|
|
||||||
@@ -52,15 +41,30 @@ export function toolInfoFromToolUse(
|
|||||||
|
|
||||||
case "NotebookRead":
|
case "NotebookRead":
|
||||||
return {
|
return {
|
||||||
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
|
title: input?.notebook_path
|
||||||
|
? `Read Notebook ${input.notebook_path}`
|
||||||
|
: "Read Notebook",
|
||||||
kind: "read",
|
kind: "read",
|
||||||
content: [],
|
content:
|
||||||
|
input && input.notebook_path
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "content",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: input.notebook_path.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case "NotebookEdit":
|
case "NotebookEdit":
|
||||||
return {
|
return {
|
||||||
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
|
title: input?.notebook_path
|
||||||
|
? `Edit Notebook ${input.notebook_path}`
|
||||||
|
: "Edit Notebook",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
content:
|
content:
|
||||||
input && input.new_source
|
input && input.new_source
|
||||||
@@ -75,12 +79,13 @@ export function toolInfoFromToolUse(
|
|||||||
};
|
};
|
||||||
|
|
||||||
case "Bash":
|
case "Bash":
|
||||||
case toolNames.bash:
|
|
||||||
return {
|
return {
|
||||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
title: input?.command
|
||||||
|
? "`" + input.command.replaceAll("`", "\\`") + "`"
|
||||||
|
: "Terminal",
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
content:
|
content:
|
||||||
input && input.description
|
input && input.command
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: "content",
|
type: "content",
|
||||||
@@ -91,40 +96,40 @@ export function toolInfoFromToolUse(
|
|||||||
};
|
};
|
||||||
|
|
||||||
case "BashOutput":
|
case "BashOutput":
|
||||||
case toolNames.bashOutput:
|
|
||||||
return {
|
return {
|
||||||
title: "Tail Logs",
|
title: "Tail Logs",
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
content: [],
|
content: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case "KillShell":
|
case "KillBash":
|
||||||
case toolNames.killShell:
|
|
||||||
return {
|
return {
|
||||||
title: "Kill Process",
|
title: "Kill Process",
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
content: [],
|
content: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case toolNames.read: {
|
case "mcp__acp__read": {
|
||||||
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) + ")";
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: "Read " + (input.file_path ?? "File") + limit,
|
title: "Read " + (input.abs_path ?? "File") + limit,
|
||||||
kind: "read",
|
kind: "read",
|
||||||
locations: input.file_path
|
locations: [
|
||||||
? [
|
{
|
||||||
{
|
path: input.abs_path,
|
||||||
path: input.file_path,
|
line: input.offset ?? 0,
|
||||||
line: input.offset ?? 0,
|
},
|
||||||
},
|
],
|
||||||
]
|
|
||||||
: [],
|
|
||||||
content: [],
|
content: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,78 +138,71 @@ export function toolInfoFromToolUse(
|
|||||||
return {
|
return {
|
||||||
title: "Read File",
|
title: "Read File",
|
||||||
kind: "read",
|
kind: "read",
|
||||||
content: [],
|
content:
|
||||||
locations: input.file_path
|
input && input.abs_path
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
path: input.file_path,
|
type: "content",
|
||||||
line: input.offset ?? 0,
|
content: { type: "text", text: input.abs_path.toString() },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
|
locations: [{ path: input.abs_path, line: input.offset ?? 0 }],
|
||||||
};
|
};
|
||||||
|
|
||||||
case "LS":
|
case "LS":
|
||||||
return {
|
return {
|
||||||
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
|
title: input?.path ? input.path : "Current Directory",
|
||||||
kind: "search",
|
kind: "search",
|
||||||
content: [],
|
content: [],
|
||||||
locations: [],
|
locations: input?.path ? [{ path: input.path }] : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case toolNames.edit:
|
case "mcp__acp__edit":
|
||||||
case "Edit": {
|
case "Edit":
|
||||||
const path = input?.file_path ?? input?.file_path;
|
|
||||||
let oldText = input.old_string ?? null;
|
|
||||||
let newText = input.new_string ?? "";
|
|
||||||
let affectedLines: number[] = [];
|
|
||||||
|
|
||||||
if (path && oldText) {
|
|
||||||
try {
|
|
||||||
const oldContent = cachedFileContent[path] || "";
|
|
||||||
const newContent = replaceAndCalculateLocation(oldContent, [
|
|
||||||
{
|
|
||||||
oldText,
|
|
||||||
newText,
|
|
||||||
replaceAll: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
oldText = oldContent;
|
|
||||||
newText = newContent.newContent;
|
|
||||||
affectedLines = newContent.lineNumbers;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
title: path ? `Edit \`${path}\`` : "Edit",
|
title: input?.abs_path ? `Edit ${input.abs_path}` : "Edit",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
content:
|
content:
|
||||||
input && path
|
input && input.abs_path
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: "diff",
|
type: "diff",
|
||||||
path,
|
path: input.abs_path,
|
||||||
oldText,
|
oldText: input.old_text || null,
|
||||||
newText,
|
newText: input.new_text,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
locations: path
|
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
|
||||||
? affectedLines.length > 0
|
|
||||||
? affectedLines.map((line) => ({ line, path }))
|
|
||||||
: [{ path }]
|
|
||||||
: [],
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
case toolNames.write: {
|
case "mcp__acp__multi-edit":
|
||||||
|
case "MultiEdit":
|
||||||
|
// Display it as a normal edit, because end users don't care about
|
||||||
|
// the distinction between edits and multi-edits.
|
||||||
|
return {
|
||||||
|
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||||
|
kind: "edit",
|
||||||
|
content:
|
||||||
|
input && input.edits && input.edits.length > 0
|
||||||
|
? input.edits.map((edit: any) => ({
|
||||||
|
type: "diff" as const,
|
||||||
|
path: input.file_path,
|
||||||
|
oldText: edit.old_string || null,
|
||||||
|
newText: edit.new_string,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "mcp__acp__write":
|
||||||
let content: ToolCallContent[] = [];
|
let content: ToolCallContent[] = [];
|
||||||
if (input && input.file_path) {
|
if (input && input.abs_path) {
|
||||||
content = [
|
content = [
|
||||||
{
|
{
|
||||||
type: "diff",
|
type: "diff",
|
||||||
path: input.file_path,
|
path: input.abs_path,
|
||||||
oldText: null,
|
oldText: null,
|
||||||
newText: input.content,
|
newText: input.content,
|
||||||
},
|
},
|
||||||
@@ -218,38 +216,37 @@ export function toolInfoFromToolUse(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
content,
|
content,
|
||||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
case "Write":
|
case "Write":
|
||||||
return {
|
return {
|
||||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
title: input?.abs_path ? `Write ${input.abs_path}` : "Write",
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
content:
|
content:
|
||||||
input && input.file_path
|
input && input.abs_path
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: "diff",
|
type: "diff",
|
||||||
path: input.file_path,
|
path: input.abs_path,
|
||||||
oldText: null,
|
oldText: null,
|
||||||
newText: input.content,
|
newText: input.content,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
locations: input?.abs_path ? [{ path: input.abs_path }] : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case "Glob": {
|
case "Glob": {
|
||||||
let label = "Find";
|
let label = "Find";
|
||||||
if (input.path) {
|
if (input.path) {
|
||||||
label += ` \`${input.path}\``;
|
label += ` ${input.path}`;
|
||||||
}
|
}
|
||||||
if (input.pattern) {
|
if (input.pattern) {
|
||||||
label += ` \`${input.pattern}\``;
|
label += ` ${input.pattern}`;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: label,
|
title: label,
|
||||||
@@ -288,7 +285,6 @@ export function toolInfoFromToolUse(
|
|||||||
label += " -c";
|
label += " -c";
|
||||||
break;
|
break;
|
||||||
case "Content":
|
case "Content":
|
||||||
default:
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,30 +353,24 @@ export function toolInfoFromToolUse(
|
|||||||
|
|
||||||
case "TodoWrite":
|
case "TodoWrite":
|
||||||
return {
|
return {
|
||||||
title: Array.isArray(input?.todos)
|
title: input?.todos
|
||||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||||
: "Update TODOs",
|
: "Update TODOs",
|
||||||
kind: "think",
|
kind: "think",
|
||||||
content: [],
|
content: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case "ExitPlanMode":
|
case "exit_plan_mode":
|
||||||
return {
|
return {
|
||||||
title: "Ready to code?",
|
title: "Exit Plan Mode",
|
||||||
kind: "switch_mode",
|
kind: "think",
|
||||||
content:
|
content:
|
||||||
input && input.plan
|
input && input.plan
|
||||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
case "Other": {
|
case "Other":
|
||||||
let output;
|
|
||||||
try {
|
|
||||||
output = JSON.stringify(input, null, 2);
|
|
||||||
} catch {
|
|
||||||
output = typeof input === "string" ? input : "{}";
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
title: name || "Unknown Tool",
|
title: name || "Unknown Tool",
|
||||||
kind: "other",
|
kind: "other",
|
||||||
@@ -389,12 +379,11 @@ export function toolInfoFromToolUse(
|
|||||||
type: "content",
|
type: "content",
|
||||||
content: {
|
content: {
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `\`\`\`json\n${output}\`\`\``,
|
text: `\`\`\`json\n${JSON.stringify(input, null, 2) || "{}"}\`\`\``,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@@ -406,74 +395,63 @@ export function toolInfoFromToolUse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toolUpdateFromToolResult(
|
export function toolUpdateFromToolResult(
|
||||||
toolResult:
|
toolResult: any,
|
||||||
| ToolResultBlockParam
|
|
||||||
| BetaWebSearchToolResultBlockParam
|
|
||||||
| BetaWebFetchToolResultBlockParam
|
|
||||||
| WebSearchToolResultBlockParam
|
|
||||||
| BetaCodeExecutionToolResultBlockParam
|
|
||||||
| BetaBashCodeExecutionToolResultBlockParam
|
|
||||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
|
||||||
| BetaRequestMCPToolResultBlockParam
|
|
||||||
| BetaToolSearchToolResultBlockParam,
|
|
||||||
toolUse: any | undefined,
|
toolUse: any | undefined,
|
||||||
): ToolUpdate {
|
): ToolUpdate {
|
||||||
switch (toolUse?.name) {
|
switch (toolUse?.name) {
|
||||||
case "Read":
|
case "mcp__acp__edit":
|
||||||
case toolNames.read:
|
|
||||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
|
||||||
return {
|
|
||||||
content: toolResult.content.map((content: any) => ({
|
|
||||||
type: "content",
|
|
||||||
content:
|
|
||||||
content.type === "text"
|
|
||||||
? {
|
|
||||||
type: "text",
|
|
||||||
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
|
|
||||||
}
|
|
||||||
: content,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "content",
|
|
||||||
content: {
|
|
||||||
type: "text",
|
|
||||||
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
|
|
||||||
case toolNames.bash:
|
|
||||||
case "edit":
|
case "edit":
|
||||||
case "Edit":
|
case "Edit": {
|
||||||
case toolNames.edit:
|
// Parse the line numbers from the tool result
|
||||||
case toolNames.write:
|
if (toolResult.content && toolResult.content.length > 0) {
|
||||||
case "Write": {
|
try {
|
||||||
if (
|
const firstContent = toolResult.content[0];
|
||||||
"is_error" in toolResult &&
|
const data = JSON.parse(firstContent.text || firstContent);
|
||||||
toolResult.is_error &&
|
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
|
||||||
toolResult.content &&
|
const locations: ToolCallLocation[] = data.lineNumbers.map(
|
||||||
toolResult.content.length > 0
|
(line: number) => ({
|
||||||
) {
|
path: toolUse?.input?.abs_path || toolUse?.input?.file_path,
|
||||||
// Only return errors
|
line: line,
|
||||||
return toAcpContentUpdate(toolResult.content, true);
|
}),
|
||||||
|
);
|
||||||
|
return { locations };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, return empty object
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
case "mcp__acp__multi-edit":
|
||||||
case "ExitPlanMode": {
|
case "multi-edit":
|
||||||
return { title: "Exited Plan Mode" };
|
case "MultiEdit": {
|
||||||
|
// Parse the line numbers from the tool result
|
||||||
|
if (toolResult.content && toolResult.content.length > 0) {
|
||||||
|
try {
|
||||||
|
const firstContent = toolResult.content[0];
|
||||||
|
const data = JSON.parse(firstContent.text || firstContent);
|
||||||
|
if (data.lineNumbers && Array.isArray(data.lineNumbers)) {
|
||||||
|
const locations: ToolCallLocation[] = data.lineNumbers.map(
|
||||||
|
(line: number) => ({
|
||||||
|
path: toolUse?.input?.file_path || toolUse?.input?.abs_path,
|
||||||
|
line: line,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { locations };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, return empty object
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Task":
|
case "Task":
|
||||||
case "NotebookEdit":
|
case "NotebookEdit":
|
||||||
case "NotebookRead":
|
case "NotebookRead":
|
||||||
|
case "mcp__acp__write":
|
||||||
|
case "Write":
|
||||||
case "TodoWrite":
|
case "TodoWrite":
|
||||||
case "exit_plan_mode":
|
case "exit_plan_mode":
|
||||||
case "Bash":
|
case "Bash":
|
||||||
@@ -485,49 +463,43 @@ export function toolUpdateFromToolResult(
|
|||||||
case "WebFetch":
|
case "WebFetch":
|
||||||
case "WebSearch":
|
case "WebSearch":
|
||||||
case "Other":
|
case "Other":
|
||||||
|
case "mcp__acp__read":
|
||||||
|
if (Array.isArray(toolResult.content)) {
|
||||||
|
return {
|
||||||
|
content: toolResult.content.map((content: any) => ({
|
||||||
|
type: "content",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: `\`\`\`${toolUse.input?.abs_path ?? ""}\n${content.text ?? ""}\n\`\`\``,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
default: {
|
default: {
|
||||||
return toAcpContentUpdate(
|
if (Array.isArray(toolResult.content)) {
|
||||||
toolResult.content,
|
return {
|
||||||
"is_error" in toolResult ? toolResult.is_error : false,
|
content: toolResult.content.map((content: any) => ({
|
||||||
);
|
type: "content",
|
||||||
|
content,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else if (typeof toolResult.content === "string") {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "content",
|
||||||
|
content: { type: "text", text: toolResult.content },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAcpContentUpdate(
|
type ClaudePlanEntry = {
|
||||||
content: any,
|
|
||||||
isError: boolean = false,
|
|
||||||
): { content?: ToolCallContent[] } {
|
|
||||||
if (Array.isArray(content) && content.length > 0) {
|
|
||||||
return {
|
|
||||||
content: content.map((content: any) => ({
|
|
||||||
type: "content",
|
|
||||||
content:
|
|
||||||
isError && content.type === "text"
|
|
||||||
? {
|
|
||||||
...content,
|
|
||||||
text: `\`\`\`\n${content.text}\n\`\`\``,
|
|
||||||
}
|
|
||||||
: content,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} else if (typeof content === "string" && content.length > 0) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "content",
|
|
||||||
content: {
|
|
||||||
type: "text",
|
|
||||||
text: isError ? `\`\`\`\n${content}\n\`\`\`` : content,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClaudePlanEntry = {
|
|
||||||
content: string;
|
content: string;
|
||||||
status: "pending" | "in_progress" | "completed";
|
status: "pending" | "in_progress" | "completed";
|
||||||
activeForm: string;
|
activeForm: string;
|
||||||
@@ -540,59 +512,3 @@ export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
|
|||||||
priority: "medium",
|
priority: "medium",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markdownEscape(text: string): string {
|
|
||||||
let escape = "```";
|
|
||||||
for (const [m] of text.matchAll(/^```+/gm)) {
|
|
||||||
while (m.length >= escape.length) {
|
|
||||||
escape += "`";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
|
||||||
const toolUseCallbacks: {
|
|
||||||
[toolUseId: string]: {
|
|
||||||
onPostToolUseHook?: (
|
|
||||||
toolUseID: string,
|
|
||||||
toolInput: unknown,
|
|
||||||
toolResponse: unknown,
|
|
||||||
) => Promise<void>;
|
|
||||||
};
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
/* Setup callbacks that will be called when receiving hooks from Claude Code */
|
|
||||||
export const registerHookCallback = (
|
|
||||||
toolUseID: string,
|
|
||||||
{
|
|
||||||
onPostToolUseHook,
|
|
||||||
}: {
|
|
||||||
onPostToolUseHook?: (
|
|
||||||
toolUseID: string,
|
|
||||||
toolInput: unknown,
|
|
||||||
toolResponse: unknown,
|
|
||||||
) => Promise<void>;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
toolUseCallbacks[toolUseID] = {
|
|
||||||
onPostToolUseHook,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
|
||||||
export const createPostToolUseHook =
|
|
||||||
(logger: Logger = console): HookCallback =>
|
|
||||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
|
||||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
|
||||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
|
||||||
if (onPostToolUseHook) {
|
|
||||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
|
||||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
|
||||||
} else {
|
|
||||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
|
||||||
delete toolUseCallbacks[toolUseID];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { continue: true };
|
|
||||||
};
|
|
||||||
|
|||||||
136
src/utils.ts
136
src/utils.ts
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
import { Readable, Writable } from "node:stream";
|
import { Readable, Writable } from "node:stream";
|
||||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { platform } from "node:os";
|
|
||||||
import { Logger } from "./acp-agent.js";
|
|
||||||
|
|
||||||
// Useful for bridging push-based and async-iterator-based code.
|
// Useful for bridging push-based and async-iterator-based code.
|
||||||
export class Pushable<T> implements AsyncIterable<T> {
|
export class Pushable<T> implements AsyncIterable<T> {
|
||||||
@@ -48,23 +45,24 @@ 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(nodeStream: Writable): WritableStream<Uint8Array> {
|
export function nodeToWebWritable(
|
||||||
|
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) {
|
if (err) reject(err);
|
||||||
reject(err);
|
else resolve();
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
|
export function nodeToWebReadable(
|
||||||
|
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) => {
|
||||||
@@ -76,120 +74,6 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unreachable(value: never, logger: Logger = console) {
|
export function unreachable(value: never): never {
|
||||||
let valueAsString;
|
throw new Error(`Unexpected case: ${value}`);
|
||||||
try {
|
|
||||||
valueAsString = JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
valueAsString = value;
|
|
||||||
}
|
|
||||||
logger.error(`Unexpected case: ${valueAsString}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sleep(time: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, time));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ManagedSettings {
|
|
||||||
permissions?: {
|
|
||||||
allow?: string[];
|
|
||||||
deny?: string[];
|
|
||||||
};
|
|
||||||
env?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
|
|
||||||
// This can be removed once the SDK supports it natively.
|
|
||||||
function getManagedSettingsPath(): string {
|
|
||||||
const os = platform();
|
|
||||||
switch (os) {
|
|
||||||
case "darwin":
|
|
||||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
||||||
case "linux": // including WSL
|
|
||||||
return "/etc/claude-code/managed-settings.json";
|
|
||||||
case "win32":
|
|
||||||
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
|
|
||||||
default:
|
|
||||||
return "/etc/claude-code/managed-settings.json";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadManagedSettings(): ManagedSettings | null {
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyEnvironmentSettings(settings: ManagedSettings): void {
|
|
||||||
if (settings.env) {
|
|
||||||
for (const [key, value] of Object.entries(settings.env)) {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtractLinesResult {
|
|
||||||
content: string;
|
|
||||||
wasLimited: boolean;
|
|
||||||
linesRead: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts lines from file content with byte limit enforcement.
|
|
||||||
*
|
|
||||||
* @param fullContent - The complete file content
|
|
||||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
|
||||||
* @returns Object containing extracted content and metadata
|
|
||||||
*/
|
|
||||||
export function extractLinesWithByteLimit(
|
|
||||||
fullContent: string,
|
|
||||||
maxContentLength: number,
|
|
||||||
): ExtractLinesResult {
|
|
||||||
if (fullContent === "") {
|
|
||||||
return {
|
|
||||||
content: "",
|
|
||||||
wasLimited: false,
|
|
||||||
linesRead: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let linesSeen = 0;
|
|
||||||
let index = 0;
|
|
||||||
linesSeen = 0;
|
|
||||||
|
|
||||||
let contentLength = 0;
|
|
||||||
let wasLimited = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const nextIndex = fullContent.indexOf("\n", index);
|
|
||||||
|
|
||||||
if (nextIndex < 0) {
|
|
||||||
// Last line in file (no trailing newline)
|
|
||||||
if (linesSeen > 0 && fullContent.length > maxContentLength) {
|
|
||||||
wasLimited = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
linesSeen += 1;
|
|
||||||
contentLength = fullContent.length;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
// Line with newline - include up to the newline
|
|
||||||
const newContentLength = nextIndex + 1;
|
|
||||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
|
||||||
wasLimited = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
linesSeen += 1;
|
|
||||||
contentLength = newContentLength;
|
|
||||||
index = newContentLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: fullContent.slice(0, contentLength),
|
|
||||||
wasLimited,
|
|
||||||
linesRead: linesSeen,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user