Compare commits

...

17 Commits

Author SHA1 Message Date
acp-release-bot[bot]
d5e1b7fa72 chore(main): release 0.49.0 (#795)
🤖 I have created a release *beep* *boop*
---


##
[0.49.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.48.0...v0.49.0)
(2026-06-22)


### Features

* Update to claude-agent-sdk 0.3.185
([#798](https://github.com/agentclientprotocol/claude-agent-acp/issues/798))
([8dc8c86](8dc8c86426))


### Bug Fixes

* Deduplicate streamed assistant blocks by content
([#800](https://github.com/agentclientprotocol/claude-agent-acp/issues/800))
([960f62d](960f62d765))
* Infer 1M context from model descriptions
([#799](https://github.com/agentclientprotocol/claude-agent-acp/issues/799))
([508453c](508453c288))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-06-22 13:48:57 +02:00
Ben Brandt
960f62d765 fix: Deduplicate streamed assistant blocks by content (#800)
It seems some providers are still having issues here...
2026-06-22 12:46:49 +02:00
Ben Brandt
508453c288 fix: Infer 1M context from model descriptions (#799) 2026-06-22 12:37:07 +02:00
Ben Brandt
8dc8c86426 feat: Update to claude-agent-sdk 0.3.185 (#798) 2026-06-22 12:10:34 +02:00
acp-release-bot[bot]
b4b7f561e2 chore(main): release 0.48.0 (#788)
🤖 I have created a release *beep* *boop*
---


##
[0.48.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.47.0...v0.48.0)
(2026-06-19)


### Features

* Agent selection dropdown in config options
([#794](https://github.com/agentclientprotocol/claude-agent-acp/issues/794))
([5729c47](5729c471f9))
* **deps:** bump the minor group with 11 updates
([#787](https://github.com/agentclientprotocol/claude-agent-acp/issues/787))
([ad3b5fe](ad3b5fe745))
* Update to claude-agent-sdk 0.3.183
([#791](https://github.com/agentclientprotocol/claude-agent-acp/issues/791))
([744b2d4](744b2d4112))
* Update to new ACP SDK patterns
([#790](https://github.com/agentclientprotocol/claude-agent-acp/issues/790))
([2554c7b](2554c7bf98))


### Bug Fixes

* duplicate assistant text when turn activates mid-message
([#789](https://github.com/agentclientprotocol/claude-agent-acp/issues/789))
([1c80bf8](1c80bf8e56))
* Skip empty thinking chunks
([#793](https://github.com/agentclientprotocol/claude-agent-acp/issues/793))
([15fdf26](15fdf26fc7))
* surface Bash tool image output instead of dropping it
([#617](https://github.com/agentclientprotocol/claude-agent-acp/issues/617))
([a759e64](a759e64ef6))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-06-19 13:19:37 +02:00
Luca Dibattista
41cde99fd7 test: isolate CLAUDE_CONFIG_DIR in settings tests to stop user-tier leak (#769)
## Summary

`src/tests/settings.test.ts` didn't isolate the **user settings tier**,
so the `availableModels` merge tests failed on any machine whose real
`~/.claude/settings.json` defines `availableModels`.

`SettingsManager.loadAllSettings()` resolves through the SDK's
`resolveSettings({ cwd })`, which reads the user `settings.json` from
`process.env.CLAUDE_CONFIG_DIR` at call time and falls back to the real
`~/.claude` when it's unset. The tests set up only the project/local
tiers under a temp `cwd` and left the user tier pointing at the
developer's real home, so the developer's own settings merged into the
result.

This only surfaced for `availableModels` because that key is **unioned**
across tiers — leaked entries get prepended. `model` and
`permissions.defaultMode` use override semantics (project tier wins), so
they masked the leak. Example failure with `availableModels:
["sonnet","opus","haiku"]` in the user's real settings:

```
- Expected
+ Received
+   "sonnet",
+   "opus",
+   "haiku",
    "claude-haiku-4-5",
    "claude-opus-4-7[1m]",
    "claude-sonnet-4-6[1m]",
```

## Fix

Point `CLAUDE_CONFIG_DIR` at an empty per-test directory in `beforeEach`
(and restore it in `afterEach`) so the user tier contributes nothing.
This mirrors the isolation already used in `acp-agent-settings.test.ts`.

Test-only change; no production code touched.

## Test Plan

- `settings.test.ts` run plainly with a real `~/.claude` containing
`availableModels` → 10 passed (previously 2 failing).
- `settings.test.ts` run with `CLAUDE_CONFIG_DIR` pointed at a directory
holding decoy `availableModels`/`model` → 10 passed, confirming
`beforeEach` overrides any external user tier.
- Full suite with no env wrapper → 355 passed, 0 failed.
- lint + prettier clean.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:19:12 +00:00
Ben Brandt
5729c471f9 feat: Agent selection dropdown in config options (#794)
- **feat: add agent selection as ACP session config option**
- **fix: harden agent selection edge cases + SDK roster guard**
- **fix: reserve "default" agent sentinel to prevent collision**

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:17:00 +02:00
David Alvarez
a759e64ef6 fix: surface Bash tool image output instead of dropping it (#617)
## What's broken

The Bash branch of `toolUpdateFromToolResult` (`src/tools.ts`) guards
the array-content path on `"text" in result[0]`. When the local Bash
tool produces image content — `[{ type: "image", source: { type:
"base64", ... } }]`, e.g. when a command pipes a base64 data URI — the
guard rejects the array and `output` stays empty. The image is silently
dropped from the ACP `tool_call_update` and the client renders nothing.

## Fix

Distinguish text-only arrays (existing terminal-stream behavior) from
arrays containing non-text blocks. For the latter, bypass the terminal
`_meta` channel (binary payloads can't be streamed through it) and
delegate to `toAcpContentUpdate` so images surface as ACP content
blocks.

The change is contained to the Bash case in `toolUpdateFromToolResult`;
no other tool dispatch is affected.

## Tests

Three regression tests added in `src/tests/tools.test.ts` under a new
`describe("with image array tool_result …")`:

- pure image, `supportsTerminalOutput: false` — image surfaces as ACP
content
- pure image, `supportsTerminalOutput: true` — `_meta` is correctly
bypassed and image still surfaces
- mixed text + image — both blocks surface in order

All existing tests pass. Total: 240 → 243.

## Validation

- `npm run check` ✅
- `npm run build` ✅
- `npm run test:run` ✅ (243 passed, 13 integration skipped)

Co-authored-by: dave <dave@mint.lan>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-06-19 10:27:32 +00:00
Ben Brandt
15fdf26fc7 fix: Skip empty thinking chunks (#793)
These carried a signature but we don't need that in the updates if it is
empty.
2026-06-19 10:18:36 +00:00
Ben Brandt
9f38cb67df test: Improve tmp dirs in tests (#792) 2026-06-19 12:08:11 +02:00
Ben Brandt
744b2d4112 feat: Update to claude-agent-sdk 0.3.183 (#791) 2026-06-19 10:03:32 +00:00
Ben Brandt
2554c7bf98 feat: Update to new ACP SDK patterns (#790) 2026-06-19 11:48:41 +02:00
Hugo Baraúna
1c80bf8e56 fix: duplicate assistant text when turn activates mid-message (#789)
I generated this PR using AI. if not helpful, please delete it.

Or, maybe its value is just to show a potential bug.

I found this bug when using Tidewave. I noticed text was being
duplicated when using claude and claude-agent-acp 0.47.0.

I asked the agent to investigate, and this PR seems to fix it.

---

## Summary

- Stop `resetTurnScratch()` from nulling `currentStreamMessageId` on
turn activation, which was causing the assistant's message text to
render **twice** (concatenated) in the feed.

## Problem

When a turn activates mid-message, the agent's message text renders
twice, back-to-back. Only prose duplicates — never tool calls — and
specifically the text/thinking block that streams *after* the
activation. Reproduces on `0.47.0` with a prompt like *"say a sentence,
run a shell command that sleeps 10s, then say a sentence."*
2026-06-19 08:28:54 +00:00
dependabot[bot]
ad3b5fe745 feat(deps): bump the minor group with 11 updates (#787)
Bumps the minor group with 11 updates:

| Package | From | To |
| --- | --- | --- |
|
[@agentclientprotocol/sdk](https://github.com/agentclientprotocol/typescript-sdk)
| `0.26.0` | `0.27.0` |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.179` | `0.3.181` |
| @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.179` | `0.3.181` |
| @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.179` | `0.3.181` |
| @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.179` | `0.3.181` |
| @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.179` |
`0.3.181` |
| @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.179` | `0.3.181` |
| @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.179` | `0.3.181`
|
| @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.179` | `0.3.181` |
| @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.179` | `0.3.181` |
| [hono](https://github.com/honojs/hono) | `4.12.25` | `4.12.26` |

Updates `@agentclientprotocol/sdk` from 0.26.0 to 0.27.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/agentclientprotocol/typescript-sdk/releases">@​agentclientprotocol/sdk's
releases</a>.</em></p>
<blockquote>
<h2>v0.27.0</h2>
<h2><a
href="https://github.com/agentclientprotocol/typescript-sdk/compare/v0.26.0...v0.27.0">0.27.0</a>
(2026-06-18)</h2>
<p>This is a big release! We have rewritten the SDK to have a more
ergonomic design for creating new agents and clients. You can read more
in the <a
href="https://github.com/agentclientprotocol/typescript-sdk/blob/main/MIGRATION_0.26_0.27.md">Migration
Guide</a> for how to migrate.</p>
<p>For now, you will still have the old interfaces available to ease the
migration, but they are deprecated and will be removed in a future
release.</p>
<h3>Features</h3>
<ul>
<li>Experimental Streamable HTTP &amp; WebSocket Transport (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/155">#155</a>)
(<a
href="d6a3d88107">d6a3d88</a>)</li>
<li>New SDK design (see MIGRATION_0.26_0.27.md) (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/181">#181</a>)
(<a
href="87e2df33b5">87e2df3</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/agentclientprotocol/typescript-sdk/blob/main/CHANGELOG.md">@​agentclientprotocol/sdk's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/agentclientprotocol/typescript-sdk/compare/v0.26.0...v0.27.0">0.27.0</a>
(2026-06-18)</h2>
<p>This is a big release! We have rewritten the SDK to have a more
ergonomic design for creating new agents and clients. You can read more
in the <a
href="https://github.com/agentclientprotocol/typescript-sdk/blob/main/MIGRATION_0.26_0.27.md">Migration
Guide</a> for how to migrate.</p>
<p>For now, you will still have the old interfaces available to ease the
migration, but they are deprecated and will be removed in a future
release.</p>
<h3>Features</h3>
<ul>
<li>Experimental Streamable HTTP &amp; WebSocket Transport (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/155">#155</a>)
(<a
href="d6a3d88107">d6a3d88</a>)</li>
<li>New SDK design (see MIGRATION_0.26_0.27.md) (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/181">#181</a>)
(<a
href="87e2df33b5">87e2df3</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="a3437f5f3f"><code>a3437f5</code></a>
chore(main): release 0.27.0 (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/182">#182</a>)</li>
<li><a
href="a3acfa25d7"><code>a3acfa2</code></a>
chore: Cleanup documentation of ctx args and slim down public api (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/184">#184</a>)</li>
<li><a
href="bd1f514d02"><code>bd1f514</code></a>
Update dev deps (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/183">#183</a>)</li>
<li><a
href="87e2df33b5"><code>87e2df3</code></a>
feat: New SDK design (see MIGRATION_0.26_0.27.md) (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/181">#181</a>)</li>
<li><a
href="d6a3d88107"><code>d6a3d88</code></a>
feat: Experimental Streamable HTTP &amp; WebSocket Transport (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/155">#155</a>)</li>
<li>See full diff in <a
href="https://github.com/agentclientprotocol/typescript-sdk/compare/v0.26.0...v0.27.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.179 to 0.3.181
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/releases">@​anthropic-ai/claude-agent-sdk's
releases</a>.</em></p>
<blockquote>
<h2>v0.3.181</h2>
<h2>What's changed</h2>
<ul>
<li>Added <code>errorCode</code>, <code>canUserPurchaseCredits</code>,
and <code>hasChargeableSavedPaymentMethod</code> fields to
<code>SDKRateLimitInfo</code> for detecting credits-required rate
limits</li>
<li>Added <code>tool_use_meta.icon_url</code> to assistant messages,
populated from MCP server directory metadata</li>
<li>Fixed SDK-hosted Remote Control sessions dropping
<code>file_attachments</code> from inbound user messages</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.181
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.181
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.181
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.181
</code></pre>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/claude-agent-sdk's
changelog</a>.</em></p>
<blockquote>
<h2>0.3.181</h2>
<ul>
<li>Added <code>errorCode</code>, <code>canUserPurchaseCredits</code>,
and <code>hasChargeableSavedPaymentMethod</code> fields to
<code>SDKRateLimitInfo</code> for detecting credits-required rate
limits</li>
<li>Added <code>tool_use_meta.icon_url</code> to assistant messages,
populated from MCP server directory metadata</li>
<li>Fixed SDK-hosted Remote Control sessions dropping
<code>file_attachments</code> from inbound user messages</li>
</ul>
<h2>0.3.180</h2>
<ul>
<li>Updated to parity with Claude Code v2.1.180</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="4d3840c936"><code>4d3840c</code></a>
chore: Update CHANGELOG.md</li>
<li>See full diff in <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.179...v0.3.181">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.3.179 to
0.3.181

Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.3.179 to
0.3.181

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.3.179 to
0.3.181

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.3.179
to 0.3.181

Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.3.179 to
0.3.181

Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.3.179 to
0.3.181

Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.3.179 to
0.3.181

Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.3.179 to
0.3.181

Updates `hono` from 4.12.25 to 4.12.26
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/honojs/hono/releases">hono's
releases</a>.</em></p>
<blockquote>
<h2>v4.12.26</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(lambda-edge): satisfy Deno lib types for Content-Length body
encoding by <a
href="https://github.com/yusukebe"><code>@​yusukebe</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/5013">honojs/hono#5013</a></li>
<li>ci: publish to npm from CI with OIDC trusted publishing by <a
href="https://github.com/yusukebe"><code>@​yusukebe</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/5028">honojs/hono#5028</a></li>
<li>chore: remove unused devcontainer and gitpod configs by <a
href="https://github.com/yusukebe"><code>@​yusukebe</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/5029">honojs/hono#5029</a></li>
<li>chore: replace arg and glob with Bun native APIs in build script by
<a href="https://github.com/yusukebe"><code>@​yusukebe</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/5030">honojs/hono#5030</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/honojs/hono/compare/v4.12.25...v4.12.26">https://github.com/honojs/hono/compare/v4.12.25...v4.12.26</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="27b7992f82"><code>27b7992</code></a>
4.12.26</li>
<li><a
href="d29982cc40"><code>d29982c</code></a>
chore: replace arg and glob with Bun native APIs in build script</li>
<li><a
href="16215d5f50"><code>16215d5</code></a>
chore: remove unused devcontainer and gitpod configs (<a
href="https://redirect.github.com/honojs/hono/issues/5029">#5029</a>)</li>
<li><a
href="c574cf1640"><code>c574cf1</code></a>
ci: publish to npm from CI with OIDC trusted publishing (<a
href="https://redirect.github.com/honojs/hono/issues/5028">#5028</a>)</li>
<li><a
href="e50df01453"><code>e50df01</code></a>
fix(lambda-edge): satisfy Deno lib types for Content-Length body
encoding (<a
href="https://redirect.github.com/honojs/hono/issues/5">#5</a>...</li>
<li>See full diff in <a
href="https://github.com/honojs/hono/compare/v4.12.25...v4.12.26">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for hono since your current version.</p>
</details>
<br />


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


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-06-18 16:55:17 +02:00
acp-release-bot[bot]
794aa84684 chore(main): release 0.47.0 (#784)
🤖 I have created a release *beep* *boop*
---


##
[0.47.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.46.0...v0.47.0)
(2026-06-17)


### Features

* Update to claude-agent-sdk 0.3.179
([#783](https://github.com/agentclientprotocol/claude-agent-acp/issues/783))
([59a098c](59a098c2b5))


### Bug Fixes

* Duplicate assistant messages in feed
([#785](https://github.com/agentclientprotocol/claude-agent-acp/issues/785))
([12d34e6](12d34e64e5))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-06-17 12:17:10 +02:00
Ben Brandt
12d34e64e5 fix: Duplicate assistant messages in feed (#785) 2026-06-17 12:12:26 +02:00
Ben Brandt
59a098c2b5 feat: Update to claude-agent-sdk 0.3.179 (#783) 2026-06-17 09:04:56 +00:00
17 changed files with 1181 additions and 242 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.46.0"
".": "0.49.0"
}

View File

@@ -1,5 +1,47 @@
# Changelog
## [0.49.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.48.0...v0.49.0) (2026-06-22)
### Features
* Update to claude-agent-sdk 0.3.185 ([#798](https://github.com/agentclientprotocol/claude-agent-acp/issues/798)) ([8dc8c86](https://github.com/agentclientprotocol/claude-agent-acp/commit/8dc8c864263fa50d03bec7ebff7aee776604cb3d))
### Bug Fixes
* Deduplicate streamed assistant blocks by content ([#800](https://github.com/agentclientprotocol/claude-agent-acp/issues/800)) ([960f62d](https://github.com/agentclientprotocol/claude-agent-acp/commit/960f62d76582ae5c9c5575ab66974809049ce1d0))
* Infer 1M context from model descriptions ([#799](https://github.com/agentclientprotocol/claude-agent-acp/issues/799)) ([508453c](https://github.com/agentclientprotocol/claude-agent-acp/commit/508453c288b4a12701abd507199e7fa0ab172171))
## [0.48.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.47.0...v0.48.0) (2026-06-19)
### Features
* Agent selection dropdown in config options ([#794](https://github.com/agentclientprotocol/claude-agent-acp/issues/794)) ([5729c47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5729c471f92e1d2a191eb2a6d18c2be47821e9ec))
* **deps:** bump the minor group with 11 updates ([#787](https://github.com/agentclientprotocol/claude-agent-acp/issues/787)) ([ad3b5fe](https://github.com/agentclientprotocol/claude-agent-acp/commit/ad3b5fe74527f83964695a1e8056d010b981fc79))
* Update to claude-agent-sdk 0.3.183 ([#791](https://github.com/agentclientprotocol/claude-agent-acp/issues/791)) ([744b2d4](https://github.com/agentclientprotocol/claude-agent-acp/commit/744b2d41128f67091d4136283594c0db11ea4db5))
* Update to new ACP SDK patterns ([#790](https://github.com/agentclientprotocol/claude-agent-acp/issues/790)) ([2554c7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/2554c7bf980472760a6e7810b826f030d2c3af25))
### Bug Fixes
* duplicate assistant text when turn activates mid-message ([#789](https://github.com/agentclientprotocol/claude-agent-acp/issues/789)) ([1c80bf8](https://github.com/agentclientprotocol/claude-agent-acp/commit/1c80bf8e56a9279dc799e7bbdcae87241e99c18b))
* Skip empty thinking chunks ([#793](https://github.com/agentclientprotocol/claude-agent-acp/issues/793)) ([15fdf26](https://github.com/agentclientprotocol/claude-agent-acp/commit/15fdf26fc7d3a6c51e89d3e56fc91dd00cb3d7ae))
* surface Bash tool image output instead of dropping it ([#617](https://github.com/agentclientprotocol/claude-agent-acp/issues/617)) ([a759e64](https://github.com/agentclientprotocol/claude-agent-acp/commit/a759e64ef6d9b7c5bfe9a6e6b182db835f8ed3b6))
## [0.47.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.46.0...v0.47.0) (2026-06-17)
### Features
* Update to claude-agent-sdk 0.3.179 ([#783](https://github.com/agentclientprotocol/claude-agent-acp/issues/783)) ([59a098c](https://github.com/agentclientprotocol/claude-agent-acp/commit/59a098c2b530bbae034e9a2dfbd31f8b4ef2a4d0))
### Bug Fixes
* Duplicate assistant messages in feed ([#785](https://github.com/agentclientprotocol/claude-agent-acp/issues/785)) ([12d34e6](https://github.com/agentclientprotocol/claude-agent-acp/commit/12d34e64e53564602ac1c38a30127e234c5c25ff))
## [0.46.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.45.1...v0.46.0) (2026-06-16)

View File

@@ -40,7 +40,7 @@ export default [
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "always"],
eqeqeq: ["error", "smart"],
curly: ["error", "all"],
},
},

126
package-lock.json generated
View File

@@ -1,25 +1,25 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.46.0",
"version": "0.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.46.0",
"version": "0.49.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.26.0",
"@anthropic-ai/claude-agent-sdk": "0.3.178",
"@agentclientprotocol/sdk": "0.28.1",
"@anthropic-ai/claude-agent-sdk": "0.3.185",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.104.2",
"@anthropic-ai/sdk": "0.105.0",
"@eslint/js": "10.0.1",
"@types/node": "25.9.3",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"eslint": "10.5.0",
@@ -32,31 +32,31 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.26.0.tgz",
"integrity": "sha512-ialrcI+RzKOYe+fw+TfpyTdRmEoqIkXLlwbTi6XgaXXfdhNcdod7TmE1VsTnG3yTlox8TMTSMQgWbLLbz3r86Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.178.tgz",
"integrity": "sha512-PNsz20jWuahDWq7OU+pjrgYzr2TnpC1oj5yCZDxGWJ8OvucXIdD2AYlf/vUo7oE2JJwGbMTBFqXErNrfPi6Ffw==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.185.tgz",
"integrity": "sha512-UDDd0cInvOufmR88btR/Ursnh71sb5scoutNlRS2L0LBdkt7JEqYJ0jt7DnOYHEVhMx8AAcg0eYgvXztcWUnFA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.178",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.178",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.178",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.178",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.178",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.178",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.178",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.178"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.185",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.185",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.185"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -65,9 +65,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.178.tgz",
"integrity": "sha512-RmIJRoZfjwcrd7cHR3fJOL1d4L8SN7oB0REcQPbIuPM1vav2Ft3g5hBX7I86u52A4LLFKBc3SMom3+E6lR1YtQ==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.185.tgz",
"integrity": "sha512-P2Svxx1IFrS9aw4ylJBtcoJrc/OAOVnDs+o05x4TV7HoHUQUcKZ9XhWOGnadyVT0KLyoktgjyjnaK7Zdtc0Ldg==",
"cpu": [
"arm64"
],
@@ -78,9 +78,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.178.tgz",
"integrity": "sha512-wfNl7JoaUk9IzKWQPr+hyEOX8qnkM+e4GBZnKISh60xVhW9wOOp5RdfnYj5MoJzaLYivMSCbo5rb4ThWguhOMw==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.185.tgz",
"integrity": "sha512-H69m2hIJawzSkCDNO/CPLQCYvDGdlbZdzGTgGF8/IQuB4O81sv8WSAh6TBXwtFXOgEi4HtrUYlxiFDmTHt7hMA==",
"cpu": [
"x64"
],
@@ -91,9 +91,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.178.tgz",
"integrity": "sha512-ktBU6EdJoZivf67AxRXe2uSwj0g5tCe20So9QvWVKuEEtRA4sXW5EpgxSWT6LXkgfwUoeNsJK7VAOuVjZumKmg==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.185.tgz",
"integrity": "sha512-KtXMCoprGYTSPb/A0/kjrO+PpDJAFbHhDd8rjqA4izU51OYjTDkahsGCcSTFM2jA8krOhPqzx/SNVuAVWmexwA==",
"cpu": [
"arm64"
],
@@ -107,9 +107,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.178.tgz",
"integrity": "sha512-Z3U0fNatVK3vkki3e5sjlLJMjros+cT6uq//tdohB2kjNK1CugUuPQFQxd6GU1Lq1DokmdSEPL4OGdKiHckNdQ==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.185.tgz",
"integrity": "sha512-1GRpKYrg5foomW+qLrxm/k2R/5PEU7RAuEdX65HV6lFMJR3HsGj8qfBq4KCJHTS3r4YdVrFLvPQ/mUqbY6klIA==",
"cpu": [
"arm64"
],
@@ -123,9 +123,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.178.tgz",
"integrity": "sha512-CPTivDz27LMn5m9iQnJSoWkztLo41e8QTbuYKlAC+CTr98gaYy3Mao0M2tirEK/nGno0xRJw/EpZ1G61yEM3yw==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.185.tgz",
"integrity": "sha512-8U5wu6dNcyzRkwoae0Gu5ZHs6lRSu8CXqd9yrilX3fHR9kKICrm76bLu5q2auuSc/8dx6VL5ZXqSzFKIMp//zw==",
"cpu": [
"x64"
],
@@ -139,9 +139,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.178.tgz",
"integrity": "sha512-K84Ybyr0Olsslg2I1Tzd7KIcSkZgFqqDHn7q1Dfqi7bXVxjMjJ4SaV+LnEAeHSM2es7H8A7ejoTEl89xMZde4Q==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.185.tgz",
"integrity": "sha512-YAky0Oez+YYrlcas/xDiaCm9AUX0z1YwM+pnk+CFxEr/ICmc7MbiFsnEIXFLPHaox9bE5iOzw4LS4b/c1tIJRg==",
"cpu": [
"x64"
],
@@ -155,9 +155,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.178.tgz",
"integrity": "sha512-uKH3vgv7cQV3d9BPU4lrUKrFgoU/flmy/1QI62j9JzgE6uWVQKWC+BI4V7iceJ36tYVneRhjjuux+l+Q8egTHA==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.185.tgz",
"integrity": "sha512-pJEOaCLQQQWSyBeseR/GAn4eEp4WtMDP/o5yPMuuAsNWeoJXM7cY4j/N3KBE6GZofgnYdWEXILF2uw3SjbtINA==",
"cpu": [
"arm64"
],
@@ -168,9 +168,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.178",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.178.tgz",
"integrity": "sha512-wSC5qG6UA1laWGylOU7axuC4NQxZJtibGQ79sYeOQCc05G7CfkUKSzszLOzsPP3eK63cKi/bX5PA6dd4nA5Wow==",
"version": "0.3.185",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.185.tgz",
"integrity": "sha512-/Kf9XneSuIkSHNwrxOVY0XT7RovAEcx5nDASKzku4994cZz54otU5mT2jT6dwevDLEStfgts5x/A4z0AePwVTQ==",
"cpu": [
"x64"
],
@@ -181,9 +181,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.104.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
"integrity": "sha512-s1wEVDAtEwkS7Ajgep6PZKJLFqybRkmD3Byz+iVVsSpbDY0gjROXE9aOft6V3PMqynn3NTcycV5whga9tCzmKA==",
"version": "0.105.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
"integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
@@ -923,13 +923,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.3.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -2360,9 +2360,9 @@
}
},
"node_modules/hono": {
"version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"version": "4.12.26",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"license": "MIT",
"peer": true,
"engines": {
@@ -2954,9 +2954,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
@@ -3366,9 +3366,9 @@
"peer": true
},
"node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -3766,9 +3766,9 @@
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.46.0",
"version": "0.49.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,14 +60,14 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.26.0",
"@anthropic-ai/claude-agent-sdk": "0.3.178",
"@agentclientprotocol/sdk": "0.28.1",
"@anthropic-ai/claude-agent-sdk": "0.3.185",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.104.2",
"@anthropic-ai/sdk": "0.105.0",
"@eslint/js": "10.0.1",
"@types/node": "25.9.3",
"@types/node": "26.0.0",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"eslint": "10.5.0",

View File

@@ -1,11 +1,14 @@
import {
Agent,
AgentSideConnection,
agent as acpAgent,
AgentContext,
AuthenticateRequest,
AuthMethod,
AvailableCommand,
CancelNotification,
ClientCapabilities,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
ForkSessionRequest,
ForkSessionResponse,
InitializeRequest,
@@ -14,6 +17,7 @@ import {
ListSessionsResponse,
LoadSessionRequest,
LoadSessionResponse,
methods,
ndJsonStream,
NewSessionRequest,
NewSessionResponse,
@@ -23,6 +27,8 @@ import {
ReadTextFileRequest,
ReadTextFileResponse,
RequestError,
RequestPermissionRequest,
RequestPermissionResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SessionConfigOption,
@@ -41,6 +47,7 @@ import {
StopReason,
} from "@agentclientprotocol/sdk";
import {
AgentInfo,
CanUseTool,
deleteSession,
getSessionMessages,
@@ -225,6 +232,14 @@ type Session = {
models: SessionModelState;
modelInfos: ModelInfo[];
configOptions: SessionConfigOption[];
/** Custom main-thread agent personas the user (or a plugin/project) has
* configured, discovered via `supportedAgents()` with Claude Code's built-in
* subagents filtered out. Empty when none are configured, in which case the
* "agent" config option is omitted entirely. */
agents: AgentInfo[];
/** The currently selected main-thread agent name, or "default" for the
* standard Claude Code agent (no `agent` flag applied). */
currentAgent: string;
abortController: AbortController;
/** Signal the consumer races `query.next()` against. Aborted by cancel()
* (after a grace period) to force the active turn to settle "cancelled" when
@@ -612,12 +627,66 @@ export function describeAlwaysAllow(
return `Always Allow ${parts.join(" and ")}`;
}
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: SessionNotification): Promise<void>;
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse>;
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: SessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, params);
}
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params);
}
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
export class ClaudeAcpAgent {
sessions: {
[key: string]: Session;
};
client: AgentSideConnection;
client: AcpClient;
clientCapabilities?: ClientCapabilities;
logger: Logger;
gatewayAuthRequest?: GatewayAuthRequest;
@@ -626,7 +695,7 @@ export class ClaudeAcpAgent implements Agent {
* tests can shrink it. */
forceCancelGraceMs: number = DEFAULT_FORCE_CANCEL_GRACE_MS;
constructor(client: AgentSideConnection, logger?: Logger) {
constructor(client: AcpClient, logger?: Logger) {
this.sessions = {};
this.client = client;
this.logger = logger ?? console;
@@ -955,13 +1024,17 @@ export class ClaudeAcpAgent implements Agent {
// (whose delta events don't carry it) can all be tagged with the same,
// replay-stable id.
let currentStreamMessageId: string | undefined;
// Per-message-id record of which assistant content actually streamed live
// via `stream_event` deltas, split by block type, so the consolidated
// `assistant` message can drop the duplicate blocks that already reached the
// client as chunks while still forwarding any that a non-streaming gateway
// delivered only as an assembled block.
let streamedTextIds = new Set<string>();
let streamedThinkingIds = new Set<string>();
// The text/thinking blocks that have actually streamed live as
// `stream_event` deltas for the message the next consolidated `assistant`
// will repeat, in stream order, each accumulated to its full streamed text.
// The consolidated handler diffs each assembled block against these and
// forwards only the un-streamed remainder — nothing if it streamed in full
// (the common case), the whole block if it never streamed (a non-streaming
// gateway), or just the tail if the stream was cut short mid-block. Matching
// on content rather than the Anthropic message id makes dedupe robust to
// gateways that don't carry a stable/matching id across the stream and the
// consolidated message. Reset after each consolidated message consumes it.
const streamedBlocks: { index: number; type: "text" | "thinking"; text: string }[] = [];
// Stop reason accumulated for the active turn (result subtype, refusal,
// max_tokens, …). Reset per turn; read when the turn settles at idle.
let stopReason: StopReason = "end_turn";
@@ -973,9 +1046,14 @@ export class ClaudeAcpAgent implements Agent {
lastAssistantError = undefined;
lastRefusalExplanation = null;
compactionInProgress = false;
currentStreamMessageId = undefined;
streamedTextIds = new Set<string>();
streamedThinkingIds = new Set<string>();
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
// activation can fire mid-message (the replayed user echo with
// --replay-user-messages lands between a message's blocks); clearing the
// streamed-content record on activation would drop the blocks that
// streamed before the echo, so the consolidated assistant message would
// re-emit them as duplicates. streamedBlocks is bounded instead by being
// cleared when each consolidated message consumes it. #785 stopped
// resetting the streamed-content tracking here but left this line.
stopReason = "end_turn";
session.accumulatedUsage = {
inputTokens: 0,
@@ -1611,21 +1689,52 @@ export class ClaudeAcpAgent implements Agent {
// it) can all be tagged with the same, replay-stable id.
if (message.event.type === "message_start") {
currentStreamMessageId = message.event.message.id || undefined;
// A new top-level message starts: clear any streamed-content
// residue from a prior message that never reached its
// consolidated reset — a cancelled turn breaks out before the
// reset, and the synthetic-auth/system/local-command paths
// `break` early too. Block indices restart at 0 each message, so
// leftover entries would otherwise collide with this message's
// blocks and re-emit (or truncate) already-streamed text. Gated on
// `parent_tool_use_id === null` so a subagent stream can't clear
// the top-level record. Fires once, before any of this message's
// blocks, so it doesn't disturb the mid-message turn-activation
// path the way resetting on turn activation would.
if (message.parent_tool_use_id === null) {
streamedBlocks.length = 0;
}
}
// Record that this top-level message id actually streamed text/
// thinking, so the `assistant` case below knows its assembled blocks
// are duplicates (filter them) rather than the only copy (forward
// them). Gated on `parent_tool_use_id === null` so a subagent stream
// can't attribute its content to the top-level message id.
// Accumulate the text/thinking actually streamed live, so the
// `assistant` case below can diff its assembled blocks against what
// already reached the client as chunks and forward only the
// remainder. Gated on `parent_tool_use_id === null` so a subagent
// stream can't attribute its content to the top-level message.
// Contiguous deltas of the same block (same index and type) extend
// the current entry; anything else opens a new one.
if (
currentStreamMessageId &&
message.parent_tool_use_id === null &&
message.event.type === "content_block_delta"
) {
if (message.event.delta.type === "text_delta") {
streamedTextIds.add(currentStreamMessageId);
} else if (message.event.delta.type === "thinking_delta") {
streamedThinkingIds.add(currentStreamMessageId);
const delta = message.event.delta;
const chunk =
delta.type === "text_delta"
? { type: "text" as const, text: delta.text }
: delta.type === "thinking_delta"
? { type: "thinking" as const, text: delta.thinking }
: undefined;
// Skip empty deltas (some gateways emit empty thinking chunks —
// #793): appending "" is a no-op, but pushing a "" entry would
// create a block the consolidated handler's `text.length > 0`
// guard can never consume, stalling the diff cursor and
// re-emitting the next block as a duplicate.
if (chunk && chunk.text.length > 0) {
const index = message.event.index;
const last = streamedBlocks[streamedBlocks.length - 1];
if (last && last.index === index && last.type === chunk.type) {
last.text += chunk.text;
} else {
streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
}
}
}
if (
@@ -1836,34 +1945,66 @@ export class ClaudeAcpAgent implements Agent {
let content: typeof message.message.content;
if (message.type === "assistant" && message.parent_tool_use_id === null) {
// Top-level assistant message: drop text/thinking blocks already
// streamed live as chunks, and forward (as a fallback) any that were
// not, so non-streaming gateways still deliver the final answer.
const id = messageIdForGrouping(message);
content = message.message.content.filter((item) => {
// Non-text blocks (tool_use, etc.) always pass through; their own
// dedupe (`toolUseCache`) collapses the streamed/assembled pair.
// Top-level assistant message: each text/thinking block may have
// already been streamed live as deltas. Diff each against what
// streamed (`streamedBlocks`, in document order) and forward only
// the un-streamed remainder — nothing if it streamed in full (the
// common case), the whole block if it never streamed (a
// non-streaming gateway), or just the tail if the stream was cut
// short mid-block. `streamPos` walks the streamed blocks in step
// with the assembled text/thinking blocks; tool_use and other
// blocks pass through untouched (their own `toolUseCache` collapses
// the streamed/assembled pair) without advancing it.
const blocks = message.message.content;
const kept: typeof blocks = [];
let streamPos = 0;
for (const item of blocks) {
if (item.type !== "text" && item.type !== "thinking") {
return true;
kept.push(item);
continue;
}
// Already delivered live as a chunk of this exact type — drop the
// duplicate. Checked per type so streaming one (e.g. text) doesn't
// suppress an un-streamed block of the other (e.g. thinking).
const streamedLive =
id !== undefined &&
(item.type === "text" ? streamedTextIds : streamedThinkingIds).has(id);
if (streamedLive) {
return false;
const full = item.type === "text" ? item.text : item.thinking;
// Empty assembled blocks carry nothing (some gateways emit an
// empty `thinking` block before the real text) — drop them.
if (full.length === 0) {
continue;
}
// Empty assembled blocks carry nothing (some gateways emit an empty
// `thinking` block before the real text) — don't forward stray
// empty chunks.
const text = item.type === "text" ? item.text : item.thinking;
if (text.length === 0) {
return false;
// A streamed block of the same type whose accumulated text is a
// prefix of this one was already (at least partly) delivered as
// chunks; consume it and forward only what's left. A non-empty
// streamed text is required so an empty/aborted streamed block
// doesn't swallow the assembled copy.
const streamed = streamedBlocks[streamPos];
if (
streamed &&
streamed.type === item.type &&
streamed.text.length > 0 &&
full.startsWith(streamed.text)
) {
streamPos++;
const remainder = full.slice(streamed.text.length);
if (remainder.length === 0) {
continue;
}
// Overwrite in place with just the un-streamed tail (the
// assembled message isn't read again after this) so the block
// keeps its exact SDK type.
if (item.type === "text") {
item.text = remainder;
} else {
item.thinking = remainder;
}
kept.push(item);
continue;
}
return true;
});
// Not matched: never streamed (or the stream diverged from the
// assembled text) — forward the block in full.
kept.push(item);
}
content = kept;
// Consumed: reset so the next message's blocks accumulate fresh and
// the record stays bounded to the in-flight message.
streamedBlocks.length = 0;
} else if (message.type === "assistant") {
// Subagent assistant message (`parent_tool_use_id !== null`). It is
// never streamed live and its text/thinking is internal to the tool
@@ -2574,19 +2715,27 @@ export class ClaudeAcpAgent implements Agent {
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else if (configId === "model") {
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
// modes below; its `displayName`/`description` also let us infer the
// context window for semantic aliases (e.g. `default`) whose ID alone
// carries no "1m" token.
const newModelInfo = session.modelInfos.find((m) => m.value === value);
if (session.models.currentModelId !== value) {
// The cached context window was learned for the previous model; reset
// to the new model's heuristic so mid-stream updates between now and
// the next `result` reflect the user's selection instead of the old
// model's window.
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
session.contextWindowSize =
inferContextWindowFromModel(
value,
newModelInfo?.displayName,
newModelInfo?.description,
) ?? DEFAULT_CONTEXT_WINDOW;
}
session.models = { ...session.models, currentModelId: value };
// Recompute availableModes for the new model and clamp the current
// mode if the SDK no longer offers it (today: "auto" on Haiku).
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
const newModelInfo = session.modelInfos.find((m) => m.value === value);
const newAvailableModes = buildAvailableModes(newModelInfo);
// Capture BEFORE mutating session.modes so the log message reflects
// the invalidated mode rather than "default".
@@ -2623,6 +2772,8 @@ export class ClaudeAcpAgent implements Agent {
session.models,
session.modelInfos,
currentEffort,
session.agents,
session.currentAgent,
);
// Sync effort with the SDK if it changed after the model switch
@@ -2650,6 +2801,19 @@ export class ClaudeAcpAgent implements Agent {
},
});
}
} else if (configId === "agent") {
// Live agent switch — no subprocess restart needed. Apply the SDK flag
// first so a rejected control request leaves both `currentAgent` and the
// config option untouched (no UI/SDK desync). Passing `null` clears the
// flag layer back to the standard Claude Code agent; the change takes
// effect on the next turn (SDK >= 0.3.161).
await session.query.applyFlagSettings({
agent: value === DEFAULT_AGENT_ID ? null : value,
});
session.currentAgent = value;
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else {
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
@@ -3072,11 +3236,25 @@ export class ClaudeAcpAgent implements Agent {
availableModes,
};
const agents = await discoverCustomAgents(q);
// Only adopt the requested agent as the selected value if it's one we
// actually surface in the picker. A built-in (filtered out above) or
// otherwise-unknown name would leave the config option's `currentValue`
// pointing at an entry not in its own `options` list, which clients render
// as a blank/invalid selection.
const requestedAgent = userProvidedOptions?.agent;
const currentAgent =
requestedAgent && agents.some((a) => a.name === requestedAgent)
? requestedAgent
: DEFAULT_AGENT_ID;
const configOptions = buildConfigOptions(
modes,
models,
allowedModels,
settingsManager.getSettings().effortLevel,
agents,
currentAgent,
);
// Apply the initial effort level to the SDK so it matches the UI default
@@ -3107,10 +3285,16 @@ export class ClaudeAcpAgent implements Agent {
models,
modelInfos: allowedModels,
configOptions,
agents,
currentAgent,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
contextWindowSize:
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
inferContextWindowFromModel(
models.currentModelId,
currentModelInfo?.displayName,
currentModelInfo?.description,
) ?? DEFAULT_CONTEXT_WINDOW,
taskState,
toolUseCache: {},
messageIdToUuid: new Map(),
@@ -3288,11 +3472,47 @@ function toSdkEffortLevel(value: string | undefined): Settings["effortLevel"] |
return value === undefined || value === "default" ? null : (value as Settings["effortLevel"]);
}
function buildConfigOptions(
// `supportedAgents()` always returns Claude Code's built-in subagents — the
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
// has configured none of their own. Those aren't meaningful *main-thread*
// personas, so we filter them out and only surface the Agent picker when the
// user (or a plugin/project) has configured custom agents. Update this set if
// the SDK's built-in roster changes.
export const BUILTIN_AGENT_NAMES = new Set([
"claude",
"general-purpose",
"Explore",
"Plan",
"statusline-setup",
]);
// Value of the synthetic "Default" entry in the agent picker, which maps to the
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
// reserved sentinel: a custom agent named exactly this would collide with it
// (two options sharing the value, selection silently routing to `null`), so we
// exclude that name from discovery.
export const DEFAULT_AGENT_ID = "default";
/** Discover user/plugin/project-configured main-thread agents, excluding the
* built-in subagents and the reserved "default" sentinel. Returns an empty
* list if discovery fails so a flaky control request never blocks session
* creation. */
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
try {
const agents = await q.supportedAgents();
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
} catch {
return [];
}
}
export function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,
modelInfos: ModelInfo[],
currentEffortLevel?: string,
agents: AgentInfo[] = [],
currentAgent: string = DEFAULT_AGENT_ID,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [
{
@@ -3356,6 +3576,28 @@ function buildConfigOptions(
});
}
// Only surface the Agent picker when there's a real choice — i.e. the user
// has configured at least one custom agent (built-ins are filtered out in
// discoverCustomAgents). With none configured, "Default" would be the only
// entry, so we omit the option entirely.
if (agents.length > 0) {
options.push({
id: "agent",
name: "Agent",
description: "Main-thread agent persona",
type: "select",
currentValue: currentAgent,
options: [
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
...agents.map((a) => ({
value: a.name,
name: a.name,
description: a.description || undefined,
})),
],
});
}
return options;
}
@@ -3780,7 +4022,7 @@ export function toAcpNotifications(
role: "assistant" | "user",
sessionId: string,
toolUseCache: ToolUseCache,
client: AgentSideConnection,
client: AcpClient,
logger: Logger,
options?: {
registerHooks?: boolean;
@@ -3850,13 +4092,17 @@ export function toAcpNotifications(
break;
case "thinking":
case "thinking_delta":
update = {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: chunk.thinking,
},
};
// Recent models default `thinking.display` to "omitted", which streams
// signature-only thinking blocks whose text is empty.
if (chunk.thinking.length > 0) {
update = {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: chunk.thinking,
},
};
}
break;
case "tool_use":
case "server_tool_use":
@@ -4102,7 +4348,7 @@ export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
client: AgentSideConnection,
client: AcpClient,
logger: Logger,
options?: {
clientCapabilities?: ClientCapabilities;
@@ -4167,11 +4413,34 @@ export function runAcp() {
const output = nodeToWebReadable(process.stdin);
const stream = ndJsonStream(input, output);
let agent!: ClaudeAcpAgent;
const connection = new AgentSideConnection((client) => {
agent = new ClaudeAcpAgent(client);
return agent;
}, stream);
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
// that stays valid for the whole connection, so the agent captures it once.
// Handlers close over `agent`, which is assigned synchronously right after
// `connect()` returns — before the connection processes any inbound message.
// It cannot be `const`: its value depends on `connection.client`, which does
// not exist until `connect()` has been called.
// eslint-disable-next-line prefer-const
let agent: ClaudeAcpAgent;
const connection = acpAgent({ name: "claude-code-acp" })
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
.onRequest(methods.agent.session.fork, (ctx) => agent.unstable_forkSession(ctx.params))
.onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
.onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
.onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params))
.onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
.onRequest(methods.agent.session.setConfigOption, (ctx) =>
agent.setSessionConfigOption(ctx.params),
)
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
.onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
.connect(stream);
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
return { connection, agent };
}
@@ -4183,14 +4452,21 @@ function commonPrefixLength(a: string, b: string) {
return i;
}
/** Best-effort first guess of a model's context window from its ID, used only
* as a fallback when the SDK's authoritative `getContextUsage` is unavailable
* (and until a `result` message arrives with the `modelUsage` value).
/** Best-effort first guess of a model's context window, used only as a
* fallback when the SDK's authoritative `getContextUsage` is unavailable (and
* until a `result` message arrives with the `modelUsage` value).
*
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
* matching things like "10m" or embedded substrings. */
function inferContextWindowFromModel(model: string): number | null {
if (/\b1m\b/i.test(model)) return 1_000_000;
* matching things like "10m" or embedded substrings. Semantic aliases like
* `default` carry no such token in the ID, but the SDK's human-facing
* `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
* pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
* `ModelInfo` exposes no structured context-window field, so this text scan is
* the only pre-`result` signal available. A miss falls back to the default
* window and is corrected by `result.modelUsage` within one turn. */
function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
return null;
}

View File

@@ -2,8 +2,7 @@ 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";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { querySpy } = vi.hoisted(() => ({
querySpy: vi.fn(),
@@ -21,13 +20,13 @@ describe("ClaudeAcpAgent settings", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
function createMockClient(): AgentSideConnection {
function createMockClient(): AcpClient {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
}
function mockQuery() {

View File

@@ -1,15 +1,15 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
import { spawn, spawnSync } from "child_process";
import {
Agent,
AgentSideConnection,
AvailableCommand,
Client,
ClientSideConnection,
client as acpClient,
CreateElicitationRequest,
CreateElicitationResponse,
methods,
ndJsonStream,
NewSessionResponse,
PromptRequest,
PromptResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
@@ -36,6 +36,9 @@ import {
describeAlwaysAllow,
streamEventToAcpNotifications,
messageIdForGrouping,
buildConfigOptions,
discoverCustomAgents,
type AcpClient,
type SDKMessageFilter,
} from "../acp-agent.js";
import { Pushable } from "../utils.js";
@@ -105,6 +108,8 @@ function mockSessionState(overrides: Record<string, any> = {}) {
cachedWriteTokens: 0,
},
configOptions: [],
agents: [],
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
@@ -158,8 +163,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
child.kill();
});
class TestClient implements Client {
agent: Agent;
class TestClient {
files: Map<string, string> = new Map();
receivedText: string = "";
// Records for the AskUserQuestion elicitation test.
@@ -169,8 +173,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
availableCommandsPromise: Promise<AvailableCommand[]>;
constructor(agent: Agent) {
this.agent = agent;
constructor() {
this.resolveAvailableCommands = () => {};
this.availableCommandsPromise = new Promise((resolve) => {
this.resolveAvailableCommands = resolve;
@@ -248,21 +251,35 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
}
}
type TestConnection = {
prompt(params: PromptRequest): Promise<PromptResponse>;
};
async function setupTestSession(cwd: string): Promise<{
client: TestClient;
connection: ClientSideConnection;
connection: TestConnection;
newSessionResponse: NewSessionResponse;
}> {
let client;
const input = nodeToWebWritable(child.stdin!);
const output = nodeToWebReadable(child.stdout!);
const stream = ndJsonStream(input, output);
const connection = new ClientSideConnection((agent) => {
client = new TestClient(agent);
return client;
}, stream);
await connection.initialize({
const client = new TestClient();
// `connect(...)` keeps the connection open and exposes the agent-side peer
// handle as `connection.agent`, valid for the lifetime of the connection.
const { agent: ctx } = acpClient({ name: "test-client" })
.onNotification(methods.client.session.update, (c) => client.sessionUpdate(c.params))
.onRequest(methods.client.session.requestPermission, (c) =>
client.requestPermission(c.params),
)
.onRequest(methods.client.fs.readTextFile, (c) => client.readTextFile(c.params))
.onRequest(methods.client.fs.writeTextFile, (c) => client.writeTextFile(c.params))
.onRequest(methods.client.elicitation.create, (c) =>
client.unstable_createElicitation(c.params),
)
.connect(stream);
await ctx.request(methods.agent.initialize, {
protocolVersion: 1,
clientCapabilities: {
fs: {
@@ -275,12 +292,16 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
},
});
const newSessionResponse = await connection.newSession({
const newSessionResponse = await ctx.request(methods.agent.session.new, {
cwd,
mcpServers: [],
});
return { client: client!, connection, newSessionResponse };
const connection: TestConnection = {
prompt: (params) => ctx.request(methods.agent.session.prompt, params),
};
return { client, connection, newSessionResponse };
}
it("should connect to the ACP subprocess", async () => {
@@ -848,7 +869,7 @@ describe("tool conversions", () => {
received.message.role,
"test",
{},
{} as AgentSideConnection,
{} as AcpClient,
console,
),
).toStrictEqual([
@@ -1766,7 +1787,7 @@ describe("stop reason propagation", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
@@ -2096,7 +2117,7 @@ describe("stop reason propagation", () => {
sessionUpdate: async (u: any) => {
sessionUpdates.push(u);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
const input = new Pushable<any>();
@@ -2263,7 +2284,7 @@ describe("session/close", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
@@ -2293,6 +2314,8 @@ describe("session/close", () => {
cachedWriteTokens: 0,
},
configOptions: [],
agents: [],
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
@@ -2352,7 +2375,7 @@ describe("session/delete", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
@@ -2376,6 +2399,8 @@ describe("session/delete", () => {
cachedWriteTokens: 0,
},
configOptions: [],
agents: [],
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
@@ -2439,7 +2464,7 @@ describe("getOrCreateSession param change detection", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
@@ -2476,6 +2501,8 @@ describe("getOrCreateSession param change detection", () => {
cachedWriteTokens: 0,
},
configOptions: [],
agents: [],
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
@@ -2664,7 +2691,7 @@ describe("usage_update computation", () => {
sessionUpdate: async (notification: any) => {
updates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
return { agent, updates };
}
@@ -3045,6 +3072,26 @@ describe("usage_update computation", () => {
expect(usageUpdates[1].update.size).toBe(1000000);
});
it("infers the 1M window from a model's description when the ID lacks a 1m token (issue #596)", async () => {
// Semantic aliases like `default` resolve to a 1M-context model but carry
// no "1m" token in the modelId — the SDK signals 1M only via the
// human-facing displayName/description (e.g. "Opus 4.7 with 1M context").
// Inference must read those so the session reports the correct window from
// the first mid-stream update instead of the 200k placeholder.
const { agent } = createMockAgentWithCapture();
injectSession(agent, [{ type: "system", subtype: "session_state_changed", state: "idle" }]);
const session = agent.sessions["test-session"];
session.models = { currentModelId: "claude-sonnet-4-6", availableModels: [] };
session.modelInfos = [
{ value: "default", displayName: "Default", description: "Opus 4.7 with 1M context" },
] as any;
expect(session.contextWindowSize).toBe(200000);
await (agent as any).applyConfigOptionValue("test-session", session, "model", "default");
expect(session.contextWindowSize).toBe(1000000);
});
it("result with no matching modelUsage preserves the learned window", async () => {
// A turn whose `result.modelUsage` doesn't contain the current top-level
// model (e.g. no top-level assistant message, or only a subagent ran) must
@@ -3586,7 +3633,7 @@ describe("assembled assistant text fallback", () => {
sessionUpdate: async (notification: any) => {
updates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
return { agent, updates };
}
@@ -3618,6 +3665,20 @@ describe("assembled assistant text fallback", () => {
};
}
function thinkingDelta(thinking: string) {
return {
type: "stream_event" as const,
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
event: {
type: "content_block_delta" as const,
index: 0,
delta: { type: "thinking_delta" as const, thinking },
},
};
}
function assistantMessage(apiId: string, content: any[], parentToolUseId: string | null = null) {
return {
type: "assistant" as const,
@@ -3679,6 +3740,36 @@ describe("assembled assistant text fallback", () => {
});
}
// Like injectSession, but the user-message echo is yielded at the position of
// the "ECHO" sentinel in `messages` rather than always first — so a test can
// reproduce the production ordering where the assistant stream arrives before
// the SDK replays the user message.
function injectSessionEchoAt(agent: ClaudeAcpAgent, messages: any[]) {
const input = new Pushable<any>();
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
for (const m of messages) {
if (m === "ECHO") {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
} else {
yield m;
}
}
}
agent.sessions["test-session"] = mockSessionState({
query: wrapQuery(messageGenerator()),
input,
});
}
function messageChunkTexts(updates: any[]): string[] {
return updates
.filter((u) => u.update?.sessionUpdate === "agent_message_chunk")
@@ -3723,6 +3814,59 @@ describe("assembled assistant text fallback", () => {
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes streamed text even when the stream arrives before the user echo", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Production ordering: the SDK emits the assistant's stream events before it
// replays the user message that activates the turn. The streamed-id tracking
// must survive activation, or the consolidated block is re-emitted as a
// duplicate (regression from the persistent-consumer rework).
injectSessionEchoAt(agent, [
messageStart("msg-streamed"),
textDelta("hello "),
textDelta("world"),
"ECHO",
assistantMessage("msg-streamed", [{ type: "text", text: "hello world" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// Still just the two streamed deltas — no duplicated assembled block.
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes streamed text when the user echo activates the turn mid-message, between a thinking and a text block", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Production ordering captured with: inside a single message id, the
// thinking block streams, THEN the SDK replays the user message that
// activates the turn, THEN the text block streams. Turn activation runs
// `resetTurnScratch()`; if that nulls `currentStreamMessageId`, every text
// delta after the echo streams untracked, so the consolidated `assistant`
// text fails dedupe and is re-emitted as a duplicate. #785 fixed the
// stream-before-echo case but left this residual mid-message path.
injectSessionEchoAt(agent, [
messageStart("msg-mixed"),
thinkingDelta("private reasoning"),
"ECHO",
textDelta("Starting now."),
assistantMessage("msg-mixed", [
{ type: "thinking", thinking: "private reasoning" },
{ type: "text", text: "Starting now." },
]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// The text streamed once; the consolidated copy must be deduped, not doubled.
expect(messageChunkTexts(updates)).toEqual(["Starting now."]);
// The thinking streamed before the echo (still tracked) so it is deduped —
// mirrors the production signature where only the text block doubled.
expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]);
});
it("dedupes per block type: streamed text is dropped but an un-streamed thinking block in the same message is forwarded", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Gateway streams the text live but delivers the thinking block only in the
@@ -3748,6 +3892,134 @@ describe("assembled assistant text fallback", () => {
expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]);
});
it("forwards only the un-streamed remainder when the stream is cut short mid-block", async () => {
const { agent, updates } = createMockAgentWithCapture();
// The stream stops partway ("hello ") but the consolidated message carries
// the whole block ("hello world"). The streamed prefix must not be re-sent,
// and the un-streamed tail must still reach the client — dropping the whole
// assembled block would truncate the answer to "hello ".
injectSession(agent, [
messageStart("msg-partial"),
textDelta("hello "),
assistantMessage("msg-partial", [{ type: "text", text: "hello world" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// The streamed prefix, then just the tail from the consolidated message.
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes streamed text even when the consolidated message carries a different id", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Some gateways assign one id during the stream and a different one (or only
// a uuid) on the assembled message. Dedupe must key on content, not the id,
// or the consolidated block re-emits already-streamed text as a duplicate.
injectSession(agent, [
messageStart("msg-stream-id"),
textDelta("hello "),
textDelta("world"),
assistantMessage("msg-DIFFERENT-id", [{ type: "text", text: "hello world" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// Only the streamed deltas — the assembled copy is deduped despite the id
// mismatch.
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes a streamed text block even when an empty thinking delta precedes it", async () => {
// An empty thinking delta (some gateways emit them — #793) must not create
// a zero-length streamedBlocks entry: that entry can never satisfy the
// consolidated handler's `text.length > 0` guard, so it would stall the
// diff cursor and re-emit the real, already-streamed text as a duplicate.
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
messageStart("msg-empty-thinking"),
thinkingDelta(""),
textDelta("real answer"),
assistantMessage("msg-empty-thinking", [{ type: "text", text: "real answer" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// The streamed text appears once; the consolidated copy is deduped.
expect(messageChunkTexts(updates)).toEqual(["real answer"]);
});
it("does not re-emit the next turn's text after a turn is cancelled mid-stream", async () => {
// Regression: streamedBlocks is reset inside the consolidated-assistant
// branch, but a cancelled turn `break`s out before reaching it (the
// `if (session.cancelled) break;` guard), and streamedBlocks is
// session-scoped — so a cancelled turn's streamed text used to leak into
// the next turn. Block indices restart at 0 per message, so the leftover
// "Hello there" would fuse with turn 2's first block and make its
// consolidated copy fail the prefix dedupe, re-emitting "Second answer" as
// a duplicate. The fix resets streamedBlocks on each top-level
// `message_start`, bounding the record to one in-flight message.
const { agent, updates } = createMockAgentWithCapture();
let releaseCancel!: () => void;
const cancelled = new Promise<void>((resolve) => {
releaseCancel = resolve;
});
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const u1 = await iter.next();
yield userEcho(u1.value); // activate turn 1
yield messageStart("msg-1");
yield textDelta("Hello ");
yield textDelta("there"); // streamedBlocks = [{ index: 0, text: "Hello there" }]
await cancelled; // hold until the test has cancelled turn 1
// Turn 1's consolidated message arrives while cancelled → hits the
// `if (session.cancelled) break;` guard, skipping the streamedBlocks
// reset. The leftover entry must not survive into turn 2.
yield assistantMessage("msg-1", [{ type: "text", text: "Hello there" }]);
yield idle; // settles turn 1 as cancelled
const u2 = await iter.next();
yield userEcho(u2.value); // activate turn 2
yield messageStart("msg-2"); // resets streamedBlocks (the fix)
yield textDelta("Second answer");
yield assistantMessage("msg-2", [{ type: "text", text: "Second answer" }]);
yield result();
yield idle;
}
return messageGenerator();
});
const first = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "first" }],
});
// Wait until turn 1's deltas have streamed before cancelling.
const deadline = Date.now() + 1000;
while (!messageChunkTexts(updates).includes("there")) {
if (Date.now() > deadline) throw new Error("turn 1 stream never arrived");
await new Promise((r) => setTimeout(r, 1));
}
await agent.cancel({ sessionId: "test-session" });
releaseCancel();
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
// Turn 2's text appears exactly once (the live delta); the consolidated copy
// is deduped despite the cancelled turn's leftover streamed text.
expect(messageChunkTexts(updates).filter((t) => t === "Second answer")).toEqual([
"Second answer",
]);
});
it("does not leak subagent assistant text into the top-level feed", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Subagent assistant messages (parent_tool_use_id !== null) are never
@@ -3820,7 +4092,7 @@ describe("emitRawSDKMessages", () => {
extNotification: async (method: string, params: any) => {
extNotifications.push({ method, params });
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
return { agent, updates, extNotifications };
}
@@ -4041,7 +4313,7 @@ describe("result origin handling", () => {
sessionUpdate: async (notification: any) => {
updates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
return { agent, updates };
}
@@ -4195,7 +4467,7 @@ describe("memory_recall handling", () => {
sessionUpdate: async (notification: any) => {
updates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
return { agent, updates };
}
@@ -4352,7 +4624,7 @@ describe("post-error recovery", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
@@ -4443,6 +4715,8 @@ describe("post-error recovery", () => {
cachedWriteTokens: 0,
},
configOptions: [],
agents: [],
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
@@ -4950,7 +5224,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
@@ -5011,6 +5285,8 @@ describe("session/cancel wedge recovery (issue #680)", () => {
cachedWriteTokens: 0,
},
configOptions: [],
agents: [],
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
@@ -5171,14 +5447,9 @@ describe("streamEventToAcpNotifications", () => {
},
} as Parameters<typeof streamEventToAcpNotifications>[0];
const result = streamEventToAcpNotifications(
message,
"test",
{},
{} as AgentSideConnection,
console,
{ messageId },
);
const result = streamEventToAcpNotifications(message, "test", {}, {} as AcpClient, console, {
messageId,
});
expect(result).toEqual([
{
@@ -5202,7 +5473,7 @@ describe("toAcpNotifications messageId", () => {
"assistant",
"test",
{},
{} as AgentSideConnection,
{} as AcpClient,
console,
{ messageId },
);
@@ -5225,7 +5496,7 @@ describe("toAcpNotifications messageId", () => {
"user",
"test",
{},
{} as AgentSideConnection,
{} as AcpClient,
console,
{ messageId },
);
@@ -5239,7 +5510,7 @@ describe("toAcpNotifications messageId", () => {
"assistant",
"test",
{},
{} as AgentSideConnection,
{} as AcpClient,
console,
{ messageId },
);
@@ -5250,14 +5521,7 @@ describe("toAcpNotifications messageId", () => {
});
it("omits messageId when none is supplied", () => {
const result = toAcpNotifications(
"hello",
"assistant",
"test",
{},
{} as AgentSideConnection,
console,
);
const result = toAcpNotifications("hello", "assistant", "test", {}, {} as AcpClient, console);
expect(result[0].update).not.toHaveProperty("messageId");
});
@@ -5274,7 +5538,7 @@ describe("toAcpNotifications messageId", () => {
"assistant",
"test",
{},
{} as AgentSideConnection,
{} as AcpClient,
console,
{ messageId, registerHooks: false },
);
@@ -5283,6 +5547,55 @@ describe("toAcpNotifications messageId", () => {
});
});
describe("toAcpNotifications thinking chunks", () => {
it("emits an agent_thought_chunk for non-empty thinking text", () => {
const result = toAcpNotifications(
[{ type: "thinking", thinking: "let me reason", signature: "" }],
"assistant",
"test",
{},
{} as AcpClient,
console,
);
expect(result).toEqual([
{
sessionId: "test",
update: {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "let me reason" },
},
},
]);
});
it("skips empty thinking blocks (display: 'omitted' signature-only blocks)", () => {
const result = toAcpNotifications(
[{ type: "thinking", thinking: "", signature: "abc" }],
"assistant",
"test",
{},
{} as AcpClient,
console,
);
expect(result).toEqual([]);
});
it("skips empty thinking deltas", () => {
const result = toAcpNotifications(
[{ type: "thinking_delta", thinking: "", estimated_tokens: 0 }],
"assistant",
"test",
{},
{} as AcpClient,
console,
);
expect(result).toEqual([]);
});
});
describe("messageIdForGrouping", () => {
it("uses the Anthropic API message id for assistant messages", () => {
const message = {
@@ -5319,3 +5632,177 @@ describe("messageIdForGrouping", () => {
expect(messageIdForGrouping({ type: "assistant", uuid: "", message: {} })).toBeUndefined();
});
});
describe("agent selection config option", () => {
const baseModes = { currentModeId: "default", availableModes: [] };
const baseModels = { currentModelId: "default", availableModels: [] };
describe("discoverCustomAgents", () => {
it("filters out Claude Code's built-in subagents", async () => {
const q = {
supportedAgents: async () => [
{ name: "claude", description: "catch-all" },
{ name: "Explore", description: "search" },
{ name: "general-purpose", description: "gp" },
{ name: "Plan", description: "architect" },
{ name: "statusline-setup", description: "status" },
{ name: "my-reviewer", description: "Reviews code" },
{ name: "my-writer", description: "Writes docs" },
],
} as any;
const agents = await discoverCustomAgents(q);
expect(agents.map((a) => a.name)).toEqual(["my-reviewer", "my-writer"]);
});
it("excludes a custom agent named 'default' (reserved sentinel)", async () => {
const q = {
supportedAgents: async () => [
{ name: "default", description: "collides with the synthetic Default entry" },
{ name: "my-reviewer", description: "Reviews code" },
],
} as any;
const agents = await discoverCustomAgents(q);
expect(agents.map((a) => a.name)).toEqual(["my-reviewer"]);
});
it("returns an empty list when discovery throws", async () => {
const q = {
supportedAgents: async () => {
throw new Error("control request failed");
},
} as any;
expect(await discoverCustomAgents(q)).toEqual([]);
});
});
describe("buildConfigOptions agent option", () => {
it("omits the agent option when no custom agents are configured", () => {
const options = buildConfigOptions(baseModes, baseModels, [], undefined, [], "default");
expect(options.find((o) => o.id === "agent")).toBeUndefined();
});
it("adds an agent option with a synthetic Default entry when custom agents exist", () => {
const agents = [
{ name: "my-reviewer", description: "Reviews code" },
// empty description should normalize to undefined, not ""
{ name: "my-writer", description: "" },
];
const options = buildConfigOptions(
baseModes,
baseModels,
[],
undefined,
agents,
"my-reviewer",
);
const agentOption = options.find((o) => o.id === "agent");
expect(agentOption).toBeDefined();
expect(agentOption!.currentValue).toBe("my-reviewer");
expect(agentOption!.type).toBe("select");
const entries = (agentOption as any).options;
expect(entries.map((o: any) => o.value)).toEqual(["default", "my-reviewer", "my-writer"]);
expect(entries[2].description).toBeUndefined();
});
});
describe("switching the agent", () => {
function createMockAgent() {
const mockClient = { sessionUpdate: async () => {} } as unknown as AcpClient;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
const agents = [{ name: "my-reviewer", description: "Reviews code" }];
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
function* empty() {}
const applyFlagSettings = vi.fn(async () => {});
const gen = Object.assign(empty(), {
interrupt: vi.fn(),
close: vi.fn(),
applyFlagSettings,
});
agent.sessions[sessionId] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
cwd: "/test",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: buildConfigOptions(baseModes, baseModels, [], undefined, agents, "default"),
agents,
currentAgent: "default",
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
return { session: agent.sessions[sessionId]!, applyFlagSettings };
}
it("applies the agent flag live without restarting the subprocess", async () => {
const agent = createMockAgent();
const { session, applyFlagSettings } = injectSession(agent, "s1");
const result = await agent.setSessionConfigOption({
sessionId: "s1",
configId: "agent",
value: "my-reviewer",
});
expect(applyFlagSettings).toHaveBeenCalledWith({ agent: "my-reviewer" });
expect(session.currentAgent).toBe("my-reviewer");
// The whole point of the SDK >= 0.3.161 approach: no process teardown.
expect(session.query.interrupt).not.toHaveBeenCalled();
expect(session.abortController.signal.aborted).toBe(false);
expect(agent.sessions["s1"]).toBe(session);
const agentOption = result.configOptions.find((o) => o.id === "agent");
expect(agentOption?.currentValue).toBe("my-reviewer");
});
it("clears the flag (agent: null) when switching back to default", async () => {
const agent = createMockAgent();
const { session, applyFlagSettings } = injectSession(agent, "s2");
session.currentAgent = "my-reviewer";
await agent.setSessionConfigOption({
sessionId: "s2",
configId: "agent",
value: "default",
});
expect(applyFlagSettings).toHaveBeenCalledWith({ agent: null });
expect(session.currentAgent).toBe("default");
});
it("leaves tracked state untouched when the live switch is rejected", async () => {
const agent = createMockAgent();
const { session, applyFlagSettings } = injectSession(agent, "s3");
applyFlagSettings.mockRejectedValueOnce(new Error("control channel closed"));
await expect(
agent.setSessionConfigOption({
sessionId: "s3",
configId: "agent",
value: "my-reviewer",
}),
).rejects.toThrow("control channel closed");
// The flag never applied, so neither currentAgent nor the config option
// moves — no desync with the agent the SDK is actually running.
expect(session.currentAgent).toBe("default");
const agentOption = session.configOptions.find((o) => o.id === "agent");
expect(agentOption?.currentValue).toBe("default");
});
});
});

View File

@@ -1,9 +1,9 @@
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 { SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
@@ -52,7 +52,7 @@ describe("additionalRoots", () => {
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection);
} as unknown as AcpClient);
});
afterEach(

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { Pushable } from "../utils.js";
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the
// SDK's `AgentInfo` carries no built-in/source flag. That hardcoded set drifts
// silently if the SDK changes its default roster: a newly added built-in would
// leak into the ACP "Agent" picker as if the user had configured it. This
// integration test runs the real SDK so the set is flagged the moment it does.
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("agent discovery (SDK)", () => {
it("BUILTIN_AGENT_NAMES exactly covers the SDK's default agent roster", async () => {
const input = new Pushable<SDKUserMessage>();
const q = query({
prompt: input,
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
sessionId: randomUUID(),
// No setting sources → no user/project/local custom agents are loaded,
// so `supportedAgents()` reports exactly the intrinsic built-ins
// regardless of what the developer has in ~/.claude/agents.
settingSources: [],
includePartialMessages: true,
},
});
try {
const reported = (await q.supportedAgents()).map((a) => a.name).sort();
expect(reported).toEqual([...BUILTIN_AGENT_NAMES].sort());
// With the built-ins being the only agents present, discovery surfaces
// nothing — the ACP "Agent" picker is correctly omitted.
expect(await discoverCustomAgents(q)).toEqual([]);
} finally {
input.end();
q.return(undefined);
}
}, 30000);
});

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { ClaudeAcpAgent } from "../acp-agent.js";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
@@ -39,7 +38,7 @@ describe("authorization", () => {
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AgentSideConnection;
} as AcpClient;
const agent = new ClaudeAcpAgent(connectionMock);

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -47,13 +47,13 @@ describe("createSession options merging", () => {
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
function createMockClient(): AgentSideConnection {
function createMockClient(): AcpClient {
return {
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
}
beforeEach(async () => {
@@ -550,14 +550,15 @@ describe("createSession options merging", () => {
});
it("rejects a cwd that points at a file with invalidParams", async () => {
const file = path.join(os.tmpdir(), "claude-acp-cwd-is-a-file.txt");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-cwd-is-a-file-"));
const file = path.join(dir, "not-a-directory.txt");
fs.writeFileSync(file, "not a directory");
try {
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
code: RequestError.invalidParams().code,
});
} finally {
fs.rmSync(file, { force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import { SessionNotification } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
@@ -84,7 +84,7 @@ describe("session config options", () => {
let setModelSpy: ReturnType<typeof vi.fn>;
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
function createMockClient(): AgentSideConnection {
function createMockClient(): AcpClient {
return {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
@@ -92,7 +92,7 @@ describe("session config options", () => {
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
}
function populateSession() {

View File

@@ -1,18 +1,18 @@
import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeAcpAgent } from "../acp-agent.js";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
function createMockClient(): AgentSideConnection {
function createMockClient(): AcpClient {
return {
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
}
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => {
@@ -165,7 +165,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
const commandName =
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
@@ -274,7 +274,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
const concatenated =

View File

@@ -7,13 +7,26 @@ import * as os from "node:os";
describe("SettingsManager", () => {
let tempDir: string;
let settingsManager: SettingsManager;
let originalConfigDir: string | undefined;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
// Isolate the user tier. resolveSettings reads the user settings.json from
// CLAUDE_CONFIG_DIR at call time; without this it falls back to the real
// ~/.claude and the developer's own settings (e.g. availableModels) leak
// into the merge, making union-based assertions fail on their machine.
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, "user-config");
await fs.promises.mkdir(process.env.CLAUDE_CONFIG_DIR, { recursive: true });
});
afterEach(async () => {
settingsManager?.dispose();
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
}
await fs.promises.rm(tempDir, { recursive: true, force: true });
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
import { ClientCapabilities } from "@agentclientprotocol/sdk";
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
BetaMCPToolResultBlock,
@@ -10,7 +10,7 @@ import {
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import { AcpClient, toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import {
toolUpdateFromToolResult,
createPostToolUseHook,
@@ -25,7 +25,7 @@ import {
} from "../tools.js";
describe("rawOutput in tool call updates", () => {
const mockClient = {} as AgentSideConnection;
const mockClient = {} as AcpClient;
const mockLogger: Logger = { log: () => {}, error: () => {} };
it("should include rawOutput with string content for tool_result", () => {
@@ -425,7 +425,7 @@ describe("rawOutput in tool call updates", () => {
});
describe("Bash terminal output", () => {
const mockClient = {} as AgentSideConnection;
const mockClient = {} as AcpClient;
const mockLogger: Logger = { log: () => {}, error: () => {} };
const bashToolUse = {
@@ -702,6 +702,77 @@ describe("Bash terminal output", () => {
});
});
});
describe("with image array tool_result (local Bash image output path)", () => {
// The local Bash tool emits image content as
// `[{ type: "image", source: { type: "base64", ... } }]` when a
// command produces an image (e.g. piping a base64 data URI).
const makeImageBashResult = (
data: string,
media_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp" = "image/png",
): ToolResultBlockParam => ({
type: "tool_result",
tool_use_id: "toolu_bash",
content: [
{
type: "image",
source: { type: "base64", media_type, data },
} as ImageBlockParam,
],
});
it("should surface image content as ACP image content (terminal disabled)", () => {
const toolResult = makeImageBashResult("iVBORw0KGgo=");
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update.content).toEqual([
{
type: "content",
content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
},
]);
expect(update._meta).toBeUndefined();
});
it("should bypass terminal _meta even when supportsTerminalOutput is true", () => {
// Binary content cannot be streamed through the terminal-output
// _meta channel, so the fix returns ACP content directly and skips
// the terminal info/output/exit triple.
const toolResult = makeImageBashResult("iVBORw0KGgo=", "image/jpeg");
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
expect(update.content).toEqual([
{
type: "content",
content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/jpeg" },
},
]);
expect(update._meta).toBeUndefined();
});
it("should still surface multi-block content with text + image mixed", () => {
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_bash",
content: [
{ type: "text", text: "generated:" },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "AAAA" },
} as ImageBlockParam,
],
};
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
expect(update.content).toEqual([
{ type: "content", content: { type: "text", text: "generated:" } },
{
type: "content",
content: { type: "image", data: "AAAA", mimeType: "image/png" },
},
]);
});
});
});
describe("toAcpNotifications with clientCapabilities", () => {
@@ -929,7 +1000,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
// Register hook callback by processing tool_use
toAcpNotifications(
@@ -1008,7 +1079,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
toAcpNotifications(
[
@@ -1093,7 +1164,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
toAcpNotifications(
[
@@ -1147,7 +1218,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
toAcpNotifications(
[
@@ -1223,7 +1294,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
toAcpNotifications(
[
@@ -1304,7 +1375,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
// Step 1: Process tool_use chunk — registers the PostToolUse hook callback
const toolUseChunk = {
@@ -1404,7 +1475,7 @@ describe("Bash terminal output", () => {
sessionUpdate: async (notification: any) => {
hookUpdates.push(notification);
},
} as unknown as AgentSideConnection;
} as unknown as AcpClient;
// Process tool_use (registers hook)
toAcpNotifications(
@@ -1569,7 +1640,7 @@ describe("planEntries - undefined input regression", () => {
});
describe("toAcpNotifications - TodoWrite with undefined input regression", () => {
const mockClient = {} as AgentSideConnection;
const mockClient = {} as AcpClient;
const mockLogger: Logger = { log: () => {}, error: () => {} };
it("should not throw when TodoWrite tool_use has undefined input", () => {
@@ -1704,7 +1775,7 @@ describe("applyTaskCreate / applyTaskUpdate", () => {
});
describe("toAcpNotifications - Task* tools", () => {
const mockClient = {} as AgentSideConnection;
const mockClient = {} as AcpClient;
const mockLogger: Logger = { log: () => {}, error: () => {} };
it("suppresses tool_call for TaskCreate/TaskUpdate/TaskList/TaskGet on tool_use", () => {

View File

@@ -530,7 +530,9 @@ export function toolUpdateFromToolResult(
// 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: "..." }])
// 3. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
// or [{ type: "image", source: {...} }] when the local Bash tool
// produces an image, e.g. piping a base64 data URI)
let output = "";
let exitCode = isError ? 1 : 0;
@@ -545,13 +547,20 @@ export function toolUpdateFromToolResult(
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");
} else if (Array.isArray(result) && result.length > 0) {
const textOnly = result.every(
(c: any) => c && typeof c === "object" && typeof c.text === "string",
);
if (textOnly) {
output = result.map((c: any) => c.text).join("\n");
} else {
// Image (or mixed non-text) content. Binary payloads can't be
// streamed through the terminal-output _meta channel, so bypass
// it and surface the blocks as ACP content. This handles the
// local Bash tool's image output, which previously failed the
// text-only guard and was silently dropped.
return toAcpContentUpdate(result, isError);
}
}
if (supportsTerminalOutput) {