Compare commits

...

53 Commits

Author SHA1 Message Date
Ben Brandt
e8545266fe Prep 0.22.1 release (#438) 2026-03-17 21:58:33 +00:00
Aleksandr Suhinin
ff3b97aece fix: suppress account assertion if gateway authentication is used (#436) 2026-03-17 17:51:02 +01:00
Steffen Deusch
c40d7bc30f fix session close method name (#431)
that's the nice part about unit tests, all green and still broken...

Relates to https://github.com/zed-industries/claude-agent-acp/pull/409.
2026-03-16 19:06:55 +01:00
Ben Brandt
152fe91152 Prep 0.22.0 release (#430) 2026-03-16 15:48:04 +00:00
Ben Brandt
45de3d2847 Use stable list sessions method (#429) 2026-03-16 15:41:27 +00:00
Ben Brandt
c7ae07da6b Use correct Claude CLI path for static binaries (#428)
Closes #424
2026-03-16 15:30:24 +00:00
Ben Brandt
0af467c400 Update claude-agent-sdk to 0.2.76 (#427)
Closes #420
2026-03-16 15:18:51 +00:00
mudrii
f4ce899223 fix: resolve model aliases in setSessionConfigOption (#401) (#403)
## Summary
Fixes #401 by enabling model alias/fuzzy resolution in
`setSessionConfigOption` when updating the `model` config option.

## Root cause
`setSessionConfigOption` validated model values using an exact string
match against config option values (model IDs like `claude-opus-4-5`):

```ts
const validValue = allValues.find((o) => o.value === params.value);
```

Clients often send human-friendly model preferences (`opus`, `sonnet`,
`Claude Sonnet`), which failed this exact check and produced:

- `Invalid value for config option model: <value>`

The file already had `resolveModelPreference(...)`, which supports
exact/display/substring/tokenized matching, but that logic was only used
in model initialization paths and not in `setSessionConfigOption`.

## What changed
### 1) Fuzzy model resolution in `setSessionConfigOption`
- Kept the existing exact-match validation path.
- When `configId === "model"` and exact match fails, map config option
entries to `ModelInfo[]` (`value` + `displayName`) and call
`resolveModelPreference(...)`.
- If resolution succeeds, use the resolved canonical model ID
(`resolvedValue`) for:
  - `query.setModel(...)`
  - `session.configOptions` current value update
  - response payload (`configOptions`)

### 2) Notification behavior unchanged by design
- Intentionally **did not** add `updateConfigOption(...)` in
`setSessionConfigOption`.
- This preserves existing behavior/tests that verify no
`config_option_update` notification is sent for this RPC (response
already returns full `configOptions`).

## Tests
Added focused coverage in `src/tests/session-config-options.test.ts`
under `describe("setSessionConfigOption")`:
- resolves model alias `opus` -> `claude-opus-4-5`
- resolves model alias `sonnet` -> `claude-sonnet-4-5`
- resolves display name `Claude Sonnet` -> `claude-sonnet-4-5`
- still accepts exact model ID
- still throws for invalid model value (`gpt-4`)

## Behavior before / after
### Before
- `setSessionConfigOption({ configId: "model", value: "opus" })` ->
error

### After
- same call resolves to canonical ID and succeeds:
  - `setModel("claude-opus-4-5")`
  - returned `configOptions.model.currentValue === "claude-opus-4-5"`

## Verification
- `npm run build` ✅
- `npm test` ✅ (all tests passing)
2026-03-16 16:05:01 +01:00
Ben Brandt
31539b4ee9 Reuse existing sessions for load/resume if possible (#426)
Closes #415

Add `getOrCreateSession` so resume/load returns existing session config
without recreating the session. Replace ad-hoc permission tracking with
`modes.currentModeId` and centralize config updates through
`syncSessionConfigState` to keep `modes` and `models` in sync with
config option changes. Update tests for the new session shape.
2026-03-16 15:59:45 +01:00
Ben Brandt
61a39ea801 Remove interrupt flag from deny responses (#425)
Now that the models are doing more parallel tool calls, we don't
necessarily want to interrupt in all of these cases.

Closes #402
2026-03-16 14:04:38 +00:00
Shivansh
c0b7752b45 Allow Bypass permissions mode after Exiting plan (#410)
Adds a new `ExitPlanMode` choice so users can switch directly to
`bypassPermissions` after accepting a plan, instead of only
`acceptEdits` or `default`.

### Changes
- Updated the `ExitPlanMode` permission options in `src/acp-agent.ts` to
conditionally include:
- `Yes, and bypass permissions` (`optionId: "bypassPermissions"`)
- Reused existing `ALLOW_BYPASS` guard so the option is only shown when
bypass is supported.
- Updated selection handling so `bypassPermissions` is treated as a
valid accepted outcome and applies mode updates like the existing paths.
2026-03-13 16:01:46 +00:00
Ben Brandt
3002539e4a Fix test (#416) 2026-03-13 15:58:18 +00:00
Steffen Deusch
87a0886a34 Don't get out of sync when background task creates new init/result (try 2) (#400)
Followup for
https://github.com/zed-industries/claude-agent-acp/pull/386.

When sending `/compact`, there's no replay for the user message, so we
did not resolve the prompt. Same for `/context`.

I tried what happens when sending a `/context` or `/compact` while
another prompt is running and Claude does not properly handle it.
Instead, it responds with something like

> I also noticed you sent /compact while I was working. That's a
built-in CLI command (not a skill), which compacts the conversation
context. It should be invoked directly in the CLI rather than as a
message to me.

The important part is
f52f214937.
2026-03-13 16:55:42 +01:00
Steffen Deusch
2193a13a3b Add session/close support (#409)
~Waiting for
https://github.com/agentclientprotocol/agent-client-protocol/pull/723.~

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-13 16:53:22 +01:00
Ben Brandt
c13edf3b02 Prep 0.21.0 release (#399) 2026-03-09 15:10:34 +00:00
Ben Brandt
a217891e6c Revert "Don't get out of sync when background task creates new init/result (#386)" (#398) 2026-03-09 16:07:23 +01:00
Ben Brandt
867de85f0b Revert "Don't get out of sync when background task creates new init/result (#386)" (#398) 2026-03-09 15:05:30 +00:00
Ben Brandt
af11cdbba5 Update to claude-agent-sdk 0.2.71 (#397) 2026-03-09 15:00:58 +00:00
Cengiz Han
f7af07758c feat: pass through tools array to control built-in tool availability (#387)
## Summary

- Pass through `_meta.claudeCode.options.tools` to the Claude Agent SDK
instead of hard-coding `{ type: "preset", preset: "claude_code" }`
- Reimplement `disableBuiltInTools` as `tools: []` instead of pushing 14
tool names into `disallowedTools`
- Explicit `tools` array takes precedence over `disableBuiltInTools`

## Problem

The ACP adapter hard-coded `tools: { type: "preset", preset:
"claude_code" }`, ignoring any value the caller provided via
`_meta.claudeCode.options.tools`. This caused two problems:

1. **Wasted context tokens** — All ~15 tool schemas were always sent to
the model, even when the caller only needed a subset (e.g., Read and
Glob for a review-only workflow) or none at all (MCP-only sessions).

2. **Rejected tool call round trips** — When `disableBuiltInTools: true`
was used, the tools were still registered in the model's context (the
flag only added them to `disallowedTools`). The model would see the tool
schemas, attempt to call them, get rejected, and waste a round trip.

Additionally, the existing `disableBuiltInTools` flag maintained a
hard-coded list of 14 tool names to blocklist. If the SDK added a new
built-in tool, `disableBuiltInTools` wouldn't cover it — the new tool
would leak through.

## SDK Contract Validation

The `tools` option is documented in the [Claude Agent SDK TypeScript
reference](https://platform.claude.com/docs/en/agent-sdk/typescript) as:

```typescript
tools?: string[] | { type: 'preset'; preset: 'claude_code' };
```

> Tool configuration. Pass an array of tool names or use the preset to
get Claude Code's default tools.

The type `string[]` includes `[]` (empty array) with no minimum length
constraint. Passing an empty array to disable all built-in tools is a
valid use of the SDK contract, not a workaround.

## Changes

### `src/acp-agent.ts`

**One line of behavioral change** — the `tools` field now uses the
caller-provided value if present, falling back to the preset:

```typescript
tools: userProvidedOptions?.tools ?? { type: "preset", preset: "claude_code" },
```

**`disableBuiltInTools` reimplemented** — instead of pushing 14 tool
names into `disallowedTools`, it resolves to `tools: []`:

```typescript
const resolvedTools: Options["tools"] =
  userProvidedOptions?.tools ??
  (params._meta?.disableBuiltInTools === true
    ? []
    : { type: "preset", preset: "claude_code" });
```

Resolution priority:
```
_meta.claudeCode.options.tools  →  use it (explicit whitelist)
_meta.disableBuiltInTools: true →  tools: [] (legacy shorthand)
neither                         →  { type: "preset", preset: "claude_code" } (default)
```

### `src/tests/create-session-options.test.ts`

4 new tests + 1 updated test:

| Test | Verifies |
|------|----------|
| `defaults tools to claude_code preset when not provided` | Backward
compatibility — no `tools` = full preset |
| `passes through user-provided tools string array` | Whitelist:
`["Read", "Glob"]` arrives as-is |
| `passes through empty tools array to disable all built-in tools` |
`tools: []` disables everything |
| `explicit tools array takes precedence over disableBuiltInTools` |
Resolution priority: explicit `tools` wins over `disableBuiltInTools` |
| `sets tools to empty array when disableBuiltInTools is true` | Legacy
flag now produces `tools: []` instead of blocklist |

## Usage Examples

```typescript
// Review-only session: model can only read files
_meta: { claudeCode: { options: { tools: ["Read", "Glob"] } } }

// MCP-only session: no built-in tools
_meta: { claudeCode: { options: { tools: [] } } }

// Legacy: still works, now equivalent to tools: []
_meta: { disableBuiltInTools: true }
```

## Non-breaking

Callers that don't provide `tools` get identical behavior (full
`claude_code` preset). `disableBuiltInTools: true` still works but is
now a less flexible alias for `tools: []`.

## Test plan

- [x] All existing tests pass (`npm test` — 131 tests, 126 passed, 5
skipped)
- [x] New tests cover: default preset, explicit array, empty array,
precedence over `disableBuiltInTools`
- [x] Verified `tools` type against [official SDK
docs](https://platform.claude.com/docs/en/agent-sdk/typescript)

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-09 13:31:48 +00:00
Steffen Deusch
3524606555 Don't get out of sync when background task creates new init/result (#386)
Relates to #336.

This does NOT ensure that the results are immediately sent to the
client.

See
https://github.com/zed-industries/claude-agent-acp/issues/336#issuecomment-4011939471.

I guess some tests for this would be useful.
2026-03-09 14:19:49 +01:00
Rohan Patra
9ecb222807 feat: show project-relative paths in tool call titles (#393)
## Summary
- Tool call titles (Read, Write, Edit) currently display absolute file
paths like `/Users/jane/my-project/src/main.ts`, which are long and
redundant since the user already knows their project root.
- This PR converts those paths to project-relative display paths (e.g.,
`src/main.ts`) by comparing against the session's `cwd`. Paths outside
the project directory remain absolute.
- The `locations.path` field is **not** changed, it stays absolute for
Zed's file navigation.

## Test plan
- [x] Added unit tests for `toDisplayPath` (relativizes paths inside
cwd, keeps absolute paths outside, handles partial directory name
matches)
- [x] Added unit test for `toolInfoFromToolUse` with `cwd` parameter
(verifies title uses relative path, locations stay absolute)
- [x] All 124 existing tests pass
2026-03-09 14:13:08 +01:00
Steffen Deusch
dd9fe3a98e skip user replay (#385)
Closes #381.
Relates to #348.

In #348 I added replay to handle queued prompts.
In Tidewave, we ignore user_message_chunks, so I did not experience the
issue when testing. But clients that show the user_message_chunk like
Zed showed the replayed content. This commit ensures that replayed
messages are skipped.
2026-03-06 14:38:31 +01:00
Rohan Patra
1c641cd6a7 fix: handle renamed Agent tool in toolInfoFromToolUse (#384)
## Summary
- The Claude SDK renamed the `Task` tool to `Agent`, but
`toolInfoFromToolUse` only matched `case "Task":`, so the `Agent` tool
fell through to the `default` case — rendering as a bare "Agent" label
with no description or content in the chat UI.
- Adds `case "Agent":` as a fallthrough before `case "Task":` so both
names are handled identically.

## Test plan
- [ ] Verify that when Claude spawns a subagent, the tool call in Zed's
chat shows the agent's description as the title (not a bare "Agent"
label)
2026-03-06 07:23:07 +00:00
Ben Brandt
a02ef184d3 Put back gateway auth check (#380) 2026-03-04 21:28:34 +00:00
Ben Brandt
3beddee766 Update to claude-agent-sdk 0.2.68 (#379) 2026-03-04 21:26:24 +00:00
José O R Ferreira
52d5b74aa7 fix: inherit process.env when spawning agent subprocess (#374)
In 0.20.0, the env option was introduced with an explicit object spread,
but without including process.env. This caused the subprocess to receive
an empty environment, stripping HOME, PATH and other inherited variables
when spawned from an external editor such as Zed.

Add ...process.env as the base so user-provided and gateway env vars
still override it, while preserving the parent process environment.

Add tests to verify HOME/PATH are inherited and that user-provided vars
are merged on top correctly.

Fixes #373

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-04 07:05:49 +01:00
Aleksandr Suhinin
edf6486c44 Authentication adjustments for JB integration (#350)
Updated authentication methods for IDEA compatibility and SDK compliance

#### New authentication type: gateway (url + custom headers)
 
- Required to support native IDEA authentication flows
 
#### Process argument to hide Claude Code authentication

- Ensures compliance with Anthropic’s third-party authentication
policies.

https://platform.claude.com/docs/en/agent-sdk/overview

> Unless previously approved, Anthropic does not allow third party
developers to offer claude.ai login or rate limits for their products,
including agents built on the Claude Agent SDK. Please use the API key
authentication methods described in this document instead.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 20:29:44 +01:00
Ben Brandt
232a8cd8e2 Only store session on successful setup (#372) 2026-03-03 15:10:59 +00:00
Ben Brandt
37e3f389f6 prep release 0.20.0 (#371)
- **Remove unnecessary static-entry file**
- **Prep 0.20 release**
2026-03-03 15:49:37 +01:00
Ben Brandt
f513a14a46 Support more notifications (#370)
- **Give notification of compaction**
- **Also support local command output**
2026-03-03 14:16:43 +00:00
Kunall Banerjee
c0e2cee265 Surface better error message when Claude Code process exits unexpectedly (#366)
As seen in https://github.com/zed-industries/zed/issues/39563, Claude
Agent child processes can die mid-session (crash, signal, OOM, et al).
When that happens, the SDK throws errors with internal messages like
`"ProcessTransport closed"`, `"Failed to write to process stdin"`, or
`"process terminated by signal"`. These would bubble up to the user
as-is, which is not helpful.

At this point, it’s [not possible to continue the
session](https://github.com/zed-industries/zed/issues/39563#issuecomment-3776814727)
either (which I also confirmed myself), but that is not obvious from the
error the user sees (screenshot on the left).

In my testing, I could not get the `Error: ProcessTransport is not ready
for writing` error, but you can easily trigger the `SIGTERM` (exit code
143) by simply killing the `claude` child process spawned by the ACP
adapter while the agent is streaming a response:

<img width="1621" height="54" alt="image"
src="https://github.com/user-attachments/assets/29d989f1-7e56-44b3-992d-065218444958"
/>

In this case, you’d do `kill 74573` to trigger the error.

AFAICT, the child process is managed internally by the Claude Agent SDK
(inside the `query` object returned by `query()`). The ACP agent only
sees it as an `AsyncGenerator`. There’s no `process.pid` or
`ChildProcess` handle exposed to check liveness against. The only way to
discover the process is dead is when the SDK throws one of these
transport-level errors*.

\*I got these error strings by asking Opus 4.6 to de-obfuscate
(`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`)

| Before | After |
|--------|--------|
| <img width="466" height="62" alt="image"
src="https://github.com/user-attachments/assets/dc959924-0929-4587-9ddd-4a3873f1fa5d"
/> | <img width="462" height="77" alt="image"
src="https://github.com/user-attachments/assets/77eff880-28da-489a-b9e3-e9f93abf3c7f"
/> |

In other words, I think the best we (the ACP adapter) can do here is to
grep for these transport errors and surface a _better_ error message to
the user, as seen above.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 13:54:51 +01:00
Anurag Akkiraju
b4f56b17fc fix: throw resourceNotFound when loadSession fails to resume (#363)
## Summary

- When `loadSession` calls `createSession({ resume: sessionId })` and
the CLI subprocess can't find persisted session data (no `.jsonl` file),
the subprocess exits immediately and `createSession` rejects with a
generic `-32603` ("Query closed before response received")
- ACP clients like [acpx](https://github.com/openclaw/acpx) check for
`-32001`/`-32002` (resource not found) to fall back to `session/new` —
the generic `-32603` bypasses this, causing the turn to fail entirely
- This commonly happens when `session/new` creates an ACP session but
the subprocess exits before writing session data (no prompt to process),
then `session/prompt` tries `session/load` to resume a session that was
never persisted

## Fix

Wrap the `createSession({ resume })` call in `loadSession()` with
try/catch and re-throw as `RequestError.resourceNotFound()` (code
`-32002`). This lets callers handle it with their existing fallback
logic.

## Verification

Confirmed working in production with the
[OpenClaw](https://github.com/openclaw/openclaw) gateway + acpx
integration. Before the fix, all ACP tasks failed silently. After
patching both this code path and acpx's `shouldFallbackToNewSession()`
([openclaw/acpx#30](https://github.com/openclaw/acpx/pull/30)), sessions
fall back to `session/new` correctly.

Fixes #338
Closes #361
Closes #362

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 13:47:25 +01:00
Anurag Akkiraju
c2e45e638d fix: add missing zod dependency (#364)
## Summary

- `@agentclientprotocol/sdk@0.14.1` declares `zod` as a peer dependency
(`"^3.25.0 || ^4.0.0"`) but this package does not include it as a direct
dependency
- On clean installs (e.g. Zed's external agent directory), `zod` is not
hoisted from any other package, causing `ERR_MODULE_NOT_FOUND` on every
agent start
- Users must manually `npm install zod` after every update as a
workaround

## Fix

Add `"zod": "^3.25.0 || ^4.0.0"` to `dependencies` to match the SDK's
peer dependency range.

Fixes #357

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 12:01:03 +00:00
Luis
5c1863d56e Fix: propagate max_tokens stop reason instead of throwing internal error (#365)
## Summary

- When the Claude SDK reports `stop_reason: "max_tokens"`, the ACP
adapter now returns `{ stopReason: "max_tokens" }` instead of throwing a
`RequestError.internalError` (JSON-RPC `-32603`)
- Scoped to `success` and `error_during_execution` result subtypes only
— `error_max_turns` / `error_max_budget_usd` still correctly map to
`max_turn_requests`

## Problem

When tokens are exhausted, the SDK sends a `result` message with
`stop_reason: "max_tokens"` and `is_error: true`. The adapter hit the
`is_error` check first and threw an internal error, so ACP clients never
received the `stopReason` field. Downstream tools like
[toad](https://github.com/batrachianai/toad/discussions/176#discussioncomment-15445958)
saw a JSON-RPC exception instead of a clean stop reason.

## Fix

Check `stop_reason === "max_tokens"` before the `is_error` throw in the
`success` and `error_during_execution` branches. Token exhaustion is a
normal stop condition, not an internal error.

## Test plan

- [x] Added 5 unit tests covering:
  - `success` + `max_tokens` + `is_error: false` → returns `max_tokens`
- `success` + `max_tokens` + `is_error: true` → returns `max_tokens`
(main bug)
- `error_during_execution` + `max_tokens` + `is_error: true` → returns
`max_tokens`
- `success` + `null` stop_reason → still returns `end_turn` (no
regression)
- `success` + `is_error: true` + non-max_tokens → still throws (no
regression)
- [x] All 100 tests pass
- [x] Build passes

Fixes #235

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:58:40 +00:00
Tim Visher
6253b1611e Respect user settings for permission mode and model selection (#340)
Most user settings (`env`, permission rules like `allow`/`deny`/`ask`)
already work through the SDK's `settingSources` or are handled by the
CLI subprocess. Two settings require explicit handling by the ACP agent
and were not being respected:

**`permissions.defaultMode`**: Must be passed as the `permissionMode`
option to `query()`. The SDK defaults to `"default"` internally and does
not read it from settings files. The CLI handles this by reading the
setting and passing it to the SDK; `createSession` was hardcoding it to
`"default"` instead.

**`model`**: The CLI persists display strings like `"opus[1m]"` in
settings, but the SDK model list uses IDs like `"claude-opus-4-6-1m"`.
The existing substring matching in `getAvailableModels` couldn't resolve
these aliases, so they silently fell back to the first model. Passing
the raw alias to `query.setModel()` doesn't work either — the CLI's
internal `dg()` function strips `[1m]` and loses the context hint.

Changes:
- Fix pre-existing build errors from SDK type changes
(`SDKTaskStartedMessage` removed, `SDKMessageTemp` replaced with SDK's
own `SDKMessage`)
- Add `permissions.defaultMode` to `ClaudeCodeSettings`, merge it
through the settings hierarchy, and resolve it via
`resolvePermissionMode()` with case-insensitive aliases, validation, and
root-user guard
- Add tokenized model matching (`resolveModelPreference`) that handles
CLI display strings like `"opus[1m]"` by scoring against the available
models list
- Add unit and integration tests for both features

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Tim Visher <194828183+timvisher-dd@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Codex <codex@openai.com>
2026-03-03 12:54:36 +01:00
Ben Brandt
6ff856c94f Update to claude-agent-sdk 0.2.63 (#369) 2026-03-03 12:53:16 +01:00
Steffen Deusch
1b057923e8 support --cli for node (#358)
This allows to also use `--cli` when not using the standalone build.
2026-02-27 21:44:59 +01:00
Steffen Deusch
3b3ce6f287 Ensure concurrent prompts are handled. (#348)
This basically implements the pending prompt queueing RFD. When a prompt
request is received while the agent is already generating, we
immediately forward the message to Claude and then wait until it replays
the message back to us. When it replays it, we respond to the previous
prompt request with "end_turn", which also singifies to the client that
the message is now being processed by the agent.

See
https://github.com/agentclientprotocol/agent-client-protocol/pull/484.
2026-02-27 18:53:00 +01:00
Ben Brandt
b1d09f2366 Fix history support (#356) 2026-02-27 12:06:47 +01:00
Ben Brandt
d24066bd9d Support windows arm builds and clean up artifact names (#355) 2026-02-27 10:46:23 +00:00
Ben Brandt
5ca63abc47 Update to claude-agent-sdk 0.2.62 (#354) 2026-02-27 10:14:30 +00:00
Steffen Deusch
001614b459 expose parent_tool_use_id for subagents (#341)
I'm not sure yet how a good protocol for subagents could look like, so
instead of opening an RFD, I opted to expose Claude's
`parent_tool_use_id` in `_meta.claudeCode.parentToolUseId`. This will
allow Tidewave to render tools from subagents in a better way.
2026-02-26 22:45:12 +01:00
Steffen Deusch
218661f97b ACP Session Usage (#345)
The AI in https://github.com/zed-industries/claude-agent-acp/pull/344
had one good idea: only use the usage from the last assistant message
for context size. Of course, `usage` on the AssistantMessage is not
documented in the Agent SDK (we need to do an ugly cast), but this seems
to work alright.

We don't get the exact new usage after compaction, so I set it to zero.
2026-02-26 22:44:20 +01:00
Ben Brandt
5ede2b7c4c Use history methods from the SDK (#352) 2026-02-26 21:37:15 +00:00
Ben Brandt
988a741d59 Update to claude-agent-sdk 0.2.59 (#351) 2026-02-26 22:29:45 +01:00
Ben Limmer
b57a429867 fix: merge user-provided disallowedTools instead of overwriting (#335)
The disallowedTools merge fix from #295 was regressed by #316 when the
options object was restructured. Restore the merge behavior and add
regression tests for all three documented merge fields (disallowedTools,
hooks, mcpServers).

Fixes #334
2026-02-20 18:55:16 +01:00
Steffen Deusch
101f09dbf8 Add single-file executable using bun (#332)
This adds a single-file executable using bun. The trick is to use a
separate flag for ACP `--acp` and spawn the regular `cli.js` bundled by
the Agent SDK if no flags are passed. This was, we can pass ourselves as
pathToClaudeCodeExecutable.
2026-02-20 12:23:04 +01:00
Ben Brandt
d36204f6da Prep 0.18.0 release (#331) 2026-02-18 20:20:21 +00:00
Ben Brandt
4de2f98a8e Make sure we don't override terminal content (#328)
- **Fix sending markdown content that replaces terminal content**
- **Make sure to always updated content to be safe**
2026-02-18 13:46:18 +00:00
Ben Brandt
f5a5c73607 Use edit tool response to construct a better diff (#327) 2026-02-18 10:35:54 +00:00
Ben Brandt
5bd0af27ec Support session config options (#326) 2026-02-18 10:25:29 +01:00
Ben Brandt
be618f5dcb Switch over to built-in Claude Code tools (#316)
Given a number of recent issues stemming from Claude Code using more
subagents that don't play nicely with our custom mcp server, plus some
slowly diverging behavior, we made the decision to just switch back over
to the built-in tools and do more processing on the tool input/output

There are some downsides, but also a lot of upsides, on the whole, it
feels like a win for now.

Closes #310, #279, #305, #236, #94

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2026-02-18 09:52:16 +01:00
Ben Limmer
28b85aed41 fix: convert Read tool image content to ACP format (#322) (#323)
When the Read tool returns image content (e.g., reading a .png file),
the raw Claude SDK image block was passed through unconverted, causing
ACP clients to fail deserializing the tool call completion event. Route
non-text content through `toAcpContentBlock` to produce the correct ACP
image format.
2026-02-18 09:33:29 +01:00
27 changed files with 5101 additions and 7021 deletions

View File

@@ -1,11 +1,84 @@
name: Publish Package to npmjs
name: Publish and Release
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
jobs:
publish-npm:
build-binaries:
name: Build (${{ matrix.target }})
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
include:
- target: bun-linux-x64
artifact: claude-agent-acp
archive: claude-agent-acp-linux-x64.tar.gz
- target: bun-linux-arm64
artifact: claude-agent-acp
archive: claude-agent-acp-linux-arm64.tar.gz
- target: bun-linux-x64-musl
artifact: claude-agent-acp
archive: claude-agent-acp-linux-x64-musl.tar.gz
- target: bun-linux-arm64-musl
artifact: claude-agent-acp
archive: claude-agent-acp-linux-arm64-musl.tar.gz
- target: bun-darwin-x64
artifact: claude-agent-acp
archive: claude-agent-acp-darwin-x64.zip
- target: bun-darwin-arm64
artifact: claude-agent-acp
archive: claude-agent-acp-darwin-arm64.zip
- target: bun-windows-x64
artifact: claude-agent-acp.exe
archive: claude-agent-acp-windows-x64.zip
- target: bun-windows-arm64
artifact: claude-agent-acp.exe
archive: claude-agent-acp-windows-arm64.zip
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build binary
run: |
bun build src/index.ts \
--compile \
--target ${{ matrix.target }} \
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \
--outfile ${{ matrix.artifact }}
- name: Compress binary
run: |
if [[ "${{ matrix.archive }}" == *.zip ]]; then
zip ${{ matrix.archive }} ${{ matrix.artifact }}
else
tar czf ${{ matrix.archive }} ${{ matrix.artifact }}
fi
- name: Upload artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
publish-npm:
name: Publish to npm
runs-on: ubuntu-latest
needs: [build-binaries]
if: startsWith(github.ref, 'refs/tags/')
environment: release # Optional: for enhanced security
permissions:
contents: read
@@ -22,15 +95,26 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm publish
release:
name: Create Release
runs-on: ubuntu-latest
needs: [publish-npm]
needs: [build-binaries, publish-npm]
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Download all artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
path: artifacts
merge-multiple: true
- name: Create Release
id: create_release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
with:
generate_release_notes: true
files: artifacts/*

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.agent-shell

View File

@@ -1,5 +1,69 @@
# Changelog
## 0.22.1
- Fix: invalid auth required state in gateway mode
## 0.22.0
- Use stable list sessions method (#429)
- Use correct Claude CLI path for static binaries (#428)
- Update claude-agent-sdk to 0.2.76 (#427)
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
- Reuse existing sessions for load/resume if possible (#426)
- Remove interrupt flag from deny responses (#425)
- Allow Bypass permissions mode after Exiting plan (#410)
- Don't get out of sync when background task creates new init/result (try 2) (#400)
- Add session/close support (#409)
## 0.21.0
- Update to claude-agent-sdk 0.2.71
- show project-relative paths in tool call titles
- Lib: pass through tools array to control built-in tool availability
- fix: skip user replay
- fix: handle renamed Agent tool in toolInfoFromToolUse
## 0.20.2
- Update to @anthropic-ai/claude-agent-sdk@0.2.68
## 0.20.1
- fix: inherit process.env when spawning agent subprocess
## 0.20.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.63
- Respect user settings for permission mode and model selection
- Better handling of concurrent prompts
- Support --cli for node as well
- Propagate max_tokens stop reason instead of throwing internal error
- fix: throw resourceNotFound when loadSession fails to resume
- fix: add missing zod dependency
- Surface better error message when Claude Code process exits unexpectedly
## 0.19.2
- Fix for broken notifications when reloading session messages
## 0.19.1
- Support windows arm builds and clean up artifact files
## 0.19.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.62
- Use SDK functions for listing and loading session history
- Build single-file executables using bun.
- Fix for overwritten disallowed tools.
## 0.18.0
- Switch over to built-in Claude tools. We no longer replicate specific ACP tools and just rely on sending updates based on Claude's internal tools. This means it won't use client capabilities for files or terminals, but also means there will be less difference and hopefully issues arising from the differences in behavior.
- Support ACP session config options: https://agentclientprotocol.com/protocol/session-config-options
- Fix for image output from tool calls.
## 0.17.1
- Update to @anthropic-ai/claude-agent-sdk@0.2.45 to add access to Sonnet 4.6

View File

@@ -48,6 +48,11 @@ You can then use `claude-agent-acp` as a regular ACP agent:
ANTHROPIC_API_KEY=sk-... claude-agent-acp
```
#### Single-file executable
Pre-built single-file binaries are available on the [Releases](https://github.com/zed-industries/claude-agent-acp/releases) page for Linux, macOS, and Windows.
These binaries bundle everything needed and don't require Node.js.
## License
Apache-2.0

2936
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.17.1",
"version": "0.22.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,24 +60,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.45",
"@modelcontextprotocol/sdk": "1.26.0",
"diff": "8.0.3",
"minimatch": "10.2.1"
"@agentclientprotocol/sdk": "0.16.1",
"@anthropic-ai/claude-agent-sdk": "0.2.76",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.75.0",
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.2.3",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"eslint": "10.0.0",
"@types/node": "25.5.0",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"eslint": "10.0.3",
"eslint-config-prettier": "10.1.8",
"globals": "17.3.0",
"globals": "17.4.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vitest": "4.0.18"
"vitest": "4.1.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,25 +2,30 @@
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
import { claudeCliPath, runAcp } from "./acp-agent.js";
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
if (process.argv.includes("--cli")) {
process.argv = process.argv.filter((arg) => arg !== "--cli");
await import(await claudeCliPath());
} else {
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);
});
runAcp();
// Keep process alive
process.stdin.resume();
}
// 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();
// Keep process alive
process.stdin.resume();

View File

@@ -15,20 +15,15 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export { createMcpServer } from "./mcp-server.js";
export {
toolInfoFromToolUse,
toDisplayPath,
planEntries,
toolUpdateFromToolResult,
createPreToolUseHook,
acpToolNames as toolNames,
} from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,
type PermissionSettings,
type PermissionDecision,
type PermissionCheckResult,
type SettingsManagerOptions,
} from "./settings.js";

View File

@@ -1,911 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
TaskOutputInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { z } from "zod";
import { CLAUDE_CONFIG_DIR, ClaudeAcpAgent } from "./acp-agent.js";
import {
ClientCapabilities,
ReadTextFileResponse,
TerminalOutputResponse,
} from "@agentclientprotocol/sdk";
import * as diff from "diff";
import * as path from "node:path";
import * as fs from "node:fs/promises";
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
import { acpToolNames } from "./tools.js";
export const SYSTEM_REMINDER = `
<system-reminder>
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
</system-reminder>`;
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
const unqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
export function createMcpServer(
agent: ClaudeAcpAgent,
sessionId: string,
clientCapabilities: ClientCapabilities | undefined,
): McpServer {
/**
* This checks if a given path is related to internal agent persistence and if the agent should be allowed to read/write from here.
* We let the agent do normal fs operations on these paths so that it can persist its state.
* However, we block access to settings files for security reasons.
*/
function internalPath(file_path: string) {
return (
file_path.startsWith(CLAUDE_CONFIG_DIR) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "settings.json")) &&
!file_path.startsWith(path.join(CLAUDE_CONFIG_DIR, "session-env"))
);
}
async function readTextFile(input: FileReadInput): Promise<ReadTextFileResponse> {
if (internalPath(input.file_path)) {
const content = await fs.readFile(input.file_path, "utf8");
// eslint-disable-next-line eqeqeq
if (input.offset != null || input.limit != null) {
const lines = content.split("\n");
// Apply offset and limit if provided
const offset = input.offset ?? 1;
const limit = input.limit ?? lines.length;
// Extract the requested lines (offset is 1-based)
const startIndex = Math.max(0, offset - 1);
const endIndex = Math.min(lines.length, startIndex + limit);
const selectedLines = lines.slice(startIndex, endIndex);
return { content: selectedLines.join("\n") };
} else {
return { content };
}
}
return agent.readTextFile({
sessionId,
path: input.file_path,
line: input.offset,
limit: input.limit,
});
}
async function writeTextFile(input: FileWriteInput): Promise<void> {
if (internalPath(input.file_path)) {
await fs.writeFile(input.file_path, input.content, "utf8");
} else {
await agent.writeTextFile({
sessionId,
path: input.file_path,
content: input.content,
});
}
}
// Create MCP server
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
if (clientCapabilities?.fs?.readTextFile) {
server.registerTool(
unqualifiedToolNames.read,
{
title: unqualifiedToolNames.read,
description: `Reads the content of the given file in the project.
In sessions with ${acpToolNames.read} always use it instead of Read as it contains the most up-to-date contents.
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${acpToolNames.bash} tool.
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to read"),
offset: z
.number()
.optional()
.default(1)
.describe(
"The line number to start reading from. Only provide if the file is too large to read at once",
),
limit: z
.number()
.optional()
.default(defaults.linesToRead)
.describe(
`The number of lines to read. Only provide if the file is too large to read at once.`,
),
},
annotations: {
title: "Read file",
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input: FileReadInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const readResponse = await readTextFile(input);
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
// Extract lines with byte limit enforcement
const result = extractLinesWithByteLimit(readResponse.content, defaults.maxFileSize);
// Construct informative message about what was read
let readInfo = "";
if ((input.offset && input.offset > 1) || result.wasLimited) {
readInfo = "\n\n<file-read-info>";
if (result.wasLimited) {
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
} else if (input.offset && input.offset > 1) {
readInfo += `Read lines ${input.offset}-${input.offset + result.linesRead}.`;
}
if (result.wasLimited) {
readInfo += `Continue with offset=${result.linesRead}.`;
}
readInfo += "</file-read-info>";
}
return {
content: [
{
type: "text",
text: result.content + readInfo + SYSTEM_REMINDER,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Reading file failed: " + formatErrorMessage(error),
},
],
};
}
},
);
}
if (clientCapabilities?.fs?.writeTextFile) {
server.registerTool(
unqualifiedToolNames.write,
{
title: unqualifiedToolNames.write,
description: `Writes a file to the local filesystem..
In sessions with ${acpToolNames.write} always use it instead of Write as it will
allow the user to conveniently review changes.
Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the ${acpToolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
inputSchema: {
file_path: z
.string()
.describe("The absolute path to the file to write (must be absolute, not relative)"),
content: z.string().describe("The content to write to the file"),
},
annotations: {
title: "Write file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input: FileWriteInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
await writeTextFile(input);
return {
content: [
{
type: "text",
text: `The file ${input.file_path} has been updated successfully.`,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Writing file failed: " + formatErrorMessage(error),
},
],
};
}
},
);
server.registerTool(
unqualifiedToolNames.edit,
{
title: unqualifiedToolNames.edit,
description: `Performs exact string replacements in files.
In sessions with ${acpToolNames.edit} always use it instead of Edit as it will
allow the user to conveniently review changes.
Usage:
- You must use your \`${acpToolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
inputSchema: {
file_path: z.string().describe("The absolute path to the file to modify"),
old_string: z.string().describe("The text to replace"),
new_string: z
.string()
.describe("The text to replace it with (must be different from old_string)"),
replace_all: z
.boolean()
.default(false)
.optional()
.describe("Replace all occurrences of old_string (default false)"),
},
annotations: {
title: "Edit file",
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
idempotentHint: false,
},
},
async (input: FileEditInput) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const readResponse = await readTextFile({
file_path: input.file_path,
});
if (typeof readResponse?.content !== "string") {
throw new Error(`No file contents for ${input.file_path}.`);
}
const { newContent } = replaceAndCalculateLocation(readResponse.content, [
{
oldText: input.old_string,
newText: input.new_string,
replaceAll: input.replace_all,
},
]);
const patch = diff.createPatch(input.file_path, readResponse.content, newContent);
await writeTextFile({ file_path: input.file_path, content: newContent });
return {
content: [
{
type: "text",
text: patch,
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Editing file failed: " + formatErrorMessage(error),
},
],
};
}
},
);
}
if (agent.clientCapabilities?.terminal) {
server.registerTool(
unqualifiedToolNames.bash,
{
title: unqualifiedToolNames.bash,
description: `Executes a bash command
In sessions with ${acpToolNames.bash} always use it instead of Bash`,
inputSchema: {
command: z.string().describe("The command to execute"),
timeout: z.number().describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
description: z.string().optional()
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
Input: ls
Output: List files in current directory
Input: git status
Output: Show working tree status
Input: npm install
Output: Install package dependencies
Input: mkdir foo
Output: Create directory 'foo'`),
run_in_background: z
.boolean()
.default(false)
.describe(
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${acpToolNames.bashOutput}\` tool to retrieve the current output, or the \`${acpToolNames.killShell}\` tool to stop it early.`,
),
},
},
async (input: BashInput, extra) => {
try {
const session = agent.sessions[sessionId];
if (!session) {
return {
content: [
{
type: "text",
text: "The user has left the building",
},
],
};
}
const toolCallId = extra._meta?.["claudecode/toolUseId"];
if (typeof toolCallId !== "string") {
throw new Error("No tool call ID found");
}
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
throw new Error("unreachable");
}
const handle = await agent.client.createTerminal({
command: input.command,
env: [{ name: "CLAUDECODE", value: "1" }],
sessionId,
outputByteLimit: 32_000,
});
await agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
title: input.description,
content: [{ type: "terminal", terminalId: handle.id }],
},
});
const abortPromise = new Promise((resolve) => {
if (extra.signal.aborted) {
resolve(null);
} else {
extra.signal.addEventListener("abort", () => {
resolve(null);
});
}
});
const statusPromise = Promise.race([
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (agent.backgroundTerminals[handle.id]?.status === "started") {
await handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
if (input.run_in_background) {
agent.backgroundTerminals[handle.id] = {
handle,
lastOutput: null,
status: "started",
};
statusPromise.then(async ({ status, exitStatus }) => {
const bgTerm = agent.backgroundTerminals[handle.id];
if (bgTerm.status !== "started") {
return;
}
const currentOutput = await handle.currentOutput();
agent.backgroundTerminals[handle.id] = {
status,
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
return handle.release();
});
return {
content: [
{
type: "text",
text: `Command started in background with id: ${handle.id}`,
},
],
};
}
await using terminal = handle;
const { status } = await statusPromise;
if (status === "aborted") {
return {
content: [{ type: "text", text: "Tool cancelled by user" }],
};
}
const output = await terminal.currentOutput();
return {
content: [{ type: "text", text: toolCommandOutput(status, output) }],
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Running bash command failed: " + formatErrorMessage(error),
},
],
};
}
},
);
server.registerTool(
unqualifiedToolNames.bashOutput,
{
title: unqualifiedToolNames.bashOutput,
description: `- Retrieves output from a running or completed background bash shell
- Takes a bash_id parameter identifying the shell
- Always returns only new output since the last check
- Returns stdout and stderr output along with shell status
- Use this tool when you need to monitor or check the output of a long-running shell
In sessions with ${acpToolNames.bashOutput} always use it for output from Bash commands instead of TaskOutput.`,
inputSchema: {
task_id: z
.string()
.describe(
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
),
block: z.boolean().describe("Whether to wait for completion"),
timeout: z.number().describe("Max wait time in ms"),
},
},
async (input: TaskOutputInput) => {
try {
const bgTerm = agent.backgroundTerminals[input.task_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.task_id}`);
}
if (input.block && bgTerm.status === "started") {
const statusPromise = Promise.race([
bgTerm.handle
.waitForExit()
.then((exitStatus) => ({ status: "exited" as const, exitStatus })),
sleep(input.timeout ?? 2 * 60 * 1000).then(async () => {
if (bgTerm.status === "started") {
await bgTerm.handle.kill();
}
return { status: "timedOut" as const, exitStatus: null };
}),
]);
const { status, exitStatus } = await statusPromise;
const currentOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
currentOutput.output,
);
agent.backgroundTerminals[input.task_id] = {
status,
pendingOutput: {
...currentOutput,
output: strippedOutput,
exitStatus: exitStatus ?? currentOutput.exitStatus,
},
};
await bgTerm.handle.release();
return {
content: [
{
type: "text",
text: toolCommandOutput(status, {
...currentOutput,
output: strippedOutput,
exitStatus: exitStatus ?? currentOutput.exitStatus,
}),
},
],
};
}
if (bgTerm.status === "started") {
const newOutput = await bgTerm.handle.currentOutput();
const strippedOutput = stripCommonPrefix(
bgTerm.lastOutput?.output ?? "",
newOutput.output,
);
bgTerm.lastOutput = newOutput;
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, {
...newOutput,
output: strippedOutput,
}),
},
],
};
} else {
return {
content: [
{
type: "text",
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
},
],
};
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Retrieving bash output failed: " + formatErrorMessage(error),
},
],
};
}
},
);
server.registerTool(
unqualifiedToolNames.killShell,
{
title: unqualifiedToolNames.killShell,
description: `- Kills a running background bash shell by its ID
- Takes a shell_id parameter identifying the shell to kill
- Returns a success or failure status
- Use this tool when you need to terminate a long-running shell
In sessions with ${acpToolNames.killShell} always use it instead of KillShell.`,
inputSchema: {
shell_id: z
.string()
.describe(
`The id of the background bash command as returned by \`${acpToolNames.bash}\``,
),
},
},
async (input) => {
try {
const bgTerm = agent.backgroundTerminals[input.shell_id];
if (!bgTerm) {
throw new Error(`Unknown shell ${input.shell_id}`);
}
switch (bgTerm.status) {
case "started": {
await bgTerm.handle.kill();
const currentOutput = await bgTerm.handle.currentOutput();
agent.backgroundTerminals[bgTerm.handle.id] = {
status: "killed",
pendingOutput: {
...currentOutput,
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
},
};
await bgTerm.handle.release();
return {
content: [{ type: "text", text: "Command killed successfully." }],
};
}
case "aborted":
return {
content: [{ type: "text", text: "Command aborted by user." }],
};
case "exited":
return {
content: [{ type: "text", text: "Command had already exited." }],
};
case "killed":
return {
content: [{ type: "text", text: "Command was already killed." }],
};
case "timedOut":
return {
content: [{ type: "text", text: "Command killed by timeout." }],
};
default: {
unreachable(bgTerm);
throw new Error("Unexpected background terminal status");
}
}
} catch (error) {
return {
isError: true,
content: [
{
type: "text",
text: "Killing shell failed: " + formatErrorMessage(error),
},
],
};
}
},
);
}
return server;
}
function stripCommonPrefix(a: string, b: string): string {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return b.slice(i);
}
function toolCommandOutput(
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
output: TerminalOutputResponse,
): string {
const { exitStatus, output: commandOutput, truncated } = output;
let toolOutput = "";
switch (status) {
case "started":
case "exited": {
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
toolOutput += `Interrupted by the user. `;
}
break;
}
case "killed":
toolOutput += `Killed. `;
break;
case "timedOut":
toolOutput += `Timed out. `;
break;
case "aborted":
break;
default: {
const unreachable: never = status;
return unreachable;
}
}
if (exitStatus) {
if (typeof exitStatus.exitCode === "number") {
toolOutput += `Exited with code ${exitStatus.exitCode}.`;
}
if (typeof exitStatus.signal === "string") {
toolOutput += `Signal \`${exitStatus.signal}\`. `;
}
toolOutput += "Final output:\n\n";
} else {
toolOutput += "New output:\n\n";
}
toolOutput += commandOutput;
if (truncated) {
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
}
return toolOutput;
}
/**
* Replace text in a file and calculate the line numbers where the edits occurred.
*
* @param fileContent - The full file content
* @param edits - Array of edit operations to apply sequentially
* @returns the new content and the line numbers where replacements occurred in the final content
*/
export function replaceAndCalculateLocation(
fileContent: string,
edits: Array<{
oldText: string;
newText: string;
replaceAll?: boolean;
}>,
): { newContent: string; lineNumbers: number[] } {
let currentContent = fileContent;
// Use unique markers to track where replacements happen
const markerPrefix = `__REPLACE_MARKER_${Math.random().toString(36).substr(2, 9)}_`;
let markerCounter = 0;
const markers: string[] = [];
// Apply edits sequentially, inserting markers at replacement positions
for (const edit of edits) {
// Skip empty oldText
if (edit.oldText === "") {
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
}
if (edit.replaceAll) {
// Replace all occurrences with marker + newText
const parts: string[] = [];
let lastIndex = 0;
let searchIndex = 0;
while (true) {
const index = currentContent.indexOf(edit.oldText, searchIndex);
if (index === -1) {
if (searchIndex === 0) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
}
break;
}
// Add content before the match
parts.push(currentContent.substring(lastIndex, index));
// Add marker and replacement
const marker = `${markerPrefix}${markerCounter++}__`;
markers.push(marker);
parts.push(marker + edit.newText);
lastIndex = index + edit.oldText.length;
searchIndex = lastIndex;
}
// Add remaining content
parts.push(currentContent.substring(lastIndex));
currentContent = parts.join("");
} else {
// Replace first occurrence only
const index = currentContent.indexOf(edit.oldText);
if (index === -1) {
throw new Error(
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
);
} else {
const marker = `${markerPrefix}${markerCounter++}__`;
markers.push(marker);
currentContent =
currentContent.substring(0, index) +
marker +
edit.newText +
currentContent.substring(index + edit.oldText.length);
}
}
}
// Find line numbers where markers appear in the content
const lineNumbers: number[] = [];
for (const marker of markers) {
const index = currentContent.indexOf(marker);
if (index !== -1) {
const lineNumber = Math.max(
0,
currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
);
lineNumbers.push(lineNumber);
}
}
// Remove all markers from the final content
let finalContent = currentContent;
for (const marker of markers) {
finalContent = finalContent.replace(marker, "");
}
// Dedupe and sort line numbers
const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
}

View File

@@ -1,8 +1,5 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { minimatch } from "minimatch";
import { ACP_TOOL_NAME_PREFIX, acpToolNames } from "./tools.js";
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
/**
@@ -18,10 +15,6 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
*/
export interface PermissionSettings {
allow?: string[];
deny?: string[];
ask?: string[];
additionalDirectories?: string[];
defaultMode?: string;
}
@@ -31,180 +24,6 @@ export interface ClaudeCodeSettings {
model?: string;
}
export type PermissionDecision = "allow" | "deny" | "ask";
export interface PermissionCheckResult {
decision: PermissionDecision;
rule?: string;
source?: "allow" | "deny" | "ask";
}
interface ParsedRule {
toolName: string;
argument?: string;
isWildcard?: boolean;
}
/**
* Shell operators that can be used for command chaining/injection
* These should cause a prefix match to fail to prevent bypasses like:
* - "safe-cmd && malicious-cmd"
* - "safe-cmd; malicious-cmd"
* - "safe-cmd | malicious-cmd"
* - "safe-cmd || malicious-cmd"
* - "$(malicious-cmd)"
* - "`malicious-cmd`"
*/
const SHELL_OPERATORS = ["&&", "||", ";", "|", "$(", "`", "\n"];
/**
* Checks if a string contains shell operators that could allow command chaining
*/
function containsShellOperator(str: string): boolean {
return SHELL_OPERATORS.some((op) => str.includes(op));
}
/*
* Tools that modify files. Per Claude Code docs:
* "Edit rules apply to all built-in tools that edit files."
* This means an Edit(...) rule should match Write, MultiEdit, etc.
*/
const FILE_EDITING_TOOLS = [acpToolNames.edit, acpToolNames.write];
/**
* Tools that read files. Per Claude Code docs:
* "Claude will make a best-effort attempt to apply Read rules to all built-in tools
* that read files like Grep and Glob."
* This means a Read(...) rule should match Grep, Glob, etc.
*/
const FILE_READING_TOOLS = [acpToolNames.read];
/**
* Functions to extract the relevant argument from tool input for permission matching
*/
const TOOL_ARG_ACCESSORS: Record<string, (input: unknown) => string | undefined> = {
mcp__acp__Read: (input) => (input as { file_path?: string })?.file_path,
mcp__acp__Edit: (input) => (input as { file_path?: string })?.file_path,
mcp__acp__Write: (input) => (input as { file_path?: string })?.file_path,
mcp__acp__Bash: (input) => (input as { command?: string })?.command,
};
/**
* Parses a permission rule string into its components
* Examples:
* "Read" -> { toolName: "Read" }
* "Read(./.env)" -> { toolName: "Read", argument: "./.env" }
* "Bash(npm run:*)" -> { toolName: "Bash", argument: "npm run", isWildcard: true }
*/
function parseRule(rule: string): ParsedRule {
const match = rule.match(/^(\w+)(?:\((.+)\))?$/);
if (!match) {
return { toolName: rule };
}
const [, toolName, argument] = match;
if (argument && argument.endsWith(":*")) {
return {
toolName,
argument: argument.slice(0, -2),
isWildcard: true,
};
}
return { toolName, argument };
}
/**
* Normalizes a path for comparison:
* - Expands ~ to home directory
* - Resolves relative paths against cwd
* - Normalizes path separators
*/
function normalizePath(filePath: string, cwd: string): string {
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2));
} else if (filePath.startsWith("./")) {
filePath = path.join(cwd, filePath.slice(2));
} else if (!path.isAbsolute(filePath)) {
filePath = path.join(cwd, filePath);
}
// Convert backslashes to forward slashes for minimatch compatibility on Windows
return path.normalize(filePath).replace(/\\/g, "/");
}
/**
* Checks if a file path matches a glob pattern
*/
function matchesGlob(pattern: string, filePath: string, cwd: string): boolean {
const normalizedPattern = normalizePath(pattern, cwd);
const normalizedPath = normalizePath(filePath, cwd);
return minimatch(normalizedPath, normalizedPattern, {
dot: true,
matchBase: false,
nocase: process.platform === "win32",
});
}
/**
* Checks if a tool invocation matches a parsed permission rule
*/
function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd: string): boolean {
// Per Claude Code docs:
// - "Edit rules apply to all built-in tools that edit files."
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
// that read files like Grep, Glob, and LS."
const ruleAppliesToTool =
(rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
if (!ruleAppliesToTool) {
return false;
}
if (!rule.argument) {
return true;
}
const argAccessor = TOOL_ARG_ACCESSORS[toolName];
if (!argAccessor) {
return true;
}
const actualArg = argAccessor(toolInput);
if (!actualArg) {
return false;
}
if (toolName === acpToolNames.bash) {
// Per Claude Code docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
// - Bash(npm run build) matches the EXACT command "npm run build"
// - Bash(npm run test:*) matches commands STARTING WITH "npm run test"
// The :* suffix enables prefix matching, without it the match is exact
//
// Also from docs: "Claude Code is aware of shell operators (like &&) so a prefix match
// rule like Bash(safe-cmd:*) won't give it permission to run the command safe-cmd && other-cmd"
if (rule.isWildcard) {
if (!actualArg.startsWith(rule.argument)) {
return false;
}
// Check that the matched prefix isn't followed by shell operators that could
// allow command chaining/injection
const remainder = actualArg.slice(rule.argument.length);
if (containsShellOperator(remainder)) {
return false;
}
return true;
}
return actualArg === rule.argument;
}
// For file-based tools (Read, Edit, Write), use glob matching
return matchesGlob(rule.argument, actualArg, cwd);
}
/**
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
*/
@@ -338,36 +157,9 @@ export class SettingsManager {
this.enterpriseSettings,
];
const merged: ClaudeCodeSettings = {
permissions: {
allow: [],
deny: [],
ask: [],
},
};
const merged: ClaudeCodeSettings = {};
for (const settings of allSettings) {
if (settings.permissions) {
if (settings.permissions.allow) {
merged.permissions!.allow!.push(...settings.permissions.allow);
}
if (settings.permissions.deny) {
merged.permissions!.deny!.push(...settings.permissions.deny);
}
if (settings.permissions.ask) {
merged.permissions!.ask!.push(...settings.permissions.ask);
}
if (settings.permissions.additionalDirectories) {
merged.permissions!.additionalDirectories = [
...(merged.permissions!.additionalDirectories || []),
...settings.permissions.additionalDirectories,
];
}
if (settings.permissions.defaultMode) {
merged.permissions!.defaultMode = settings.permissions.defaultMode;
}
}
if (settings.env) {
merged.env = { ...merged.env, ...settings.env };
}
@@ -375,6 +167,13 @@ export class SettingsManager {
if (settings.model) {
merged.model = settings.model;
}
if (settings.permissions?.defaultMode !== undefined) {
merged.permissions = {
...merged.permissions,
defaultMode: settings.permissions.defaultMode,
};
}
}
this.mergedSettings = merged;
@@ -436,52 +235,6 @@ export class SettingsManager {
}, 100);
}
/**
* Checks if a tool invocation is allowed based on the loaded settings.
*
* @param toolName - The tool name (can be ACP-prefixed like mcp__acp__Read or plain like Read)
* @param toolInput - The tool input object
* @returns The permission decision and matching rule info
*/
checkPermission(toolName: string, toolInput: unknown): PermissionCheckResult {
if (!toolName.startsWith(ACP_TOOL_NAME_PREFIX)) {
return { decision: "ask" };
}
const permissions = this.mergedSettings.permissions;
if (!permissions) {
return { decision: "ask" };
}
// Check deny rules first (highest priority)
for (const rule of permissions.deny || []) {
const parsed = parseRule(rule);
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
return { decision: "deny", rule, source: "deny" };
}
}
// Check allow rules
for (const rule of permissions.allow || []) {
const parsed = parseRule(rule);
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
return { decision: "allow", rule, source: "allow" };
}
}
// Check ask rules
for (const rule of permissions.ask || []) {
const parsed = parseRule(rule);
if (matchesRule(parsed, toolName, toolInput, this.cwd)) {
return { decision: "ask", rule, source: "ask" };
}
}
// No matching rule - default to ask
return { decision: "ask" };
}
/**
* Returns the current merged settings
*/

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { querySpy } = vi.hoisted(() => ({
querySpy: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
return {
...actual,
query: querySpy,
};
});
describe("ClaudeAcpAgent settings", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
function mockQuery() {
let capturedOptions: any;
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return {
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet 4.5",
description: "Default",
},
],
}),
setModel: setModelSpy,
supportedCommands: async () => [],
} as any;
});
return { getCapturedOptions: () => capturedOptions, setModelSpy };
}
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-settings-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
querySpy.mockReset();
vi.resetModules();
});
afterEach(async () => {
if (originalClaudeConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
it("uses permissions.defaultMode for new sessions", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: "dontAsk",
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(getCapturedOptions().permissionMode).toBe("dontAsk");
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
expect(response.modes.currentModeId).toBe("dontAsk");
});
it("supports acceptEdits mode defaults", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: "acceptEdits",
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
expect(response.modes.currentModeId).toBe("acceptEdits");
});
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(getCapturedOptions().permissionMode).toBe("default");
expect(response.modes.currentModeId).toBe("default");
});
it("throws when permissions.defaultMode is not a string", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: 123,
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
await expect(
(agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
}),
).rejects.toThrow("Invalid permissions.defaultMode");
});
it("resolves model aliases like opus[1m] to the correct model", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
model: "opus[1m]",
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options: _options }: any) => {
return {
initializationResult: async () => ({
models: [
{
value: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Base",
},
{
value: "claude-opus-4-6-1m",
displayName: "Claude Opus 4.6 (1M)",
description: "Long context",
},
],
}),
setModel: setModelSpy,
supportedCommands: async () => [],
} as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m");
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
@@ -17,8 +17,15 @@ import {
WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import {
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
import type {
@@ -221,7 +228,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("");
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
// Send something
await connection.prompt({
@@ -242,7 +249,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toContain("");
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
}, 30000);
});
@@ -260,7 +267,7 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: "`rm README.md.rm`",
title: "rm README.md.rm",
content: [
{
content: {
@@ -319,24 +326,6 @@ describe("tool conversions", () => {
});
});
it("should handle LS tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EEqsX7Eb9hpx87KAHVPTey",
name: "LS",
input: {
path: "/Users/test/github/claude-agent-acp",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "search",
title: "List the `/Users/test/github/claude-agent-acp` directory's contents",
content: [],
locations: [],
});
});
it("should handle Grep tool calls", () => {
const tool_use = {
type: "tool_use",
@@ -380,11 +369,11 @@ describe("tool conversions", () => {
});
});
it("should handle mcp__acp__Write tool calls", () => {
it("should handle Write tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01GHI789JKL456",
name: "mcp__acp__Write",
name: "Write",
input: {
file_path: "/Users/test/project/config.json",
content: '{"version": "1.0.0"}',
@@ -406,6 +395,81 @@ describe("tool conversions", () => {
});
});
it("should handle Edit tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EDIT123",
name: "Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old text",
new_string: "new text",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Edit /Users/test/project/test.txt",
content: [
{
type: "diff",
path: "/Users/test/project/test.txt",
oldText: "old text",
newText: "new text",
},
],
locations: [{ path: "/Users/test/project/test.txt" }],
});
});
it("should handle Edit tool calls with replace_all", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EDIT456",
name: "Edit",
input: {
replace_all: false,
file_path: "/Users/benbrandt/github/codex-acp/src/thread.rs",
old_string:
"struct PromptState {\n active_command: Option<ActiveCommand>,\n active_web_search: Option<String>,\n}",
new_string:
"struct PromptState {\n active_commands: HashMap<String, ActiveCommand>,\n active_web_search: Option<String>,\n}",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Edit /Users/benbrandt/github/codex-acp/src/thread.rs",
content: [
{
type: "diff",
path: "/Users/benbrandt/github/codex-acp/src/thread.rs",
oldText:
"struct PromptState {\n active_command: Option<ActiveCommand>,\n active_web_search: Option<String>,\n}",
newText:
"struct PromptState {\n active_commands: HashMap<String, ActiveCommand>,\n active_web_search: Option<String>,\n}",
},
],
locations: [{ path: "/Users/benbrandt/github/codex-acp/src/thread.rs" }],
});
});
it("should handle Edit tool calls without file_path", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EDIT789",
name: "Edit",
input: {},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "edit",
title: "Edit",
content: [],
locations: [],
});
});
it("should handle Read tool calls", () => {
const tool_use = {
type: "tool_use",
@@ -418,17 +482,17 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read File",
title: "Read /Users/test/project/readme.md",
content: [],
locations: [{ path: "/Users/test/project/readme.md", line: 0 }],
locations: [{ path: "/Users/test/project/readme.md", line: 1 }],
});
});
it("should handle mcp__acp__Read tool calls", () => {
it("should handle Read tool calls", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01YZA789BCD123",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/data.json",
},
@@ -438,15 +502,15 @@ describe("tool conversions", () => {
kind: "read",
title: "Read /Users/test/project/data.json",
content: [],
locations: [{ path: "/Users/test/project/data.json", line: 0 }],
locations: [{ path: "/Users/test/project/data.json", line: 1 }],
});
});
it("should handle mcp__acp__Read with limit", () => {
it("should handle Read with limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01EFG456HIJ789",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/large.txt",
limit: 100,
@@ -457,15 +521,15 @@ describe("tool conversions", () => {
kind: "read",
title: "Read /Users/test/project/large.txt (1 - 100)",
content: [],
locations: [{ path: "/Users/test/project/large.txt", line: 0 }],
locations: [{ path: "/Users/test/project/large.txt", line: 1 }],
});
});
it("should handle mcp__acp__Read with offset and limit", () => {
it("should handle Read with offset and limit", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01KLM789NOP456",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/large.txt",
offset: 50,
@@ -475,17 +539,17 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (51 - 150)",
title: "Read /Users/test/project/large.txt (50 - 149)",
content: [],
locations: [{ path: "/Users/test/project/large.txt", line: 50 }],
});
});
it("should handle mcp__acp__Read with only offset", () => {
it("should handle Read with only offset", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01QRS123TUV789",
name: "mcp__acp__Read",
name: "Read",
input: {
file_path: "/Users/test/project/large.txt",
offset: 200,
@@ -494,44 +558,24 @@ describe("tool conversions", () => {
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "read",
title: "Read /Users/test/project/large.txt (from line 201)",
title: "Read /Users/test/project/large.txt (from line 200)",
content: [],
locations: [{ path: "/Users/test/project/large.txt", line: 200 }],
});
});
it("should handle KillBash entries", () => {
it("should use relative path in title when cwd is provided", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01PhLms5fuvmdjy2bb6dfUKT",
name: "KillShell",
input: {
shell_id: "bash_1",
},
id: "toolu_01READ_CWD",
name: "Read",
input: { file_path: "/Users/test/project/src/main.ts" },
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Kill Process`,
content: [],
});
});
it("should handle BashOutput entries", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01SJUWPtj1QspgANgtpqGPuN",
name: "BashOutput",
input: {
bash_id: "bash_1",
},
};
expect(toolInfoFromToolUse(tool_use)).toStrictEqual({
kind: "execute",
title: `Tail Logs`,
content: [],
});
const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project");
expect(result.title).toBe("Read src/main.ts");
// locations.path stays absolute for navigation
expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]);
});
it("should handle plan entries", () => {
@@ -682,7 +726,7 @@ describe("tool conversions", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
name: "Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
@@ -712,7 +756,7 @@ describe("tool conversions", () => {
const toolUse = {
type: "tool_use",
id: "toolu_01MNO345",
name: "mcp__acp__Edit",
name: "Edit",
input: {
file_path: "/Users/test/project/test.txt",
old_string: "old",
@@ -940,6 +984,142 @@ describe("tool conversions", () => {
});
});
describe("toDisplayPath", () => {
it("should relativize paths inside cwd and keep absolute paths outside", () => {
expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe(
"src/main.ts",
);
expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts");
expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe(
"/Users/test/project/src/main.ts",
);
// Partial directory name match should not be treated as inside cwd
expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe(
"/Users/test/project-other/file.ts",
);
});
});
describe("toolUpdateFromEditToolResponse", () => {
it("should return empty for non-object input", () => {
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
expect(toolUpdateFromEditToolResponse(undefined)).toEqual({});
expect(toolUpdateFromEditToolResponse("string")).toEqual({});
});
it("should return empty when filePath or structuredPatch is missing", () => {
expect(toolUpdateFromEditToolResponse({})).toEqual({});
expect(toolUpdateFromEditToolResponse({ filePath: "/foo.ts" })).toEqual({});
expect(toolUpdateFromEditToolResponse({ structuredPatch: [] })).toEqual({});
});
it("should build diff content from a single-hunk structuredPatch", () => {
const toolResponse = {
filePath: "/Users/test/project/test.txt",
structuredPatch: [
{
oldStart: 1,
oldLines: 3,
newStart: 1,
newLines: 3,
lines: [" context before", "-old line", "+new line", " context after"],
},
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
path: "/Users/test/project/test.txt",
oldText: "context before\nold line\ncontext after",
newText: "context before\nnew line\ncontext after",
},
],
locations: [{ path: "/Users/test/project/test.txt", line: 1 }],
});
});
it("should build multiple diff content blocks for replaceAll with multiple hunks", () => {
const toolResponse = {
filePath: "/Users/test/project/file.ts",
structuredPatch: [
{
oldStart: 5,
oldLines: 1,
newStart: 5,
newLines: 1,
lines: ["-oldValue", "+newValue"],
},
{
oldStart: 20,
oldLines: 1,
newStart: 20,
newLines: 1,
lines: ["-oldValue", "+newValue"],
},
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "oldValue",
newText: "newValue",
},
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "oldValue",
newText: "newValue",
},
],
locations: [
{ path: "/Users/test/project/file.ts", line: 5 },
{ path: "/Users/test/project/file.ts", line: 20 },
],
});
});
it("should handle deletion (newText becomes empty string)", () => {
const toolResponse = {
filePath: "/Users/test/project/file.ts",
structuredPatch: [
{
oldStart: 10,
oldLines: 2,
newStart: 10,
newLines: 1,
lines: [" context", "-removed line"],
},
],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({
content: [
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "context\nremoved line",
newText: "context",
},
],
locations: [{ path: "/Users/test/project/file.ts", line: 10 }],
});
});
it("should return empty for empty structuredPatch array", () => {
const toolResponse = {
filePath: "/Users/test/project/file.ts",
structuredPatch: [],
};
expect(toolUpdateFromEditToolResponse(toolResponse)).toEqual({});
});
});
describe("escape markdown", () => {
it("should escape markdown characters", () => {
let text = "Hello *world*!";
@@ -991,6 +1171,11 @@ describe("prompt conversion", () => {
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
it("finds vendored cli path", async () => {
const path = await claudeCliPath();
expect(path).toContain("@anthropic-ai/claude-agent-sdk/cli.js");
});
it("query has a 'default' model", async () => {
const q = query({ prompt: "hi" });
const models = await q.supportedModels();
@@ -1035,13 +1220,13 @@ describe("permission requests", () => {
name: "Bash",
input: { command: "ls -la", description: "List files" },
},
expectedTitlePart: "`ls -la`",
expectedTitlePart: "ls -la",
},
{
toolUse: {
type: "tool_use" as const,
id: "test-3",
name: "mcp__acp__Read",
name: "Read",
input: { file_path: "/test/data.json" },
},
expectedTitlePart: "/test/data.json",
@@ -1057,17 +1242,363 @@ describe("permission requests", () => {
expect(toolInfo.title).toContain(testCase.expectedTitlePart);
// Verify the structure that our fix creates for requestPermission
// We now spread the full toolInfo (title, kind, content, locations)
const requestStructure = {
toolCall: {
toolCallId: testCase.toolUse.id,
rawInput: testCase.toolUse.input,
title: toolInfo.title, // This is what commit 1785d86 adds
...toolInfo,
},
};
// Ensure the title field is present and populated
expect(requestStructure.toolCall.title).toBeDefined();
expect(requestStructure.toolCall.title).toContain(testCase.expectedTitlePart);
// Ensure kind is included so the client can render appropriate UI
expect(requestStructure.toolCall.kind).toBeDefined();
expect(typeof requestStructure.toolCall.kind).toBe("string");
// Ensure content is included so the client always has tool call details
expect(requestStructure.toolCall.content).toBeDefined();
expect(Array.isArray(requestStructure.toolCall.content)).toBe(true);
}
});
});
describe("stop reason propagation", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
function createResultMessage(overrides: {
subtype: "success" | "error_during_execution";
stop_reason: string | null;
is_error: boolean;
result?: string;
errors?: string[];
}) {
return {
type: "result" as const,
subtype: overrides.subtype,
stop_reason: overrides.stop_reason,
is_error: overrides.is_error,
result: overrides.result ?? "",
errors: overrides.errors ?? [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
}
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
const input = new Pushable<any>();
async function* messageGenerator() {
// Wait for the prompt to push its user message so we can replay it
const iter = input[Symbol.asyncIterator]();
const { value: userMessage, done } = await iter.next();
if (!done && userMessage) {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
}
yield* messages;
}
agent.sessions["test-session"] = {
query: messageGenerator() as any,
input,
cancelled: false,
cwd: "/test",
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
}
it("should return max_tokens when success result has stop_reason max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("max_tokens");
});
it("should return max_tokens when success result has stop_reason max_tokens and is_error true", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "success",
stop_reason: "max_tokens",
is_error: true,
result: "Token limit reached",
}),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("max_tokens");
});
it("should return max_tokens when error_during_execution has stop_reason max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "error_during_execution",
stop_reason: "max_tokens",
is_error: true,
errors: ["some error"],
}),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("max_tokens");
});
it("should return end_turn for success with null stop_reason", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({ subtype: "success", stop_reason: null, is_error: false }),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
});
it("should consume background task results and return the prompt's own result", async () => {
const agent = createMockAgent();
const input = new Pushable<any>();
const backgroundTaskResult = createResultMessage({
subtype: "success",
stop_reason: null,
is_error: false,
});
// Background task used some tokens
backgroundTaskResult.usage.input_tokens = 100;
backgroundTaskResult.usage.output_tokens = 50;
const promptResult = createResultMessage({
subtype: "success",
stop_reason: null,
is_error: false,
});
async function* messageGenerator() {
// Background task init + result arrive before our prompt's replay
yield { type: "system", subtype: "init", session_id: "test-session" };
yield backgroundTaskResult;
// Now the prompt's user message replay arrives
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
// Then the prompt's own result
yield promptResult;
}
agent.sessions["test-session"] = {
query: messageGenerator() as any,
input,
cwd: "/tmp/test",
cancelled: false,
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
abortController: new AbortController(),
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
};
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
// Usage should include both background task and prompt result tokens
expect(response.usage?.inputTokens).toBe(
backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens,
);
expect(response.usage?.outputTokens).toBe(
backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens,
);
});
it("should throw internal error for success with is_error true and no max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "Something went wrong",
}),
]);
await expect(
agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
}),
).rejects.toThrow("Internal error");
});
});
describe("session/close", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
function* empty() {}
const gen = Object.assign(empty(), { interrupt: vi.fn() });
agent.sessions[sessionId] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd: "/test",
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
};
return agent.sessions[sessionId]!;
}
it("should close an existing session and remove it", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-1");
expect(agent.sessions["session-1"]).toBeDefined();
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
expect(result).toEqual({});
expect(agent.sessions["session-1"]).toBeUndefined();
expect(session.query.interrupt).toHaveBeenCalled();
});
it("should abort the session's abort controller", async () => {
const agent = createMockAgent();
const session = injectSession(agent, "session-2");
expect(session.abortController.signal.aborted).toBe(false);
await agent.unstable_closeSession({ sessionId: "session-2" });
expect(session.abortController.signal.aborted).toBe(true);
});
it("should throw when closing a non-existent session", async () => {
const agent = createMockAgent();
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
"Session not found",
);
});
it("should not affect other sessions when closing one", async () => {
const agent = createMockAgent();
injectSession(agent, "session-a");
injectSession(agent, "session-b");
await agent.unstable_closeSession({ sessionId: "session-a" });
expect(agent.sessions["session-a"]).toBeUndefined();
expect(agent.sessions["session-b"]).toBeDefined();
});
});

View File

@@ -0,0 +1,158 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { ClaudeAcpAgent } from "../acp-agent.js";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
describe("authorization", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
//await all pending events like
vi.runAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.resetAllMocks();
});
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
initializationResult: vi.fn().mockResolvedValue({
models: [{ value: "id", displayName: "name", description: "description" }],
}),
setModel: vi.fn(),
supportedCommands: vi.fn().mockResolvedValue([]),
})),
);
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: mockQuery,
}));
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AgentSideConnection;
const agent = new ClaudeAcpAgent(connectionMock);
return [agent, mockQuery];
}
it("gateway auth not offered without capability", async () => {
const [agent] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {},
});
expect(initializeResponse.authMethods).not.toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
});
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
const [agent] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
});
it("uses gateway env after gateway auth", async () => {
const [agent, mockQuery] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { terminal: true, _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gateway.example", headers: { "x-api-key": "test" } } },
});
await agent.newSession({
cwd: "testRoot",
mcpServers: [],
_meta: {
claudeCode: {
options: {
env: {
userEnv: "userEnv",
},
},
},
},
});
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_BASE_URL: "https://gateway.example",
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
userEnv: "userEnv",
}),
}),
}),
);
});
it("hide claude authentication without terminal-auth", async () => {
const [agent] = await createAgentMock();
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).not.toContainEqual(
expect.objectContaining({ id: "claude-login" }),
);
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
});
it("hide terminal auth even when terminal-auth is set", async () => {
const [agent] = await createAgentMock();
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
_meta: { "terminal-auth": true },
},
});
expect(initializeResponse.authMethods).toStrictEqual([]);
});
it("show claude authentication", async () => {
const [agent] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: { auth: { terminal: true } },
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "claude-login" }),
);
});
});

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
let capturedOptions: Options | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
return {
...actual,
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.options;
return {
initializationResult: async () => ({
models: [
{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" },
],
}),
setModel: async () => {},
supportedCommands: async () => [],
[Symbol.asyncIterator]: async function* () {},
};
},
};
});
vi.mock("../tools.js", async () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
return {
...actual,
registerHookCallback: vi.fn(),
};
});
describe("createSession options merging", () => {
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
beforeEach(async () => {
capturedOptions = undefined;
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
});
it("merges user-provided disallowedTools with ACP internal list", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
disallowedTools: ["WebSearch", "WebFetch"],
},
},
},
});
// User-provided tools should be present
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
expect(capturedOptions!.disallowedTools).toContain("WebFetch");
// ACP's internal disallowed tool should also be present
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("works when user provides no disallowedTools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
});
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("works when user provides empty disallowedTools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
disallowedTools: [],
},
},
},
});
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("sets tools to empty array when disableBuiltInTools is true", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
claudeCode: {
options: {
disallowedTools: ["CustomTool"],
},
},
},
});
// disableBuiltInTools removes all built-in tools from context
expect(capturedOptions!.tools).toEqual([]);
// User-provided and ACP disallowedTools still apply
expect(capturedOptions!.disallowedTools).toContain("CustomTool");
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("merges user-provided hooks with ACP hooks", async () => {
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
hooks: {
PreToolUse: [userPreToolUseHook],
PostToolUse: [{ hooks: [{ command: "echo user-post" }] }],
},
},
},
},
});
// User's PreToolUse hooks should be preserved
expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]);
// PostToolUse should contain both user and ACP hooks
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
});
it("inherits HOME and PATH from process.env when no env is provided", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
});
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
});
it("merges user-provided env vars on top of process.env", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
env: {
CUSTOM_VAR: "custom-value",
},
},
},
},
});
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
expect(capturedOptions?.env?.CUSTOM_VAR).toBe("custom-value");
});
it("allows user-provided env vars to override process.env entries", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
env: {
HOME: "/custom/home",
},
},
},
},
});
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
});
it("defaults tools to claude_code preset when not provided", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
});
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
});
it("passes through user-provided tools string array", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
tools: ["Read", "Glob"],
},
},
},
});
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
});
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
claudeCode: {
options: {
tools: ["Read", "Glob"],
},
},
},
});
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
});
it("passes through empty tools array to disable all built-in tools", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
tools: [],
},
},
},
});
expect(capturedOptions!.tools).toEqual([]);
});
it("merges user-provided mcpServers with ACP mcpServers", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [
{
name: "acp-server",
command: "node",
args: ["acp-server.js"],
env: [],
},
],
_meta: {
claudeCode: {
options: {
mcpServers: {
"user-server": {
type: "stdio",
command: "node",
args: ["server.js"],
},
},
},
},
},
});
// User-provided MCP server should be present
expect(capturedOptions!.mcpServers).toHaveProperty("user-server");
// ACP-provided MCP server should also be present
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
});
});

View File

@@ -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);
});
});

View File

@@ -1,596 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { encodeProjectPath } from "../utils.js";
describe("unstable_listSessions", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
// Helper to create a mock AgentSideConnection
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
// Helper to write a session file
function writeSessionFile(
cwd: string,
sessionId: string,
options: {
userMessage?: string;
userMessageArray?: boolean;
mtime?: Date;
malformed?: boolean;
isAgentFile?: boolean;
omitCwdFromEntries?: boolean;
} = {},
): void {
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filename = options.isAgentFile ? `agent-${sessionId}.jsonl` : `${sessionId}.jsonl`;
const filePath = path.join(projectDir, filename);
if (options.malformed) {
fs.writeFileSync(filePath, "not valid json\n{also bad");
return;
}
const lines: string[] = [];
// First line with sessionId
lines.push(JSON.stringify({ sessionId, type: "init" }));
// User message if provided
if (options.userMessage) {
const content = options.userMessageArray
? [{ type: "text", text: options.userMessage }]
: options.userMessage;
lines.push(
JSON.stringify({
type: "user",
sessionId,
...(options.omitCwdFromEntries ? {} : { cwd }),
message: { content },
}),
);
}
// Assistant response
lines.push(
JSON.stringify({
type: "assistant",
sessionId,
...(options.omitCwdFromEntries ? {} : { cwd }),
message: { content: "Hello!" },
}),
);
fs.writeFileSync(filePath, lines.join("\n"));
// Set modification time if specified
if (options.mtime) {
fs.utimesSync(filePath, options.mtime, options.mtime);
}
}
beforeEach(async () => {
// Create temp directory to isolate tests from real filesystem.
// We set process.env.CLAUDE_CONFIG_DIR before importing the module so that
// CLAUDE_CONFIG_DIR is evaluated with our temp directory.
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-test-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
// Reset modules to pick up the new CLAUDE_CONFIG_DIR env var
vi.resetModules();
// Dynamic import after setting env var
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
// Create agent instance
agent = new ClaudeAcpAgent(createMockClient());
});
afterEach(() => {
// Restore env and cleanup
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("returns empty array when projects directory does not exist", async () => {
const result = await agent.unstable_listSessions({});
expect(result.sessions).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it("returns empty array when projects directory is empty", async () => {
fs.mkdirSync(path.join(tempDir, "projects"), { recursive: true });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toEqual([]);
});
it("parses session files and returns correct metadata", async () => {
const cwd = "/Users/test/myproject";
const sessionId = "sess-123";
const mtime = new Date("2025-01-15T10:30:00Z");
writeSessionFile(cwd, sessionId, {
userMessage: "Hello, can you help me?",
mtime,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]).toMatchObject({
sessionId: "sess-123",
cwd: "/Users/test/myproject",
title: "Hello, can you help me?",
});
expect(result.sessions[0]!.updatedAt).toBeDefined();
});
it("extracts title from array-style user message content", async () => {
writeSessionFile("/Users/test/project", "sess-456", {
userMessage: "Fix the bug in auth",
userMessageArray: true,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("Fix the bug in auth");
});
it("truncates long titles to 128 characters with ellipsis", async () => {
const longMessage = "A".repeat(150);
writeSessionFile("/Users/test/project", "sess-789", {
userMessage: longMessage,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("A".repeat(127) + "…");
});
it("replaces newlines with spaces in titles", async () => {
const messageWithNewlines = "First line\nSecond line\r\nThird line";
writeSessionFile("/Users/test/project", "sess-newlines", {
userMessage: messageWithNewlines,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("First line Second line Third line");
});
it("collapses multiple whitespace characters in titles", async () => {
const messageWithWhitespace = "Hello world \n\n test";
writeSessionFile("/Users/test/project", "sess-whitespace", {
userMessage: messageWithWhitespace,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBe("Hello world test");
});
it("filters sessions by cwd parameter", async () => {
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "Project A" });
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "Project B" });
const result = await agent.unstable_listSessions({ cwd: "/Users/test/projectone" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sess-a");
});
it("uses per-entry cwd when filtering even if encoded folder collides", async () => {
const encodedPath = encodeProjectPath("/Users/test/a-b");
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, "sess-a-b.jsonl"),
[
JSON.stringify({ sessionId: "sess-a-b", type: "init" }),
JSON.stringify({
type: "user",
sessionId: "sess-a-b",
cwd: "/Users/test/a-b",
message: { content: "A-B" },
}),
].join("\n"),
);
fs.writeFileSync(
path.join(projectDir, "sess-a-slash-b.jsonl"),
[
JSON.stringify({ sessionId: "sess-a-slash-b", type: "init" }),
JSON.stringify({
type: "user",
sessionId: "sess-a-slash-b",
cwd: "/Users/test/a/b",
message: { content: "A/B" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({ cwd: "/Users/test/a-b" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sess-a-b");
expect(result.sessions[0]!.cwd).toBe("/Users/test/a-b");
});
it("returns the true cwd for paths containing hyphens", async () => {
const cwd = "/Users/test/claude-agent-acp";
writeSessionFile(cwd, "hyphen-session", { userMessage: "Hyphen test" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.cwd).toBe(cwd);
expect(result.sessions[0]!.sessionId).toBe("hyphen-session");
});
it("filters hyphenated paths by cwd correctly", async () => {
writeSessionFile("/Users/test/claude-agent-acp", "hyphen-a", { userMessage: "A" });
writeSessionFile("/Users/test/other-project", "hyphen-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({ cwd: "/Users/test/claude-agent-acp" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("hyphen-a");
expect(result.sessions[0]!.cwd).toBe("/Users/test/claude-agent-acp");
});
it("returns all sessions when cwd is not specified", async () => {
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "A" });
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(2);
});
it("sorts sessions by updatedAt descending (most recent first)", async () => {
writeSessionFile("/Users/test/project", "old-session", {
userMessage: "Old",
mtime: new Date("2025-01-01T00:00:00Z"),
});
writeSessionFile("/Users/test/project", "new-session", {
userMessage: "New",
mtime: new Date("2025-01-15T00:00:00Z"),
});
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.sessionId).toBe("new-session");
expect(result.sessions[1]!.sessionId).toBe("old-session");
});
it("skips files starting with agent-", async () => {
writeSessionFile("/Users/test/project", "regular-session", { userMessage: "Regular" });
writeSessionFile("/Users/test/project", "hidden-session", {
userMessage: "Hidden",
isAgentFile: true,
});
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("regular-session");
});
it("skips malformed session files gracefully", async () => {
writeSessionFile("/Users/test/project", "good-session", { userMessage: "Good" });
writeSessionFile("/Users/test/project", "bad-session", { malformed: true });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("good-session");
});
it("uses filename as sessionId when not in file content", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
// Write file without sessionId in content (uses filename fallback), but with cwd.
const filePath = path.join(projectDir, "fallback-id.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init" }),
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.sessionId).toBe("fallback-id");
});
it("keeps sessionId stable from filename when later entries have different sessionId", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "stable-id.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init", sessionId: "stable-id" }),
JSON.stringify({
type: "user",
sessionId: "stable-id",
cwd,
message: { content: "Hello" },
}),
JSON.stringify({
type: "assistant",
sessionId: "other-id",
cwd,
message: { content: "Wrong session id from sidechain" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("stable-id");
});
it("derives cwd from entries for the filename sessionId only", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "target-session.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({
type: "user",
sessionId: "target-session",
cwd,
message: { content: "Correct cwd entry" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("target-session");
expect(result.sessions[0]!.cwd).toBe(cwd);
});
it("keeps scanning after title to find later cwd in same session", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "late-cwd.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init", sessionId: "late-cwd" }),
JSON.stringify({
type: "user",
sessionId: "late-cwd",
message: { content: "Title from first user message" },
}),
JSON.stringify({
type: "assistant",
sessionId: "late-cwd",
cwd,
message: { content: "Assistant entry carries cwd" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("late-cwd");
expect(result.sessions[0]!.cwd).toBe(cwd);
expect(result.sessions[0]!.title).toBe("Title from first user message");
});
it("ignores sidechain entries when deriving cwd and title", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "sidechain-metadata.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ type: "init", sessionId: "sidechain-metadata" }),
JSON.stringify({
type: "user",
sessionId: "sidechain-metadata",
isSidechain: true,
cwd: "/Users/test/wrong-project",
message: { content: "Wrong sidechain title" },
}),
JSON.stringify({
type: "user",
sessionId: "sidechain-metadata",
cwd,
message: { content: "Correct primary title" },
}),
].join("\n"),
);
const result = await agent.unstable_listSessions({ cwd });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("sidechain-metadata");
expect(result.sessions[0]!.cwd).toBe(cwd);
expect(result.sessions[0]!.title).toBe("Correct primary title");
});
it("skips entries without cwd when cwd filter is not provided", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, "no-cwd.jsonl");
fs.writeFileSync(filePath, JSON.stringify({ sessionId: "no-cwd", type: "summary" }) + "\n");
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(0);
});
it("returns null title when no user message exists", async () => {
const cwd = "/Users/test/project";
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
// Write file with only init and assistant message
const filePath = path.join(projectDir, "no-user.jsonl");
fs.writeFileSync(
filePath,
[
JSON.stringify({ sessionId: "no-user", type: "init" }),
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
].join("\n"),
);
const result = await agent.unstable_listSessions({});
expect(result.sessions[0]!.title).toBeNull();
});
describe("pagination", () => {
it("returns nextCursor when there are more results", async () => {
// Create 60 sessions (more than PAGE_SIZE of 50)
for (let i = 0; i < 60; i++) {
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
userMessage: `Session ${i}`,
mtime: new Date(Date.now() - i * 1000), // Each older by 1 second
});
}
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(50);
expect(result.nextCursor).toBeDefined();
});
it("does not return nextCursor when all results fit in one page", async () => {
for (let i = 0; i < 10; i++) {
writeSessionFile("/Users/test/project", `sess-${i}`, { userMessage: `Session ${i}` });
}
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(10);
expect(result.nextCursor).toBeUndefined();
});
it("returns next page when cursor is provided", async () => {
// Create 60 sessions
for (let i = 0; i < 60; i++) {
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
userMessage: `Session ${i}`,
mtime: new Date(Date.now() - i * 1000),
});
}
// Get first page
const firstPage = await agent.unstable_listSessions({});
expect(firstPage.sessions).toHaveLength(50);
expect(firstPage.nextCursor).toBeDefined();
// Get second page
const secondPage = await agent.unstable_listSessions({ cursor: firstPage.nextCursor });
expect(secondPage.sessions).toHaveLength(10);
expect(secondPage.nextCursor).toBeUndefined();
});
it("handles invalid cursor gracefully (starts from beginning)", async () => {
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
const result = await agent.unstable_listSessions({ cursor: "invalid-base64!" });
expect(result.sessions).toHaveLength(1);
});
it("handles malformed cursor JSON gracefully", async () => {
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
// Valid base64 but not valid JSON
const badCursor = Buffer.from("not json").toString("base64");
const result = await agent.unstable_listSessions({ cursor: badCursor });
expect(result.sessions).toHaveLength(1);
});
});
describe("Windows path support", () => {
it("decodes Windows-style paths correctly", async () => {
// Simulate a Windows path encoded as "C-Users-test-project"
const windowsCwd = "C:\\Users\\test\\project";
writeSessionFile(windowsCwd, "win-session", { userMessage: "Windows test" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.cwd).toBe("C:\\Users\\test\\project");
expect(result.sessions[0]!.sessionId).toBe("win-session");
});
it("filters Windows paths by cwd correctly", async () => {
writeSessionFile("C:\\Users\\test\\projectone", "win-a", { userMessage: "A" });
writeSessionFile("C:\\Users\\test\\projecttwo", "win-b", { userMessage: "B" });
const result = await agent.unstable_listSessions({ cwd: "C:\\Users\\test\\projectone" });
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]!.sessionId).toBe("win-a");
});
it("handles mixed Unix and Windows paths", async () => {
writeSessionFile("/Users/test/unixproject", "unix-session", { userMessage: "Unix" });
writeSessionFile("C:\\Users\\test\\winproject", "win-session", { userMessage: "Windows" });
const result = await agent.unstable_listSessions({});
expect(result.sessions).toHaveLength(2);
const cwds = result.sessions.map((s) => s.cwd);
expect(cwds).toContain("/Users/test/unixproject");
expect(cwds).toContain("C:\\Users\\test\\winproject");
});
});
});

View File

@@ -1,320 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { encodeProjectPath } from "../utils.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
}));
vi.mock("../tools.js", async () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
return {
...actual,
registerHookCallback: registerHookCallbackSpy,
};
});
describe("loadSession", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
let sessionUpdates: SessionNotification[];
let createSessionSpy: ReturnType<typeof vi.fn>;
// Helper to create a mock AgentSideConnection
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
function writeSessionFile(
cwd: string,
sessionId: string,
entries: Array<Record<string, unknown> | string>,
): void {
const encodedPath = encodeProjectPath(cwd);
const projectDir = path.join(tempDir, "projects", encodedPath);
fs.mkdirSync(projectDir, { recursive: true });
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
const lines = entries.map((entry) =>
typeof entry === "string" ? entry : JSON.stringify(entry),
);
fs.writeFileSync(filePath, lines.join("\n"));
}
beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-load-session-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
sessionUpdates = [];
registerHookCallbackSpy.mockClear();
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
createSessionSpy = vi.fn(async () => ({ modes: null, models: null }));
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
createSessionSpy;
});
afterEach(() => {
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("throws when the session file does not exist", async () => {
await expect(
agent.loadSession({
cwd: "/Users/test/project",
sessionId: "missing-session",
mcpServers: [],
}),
).rejects.toThrow("Session not found");
expect(createSessionSpy).not.toHaveBeenCalled();
});
it("replays history and returns modes/models from createSession", async () => {
const cwd = "/Users/test/project";
const sessionId = "session-123";
writeSessionFile(cwd, sessionId, [
{ type: "summary", summary: "ignored" },
{
type: "user",
sessionId,
message: { role: "user", content: "Hello" },
},
{
type: "assistant",
sessionId,
message: {
role: "assistant",
content: [{ type: "thinking", thinking: "Thinking" }],
},
},
{
type: "assistant",
sessionId,
message: {
role: "assistant",
content: [{ type: "text", text: "Hi" }],
},
},
{
type: "assistant",
sessionId,
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "Read",
input: { file_path: "/tmp/example.txt" },
},
],
},
},
{
type: "user",
sessionId,
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool-1", content: "ok" }],
},
},
]);
const result = await agent.loadSession({ cwd, sessionId, mcpServers: [] });
expect(result).toEqual({ modes: null, models: null });
expect(createSessionSpy).toHaveBeenCalledWith(
expect.objectContaining({ cwd, mcpServers: [] }),
{ resume: sessionId },
);
expect(registerHookCallbackSpy).not.toHaveBeenCalled();
expect(sessionUpdates.map((notification) => notification.update.sessionUpdate)).toEqual([
"user_message_chunk",
"agent_thought_chunk",
"agent_message_chunk",
"tool_call",
"tool_call_update",
]);
expect(sessionUpdates[0]?.update).toMatchObject({
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Hello" },
});
expect(sessionUpdates[1]?.update).toMatchObject({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "Thinking" },
});
expect(sessionUpdates[2]?.update).toMatchObject({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Hi" },
});
expect(sessionUpdates[3]?.update).toMatchObject({
sessionUpdate: "tool_call",
toolCallId: "tool-1",
status: "pending",
});
expect(sessionUpdates[4]?.update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
});
});
it("skips sidechain, mismatched sessionId, invalid, and unsupported entries", async () => {
const cwd = "/Users/test/project";
const sessionId = "session-456";
writeSessionFile(cwd, sessionId, [
"{not valid json}",
{ type: "summary", summary: "ignore" },
{
type: "user",
sessionId,
isSidechain: true,
message: { role: "user", content: "Sidechain" },
},
{
type: "assistant",
sessionId: "other-session",
message: { role: "assistant", content: "Wrong session" },
},
{
type: "assistant",
sessionId,
message: { role: "system", content: "Unsupported role" },
},
{
type: "user",
sessionId,
message: { role: "user", content: 123 },
},
{
type: "user",
sessionId,
message: { role: "user", content: "Keep me" },
},
]);
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0]?.update).toMatchObject({
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Keep me" },
});
});
it("finds session file when cwd differs from the original session cwd", async () => {
const originalCwd = "/Users/test/original-project";
const differentCwd = "/Users/test/different-project";
const sessionId = "cross-cwd-session";
// Session was created in originalCwd
writeSessionFile(originalCwd, sessionId, [
{
type: "user",
sessionId,
cwd: originalCwd,
message: { role: "user", content: "Hello from original" },
},
]);
// Load with a different cwd
await agent.loadSession({ cwd: differentCwd, sessionId, mcpServers: [] });
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0]?.update).toMatchObject({
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "Hello from original" },
});
expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ cwd: differentCwd }), {
resume: sessionId,
});
});
it("sends available_commands_update after history replay, not during", async () => {
const cwd = "/Users/test/project";
const sessionId = "session-commands";
// Mock createSession to populate the sessions map with a mock query
createSessionSpy.mockImplementationOnce(async () => {
(agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId] = {
query: {
supportedCommands: async () => [{ name: "help", description: "Get help" }],
},
};
return { modes: null, models: null };
});
writeSessionFile(cwd, sessionId, [
{
type: "user",
sessionId,
message: { role: "user", content: "Hello" },
},
{
type: "assistant",
sessionId,
message: { role: "assistant", content: [{ type: "text", text: "Hi" }] },
},
]);
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
// Flush the setTimeout + async work inside sendAvailableCommandsUpdate
await new Promise((resolve) => setTimeout(resolve, 10));
const updateTypes = sessionUpdates.map((n) => n.update.sessionUpdate);
// available_commands_update should come AFTER all replay notifications
expect(updateTypes).toEqual([
"user_message_chunk",
"agent_message_chunk",
"available_commands_update",
]);
});
it("handles Windows-style paths", async () => {
const cwd = "C:\\Users\\test\\project";
const sessionId = "win-session";
writeSessionFile(cwd, sessionId, [
{
type: "user",
sessionId,
message: { role: "user", content: "Hello" },
},
]);
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0]?.sessionId).toBe(sessionId);
});
});

View File

@@ -1,334 +0,0 @@
import { describe, it, expect } from "vitest";
import { replaceAndCalculateLocation } from "../mcp-server.js";
describe("replaceAndCalculateLocation", () => {
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 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.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
});
it("should replace all occurrences when replaceAll is true", () => {
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
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)
});
it("should return empty line numbers array when no match found", () => {
const content = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
});
it("should handle Windows line endings (CRLF)", () => {
const content = "line 1\r\nline 2 with text\r\nline 3 with text\r\nline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe(
"line 1\r\nline 2 with replaced\r\nline 3 with replaced\r\nline 4",
);
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle old Mac line endings (CR)", () => {
const content = "line 1\rline 2 with text\rline 3 with text\rline 4";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
expect(result.lineNumbers).toEqual([1, 2]);
});
it("should handle mixed line endings", () => {
const content = "line 1\nline 2 with text\r\nline 3 with text\rline 4";
const result = replaceAndCalculateLocation(content, [
{ 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.lineNumbers).toEqual([1, 2]);
});
it("should handle text at the beginning of file", () => {
const content = "text at start\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
});
it("should handle text at the end of file", () => {
const content = "line 1\nline 2\nlast line with text";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
});
it("should handle multiple occurrences on the same line", () => {
const content = "line 1\ntext text text\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
});
it("should handle empty file content", () => {
expect(() => {
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
}).toThrowError(
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
);
});
it("should handle empty search text", () => {
// Test with replaceAll false (default)
const content1 = "line 1\nline 2\nline 3";
expect(() => {
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on single line
const content2 = "abc";
expect(() => {
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
// Test with replaceAll true on multiline content
const content3 = "ab\ncd";
expect(() => {
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
});
it("should handle single line content", () => {
const content = "single line with text here";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("single line with replaced here");
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
});
it("should handle special characters in search text", () => {
const content = "line 1\nline with $special.chars*\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "$special.chars*", newText: "replaced" },
]);
expect(result.newContent).toBe("line 1\nline with replaced\nline 3");
expect(result.lineNumbers).toEqual([1]);
});
it("should handle newlines in search text", () => {
const content = "line 1\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "1\nline", newText: "1\nmodified line" },
]);
expect(result.newContent).toBe("line 1\nmodified line 2\nline 3");
expect(result.lineNumbers).toEqual([0]); // Match starts at line 1 (0-based)
});
it("should handle newlines in replacement text", () => {
const content = "line 1\nline 2\nline 3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "line 2", newText: "line 2a\nline 2b" },
]);
expect(result.newContent).toBe("line 1\nline 2a\nline 2b\nline 3");
expect(result.lineNumbers).toEqual([1]);
});
it("should handle very long lines", () => {
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
const content = `line 1\n${longLine}\nline 3`;
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe(
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
);
expect(result.lineNumbers).toEqual([1]);
});
it("should handle many lines", () => {
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`);
lines[500] = "line with text";
const content = lines.join("\n");
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.lineNumbers).toEqual([500]);
expect(result.newContent).includes("line with replaced");
});
it("should handle overlapping matches correctly", () => {
const content = "aaaa\nbbbb\ncccc";
const result = replaceAndCalculateLocation(content, [
{ oldText: "aa", newText: "xx", replaceAll: true },
]);
// Should replace non-overlapping occurrences
expect(result.newContent).toBe("xxxx\nbbbb\ncccc");
expect(result.lineNumbers).toEqual([0]); // Two matches on first line, deduplicated
});
it("should preserve content after replacement with different lengths", () => {
const content = "short\nmedium line\nlong line here";
// Replacing with longer text
const result1 = replaceAndCalculateLocation(content, [
{ oldText: "short", newText: "very very long text" },
]);
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
expect(result1.lineNumbers).toEqual([0]);
// Replacing with shorter text
const result2 = replaceAndCalculateLocation(content, [
{ oldText: "medium line", newText: "m" },
]);
expect(result2.newContent).toBe("short\nm\nlong line here");
expect(result2.lineNumbers).toEqual([1]);
});
it("should handle consecutive replacements", () => {
const content = "text1 text2 text3";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
expect(result.newContent).toBe("replaced1 replaced2 replaced3");
expect(result.lineNumbers).toEqual([0]); // All on first line, deduplicated
});
it("should handle case-sensitive replacements", () => {
const content = "Text TEXT text";
const result = replaceAndCalculateLocation(content, [
{ oldText: "text", newText: "replaced", replaceAll: true },
]);
// Should only replace exact matches
expect(result.newContent).toBe("Text TEXT replaced");
expect(result.lineNumbers).toEqual([0]); // Only lowercase "text" matched
});
it("should handle replacement at exact end of line", () => {
const content = "line ends with text\nnext line";
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
expect(result.newContent).toBe("line ends with replaced\nnext line");
expect(result.lineNumbers).toEqual([0]);
});
it("should handle replacement spanning multiple lines", () => {
const content = "line 1\nspan\nacross\nlines\nline 5";
const result = replaceAndCalculateLocation(content, [
{ oldText: "span\nacross\nlines", newText: "single" },
]);
expect(result.newContent).toBe("line 1\nsingle\nline 5");
expect(result.lineNumbers).toEqual([1]); // Match starts at line 2 (0-based)
});
it("should default replaceAll to false", () => {
const content = "match match match";
// Without passing replaceAll parameter
const result = replaceAndCalculateLocation(content, [
{ oldText: "match", newText: "replaced" },
]);
expect(result.newContent).toBe("replaced match match");
expect(result.lineNumbers).toEqual([0]); // Only first match
});
it("should handle multiple edits with correct line numbers", () => {
const content = "line 1\nfoo bar\nline 3\nfoo baz\nline 5\nbar foo\nline 7";
const result = replaceAndCalculateLocation(content, [
{ oldText: "foo", newText: "FOO", replaceAll: true },
{ oldText: "bar", newText: "BAR", replaceAll: false },
{ oldText: "line 3", newText: "LINE THREE" },
]);
// Should replace:
// - All "foo" occurrences (lines 1, 3, 5 in original)
// - First "bar" occurrence (line 1 after first edit)
// - "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");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
1, // FOO and BAR on line 1
2, // LINE THREE on line 2
3, // FOO on line 3
5, // FOO on line 5
]);
});
it("should handle multiple edits with overlapping text", () => {
const content = "hello world\nhello hello\nworld hello world";
const result = replaceAndCalculateLocation(content, [
{ oldText: "hello", newText: "hi", replaceAll: false },
{ oldText: "world", newText: "earth", replaceAll: true },
{ oldText: "hello", newText: "greetings", replaceAll: true },
]);
// First edit replaces first "hello" only
// Second edit replaces all "world" 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");
// Line numbers reflect position in final content, deduplicated and sorted
expect(result.lineNumbers).toEqual([
0, // hi and earth on line 0
1, // greetings (multiple) on line 1
2, // earth and greetings on line 2
]);
});
it("should handle complex scenarios with marker-like 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 result = replaceAndCalculateLocation(content, [
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
{ oldText: "text", newText: "content" },
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
]);
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
// Line numbers should be correct even with marker-like text
expect(result.lineNumbers).toEqual([
1, // REPLACED on line 1
3, // DONE on line 3
]);
});
it("should handle edits where newText matches subsequent oldText", () => {
const content = "foo\nbar\nbaz";
const result = replaceAndCalculateLocation(content, [
{ oldText: "foo", newText: "bar" },
{ oldText: "bar", newText: "baz", replaceAll: true },
{ oldText: "baz", newText: "qux", replaceAll: true },
]);
// First edit: foo -> bar
// Second edit: both original bar and new bar -> baz
// Third edit: all baz instances -> qux
expect(result.newContent).toBe("qux\nqux\nqux");
// All replacements end up on their respective lines
expect(result.lineNumbers).toEqual([0, 1, 2]);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { resolvePermissionMode } from "../acp-agent.js";
describe("resolvePermissionMode", () => {
it("returns 'default' when no mode is provided", () => {
expect(resolvePermissionMode()).toBe("default");
expect(resolvePermissionMode(undefined)).toBe("default");
});
it("resolves exact canonical modes", () => {
expect(resolvePermissionMode("default")).toBe("default");
expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits");
expect(resolvePermissionMode("dontAsk")).toBe("dontAsk");
expect(resolvePermissionMode("plan")).toBe("plan");
expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions");
});
it("resolves case-insensitive aliases", () => {
expect(resolvePermissionMode("DontAsk")).toBe("dontAsk");
expect(resolvePermissionMode("DONTASK")).toBe("dontAsk");
expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits");
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
});
it("trims whitespace", () => {
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
});
it("throws on non-string values", () => {
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
expect(() => resolvePermissionMode({})).toThrow("expected a string");
});
it("throws on empty string", () => {
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
});
it("throws on unknown mode", () => {
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
});
});

View File

@@ -0,0 +1,473 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
}));
vi.mock("../tools.js", async () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
return {
...actual,
registerHookCallback: registerHookCallbackSpy,
};
});
const SESSION_ID = "test-session-id";
const MOCK_MODES = {
currentModeId: "default",
availableModes: [
{ id: "default", name: "Default", description: "Standard behavior" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
],
};
const MOCK_MODELS = {
currentModelId: "claude-opus-4-5",
availableModels: [
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
],
};
const MOCK_CONFIG_OPTIONS = [
{
id: "mode",
name: "Mode",
type: "select",
category: "mode",
currentValue: "default",
options: MOCK_MODES.availableModes.map((m) => ({
value: m.id,
name: m.name,
description: m.description,
})),
},
{
id: "model",
name: "Model",
type: "select",
category: "model",
currentValue: "claude-opus-4-5",
options: MOCK_MODELS.availableModels.map((m) => ({
value: m.modelId,
name: m.name,
description: m.description,
})),
},
];
describe("session config options", () => {
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
let sessionUpdates: SessionNotification[];
let createSessionSpy: ReturnType<typeof vi.fn>;
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
let setModelSpy: ReturnType<typeof vi.fn>;
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
function populateSession() {
setPermissionModeSpy = vi.fn();
setModelSpy = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: {
setPermissionMode: setPermissionModeSpy,
setModel: setModelSpy,
supportedCommands: async () => [],
},
input: null,
cancelled: false,
permissionMode: "default",
settingsManager: {},
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
};
}
beforeEach(async () => {
sessionUpdates = [];
registerHookCallbackSpy.mockClear();
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
createSessionSpy = vi.fn(async () => ({
sessionId: SESSION_ID,
modes: MOCK_MODES,
models: MOCK_MODELS,
configOptions: MOCK_CONFIG_OPTIONS,
}));
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
createSessionSpy;
});
describe("newSession returns configOptions", () => {
it("includes configOptions in the response", async () => {
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(response.configOptions).toBeDefined();
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
});
it("includes mode and model config options", async () => {
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
const modeOption = response.configOptions?.find((o) => o.id === "mode");
const modelOption = response.configOptions?.find((o) => o.id === "model");
expect(modeOption).toBeDefined();
expect(modelOption).toBeDefined();
});
});
describe("loadSession returns configOptions", () => {
it("includes configOptions from createSession", async () => {
// loadSession calls findSessionFile first - override the whole method
const loadSessionSpy = vi.fn(async () => ({
modes: MOCK_MODES,
models: MOCK_MODELS,
configOptions: MOCK_CONFIG_OPTIONS,
}));
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
const response = await agent.loadSession({
cwd: "/test",
sessionId: SESSION_ID,
mcpServers: [],
});
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
});
});
describe("setSessionConfigOption", () => {
beforeEach(() => {
populateSession();
});
it("throws when session not found", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: "nonexistent",
configId: "mode",
value: "plan",
}),
).rejects.toThrow("Session not found");
});
it("throws when config option not found", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "unknown-option",
value: "some-value",
}),
).rejects.toThrow("Unknown config option: unknown-option");
});
it("throws when value is not valid for the option", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "invalid-mode",
}),
).rejects.toThrow("Invalid value for config option mode: invalid-mode");
});
it("changes mode, sends current_mode_update but not config_option_update", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
expect(setPermissionModeSpy).toHaveBeenCalledWith("plan");
const modeUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdate?.update).toMatchObject({
sessionUpdate: "current_mode_update",
currentModeId: "plan",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeUndefined();
});
it("changes model and does not send a config_option_update notification", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeUndefined();
});
it("resolves model alias 'opus' to full model ID", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "opus",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
});
it("resolves model alias 'sonnet' to full model ID", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "sonnet",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("resolves display name to model ID", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "Claude Sonnet",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("still works with exact model ID", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
});
it("throws for completely invalid model value", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "gpt-4",
}),
).rejects.toThrow("Invalid value for config option model: gpt-4");
});
it("returns full configOptions in the response", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
expect(response.configOptions).toHaveLength(MOCK_CONFIG_OPTIONS.length);
const modeOption = response.configOptions.find((o) => o.id === "mode");
expect(modeOption?.currentValue).toBe("plan");
});
it("other options are unchanged when one is updated", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
});
});
describe("setSessionMode sends config_option_update", () => {
beforeEach(() => {
populateSession();
});
it("sends config_option_update when mode is changed via setSessionMode", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "acceptEdits" });
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
expect(configUpdate?.update).toMatchObject({
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({ id: "mode", currentValue: "acceptEdits" }),
]),
});
});
it("updates stored configOptions currentValue when mode changes", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
const modeOption = session.configOptions.find((o) => o.id === "mode");
expect(modeOption?.currentValue).toBe("plan");
});
it("does not send config_option_update for an invalid mode", async () => {
await expect(
agent.setSessionMode({ sessionId: SESSION_ID, modeId: "not-a-mode" as any }),
).rejects.toThrow("Invalid Mode");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeUndefined();
});
});
describe("unstable_setSessionModel sends config_option_update", () => {
beforeEach(() => {
populateSession();
});
it("sends config_option_update when model is changed via setSessionModel", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
expect(configUpdate?.update).toMatchObject({
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-5" }),
]),
});
});
it("updates stored configOptions currentValue when model changes", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
const modelOption = session.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
});
});
describe("no config_option_update notification when using setSessionConfigOption", () => {
beforeEach(() => {
populateSession();
});
it("sends no config_option_update when setting mode via config option", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "plan",
});
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
});
it("sends no config_option_update when setting model via config option", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
});
});
describe("bidirectional consistency", () => {
beforeEach(() => {
populateSession();
});
it("setSessionConfigOption for mode also calls underlying setPermissionMode", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "mode",
value: "acceptEdits",
});
expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits");
});
it("setSessionConfigOption for model also calls underlying setModel", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
});
it("setSessionMode also syncs configOptions", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan");
});
it("setSessionModel also syncs configOptions", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
"claude-sonnet-4-5",
);
});
});
});

