Compare commits

...

20 Commits

Author SHA1 Message Date
acp-release-bot[bot]
9957b54882 chore(main): release 0.31.4 (#611)
🤖 I have created a release *beep* *boop*
---


##
[0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4)
(2026-04-28)


### Bug Fixes

* gate auto mode on model support
([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604))
([ec47d34](ec47d3446f))

---
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-04-28 18:10:28 +02:00
davethedev3
ec47d3446f fix: gate auto mode on model support (#604)
## Summary

Closes #594

Only advertise and accept `auto` permission mode when the active SDK
model reports `supportsAutoMode: true`.

This fixes the Haiku path where clients could select `auto`, only for
the SDK to later reject it with `auto mode unavailable for this model`.

Changes included:

- build available permission modes from the active model's `ModelInfo`
- omit `auto` from `availableModes` when `supportsAutoMode` is absent or
false
- clamp `permissions.defaultMode: "auto"` to `default` when the resolved
model cannot use auto mode
- recompute and clamp the current mode on model switch
- reject direct `setSessionMode("auto")` when `auto` is not available
- filter `ExitPlanMode` choices so unavailable modes are not offered or
accepted
- add regression coverage for session creation, model switching, direct
mode setting, and `ExitPlanMode`

## Verification

- `npm run lint`
- `npx prettier --check src/acp-agent.ts
src/tests/acp-agent-settings.test.ts src/tests/acp-agent.test.ts
src/tests/session-config-options.test.ts`
- `npm run build`
- `npm run test:run`
- Manual ACP subprocess smoke test with `ANTHROPIC_MODEL=haiku`
  - `auto` was not advertised
  - `setSessionMode("auto")` was rejected before reaching the SDK

---------

Co-authored-by: dave <dave@mint.lan>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-04-28 16:08:46 +00:00
acp-release-bot[bot]
546040ffe7 chore(main): release 0.31.3 (#610)
🤖 I have created a release *beep* *boop*
---


##
[0.31.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.2...v0.31.3)
(2026-04-28)


### Bug Fixes

* Rename effort config category to thought_level
([#609](https://github.com/agentclientprotocol/claude-agent-acp/issues/609))
([76b4e96](76b4e96508))

---
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-04-28 16:59:32 +02:00
dependabot[bot]
a52ab4ab64 chore(deps): bump the minor group across 1 directory with 25 updates (#608)
Bumps the minor group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
|
[@agentclientprotocol/sdk](https://github.com/agentclientprotocol/typescript-sdk)
| `0.20.0` | `0.21.0` |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.2.119` | `0.2.121` |
|
[@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript)
| `0.89.0` | `0.91.1` |
|
[@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin)
| `8.59.0` | `8.59.1` |
|
[@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser)
| `8.59.0` | `8.59.1` |
| [es-module-lexer](https://github.com/guybedford/es-module-lexer) |
`2.0.0` | `2.1.0` |
|
[express-rate-limit](https://github.com/express-rate-limit/express-rate-limit)
| `8.4.0` | `8.4.1` |
| [jose](https://github.com/panva/jose) | `6.2.2` | `6.2.3` |
| [postcss](https://github.com/postcss/postcss) | `8.5.10` | `8.5.12` |


Updates `@agentclientprotocol/sdk` from 0.20.0 to 0.21.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/agentclientprotocol/typescript-sdk/releases"><code>@​agentclientprotocol/sdk</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v0.21.0</h2>
<h2><a
href="https://github.com/agentclientprotocol/typescript-sdk/compare/v0.20.0...v0.21.0">0.21.0</a>
(2026-04-28)</h2>
<h3>Features</h3>
<ul>
<li><strong>unstable:</strong> Add <code>providers/*</code> support (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/138">#138</a>)
(<a
href="e234c213d3">e234c21</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"><code>@​agentclientprotocol/sdk</code>'s
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/agentclientprotocol/typescript-sdk/compare/v0.20.0...v0.21.0">0.21.0</a>
(2026-04-28)</h2>
<h3>Features</h3>
<ul>
<li><strong>unstable:</strong> Add <code>providers/*</code> support (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/138">#138</a>)
(<a
href="e234c213d3">e234c21</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="74372b59f8"><code>74372b5</code></a>
chore(main): release 0.21.0 (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/139">#139</a>)</li>
<li><a
href="e234c213d3"><code>e234c21</code></a>
feat(unstable): Add <code>providers/*</code> support (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/138">#138</a>)</li>
<li><a
href="b0e1d046a5"><code>b0e1d04</code></a>
chore(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/134">#134</a>)</li>
<li><a
href="c6ee38997c"><code>c6ee389</code></a>
chore(deps): bump googleapis/release-please-action from 4.4.1 to 5.0.0
(<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/135">#135</a>)</li>
<li><a
href="bd0b486fce"><code>bd0b486</code></a>
chore(deps): bump the minor group with 43 updates (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/136">#136</a>)</li>
<li><a
href="3becf2ee80"><code>3becf2e</code></a>
chore(deps-dev): bump ajv from 6.14.0 to 6.15.0 (<a
href="https://redirect.github.com/agentclientprotocol/typescript-sdk/issues/137">#137</a>)</li>
<li>See full diff in <a
href="https://github.com/agentclientprotocol/typescript-sdk/compare/v0.20.0...v0.21.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk` from 0.2.119 to 0.2.121
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/releases"><code>@​anthropic-ai/claude-agent-sdk</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v0.2.121</h2>
<h2>What's changed</h2>
<ul>
<li>Added <code>updatedToolOutput</code> to
<code>PostToolUseHookSpecificOutput</code> for replacing tool output on
all tools. <code>updatedMCPToolOutput</code> is deprecated.</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.2.121
# or
yarn add @anthropic-ai/claude-agent-sdk@0.2.121
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.2.121
# or
bun add @anthropic-ai/claude-agent-sdk@0.2.121
</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"><code>@​anthropic-ai/claude-agent-sdk</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>0.2.121</h2>
<ul>
<li>Added <code>updatedToolOutput</code> to
<code>PostToolUseHookSpecificOutput</code> for replacing tool output on
all tools. <code>updatedMCPToolOutput</code> is deprecated.</li>
</ul>
<h2>0.2.120</h2>
<ul>
<li>Added <code>skills</code> option (<code>string[] | 'all'</code>) to
control which Skills are loaded into the main session, matching the
Python SDK</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="49b6b0e827"><code>49b6b0e</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="2b1ffcc321"><code>2b1ffcc</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="48de067ca4"><code>48de067</code></a>
chore: Update CHANGELOG.md</li>
<li>See full diff in <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.2.119...v0.2.121">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/sdk` from 0.89.0 to 0.91.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/releases"><code>@​anthropic-ai/sdk</code>'s
releases</a>.</em></p>
<blockquote>
<h2>sdk: v0.91.1</h2>
<h2>0.91.1 (2026-04-24)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.91.0...sdk-v0.91.1">sdk-v0.91.0...sdk-v0.91.1</a></p>
<h3>Bug Fixes</h3>
<ul>
<li><strong>memory:</strong> use restrictive file mode for memory files
(<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/901">#901</a>)
(<a
href="6db3b7e221">6db3b7e</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>formatter:</strong> run prettier and eslint separately (<a
href="974d22fcf6">974d22f</a>)</li>
</ul>
<h2>sdk: v0.91.0</h2>
<h2>0.91.0 (2026-04-23)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.90.0...sdk-v0.91.0">sdk-v0.90.0...sdk-v0.91.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> CMA Memory public beta (<a
href="ddf732f5f3">ddf732f</a>)</li>
<li><strong>bedrock:</strong> use auth header for mantle client (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/866">#866</a>)
(<a
href="aec801afc7">aec801a</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>api:</strong> fix errors in api spec (<a
href="ae10768485">ae10768</a>)</li>
<li><strong>api:</strong> restore missing features (<a
href="1a5b47b689">1a5b47b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>internal:</strong> more robust bootstrap script (<a
href="7716e19f7f">7716e19</a>)</li>
<li><strong>tests:</strong> bump steady to v0.22.1 (<a
href="219a971743">219a971</a>)</li>
</ul>
<h2>sdk: v0.90.0</h2>
<h2>0.90.0 (2026-04-16)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.89.0...sdk-v0.90.0">sdk-v0.89.0...sdk-v0.90.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add claude-opus-4-7, token budgets and
user_profiles (<a
href="b26134bb17">b26134b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li>actually delete release-doctor.yml (<a
href="0fe984d1f6">0fe984d</a>)</li>
<li><strong>ci:</strong> remove release-doctor workflow (<a
href="08e58bd0bb">08e58bd</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md"><code>@​anthropic-ai/sdk</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>0.91.1 (2026-04-24)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.91.0...sdk-v0.91.1">sdk-v0.91.0...sdk-v0.91.1</a></p>
<h3>Bug Fixes</h3>
<ul>
<li><strong>memory:</strong> use restrictive file mode for memory files
(<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/901">#901</a>)
(<a
href="6db3b7e221">6db3b7e</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>formatter:</strong> run prettier and eslint separately (<a
href="974d22fcf6">974d22f</a>)</li>
</ul>
<h2>0.91.0 (2026-04-23)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.90.0...sdk-v0.91.0">sdk-v0.90.0...sdk-v0.91.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> CMA Memory public beta (<a
href="ddf732f5f3">ddf732f</a>)</li>
<li><strong>bedrock:</strong> use auth header for mantle client (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/866">#866</a>)
(<a
href="aec801afc7">aec801a</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>api:</strong> fix errors in api spec (<a
href="ae10768485">ae10768</a>)</li>
<li><strong>api:</strong> restore missing features (<a
href="1a5b47b689">1a5b47b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>internal:</strong> more robust bootstrap script (<a
href="7716e19f7f">7716e19</a>)</li>
<li><strong>tests:</strong> bump steady to v0.22.1 (<a
href="219a971743">219a971</a>)</li>
</ul>
<h2>0.90.0 (2026-04-16)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.89.0...sdk-v0.90.0">sdk-v0.89.0...sdk-v0.90.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add claude-opus-4-7, token budgets and
user_profiles (<a
href="b26134bb17">b26134b</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li>actually delete release-doctor.yml (<a
href="0fe984d1f6">0fe984d</a>)</li>
<li><strong>ci:</strong> remove release-doctor workflow (<a
href="08e58bd0bb">08e58bd</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="74ac150346"><code>74ac150</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1014">#1014</a>)</li>
<li><a
href="22cb810364"><code>22cb810</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1008">#1008</a>)</li>
<li><a
href="93ac7c7e05"><code>93ac7c7</code></a>
chore: release main (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1003">#1003</a>)</li>
<li>See full diff in <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.89.0...sdk-v0.91.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/eslint-plugin` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/eslint-plugin</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md"><code>@​typescript-eslint/eslint-plugin</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li><a
href="3cef124716"><code>3cef124</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in dot...</li>
<li><a
href="27c507b6e5"><code>27c507b</code></a>
test: make sort-type-constituents tests fully static (<a
href="https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin/issues/12262">#12262</a>)</li>
<li><a
href="a03b31d422"><code>a03b31d</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in no-...</li>
<li><a
href="a7099a7895"><code>a7099a7</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in no-...</li>
<li><a
href="bfbd4a5433"><code>bfbd4a5</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in no-...</li>
<li><a
href="b49d4b15cd"><code>b49d4b1</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in no-...</li>
<li><a
href="3097e72747"><code>3097e72</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in nam...</li>
<li><a
href="676191bbd7"><code>676191b</code></a>
chore(eslint-plugin): switch auto-generated test cases to hand-written
in mem...</li>
<li><a
href="e9dce8bdc6"><code>e9dce8b</code></a>
fix(eslint-plugin): [no-unnecessary-condition] treat void as nullish in
no-un...</li>
<li>Additional commits viewable in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/eslint-plugin">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/parser` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/parser</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md"><code>@​typescript-eslint/parser</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for parser to align it with other
projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/parser">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.2.119 to
0.2.121

Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.2.119 to
0.2.121

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.2.119 to
0.2.121

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.2.119
to 0.2.121

Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.2.119 to
0.2.121

Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.2.119 to
0.2.121

Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.2.119 to
0.2.121

Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.2.119 to
0.2.121

Updates `@typescript-eslint/project-service` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/project-service</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/project-service/CHANGELOG.md"><code>@​typescript-eslint/project-service</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for project-service to align it with
other projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/project-service">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/scope-manager` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/scope-manager</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/scope-manager/CHANGELOG.md"><code>@​typescript-eslint/scope-manager</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for scope-manager to align it with other
projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/scope-manager">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/tsconfig-utils` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/tsconfig-utils</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/tsconfig-utils/CHANGELOG.md"><code>@​typescript-eslint/tsconfig-utils</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for tsconfig-utils to align it with
other projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/tsconfig-utils">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/type-utils` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/type-utils</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/type-utils/CHANGELOG.md"><code>@​typescript-eslint/type-utils</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for type-utils to align it with other
projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/type-utils">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/types` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/types</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/types/CHANGELOG.md"><code>@​typescript-eslint/types</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for types to align it with other
projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/types">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/typescript-estree` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/typescript-estree</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-estree/CHANGELOG.md"><code>@​typescript-eslint/typescript-estree</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for typescript-estree to align it with
other projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-estree">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/utils` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/utils</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md"><code>@​typescript-eslint/utils</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for utils to align it with other
projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/utils">compare
view</a></li>
</ul>
</details>
<br />

Updates `@typescript-eslint/visitor-keys` from 8.59.0 to 8.59.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/releases"><code>@​typescript-eslint/visitor-keys</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v8.59.1</h2>
<h2>8.59.1 (2026-04-27)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion] fix
crash &quot;TypeError: checker.getTypeArguments is not a function&quot;
(<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12246">#12246</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve index signatures in undefined unions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12257">#12257</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
preserve phantom type arguments in generic inference (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12269">#12269</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-assertion]
avoid false positive in logical assignment assertions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12278">#12278</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-type-arguments]
handle instantiation expressions (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12220">#12220</a>)</li>
<li><strong>eslint-plugin:</strong> [no-unnecessary-condition] treat
void as nullish in no-unnecessary-condition (<a
href="https://redirect.github.com/typescript-eslint/typescript-eslint/pull/12241">#12241</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>anasm266 <a
href="https://github.com/anasm266"><code>@​anasm266</code></a></li>
<li>Anshika Jain <a
href="https://github.com/Anshikakalpana"><code>@​Anshikakalpana</code></a></li>
<li>Ulrich Stark</li>
<li>yugo innami <a
href="https://github.com/nami8824"><code>@​nami8824</code></a></li>
</ul>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/visitor-keys/CHANGELOG.md"><code>@​typescript-eslint/visitor-keys</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>8.59.1 (2026-04-27)</h2>
<p>This was a version bump only for visitor-keys to align it with other
projects, there were no code changes.</p>
<p>See <a
href="https://github.com/typescript-eslint/typescript-eslint/releases/tag/v8.59.1">GitHub
Releases</a> for more information.</p>
<p>You can read about our <a
href="https://typescript-eslint.io/users/versioning">versioning
strategy</a> and <a
href="https://typescript-eslint.io/users/releases">releases</a> on our
website.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="52457932e5"><code>5245793</code></a>
chore(release): publish 8.59.1</li>
<li>See full diff in <a
href="https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/visitor-keys">compare
view</a></li>
</ul>
</details>
<br />

Updates `es-module-lexer` from 2.0.0 to 2.1.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/guybedford/es-module-lexer/releases">es-module-lexer's
releases</a>.</em></p>
<blockquote>
<h2>2.1.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Always exclude dyn import attributes from specifier range by <a
href="https://github.com/nicolo-ribaudo"><code>@​nicolo-ribaudo</code></a>
in <a
href="https://redirect.github.com/guybedford/es-module-lexer/pull/197">guybedford/es-module-lexer#197</a></li>
<li>fix: handle new of conflict cases by <a
href="https://github.com/guybedford"><code>@​guybedford</code></a> in <a
href="https://redirect.github.com/guybedford/es-module-lexer/pull/195">guybedford/es-module-lexer#195</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/nicolo-ribaudo"><code>@​nicolo-ribaudo</code></a>
made their first contribution in <a
href="https://redirect.github.com/guybedford/es-module-lexer/pull/197">guybedford/es-module-lexer#197</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/guybedford/es-module-lexer/compare/2.0.0...2.1.0">https://github.com/guybedford/es-module-lexer/compare/2.0.0...2.1.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="5fb11e1e01"><code>5fb11e1</code></a>
2.1.0</li>
<li><a
href="cc29c5bca1"><code>cc29c5b</code></a>
fix: handle new of conflict cases (<a
href="https://redirect.github.com/guybedford/es-module-lexer/issues/195">#195</a>)</li>
<li><a
href="4a351384ff"><code>4a35138</code></a>
Always exclude dyn import attributes from specifier range (<a
href="https://redirect.github.com/guybedford/es-module-lexer/issues/197">#197</a>)</li>
<li>See full diff in <a
href="https://github.com/guybedford/es-module-lexer/compare/2.0.0...2.1.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `express-rate-limit` from 8.4.0 to 8.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/express-rate-limit/express-rate-limit/releases">express-rate-limit's
releases</a>.</em></p>
<blockquote>
<h2>v8.4.1</h2>
<p>You can view the changelog <a
href="https://express-rate-limit.mintlify.app/reference/changelog">here</a>.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="69568d4ea7"><code>69568d4</code></a>
8.4.1</li>
<li><a
href="c686acd0ba"><code>c686acd</code></a>
v8.4.1 changelog</li>
<li><a
href="ba71353abb"><code>ba71353</code></a>
test: bump timeout in flakey skipFailedRequests test (<a
href="https://redirect.github.com/express-rate-limit/express-rate-limit/issues/618">#618</a>)</li>
<li><a
href="dd4c8944d4"><code>dd4c894</code></a>
feat: allow usage of custom logger (<a
href="https://redirect.github.com/express-rate-limit/express-rate-limit/issues/616">#616</a>)</li>
<li>See full diff in <a
href="https://github.com/express-rate-limit/express-rate-limit/compare/v8.4.0...v8.4.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `jose` from 6.2.2 to 6.2.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/panva/jose/releases">jose's
releases</a>.</em></p>
<blockquote>
<h2>v6.2.3</h2>
<h3>Refactor</h3>
<ul>
<li>cleanly reject invalid PBES2 p2c (<a
href="0cdb851ca5">0cdb851</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/panva/jose/blob/main/CHANGELOG.md">jose's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/panva/jose/compare/v6.2.2...v6.2.3">6.2.3</a>
(2026-04-27)</h2>
<h3>Refactor</h3>
<ul>
<li>cleanly reject invalid PBES2 p2c (<a
href="0cdb851ca5">0cdb851</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="41ad7e9a76"><code>41ad7e9</code></a>
chore(release): 6.2.3</li>
<li><a
href="988e90f666"><code>988e90f</code></a>
chore: account for commit-and-tag-version instead of
standard-version</li>
<li><a
href="4b24656b61"><code>4b24656</code></a>
chore: update CHANGELOG.md header</li>
<li><a
href="0cdb851ca5"><code>0cdb851</code></a>
refactor: cleanly reject invalid PBES2 p2c</li>
<li><a
href="a0b261eddf"><code>a0b261e</code></a>
test: update Bun expectations</li>
<li><a
href="b39dc1af78"><code>b39dc1a</code></a>
chore: use fs.globSync</li>
<li><a
href="0675be19a6"><code>0675be1</code></a>
build: replace rollup umd build with a custom esbuild iife wrap</li>
<li><a
href="9b0332370a"><code>9b03323</code></a>
chore: bump packages</li>
<li><a
href="914b73dad2"><code>914b73d</code></a>
chore(deps-dev): bump lodash</li>
<li><a
href="9dce817d74"><code>9dce817</code></a>
chore: bump packages</li>
<li>Additional commits viewable in <a
href="https://github.com/panva/jose/compare/v6.2.2...v6.2.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `postcss` from 8.5.10 to 8.5.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/releases">postcss's
releases</a>.</em></p>
<blockquote>
<h2>8.5.12</h2>
<ul>
<li>Fixed reading any file via user-generated CSS.</li>
<li>Added <code>opts.unsafeMap</code> to disable checks.</li>
</ul>
<h2>8.5.11</h2>
<ul>
<li>Fixed nested brackets parsing performance (by <a
href="https://github.com/offset"><code>@​offset</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/postcss/postcss/blob/main/CHANGELOG.md">postcss's
changelog</a>.</em></p>
<blockquote>
<h2>8.5.12</h2>
<ul>
<li>Fixed reading any file via user-generated CSS.</li>
<li>Added <code>opts.unsafeMap</code> to disable checks.</li>
</ul>
<h2>8.5.11</h2>
<ul>
<li>Fixed nested brackets parsing performance (by <a
href="https://github.com/offset"><code>@​offset</code></a>).</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9bc81c48f0"><code>9bc81c4</code></a>
Release 8.5.12 version</li>
<li><a
href="85c4d7dab8"><code>85c4d7d</code></a>
Another try to fix coverage</li>
<li><a
href="94484cae6d"><code>94484ca</code></a>
Try to fix coverage</li>
<li><a
href="c64b7488d2"><code>c64b748</code></a>
Load only .map source maps</li>
<li><a
href="aaec7b78b3"><code>aaec7b7</code></a>
Avoid throwing JSON parsing errors for non-JSON source maps</li>
<li><a
href="233fb264ea"><code>233fb26</code></a>
Mention original author of the solution</li>
<li><a
href="2502f75030"><code>2502f75</code></a>
Release 8.5.11 version</li>
<li><a
href="5ca1901949"><code>5ca1901</code></a>
Speed up parsing many nested brackets</li>
<li><a
href="42b5337dd7"><code>42b5337</code></a>
Update dependencies</li>
<li><a
href="7e36e153d0"><code>7e36e15</code></a>
Cache node.raws locally in Stringifier hot methods</li>
<li>Additional commits viewable in <a
href="https://github.com/postcss/postcss/compare/8.5.10...8.5.12">compare
view</a></li>
</ul>
</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-04-28 16:57:58 +02:00
Ben Brandt
76b4e96508 fix: Rename effort config category to thought_level (#609)
Uses the canonical version it should have been using.
2026-04-28 14:54:53 +00:00
acp-release-bot[bot]
32abd34bc0 chore(main): release 0.31.2 (#605)
🤖 I have created a release *beep* *boop*
---


##
[0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2)
(2026-04-28)


### Bug Fixes

* tolerate invalid values in settings.json
([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601))
([67af018](67af018d02))

---
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-04-28 10:21:32 +02:00
Alex Plãte
67af018d02 fix: tolerate invalid values in settings.json (#601)
A broken value in settings.json (e.g. an unknown permissions.defaultMode
or a non-string model) used to throw and prevent session creation. Match
the Claude Code SDK behavior by logging the bad value and falling back
to defaults instead, so a single typo no longer renders the agent
unusable.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-04-28 10:20:09 +02:00
acp-release-bot[bot]
a55f31a6b0 chore(main): release 0.31.1 (#600)
🤖 I have created a release *beep* *boop*
---


##
[0.31.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.0...v0.31.1)
(2026-04-27)


### Bug Fixes

* detect glibc and use correct binary
([#599](https://github.com/agentclientprotocol/claude-agent-acp/issues/599))
([f0801d5](f0801d50b2))

---
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-04-27 12:02:31 +02:00
Ben Brandt
f0801d50b2 fix: detect glibc and use correct binary (#599)
## Summary

Re-records #598 with a Conventional Commit prefix so
[release-please](https://github.com/googleapis/release-please) generates
a release PR. The actual fix was merged in 1a9d3ce but lacked the `fix:`
prefix, so release-please skipped it.

This PR is intentionally empty — once squash-merged, the resulting
commit message on `main` will start with `fix:` and trigger a patch
release.

See https://github.com/tidewave-ai/tidewave_app/issues/141.
2026-04-27 10:01:08 +00:00
Steffen Deusch
1a9d3cebcd detect glibc and use correct binary (#598)
On Linux, the current code prefers the musl binary if multiple versions
are installed, but it actually crashes on glibc systems.

Supersedes
https://github.com/agentclientprotocol/claude-agent-acp/pull/597.

Importantly, we need to pass the detected binary to
`pathToClaudeCodeExecutable`, otherwise the SDK itself selects the wrong
version.

See https://github.com/tidewave-ai/tidewave_app/issues/141.
Closes https://github.com/tidewave-ai/tidewave_app/issues/141.
2026-04-27 11:54:12 +02:00
acp-release-bot[bot]
84e05eecc5 chore(main): release 0.31.0 (#574)
🤖 I have created a release *beep* *boop*
---


##
[0.31.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.30.0...v0.31.0)
(2026-04-24)


### Features

* allow configuring models through env vars
([#586](https://github.com/agentclientprotocol/claude-agent-acp/issues/586))
([1e15f3d](1e15f3d8b0))
* Support effort levels
([#464](https://github.com/agentclientprotocol/claude-agent-acp/issues/464))
([9e1185b](9e1185bfd5))
* Update to acp sdk v0.20
([#589](https://github.com/agentclientprotocol/claude-agent-acp/issues/589))
([92adcbd](92adcbd1ac))
* Update to claude-agent-sdk 0.2.119
([#587](https://github.com/agentclientprotocol/claude-agent-acp/issues/587))
([ef1dbd1](ef1dbd1cb4))


### Bug Fixes

* Forward full systemPrompt preset options from _meta
([#591](https://github.com/agentclientprotocol/claude-agent-acp/issues/591))
([b3ddfc3](b3ddfc3b86))
* Session Replay and Permission rendering
([#593](https://github.com/agentclientprotocol/claude-agent-acp/issues/593))
([5faefab](5faefab8ab)),
closes
[#579](https://github.com/agentclientprotocol/claude-agent-acp/issues/579)

---
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-04-24 17:14:31 +02:00
Ben Brandt
5faefab8ab fix: Session Replay and Permission rendering (#593)
Cleans up the replay of loaded sessions.
Also makes always allow permissions clearer of what will be applied.

Closes #579
2026-04-24 15:13:02 +00:00
Ben Brandt
b3ddfc3b86 fix: Forward full systemPrompt preset options from _meta (#591)
Closes #581

Pass through object-based systemPrompt options from _meta while
forcing the claude_code preset, and add tests for defaulting,
string replacement, option forwarding, and override protection.
2026-04-24 16:08:04 +02:00
Eric Holk
e09f1486f1 fix: Forward SDKAssistantMessage.error as structured data on internal errors (#582)
## Motivation

When a Zed user on the Claude Pro tier hits their usage limit, Zed
receives an error like this from claude-agent-acp:

```json
{
  "code": -32603,
  "message": "Internal error: You've hit your limit \u00b7 resets 8pm (America/Los_Angeles)",
  "data": undefined
}
```

The only way for a client to recognize this as a rate-limit condition
(vs. any of the other things that can hit the `is_error: true` path —
billing issues, server errors, max tokens, etc.) is to pattern-match on
the human-readable message text. That's fragile and breaks every time
the message gets reworded upstream.

## What Claude Code already provides

The Claude Agent SDK actually emits a categorical kind on the
`SDKAssistantMessage` that precedes the failing `result`:

```ts
export declare type SDKAssistantMessageError =
  | 'authentication_failed'
  | 'billing_error'
  | 'rate_limit'
  | 'invalid_request'
  | 'server_error'
  | 'unknown'
  | 'max_output_tokens';
```

\(Confirmed by inspection of the shipped `cli.js`: the CLI calls a
helper that wraps errors into assistant messages with `error:
"rate_limit"` \(or `billing_error`, etc.\) based on the nature of the
failure.\)

The adapter today reads `message.message.usage`,
`message.message.model`, and `message.message.content` off the
`SDKAssistantMessage`, but ignores the top-level `error` field. By the
time we throw `RequestError.internalError\(undefined, message.result\)`
on the failing result, the categorical info has been discarded.

## This PR

Capture the last seen `SDKAssistantMessage.error` and forward it to
`RequestError.internalError`'s `data` as `{ errorKind }`:

```json
{
  "code": -32603,
  "message": "Internal error: You've hit your limit \u00b7 resets 8pm (America/Los_Angeles)",
  "data": { "errorKind": "rate_limit" }
}
```

ACP clients can then dispatch on the structured kind instead of
pattern-matching on the message text. Clients that don't understand the
field fall back to the existing message-based rendering unchanged
\(`data` is now populated in some cases where it used to be `undefined`,
but that's purely additive — no existing client reads this field today
so there's no compatibility risk\).

When the SDK doesn't provide a categorical error, we continue to pass
`undefined` so `data` stays absent.

## Changes

- `src/acp-agent.ts`: Track a `lastAssistantError:
SDKAssistantMessageError | undefined` through the `prompt()` iteration,
populated from the assistant branch. Thread it through to all three
`throw RequestError.internalError` sites in the result branch via a
small `errorKindData` helper.
- `src/tests/acp-agent.test.ts`: Two new tests — one verifying that an
upstream `error: "rate_limit"` on an `SDKAssistantMessage` shows up as
`{ errorKind: "rate_limit" }` on the thrown error's `data`, one
verifying that absence stays `undefined`.

## Open questions

1. **Field name.** I went with `errorKind` since it's short,
agent-agnostic, and not conflated with JSON-RPC's own `code`. Open to
bikeshedding.
2. **Whether to push this toward being an ACP-wide convention.** Other
adapters \(codex, gemini\) face the same problem — everything non-auth
squashed into `InternalError`. If this lands and works well here, it
might be worth writing up a little guidance in the ACP docs for "how
adapters should encode categorical error reasons" so we get the same
shape from every agent.
3. **Companion Zed PR.** The Zed-side change to read `data.errorKind`
and map to the existing `ThreadError::RateLimitExceeded` /
`AuthenticationRequired` / `PaymentRequired` / `MaxOutputTokens`
variants is straightforward \(~20 lines\) but I've held off on it until
this ships and a release is cut.

## Testing

- `npm run test:run`: 199 passing \(was 197; 2 new\).
- `npm run build`: clean.
- `npm run check`: lint + format clean.

This is part of a broader quality-week effort in Zed to improve the
external-agent debugging experience
\(<https://github.com/zed-industries/zed/issues/54531>\).
2026-04-24 13:52:20 +00:00
Anna Zhdan
1e15f3d8b0 feat: allow configuring models through env vars (#586)
Allow providing Claude model settings via env var on startup. Providing
model settings is required when using Claude with alternative llm
provider, e.g. Bedrock as models have different IDs there
2026-04-24 15:50:28 +02:00
Ben Brandt
92adcbd1ac feat: Update to acp sdk v0.20 (#589) 2026-04-24 13:49:18 +00:00
dependabot[bot]
e474a0c127 chore(deps): bump googleapis/release-please-action from 4.4.1 to 5.0.0 (#588)
Bumps
[googleapis/release-please-action](https://github.com/googleapis/release-please-action)
from 4.4.1 to 5.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/release-please-action/releases">googleapis/release-please-action's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<h2><a
href="https://github.com/googleapis/release-please-action/compare/v4.4.1...v5.0.0">5.0.0</a>
(2026-04-22)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>upgrade to node24 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1188">#1188</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>upgrade to node24 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1188">#1188</a>)
(<a
href="46dfc01594">46dfc01</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>bump release-please from 17.3.0 to 17.6.0 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1199">#1199</a>)
(<a
href="f533c26b74">f533c26</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md">googleapis/release-please-action's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/googleapis/release-please-action/compare/v4.4.1...v5.0.0">5.0.0</a>
(2026-04-22)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>upgrade to node24 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1188">#1188</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>upgrade to node24 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1188">#1188</a>)
(<a
href="46dfc01594">46dfc01</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>bump release-please from 17.3.0 to 17.6.0 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1199">#1199</a>)
(<a
href="f533c26b74">f533c26</a>)</li>
</ul>
<h2><a
href="https://github.com/googleapis/release-please-action/compare/v4.4.0...v4.4.1">4.4.1</a>
(2026-02-20)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>bump release-please from 17.1.3 to 17.3.0 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1183">#1183</a>)
(<a
href="ef9c2745db">ef9c274</a>)</li>
</ul>
<h2><a
href="https://github.com/googleapis/release-please-action/compare/v4.3.0...v4.4.0">4.4.0</a>
(2025-10-09)</h2>
<h3>Features</h3>
<ul>
<li>add ability to select versioning-strategy and release-as (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1121">#1121</a>)
(<a
href="ee0f5bae45">ee0f5ba</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><code>changelog-host</code> parameter ignored when using manifest
configuration (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1151">#1151</a>)
(<a
href="535c4130c1">535c413</a>)</li>
<li>bump mocha from 11.7.1 to 11.7.2 in the npm_and_yarn group across 1
directory (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1149">#1149</a>)
(<a
href="3612a99d75">3612a99</a>)</li>
<li>bump release-please from 17.1.2 to 17.1.3 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1158">#1158</a>)
(<a
href="66fbfe9439">66fbfe9</a>)</li>
</ul>
<h2><a
href="https://github.com/googleapis/release-please-action/compare/v4.2.0...v4.3.0">4.3.0</a>
(2025-08-20)</h2>
<h3>Features</h3>
<ul>
<li><strong>deps:</strong> update release-please to 17.1.2 (<a
href="f07192c046">f07192c</a>)</li>
</ul>
<h2><a
href="https://github.com/googleapis/release-please-action/compare/v4.1.5...v4.2.0">4.2.0</a>
(2025-03-07)</h2>
<h3>Features</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="45996ed1f6"><code>45996ed</code></a>
chore(main): release 5.0.0 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1200">#1200</a>)</li>
<li><a
href="a8121b99c9"><code>a8121b9</code></a>
chore: build dist (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1201">#1201</a>)</li>
<li><a
href="f533c26b74"><code>f533c26</code></a>
fix: bump release-please from 17.3.0 to 17.6.0 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1199">#1199</a>)</li>
<li><a
href="46dfc01594"><code>46dfc01</code></a>
feat!: upgrade to node24 (<a
href="https://redirect.github.com/googleapis/release-please-action/issues/1188">#1188</a>)</li>
<li>See full diff in <a
href="5c625bfb5d...45996ed1f6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=googleapis/release-please-action&package-manager=github_actions&previous-version=4.4.1&new-version=5.0.0)](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 this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 15:49:15 +02:00
XiaoYan Li
ef1dbd1cb4 feat: Update to claude-agent-sdk 0.2.119 (#587)
Bumps `@anthropic-ai/claude-agent-sdk` from `0.2.114` to `0.2.119`
(latest).

## Why

Primarily to pick up the April 20 system prompt verbosity revert called
out in the [April 23
postmortem](https://www.anthropic.com/engineering/april-23-postmortem),
which shipped in `claude-agent-sdk` `0.2.116` (parity with Claude Code
v2.1.116). Per the postmortem, an instruction added on April 16 —
_"Length limits: keep text between tool calls to ≤25 words. Keep final
responses to ≤100 words unless the task requires more detail."_ — caused
a measurable drop in coding quality for Sonnet 4.6, Opus 4.6, and Opus
4.7, and was reverted in the v2.1.116 release.

The other two regressions described in the postmortem (the
cache-clearing thinking-blocks bug fixed on April 10, and the default
reasoning effort revert on April 7) were already addressed before the
`0.2.114` this repo currently pins, so this upgrade is specifically
about the verbosity-prompt fix and the SDK improvements that have landed
since.

## What's also included between 0.2.114 and 0.2.119

From the [SDK release
notes](https://github.com/anthropics/claude-agent-sdk-typescript/releases):

- **0.2.116** — parity with Claude Code v2.1.116 (postmortem fix)
- **0.2.117** — parity with Claude Code v2.1.117
- **0.2.118** — adds `Options.managedSettings` for embedders to pass
policy-tier settings to the spawned CLI in-memory, honored below
IT-controlled managed sources
- **0.2.119**
- `excludeDynamicSections` now keeps static auto-memory instructions in
the cacheable system-prompt block; only the per-user memory directory
path and per-machine environment values are relocated to the first user
message
- Long-running SDK sessions now reconnect claude.ai-proxied MCP servers
after a transport-stream abort
- `SessionStore.append()` failures are now retried up to 3 times with
short backoff before the batch is dropped and `mirror_error` is emitted

No source changes were required in this repo — the public API surface
this project uses is unchanged.

## Verification

- `npm run build` ✅
- `npm run check` (lint + prettier) ✅
- `npm run test:run` ✅ (197 passed, 11 skipped)

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-04-24 13:44:38 +00:00
dependabot[bot]
e30f0e5ba0 chore(deps): bump actions/setup-node from 6.3.0 to 6.4.0 (#577)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from
6.3.0 to 6.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-node/releases">actions/setup-node's
releases</a>.</em></p>
<blockquote>
<h2>v6.4.0</h2>
<h2>What's Changed</h2>
<h3>Dependency updates:</h3>
<ul>
<li>Upgrade <a
href="https://github.com/actions"><code>@​actions</code></a>
dependencies by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
<li>Update Node.js versions in versions.yml and bump package to v6.4.0
by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1533">actions/setup-node#1533</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Copilot"><code>@​Copilot</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v6.4.0">https://github.com/actions/setup-node/compare/v6...v6.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="48b55a011b"><code>48b55a0</code></a>
Update Node.js versions in versions.yml and bump package to v6.4.0 (<a
href="https://redirect.github.com/actions/setup-node/issues/1533">#1533</a>)</li>
<li><a
href="ab72c7e7eb"><code>ab72c7e</code></a>
Upgrade <a href="https://github.com/actions"><code>@​actions</code></a>
dependencies (<a
href="https://redirect.github.com/actions/setup-node/issues/1525">#1525</a>)</li>
<li>See full diff in <a
href="53b83947a5...48b55a011b">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=6.3.0&new-version=6.4.0)](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 this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 19:00:23 +02:00
Ben Brandt
9e1185bfd5 feat: Support effort levels (#464) 2026-04-20 15:21:41 +02:00
17 changed files with 2501 additions and 429 deletions

View File

@@ -8,7 +8,7 @@ updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
interval: "daily"
allow:
- dependency-type: "all"
groups:

View File

@@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Use Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: "lts/*"
cache: "npm"

View File

@@ -23,7 +23,7 @@ jobs:
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
# GitHub App private key secret name
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7
id: release
with:
token: ${{ steps.generate-token.outputs.token }}
@@ -46,7 +46,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"

View File

@@ -1,3 +1,3 @@
{
".": "0.30.0"
".": "0.31.4"
}

View File

@@ -1,5 +1,49 @@
# Changelog
## [0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4) (2026-04-28)
### Bug Fixes
* gate auto mode on model support ([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604)) ([ec47d34](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec47d3446fa22e5895621f57c0a4497dbe044505))
## [0.31.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.2...v0.31.3) (2026-04-28)
### Bug Fixes
* Rename effort config category to thought_level ([#609](https://github.com/agentclientprotocol/claude-agent-acp/issues/609)) ([76b4e96](https://github.com/agentclientprotocol/claude-agent-acp/commit/76b4e9650832e59a6b4915ad898fc66a065022fa))
## [0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2) (2026-04-28)
### Bug Fixes
* tolerate invalid values in settings.json ([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601)) ([67af018](https://github.com/agentclientprotocol/claude-agent-acp/commit/67af018d0216238d8f3cd5bad04a55200518239b))
## [0.31.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.0...v0.31.1) (2026-04-27)
### Bug Fixes
* detect glibc and use correct binary ([#599](https://github.com/agentclientprotocol/claude-agent-acp/issues/599)) ([f0801d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/f0801d50b2390a3c48150b9b8507d30a70c18b84))
## [0.31.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.30.0...v0.31.0) (2026-04-24)
### Features
* allow configuring models through env vars ([#586](https://github.com/agentclientprotocol/claude-agent-acp/issues/586)) ([1e15f3d](https://github.com/agentclientprotocol/claude-agent-acp/commit/1e15f3d8b0a06076ee62764ab576ac29fe056a38))
* Support effort levels ([#464](https://github.com/agentclientprotocol/claude-agent-acp/issues/464)) ([9e1185b](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e1185bfd55cbf83a653a0ff8c194f986cbd1574))
* Update to acp sdk v0.20 ([#589](https://github.com/agentclientprotocol/claude-agent-acp/issues/589)) ([92adcbd](https://github.com/agentclientprotocol/claude-agent-acp/commit/92adcbd1ace2f2103742ffcf5c589eb1ca41b9b8))
* Update to claude-agent-sdk 0.2.119 ([#587](https://github.com/agentclientprotocol/claude-agent-acp/issues/587)) ([ef1dbd1](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef1dbd1cb457f0524d221ea07235abf49dc941a0))
### Bug Fixes
* Forward full systemPrompt preset options from _meta ([#591](https://github.com/agentclientprotocol/claude-agent-acp/issues/591)) ([b3ddfc3](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3ddfc3b86703c23fe99d93611885c12b1e4812c))
* Session Replay and Permission rendering ([#593](https://github.com/agentclientprotocol/claude-agent-acp/issues/593)) ([5faefab](https://github.com/agentclientprotocol/claude-agent-acp/commit/5faefab8ab9b725f7a3374808bf5579e411884bc)), closes [#579](https://github.com/agentclientprotocol/claude-agent-acp/issues/579)
## [0.30.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.2...v0.30.0) (2026-04-20)

View File

@@ -0,0 +1,56 @@
# Model Configuration
When using claude-agent-acp with alternative providers (e.g. AWS Bedrock), model IDs differ from the direct Anthropic API. The `CLAUDE_MODEL_CONFIG` environment variable lets you configure model overrides and availability at the deployment level.
## `CLAUDE_MODEL_CONFIG`
A JSON string with two optional fields:
| Field | Type | Description |
| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `modelOverrides` | `Record<string, string>` | Maps Anthropic model IDs to provider-specific model IDs (e.g. Bedrock model IDs or ARNs) |
| `availableModels` | `string[]` | Restricts which models are offered to users. Accepts aliases (`"opus"`), prefixes (`"opus-4-5"`), or full IDs |
### Examples
**Bedrock model overrides:**
```bash
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"}}'
```
**Restrict available models:**
```bash
CLAUDE_MODEL_CONFIG='{"availableModels":["opus","sonnet"]}'
```
**Both together:**
```bash
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"},"availableModels":["opus","sonnet"]}'
```
**Full Bedrock example:**
```bash
CLAUDE_CODE_USE_BEDROCK=1 \
AWS_REGION=us-west-2 \
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1"}}' \
node dist/index.js
```
## Precedence
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
| Source | Priority |
| -------------------------------------------- | ----------------------------------------------------- |
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
## Format details
- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error.
- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored.
- Both fields map directly to the Claude Agent SDK's `Settings` type.

530
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.30.0",
"version": "0.31.4",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -60,22 +60,22 @@
"author": "Zed Industries",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.19.0",
"@anthropic-ai/claude-agent-sdk": "0.2.114",
"@agentclientprotocol/sdk": "0.21.0",
"@anthropic-ai/claude-agent-sdk": "0.2.121",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.89.0",
"@eslint/js": "10.0.1",
"@types/node": "25.6.0",
"@typescript-eslint/eslint-plugin": "8.58.2",
"@typescript-eslint/parser": "8.58.2",
"@typescript-eslint/eslint-plugin": "8.59.1",
"@typescript-eslint/parser": "8.59.1",
"eslint": "10.2.1",
"eslint-config-prettier": "10.1.8",
"globals": "17.5.0",
"prettier": "3.8.3",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "4.1.4"
"vitest": "4.1.5"
}
}

View File

@@ -51,8 +51,11 @@ import {
ModelUsage,
Options,
PermissionMode,
PermissionUpdate,
Query,
query,
Settings,
SDKAssistantMessageError,
SDKPartialAssistantMessage,
SDKUserMessage,
SlashCommand,
@@ -135,6 +138,7 @@ type Session = {
accumulatedUsage: AccumulatedUsage;
modes: SessionModeState;
models: SessionModelState;
modelInfos: ModelInfo[];
configOptions: SessionConfigOption[];
promptRunning: boolean;
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
@@ -270,12 +274,22 @@ export async function claudeCliPath(): Promise<string> {
const { createRequire } = await import("node:module");
const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
const ext = process.platform === "win32" ? ".exe" : "";
// On linux, both glibc and musl variants may be installed side-by-side
// (e.g. bunx hydrates every optional dep), so picking one by trial is
// unreliable: the wrong binary segfaults at runtime instead of failing to
// spawn. Detect the runtime libc and prefer the matching variant, falling
// back to the other only if the preferred one isn't installed.
const candidates =
process.platform === "linux"
? [
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
]
? isMuslLibc()
? [
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
]
: [
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
]
: [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
for (const candidate of candidates) {
try {
@@ -290,6 +304,15 @@ export async function claudeCliPath(): Promise<string> {
);
}
function isMuslLibc(): boolean {
// process.report.getReport().header.glibcVersionRuntime is populated when
// Node is dynamically linked against glibc, and absent on musl.
const report = process.report?.getReport() as
| { header?: { glibcVersionRuntime?: string } }
| undefined;
return !report?.header?.glibcVersionRuntime;
}
function shouldHideClaudeAuth(): boolean {
return process.argv.includes("--hide-claude-auth");
}
@@ -302,6 +325,56 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
// message and without invoking the model.
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
// their output as user messages in the session transcript, wrapping the
// payload in these XML-like markers that the CLI uses for its own display.
// The live prompt loop drops them; replay must strip them too or they leak
// into the UI on session/load.
const LOCAL_COMMAND_TAG_PATTERN =
/<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
function stripMarkerTags(text: string): string {
return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
}
/**
* Return user-message content with local-command marker tags removed, or
* `null` if nothing meaningful remains (caller should skip the message).
* Preserves real prose that's mixed in alongside the markers — e.g. a
* message like `<command-name>…</command-name>hi` becomes `hi`.
*/
export function stripLocalCommandMetadata(content: unknown): unknown | null {
if (typeof content === "string") {
const stripped = stripMarkerTags(content);
return stripped.trim() === "" ? null : stripped;
}
if (!Array.isArray(content)) return content;
const kept: unknown[] = [];
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as { type: unknown }).type === "text" &&
"text" in block &&
typeof (block as { text: unknown }).text === "string"
) {
const stripped = stripMarkerTags((block as { text: string }).text);
if (stripped.trim() === "") continue;
kept.push({ ...(block as object), text: stripped });
} else {
kept.push(block);
}
}
if (kept.length === 0) return null;
return kept;
}
export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
auto: "auto",
default: "default",
@@ -312,34 +385,85 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
bypass: "bypassPermissions",
};
export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
export function resolvePermissionMode(
defaultMode?: unknown,
logger: Logger = console,
): PermissionMode {
if (defaultMode === undefined) {
return "default";
}
if (typeof defaultMode !== "string") {
throw new Error("Invalid permissions.defaultMode: expected a string.");
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
return "default";
}
const normalized = defaultMode.trim().toLowerCase();
if (normalized === "") {
throw new Error("Invalid permissions.defaultMode: expected a non-empty string.");
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
return "default";
}
const mapped = PERMISSION_MODE_ALIASES[normalized];
if (!mapped) {
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`);
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
return "default";
}
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
throw new Error(
"Invalid permissions.defaultMode: bypassPermissions is not available when running as root.",
logger.error(
"Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.",
);
return "default";
}
return mapped;
}
/**
* Builds the label for the "Always Allow" permission option so the user can see
* the exact scope they are committing to. Uses the SDK-provided suggestions
* when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
* tool so "Always Allow" is never a blank check without disclosure.
*/
export function describeAlwaysAllow(
suggestions: PermissionUpdate[] | undefined,
toolName: string,
): string {
if (!suggestions || suggestions.length === 0) {
return `Always Allow all ${toolName}`;
}
const ruleLabels: string[] = [];
const directories: string[] = [];
for (const update of suggestions) {
if (update.type === "addRules" && update.behavior === "allow") {
for (const rule of update.rules) {
ruleLabels.push(
rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`,
);
}
} else if (update.type === "addDirectories") {
directories.push(...update.directories);
}
}
const parts: string[] = [];
if (ruleLabels.length > 0) {
parts.push(ruleLabels.join(", "));
}
if (directories.length > 0) {
parts.push(`access to ${directories.join(", ")}`);
}
if (parts.length === 0) {
return `Always Allow all ${toolName}`;
}
return `Always Allow ${parts.join(" and ")}`;
}
// Implement the ACP Agent interface
export class ClaudeAcpAgent implements Agent {
sessions: {
@@ -522,7 +646,7 @@ export class ClaudeAcpAgent implements Agent {
return response;
}
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const result = await this.getOrCreateSession(params);
// Needs to happen after we return the session
@@ -588,6 +712,13 @@ export class ClaudeAcpAgent implements Agent {
let lastAssistantTotalUsage: number | null = null;
let lastAssistantUsage: UsageSnapshot | null = null;
let lastAssistantModel: string | null = null;
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
// problem, billing), it sets a categorical `error` field on the
// `SDKAssistantMessage` that precedes the final `result` message. We
// capture it here so the subsequent `RequestError.internalError` can
// forward it to clients as structured `data`, sparing them from
// pattern-matching on the human-readable message text.
let lastAssistantError: SDKAssistantMessageError | undefined;
const userMessage = promptToClaude(params);
@@ -773,7 +904,10 @@ export class ClaudeAcpAgent implements Agent {
break;
}
if (message.is_error) {
throw RequestError.internalError(undefined, message.result);
throw RequestError.internalError(
errorKindData(lastAssistantError),
message.result,
);
}
// For local-only commands (no model invocation), the result
// text is the command output — forward it to the client.
@@ -798,7 +932,7 @@ export class ClaudeAcpAgent implements Agent {
}
if (message.is_error) {
throw RequestError.internalError(
undefined,
errorKindData(lastAssistantError),
message.errors.join(", ") || message.subtype,
);
}
@@ -810,7 +944,7 @@ export class ClaudeAcpAgent implements Agent {
case "error_max_structured_output_retries":
if (message.is_error) {
throw RequestError.internalError(
undefined,
errorKindData(lastAssistantError),
message.errors.join(", ") || message.subtype,
);
}
@@ -926,6 +1060,9 @@ export class ClaudeAcpAgent implements Agent {
if (message.message.model && message.message.model !== "<synthetic>") {
lastAssistantModel = message.message.model;
}
if (message.error) {
lastAssistantError = message.error;
}
}
// Slash commands like /compact can generate invalid output... doesn't match
@@ -1077,7 +1214,7 @@ export class ClaudeAcpAgent implements Agent {
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
}
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
if (!this.sessions[params.sessionId]) {
throw new Error("Session not found");
}
@@ -1088,11 +1225,17 @@ export class ClaudeAcpAgent implements Agent {
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
if (!this.sessions[params.sessionId]) {
const session = this.sessions[params.sessionId];
if (!session) {
throw new Error("Session not found");
}
await this.sessions[params.sessionId].query.setModel(params.modelId);
await this.updateConfigOption(params.sessionId, "model", params.modelId);
// Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
// downstream lookups in modelInfos succeed and the effort option isn't
// silently dropped.
const resolved = resolveModelPreference(session.modelInfos, params.modelId);
const modelId = resolved?.value ?? params.modelId;
await session.query.setModel(modelId);
await this.updateConfigOption(params.sessionId, "model", modelId);
}
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
@@ -1162,14 +1305,11 @@ export class ClaudeAcpAgent implements Agent {
} else if (params.configId === "model") {
await this.sessions[params.sessionId].query.setModel(resolvedValue);
}
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
// effort changes and effort changes induced by a model switch go through
// the same path.
this.syncSessionConfigState(session, params.configId, params.value);
session.configOptions = session.configOptions.map((o) =>
o.id === params.configId && typeof o.currentValue === "string"
? { ...o, currentValue: resolvedValue }
: o,
);
await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
return { configOptions: session.configOptions };
}
@@ -1186,8 +1326,17 @@ export class ClaudeAcpAgent implements Agent {
default:
throw new Error("Invalid Mode");
}
const session = this.sessions[sessionId];
if (!session) {
throw new Error("Session not found");
}
if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
throw new Error(`Mode ${modeId} is not available in this session`);
}
try {
await this.sessions[sessionId].query.setPermissionMode(modeId);
await session.query.setPermissionMode(modeId);
} catch (error) {
if (error instanceof Error) {
if (!error.message) {
@@ -1206,9 +1355,17 @@ export class ClaudeAcpAgent implements Agent {
const messages = await getSessionMessages(sessionId);
for (const message of messages) {
// @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
if (message.message.role === "user") {
content = stripLocalCommandMetadata(content);
if (content === null) continue;
}
for (const notification of toAcpNotifications(
// @ts-expect-error - untyped in SDK but we handle all of these
message.message.content,
content,
// @ts-expect-error - untyped in SDK but we handle all of these
message.message.role,
sessionId,
@@ -1238,6 +1395,7 @@ export class ClaudeAcpAgent implements Agent {
canUseTool(sessionId: string): CanUseTool {
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
const session = this.sessions[sessionId];
if (!session) {
@@ -1248,7 +1406,7 @@ export class ClaudeAcpAgent implements Agent {
}
if (toolName === "ExitPlanMode") {
const options = [
const optionsAll = [
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
{
kind: "allow_always",
@@ -1259,12 +1417,20 @@ export class ClaudeAcpAgent implements Agent {
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
];
if (ALLOW_BYPASS) {
options.unshift({
optionsAll.unshift({
kind: "allow_always",
name: "Yes, and bypass permissions",
optionId: "bypassPermissions",
});
}
// Filter against the session's currently-advertised modes so we never
// present options the active model can't honor (e.g. `auto` on Haiku).
// `bypassPermissions` is already covered by `availableModes` via
// `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
// "keep planning" reject path; it's always present in `availableModes`.
const options = optionsAll.filter((o) =>
session.modes.availableModes.some((m) => m.id === o.optionId),
);
const response = await this.client.requestPermission({
options,
@@ -1283,27 +1449,30 @@ export class ClaudeAcpAgent implements Agent {
if (signal.aborted || response.outcome?.outcome === "cancelled") {
throw new Error("Tool use aborted");
}
const selectedMode =
response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
if (
response.outcome?.outcome === "selected" &&
(response.outcome.optionId === "default" ||
response.outcome.optionId === "acceptEdits" ||
response.outcome.optionId === "auto" ||
response.outcome.optionId === "bypassPermissions")
selectedModeWasOffered &&
(selectedMode === "default" ||
selectedMode === "acceptEdits" ||
selectedMode === "auto" ||
selectedMode === "bypassPermissions")
) {
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: response.outcome.optionId,
currentModeId: selectedMode,
},
});
await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
await this.updateConfigOption(sessionId, "mode", selectedMode);
return {
behavior: "allow",
updatedInput: toolInput,
updatedPermissions: suggestions ?? [
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
{ type: "setMode", mode: selectedMode, destination: "session" },
],
};
} else {
@@ -1328,7 +1497,7 @@ export class ClaudeAcpAgent implements Agent {
options: [
{
kind: "allow_always",
name: "Always Allow",
name: alwaysAllowLabel,
optionId: "allow_always",
},
{ kind: "allow_once", name: "Allow", optionId: "allow" },
@@ -1401,11 +1570,7 @@ export class ClaudeAcpAgent implements Agent {
const session = this.sessions[sessionId];
if (!session) return;
this.syncSessionConfigState(session, configId, value);
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
await this.applyConfigOptionValue(sessionId, session, configId, value);
await this.client.sessionUpdate({
sessionId,
@@ -1416,9 +1581,17 @@ export class ClaudeAcpAgent implements Agent {
});
}
private syncSessionConfigState(session: Session, configId: string, value: string): void {
private async applyConfigOptionValue(
sessionId: string,
session: Session,
configId: string,
value: string,
): Promise<void> {
if (configId === "mode") {
session.modes = { ...session.modes, currentModeId: value };
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else if (configId === "model") {
if (session.models.currentModelId !== value) {
// The cached context window was learned for the previous model; reset
@@ -1428,6 +1601,83 @@ export class ClaudeAcpAgent implements Agent {
session.contextWindowSize = inferContextWindowFromModel(value) ?? 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".
const previousModeId = session.modes.currentModeId;
let modeDowngraded = false;
if (!newAvailableModes.some((m) => m.id === previousModeId)) {
session.modes = {
availableModes: newAvailableModes,
currentModeId: "default",
};
try {
await session.query.setPermissionMode("default");
} catch (err) {
// Failing the entire model switch over a bookkeeping sync error is
// worse UX than logging and continuing; the user explicitly asked
// to change models. The next setPermissionMode from the user will
// either succeed or surface a fresh error.
this.logger.error(
`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`,
err,
);
}
modeDowngraded = true;
} else {
session.modes = { ...session.modes, availableModes: newAvailableModes };
}
// Rebuild config options since effort levels depend on the selected model
const effortOpt = session.configOptions.find((o) => o.id === "effort");
const currentEffort =
typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
session.configOptions = buildConfigOptions(
session.modes,
session.models,
session.modelInfos,
currentEffort,
);
// Sync effort with the SDK if it changed after the model switch
const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
const newEffort =
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
if (newEffort !== currentEffort) {
await session.query.applyFlagSettings({
effortLevel: newEffort as Settings["effortLevel"],
});
}
// Emit current_mode_update only after session.modes AND
// session.configOptions have been fully reconciled. This way, a failure
// in the configOptions/effort rebuild above can't leave the client with
// a clamped currentModeId but stale configOptions, and the notification
// still precedes the caller's config_option_update so order-sensitive
// clients update currentModeId before re-rendering the option list.
if (modeDowngraded) {
await this.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "current_mode_update",
currentModeId: "default",
},
});
}
} else {
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
if (configId === "effort") {
await session.query.applyFlagSettings({
effortLevel: value as Settings["effortLevel"],
});
}
}
}
@@ -1529,15 +1779,22 @@ export class ClaudeAcpAgent implements Agent {
systemPrompt = customPrompt;
} else if (
typeof customPrompt === "object" &&
"append" in customPrompt &&
typeof customPrompt.append === "string"
customPrompt !== null &&
!Array.isArray(customPrompt)
) {
systemPrompt.append = customPrompt.append;
// Forward all preset options (append, excludeDynamicSections, and
// anything the SDK adds later) while locking type/preset.
systemPrompt = {
...(customPrompt as object),
type: "preset",
preset: "claude_code",
} as Options["systemPrompt"];
}
}
const permissionMode = resolvePermissionMode(
settingsManager.getSettings().permissions?.defaultMode,
this.logger,
);
// Extract options from _meta if provided
@@ -1549,6 +1806,9 @@ export class ClaudeAcpAgent implements Agent {
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
: undefined;
// Parse model configuration from environment (e.g. Bedrock model overrides)
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
const disallowedTools = ["AskUserQuestion"];
@@ -1567,6 +1827,17 @@ export class ClaudeAcpAgent implements Agent {
settingSources: ["user", "project", "local"],
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
...userProvidedOptions,
// CLAUDE_MODEL_CONFIG env var is a fallback for model
// configuration (e.g. Bedrock model ID overrides). When the caller
// provides settings via _meta, we intentionally ignore the env var —
// the caller is assumed to have full control over model configuration.
...(!userProvidedOptions?.settings &&
modelConfig && {
settings: {
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
},
}),
env: {
...process.env,
...userProvidedOptions?.env,
@@ -1583,7 +1854,7 @@ export class ClaudeAcpAgent implements Agent {
allowDangerouslySkipPermissions: ALLOW_BYPASS,
permissionMode,
canUseTool: this.canUseTool(sessionId),
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
extraArgs: {
...userProvidedOptions?.extraArgs,
"replay-user-messages": "",
@@ -1662,50 +1933,69 @@ export class ClaudeAcpAgent implements Agent {
);
}
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
const models = await getAvailableModels(
q,
initializationResult.models,
settingsManager,
this.logger,
);
const availableModes = [
{
id: "auto",
name: "Auto",
description: "Use a model classifier to approve/deny permission prompts",
},
{
id: "default",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Auto-accept file edit operations",
},
{
id: "plan",
name: "Plan Mode",
description: "Planning mode, no actual tool execution",
},
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
];
// Only works in non-root mode
if (ALLOW_BYPASS) {
availableModes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Bypass all permission checks",
});
// Gate `auto` (and future model-specific modes) on the resolved model's
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
const currentModelInfo = initializationResult.models.find(
(m) => m.value === models.currentModelId,
);
const availableModes = buildAvailableModes(currentModelInfo);
// Clamp `permissionMode` if the resolved session does not offer it. The
// common case is `permissions.defaultMode: "auto"` resolving to a model
// that does not support auto mode (e.g. Haiku); without this clamp the
// SDK would later throw `"auto mode unavailable for this model"` from
// `setPermissionMode`. Keep `permissionMode` as the resolved user intent
// (matches what was passed into `options.permissionMode` above) and use
// `effectiveMode` for the post-clamp value the session actually runs in.
let effectiveMode: PermissionMode = permissionMode;
if (!availableModes.some((m) => m.id === effectiveMode)) {
if (effectiveMode === "auto") {
this.logger.error(
`permissions.defaultMode "auto" is not available for model ` +
`"${models.currentModelId}"; falling back to "default".`,
);
} else {
this.logger.error(
`permissions.defaultMode "${effectiveMode}" is not available in ` +
`this session; falling back to "default".`,
);
}
effectiveMode = "default";
// Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
// try/catch since failing here would abort session creation entirely.
try {
await q.setPermissionMode("default");
} catch (err) {
this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
}
}
const modes = {
currentModeId: permissionMode,
currentModeId: effectiveMode,
availableModes,
};
const configOptions = buildConfigOptions(modes, models);
const configOptions = buildConfigOptions(
modes,
models,
initializationResult.models,
settingsManager.getSettings().effortLevel,
);
// Apply the initial effort level to the SDK so it matches the UI default
const initialEffort = configOptions.find((o) => o.id === "effort");
if (initialEffort && typeof initialEffort.currentValue === "string") {
await q.applyFlagSettings({
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
});
}
this.sessions[sessionId] = {
query: q,
@@ -1722,6 +2012,7 @@ export class ClaudeAcpAgent implements Agent {
},
modes,
models,
modelInfos: initializationResult.models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
@@ -1779,6 +2070,22 @@ function totalTokens(usage: UsageSnapshot): number {
);
}
/**
* Build the `data` payload attached to a `RequestError.internalError` when we
* have a categorical error from the Claude SDK. Returns `undefined` when no
* categorical error is available, matching the previous behavior of passing
* `undefined` to `RequestError.internalError`.
*
* The `errorKind` field is a convention for ACP clients to dispatch on
* without having to pattern-match the human-readable message text. Clients
* that don't understand it fall back to the existing message-based rendering.
*/
function errorKindData(
errorKind: SDKAssistantMessageError | undefined,
): { errorKind: SDKAssistantMessageError } | undefined {
return errorKind ? { errorKind } : undefined;
}
/** Project a nullable API usage object into our non-null snapshot shape.
* Both SDK message_start and assistant message `usage` have `number | null`
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
@@ -1814,11 +2121,65 @@ function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
};
}
/**
* Build the list of permission modes the agent will advertise for the given
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
* the SDK's model-level availability signal. `undefined`/`false` both exclude
* `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
*/
function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState["availableModes"] {
const modes: SessionModeState["availableModes"] = [];
// Only advertise "auto" when the SDK reports the model supports it.
if (modelInfo?.supportsAutoMode === true) {
modes.push({
id: "auto",
name: "Auto",
description: "Use a model classifier to approve/deny permission prompts",
});
}
modes.push(
{
id: "default",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Auto-accept file edit operations",
},
{
id: "plan",
name: "Plan Mode",
description: "Planning mode, no actual tool execution",
},
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
);
if (ALLOW_BYPASS) {
modes.push({
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Bypass all permission checks",
});
}
return modes;
}
function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,
modelInfos: ModelInfo[],
currentEffortLevel?: string,
): SessionConfigOption[] {
return [
const options: SessionConfigOption[] = [
{
id: "mode",
name: "Mode",
@@ -1846,6 +2207,46 @@ function buildConfigOptions(
})),
},
];
// Add effort level option based on the currently selected model
const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
const supportedLevels = currentModelInfo?.supportsEffort
? (currentModelInfo.supportedEffortLevels ?? [])
: [];
if (supportedLevels.length > 0) {
const effortOptions = supportedLevels.map((level) => ({
value: level,
name: level
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" "),
}));
// Keep the current level if valid, otherwise prefer xhigh (Claude Code's
// recommended default for capable models), then high (the API default).
const includes = (l: string) => (supportedLevels as string[]).includes(l);
const validEffort =
currentEffortLevel && includes(currentEffortLevel)
? currentEffortLevel
: includes("xhigh")
? "xhigh"
: includes("high")
? "high"
: supportedLevels[0];
options.push({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: validEffort,
options: effortOptions,
});
}
return options;
}
// Claude Code CLI persists display strings like "opus[1m]" in settings,
@@ -1921,10 +2322,27 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
return bestMatch;
}
function resolveSettingsModel(
models: ModelInfo[],
settingsModel: unknown,
logger: Logger,
): ModelInfo | null {
if (settingsModel === undefined) {
return null;
}
if (typeof settingsModel !== "string") {
const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
return null;
}
return resolveModelPreference(models, settingsModel);
}
async function getAvailableModels(
query: Query,
models: ModelInfo[],
settingsManager: SettingsManager,
logger: Logger,
): Promise<SessionModelState> {
const settings = settingsManager.getSettings();
@@ -1939,8 +2357,8 @@ async function getAvailableModels(
if (match) {
currentModel = match;
}
} else if (settings.model) {
const match = resolveModelPreference(models, settings.model);
} else {
const match = resolveSettingsModel(models, settings.model, logger);
if (match) {
currentModel = match;
}
@@ -2435,6 +2853,20 @@ function inferContextWindowFromModel(model: string): number | null {
return null;
}
function parseModelConfig(
raw: string | undefined,
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {
if (!raw) return undefined;
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
}
const result: { modelOverrides?: Record<string, string>; availableModels?: string[] } = {};
if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides;
if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels;
return Object.keys(result).length > 0 ? result : undefined;
}
function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentModel: string) {
let bestKey: string | null = null;
let bestLen = 0;

View File

@@ -1,6 +1,8 @@
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
isLocalCommandMetadata,
stripLocalCommandMetadata,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,

View File

@@ -22,6 +22,7 @@ export interface ClaudeCodeSettings {
permissions?: PermissionSettings;
env?: Record<string, string>;
model?: string;
effortLevel?: string;
}
/**
@@ -188,6 +189,10 @@ export class SettingsManager {
merged.model = settings.model;
}
if (settings.effortLevel !== undefined) {
merged.effortLevel = settings.effortLevel;
}
if (settings.permissions?.defaultMode !== undefined) {
merged.permissions = {
...merged.permissions,

View File

@@ -145,12 +145,12 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.modes.currentModeId).toBe("default");
});
it("throws when permissions.defaultMode is not a string", async () => {
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: 123,
defaultMode: "not-a-real-mode",
},
}),
);
@@ -158,18 +158,208 @@ describe("ClaudeAcpAgent settings", () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQuery();
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
await expect(
(agent as any).createSession({
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
// Bad mode is ignored at the usage site; session creation must not throw.
expect(getCapturedOptions().permissionMode).toBe("default");
expect(response.modes.currentModeId).toBe("default");
});
it("ignores model from settings when it is not a string", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
model: 123,
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { setModelSpy } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
// Bad model is ignored at the usage site; falls back to the first SDK model.
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
expect(response.models.currentModelId).toBe("claude-sonnet-4-5");
});
describe("auto mode availability per model", () => {
function mockQueryWithModels(models: any[]): {
getCapturedOptions: () => any;
setModelSpy: ReturnType<typeof vi.fn>;
setPermissionModeSpy: ReturnType<typeof vi.fn>;
} {
let capturedOptions: any;
const setModelSpy = vi.fn();
const setPermissionModeSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return {
initializationResult: async () => ({ models }),
setModel: setModelSpy,
setPermissionMode: setPermissionModeSpy,
supportedCommands: async () => [],
} as any;
});
return {
getCapturedOptions: () => capturedOptions,
setModelSpy,
setPermissionModeSpy,
};
}
it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{
value: "claude-haiku-4-5",
displayName: "Claude Haiku",
description: "Fast",
// supportsAutoMode intentionally omitted
},
]);
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 },
}),
).rejects.toThrow("Invalid permissions.defaultMode");
});
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
expect(modeIds).not.toContain("auto");
expect(modeIds).toEqual(
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
);
});
it("includes `auto` when the resolved model has supportsAutoMode: true", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsAutoMode: true,
},
]);
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 modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
expect(modeIds).toContain("auto");
});
it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "auto" } }),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
{
value: "claude-haiku-4-5",
displayName: "Claude Haiku",
description: "Fast",
// supportsAutoMode intentionally omitted
},
]);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
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 },
});
// Options.permissionMode is built before init resolves, so it still
// carries the user-typed value; the SDK was synced via
// setPermissionMode after we discovered the model can't honor it.
expect(getCapturedOptions().permissionMode).toBe("auto");
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
expect(response.modes.currentModeId).toBe("default");
expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto");
// A descriptive warning was logged so operators see the clamp.
const messages = errorSpy.mock.calls.map((c) => c.join(" "));
expect(messages.some((m) => m.includes("auto") && m.includes("claude-haiku-4-5"))).toBe(
true,
);
} finally {
errorSpy.mockRestore();
}
});
it("does not clamp permissions.defaultMode='auto' on a model that supports auto", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "auto" } }),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsAutoMode: true,
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
expect(getCapturedOptions().permissionMode).toBe("auto");
expect(setPermissionModeSpy).not.toHaveBeenCalled();
expect(response.modes.currentModeId).toBe("auto");
});
});
it("resolves model aliases like opus[1m] to the correct model", async () => {

View File

@@ -24,7 +24,15 @@ import {
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
import {
toAcpNotifications,
promptToClaude,
isLocalCommandMetadata,
stripLocalCommandMetadata,
ClaudeAcpAgent,
claudeCliPath,
describeAlwaysAllow,
} from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
@@ -221,14 +229,6 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
name: "compact",
});
// Error case (no previous message)
await connection.prompt({
prompt: [{ type: "text", text: "/compact" }],
sessionId: newSessionResponse.sessionId,
});
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
// Send something
await connection.prompt({
prompt: [{ type: "text", text: "Hi" }],
@@ -237,12 +237,11 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
// Clear response
client.takeReceivedText();
// Test with instruction
await connection.prompt({
prompt: [
{
type: "text",
text: "/compact greeting",
text: "/compact",
},
],
sessionId: newSessionResponse.sessionId,
@@ -1119,6 +1118,113 @@ describe("toolUpdateFromEditToolResponse", () => {
});
});
describe("stripLocalCommandMetadata", () => {
it("returns null for strings that are pure marker metadata", () => {
expect(stripLocalCommandMetadata("<command-name>/model</command-name>")).toBeNull();
expect(
stripLocalCommandMetadata("<local-command-stdout>out</local-command-stdout>"),
).toBeNull();
expect(
stripLocalCommandMetadata("<local-command-stderr>err</local-command-stderr>"),
).toBeNull();
expect(
stripLocalCommandMetadata(
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>",
),
).toBeNull();
});
it("returns the string unchanged for real content", () => {
expect(stripLocalCommandMetadata("hi")).toBe("hi");
expect(stripLocalCommandMetadata("please run /model with args")).toBe(
"please run /model with args",
);
});
// Regression: in the original bug report the entire /model preamble and
// the user's real "hi" prompt were concatenated into a single message.
// We want to strip the marker tags and preserve the real prose, not drop
// the whole message.
it("strips marker tags from mixed-content strings, preserving real prose", () => {
const mixed =
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>" +
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
"hi";
const stripped = stripLocalCommandMetadata(mixed);
expect(typeof stripped).toBe("string");
expect(stripped as string).not.toContain("<command-name>");
expect(stripped as string).not.toContain("<command-message>");
expect(stripped as string).not.toContain("<command-args>");
expect(stripped as string).not.toContain("<local-command-stdout>");
expect((stripped as string).trimEnd()).toMatch(/hi$/);
});
it("drops marker-only blocks from mixed arrays, keeping real blocks", () => {
const result = stripLocalCommandMetadata([
{ type: "text", text: "<command-name>/model</command-name>" },
{ type: "text", text: "<local-command-stdout>ok</local-command-stdout>" },
{ type: "text", text: "hi" },
]);
expect(result).toEqual([{ type: "text", text: "hi" }]);
});
it("returns null when every block is a marker", () => {
expect(
stripLocalCommandMetadata([
{ type: "text", text: "<command-name>/model</command-name>" },
{ type: "text", text: "<local-command-stdout>ok</local-command-stdout>" },
]),
).toBeNull();
});
it("strips tags inside a text block while keeping the trailing prose", () => {
const result = stripLocalCommandMetadata([
{
type: "text",
text: "<command-name>/model</command-name><local-command-stdout>ok</local-command-stdout>hi",
},
]);
expect(result).toEqual([{ type: "text", text: "hi" }]);
});
it("leaves non-text blocks alone", () => {
const image = { type: "image", source: { type: "base64", data: "", media_type: "image/png" } };
const result = stripLocalCommandMetadata([
{ type: "text", text: "<command-name>/model</command-name>" },
image,
]);
expect(result).toEqual([image]);
});
it("handles null/undefined/non-container shapes", () => {
expect(stripLocalCommandMetadata(null)).toBeNull();
expect(stripLocalCommandMetadata(undefined)).toBeUndefined();
expect(stripLocalCommandMetadata({ arbitrary: "object" })).toEqual({ arbitrary: "object" });
});
});
describe("isLocalCommandMetadata", () => {
it("is true when stripping leaves nothing", () => {
expect(isLocalCommandMetadata("<command-name>/model</command-name>")).toBe(true);
expect(
isLocalCommandMetadata([{ type: "text", text: "<command-name>/model</command-name>" }]),
).toBe(true);
});
it("is false when real content survives stripping", () => {
expect(isLocalCommandMetadata("hi")).toBe(false);
expect(isLocalCommandMetadata("<command-name>/model</command-name>hi")).toBe(false);
expect(
isLocalCommandMetadata([
{ type: "text", text: "<command-name>/model</command-name>" },
{ type: "text", text: "hi" },
]),
).toBe(false);
});
});
describe("escape markdown", () => {
it("should escape markdown characters", () => {
let text = "Hello *world*!";
@@ -1195,7 +1301,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
});
const { value } = await q.next();
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
expect(value).toMatchObject({ type: "system", session_id: sessionId });
}, 10000);
});
@@ -1263,6 +1369,81 @@ describe("permission requests", () => {
expect(Array.isArray(requestStructure.toolCall.content)).toBe(true);
}
});
describe("describeAlwaysAllow", () => {
it("falls back to naming the whole tool when no suggestions are provided", () => {
expect(describeAlwaysAllow(undefined, "Bash")).toBe("Always Allow all Bash");
expect(describeAlwaysAllow([], "Read")).toBe("Always Allow all Read");
});
it("includes the scoped rule content from a suggestion", () => {
const label = describeAlwaysAllow(
[
{
type: "addRules",
rules: [{ toolName: "Bash", ruleContent: "npm test:*" }],
behavior: "allow",
destination: "session",
},
],
"Bash",
);
expect(label).toBe("Always Allow Bash(npm test:*)");
});
it("indicates a tool-wide rule when the suggestion has no ruleContent", () => {
const label = describeAlwaysAllow(
[
{
type: "addRules",
rules: [{ toolName: "Read" }],
behavior: "allow",
destination: "session",
},
],
"Read",
);
expect(label).toBe("Always Allow all Read");
});
it("joins multiple rules and directory suggestions", () => {
const label = describeAlwaysAllow(
[
{
type: "addRules",
rules: [
{ toolName: "Bash", ruleContent: "git status" },
{ toolName: "Bash", ruleContent: "git diff:*" },
],
behavior: "allow",
destination: "session",
},
{
type: "addDirectories",
directories: ["/tmp/work"],
destination: "session",
},
],
"Bash",
);
expect(label).toBe("Always Allow Bash(git status), Bash(git diff:*) and access to /tmp/work");
});
it("ignores non-allow rules and falls back when nothing is left", () => {
const label = describeAlwaysAllow(
[
{
type: "addRules",
rules: [{ toolName: "Bash", ruleContent: "rm -rf:*" }],
behavior: "deny",
destination: "session",
},
],
"Bash",
);
expect(label).toBe("Always Allow all Bash");
});
});
});
describe("stop reason propagation", () => {
@@ -1336,6 +1517,7 @@ describe("stop reason propagation", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1478,6 +1660,7 @@ describe("stop reason propagation", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1527,6 +1710,87 @@ describe("stop reason propagation", () => {
}),
).rejects.toThrow("Internal error");
});
it("forwards SDKAssistantMessage.error as structured data on internal errors", async () => {
const agent = createMockAgent();
const assistantMessage: SDKAssistantMessage = {
type: "assistant",
parent_tool_use_id: null,
error: "rate_limit",
uuid: randomUUID(),
session_id: "test-session",
message: {
id: "msg-1",
type: "message",
role: "assistant",
container: null,
model: "claude-sonnet-4-20250514",
content: [],
stop_reason: "stop_sequence",
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: null,
cache_creation: {
ephemeral_1h_input_tokens: 0,
ephemeral_5m_input_tokens: 0,
},
} as any,
} as any,
};
injectSession(agent, [
assistantMessage,
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "You've hit your limit · resets 8pm",
}),
]);
const err = await agent
.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
})
.then(
() => null,
(e) => e,
);
expect(err).not.toBeNull();
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
});
it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "Something went wrong",
}),
]);
const err = await agent
.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
})
.then(
() => null,
(e) => e,
);
expect(err).not.toBeNull();
expect((err as { data: unknown }).data).toBeUndefined();
});
});
describe("session/close", () => {
@@ -1554,6 +1818,7 @@ describe("session/close", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1578,7 +1843,7 @@ describe("session/close", () => {
expect(agent.sessions["session-1"]).toBeDefined();
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
const result = await agent.closeSession({ sessionId: "session-1" });
expect(result).toEqual({});
expect(agent.sessions["session-1"]).toBeUndefined();
@@ -1592,7 +1857,7 @@ describe("session/close", () => {
expect(session.abortController.signal.aborted).toBe(false);
await agent.unstable_closeSession({ sessionId: "session-2" });
await agent.closeSession({ sessionId: "session-2" });
expect(session.abortController.signal.aborted).toBe(true);
});
@@ -1600,7 +1865,7 @@ describe("session/close", () => {
it("should throw when closing a non-existent session", async () => {
const agent = createMockAgent();
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
await expect(agent.closeSession({ sessionId: "non-existent" })).rejects.toThrow(
"Session not found",
);
});
@@ -1610,7 +1875,7 @@ describe("session/close", () => {
injectSession(agent, "session-a");
injectSession(agent, "session-b");
await agent.unstable_closeSession({ sessionId: "session-a" });
await agent.closeSession({ sessionId: "session-a" });
expect(agent.sessions["session-a"]).toBeUndefined();
expect(agent.sessions["session-b"]).toBeDefined();
@@ -1649,6 +1914,7 @@ describe("getOrCreateSession param change detection", () => {
}),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
@@ -1671,7 +1937,7 @@ describe("getOrCreateSession param change detection", () => {
const agent = createMockAgent();
const session = injectSession(agent, "s1", { cwd: "/project" });
await agent.unstable_resumeSession({
await agent.resumeSession({
sessionId: "s1",
cwd: "/project",
mcpServers: [],
@@ -1694,7 +1960,7 @@ describe("getOrCreateSession param change detection", () => {
.mockRejectedValue(new Error("mock"));
await expect(
agent.unstable_resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
agent.resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
).rejects.toThrow("mock");
// Old session should have been fully torn down
@@ -1719,7 +1985,7 @@ describe("getOrCreateSession param change detection", () => {
.mockRejectedValue(new Error("mock"));
await expect(
agent.unstable_resumeSession({
agent.resumeSession({
sessionId: "s1",
cwd: "/project",
mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }],
@@ -1744,7 +2010,7 @@ describe("getOrCreateSession param change detection", () => {
});
// Same servers but reversed order — should NOT trigger teardown
await agent.unstable_resumeSession({
await agent.resumeSession({
sessionId: "s1",
cwd: "/project",
mcpServers: [...servers].reverse() as any,
@@ -1882,6 +2148,7 @@ describe("usage_update computation", () => {
currentModelId: "default",
availableModels: [],
},
modelInfos: [],
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
@@ -2235,7 +2502,12 @@ describe("usage_update computation", () => {
const session = agent.sessions["test-session"];
expect(session.contextWindowSize).toBe(200000);
(agent as any).syncSessionConfigState(session, "model", "claude-opus-4-6-1m");
await (agent as any).applyConfigOptionValue(
"test-session",
session,
"model",
"claude-opus-4-6-1m",
);
expect(session.contextWindowSize).toBe(1000000);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
@@ -2286,7 +2558,7 @@ describe("usage_update computation", () => {
it("switching the session's model invalidates the learned context window", async () => {
// When the user switches models mid-session, the window learned for the
// previous model would otherwise persist into the next prompt's first
// mid-stream update. syncSessionConfigState should reset it so the next
// mid-stream update. applyConfigOptionValue should reset it so the next
// turn's first update falls back to the heuristic (here: 200k default).
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
@@ -2320,7 +2592,12 @@ describe("usage_update computation", () => {
session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" };
// User flips the selector to a 200k model.
(agent as any).syncSessionConfigState(session, "model", "claude-sonnet-4-6");
await (agent as any).applyConfigOptionValue(
"test-session",
session,
"model",
"claude-sonnet-4-6",
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
@@ -2770,6 +3047,7 @@ describe("emitRawSDKMessages", () => {
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
@@ -253,6 +253,189 @@ describe("createSession options merging", () => {
expect(capturedOptions!.tools).toEqual([]);
});
describe("systemPrompt via _meta", () => {
it("defaults to the claude_code preset when not provided", async () => {
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
});
});
it("replaces the preset when a string is provided", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: { systemPrompt: "custom prompt" },
});
expect(capturedOptions!.systemPrompt).toBe("custom prompt");
});
it("forwards append", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: { systemPrompt: { append: "extra instructions" } },
});
expect(capturedOptions!.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
append: "extra instructions",
});
});
it("forwards excludeDynamicSections", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: { systemPrompt: { excludeDynamicSections: true } },
});
expect(capturedOptions!.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
excludeDynamicSections: true,
});
});
it("forwards append and excludeDynamicSections together", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
systemPrompt: {
append: "extra instructions",
excludeDynamicSections: true,
},
},
});
expect(capturedOptions!.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
append: "extra instructions",
excludeDynamicSections: true,
});
});
it("ignores caller-provided type/preset overrides", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
systemPrompt: {
type: "something-else",
preset: "other-preset",
append: "extra",
},
},
});
expect(capturedOptions!.systemPrompt).toEqual({
type: "preset",
preset: "claude_code",
append: "extra",
});
});
});
describe("CLAUDE_MODEL_CONFIG", () => {
let originalModelConfig: string | undefined;
beforeEach(() => {
originalModelConfig = process.env.CLAUDE_MODEL_CONFIG;
delete process.env.CLAUDE_MODEL_CONFIG;
});
afterEach(() => {
if (originalModelConfig !== undefined) {
process.env.CLAUDE_MODEL_CONFIG = originalModelConfig;
} else {
delete process.env.CLAUDE_MODEL_CONFIG;
}
});
it("passes modelOverrides as settings", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
});
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toEqual({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
});
});
it("passes availableModels as settings", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
availableModels: ["opus", "sonnet"],
});
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toEqual({
availableModels: ["opus", "sonnet"],
});
});
it("passes both modelOverrides and availableModels", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
});
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toEqual({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
availableModels: ["opus"],
});
});
it("does not add settings when env var is not set", async () => {
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toBeUndefined();
});
it("ignores env var when _meta provides settings", async () => {
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
});
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
options: {
settings: {
model: "claude-sonnet-4-5",
modelOverrides: { "claude-opus-4-6": "meta-value" },
},
},
},
},
});
// _meta settings take precedence; env var is ignored entirely
expect(capturedOptions!.settings).toEqual({
model: "claude-sonnet-4-5",
modelOverrides: { "claude-opus-4-6": "meta-value" },
});
});
it("throws on invalid JSON", async () => {
process.env.CLAUDE_MODEL_CONFIG = "not-json";
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
});
});
it("merges user-provided mcpServers with ACP mcpServers", async () => {
await agent.newSession({
cwd: "/test",

View File

@@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest";
import { resolvePermissionMode } from "../acp-agent.js";
import { describe, it, expect, vi } from "vitest";
import { resolvePermissionMode, type Logger } from "../acp-agent.js";
function mockLogger() {
const error = vi.fn<(...args: any[]) => void>();
const log = vi.fn<(...args: any[]) => void>();
const logger: Logger = { log, error };
return { logger, error, log };
}
describe("resolvePermissionMode", () => {
it("returns 'default' when no mode is provided", () => {
@@ -26,18 +33,25 @@ describe("resolvePermissionMode", () => {
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
});
it("throws on non-string values", () => {
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
expect(() => resolvePermissionMode({})).toThrow("expected a string");
it("falls back to 'default' and logs on non-string values", () => {
for (const value of [123, true, {}]) {
const { logger, error } = mockLogger();
expect(resolvePermissionMode(value, logger)).toBe("default");
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a string"));
}
});
it("throws on empty string", () => {
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
it("falls back to 'default' and logs on empty string", () => {
for (const value of ["", " "]) {
const { logger, error } = mockLogger();
expect(resolvePermissionMode(value, logger)).toBe("default");
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a non-empty string"));
}
});
it("throws on unknown mode", () => {
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
it("falls back to 'default' and logs on unknown mode", () => {
const { logger, error } = mockLogger();
expect(resolvePermissionMode("yolo", logger)).toBe("default");
expect(error).toHaveBeenCalledWith(expect.stringContaining("yolo"));
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
@@ -58,6 +59,19 @@ const MOCK_CONFIG_OPTIONS = [
description: m.description,
})),
},
{
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
type: "select",
category: "effort",
currentValue: "high",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
];
describe("session config options", () => {
@@ -67,6 +81,7 @@ describe("session config options", () => {
let createSessionSpy: ReturnType<typeof vi.fn>;
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
let setModelSpy: ReturnType<typeof vi.fn>;
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
function createMockClient(): AgentSideConnection {
return {
@@ -82,11 +97,13 @@ describe("session config options", () => {
function populateSession() {
setPermissionModeSpy = vi.fn();
setModelSpy = vi.fn();
applyFlagSettingsSpy = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: {
setPermissionMode: setPermissionModeSpy,
setModel: setModelSpy,
applyFlagSettings: applyFlagSettingsSpy,
supportedCommands: async () => [],
},
input: null,
@@ -95,6 +112,15 @@ describe("session config options", () => {
settingsManager: {},
modes: structuredClone(MOCK_MODES),
models: structuredClone(MOCK_MODELS),
modelInfos: MOCK_MODELS.availableModels.map(
(m): ModelInfo => ({
value: m.modelId,
displayName: m.name,
description: m.description,
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
}),
),
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
contextWindowSize: 200000,
};
@@ -387,6 +413,101 @@ describe("session config options", () => {
const modelOption = session.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
});
it("includes updated effort in config_option_update when model drops effort support", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: false,
},
];
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const effortOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "effort",
);
expect(effortOption).toBeUndefined();
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
});
it("clamps effort in config_option_update when new model has different supported levels", async () => {
// Set current effort to "max" which the new model won't support
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
if (effortOpt) effortOpt.currentValue = "max";
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
];
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const effortOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "effort",
);
expect(effortOption).toBeDefined();
expect(effortOption.currentValue).toBe("high");
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
});
it("preserves effort in config_option_update when new model supports same level", async () => {
// Set effort to "low" first
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
if (effortOpt) effortOpt.currentValue = "low";
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-sonnet-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const effortOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "effort",
);
expect(effortOption?.currentValue).toBe("low");
// Effort didn't change, so applyFlagSettings should NOT be called
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
});
});
describe("no config_option_update notification when using setSessionConfigOption", () => {
@@ -421,6 +542,223 @@ describe("session config options", () => {
});
});
describe("setSessionConfigOption for effort", () => {
beforeEach(() => {
populateSession();
});
it("calls applyFlagSettings with effortLevel", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "low",
});
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" });
});
it("updates effort currentValue in returned configOptions", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "medium",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
expect(effortOption?.currentValue).toBe("medium");
});
it("throws for invalid effort value", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "turbo",
}),
).rejects.toThrow("Invalid value for config option effort: turbo");
});
it("does not send config_option_update notification", async () => {
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "low",
});
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
});
it("other options are unchanged when effort is updated", async () => {
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "low",
});
const modeOption = response.configOptions.find((o) => o.id === "mode");
expect(modeOption?.currentValue).toBe("default");
const modelOption = response.configOptions.find((o) => o.id === "model");
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
});
});
describe("effort level and model switch interactions", () => {
beforeEach(() => {
populateSession();
});
it("drops effort option when switching to a model without effort support", async () => {
// Make sonnet not support effort
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: false,
},
];
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
expect(effortOption).toBeUndefined();
});
it("clears effort via applyFlagSettings when switching to a model without effort", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: false,
},
];
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
});
it("adds effort option when switching to a model that supports effort", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
// Start with sonnet (no effort) as current
session.models = { ...session.models, currentModelId: "claude-sonnet-4-5" };
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: false,
},
];
// Remove effort from current config options
session.configOptions = session.configOptions.filter((o: any) => o.id !== "effort");
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-opus-4-5",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
expect(effortOption).toBeDefined();
// No previous effort, so defaults to "high" (the API default)
expect(effortOption?.currentValue).toBe("high");
});
it("clamps effort to valid value when new model has different supported levels", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
// Set current effort to "max" (not supported by sonnet in our mock)
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
if (effortOpt) effortOpt.currentValue = "max";
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
];
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
expect(effortOption).toBeDefined();
// "max" is not in sonnet's levels, so should fall back to "high" (the API default)
expect(effortOption?.currentValue).toBe("high");
// SDK should be told about the clamped value
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
});
it("preserves effort value when new model supports the same level", async () => {
// Set effort to "low"
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "low",
});
// Switch model — both support "low"
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-5",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
expect(effortOption?.currentValue).toBe("low");
// applyFlagSettings was called once for the effort change, but not again for the model switch
expect(applyFlagSettingsSpy).toHaveBeenCalledTimes(1);
});
});
describe("bidirectional consistency", () => {
beforeEach(() => {
populateSession();
@@ -473,4 +811,391 @@ describe("session config options", () => {
);
});
});
describe("auto mode availability per model", () => {
/**
* Augment the session populated by `populateSession()` with a Haiku entry
* (no `supportsAutoMode`), Opus + Sonnet entries with `supportsAutoMode:
* true`, and seed `availableModes` so it currently includes `auto`. This
* exercises the per-model recomputation done by `applyConfigOptionValue`
* on a model switch.
*/
function setupHaikuOpusSession(currentModeId: string = "default") {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modelInfos = [
{
value: "claude-opus-4-5",
displayName: "Claude Opus",
description: "Most capable",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
supportsAutoMode: true,
},
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet",
description: "Balanced",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
supportsAutoMode: true,
},
{
value: "claude-haiku-4-5",
displayName: "Claude Haiku",
description: "Fast",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
// supportsAutoMode intentionally omitted
},
];
session.models = {
currentModelId: "claude-opus-4-5",
availableModels: [
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
{ modelId: "claude-haiku-4-5", name: "Claude Haiku", description: "Fast" },
],
};
session.modes = {
currentModeId,
availableModes: [
{
id: "auto",
name: "Auto",
description: "Use a model classifier to approve/deny permission prompts",
},
{
id: "default",
name: "Default",
description: "Standard behavior, prompts for dangerous operations",
},
{
id: "acceptEdits",
name: "Accept Edits",
description: "Auto-accept file edit operations",
},
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{
id: "dontAsk",
name: "Don't Ask",
description: "Don't prompt for permissions, deny if not pre-approved",
},
],
};
// Reflect the seeded availableModes/availableModels in configOptions so
// the pre-state matches what `createSession` would have produced for
// Opus, and `setSessionConfigOption` validation can accept the seeded
// model ids (notably the new Haiku entry).
session.configOptions = session.configOptions.map((o: any) => {
if (o.id === "mode") {
return {
...o,
currentValue: currentModeId,
options: session.modes.availableModes.map((m: any) => ({
value: m.id,
name: m.name,
description: m.description,
})),
};
}
if (o.id === "model") {
return {
...o,
currentValue: session.models.currentModelId,
options: session.models.availableModels.map((m: any) => ({
value: m.modelId,
name: m.name,
description: m.description,
})),
};
}
return o;
});
return session;
}
beforeEach(() => {
populateSession();
});
it("drops `auto` from available modes when switching to Haiku", async () => {
setupHaikuOpusSession("default");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption).toBeDefined();
const modeValues = modeOption.options.map((o: any) => o.value);
expect(modeValues).not.toContain("auto");
expect(modeValues).toEqual(
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
);
});
it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => {
setupHaikuOpusSession("auto");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
// SDK was synced to "default".
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
// current_mode_update was emitted before config_option_update so a
// client applying notifications in order observes the mode change
// before re-rendering the config-option list.
const modeUpdateIdx = sessionUpdates.findIndex(
(n) => n.update.sessionUpdate === "current_mode_update",
);
const configUpdateIdx = sessionUpdates.findIndex(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(modeUpdateIdx).toBeGreaterThanOrEqual(0);
expect(configUpdateIdx).toBeGreaterThanOrEqual(0);
expect(modeUpdateIdx).toBeLessThan(configUpdateIdx);
expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default");
// configOptions reflect the clamped mode.
const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption.currentValue).toBe("default");
});
it("re-adds `auto` when switching from Haiku back to Opus", async () => {
const session = setupHaikuOpusSession("default");
// Pretend Haiku is the current model with no `auto`.
session.models.currentModelId = "claude-haiku-4-5";
session.modes.availableModes = session.modes.availableModes.filter(
(m: any) => m.id !== "auto",
);
const modeOpt = session.configOptions.find((o: any) => o.id === "mode");
modeOpt.options = session.modes.availableModes.map((m: any) => ({
value: m.id,
name: m.name,
description: m.description,
}));
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-opus-4-5",
});
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
const modeValues = modeOption.options.map((o: any) => o.value);
expect(modeValues).toContain("auto");
// The current mode ("default") is still valid on Opus, so no
// current_mode_update should have been emitted by the model switch.
const modeUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdates).toHaveLength(0);
});
it("preserves the current mode when it remains valid after a model switch", async () => {
setupHaikuOpusSession("plan");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
// `plan` is in availableModes for both Opus and Haiku, so no clamp.
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("default");
const modeUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdates).toHaveLength(0);
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption.currentValue).toBe("plan");
});
it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => {
// Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes
// through the request/response API. The `current_mode_update` side
// effect must still fire so clients learn about the clamp regardless of
// which entry point triggered the model switch.
setupHaikuOpusSession("auto");
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-haiku-4-5",
});
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
const modeUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "current_mode_update",
);
expect(modeUpdates).toHaveLength(1);
expect((modeUpdates[0].update as any).currentModeId).toBe("default");
// setSessionConfigOption is a request/response API: it returns the new
// configOptions in the response rather than emitting a
// config_option_update notification.
const configUpdates = sessionUpdates.filter(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdates).toHaveLength(0);
const modeOption = response.configOptions.find((o: any) => o.id === "mode");
expect(modeOption).toBeDefined();
expect((modeOption as any).currentValue).toBe("default");
expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto");
});
it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => {
const session = setupHaikuOpusSession("default");
session.models.currentModelId = "claude-haiku-4-5";
session.modes.availableModes = session.modes.availableModes.filter(
(mode: any) => mode.id !== "auto",
);
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow(
"Mode auto is not available in this session",
);
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto");
expect(sessionUpdates).toHaveLength(0);
});
});
describe("ExitPlanMode permission options filtered by availableModes", () => {
let capturedPermissionRequest: any;
let permissionResponse: any;
beforeEach(() => {
capturedPermissionRequest = null;
permissionResponse = { outcome: { outcome: "cancelled" } };
// Replace the default mock client with one that captures the
// requestPermission call so we can assert on the offered options.
(agent as any).client = {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
},
requestPermission: async (params: any) => {
capturedPermissionRequest = params;
return permissionResponse;
},
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
};
populateSession();
});
it("omits the `auto` option on a model without supportsAutoMode", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
// Haiku-shaped session: availableModes does NOT include `auto`.
session.modes = {
currentModeId: "plan",
availableModes: [
{ id: "default", name: "Default", description: "Standard" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const signal = new AbortController().signal;
try {
await canUseTool(
"ExitPlanMode",
{ plan: "do stuff" },
{ signal, suggestions: undefined, toolUseID: "toolu_1" },
);
} catch {
// The mock client returns `cancelled`, which makes canUseTool throw.
// We only care about the captured requestPermission options.
}
expect(capturedPermissionRequest).not.toBeNull();
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
expect(optionIds).not.toContain("auto");
expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"]));
});
it("denies a selected `auto` option if the client did not receive that option", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modes = {
currentModeId: "plan",
availableModes: [
{ id: "default", name: "Default", description: "Standard" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const result = await canUseTool(
"ExitPlanMode",
{ plan: "do stuff" },
{ signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" },
);
expect(capturedPermissionRequest).not.toBeNull();
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
expect(optionIds).not.toContain("auto");
expect(result.behavior).toBe("deny");
expect(sessionUpdates).toHaveLength(0);
});
it("includes the `auto` option on a model with supportsAutoMode", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modes = {
currentModeId: "plan",
availableModes: [
{ id: "auto", name: "Auto", description: "Use a model classifier" },
{ id: "default", name: "Default", description: "Standard" },
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const signal = new AbortController().signal;
try {
await canUseTool(
"ExitPlanMode",
{ plan: "do stuff" },
{ signal, suggestions: undefined, toolUseID: "toolu_3" },
);
} catch {
// mock returns cancelled
}
expect(capturedPermissionRequest).not.toBeNull();
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
expect(optionIds).toContain("auto");
});
});
});

View File

@@ -86,7 +86,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
try {
await expect(
agent.unstable_resumeSession({
agent.resumeSession({
sessionId: bogusSessionId,
cwd: process.cwd(),
mcpServers: [],
@@ -135,7 +135,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
try {
await expect(
agentB.unstable_resumeSession({
agentB.resumeSession({
sessionId,
cwd: process.cwd(),
mcpServers: [],
@@ -145,4 +145,189 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
await agentB.dispose();
}
}, 30000);
// Regression test for https://github.com/zed-industries/claude-code-acp/issues/579
// The client (Zed) renders its own local slash commands — e.g. `/model` —
// by injecting user-message prompts whose text is wrapped in
// `<command-name>` / `<local-command-stdout>` markers. The Claude SDK
// persists those messages verbatim in the session transcript. Without
// filtering, `loadSession` replays the markers as user_message_chunks
// ahead of the real prompt, leaking CLI internals into the transcript.
it("ACP: loadSession does not replay local-command metadata user messages", async () => {
const recordedUserChunks: string[] = [];
const client = {
sessionUpdate: async (notification: SessionNotification) => {
if (notification.update.sessionUpdate === "user_message_chunk") {
const content = notification.update.content;
if (content.type === "text") recordedUserChunks.push(content.text);
}
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
const commandName =
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
const commandStdout =
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>";
// Step 1: create a session via the SDK, push the marker user messages
// followed by a real "hi" prompt. We bypass the ACP agent's prompt loop
// so we're only testing what the loadSession replay path does, not how
// prompts get routed.
const sessionId = randomUUID();
const input = new Pushable<SDKUserMessage>();
const q = query({
prompt: input,
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
sessionId,
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
});
await q.initializationResult();
// Zed renders its local slash commands by mixing the marker blocks into
// the user's prompt content array alongside the real text. The Claude
// SDK persists the message verbatim — including the marker blocks —
// which is what loadSession must filter when it replays.
input.push({
type: "user",
message: {
role: "user",
content: [
{ type: "text", text: commandName },
{ type: "text", text: commandStdout },
{ type: "text", text: "hi" },
],
},
session_id: sessionId,
parent_tool_use_id: null,
});
for await (const msg of q) {
if (msg.type === "result") break;
}
input.end();
q.return(undefined);
// Sanity check: the SDK transcript contains the metadata we're filtering.
const sdkMessages = await getSessionMessages(sessionId);
const sdkTexts = sdkMessages
.map((m) => {
const c = (m as { message?: { content?: unknown } }).message?.content;
if (typeof c === "string") return c;
if (Array.isArray(c))
return c
.map((b) =>
b && typeof b === "object" && "text" in b
? String((b as { text: unknown }).text)
: "",
)
.join("");
return "";
})
.join("\n");
expect(sdkTexts).toContain("<command-name>");
expect(sdkTexts).toContain("<local-command-stdout>");
expect(sdkTexts).toContain("hi");
// Step 2: load the session through the ACP agent and confirm the markers
// never reach the client as user_message_chunks, while the real "hi"
// prompt does.
const agent = new ClaudeAcpAgent(client);
try {
await agent.loadSession({
sessionId,
cwd: process.cwd(),
mcpServers: [],
});
} finally {
await agent.dispose();
}
for (const chunk of recordedUserChunks) {
expect(chunk).not.toContain("<command-name>");
expect(chunk).not.toContain("<command-message>");
expect(chunk).not.toContain("<command-args>");
expect(chunk).not.toContain("<local-command-stdout>");
expect(chunk).not.toContain("<local-command-stderr>");
}
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
}, 60000);
// Second regression: the original issue showed the markers and the real
// "hi" prompt all concatenated into a single string ending with "hi".
// loadSession must strip the marker tags in-place rather than dropping the
// whole message.
it("ACP: loadSession strips marker tags from a concatenated single-string prompt", async () => {
const recordedUserChunks: string[] = [];
const client = {
sessionUpdate: async (notification: SessionNotification) => {
if (notification.update.sessionUpdate === "user_message_chunk") {
const content = notification.update.content;
if (content.type === "text") recordedUserChunks.push(content.text);
}
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
const concatenated =
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>\n" +
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
"hi";
const sessionId = randomUUID();
const input = new Pushable<SDKUserMessage>();
const q = query({
prompt: input,
options: {
systemPrompt: { type: "preset", preset: "claude_code" },
sessionId,
settingSources: ["user", "project", "local"],
includePartialMessages: true,
},
});
await q.initializationResult();
input.push({
type: "user",
message: { role: "user", content: concatenated },
session_id: sessionId,
parent_tool_use_id: null,
});
for await (const msg of q) {
if (msg.type === "result") break;
}
input.end();
q.return(undefined);
const agent = new ClaudeAcpAgent(client);
try {
await agent.loadSession({
sessionId,
cwd: process.cwd(),
mcpServers: [],
});
} finally {
await agent.dispose();
}
expect(recordedUserChunks.length).toBeGreaterThan(0);
for (const chunk of recordedUserChunks) {
expect(chunk).not.toContain("<command-name>");
expect(chunk).not.toContain("<command-message>");
expect(chunk).not.toContain("<command-args>");
expect(chunk).not.toContain("<local-command-stdout>");
expect(chunk).not.toContain("<local-command-stderr>");
}
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
}, 60000);
});