Compare commits

..

52 Commits

Author SHA1 Message Date
Ben Brandt
95721e4e39 Prep 0.23.0 (#465) 2026-03-25 22:18:12 +00:00
Ben Brandt
7f1c6c2078 Use idle session state as end of turn (#463)
According to the type docs, this should be a more reliable indicator of
turn end:

> Mirrors notifySessionStateChanged. 'idle' fires after heldBackResult
  flushes and the bg-agent do-while exits — authoritative turn-over
  signal.
2026-03-25 22:50:01 +01:00
Ben Brandt
3500b6c16e Update claude-agent-sdk to 0.2.83 (#462) 2026-03-25 13:48:23 +00:00
qystishere
07db59ef0d Fix handling of local-only slash commands (#432)
Instead of parsing <local-command-stdout> tags from the message stream,
intercept these commands early and forward
the clean result from the success handler directly to the client.
Applies to /context, /heapdump, and /extra-usage.
2026-03-24 21:23:26 +00:00
tbounsiar
34c847a9e3 fix: correct null check for gatewayAuthMeta in subscription validation (#455)
## Summary

- `gatewayAuthMeta` is declared as an optional property (`undefined`
when unset), but the subscription validation compared it against `null`
- This made the condition always false, so `--hide-claude-auth` never
blocked claude.ai subscription users when no gateway was configured
- Changed `=== null` to `!this.gatewayAuthMeta` to match the actual
property semantics

## Test plan

- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] All 6 authorization tests pass
2026-03-24 21:22:02 +00:00
tbounsiar
d2428a49ef fix: include both stdout and stderr in Bash tool output (#456)
## Summary

- When a Bash tool execution produced both stdout and stderr, only
stdout was shown due to the `||` operator short-circuiting
- Changed to concatenate both streams with `[stdout,
stderr].filter(Boolean).join("\n")` so error output is no longer
silently dropped

## Test plan

- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] All 34 tools tests pass
2026-03-24 21:21:30 +00:00
tbounsiar
91d629d6c6 fix: prevent prompt loop hang when cancel races with first result (#458)
Fixes #442

## Summary

- When `cancel()` was called immediately after a prompt, the result
message arrived before `promptReplayed` was set to `true`
- The `!promptReplayed` check consumed it as a "background task result",
making the `session.cancelled` check unreachable
- The loop then blocked forever on `query.next()` since no further
messages would arrive
- Fix: move the `session.cancelled` check **before** the
`!promptReplayed` check so cancellation is always detected

## Test plan

- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] All unit tests pass
- [x] Logic verified against the race condition sequence described in
#442
2026-03-24 21:19:35 +00:00
tbounsiar
5fd7873c7c fix: dispose SettingsManager on session close to prevent resource leaks (#454)
## Summary

- `SettingsManager.dispose()` was never called when a session was closed
via `unstable_closeSession` or when the Claude Agent process died
unexpectedly
- Each leaked session left up to 4 active `fs.watch()` file descriptors
and a pending debounce timer
- Added `session.settingsManager.dispose()` in both cleanup paths and
updated test mocks to assert the call

## Test plan

- [x] `npm run build` compiles clean
- [x] `npm run lint` passes
- [x] Existing `session/close` tests updated to verify `dispose()` is
called
- [x] All 4 session/close tests pass
2026-03-24 21:19:02 +00:00
Cengiz Han
83496544b2 fix: restore plan content in ExitPlanMode tool call (#451)
## Summary

The plan text was accidentally dropped from the `ExitPlanMode` content
array during the switch to built-in Claude Code tools (#316, commit
`be618f5`). This meant ACP clients could not display the plan when
asking the user for approval — they only saw the title "Ready to code?"
with no plan body.

We noticed this in the Fabriqa app where our plan approval UI was
rendering an empty card.

## What changed

Restored the original `input.plan` → `content` mapping in
`toolInfoFromToolUse()` so the plan markdown is included in the
`tool_call` and `request_permission` events.

**Before (broken):**
```typescript
case "ExitPlanMode": {
  return {
    title: "Ready to code?",
    kind: "switch_mode",
    content: [],
  };
}
```

**After (fixed):**
```typescript
case "ExitPlanMode": {
  const planInput = toolUse.input as { plan?: string };
  return {
    title: "Ready to code?",
    kind: "switch_mode",
    content:
      planInput?.plan
        ? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
        : [],
  };
}
```

## Where does `input.plan` come from?

The Claude Agent SDK passes tool inputs to the `canUseTool` callback as
`Record<string, unknown>` (see `Options.canUseTool` in
`@anthropic-ai/claude-agent-sdk/sdk.d.ts`). When Claude calls
`ExitPlanMode`, the model includes the plan text in the input.

The formal `ExitPlanModeInput` type (in
`@anthropic-ai/claude-agent-sdk/sdk-tools.d.ts`) only declares
`allowedPrompts`, but it has a `[k: string]: unknown` index signature
that allows additional properties. The model consistently sends `plan`
(string) and `planFilePath` (string). SDK v0.2.76 explicitly added
`planFilePath` to the tool input for hooks and SDK consumers, confirming
these fields are intentional.

The original implementation (commit `0289dd9`, "Support plan mode and
all other permission modes" #40) relied on `input.plan` and worked
correctly until the refactor in #316 dropped it.

### SDK history confirms `input.plan` is reliable

The Claude Agent SDK itself had a regression where `ExitPlanMode` input
was empty
([anthropics/claude-code#12288](https://github.com/anthropics/claude-code/issues/12288)):

| Version | Status |
|---------|--------|
| Claude Code 2.0.34 | `input.plan` populated |
| Claude Code 2.0.51 | Regression — `input: {}` empty |
| Agent SDK 0.1.54 | Fixed — `input.plan` restored |
| Agent SDK 0.2.76 (current) | Working — also added `planFilePath` |

The SDK team treated the empty input as a critical bug and fixed it
promptly. The current SDK (0.2.76) reliably sends `input.plan`, so this
adapter fix is safe to depend on.

## Test plan

- [x] Added test: plan text is included in content when `input.plan` is
provided
- [x] Added test: content is empty when `input.plan` is not provided
- [x] All existing tests pass

Fixes #450
2026-03-24 22:16:16 +01:00
dependabot[bot]
c5d5c11f40 Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1 directory (#445)
Bumps the npm_and_yarn group with 1 update in the / directory:
[flatted](https://github.com/WebReflection/flatted).

Updates `flatted` from 3.4.1 to 3.4.2
<details>
<summary>Commits</summary>
<ul>
<li><a
href="3bf09091c3"><code>3bf0909</code></a>
3.4.2</li>
<li><a
href="885ddcc33c"><code>885ddcc</code></a>
fix CWE-1321</li>
<li><a
href="0bdba705d1"><code>0bdba70</code></a>
added flatted-view to the benchmark</li>
<li>See full diff in <a
href="https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=flatted&package-manager=npm_and_yarn&previous-version=3.4.1&new-version=3.4.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/zed-industries/claude-agent-acp/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 10:28:16 +01:00
Ben Brandt
521d1f766d Prep 0.22.2 release (#440) 2026-03-18 14:26:49 +00:00
Egor Baranov
f11435df13 Support additional skill directories via additionalRoots meta parameter (#406)
Add support for `_meta.additionalRoots` in `claude-agent-acp`.

This change introduces a top-level `additionalRoots` field in session
metadata and forwards it to Claude by merging it into
`additionalDirectories` when creating a session.

Behavior:
- reads `_meta.additionalRoots` from the ACP session request
- treats it as a `string[]`
- merges it with any user-provided
`claudeCode.options.additionalDirectories`
- passes the combined list through to Claude unchanged

This keeps `additionalRoots` as an ACP-level extension rather than a
Claude-specific option, while mapping it onto Claude's existing
`additionalDirectories` support.
2026-03-18 14:20:30 +00:00
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
20 changed files with 2832 additions and 1412 deletions

View File

@@ -18,26 +18,29 @@ jobs:
matrix:
include:
- target: bun-linux-x64
artifact: claude-agent-acp-linux-x64
artifact: claude-agent-acp
archive: claude-agent-acp-linux-x64.tar.gz
- target: bun-linux-arm64
artifact: claude-agent-acp-linux-arm64
artifact: claude-agent-acp
archive: claude-agent-acp-linux-arm64.tar.gz
- target: bun-linux-x64-musl
artifact: claude-agent-acp-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-linux-arm64-musl
artifact: claude-agent-acp
archive: claude-agent-acp-linux-arm64-musl.tar.gz
- target: bun-darwin-x64
artifact: claude-agent-acp-darwin-x64
artifact: claude-agent-acp
archive: claude-agent-acp-darwin-x64.zip
- target: bun-darwin-arm64
artifact: claude-agent-acp-darwin-arm64
artifact: claude-agent-acp
archive: claude-agent-acp-darwin-arm64.zip
- target: bun-windows-x64
artifact: claude-agent-acp-windows-x64.exe
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
@@ -51,7 +54,7 @@ jobs:
- name: Build binary
run: |
bun build src/static-entry.ts \
bun build src/index.ts \
--compile \
--target ${{ matrix.target }} \
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \

3
.prettierignore Normal file
View File

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

View File

@@ -1,5 +1,71 @@
# Changelog
## 0.23.0
- Use idle session state as end of turn (#463)
- Update claude-agent-sdk to 0.2.83 (#462)
- Fix handling of local-only slash commands (#432)
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
- fix: include both stdout and stderr in Bash tool output (#456)
- fix: prevent prompt loop hang when cancel races with first result (#458)
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
- fix: restore plan content in ExitPlanMode tool call (#451)
## 0.22.2
- Add experimental meta param for testing additional directories
## 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

1626
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.19.0",
"version": "0.23.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,21 +60,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.62"
"@agentclientprotocol/sdk": "0.17.0",
"@anthropic-ai/claude-agent-sdk": "0.2.83",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@anthropic-ai/sdk": "0.80.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.2",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"@types/node": "25.5.0",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"eslint": "10.1.0",
"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.1"
}
}

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,7 +15,12 @@ export {
Pushable,
unreachable,
} from "./utils.js";
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
export {
toolInfoFromToolUse,
toDisplayPath,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
export {
SettingsManager,
type ClaudeCodeSettings,

View File

@@ -14,7 +14,12 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
*/
export interface PermissionSettings {
defaultMode?: string;
}
export interface ClaudeCodeSettings {
permissions?: PermissionSettings;
env?: Record<string, string>;
model?: string;
}
@@ -162,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;

View File

@@ -1,7 +0,0 @@
if (process.argv.includes("--cli")) {
process.argv = process.argv.filter((arg) => arg !== "--cli");
// @ts-expect-error -- no types
await import("@anthropic-ai/claude-agent-sdk/cli.js");
} else {
await import("./index.js");
}

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,
@@ -20,10 +20,12 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import {
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.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 {
@@ -226,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({
@@ -247,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);
});
@@ -562,6 +564,20 @@ describe("tool conversions", () => {
});
});
it("should use relative path in title when cwd is provided", () => {
const tool_use = {
type: "tool_use",
id: "toolu_01READ_CWD",
name: "Read",
input: { file_path: "/Users/test/project/src/main.ts" },
};
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", () => {
const received: SDKAssistantMessage = {
type: "assistant",
@@ -968,6 +984,22 @@ 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({});
@@ -1139,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();
@@ -1228,3 +1265,343 @@ describe("permission requests", () => {
}
});
});
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: { dispose: vi.fn() } 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 }),
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
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;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
agent.sessions["test-session"] = {
query: messageGenerator() as any,
input,
cwd: "/tmp/test",
cancelled: false,
modes: {
currentModeId: "default",
availableModes: [],
},
models: {
currentModelId: "default",
availableModels: [],
},
settingsManager: { dispose: vi.fn() } 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: { dispose: vi.fn() } 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();
expect(session.settingsManager.dispose).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,71 @@
import { mkdtemp, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: ({ options }: { options: Options }) => {
capturedOptions = 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 () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
describe("additionalRoots", () => {
let agent: ClaudeAcpAgentType;
const tempDirs: string[] = [];
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
agent.newSession({ cwd, mcpServers: [], _meta: meta });
beforeEach(async () => {
capturedOptions = undefined;
tempDirs.length = 0;
vi.resetModules();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection);
});
afterEach(
async () =>
void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))),
);
it("passes through relative roots as provided", async () => {
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-"));
tempDirs.push(projectRoot);
await newSession({ additionalRoots: ["."] }, projectRoot);
expect(capturedOptions!.additionalDirectories).toEqual(["."]);
});
it("merges additionalRoots with user additionalDirectories without normalization", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-"));
tempDirs.push(root);
await newSession({
additionalRoots: ["", root],
claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } },
});
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
});
});

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

@@ -102,7 +102,7 @@ describe("createSession options merging", () => {
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
});
it("includes both user and built-in disallowed tools when disableBuiltInTools is true", async () => {
it("sets tools to empty array when disableBuiltInTools is true", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
@@ -116,15 +116,11 @@ describe("createSession options merging", () => {
},
});
const disallowed = capturedOptions!.disallowedTools!;
// User-provided
expect(disallowed).toContain("CustomTool");
// ACP internal
expect(disallowed).toContain("AskUserQuestion");
// Built-in tools disabled by disableBuiltInTools
expect(disallowed).toContain("Read");
expect(disallowed).toContain("Write");
expect(disallowed).toContain("Bash");
// 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 () => {
@@ -151,6 +147,112 @@ describe("createSession options merging", () => {
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",

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

@@ -224,6 +224,61 @@ describe("session config options", () => {
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,

View File

@@ -52,5 +52,42 @@ describe("SettingsManager", () => {
settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-haiku");
});
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: {
defaultMode: "dontAsk",
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
let settings = settingsManager.getSettings();
expect(settings.permissions?.defaultMode).toBe("dontAsk");
// Local settings override the mode
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
permissions: {
defaultMode: "plan",
},
}),
);
settingsManager.dispose();
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
settings = settingsManager.getSettings();
expect(settings.permissions?.defaultMode).toBe("plan");
});
});
});

View File

@@ -11,7 +11,7 @@ import {
BetaBashCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
import { toolUpdateFromToolResult, createPostToolUseHook, toolInfoFromToolUse } from "../tools.js";
describe("rawOutput in tool call updates", () => {
const mockClient = {} as AgentSideConnection;
@@ -1235,3 +1235,39 @@ describe("Bash terminal output", () => {
});
});
});
describe("toolInfoFromToolUse - ExitPlanMode", () => {
it("should include plan text in content when input.plan is provided", () => {
const toolUse = {
name: "ExitPlanMode",
id: "toolu_plan_1",
input: {
plan: "# My Plan\n\n## Step 1\nDo something",
planFilePath: "/tmp/plan.md",
},
};
const info = toolInfoFromToolUse(toolUse, false);
expect(info.kind).toBe("switch_mode");
expect(info.title).toBe("Ready to code?");
expect(info.content).toHaveLength(1);
expect(info.content![0]).toEqual({
type: "content",
content: { type: "text", text: "# My Plan\n\n## Step 1\nDo something" },
});
});
it("should return empty content when input.plan is not provided", () => {
const toolUse = {
name: "ExitPlanMode",
id: "toolu_plan_2",
input: {},
};
const info = toolInfoFromToolUse(toolUse, false);
expect(info.kind).toBe("switch_mode");
expect(info.content).toEqual([]);
});
});

View File

@@ -5,6 +5,19 @@ import {
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
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,
@@ -14,29 +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";
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.
@@ -61,20 +76,6 @@ type ToolResultContent =
| BetaTextEditorCodeExecutionCreateResultBlock
| BetaTextEditorCodeExecutionStrReplaceResultBlock
| BetaTextEditorCodeExecutionToolResultError;
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import { Logger } from "./acp-agent.js";
import {
AgentInput,
BashInput,
FileEditInput,
FileReadInput,
FileWriteInput,
GlobInput,
GrepInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
interface ToolInfo {
title: string;
@@ -103,13 +104,29 @@ interface ToolUpdate {
};
}
/**
* 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;
switch (name) {
case "Agent":
case "Task": {
const input = toolUse.input as AgentInput | BashInput;
return {
@@ -153,8 +170,9 @@ export function toolInfoFromToolUse(
} else if (input.offset) {
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
? [
@@ -188,8 +206,9 @@ export function toolInfoFromToolUse(
},
];
}
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 }] : [],
@@ -209,8 +228,9 @@ export function toolInfoFromToolUse(
},
];
}
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
return {
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
title: displayPath ? `Edit ${displayPath}` : "Edit",
kind: "edit",
content,
locations: input?.file_path ? [{ path: input.file_path }] : [],
@@ -348,10 +368,13 @@ export function toolInfoFromToolUse(
}
case "ExitPlanMode": {
const planInput = toolUse.input as { plan?: string };
return {
title: "Ready to code?",
kind: "switch_mode",
content: [],
content: planInput?.plan
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
: [],
};
}
@@ -461,7 +484,7 @@ export function toolUpdateFromToolResult(
result.type === "bash_code_execution_result"
) {
const bashResult = result as BetaBashCodeExecutionResultBlock;
output = bashResult.stdout || bashResult.stderr || "";
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
exitCode = bashResult.return_code;
} else if (typeof result === "string") {
output = result;