View File

@@ -17,455 +17,7 @@ describe("SettingsManager", () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
describe("permission checking", () => {
it("should return 'ask' when no settings exist", async () => {
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: "/some/file.txt",
});
expect(result.decision).toBe("ask");
});
it("should return 'ask' for non-ACP tools (permission checks only apply to mcp__acp__* tools)", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Non-ACP tools should always return 'ask' regardless of rules
const result = settingsManager.checkPermission("Read", { file_path: "/some/file.txt" });
expect(result.decision).toBe("ask");
});
it("should allow tool use when matching allow rule exists", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: "/some/file.txt",
});
expect(result.decision).toBe("allow");
expect(result.rule).toBe("Read");
});
it("should deny tool use when matching deny rule exists", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(result.decision).toBe("deny");
expect(result.rule).toBe("Read(./.env)");
});
it("should prioritize deny rules over allow rules", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// .env should be denied
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(envResult.decision).toBe("deny");
// other files should be allowed
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "other.txt"),
});
expect(otherResult.decision).toBe("allow");
});
it("should handle ACP-prefixed tool names", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: "/some/file.txt",
});
expect(result.decision).toBe("allow");
});
});
describe("Bash permission rules", () => {
it("should match exact Bash commands without :* wildcard", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
// Per docs: Bash(npm run build) matches the EXACT command "npm run build"
allow: ["Bash(npm run lint)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Exact match should be allowed
const exactResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run lint",
});
expect(exactResult.decision).toBe("allow");
// Command with extra arguments should NOT match (exact match only)
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run lint --fix",
});
expect(withArgsResult.decision).toBe("ask");
// Similar command should NOT match (exact match only)
const similarResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run linting",
});
expect(similarResult.decision).toBe("ask");
// Different command should not match
const differentResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run test",
});
expect(differentResult.decision).toBe("ask");
});
it("should match Bash commands with :* wildcard suffix (prefix matching)", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
// The :* suffix is a convention to make prefix matching explicit
allow: ["Bash(npm run:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Any command starting with "npm run" should match (prefix matching with :*)
const lintResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run lint",
});
expect(lintResult.decision).toBe("allow");
const testResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run test",
});
expect(testResult.decision).toBe("allow");
// Commands with additional args also match
const withArgsResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm run test --watch",
});
expect(withArgsResult.decision).toBe("allow");
// Non-matching command
const installResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "npm install",
});
expect(installResult.decision).toBe("ask");
});
it("should not allow shell operators to bypass prefix matching", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Bash(safe-cmd:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Normal prefix match should work
const normalResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd --flag",
});
expect(normalResult.decision).toBe("allow");
// Shell operators should NOT be allowed (per docs: Claude Code is aware of shell operators)
const andResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd && malicious-cmd",
});
expect(andResult.decision).toBe("ask");
const orResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd || malicious-cmd",
});
expect(orResult.decision).toBe("ask");
const semicolonResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd; malicious-cmd",
});
expect(semicolonResult.decision).toBe("ask");
const pipeResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd | malicious-cmd",
});
expect(pipeResult.decision).toBe("ask");
const subshellResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd $(malicious-cmd)",
});
expect(subshellResult.decision).toBe("ask");
const backtickResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd `malicious-cmd`",
});
expect(backtickResult.decision).toBe("ask");
const newlineResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "safe-cmd\nmalicious-cmd",
});
expect(newlineResult.decision).toBe("ask");
});
it("should deny dangerous Bash commands", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Bash(curl:*)", "Bash(wget:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const curlResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "curl https://example.com",
});
expect(curlResult.decision).toBe("deny");
const wgetResult = settingsManager.checkPermission("mcp__acp__Bash", {
command: "wget https://example.com",
});
expect(wgetResult.decision).toBe("deny");
const lsResult = settingsManager.checkPermission("mcp__acp__Bash", { command: "ls -la" });
expect(lsResult.decision).toBe("ask");
});
});
describe("file path glob matching", () => {
it("should match exact file paths", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(envResult.decision).toBe("deny");
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".envrc"),
});
expect(otherResult.decision).toBe("ask");
});
it("should match glob patterns with single wildcard", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env.*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const envLocalResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env.local"),
});
expect(envLocalResult.decision).toBe("deny");
const envProdResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env.production"),
});
expect(envProdResult.decision).toBe("deny");
// Plain .env should not match .env.*
const plainEnvResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(plainEnvResult.decision).toBe("ask");
});
it("should match glob patterns with double wildcard (recursive)", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read(./secrets/**)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const secretResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "secrets", "api-key.txt"),
});
expect(secretResult.decision).toBe("deny");
const nestedSecretResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "secrets", "deep", "nested", "key.txt"),
});
expect(nestedSecretResult.decision).toBe("deny");
const otherResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "public", "file.txt"),
});
expect(otherResult.decision).toBe("ask");
});
it("should handle home directory expansion", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read(~/.zshrc)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const zshrcResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(os.homedir(), ".zshrc"),
});
expect(zshrcResult.decision).toBe("allow");
});
});
describe("settings merging", () => {
it("should merge project and local settings", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
// Project settings
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
// Local settings
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
permissions: {
deny: ["Read(./.env)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Read should be allowed in general
const readResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "file.txt"),
});
expect(readResult.decision).toBe("allow");
// But .env should be denied (local settings take precedence)
const envResult = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, ".env"),
});
expect(envResult.decision).toBe("deny");
});
it("should merge model setting with later sources taking precedence", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
@@ -500,17 +52,16 @@ describe("SettingsManager", () => {
settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-haiku");
});
});
describe("ask rules", () => {
it("should return 'ask' for matching ask rules", async () => {
it("should merge permissions.defaultMode with later sources taking precedence", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
ask: ["Bash(git push:*)"],
defaultMode: "dontAsk",
},
}),
);
@@ -518,250 +69,25 @@ describe("SettingsManager", () => {
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Bash", {
command: "git push origin main",
});
expect(result.decision).toBe("ask");
expect(result.rule).toBe("Bash(git push:*)");
});
});
let settings = settingsManager.getSettings();
expect(settings.permissions?.defaultMode).toBe("dontAsk");
describe("Edit and Write tools", () => {
it("should handle Edit tool permissions", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
// Local settings override the mode
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
permissions: {
deny: ["Edit(./package-lock.json)"],
defaultMode: "plan",
},
}),
);
settingsManager.dispose();
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const lockFileResult = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: path.join(tempDir, "package-lock.json"),
});
expect(lockFileResult.decision).toBe("deny");
const otherResult = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: path.join(tempDir, "package.json"),
});
expect(otherResult.decision).toBe("ask");
});
it("should handle mcp__acp__Edit and mcp__acp__Write tool names", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Edit", "Write"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const editResult = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: "/some/file.ts",
});
expect(editResult.decision).toBe("allow");
const writeResult = settingsManager.checkPermission("mcp__acp__Write", {
file_path: "/some/file.ts",
});
expect(writeResult.decision).toBe("allow");
});
});
describe("getSettings", () => {
it("should return merged settings", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read", "Bash(npm:*)"],
deny: ["Read(./.env)"],
},
env: {
FOO: "bar",
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const settings = settingsManager.getSettings();
expect(settings.permissions?.allow).toContain("Read");
expect(settings.permissions?.allow).toContain("Bash(npm:*)");
expect(settings.permissions?.deny).toContain("Read(./.env)");
expect(settings.env?.FOO).toBe("bar");
});
});
describe("setCwd", () => {
it("should reload settings when cwd changes", async () => {
const claudeDir1 = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir1, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir1, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
let result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
expect(result.decision).toBe("allow");
// Create a new temp directory with different settings
const tempDir2 = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-2-"));
const claudeDir2 = path.join(tempDir2, ".claude");
await fs.promises.mkdir(claudeDir2, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir2, "settings.json"),
JSON.stringify({
permissions: {
deny: ["Read"],
},
}),
);
await settingsManager.setCwd(tempDir2);
result = settingsManager.checkPermission("mcp__acp__Read", { file_path: "/file.txt" });
expect(result.decision).toBe("deny");
// Cleanup second temp dir
await fs.promises.rm(tempDir2, { recursive: true, force: true });
});
});
describe("cross-tool rule isolation", () => {
// These tests verify the fix in commit d471ccf:
// Permission rules should only match their intended tool type.
// A Bash rule should not match Edit/Write/Read operations, and vice versa.
it("should not apply Bash rules to Edit tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
// This Bash rule has an argument that looks like a glob pattern.
// Before the fix, it would incorrectly match Edit operations
// because the argument would be interpreted as a file glob.
allow: ["Bash(./**:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
// Bash rule should NOT allow Edit operations
const result = settingsManager.checkPermission("mcp__acp__Edit", {
file_path: path.join(tempDir, "any-file.txt"),
});
expect(result.decision).toBe("ask");
});
it("should not apply Bash rules to Write tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Bash(./**:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Write", {
file_path: path.join(tempDir, "any-file.txt"),
});
expect(result.decision).toBe("ask");
});
it("should not apply Bash rules to Read tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Bash(./**:*)"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Read", {
file_path: path.join(tempDir, "any-file.txt"),
});
expect(result.decision).toBe("ask");
});
it("should not apply Edit rules to Bash tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Edit"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Bash", {
command: "echo hello",
});
expect(result.decision).toBe("ask");
});
it("should not apply Read rules to Bash tool calls", async () => {
const claudeDir = path.join(tempDir, ".claude");
await fs.promises.mkdir(claudeDir, { recursive: true });
await fs.promises.writeFile(
path.join(claudeDir, "settings.json"),
JSON.stringify({
permissions: {
allow: ["Read"],
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
const result = settingsManager.checkPermission("mcp__acp__Bash", {
command: "ls -la",
});
expect(result.decision).toBe("ask");
settings = settingsManager.getSettings();
expect(settings.permissions?.defaultMode).toBe("plan");
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import { ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaMCPToolResultBlock,
BetaTextBlock,
@@ -8,8 +8,10 @@ import {
BetaWebSearchToolResultBlock,
BetaBashCodeExecutionToolResultBlock,
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
describe("rawOutput in tool call updates", () => {
const mockClient = {} as AgentSideConnection;
@@ -315,4 +317,921 @@ describe("rawOutput in tool call updates", () => {
// TodoWrite should not emit tool_call_update - it emits plan updates instead
expect(notifications).toHaveLength(0);
});
it("should convert Read tool base64 image content to ACP image format", () => {
const toolUseCache: ToolUseCache = {
toolu_img: {
type: "tool_use",
id: "toolu_img",
name: "Read",
input: { file_path: "/test/image.png" },
},
};
const imageBlock: ImageBlockParam = {
type: "image",
source: { type: "base64", data: "iVBORw0KGgo=", media_type: "image/png" },
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_img",
content: [imageBlock],
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_img",
status: "completed",
content: [
{
type: "content",
content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
},
],
});
});
it("should handle Read tool with mixed text and image content", () => {
const toolUseCache: ToolUseCache = {
toolu_mix: {
type: "tool_use",
id: "toolu_mix",
name: "Read",
input: { file_path: "/test/image.png" },
},
};
const imageBlock: ImageBlockParam = {
type: "image",
source: { type: "base64", data: "iVBORw0KGgo=", media_type: "image/png" },
};
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_mix",
content: [{ type: "text", text: "File preview:" }, imageBlock],
is_error: false,
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
expect(notifications[0].update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_mix",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "```\nFile preview:\n```" },
},
{
type: "content",
content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
},
],
});
});
});
describe("Bash terminal output", () => {
const mockClient = {} as AgentSideConnection;
const mockLogger: Logger = { log: () => {}, error: () => {} };
const bashToolUse = {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
};
const makeBashResult = (
stdout: string,
stderr: string,
return_code: number,
): BetaBashCodeExecutionToolResultBlockParam => ({
type: "bash_code_execution_tool_result",
tool_use_id: "toolu_bash",
content: {
type: "bash_code_execution_result",
stdout,
stderr,
return_code,
content: [],
},
});
describe("toolUpdateFromToolResult", () => {
it("should return formatted content without _meta when supportsTerminalOutput is false", () => {
const toolResult = makeBashResult("file1.txt\nfile2.txt", "", 0);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update).toEqual({
content: [
{
type: "content",
content: {
type: "text",
text: "```console\nfile1.txt\nfile2.txt\n```",
},
},
],
});
expect(update._meta).toBeUndefined();
});
it("should return no content with _meta when supportsTerminalOutput is true", () => {
const toolResult = makeBashResult("file1.txt\nfile2.txt", "", 0);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
expect(update._meta).toEqual({
terminal_info: {
terminal_id: "toolu_bash",
},
terminal_output: {
terminal_id: "toolu_bash",
data: "file1.txt\nfile2.txt",
},
terminal_exit: {
terminal_id: "toolu_bash",
exit_code: 0,
signal: null,
},
});
});
it("should include exit_code from return_code in terminal_exit", () => {
const toolResult = makeBashResult("", "command not found", 127);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update._meta?.terminal_exit).toEqual({
terminal_id: "toolu_bash",
exit_code: 127,
signal: null,
});
});
it("should fall back to stderr when stdout is empty", () => {
const toolResult = makeBashResult("", "some error output", 1);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update.content).toEqual([
{
type: "content",
content: {
type: "text",
text: "```console\nsome error output\n```",
},
},
]);
});
it("should return no content with _meta when output is empty and supportsTerminalOutput is true", () => {
const toolResult = makeBashResult("", "", 0);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
expect(update._meta).toEqual({
terminal_info: {
terminal_id: "toolu_bash",
},
terminal_output: {
terminal_id: "toolu_bash",
data: "",
},
terminal_exit: {
terminal_id: "toolu_bash",
exit_code: 0,
signal: null,
},
});
});
it("should return empty object when output is empty and supportsTerminalOutput is false", () => {
const toolResult = makeBashResult("", "", 0);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update).toEqual({});
});
it("should default supportsTerminalOutput to false when not provided", () => {
const toolResult = makeBashResult("hello", "", 0);
const update = toolUpdateFromToolResult(toolResult, bashToolUse);
expect(update._meta).toBeUndefined();
expect(update.content).toEqual([
{
type: "content",
content: {
type: "text",
text: "```console\nhello\n```",
},
},
]);
});
it("should preserve trailing whitespace in _meta data when supportsTerminalOutput is true", () => {
const toolResult = makeBashResult("hello\n\n\n", "", 0);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
expect(update._meta?.terminal_output?.data).toBe("hello\n\n\n");
});
describe("with plain string tool_result (production format)", () => {
const makeStringBashResult = (
content: string,
is_error: boolean = false,
): ToolResultBlockParam => ({
type: "tool_result",
tool_use_id: "toolu_bash",
content,
is_error,
});
it("should format string content as sh code block without _meta when supportsTerminalOutput is false", () => {
const toolResult = makeStringBashResult("Cargo.lock\nCargo.toml\nREADME.md");
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update).toEqual({
content: [
{
type: "content",
content: {
type: "text",
text: "```console\nCargo.lock\nCargo.toml\nREADME.md\n```",
},
},
],
});
expect(update._meta).toBeUndefined();
});
it("should return no content with _meta when supportsTerminalOutput is true", () => {
const toolResult = makeStringBashResult("Cargo.lock\nCargo.toml\nREADME.md");
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
expect(update._meta).toEqual({
terminal_info: { terminal_id: "toolu_bash" },
terminal_output: { terminal_id: "toolu_bash", data: "Cargo.lock\nCargo.toml\nREADME.md" },
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
});
});
it("should use error handler when is_error is true (early return before Bash case)", () => {
const toolResult = makeStringBashResult("command not found: bad_cmd", true);
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
// is_error with content hits the early error return at the top of
// toolUpdateFromToolResult, before reaching the Bash switch case.
// So there's no terminal _meta, just error-formatted content.
expect(update._meta).toBeUndefined();
expect(update.content).toEqual([
{
type: "content",
content: {
type: "text",
text: "```\ncommand not found: bad_cmd\n```",
},
},
]);
});
it("should return empty object for empty string content without terminal support", () => {
const toolResult = makeStringBashResult("");
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update).toEqual({});
});
it("should return no content with _meta for empty string content with terminal support", () => {
const toolResult = makeStringBashResult("");
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
expect(update._meta).toEqual({
terminal_info: { terminal_id: "toolu_bash" },
terminal_output: { terminal_id: "toolu_bash", data: "" },
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
});
});
it("should handle array content with text blocks", () => {
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_bash",
content: [{ type: "text", text: "line1\nline2" }],
is_error: false,
};
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update).toEqual({
content: [
{
type: "content",
content: {
type: "text",
text: "```console\nline1\nline2\n```",
},
},
],
});
});
});
});
describe("toAcpNotifications with clientCapabilities", () => {
const toolUseCache: ToolUseCache = {
toolu_bash: {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
},
};
const bashResult: BetaBashCodeExecutionResultBlock = {
type: "bash_code_execution_result",
stdout: "file1.txt\nfile2.txt",
stderr: "",
return_code: 0,
content: [],
};
const toolResult: BetaBashCodeExecutionToolResultBlock = {
type: "bash_code_execution_tool_result",
tool_use_id: "toolu_bash",
content: bashResult,
};
it("should include terminal _meta when client declares terminal_output support", () => {
const clientCapabilities: ClientCapabilities = {
_meta: { terminal_output: true },
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ clientCapabilities },
);
// Split into 2 notifications: terminal_output, then terminal_exit + completion
expect(notifications).toHaveLength(2);
// First notification: terminal_output only
const outputUpdate = notifications[0].update;
expect(outputUpdate).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_bash",
});
expect((outputUpdate as any)._meta).toEqual({
terminal_output: { terminal_id: "toolu_bash", data: "file1.txt\nfile2.txt" },
});
expect((outputUpdate as any).status).toBeUndefined();
// Second notification: terminal_exit + status + content
const exitUpdate = notifications[1].update;
expect(exitUpdate).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_bash",
status: "completed",
});
expect((exitUpdate as any)._meta).toMatchObject({
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
});
// terminal_info and terminal_output should NOT be on the exit notification
expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_info");
expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_output");
});
it("should not include terminal _meta when client does not declare terminal_output support", () => {
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
expect(notifications).toHaveLength(1);
const update = notifications[0].update;
expect(update).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: "toolu_bash",
status: "completed",
});
expect((update as any)._meta).not.toHaveProperty("terminal_info");
expect((update as any)._meta).not.toHaveProperty("terminal_output");
expect((update as any)._meta).not.toHaveProperty("terminal_exit");
});
it("should not include terminal _meta when _meta.terminal_output is false", () => {
const clientCapabilities: ClientCapabilities = {
_meta: { terminal_output: false },
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ clientCapabilities },
);
expect(notifications).toHaveLength(1);
expect((notifications[0].update as any)._meta).not.toHaveProperty("terminal_output");
});
it("should include formatted content only when terminal_output is not supported", () => {
const withSupport = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ clientCapabilities: { _meta: { terminal_output: true } } },
);
const withoutSupport = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
);
// With support: output is delivered via terminal_output _meta, content references the terminal widget
expect(withSupport).toHaveLength(2);
expect((withSupport[1].update as any).content).toEqual([
{ type: "terminal", terminalId: "toolu_bash" },
]);
// Without support: content is on the only notification
expect((withoutSupport[0].update as any).content).toEqual([
{
type: "content",
content: {
type: "text",
text: "```console\nfile1.txt\nfile2.txt\n```",
},
},
]);
});
it("should preserve claudeCode in _meta alongside terminal_exit on completion notification", () => {
const clientCapabilities: ClientCapabilities = {
_meta: { terminal_output: true },
};
const notifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ clientCapabilities },
);
expect(notifications).toHaveLength(2);
// First notification (terminal_output) has no claudeCode
const outputMeta = (notifications[0].update as any)._meta;
expect(outputMeta.terminal_output).toBeDefined();
expect(outputMeta.claudeCode).toBeUndefined();
// Second notification (completion) has claudeCode + terminal_exit
const exitMeta = (notifications[1].update as any)._meta;
expect(exitMeta.claudeCode).toEqual({ toolName: "Bash" });
expect(exitMeta.terminal_exit).toBeDefined();
});
});
describe("post-tool-use hook sends diff content for Edit tool", () => {
it("should include content and locations from structuredPatch in hook update", async () => {
const toolUseCache: ToolUseCache = {};
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
// Register hook callback by processing tool_use
toAcpNotifications(
[
{
type: "tool_use" as const,
id: "toolu_edit_hook",
name: "Edit",
input: {
file_path: "/Users/test/project/file.ts",
old_string: "old text",
new_string: "new text",
},
},
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
);
// Fire PostToolUse hook with a structuredPatch in tool_response
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Edit",
tool_input: {
file_path: "/Users/test/project/file.ts",
old_string: "old text",
new_string: "new text",
},
tool_response: {
filePath: "/Users/test/project/file.ts",
oldString: "old text",
newString: "new text",
structuredPatch: [
{
oldStart: 5,
oldLines: 3,
newStart: 5,
newLines: 3,
lines: [" context before", "-old text", "+new text", " context after"],
},
],
},
tool_use_id: "toolu_edit_hook",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_edit_hook",
{ signal: AbortSignal.abort() },
);
expect(hookUpdates).toHaveLength(1);
const hookUpdate = hookUpdates[0].update;
expect(hookUpdate._meta.claudeCode.toolName).toBe("Edit");
expect(hookUpdate.content).toEqual([
{
type: "diff",
path: "/Users/test/project/file.ts",
oldText: "context before\nold text\ncontext after",
newText: "context before\nnew text\ncontext after",
},
]);
expect(hookUpdate.locations).toEqual([{ path: "/Users/test/project/file.ts", line: 5 }]);
});
it("should include multiple diff blocks for replaceAll with multiple hunks", async () => {
const toolUseCache: ToolUseCache = {};
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
toAcpNotifications(
[
{
type: "tool_use" as const,
id: "toolu_edit_replace_all",
name: "Edit",
input: {
file_path: "/Users/test/project/file.ts",
old_string: "foo",
new_string: "bar",
replace_all: true,
},
},
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
);
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Edit",
tool_input: {
file_path: "/Users/test/project/file.ts",
old_string: "foo",
new_string: "bar",
replace_all: true,
},
tool_response: {
filePath: "/Users/test/project/file.ts",
oldString: "foo",
newString: "bar",
replaceAll: true,
structuredPatch: [
{
oldStart: 3,
oldLines: 1,
newStart: 3,
newLines: 1,
lines: ["-foo", "+bar"],
},
{
oldStart: 15,
oldLines: 1,
newStart: 15,
newLines: 1,
lines: ["-foo", "+bar"],
},
],
},
tool_use_id: "toolu_edit_replace_all",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_edit_replace_all",
{ signal: AbortSignal.abort() },
);
expect(hookUpdates).toHaveLength(1);
const hookUpdate = hookUpdates[0].update;
expect(hookUpdate.content).toEqual([
{ type: "diff", path: "/Users/test/project/file.ts", oldText: "foo", newText: "bar" },
{ type: "diff", path: "/Users/test/project/file.ts", oldText: "foo", newText: "bar" },
]);
expect(hookUpdate.locations).toEqual([
{ path: "/Users/test/project/file.ts", line: 3 },
{ path: "/Users/test/project/file.ts", line: 15 },
]);
});
it("should not include content/locations for non-Edit tools", async () => {
const toolUseCache: ToolUseCache = {};
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
toAcpNotifications(
[
{
type: "tool_use" as const,
id: "toolu_bash_no_diff",
name: "Bash",
input: { command: "echo hi" },
},
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
);
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command: "echo hi" },
tool_response: "hi",
tool_use_id: "toolu_bash_no_diff",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_bash_no_diff",
{ signal: AbortSignal.abort() },
);
expect(hookUpdates).toHaveLength(1);
const hookUpdate = hookUpdates[0].update;
expect(hookUpdate.content).toBeUndefined();
expect(hookUpdate.locations).toBeUndefined();
});
});
describe("post-tool-use hook preserves terminal _meta", () => {
it("should send terminal_output and terminal_exit as separate notifications, and hook should only have claudeCode", async () => {
const clientCapabilities: ClientCapabilities = {
_meta: { terminal_output: true },
};
const toolUseCache: ToolUseCache = {};
// Capture session updates sent by the hook callback
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
// Step 1: Process tool_use chunk — registers the PostToolUse hook callback
const toolUseChunk = {
type: "tool_use" as const,
id: "toolu_bash_hook",
name: "Bash",
input: { command: "ls -la" },
};
const toolUseNotifications = toAcpNotifications(
[toolUseChunk],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
{ clientCapabilities },
);
// The initial tool_call should include terminal_info in _meta
expect(toolUseNotifications).toHaveLength(1);
expect((toolUseNotifications[0].update as any)._meta).toMatchObject({
terminal_info: { terminal_id: "toolu_bash_hook" },
});
// Step 2: Process bash result — produces separate terminal_output and terminal_exit notifications
const bashResult: BetaBashCodeExecutionResultBlock = {
type: "bash_code_execution_result",
stdout: "file1.txt",
stderr: "",
return_code: 0,
content: [],
};
const toolResult: BetaBashCodeExecutionToolResultBlock = {
type: "bash_code_execution_tool_result",
tool_use_id: "toolu_bash_hook",
content: bashResult,
};
const resultNotifications = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
{ clientCapabilities },
);
// Should produce 2 notifications: terminal_output, then terminal_exit + completion
expect(resultNotifications).toHaveLength(2);
// First: terminal_output only
expect((resultNotifications[0].update as any)._meta).toEqual({
terminal_output: { terminal_id: "toolu_bash_hook", data: "file1.txt" },
});
// Second: terminal_exit + status
expect((resultNotifications[1].update as any)._meta).toMatchObject({
terminal_exit: { terminal_id: "toolu_bash_hook", exit_code: 0, signal: null },
});
expect((resultNotifications[1].update as any).status).toBe("completed");
// Step 3: Fire the PostToolUse hook (simulates what Claude Code SDK does)
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command: "ls -la" },
tool_response: "file1.txt",
tool_use_id: "toolu_bash_hook",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_bash_hook",
{ signal: AbortSignal.abort() },
);
// Step 4: Hook update should only have claudeCode, no terminal fields
// (terminal events were already sent as separate notifications)
expect(hookUpdates).toHaveLength(1);
const hookMeta = hookUpdates[0].update._meta;
expect(hookMeta.claudeCode).toMatchObject({
toolName: "Bash",
toolResponse: "file1.txt",
});
expect(hookMeta.terminal_info).toBeUndefined();
expect(hookMeta.terminal_output).toBeUndefined();
expect(hookMeta.terminal_exit).toBeUndefined();
});
it("should not include terminal _meta in hook update when client lacks terminal_output support", async () => {
const toolUseCache: ToolUseCache = {};
const hookUpdates: any[] = [];
const mockClientWithUpdate = {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
// Process tool_use (registers hook)
toAcpNotifications(
[
{
type: "tool_use" as const,
id: "toolu_bash_no_term",
name: "Bash",
input: { command: "echo hi" },
},
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
// No clientCapabilities — terminal_output not supported
);
// Process bash result
const bashResult: BetaBashCodeExecutionResultBlock = {
type: "bash_code_execution_result",
stdout: "hi",
stderr: "",
return_code: 0,
content: [],
};
toAcpNotifications(
[
{
type: "bash_code_execution_tool_result",
tool_use_id: "toolu_bash_no_term",
content: bashResult,
} as BetaBashCodeExecutionToolResultBlock,
],
"assistant",
"test-session",
toolUseCache,
mockClientWithUpdate,
mockLogger,
);
// Fire hook
const hook = createPostToolUseHook(mockLogger);
await hook(
{
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command: "echo hi" },
tool_response: "hi",
tool_use_id: "toolu_bash_no_term",
session_id: "test-session",
transcript_path: "/tmp/test",
cwd: "/tmp",
},
"toolu_bash_no_term",
{ signal: AbortSignal.abort() },
);
// Hook update should only have claudeCode, no terminal fields
expect(hookUpdates).toHaveLength(1);
const hookMeta = hookUpdates[0].update._meta;
expect(hookMeta.claudeCode).toBeDefined();
expect(hookMeta.terminal_info).toBeUndefined();
expect(hookMeta.terminal_output).toBeUndefined();
expect(hookMeta.terminal_exit).toBeUndefined();
});
});
});

View File

@@ -1,558 +0,0 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { spawnSync } from "child_process";
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"TypeScript declaration files integration",
() => {
let tempDir: string;
let tarballPath: string;
const projectRoot = path.resolve(__dirname, "../..");
// Base configuration templates
const basePackageJson = {
name: "ts-declaration-test",
version: "1.0.0",
type: "module",
dependencies: {},
devDependencies: {
typescript: "5.9.3",
"@types/node": "25.0.3",
},
};
const baseTsConfig = {
compilerOptions: {
target: "ES2020",
module: "NodeNext",
moduleResolution: "NodeNext",
lib: ["ES2020"],
strict: true,
esModuleInterop: true,
skipLibCheck: true, // Skip checking dependency internals, focus on our types
noEmit: true,
declaration: false,
},
include: ["*.ts"],
};
// Build and pack the package once for all tests
beforeAll(async () => {
// Step 1: Clean dist folder to ensure fresh build
const distPath = path.join(projectRoot, "dist");
await fs.promises.rm(distPath, { recursive: true, force: true });
console.log("Cleaned dist folder");
console.log("Building package...");
// Step 2: Build the package
const buildResult = spawnSync("npm", ["run", "build"], {
cwd: projectRoot,
stdio: "pipe",
encoding: "utf-8",
});
if (buildResult.status !== 0) {
throw new Error(`Build failed: ${buildResult.stderr || buildResult.stdout}`);
}
console.log("Packing package...");
// Step 3: Pack to create tarball
const packResult = spawnSync("npm", ["pack", "--pack-destination", os.tmpdir()], {
cwd: projectRoot,
stdio: "pipe",
encoding: "utf-8",
});
if (packResult.status !== 0) {
throw new Error(`Pack failed: ${packResult.stderr || packResult.stdout}`);
}
// Get the tarball filename from stdout (npm pack outputs the filename)
const tarballName = packResult.stdout.trim().split("\n").pop();
if (!tarballName) {
throw new Error("Failed to get tarball name from npm pack output");
}
tarballPath = path.join(os.tmpdir(), tarballName);
console.log(`Tarball created at: ${tarballPath}`);
}, 60000); // 60 second timeout for build
// Clean up the tarball after all tests
afterAll(async () => {
if (tarballPath && fs.existsSync(tarballPath)) {
await fs.promises.unlink(tarballPath);
console.log("Cleaned up tarball");
}
});
// Create fresh temp directory for each test
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ts-declaration-test-"));
});
// Clean up temp directory after each test
afterEach(async () => {
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
});
// Helper function to set up a test TypeScript project
async function setupTestProject(
packageJson: object = basePackageJson,
tsconfig: object = baseTsConfig,
): Promise<void> {
// Write package.json
await fs.promises.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(packageJson, null, 2),
);
// Write tsconfig.json
await fs.promises.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(tsconfig, null, 2),
);
// Install all dependencies (TypeScript, @types/node, and the tarball)
console.log(`Installing dependencies in ${tempDir}...`);
const installResult = spawnSync("npm", ["install", tarballPath], {
cwd: tempDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 60000, // 60 second timeout
});
if (installResult.status !== 0) {
throw new Error(`npm install failed: ${installResult.stderr || installResult.stdout}`);
}
}
// Helper function to run TypeScript type checking
function runTypeCheck(srcDir: string = tempDir): {
success: boolean;
output: string;
} {
const result = spawnSync("npx", ["tsc", "--noEmit"], {
cwd: srcDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 30000, // 30 second timeout
});
return {
success: result.status === 0,
output: result.stdout + result.stderr,
};
}
// Helper function to write a test TypeScript file
async function writeTestFile(filename: string, content: string): Promise<void> {
await fs.promises.writeFile(path.join(tempDir, filename), content);
}
// Test 1: Main exports import verification
it("should successfully type-check main exports", async () => {
await setupTestProject();
await writeTestFile(
"test-main-exports.ts",
`
import {
ClaudeAcpAgent,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
SettingsManager,
createMcpServer,
loadManagedSettings,
applyEnvironmentSettings,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
toolInfoFromToolUse,
planEntries,
toolUpdateFromToolResult,
createPreToolUseHook,
toolNames,
} from "@zed-industries/claude-agent-acp";
// Type-only imports
import type {
ToolUpdateMeta,
NewSessionMeta,
ClaudeCodeSettings,
PermissionSettings,
PermissionDecision,
PermissionCheckResult,
SettingsManagerOptions,
ClaudePlanEntry,
} from "@zed-industries/claude-agent-acp";
// Verify exports exist and have expected types
const _runAcp: typeof runAcp = runAcp;
const _createMcpServer: typeof createMcpServer = createMcpServer;
const _toolNames: typeof toolNames = toolNames;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 2: Deep imports verification (backwards compatibility)
it("should successfully type-check deep imports", async () => {
await setupTestProject();
await writeTestFile(
"test-deep-imports.ts",
`
// Deep import from dist/tools.js
import {
acpToolNames,
EDIT_TOOL_NAMES,
ACP_TOOL_NAME_PREFIX,
toolInfoFromToolUse,
planEntries,
} from "@zed-industries/claude-agent-acp/dist/tools.js";
// Deep import from dist/settings.js
import {
SettingsManager,
getManagedSettingsPath,
} from "@zed-industries/claude-agent-acp/dist/settings.js";
// Deep import from dist/utils.js
import {
Pushable,
nodeToWebReadable,
nodeToWebWritable,
loadManagedSettings,
} from "@zed-industries/claude-agent-acp/dist/utils.js";
// Verify types work
const prefix: string = ACP_TOOL_NAME_PREFIX;
const editTools: readonly string[] = EDIT_TOOL_NAMES;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 3: SettingsManager type shape verification
it("should verify SettingsManager has correct type shape", async () => {
await setupTestProject();
await writeTestFile(
"test-settings-manager.ts",
`
import {
SettingsManager,
ClaudeCodeSettings,
PermissionCheckResult,
SettingsManagerOptions
} from "@zed-industries/claude-agent-acp";
// Test constructor signature
const options: SettingsManagerOptions = {
onChange: () => {},
logger: { log: console.log, error: console.error },
};
declare const cwd: string;
const manager = new SettingsManager(cwd, options);
// Test method signatures
async function testMethods() {
// initialize returns Promise<void>
await manager.initialize();
// checkPermission returns PermissionCheckResult
const result: PermissionCheckResult = manager.checkPermission(
"mcp__acp__Read",
{ file_path: "/some/path" }
);
// Verify decision type
const decision: "allow" | "deny" | "ask" = result.decision;
const rule: string | undefined = result.rule;
const source: "allow" | "deny" | "ask" | undefined = result.source;
// getSettings returns ClaudeCodeSettings
const settings: ClaudeCodeSettings = manager.getSettings();
// getCwd returns string
const currentCwd: string = manager.getCwd();
// setCwd returns Promise<void>
await manager.setCwd("/new/path");
// dispose returns void
manager.dispose();
}
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 4: ClaudeAcpAgent instantiation and type verification
it("should verify ClaudeAcpAgent has correct type shape", async () => {
await setupTestProject();
await writeTestFile(
"test-claude-acp-agent.ts",
`
import { ClaudeAcpAgent } from "@zed-industries/claude-agent-acp";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
// ClaudeAcpAgent should be a class that can be instantiated
declare const mockConnection: AgentSideConnection;
declare const mockLogger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
// Test constructor - accepts AgentSideConnection and optional logger
const agent = new ClaudeAcpAgent(mockConnection, mockLogger);
// Verify it has sessions property
const sessions: Record<string, any> = agent.sessions;
// Verify it has client property
const client: AgentSideConnection = agent.client;
// Verify it has logger property
const logger = agent.logger;
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 5: Type-only exports work correctly
it("should verify type-only exports are usable", async () => {
await setupTestProject();
await writeTestFile(
"test-type-exports.ts",
`
import type {
ClaudeCodeSettings,
PermissionSettings,
PermissionDecision,
PermissionCheckResult,
SettingsManagerOptions,
ClaudePlanEntry,
ToolUpdateMeta,
NewSessionMeta,
} from "@zed-industries/claude-agent-acp";
// Test ClaudeCodeSettings shape
const settings: ClaudeCodeSettings = {
permissions: {
allow: ["Read"],
deny: ["Read(./.env)"],
ask: ["Bash"],
additionalDirectories: ["/extra"],
defaultMode: "default",
},
env: {
API_KEY: "secret",
},
};
// Test PermissionSettings shape
const perms: PermissionSettings = {
allow: ["Read"],
deny: ["Write"],
};
// Test PermissionDecision
const decisions: PermissionDecision[] = ["allow", "deny", "ask"];
// Test ClaudePlanEntry shape
const planEntry: ClaudePlanEntry = {
content: "Do something",
status: "pending",
activeForm: "Doing something",
};
// Test valid status values
const validStatuses: ClaudePlanEntry["status"][] = [
"pending",
"in_progress",
"completed",
];
// Test ToolUpdateMeta shape
const toolMeta: ToolUpdateMeta = {
claudeCode: {
toolName: "Read",
toolResponse: { success: true },
},
};
// Test NewSessionMeta shape
const sessionMeta: NewSessionMeta = {
claudeCode: {
options: {},
},
};
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 6: Function signatures verification
it("should verify function signatures are correct", async () => {
await setupTestProject();
await writeTestFile(
"test-function-signatures.ts",
`
import {
runAcp,
createMcpServer,
toolInfoFromToolUse,
planEntries,
createPreToolUseHook,
loadManagedSettings,
applyEnvironmentSettings,
SettingsManager,
} from "@zed-industries/claude-agent-acp";
import type { ClaudeCodeSettings } from "@zed-industries/claude-agent-acp";
// runAcp should be a function with no parameters that returns void
const runAcpType: () => void = runAcp;
// toolInfoFromToolUse should accept any and return object with title and kind
const info = toolInfoFromToolUse({ name: "Read", input: {} });
const title: string = info.title;
const kind: string = info.kind;
// planEntries should accept todos array and return array
const entries = planEntries({
todos: [
{ content: "test", status: "pending", activeForm: "testing" }
]
});
// entries should be an array
const entriesArray: any[] = entries;
// createPreToolUseHook should accept SettingsManager and optional logger
declare const settingsManager: SettingsManager;
const hook = createPreToolUseHook(settingsManager, console);
// loadManagedSettings should return ClaudeCodeSettings | null
const managedSettings: ClaudeCodeSettings | null = loadManagedSettings();
// applyEnvironmentSettings should accept ClaudeCodeSettings and return void
const applyResult: void = applyEnvironmentSettings({ permissions: {} });
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 7: Pushable generic class verification
it("should verify Pushable class works correctly", async () => {
await setupTestProject();
await writeTestFile(
"test-pushable.ts",
`
import { Pushable } from "@zed-industries/claude-agent-acp";
// Pushable should be a generic class
const pushable = new Pushable<string>();
// Should have push method
pushable.push("test");
// Should have end method
pushable.end();
// Should implement AsyncIterable
async function consume() {
for await (const item of pushable) {
const str: string = item;
}
}
// Generic type parameter should work
const numPushable = new Pushable<number>();
numPushable.push(42);
interface MyType { id: number; name: string; }
const customPushable = new Pushable<MyType>();
customPushable.push({ id: 1, name: "test" });
`,
);
const result = runTypeCheck();
if (!result.success) {
console.error("TypeScript errors:", result.output);
}
expect(result.success).toBe(true);
}, 120000);
// Test 8: Verify incorrect types fail
it("should fail type-check with incorrect types", async () => {
await setupTestProject();
await writeTestFile(
"test-invalid-types.ts",
`
import { SettingsManager, ClaudeCodeSettings } from "@zed-industries/claude-agent-acp";
// This should fail - SettingsManager constructor requires string cwd
// @ts-expect-error - Testing that wrong argument type fails
const badManager = new SettingsManager(123);
// This should fail - ClaudeCodeSettings permissions must be an object
const badSettings: ClaudeCodeSettings = {
// @ts-expect-error - Testing that wrong type fails
permissions: "not-an-object",
};
`,
);
// This test should PASS because we expect tsc to catch these errors
// with @ts-expect-error directives
const result = runTypeCheck();
if (!result.success) {
// If it fails, it means @ts-expect-error didn't catch the error
// which could mean the types are too permissive
console.error("TypeScript errors (expected @ts-expect-error to catch):", result.output);
}
expect(result.success).toBe(true);
}, 120000);
},
);

View File

@@ -5,8 +5,19 @@ import {
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
import { SYSTEM_REMINDER } from "./mcp-server.js";
import * as diff from "diff";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import {
AgentInput,
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
GlobInput,
GrepInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import {
ImageBlockParam,
TextBlockParam,
@@ -16,50 +27,31 @@ import {
WebSearchToolResultError,
} from "@anthropic-ai/sdk/resources";
import {
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
BetaBashCodeExecutionToolResultError,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionResultBlock,
BetaCodeExecutionToolResultBlockParam,
BetaCodeExecutionToolResultError,
BetaImageBlockParam,
BetaRequestMCPToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionViewResultBlock,
BetaTextEditorCodeExecutionCreateResultBlock,
BetaTextEditorCodeExecutionStrReplaceResultBlock,
BetaTextEditorCodeExecutionToolResultBlockParam,
BetaTextEditorCodeExecutionToolResultError,
BetaTextEditorCodeExecutionViewResultBlock,
BetaToolReferenceBlock,
BetaToolResultBlockParam,
BetaToolSearchToolResultBlockParam,
BetaToolReferenceBlock,
BetaToolSearchToolSearchResultBlock,
BetaToolSearchToolResultError,
BetaWebFetchToolResultBlockParam,
BetaToolSearchToolSearchResultBlock,
BetaWebFetchBlock,
BetaWebFetchToolResultBlockParam,
BetaWebFetchToolResultErrorBlock,
BetaWebSearchToolResultBlockParam,
BetaImageBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
const acpUnqualifiedToolNames = {
read: "Read",
edit: "Edit",
write: "Write",
bash: "Bash",
killShell: "KillShell",
bashOutput: "BashOutput",
};
export const ACP_TOOL_NAME_PREFIX = "mcp__acp__";
export const acpToolNames = {
read: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.read,
edit: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.edit,
write: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.write,
bash: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bash,
killShell: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.killShell,
bashOutput: ACP_TOOL_NAME_PREFIX + acpUnqualifiedToolNames.bashOutput,
};
export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
import path from "node:path";
import { Logger } from "./acp-agent.js";
/**
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
@@ -84,9 +76,6 @@ type ToolResultContent =
| BetaTextEditorCodeExecutionCreateResultBlock
| BetaTextEditorCodeExecutionStrReplaceResultBlock
| BetaTextEditorCodeExecutionToolResultError;
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
import { SettingsManager } from "./settings.js";
interface ToolInfo {
title: string;
@@ -99,19 +88,52 @@ interface ToolUpdate {
title?: string;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
_meta?: {
terminal_info?: {
terminal_id: string;
};
terminal_output?: {
terminal_id: string;
data: string;
};
terminal_exit?: {
terminal_id: string;
exit_code: number;
signal: string | null;
};
};
}
export function toolInfoFromToolUse(toolUse: any): ToolInfo {
/**
* Convert an absolute file path to a project-relative path for display.
* Returns the original path if it's outside the project directory or if no cwd is provided.
*/
export function toDisplayPath(filePath: string, cwd?: string): string {
if (!cwd) return filePath;
const resolvedCwd = path.resolve(cwd);
const resolvedFile = path.resolve(filePath);
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
return path.relative(resolvedCwd, resolvedFile);
}
return filePath;
}
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
): ToolInfo {
const name = toolUse.name;
const input = toolUse.input;
switch (name) {
case "Task":
case "Agent":
case "Task": {
const input = toolUse.input as AgentInput | BashInput;
return {
title: input?.description ? input.description : "Task",
kind: "think",
content:
input && input.prompt
input && "prompt" in input
? [
{
type: "content",
@@ -120,38 +142,16 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
]
: [],
};
}
case "NotebookRead":
case "Bash": {
const input = toolUse.input as BashInput;
return {
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
kind: "read",
content: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "NotebookEdit":
return {
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
kind: "edit",
content:
input && input.new_source
? [
{
type: "content",
content: { type: "text", text: input.new_source },
},
]
: [],
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
};
case "Bash":
case acpToolNames.bash:
return {
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
title: input?.command ? input.command : "Terminal",
kind: "execute",
content:
input && input.description
content: supportsTerminalOutput
? [{ type: "terminal" as const, terminalId: toolUse.id }]
: input && input.description
? [
{
type: "content",
@@ -160,39 +160,25 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
]
: [],
};
}
case "BashOutput":
case acpToolNames.bashOutput:
return {
title: "Tail Logs",
kind: "execute",
content: [],
};
case "KillShell":
case acpToolNames.killShell:
return {
title: "Kill Process",
kind: "execute",
content: [],
};
case acpToolNames.read: {
case "Read": {
const input = toolUse.input as FileReadInput;
let limit = "";
if (input.limit) {
limit =
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
if (input.limit && input.limit > 0) {
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
} else if (input.offset) {
limit = " (from line " + (input.offset + 1) + ")";
limit = " (from line " + input.offset + ")";
}
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
return {
title: "Read " + (input.file_path ?? "File") + limit,
title: "Read " + displayPath + limit,
kind: "read",
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
line: input.offset ?? 1,
},
]
: [],
@@ -200,52 +186,8 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
}
case "Read":
return {
title: "Read File",
kind: "read",
content: [],
locations: input.file_path
? [
{
path: input.file_path,
line: input.offset ?? 0,
},
]
: [],
};
case "LS":
return {
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
kind: "search",
content: [],
locations: [],
};
case acpToolNames.edit:
case "Edit": {
const path = input?.file_path ?? input?.file_path;
return {
title: path ? `Edit \`${path}\`` : "Edit",
kind: "edit",
content:
input && path
? [
{
type: "diff",
path,
oldText: input.old_string ?? null,
newText: input.new_string ?? "",
},
]
: [],
locations: path ? [{ path }] : undefined,
};
}
case acpToolNames.write: {
case "Write": {
const input = toolUse.input as FileWriteInput;
let content: ToolCallContent[] = [];
if (input && input.file_path) {
content = [
@@ -264,33 +206,39 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: displayPath ? `Write ${displayPath}` : "Write",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case "Write":
case "Edit": {
const input = toolUse.input as FileEditInput;
let content: ToolCallContent[] = [];
if (input && input.file_path && (input.old_string || input.new_string)) {
content = [
{
type: "diff",
path: input.file_path,
oldText: input.old_string || null,
newText: input.new_string ?? "",
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Write ${input.file_path}` : "Write",
title: displayPath ? `Edit ${displayPath}` : "Edit",
kind: "edit",
content:
input && input.file_path
? [
{
type: "diff",
path: input.file_path,
oldText: null,
newText: input.content,
},
]
: [],
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
};
}
case "Glob": {
const input = toolUse.input as GlobInput;
let label = "Find";
if (input.path) {
label += ` \`${input.path}\``;
@@ -307,6 +255,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
}
case "Grep": {
const input = toolUse.input as GrepInput;
let label = "grep";
if (input["-i"]) {
@@ -328,13 +277,13 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
if (input.output_mode) {
switch (input.output_mode) {
case "FilesWithMatches":
case "files_with_matches":
label += " -l";
break;
case "Count":
case "count":
label += " -c";
break;
case "Content":
case "content":
default:
break;
}
@@ -371,7 +320,8 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
}
case "WebFetch":
case "WebFetch": {
const input = toolUse.input as WebFetchInput;
return {
title: input?.url ? `Fetch ${input.url}` : "Fetch",
kind: "fetch",
@@ -385,8 +335,10 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
]
: [],
};
}
case "WebSearch": {
const input = toolUse.input as WebSearchInput;
let label = `"${input.query}"`;
if (input.allowed_domains && input.allowed_domains.length > 0) {
@@ -404,7 +356,8 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
};
}
case "TodoWrite":
case "TodoWrite": {
const input = toolUse.input as TodoWriteInput;
return {
title: Array.isArray(input?.todos)
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
@@ -412,18 +365,18 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
kind: "think",
content: [],
};
}
case "ExitPlanMode":
case "ExitPlanMode": {
return {
title: "Ready to code?",
kind: "switch_mode",
content:
input && input.plan
? [{ type: "content", content: { type: "text", text: input.plan } }]
: [],
content: [],
};
}
case "Other": {
const input = toolUse.input;
let output;
try {
output = JSON.stringify(input, null, 2);
@@ -467,6 +420,7 @@ export function toolUpdateFromToolResult(
| BetaRequestMCPToolResultBlockParam
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
): ToolUpdate {
if (
"is_error" in toolResult &&
@@ -480,7 +434,6 @@ export function toolUpdateFromToolResult(
switch (toolUse?.name) {
case "Read":
case acpToolNames.read:
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -489,9 +442,9 @@ export function toolUpdateFromToolResult(
content.type === "text"
? {
type: "text",
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
text: markdownEscape(content.text),
}
: content,
: toAcpContentBlock(content, false),
})),
};
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
@@ -501,7 +454,7 @@ export function toolUpdateFromToolResult(
type: "content",
content: {
type: "text",
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
text: markdownEscape(toolResult.content),
},
},
],
@@ -509,59 +462,75 @@ export function toolUpdateFromToolResult(
}
return {};
case acpToolNames.edit: {
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
case "Bash": {
const result = toolResult.content;
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
const isError = "is_error" in toolResult && toolResult.is_error;
// Extract output and exit code from either format:
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
// 2. Plain string content from a regular tool_result
// 3. Array content (e.g. [{ type: "text", text: "..." }])
let output = "";
let exitCode = isError ? 1 : 0;
if (
Array.isArray(toolResult.content) &&
toolResult.content.length > 0 &&
"text" in toolResult.content[0] &&
typeof toolResult.content[0].text === "string"
result &&
typeof result === "object" &&
"type" in result &&
result.type === "bash_code_execution_result"
) {
const patches = diff.parsePatch(toolResult.content[0].text);
console.error(JSON.stringify(patches));
for (const { oldFileName, newFileName, hunks } of patches) {
for (const { lines, newStart } of hunks) {
const oldText = [];
const newText = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: newFileName || oldFileName, line: newStart });
content.push({
type: "diff",
path: newFileName || oldFileName,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
}
const bashResult = result as BetaBashCodeExecutionResultBlock;
output = bashResult.stdout || bashResult.stderr || "";
exitCode = bashResult.return_code;
} else if (typeof result === "string") {
output = result;
} else if (
Array.isArray(result) &&
result.length > 0 &&
"text" in result[0] &&
typeof result[0].text === "string"
) {
output = result.map((c: any) => c.text).join("\n");
}
const result: ToolUpdate = {};
if (content.length > 0) {
result.content = content;
if (supportsTerminalOutput) {
return {
content: [{ type: "terminal" as const, terminalId }],
_meta: {
terminal_info: {
terminal_id: terminalId,
},
terminal_output: {
terminal_id: terminalId,
data: output,
},
terminal_exit: {
terminal_id: terminalId,
exit_code: exitCode,
signal: null,
},
},
};
}
if (locations.length > 0) {
result.locations = locations;
// Fallback: format output as a code block without terminal _meta
if (output.trim()) {
return {
content: [
{
type: "content",
content: {
type: "text",
text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
},
},
],
};
}
return result;
return {};
}
case acpToolNames.bash:
case "edit":
case "Edit":
case acpToolNames.write:
case "Edit": // Edit is handled in hooks
case "Write": {
return {};
}
@@ -570,20 +539,6 @@ export function toolUpdateFromToolResult(
return { title: "Exited Plan Mode" };
}
case "Task":
case "NotebookEdit":
case "NotebookRead":
case "TodoWrite":
case "exit_plan_mode":
case "Bash":
case "BashOutput":
case "KillBash":
case "LS":
case "Glob":
case "Grep":
case "WebFetch":
case "WebSearch":
case "Other":
default: {
return toAcpContentUpdate(
toolResult.content,
@@ -721,6 +676,66 @@ export function markdownEscape(text: string): string {
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
}
interface EditToolResponseHunk {
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
lines: string[];
}
interface EditToolResponse {
filePath?: string;
structuredPatch?: EditToolResponseHunk[];
}
/**
* Builds diff ToolUpdate content from the structured Edit toolResponse provided
* by the PostToolUse hook. Unlike parsing the plain unified diff string, this uses
* the pre-parsed structuredPatch which supports multiple replacement sites (replaceAll)
* and always includes context lines for better readability.
*/
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
content?: ToolCallContent[];
locations?: ToolCallLocation[];
} {
if (!toolResponse || typeof toolResponse !== "object") return {};
const response = toolResponse as EditToolResponse;
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
const content: ToolCallContent[] = [];
const locations: ToolCallLocation[] = [];
for (const { lines, newStart } of response.structuredPatch) {
const oldText: string[] = [];
const newText: string[] = [];
for (const line of lines) {
if (line.startsWith("-")) {
oldText.push(line.slice(1));
} else if (line.startsWith("+")) {
newText.push(line.slice(1));
} else {
oldText.push(line.slice(1));
newText.push(line.slice(1));
}
}
if (oldText.length > 0 || newText.length > 0) {
locations.push({ path: response.filePath, line: newStart });
content.push({
type: "diff",
path: response.filePath,
oldText: oldText.join("\n") || null,
newText: newText.join("\n"),
});
}
}
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
if (content.length > 0) result.content = content;
if (locations.length > 0) result.locations = locations;
return result;
}
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
const toolUseCallbacks: {
[toolUseId: string]: {
@@ -778,54 +793,3 @@ export const createPostToolUseHook =
}
return { continue: true };
};
/**
* Creates a PreToolUse hook that checks permissions using the SettingsManager.
* This runs before the SDK's built-in permission rules, allowing us to enforce
* our own permission settings for ACP-prefixed tools.
*/
export const createPreToolUseHook =
(settingsManager: SettingsManager, logger: Logger = console): HookCallback =>
async (input: any, _toolUseID: string | undefined) => {
if (input.hook_event_name !== "PreToolUse") {
return { continue: true };
}
const toolName = input.tool_name;
const toolInput = input.tool_input;
const permissionCheck = settingsManager.checkPermission(toolName, toolInput);
if (permissionCheck.decision !== "ask") {
logger.log(
`[PreToolUseHook] Tool: ${toolName}, Decision: ${permissionCheck.decision}, Rule: ${permissionCheck.rule}`,
);
}
switch (permissionCheck.decision) {
case "allow":
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "allow" as const,
permissionDecisionReason: `Allowed by settings rule: ${permissionCheck.rule}`,
},
};
case "deny":
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason: `Denied by settings rule: ${permissionCheck.rule}`,
},
};
case "ask":
default:
// Let the normal permission flow continue
return { continue: true };
}
};

View File

@@ -105,73 +105,3 @@ export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
}
}
}
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;
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,
};
}
// Helper to encode a path like Claude does:
// - Unix: "/Users/test" -> "-Users-test"
// - Windows: "C:\Users\test" -> "C--Users-test"
export function encodeProjectPath(cwd: string): string {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2021",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020"],
"lib": ["ES2021"],
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,