Compare commits

..

10 Commits

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


##
[0.42.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.41.0...v0.42.0)
(2026-06-05)


### Features

* **deps-dev:** bump obug from 2.1.1 to 2.1.2 in the minor group
([#743](https://github.com/agentclientprotocol/claude-agent-acp/issues/743))
([7ffb78c](7ffb78c572))
* Send message ids for assistant and user messages
([#750](https://github.com/agentclientprotocol/claude-agent-acp/issues/750))
([18516a3](18516a3088))
* Update Claude Agent SDK to 0.3.165
([#746](https://github.com/agentclientprotocol/claude-agent-acp/issues/746))
([23cbe6f](23cbe6f58f))


### Bug Fixes

* Prevent cross-family model matching in resolveModelPreference
([#731](https://github.com/agentclientprotocol/claude-agent-acp/issues/731))
([f4704c1](f4704c168f))
* Prune tool cache per session after results
([#748](https://github.com/agentclientprotocol/claude-agent-acp/issues/748))
([ec14211](ec14211056))
* Update to better utilize existing SDK data
([#747](https://github.com/agentclientprotocol/claude-agent-acp/issues/747))
([398f763](398f763f71))

---
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-05 10:54:26 +00:00
Ben Brandt
18516a3088 feat: Send message ids for assistant and user messages (#750) 2026-06-05 10:53:21 +00:00
Ben Brandt
ec14211056 fix: Prune tool cache per session after results (#748) 2026-06-05 09:11:34 +00:00
Ben Brandt
398f763f71 fix: Update to better utilize existing SDK data (#747)
- **feat: Better surface permission, progress, and rate updates**
- **Use SDK context usage and thinking config**
- **Keep learned context size after compaction**
2026-06-05 08:59:06 +00:00
Ben Brandt
23cbe6f58f feat: Update Claude Agent SDK to 0.3.165 (#746) 2026-06-05 07:58:26 +00:00
dependabot[bot]
7ffb78c572 feat(deps-dev): bump obug from 2.1.1 to 2.1.2 in the minor group (#743)
Bumps the minor group with 1 update:
[obug](https://github.com/sxzz/obug).

Updates `obug` from 2.1.1 to 2.1.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sxzz/obug/releases">obug's
releases</a>.</em></p>
<blockquote>
<h2>v2.1.2</h2>
<p><em>No significant changes</em></p>
<h5>    <a
href="https://github.com/sxzz/obug/compare/v2.1.1...v2.1.2">View changes
on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1f9500da5b"><code>1f9500d</code></a>
chore: release v2.1.2</li>
<li><a
href="6dcc07cd49"><code>6dcc07c</code></a>
chore: upgrade deps</li>
<li><a
href="9bcc620b15"><code>9bcc620</code></a>
ci: staging publish</li>
<li><a
href="c9688880be"><code>c968888</code></a>
chore: migrate tsdown config</li>
<li><a
href="2d652378be"><code>2d65237</code></a>
chore: add &quot;engines&quot; field to <code>package.json</code> (<a
href="https://redirect.github.com/sxzz/obug/issues/3">#3</a>)</li>
<li><a
href="a1191fd954"><code>a1191fd</code></a>
test: add file extensions to imports</li>
<li><a
href="b8239e5b9e"><code>b8239e5</code></a>
docs: add 'How to use' section to README [ci skip] (<a
href="https://redirect.github.com/sxzz/obug/issues/2">#2</a>)</li>
<li><a
href="55fe075260"><code>55fe075</code></a>
test: ignore dynamic import warning</li>
<li><a
href="a71beb8092"><code>a71beb8</code></a>
chore: update workflow references to main branch</li>
<li><a
href="e39bee9fcf"><code>e39bee9</code></a>
chore: upgrade deps</li>
<li>Additional commits viewable in <a
href="https://github.com/sxzz/obug/compare/v2.1.1...v2.1.2">compare
view</a></li>
</ul>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 09:49:53 +02:00
Luca Dibattista
f4704c168f fix: Prevent cross-family model matching in resolveModelPreference (#731)
## Summary

Partially addresses #639. Builds on #702 which added version-aware
matching.

When the SDK's `opus` alias resolves to a newer version than what's in
`availableModels` (e.g. the SDK was bumped to Opus 4.8 but
`availableModels` still contains `claude-opus-4-6[1m]`), the version
check from #702 correctly rejects the `opus` entry (4.8 ≠ 4.6). However,
the tokenized matcher then falls through to `sonnet[1m]` because the
`1m` context hint token alone scores 3 points — enough to win. This
causes `claude-opus-4-6[1m]` to display as "Sonnet (1M context)".

Fix: `scoreModelMatch` now returns 0 when the only matching token is the
context hint. This prevents cross-family matches without hardcoding
model family names.

Note: Claude initially recommended hardcoding model families to be 100%
sure we avoid cross-family matching. While it would be a stronger
signal, I didn't find references to model families in the codebase
(except for opusplan --> opus) so I'm assuming the family list is
transparent to claude-agent-acp.

## Test plan

- [x] Added test: `does not inherit display info across mismatched model
families` — verifies `claude-opus-4-6[1m]` does not resolve to `Sonnet
(1M context)`
- [x] Existing tests pass (20/20 in `acp-agent-settings.test.ts`)
- [x] New test confirmed to fail on unmodified `main` and pass with the
fix

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 07:46:22 +00:00
acp-release-bot[bot]
49d73b2ad3 chore(main): release 0.41.0 (#741)
🤖 I have created a release *beep* *boop*
---


##
[0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0)
(2026-06-04)


### Features

* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162
([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740))
([add7e31](add7e31704))


### Bug Fixes

* Force cancellation when SDK query hangs
([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742))
([cffea4b](cffea4be28)),
closes
[#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)

---
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-04 14:16:42 +02:00
Ben Brandt
cffea4be28 fix: Force cancellation when SDK query hangs (#742)
Closes #680
2026-06-04 11:47:13 +00:00
dependabot[bot]
add7e31704 feat(deps): upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 (#740)
Bumps the minor group with 9 updates:

| Package | From | To |
| --- | --- | --- |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.160` |
`0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.160` | `0.3.162`
|
| @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.160` | `0.3.162` |

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.160 to 0.3.162
<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.162</h2>
<h2>What's changed</h2>
<ul>
<li>Refusal error messages now carry <code>stop_reason:
&quot;refusal&quot;</code> and <code>stop_details</code> on the
assistant message and in session transcripts, so SDK consumers can
detect refusals without text-matching the error content</li>
<li>Agent SDK sessions on native builds now default to fast embedded
<code>find</code>/<code>grep</code> search in Bash, matching the
interactive CLI, instead of always registering the dedicated Grep/Glob
tools. To keep the dedicated tools (e.g. to intercept searches via
<code>canUseTool</code> or hooks), name them in the <code>tools</code>
option or reference them in <code>allowedTools</code></li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.162
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.162
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.162
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.162
</code></pre>
<h2>v0.3.161</h2>
<h2>What's changed</h2>
<ul>
<li>The <code>initialize</code> control request is now idempotent: a
second <code>initialize</code> returns the same success payload instead
of an <code>Already initialized</code> error.
<code>ControlResponse</code> gains an optional
<code>pending_permission_requests</code> field, mirroring
<code>ControlErrorResponse</code></li>
<li><code>applyFlagSettings</code> now live-applies <code>agent</code>
changes: switching the active agent (or passing <code>null</code> to
reset) takes effect on the next turn in a running session</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.161
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.161
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.161
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.161
</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.162</h2>
<ul>
<li>Refusal error messages now carry <code>stop_reason:
&quot;refusal&quot;</code> and <code>stop_details</code> on the
assistant message and in session transcripts, so SDK consumers can
detect refusals without text-matching the error content</li>
<li>Agent SDK sessions on native builds now default to fast embedded
<code>find</code>/<code>grep</code> search in Bash, matching the
interactive CLI, instead of always registering the dedicated Grep/Glob
tools. To keep the dedicated tools (e.g. to intercept searches via
<code>canUseTool</code> or hooks), name them in the <code>tools</code>
option or reference them in <code>allowedTools</code></li>
</ul>
<h2>0.3.161</h2>
<ul>
<li>The <code>initialize</code> control request is now idempotent: a
second <code>initialize</code> returns the same success payload instead
of an <code>Already initialized</code> error.
<code>ControlResponse</code> gains an optional
<code>pending_permission_requests</code> field, mirroring
<code>ControlErrorResponse</code></li>
<li><code>applyFlagSettings</code> now live-applies <code>agent</code>
changes: switching the active agent (or passing <code>null</code> to
reset) takes effect on the next turn in a running session</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="e9f26a0e02"><code>e9f26a0</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="2f67c51150"><code>2f67c51</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.160...v0.3.162">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.3.160
to 0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.3.160 to
0.3.162


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-04 12:45:17 +02:00
9 changed files with 1206 additions and 139 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.40.0"
".": "0.42.0"
}

View File

@@ -1,5 +1,33 @@
# Changelog
## [0.42.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.41.0...v0.42.0) (2026-06-05)
### Features
* **deps-dev:** bump obug from 2.1.1 to 2.1.2 in the minor group ([#743](https://github.com/agentclientprotocol/claude-agent-acp/issues/743)) ([7ffb78c](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ffb78c57280fc32af77862896c0035ce0e690b7))
* Send message ids for assistant and user messages ([#750](https://github.com/agentclientprotocol/claude-agent-acp/issues/750)) ([18516a3](https://github.com/agentclientprotocol/claude-agent-acp/commit/18516a3088f847a38f5eee0fe81ad5ca3e2a751c))
* Update Claude Agent SDK to 0.3.165 ([#746](https://github.com/agentclientprotocol/claude-agent-acp/issues/746)) ([23cbe6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/23cbe6f58fa9e10ad736f664c4cf94dbd1c6ad65))
### Bug Fixes
* Prevent cross-family model matching in resolveModelPreference ([#731](https://github.com/agentclientprotocol/claude-agent-acp/issues/731)) ([f4704c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/f4704c168f917e6710f2b3cd49e1fef49613a469))
* Prune tool cache per session after results ([#748](https://github.com/agentclientprotocol/claude-agent-acp/issues/748)) ([ec14211](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec142110561f680330c6976783805685c5c069ec))
* Update to better utilize existing SDK data ([#747](https://github.com/agentclientprotocol/claude-agent-acp/issues/747)) ([398f763](https://github.com/agentclientprotocol/claude-agent-acp/commit/398f763f71bd2fe2ca9e8269de120d26a744b7a8))
## [0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0) (2026-06-04)
### Features
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 ([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740)) ([add7e31](https://github.com/agentclientprotocol/claude-agent-acp/commit/add7e31704581aa70febb6341ca0e8cee5d93b43))
### Bug Fixes
* Force cancellation when SDK query hangs ([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742)) ([cffea4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/cffea4be28e6def91027110433a99e80b1be8a0c)), closes [#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)
## [0.40.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.39.0...v0.40.0) (2026-06-02)

137
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.40.0",
"version": "0.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.40.0",
"version": "0.42.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.24.0",
"@anthropic-ai/claude-agent-sdk": "0.3.160",
"@anthropic-ai/claude-agent-sdk": "0.3.165",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
@@ -41,22 +41,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.160.tgz",
"integrity": "sha512-kJjgIkb09TIhYXbOEngQV+iERhYMbxQwjciXEfVYP/Zo27tacdWYS5MaIwzKxmPW+Yd1VL2BcvCIo1ES4F1tow==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz",
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.160",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.160"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -65,9 +65,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.160.tgz",
"integrity": "sha512-lGd7aySWuFO9MmxXj8plnRobUPVSXztk+EYdews2sDiC28483SLeJ4Ft086GYCaCb/ILeYAsK6aKo25R7Gl9QA==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz",
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==",
"cpu": [
"arm64"
],
@@ -78,9 +78,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.160.tgz",
"integrity": "sha512-ck9r+zCg8mvOnEMZhwWSsizkuAW1evB+UzRBFqOlTQCij7KJK5Nw2YRTVVdp4knesM7/q82/cGGZgFtAVxq6IQ==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz",
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==",
"cpu": [
"x64"
],
@@ -91,12 +91,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.160.tgz",
"integrity": "sha512-gzC5CGG2jiSoFEfj4MPc4+QkfQbvGKj8pKN6kpH9TLJeexKnoPNzDEGwy0T/+VGGn2zQwOxIl9sh0QBb+v01DA==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz",
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -104,12 +107,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.160.tgz",
"integrity": "sha512-17rq9mNH26jndqIYKkCwNoZrThj/0wJosiixuGEGPEFSzjxv4LDNfLp+Rnpt553HvLBpW8a0IWhYyOyruvpsZQ==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz",
"integrity": "sha512-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -117,12 +123,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.160.tgz",
"integrity": "sha512-WvAuHDekEwTr8O192WOVrCpDHO+cH2a1Dgm5DQAobiPMZxD64mQ9Bbo8otlSnuN3ziPGv/RbouyJHf6JFrotMA==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz",
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -130,12 +139,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.160.tgz",
"integrity": "sha512-85FfW+Heaop8U2Xq5LxIZzunVYcua8X3XSmcWgUX3DXFiAPMfXKyKwJOUDJC5PrVqlWduDZA2K30yTl2JvapcQ==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz",
"integrity": "sha512-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -143,9 +155,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.160.tgz",
"integrity": "sha512-CEpNzmazlgAl/hCaY3Hc12QxuMdvG+kOhi0V0Cp706p5zUiHUojX/e0ahqURn4yBfTddLoAlGAq0VzlMriwsgA==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz",
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==",
"cpu": [
"arm64"
],
@@ -156,9 +168,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.160.tgz",
"integrity": "sha512-EVjDyTUem5xAjbPfYu/uBAeCK/qrZNjociW9u8wMvwxF1f0mmXTH+CdCVYeQ78RZBTG5SxV+OXAsgRX/OeWxOQ==",
"version": "0.3.165",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz",
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==",
"cpu": [
"x64"
],
@@ -630,6 +642,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -647,6 +662,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -664,6 +682,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -681,6 +702,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -698,6 +722,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -715,6 +742,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -926,7 +956,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -2661,6 +2691,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2682,6 +2715,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2703,6 +2739,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2724,6 +2763,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2954,15 +2996,18 @@
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
@@ -3304,9 +3349,9 @@
"peer": true
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"dev": true,
"license": "ISC",
"bin": {

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.40.0",
"version": "0.42.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -61,7 +61,7 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.24.0",
"@anthropic-ai/claude-agent-sdk": "0.3.160",
"@anthropic-ai/claude-agent-sdk": "0.3.165",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {

View File

@@ -36,8 +36,6 @@ import {
CloseSessionResponse,
DeleteSessionRequest,
DeleteSessionResponse,
TerminalHandle,
TerminalOutputResponse,
WriteTextFileRequest,
WriteTextFileResponse,
StopReason,
@@ -57,10 +55,12 @@ import {
query,
Settings,
SDKAssistantMessageError,
SDKMessage,
SDKMessageOrigin,
SDKPartialAssistantMessage,
SDKUserMessage,
SlashCommand,
ThinkingConfig,
} from "@anthropic-ai/claude-agent-sdk";
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
@@ -134,6 +134,17 @@ const ZERO_USAGE = Object.freeze({
const DEFAULT_CONTEXT_WINDOW = 200000;
/** Floor after `session/cancel` before the adapter forces the active prompt
* loop to return "cancelled". `query.interrupt()` normally makes the SDK
* yield a trailing idle within milliseconds, and the loop returns through its
* usual path — so this timer is armed and cleared, never fired, on healthy
* cancels. It only trips when the SDK is genuinely wedged (e.g. a
* `TaskOutput { block: true }` poll against a hung background task — issue
* #680) and never yields. The value is deliberately loose: it's an
* "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
* pre-empt a slow-but-healthy interrupt. */
const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
/** Internal model-selection state. Mirrors the shape the ACP SDK exposed as
* `SessionModelState` before model selection moved entirely into
* `SessionConfigOption` (category "model"). Retained internally to track the
@@ -161,6 +172,17 @@ type Session = {
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
nextPendingOrder: number;
abortController: AbortController;
/** Per-turn signal the active prompt loop races `query.next()` against.
* Aborted by cancel() (after a grace period) to force the loop to return
* "cancelled" when the SDK is wedged and `query.next()` never yields again
* (issue #680). Distinct from `abortController`: this only wakes the loop;
* it does NOT touch the SDK query/subprocess. Undefined when no prompt is
* actively consuming the query. */
cancelController?: AbortController;
/** Pending grace-period timer that aborts `cancelController`. Cleared when
* the loop returns normally so the backstop never fires after a clean
* cancel. */
forceCancelTimer?: ReturnType<typeof setTimeout>;
emitRawSDKMessages: boolean | SDKMessageFilter[];
/** Context window size of the last top-level assistant model, carried across
* prompts so mid-stream usage_update notifications report a correct `size`
@@ -171,6 +193,30 @@ type Session = {
/** Accumulated task list for the session, keyed by task ID. Task IDs are
* per-session, so this state must not be shared across sessions. */
taskState: TaskState;
/** Caches `tool_use` blocks by id so the matching `tool_result` can recover
* the tool name/input when mapping it to a `tool_call_update`. Per-session
* (tool_use ids are only unique within a session) and pruned at
* `tool_result` time so a long-running session doesn't accumulate every
* tool call for its whole lifetime. */
toolUseCache: ToolUseCache;
/** Maps the ACP `messageId` we expose to clients (see `messageIdForGrouping`)
* to the SDK message uuid that the Agent SDK's rewind/resume APIs key on
* (`Query.rewindFiles` takes a user-message uuid; `resumeSessionAt` takes an
* `SDKAssistantMessage.uuid`). For assistant turns the two differ — the ACP
* id is the Anthropic API message id (`msg_…`), available at `message_start`
* so streamed chunks can carry it, while the uuid only arrives on the
* consolidated message — so a client can only ask to rewind/fork by the id it
* was given, and we need this table to translate it back.
*
* Populated as a byproduct of the message loop (the consolidated message
* carries both ids) and of `replaySessionHistory` on load, so no extra
* `getSessionMessages` read is needed at rewind time. Last-write-wins
* naturally yields the turn-boundary uuid when one `msg_…` spans several
* content-block messages.
*
* NOT READ YET — recorded now so the mapping exists if/when we wire up
* fork/rewind. */
messageIdToUuid: Map<string, string>;
};
/** Compute a stable fingerprint of the session-defining params so we can
@@ -185,17 +231,6 @@ function computeSessionFingerprint(params: {
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
}
type BackgroundTerminal =
| {
handle: TerminalHandle;
status: "started";
lastOutput: TerminalOutputResponse | null;
}
| {
status: "aborted" | "exited" | "killed" | "timedOut";
pendingOutput: TerminalOutputResponse;
};
export type SDKMessageFilter = {
type: string;
subtype?: string;
@@ -524,16 +559,17 @@ export class ClaudeAcpAgent implements Agent {
[key: string]: Session;
};
client: AgentSideConnection;
toolUseCache: ToolUseCache;
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
clientCapabilities?: ClientCapabilities;
logger: Logger;
gatewayAuthRequest?: GatewayAuthRequest;
/** Grace period before a `session/cancel` forces a wedged prompt loop to
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
* tests can shrink it. */
forceCancelGraceMs: number = DEFAULT_FORCE_CANCEL_GRACE_MS;
constructor(client: AgentSideConnection, logger?: Logger) {
this.sessions = {};
this.client = client;
this.toolUseCache = {};
this.logger = logger ?? console;
}
@@ -790,6 +826,12 @@ export class ClaudeAcpAgent implements Agent {
// forward it to clients as structured `data`, sparing them from
// pattern-matching on the human-readable message text.
let lastAssistantError: SDKAssistantMessageError | undefined;
// When a streaming classifier refuses a turn, the assistant message carries
// stop_reason "refusal" and structured stop_details. We capture the
// human-readable explanation here so the terminal `result` can surface it
// to the user (the refused assistant message itself usually has no content)
// and report ACP's dedicated `refusal` stop reason.
let lastRefusalExplanation: string | null = null;
// Tracks whether we're inside a compaction. The SDK emits the terminal
// `status` (compact_result success/failed) twice for a single failed
// compaction, and the two messages are indistinguishable — so we report the
@@ -797,6 +839,17 @@ export class ClaudeAcpAgent implements Agent {
// `compacting` status sets it again, so every distinct compaction (e.g.
// repeated auto-compactions in a long turn) is still shown.
let compactionInProgress = false;
// Holds the Anthropic API message id of the assistant message currently
// being streamed, captured from `message_start` so every streamed chunk can
// be tagged with it. We use the API message id rather than the
// per-`stream_event` uuid because the same id is also present on the
// consolidated assistant message and in the persisted transcript — so a turn
// keeps the same ACP `messageId` whether it is streamed live or replayed
// from history. The per-event uuid is unique per event and never persisted.
// A single value suffices because every streaming partial arrives with
// `parent_tool_use_id === null` (subagent work is folded into tool-result
// messages, never surfaced as partial streams).
let currentStreamMessageId: string | undefined;
const userMessage = promptToClaude(params);
@@ -828,9 +881,27 @@ export class ClaudeAcpAgent implements Agent {
let errored = false;
let stopReason: StopReason = "end_turn";
// Wake-up channel so cancel() can force this loop to return "cancelled"
// even when query.next() is wedged and never yields again (issue #680).
const cancelController = new AbortController();
session.cancelController = cancelController;
const cancelled = new Promise<void>((resolve) => {
cancelController.signal.addEventListener("abort", () => resolve(), { once: true });
});
try {
while (true) {
const { value: message, done } = await session.query.next();
const nextMessage = session.query.next();
const next = await Promise.race([nextMessage, cancelled]);
if (cancelController.signal.aborted) {
// The SDK never yielded after interrupt() (e.g. a wedged TaskOutput
// block). Abandon the in-flight next() — swallowing any later
// rejection so it can't surface as an unhandled rejection — and
// honor the cancel per the ACP contract.
void nextMessage.catch(() => {});
return { stopReason: "cancelled" };
}
const { value: message, done } = next as IteratorResult<SDKMessage, void>;
if (done || !message) {
if (session.cancelled) {
@@ -890,28 +961,35 @@ export class ClaudeAcpAgent implements Agent {
break;
}
case "compact_boundary": {
// Send used:0 immediately so the client doesn't keep showing
// the stale pre-compaction context size until the next turn.
// Refresh the displayed usage immediately so the client doesn't
// keep showing the stale pre-compaction size (e.g. "944k/1m")
// right after the user sees "Compacting completed", which is
// confusing and wrong.
//
// This is a deliberate approximation: we don't know the exact
// post-compaction token count (only the SDK's next API call
// reveals that). But used:0 is directionally correct — context
// just dropped dramatically — and the real value replaces it
// within seconds when the next result message arrives.
// The alternative (no update) leaves the client showing e.g.
// "944k/1m" right after the user sees "Compacting completed",
// which is confusing and wrong.
// Prefer the SDK's authoritative post-compaction `used` via
// getContextUsage — it reflects the real retained context
// (system prompt + tools + surviving messages), which the
// per-message API usage numbers can't give us until the next
// turn's result. If the control request fails, fall back to the
// used:0 approximation: directionally correct (context just
// dropped dramatically) and replaced within seconds by the next
// result message.
//
// `size` keeps coming from session.contextWindowSize (learned
// from modelUsage / the model heuristic) — getContextUsage's
// window field under-reports extended 1M windows.
//
// The "Compacting completed." text is emitted from the `status`
// handler (keyed on `compact_result`), not here, so the failure
// path gets a message too.
lastAssistantTotalUsage = 0;
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
lastAssistantUsage = null;
lastAssistantTotalUsage = usedTokens ?? 0;
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "usage_update",
used: 0,
used: lastAssistantTotalUsage,
size: session.contextWindowSize,
},
});
@@ -976,6 +1054,65 @@ export class ClaudeAcpAgent implements Agent {
});
break;
}
case "commands_changed": {
// Push the full slash-command list after a mid-session change
// (e.g. skills discovered dynamically as the agent works in a
// subdirectory). The client should REPLACE its cached command
// list with this payload: supportedCommands() is captured once
// at initialize and never reflects mid-session changes, so we
// forward message.commands directly rather than re-querying.
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: getAvailableSlashCommands(message.commands),
},
});
break;
}
case "mirror_error": {
// The SDK failed to persist session history (SessionStore
// append rejected/timed out after retry) — potential data loss
// the user should know about rather than a silent gap on
// resume. Log it and surface a warning in the conversation.
this.logger.error(
`Session ${message.session_id}: failed to persist history: ${message.error}`,
);
break;
}
case "permission_denied": {
// A tool call was auto-denied (by a rule, the classifier,
// dontAsk mode, etc.) before running. The tool_use block was
// already emitted as a `tool_call`, so mark it failed with the
// rejection reason — otherwise the client shows a tool call
// that silently never resolves.
const reason = message.decision_reason ?? message.message;
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: message.tool_use_id,
status: "failed",
content: [
{
type: "content",
content: { type: "text", text: `Permission denied: ${reason}` },
},
],
_meta: {
claudeCode: {
toolName: message.tool_name,
toolResponse: {
decisionReasonType: message.decision_reason_type,
decisionReason: message.decision_reason,
message: message.message,
},
},
} satisfies ToolUpdateMeta,
},
});
break;
}
case "hook_started":
case "hook_progress":
case "hook_response":
@@ -988,8 +1125,6 @@ export class ClaudeAcpAgent implements Agent {
case "plugin_install":
case "notification":
case "api_retry":
case "mirror_error":
case "permission_denied":
case "thinking_tokens":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
@@ -1048,6 +1183,26 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// A refusal can arrive on any result subtype (and may even set
// is_error), so handle it before the subtype switch — otherwise the
// is_error throw below would surface it as an internal error. The
// refused assistant message carries no visible content, so surface
// the classifier's explanation (when available) and report ACP's
// dedicated `refusal` stop reason.
if (message.stop_reason === "refusal" && !isTaskNotification) {
if (lastRefusalExplanation) {
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: lastRefusalExplanation },
},
});
}
stopReason = "refusal";
break;
}
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
@@ -1074,7 +1229,7 @@ export class ClaudeAcpAgent implements Agent {
message.result,
"assistant",
params.sessionId,
this.toolUseCache,
session.toolUseCache,
this.client,
this.logger,
)) {
@@ -1121,6 +1276,12 @@ export class ClaudeAcpAgent implements Agent {
break;
}
case "stream_event": {
// `message_start` carries the Anthropic API message id; capture it
// so the streamed chunks that follow (whose delta events don't carry
// it) can all be tagged with the same, replay-stable id.
if (message.event.type === "message_start") {
currentStreamMessageId = message.event.message.id || undefined;
}
if (
message.parent_tool_use_id === null &&
(message.event.type === "message_start" || message.event.type === "message_delta")
@@ -1175,13 +1336,14 @@ export class ClaudeAcpAgent implements Agent {
for (const notification of streamEventToAcpNotifications(
message,
params.sessionId,
this.toolUseCache,
session.toolUseCache,
this.client,
this.logger,
{
clientCapabilities: this.clientCapabilities,
cwd: session.cwd,
taskState: session.taskState,
messageId: currentStreamMessageId,
},
)) {
await this.client.sessionUpdate(notification);
@@ -1194,6 +1356,15 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// Record the ACP messageId -> SDK uuid mapping for this message. The
// consolidated message carries both ids, so this is where we learn
// the uuid that the SDK's rewind/resume APIs key on for the id we
// hand clients. Not read yet (see Session.messageIdToUuid).
const mappedMessageId = messageIdForGrouping(message);
if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
session.messageIdToUuid.set(mappedMessageId, message.uuid);
}
// Check for prompt replay
if (message.type === "user" && "uuid" in message && message.uuid) {
if (message.uuid === promptUuid) {
@@ -1228,6 +1399,9 @@ export class ClaudeAcpAgent implements Agent {
if (message.error) {
lastAssistantError = message.error;
}
if (message.message.stop_reason === "refusal") {
lastRefusalExplanation = message.message.stop_details?.explanation ?? null;
}
}
// Strip <command-*>/<local-command-stdout> markers and render any
@@ -1246,7 +1420,7 @@ export class ClaudeAcpAgent implements Agent {
stripped,
message.message.role,
params.sessionId,
this.toolUseCache,
session.toolUseCache,
this.client,
this.logger,
{
@@ -1254,6 +1428,7 @@ export class ClaudeAcpAgent implements Agent {
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
taskState: session.taskState,
messageId: messageIdForGrouping(message),
},
)) {
await this.client.sessionUpdate(notification);
@@ -1308,7 +1483,7 @@ export class ClaudeAcpAgent implements Agent {
content,
message.message.role,
params.sessionId,
this.toolUseCache,
session.toolUseCache,
this.client,
this.logger,
{
@@ -1316,17 +1491,47 @@ export class ClaudeAcpAgent implements Agent {
parentToolUseId: message.parent_tool_use_id,
cwd: session.cwd,
taskState: session.taskState,
messageId: messageIdForGrouping(message),
},
)) {
await this.client.sessionUpdate(notification);
}
break;
}
case "tool_progress":
case "tool_progress": {
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "tool_call_update",
toolCallId: message.tool_use_id,
status: "in_progress",
_meta: {
claudeCode: {
toolName: message.tool_name,
toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
},
} satisfies ToolUpdateMeta,
},
});
break;
}
case "rate_limit_event": {
if (lastAssistantTotalUsage !== null) {
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "usage_update",
used: lastAssistantTotalUsage,
size: session.contextWindowSize,
_meta: { "_claude/rateLimit": message.rate_limit_info },
},
});
}
break;
}
case "tool_use_summary":
case "auth_status":
case "prompt_suggestion":
case "rate_limit_event":
break;
default:
unreachable(message);
@@ -1385,6 +1590,16 @@ export class ClaudeAcpAgent implements Agent {
}
throw error;
} finally {
// The loop is returning — interrupt() succeeded or the prompt finished
// — so disarm the force-cancel backstop and release the wake-up channel
// (only if we still own it; a handoff installs the next prompt's).
if (session.forceCancelTimer) {
clearTimeout(session.forceCancelTimer);
session.forceCancelTimer = undefined;
}
if (session.cancelController === cancelController) {
session.cancelController = undefined;
}
if (!handedOff) {
session.promptRunning = false;
if (errored) {
@@ -1422,6 +1637,33 @@ export class ClaudeAcpAgent implements Agent {
pending.resolve(true);
}
session.pendingMessages.clear();
// Arm a backstop before interrupting: if a prompt is actively consuming
// the query and interrupt() doesn't make the SDK yield (e.g. a wedged
// TaskOutput block — issue #680), force the loop to return "cancelled"
// after the floor elapses so the pending session/prompt still resolves per
// the ACP cancellation contract instead of hanging forever. The loop's
// `finally` clears this timer when interrupt() works and it returns through
// the normal idle path, so on healthy cancels it is armed but never fires.
//
// Arm at most once per turn: the floor is an absolute ceiling from the
// first cancel, so a client that re-sends cancel (each call still retries
// interrupt() below) can't keep pushing the deadline out.
if (
session.promptRunning &&
session.cancelController &&
!session.cancelController.signal.aborted &&
!session.forceCancelTimer
) {
const cancelController = session.cancelController;
session.forceCancelTimer = setTimeout(() => {
this.logger.error(
`Session ${params.sessionId}: cancel floor elapsed without the SDK yielding; forcing "cancelled". The underlying query may still be wedged — a new session may be required.`,
);
cancelController.abort();
}, this.forceCancelGraceMs);
}
await session.query.interrupt();
}
@@ -1433,6 +1675,18 @@ export class ClaudeAcpAgent implements Agent {
return;
}
await this.cancel({ sessionId });
// cancel() arms the force-cancel floor and interrupts gracefully, but a
// wedged prompt loop only wakes when `cancelController` aborts — closing
// the query/abortController below doesn't touch it. Since we're tearing the
// session down anyway, wake the loop now so the in-flight prompt() resolves
// immediately instead of after the floor, and clear the timer so it can't
// outlive the deleted session (it isn't unref'd and would otherwise keep
// the event loop alive until it fires).
if (session.forceCancelTimer) {
clearTimeout(session.forceCancelTimer);
session.forceCancelTimer = undefined;
}
session.cancelController?.abort();
session.settingsManager.dispose();
session.abortController.abort();
session.query.close();
@@ -1579,6 +1833,16 @@ export class ClaudeAcpAgent implements Agent {
const messages = await getSessionMessages(sessionId);
for (const message of messages) {
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
// observe live (resumed/loaded sessions), so rewind/resume can translate
// a client-supplied id without an extra getSessionMessages read. Not read
// yet (see Session.messageIdToUuid).
const replayMessageId = messageIdForGrouping(message);
const replaySession = this.sessions[sessionId];
if (replaySession && replayMessageId && message.uuid) {
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
}
// @ts-expect-error - untyped in SDK but we handle all of these
let content: unknown = message.message.content;
// @ts-expect-error - untyped in SDK but we handle all of these
@@ -1601,6 +1865,7 @@ export class ClaudeAcpAgent implements Agent {
clientCapabilities: this.clientCapabilities,
cwd: this.sessions[sessionId]?.cwd,
taskState: this.sessions[sessionId]?.taskState,
messageId: replayMessageId,
},
)) {
await this.client.sessionUpdate(notification);
@@ -2026,10 +2291,8 @@ export class ClaudeAcpAgent implements Agent {
const sessionMeta = params._meta as NewSessionMeta | undefined;
const userProvidedOptions = sessionMeta?.claudeCode?.options;
// Configure thinking tokens from environment variable
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
: undefined;
// Configure thinking behavior from environment variable
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
// Parse model configuration from environment (e.g. Bedrock model overrides)
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
@@ -2055,7 +2318,7 @@ export class ClaudeAcpAgent implements Agent {
const options: Options = {
systemPrompt,
settingSources: ["user", "project", "local"],
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
// CLAUDE_MODEL_CONFIG env var is a fallback for model
// configuration (e.g. Bedrock model ID overrides). When the caller
@@ -2283,7 +2546,6 @@ export class ClaudeAcpAgent implements Agent {
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
});
}
this.sessions[sessionId] = {
query: q,
input: input,
@@ -2309,6 +2571,8 @@ export class ClaudeAcpAgent implements Agent {
contextWindowSize:
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
taskState,
toolUseCache: {},
messageIdToUuid: new Map(),
};
return {
@@ -2600,11 +2864,14 @@ function tokenizeModelPreference(model: string): { tokens: string[]; contextHint
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
let score = 0;
let nonHintMatched = false;
for (const token of tokens) {
if (haystack.includes(token)) {
if (token !== contextHint) nonHintMatched = true;
score += token === contextHint ? 3 : 1;
}
}
if (contextHint && !nonHintMatched) return 0;
return score;
}
@@ -2900,6 +3167,54 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
};
}
/**
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
* transcript message (replay) so chunk grouping is identical in both views.
*
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
* which is identical at `message_start`, on the consolidated assistant message,
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
* unique per event and never persisted. User messages have no API id, but they
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
* ids are opaque strings, so no particular format is required.
*/
export function messageIdForGrouping(message: {
type?: string;
uuid?: string | null;
message?: unknown;
}): string | undefined {
if (message.type === "assistant") {
const inner = message.message;
const apiId =
inner && typeof inner === "object" && "id" in inner
? (inner as { id?: unknown }).id
: undefined;
if (typeof apiId === "string" && apiId.length > 0) {
return apiId;
}
}
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
}
/**
* Stamps an ACP `messageId` onto a session update, but only on the message/
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
*/
function applyMessageId(
update: SessionNotification["update"],
messageId: string | undefined,
): void {
if (
messageId &&
(update.sessionUpdate === "agent_message_chunk" ||
update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "agent_thought_chunk")
) {
update.messageId = messageId;
}
}
/**
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
* Only handles text, image, and thinking chunks for now.
@@ -2917,6 +3232,12 @@ export function toAcpNotifications(
parentToolUseId?: string | null;
cwd?: string;
taskState?: TaskState;
// Opaque id identifying the message these chunks belong to (ACP message ids
// are opaque strings — no particular format is required). Attached to
// user/agent message and thought chunks so clients can group streamed chunks
// into a single message. Omit it (leave undefined) when unknown — never send
// an explicit `null`.
messageId?: string;
},
): SessionNotification[] {
const taskState = options?.taskState ?? new Map();
@@ -2930,6 +3251,7 @@ export function toAcpNotifications(
text: content,
},
};
applyMessageId(update, options?.messageId);
if (options?.parentToolUseId) {
update._meta = {
@@ -3005,42 +3327,41 @@ export function toAcpNotifications(
} else {
// Only register hooks on first encounter to avoid double-firing
if (registerHooks && !alreadyCached) {
// Capture the tool name in the closure rather than re-reading the
// cache when the hook fires. The cache entry is pruned at
// tool_result time, and a PostToolUse hook can fire after that, so
// closing over the name keeps the diff working without depending on
// (or pinning) the cache entry's lifetime.
const toolName = chunk.name;
registerHookCallback(chunk.id, {
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
const toolUse = toolUseCache[toolUseId];
if (toolUse) {
// Both `Edit` and `Write` produce a structuredPatch in their
// PostToolUse tool_response. For Edit the diff replaces the
// optimistic content built at tool_use time. For Write the
// optimistic content (built from `input.content` alone with
// `oldText: null`) shows "creation" semantics regardless of
// whether the file existed; the structuredPatch from the
// hook lets us emit the real diff for `type: "update"`. The
// helper returns `{}` if the response shape isn't usable.
const editDiff =
toolUse.name === "Edit" || toolUse.name === "Write"
? toolUpdateFromDiffToolResponse(toolResponse)
: {};
const update: SessionNotification["update"] = {
_meta: {
claudeCode: {
toolResponse,
toolName: toolUse.name,
},
} satisfies ToolUpdateMeta,
toolCallId: toolUseId,
sessionUpdate: "tool_call_update",
...editDiff,
};
await client.sessionUpdate({
sessionId,
update,
});
} else {
logger.error(
`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
);
}
// Both `Edit` and `Write` produce a structuredPatch in their
// PostToolUse tool_response. For Edit the diff replaces the
// optimistic content built at tool_use time. For Write the
// optimistic content (built from `input.content` alone with
// `oldText: null`) shows "creation" semantics regardless of
// whether the file existed; the structuredPatch from the
// hook lets us emit the real diff for `type: "update"`. The
// helper returns `{}` if the response shape isn't usable.
const editDiff =
toolName === "Edit" || toolName === "Write"
? toolUpdateFromDiffToolResponse(toolResponse)
: {};
const update: SessionNotification["update"] = {
_meta: {
claudeCode: {
toolResponse,
toolName,
},
} satisfies ToolUpdateMeta,
toolCallId: toolUseId,
sessionUpdate: "tool_call_update",
...editDiff,
};
await client.sessionUpdate({
sessionId,
update,
});
},
});
}
@@ -3177,6 +3498,11 @@ export function toAcpNotifications(
...toolUpdate,
};
}
// The tool_use is fully resolved now — drop it so a long session doesn't
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
// over the tool name and no longer reads the cache, so pruning here is
// safe regardless of hook/result ordering.
delete toolUseCache[chunk.tool_use_id];
break;
}
@@ -3207,6 +3533,7 @@ export function toAcpNotifications(
},
};
}
applyMessageId(update, options?.messageId);
output.push({ sessionId, update });
}
}
@@ -3224,6 +3551,7 @@ export function streamEventToAcpNotifications(
clientCapabilities?: ClientCapabilities;
cwd?: string;
taskState?: TaskState;
messageId?: string;
},
): SessionNotification[] {
const event = message.event;
@@ -3241,6 +3569,7 @@ export function streamEventToAcpNotifications(
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
taskState: options?.taskState,
messageId: options?.messageId,
},
);
case "content_block_delta":
@@ -3256,6 +3585,7 @@ export function streamEventToAcpNotifications(
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
taskState: options?.taskState,
messageId: options?.messageId,
},
);
// No content. `ping` is a Messages-API keep-alive event that the SDK's
@@ -3297,7 +3627,8 @@ function commonPrefixLength(a: string, b: string) {
}
/** Best-effort first guess of a model's context window from its ID, used only
* until a `result` message arrives with the authoritative `modelUsage` value.
* 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. */
@@ -3306,6 +3637,45 @@ function inferContextWindowFromModel(model: string): number | null {
return null;
}
/** Fetch the SDK's authoritative context-window occupancy via the
* `getContextUsage` control request. Unlike the per-message API usage numbers
* (which only count message tokens), this `totalTokens` includes the system
* prompt, tool schemas, MCP tools, and memory-file overhead — the real
* occupancy the user sees. Returns `null` on any control-request failure.
*
* Note: we deliberately do NOT use this response's window fields for `size`.
* They have been observed to under-report extended (1M) context windows, so
* the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
* which handle the 1M variants correctly. */
async function fetchContextUsedTokens(query: Query, logger: Logger): Promise<number | null> {
try {
const usage = await query.getContextUsage();
return usage.totalTokens;
} catch (error) {
logger.error("Failed to fetch context usage from SDK:", error);
return null;
}
}
/** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
* option. The `maxThinkingTokens` option it used to feed is deprecated and
* reduced to on/off on current models, so map the value to explicit thinking
* config instead: unset → `undefined` (SDK default, adaptive on models that
* support it); `0` → disabled; a positive integer → a fixed token budget.
* Anything else is ignored with a warning. */
function resolveThinkingConfig(
raw: string | undefined,
logger: Logger,
): ThinkingConfig | undefined {
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed < 0) {
logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`);
return undefined;
}
return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed };
}
function parseModelConfig(
raw: string | undefined,
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {

View File

@@ -585,7 +585,7 @@ describe("ClaudeAcpAgent settings", () => {
);
});
it("does not inherit display info across mismatched model family versions", async () => {
it("does not inherit display info across mismatched model versions", async () => {
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
// when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry
// of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match
@@ -646,6 +646,60 @@ describe("ClaudeAcpAgent settings", () => {
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
});
it("does not inherit display info across mismatched model families", async () => {
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
// when the SDK's `opus` alias resolves to Opus 4.8, `claude-opus-4-6[1m]`
// fails the version check against `opus` but the tokenized matcher
// falls through to `sonnet[1m]` because the `1m` context hint alone
// is enough to score a match.
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["claude-opus-4-6", "claude-opus-4-6[1m]"],
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "Default model" },
{
value: "sonnet",
displayName: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks",
},
{
value: "sonnet[1m]",
displayName: "Sonnet (1M context)",
description: "Sonnet 4.6 for long sessions",
},
{
value: "opus",
displayName: "Opus 4.8",
description: "Claude Opus 4.8",
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modelOption = response.configOptions.find((o: any) => o.id === "model");
const byValue: Record<string, { name: string; description?: string }> = {};
for (const opt of modelOption.options) {
byValue[opt.value] = { name: opt.name, description: opt.description };
}
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
});
});
it("resolves model aliases like opus[1m] to the correct model", async () => {

View File

@@ -33,10 +33,16 @@ import {
claudeCliPath,
describeAlwaysAllow,
streamEventToAcpNotifications,
messageIdForGrouping,
type SDKMessageFilter,
} from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { deleteSession, query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import {
deleteSession,
getSessionMessages,
query,
SDKAssistantMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
@@ -1318,6 +1324,67 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
const { value } = await q.next();
expect(value).toMatchObject({ type: "system", session_id: sessionId });
}, 10000);
// Pins the SDK invariant our `messageId` plumbing relies on: the Anthropic
// API message id is available at `message_start` (before any delta), is the
// same on the consolidated assistant message, and is recoverable from the
// persisted transcript — so a turn keeps one stable id across streaming and
// replay. The per-`stream_event` uuid is NOT used because it is unique per
// event and never persisted; this test would fail if a future SDK regressed
// any of those properties.
it("uses the API message id as a stable anchor across streaming and replay", async () => {
const sessionId = randomUUID();
const q = query({
prompt: "Reply with exactly these words and nothing else: hello there my friend",
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
sessionId,
includePartialMessages: true,
maxTurns: 1,
allowedTools: [],
},
});
let messageStartApiId: string | undefined;
let consolidatedApiId: string | undefined;
let sawDelta = false;
let allPartialsTopLevel = true;
for await (const message of q) {
if (message.type === "assistant") {
consolidatedApiId = message.message.id;
}
if (message.type !== "stream_event") continue;
// Every streaming partial must belong to the top-level agent
// (parent_tool_use_id === null). Subagent work is folded into tool-result
// messages rather than surfaced as partial streams, which is what lets us
// track a single anchor without keying by parent_tool_use_id.
if (message.parent_tool_use_id !== null) allPartialsTopLevel = false;
if (message.event.type === "message_start") {
messageStartApiId = message.event.message.id;
} else if (message.event.type === "content_block_delta") {
sawDelta = true;
}
}
// The API message id is present at message_start (before deltas), so we can
// tag every streamed chunk with it, and it is identical on the consolidated
// assistant message.
expect(messageStartApiId).toBeTruthy();
expect(sawDelta).toBe(true);
expect(allPartialsTopLevel).toBe(true);
expect(consolidatedApiId).toBe(messageStartApiId);
// ...and the SAME id is recoverable from the persisted transcript, so chunks
// grouped live keep their id when the session is replayed.
const persisted = await getSessionMessages(sessionId);
const replayedAssistant = persisted.find((m) => m.type === "assistant");
expect(replayedAssistant).toBeDefined();
expect((replayedAssistant!.message as { id?: string }).id).toBe(messageStartApiId);
// The helper used in production must derive that same id from the replayed
// message.
expect(messageIdForGrouping(replayedAssistant!)).toBe(messageStartApiId);
}, 30000);
});
describe("permission requests", () => {
@@ -1548,6 +1615,8 @@ describe("stop reason propagation", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
}
@@ -1692,6 +1761,8 @@ describe("stop reason propagation", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
const response = await agent.prompt({
@@ -1851,6 +1922,8 @@ describe("session/close", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
return agent.sessions[sessionId]!;
}
@@ -1935,6 +2008,8 @@ describe("session/delete", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
return agent.sessions[sessionId]!;
}
@@ -2036,6 +2111,8 @@ describe("getOrCreateSession param change detection", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
return agent.sessions[sessionId]!;
}
@@ -2271,6 +2348,8 @@ describe("usage_update computation", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
}
@@ -3108,6 +3187,49 @@ describe("usage_update computation", () => {
// size should be 1000000 (Opus), not 200000 (the fallback if <synthetic> overrode the model)
expect(usageUpdate.update.size).toBe(1000000);
});
it("compact_boundary uses authoritative getContextUsage for used, keeps session window for size", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const session = agent.sessions["test-session"];
// A 1M window learned earlier (e.g. from modelUsage) must survive compaction
// — getContextUsage's window field under-reports it, so we don't use it.
session.contextWindowSize = 1000000;
(session.query as any).getContextUsage = vi
.fn()
.mockResolvedValue({ totalTokens: 12345, rawMaxTokens: 200000 });
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
expect(usageUpdate).toBeDefined();
expect(usageUpdate.update.used).toBe(12345);
// size stays at the session's learned window, NOT getContextUsage's value.
expect(usageUpdate.update.size).toBe(1000000);
expect(session.contextWindowSize).toBe(1000000);
});
it("compact_boundary falls back to used:0 when getContextUsage fails", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
{ type: "system", subtype: "session_state_changed", state: "idle" },
]);
const session = agent.sessions["test-session"];
session.contextWindowSize = 200000;
(session.query as any).getContextUsage = vi.fn().mockRejectedValue(new Error("boom"));
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
expect(usageUpdate).toBeDefined();
expect(usageUpdate.update.used).toBe(0);
expect(usageUpdate.update.size).toBe(200000);
expect(session.contextWindowSize).toBe(200000);
});
});
describe("emitRawSDKMessages", () => {
@@ -3171,6 +3293,8 @@ describe("emitRawSDKMessages", () => {
emitRawSDKMessages,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
}
@@ -3399,6 +3523,8 @@ describe("result origin handling", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
}
@@ -3574,6 +3700,8 @@ describe("memory_recall handling", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
}
@@ -3804,6 +3932,8 @@ describe("post-error recovery", () => {
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
return { interrupt };
}
@@ -3877,6 +4007,186 @@ describe("post-error recovery", () => {
});
});
describe("session/cancel wedge recovery (issue #680)", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
// Generator that replays the prompt's user message and then blocks forever,
// simulating the SDK wedged in a `TaskOutput { block: true }` poll against a
// hung background task. `interrupt()` is a no-op — it does NOT unblock the
// generator, matching the SDK behavior described in the issue.
function injectWedgedSession(agent: ClaudeAcpAgent, opts: { interruptUnblocks?: boolean } = {}) {
const input = new Pushable<any>();
const interrupt = vi.fn(async () => {});
const close = vi.fn();
// A promise the wedged poll awaits. When `interruptUnblocks` is set, the
// mocked interrupt() resolves it so the generator yields a trailing idle —
// the normal, healthy interrupt path.
let releaseBlock!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseBlock = resolve;
});
if (opts.interruptUnblocks) {
interrupt.mockImplementation(async () => {
releaseBlock();
});
}
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
if (!first.done && first.value) {
yield {
type: "user",
message: first.value.message,
parent_tool_use_id: null,
uuid: first.value.uuid,
session_id: "test-session",
isReplay: true,
};
}
// Wedge: never yield again unless interrupt() releases us.
await blocked;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
const gen = Object.assign(messageGenerator(), { interrupt, close });
agent.sessions["test-session"] = {
query: gen as any,
input,
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: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
toolUseCache: {},
messageIdToUuid: new Map(),
};
return { interrupt };
}
it("resolves the pending prompt with cancelled when the SDK never yields after interrupt", async () => {
const agent = createMockAgent();
// Shrink the grace period so the test doesn't wait the production default.
agent.forceCancelGraceMs = 20;
const { interrupt } = injectWedgedSession(agent);
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
// Let the loop consume the replay and block on the wedged query.next().
await new Promise((r) => setTimeout(r, 5));
await agent.cancel({ sessionId: "test-session" });
const response = await promptPromise;
expect(response.stopReason).toBe("cancelled");
expect(interrupt).toHaveBeenCalled();
});
it("returns cancelled through the normal idle path without waiting the grace period when interrupt works", async () => {
const agent = createMockAgent();
// Large grace so that if the test ever falls through to the backstop it
// would hang past the test timeout instead of passing by accident.
agent.forceCancelGraceMs = 60_000;
const { interrupt } = injectWedgedSession(agent, { interruptUnblocks: true });
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
await new Promise((r) => setTimeout(r, 5));
await agent.cancel({ sessionId: "test-session" });
const response = await promptPromise;
expect(response.stopReason).toBe("cancelled");
expect(interrupt).toHaveBeenCalled();
// Backstop timer must have been cleared so it can't fire later.
expect(agent.sessions["test-session"].forceCancelTimer).toBeUndefined();
});
it("does not arm the backstop when no prompt is running", async () => {
const agent = createMockAgent();
injectWedgedSession(agent);
await agent.cancel({ sessionId: "test-session" });
const session = agent.sessions["test-session"];
expect(session.cancelled).toBe(true);
expect(session.forceCancelTimer).toBeUndefined();
});
it("does not reset the force-cancel floor on repeated cancels", async () => {
const agent = createMockAgent();
// Long floor so the timer handle stays observable across both cancels.
agent.forceCancelGraceMs = 60_000;
injectWedgedSession(agent);
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
await new Promise((r) => setTimeout(r, 5));
await agent.cancel({ sessionId: "test-session" });
const firstTimer = agent.sessions["test-session"].forceCancelTimer;
expect(firstTimer).toBeDefined();
await agent.cancel({ sessionId: "test-session" });
// Same handle: the second cancel did not clear-and-rearm (which would push
// the floor out). The deadline stays anchored to the first cancel.
expect(agent.sessions["test-session"].forceCancelTimer).toBe(firstTimer);
// Clean up the wedged prompt + long timer.
await agent.closeSession({ sessionId: "test-session" });
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
});
it("resolves an in-flight wedged prompt immediately when the session is closed", async () => {
const agent = createMockAgent();
// Large floor: if closeSession relied on the force-cancel timer this would
// hang past the test timeout. Teardown must wake the loop via
// cancelController instead.
agent.forceCancelGraceMs = 60_000;
injectWedgedSession(agent);
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
await new Promise((r) => setTimeout(r, 5));
await agent.closeSession({ sessionId: "test-session" });
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
expect(agent.sessions["test-session"]).toBeUndefined();
});
});
describe("streamEventToAcpNotifications", () => {
it("treats `ping` keep-alive events as no-ops without logging to stderr", () => {
const errors: unknown[][] = [];
@@ -3910,4 +4220,166 @@ describe("streamEventToAcpNotifications", () => {
expect(result).toEqual([]);
expect(errors).toEqual([]);
});
it("attaches the supplied messageId to streamed text chunks", () => {
const messageId = randomUUID();
const message = {
type: "stream_event",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "hello" },
},
} as Parameters<typeof streamEventToAcpNotifications>[0];
const result = streamEventToAcpNotifications(
message,
"test",
{},
{} as AgentSideConnection,
console,
{ messageId },
);
expect(result).toEqual([
{
sessionId: "test",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello" },
messageId,
},
},
]);
});
});
describe("toAcpNotifications messageId", () => {
const messageId = "11111111-2222-3333-4444-555555555555";
it("sets messageId on agent message chunks from string content", () => {
const result = toAcpNotifications(
"hello world",
"assistant",
"test",
{},
{} as AgentSideConnection,
console,
{ messageId },
);
expect(result).toEqual([
{
sessionId: "test",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello world" },
messageId,
},
},
]);
});
it("sets messageId on user message chunks and thought chunks", () => {
const userResult = toAcpNotifications(
[{ type: "text", text: "hi" }],
"user",
"test",
{},
{} as AgentSideConnection,
console,
{ messageId },
);
expect(userResult[0].update).toMatchObject({
sessionUpdate: "user_message_chunk",
messageId,
});
const thoughtResult = toAcpNotifications(
[{ type: "thinking", thinking: "hmm", signature: "" }],
"assistant",
"test",
{},
{} as AgentSideConnection,
console,
{ messageId },
);
expect(thoughtResult[0].update).toMatchObject({
sessionUpdate: "agent_thought_chunk",
messageId,
});
});
it("omits messageId when none is supplied", () => {
const result = toAcpNotifications(
"hello",
"assistant",
"test",
{},
{} as AgentSideConnection,
console,
);
expect(result[0].update).not.toHaveProperty("messageId");
});
it("never sets messageId on non-chunk updates (tool_call)", () => {
const result = toAcpNotifications(
[
{
type: "tool_use",
id: "toolu_abc",
name: "Read",
input: { file_path: "/tmp/x" },
},
],
"assistant",
"test",
{},
{} as AgentSideConnection,
console,
{ messageId, registerHooks: false },
);
expect(result[0].update.sessionUpdate).toBe("tool_call");
expect(result[0].update).not.toHaveProperty("messageId");
});
});
describe("messageIdForGrouping", () => {
it("uses the Anthropic API message id for assistant messages", () => {
const message = {
type: "assistant",
uuid: "de242400-cdb3-4af7-9856-d3b114b20af9",
message: { id: "msg_018DQGVuZbGYwVnvDakAP9Do", role: "assistant" },
};
// The API id is identical at message_start, on the consolidated message,
// and in the persisted transcript — so it stays stable across replay,
// unlike the per-message uuid.
expect(messageIdForGrouping(message)).toBe("msg_018DQGVuZbGYwVnvDakAP9Do");
});
it("falls back to the uuid for assistant messages without an API id", () => {
const message = {
type: "assistant",
uuid: "de242400-cdb3-4af7-9856-d3b114b20af9",
message: { role: "assistant" },
};
expect(messageIdForGrouping(message)).toBe("de242400-cdb3-4af7-9856-d3b114b20af9");
});
it("uses the uuid for user messages (they carry no API id and aren't streamed)", () => {
const message = {
type: "user",
uuid: "11111111-2222-3333-4444-555555555555",
message: { id: "msg_should_be_ignored", role: "user" },
};
expect(messageIdForGrouping(message)).toBe("11111111-2222-3333-4444-555555555555");
});
it("returns undefined when there is no usable id", () => {
expect(messageIdForGrouping({ type: "system", message: {} })).toBeUndefined();
expect(messageIdForGrouping({ type: "assistant", uuid: "", message: {} })).toBeUndefined();
});
});

View File

@@ -473,4 +473,62 @@ describe("createSession options merging", () => {
// ACP-provided MCP server should also be present
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
});
describe("thinking config from MAX_THINKING_TOKENS", () => {
let originalMaxThinking: string | undefined;
beforeEach(() => {
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
delete process.env.MAX_THINKING_TOKENS;
});
afterEach(() => {
if (originalMaxThinking !== undefined) {
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
} else {
delete process.env.MAX_THINKING_TOKENS;
}
});
it("leaves thinking unset (SDK default) when env var is absent", async () => {
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.thinking).toBeUndefined();
// The deprecated option must not be set either.
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
});
it("maps 0 to disabled thinking", async () => {
process.env.MAX_THINKING_TOKENS = "0";
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
});
it("maps a positive value to a fixed thinking budget", async () => {
process.env.MAX_THINKING_TOKENS = "12000";
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 });
});
it("ignores a non-numeric value", async () => {
process.env.MAX_THINKING_TOKENS = "lots";
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.thinking).toBeUndefined();
});
it("lets a user-provided thinking option override the env default", async () => {
process.env.MAX_THINKING_TOKENS = "12000";
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
thinking: { type: "adaptive" },
},
},
},
});
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
});
});
});

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
import {
@@ -673,14 +673,19 @@ describe("Bash terminal output", () => {
});
describe("toAcpNotifications with clientCapabilities", () => {
const toolUseCache: ToolUseCache = {
toolu_bash: {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
},
};
// Reset before each test: toAcpNotifications prunes the cache entry once it
// maps the tool_result, so a shared object would be empty by the 2nd test.
let toolUseCache: ToolUseCache;
beforeEach(() => {
toolUseCache = {
toolu_bash: {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
},
};
});
const bashResult: BetaBashCodeExecutionResultBlock = {
type: "bash_code_execution_result",
@@ -792,11 +797,21 @@ describe("Bash terminal output", () => {
{ clientCapabilities: { _meta: { terminal_output: true } } },
);
// Fresh cache: the withSupport call above already consumed the entry,
// since toAcpNotifications prunes the tool_use once it maps the result.
const toolUseCacheWithoutSupport: ToolUseCache = {
toolu_bash: {
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "ls -la" },
},
};
const withoutSupport = toAcpNotifications(
[toolResult],
"assistant",
"test-session",
toolUseCache,
toolUseCacheWithoutSupport,
mockClient,
mockLogger,
);
@@ -848,6 +863,31 @@ describe("Bash terminal output", () => {
});
});
describe("toolUseCache pruning", () => {
it("retains the tool_use entry until its result, then prunes it", () => {
const toolUseCache: ToolUseCache = {};
const toolUse = {
type: "tool_use" as const,
id: "toolu_read",
name: "Read",
input: { file_path: "/tmp/x.txt" },
};
// tool_use is cached and kept (the matching result hasn't arrived yet).
toAcpNotifications([toolUse], "assistant", "s", toolUseCache, mockClient, mockLogger);
expect(toolUseCache.toolu_read).toBeDefined();
// tool_result resolves it, so the entry is pruned to bound memory.
const toolResult: ToolResultBlockParam = {
type: "tool_result",
tool_use_id: "toolu_read",
content: [{ type: "text", text: "hello" }],
};
toAcpNotifications([toolResult], "assistant", "s", toolUseCache, mockClient, mockLogger);
expect(toolUseCache.toolu_read).toBeUndefined();
});
});
describe("post-tool-use hook sends diff content for Edit tool", () => {
it("should include content and locations from structuredPatch in hook update", async () => {
const toolUseCache: ToolUseCache = {};