Compare commits

..

7 Commits

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


##
[0.76.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.75.1...v0.76.0)
(2026-09-09)


### Features

* advertise recommended config values
([#1111](https://github.com/agentclientprotocol/claude-agent-acp/issues/1111))
([2b666c3](2b666c3d35))
* **deps-dev:** Bump vitest from 4.1.11 to 5.0.0
([#1099](https://github.com/agentclientprotocol/claude-agent-acp/issues/1099))
([194e195](194e195b05))
* **deps:** Bump fast-uri from 3.1.6 to 3.1.7
([#1100](https://github.com/agentclientprotocol/claude-agent-acp/issues/1100))
([d870ebd](d870ebd2fd))

---
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-09-10 01:12:57 +04:00
nikita-ashihmin
2b666c3d35 feat: advertise recommended config values (#1111)
Clients that negotiate the AIR `recommendedValue` capability receive
concrete model and effort choices instead of ambiguous default rows. The
SDK's default model is mapped only to an exact selectable model
(including equivalent context suffix spellings); unavailable
recommendations retain the legacy default. Recommendations remain
independent of explicit selections.

The adapter applies the displayed effort to the SDK at startup and on
model switches, preserving supported user pins and re-reading per-model
settings for automatic choices. Permission modes and clients without the
capability retain their existing configuration behavior. Model and
effort configuration logic is extracted into dedicated modules.

Terse SDK model labels are enriched with concrete versions from model
metadata: for example, `Fable` becomes `Fable 5.1`, `Sonnet` becomes
`Sonnet 5`, and `Opus (1M context)` becomes `Opus 5`. Custom names are
preserved, and collisions fall back to the original labels. An SDK
version guard requires review on upgrades, and an opt-in live
initialization test verifies the upstream model contract without sending
a prompt.

Validation:
- `npm run check`
- `npm run build`
- `env -u ANTHROPIC_BASE_URL npm run test:run` — 1253 passed, 28 skipped
- `RUN_INTEGRATION_TESTS=true npx vitest run
src/tests/model-presentation.test.ts` — 4 passed
- Regression coverage for unavailable model generations/context windows,
startup effort, per-model settings, unsupported effort pins, and
duplicate Opus labels
2026-09-10 01:10:31 +04:00
Andrey Bragin
45f8b34020 ci: don't wait for CI workflow when publishing preview releases, replace --dry-run with real publication (#1113) 2026-09-09 13:08:08 +02:00
Andrey Bragin
cc528fb5e9 ci: testing preview publication (#1105) 2026-09-08 14:33:32 +02:00
Andrey Bragin
0dd58685b1 ci: set up preview publishing (#1084)
Today nothing reaches npm between two release-please releases, so trying
a merged
but unreleased change means building from source. This adds a
continuously
published preview channel.

Every CI-green push to `main` that is not a release merge publishes
`<package.json version, patch bumped>-preview.N` under the `preview`
dist-tag,
tags the commit `v<version>`, and dispatches the same agent registry
update a
stable release does. With `main` at 0.73.0 that gives
`0.73.1-preview.1`,
`0.73.1-preview.2`, and so on; `N` restarts at 1 whenever a release
moves the
base. `latest` is untouched.

## Approach

- **Added to `publish.yml` rather than a new workflow.** npm binds a
trusted
publisher to one repo + workflow filename, and a package gets only one.
A
  separate file would fail OIDC and cost the stable path its publisher.
- **Gated on the `CI` workflow succeeding** (`workflow_run`), so a red
commit is
never published. release-please's release merge is excluded by the head
commit's author and subject — its `releases_created` output is not
reachable
  from a different workflow run.
- **`N` is the max of the npm registry and local git tags.** npm
reserves
versions forever, even after `unpublish`, so a reused `N` is a hard
failure;
but the registry is CDN-served and lags, which the tags cover. A
registry read
  failure aborts rather than guessing.
- **Base is patch+1, not release-please's prediction**, which moves
mid-flight
  and would reset `N` under already-published previews.
- **The version is applied to the CI working tree only**, never
committed —
`package.json` and the release-please manifest stay release-please's to
own.
- **`--tag preview` is mandatory:** `npm publish` defaults to `latest`
even for a
  semver prerelease.
- **Three jobs.** `publish-npm-preview` mirrors `publish-npm` and only
publishes
(no repo write access); `publish-tag-preview` only tags, so a failed tag
is
retryable with _Re-run failed jobs_ without re-publishing an immutable
version;
`trigger-registry-update` is shared with the stable path. Both
downstream jobs
gate on a `published` output the publish step sets, not on job status —
the tag
push happens after the publish, so a red job can still mean npm has the
  version.

## Next step

The publish is `npm publish --dry-run` for now. The `workflow_run`
trigger and
the `release` environment's protected-branch policy mean this job cannot
run off
the default branch at all, so merging this is the only way to exercise
the
trigger, the gate and the version calculation — and with `published`
unset, the
rehearsal neither tags nor touches the registry.

Once one run looks right, a follow-up drops `--dry-run` and adds
`echo "published=true" >> "$GITHUB_OUTPUT"` to that step. Nothing else
changes.
2026-09-08 14:16:37 +02:00
dependabot[bot]
d870ebd2fd feat(deps): Bump fast-uri from 3.1.6 to 3.1.7 (#1100)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.6 to
3.1.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastify/fast-uri/releases">fast-uri's
releases</a>.</em></p>
<blockquote>
<h2>v3.1.7</h2>
<h2>⚠️ Security Warning</h2>
<p>This is a security release that fixes the following high-severity
security advisories:</p>
<ul>
<li><a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-qw65-cvwx-89v3">GHSA-qw65-cvwx-89v3</a>
— authority injection via an unvalidated port in
<code>serialize()</code></li>
<li><a
href="https://github.com/fastify/fast-uri/security/advisories/GHSA-58mr-gqgx-xq4g">GHSA-58mr-gqgx-xq4g</a>
— host confusion via unbalanced or misplaced IP-literal brackets</li>
</ul>
<p>Users of the v3.x release line should upgrade to v3.1.7.</p>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/fastify/fast-uri/compare/v3.1.6...v3.1.7">https://github.com/fastify/fast-uri/compare/v3.1.6...v3.1.7</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="412e40abd4"><code>412e40a</code></a>
Bumped v3.1.7</li>
<li><a
href="9f4c943e4d"><code>9f4c943</code></a>
fix: backport port and IP-literal validation to v3.x (<a
href="https://redirect.github.com/fastify/fast-uri/issues/216">#216</a>)</li>
<li><a
href="1eb3ce436f"><code>1eb3ce4</code></a>
fix: treat unterminated bracket hosts as reg-names again (<a
href="https://redirect.github.com/fastify/fast-uri/issues/214">#214</a>)</li>
<li>See full diff in <a
href="https://github.com/fastify/fast-uri/compare/v3.1.6...v3.1.7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fast-uri&package-manager=npm_and_yarn&previous-version=3.1.6&new-version=3.1.7)](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-09-07 12:21:15 -07:00
dependabot[bot]
194e195b05 feat(deps-dev): Bump vitest from 4.1.11 to 5.0.0 (#1099)
Bumps
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
from 4.1.11 to 5.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<p>Vitest 5 is officially out! This release focuses on performance and
brings a lot of new features while fixing long-standing bugs. See our <a
href="https://vitest.dev/blog/vitest-5.html">blog post</a> for the
official announcement.</p>
<h3>   🚨 Breaking Changes</h3>
<ul>
<li>Replace <code>loupe.inspect</code> with pretty-format  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Claude Opus 5 (1M context)</strong> and <strong>OpenAI
Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9609">vitest-dev/vitest#9609</a>
<a href="https://github.com/vitest-dev/vitest/commit/3f802da4b"><!-- raw
HTML omitted -->(3f802)<!-- raw HTML omitted --></a></li>
<li>Remove quotes from string values in <code>test.for/each</code> title
<code>$</code> variable (take 2)  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10170">vitest-dev/vitest#10170</a>
<a href="https://github.com/vitest-dev/vitest/commit/04d37e9d7"><!-- raw
HTML omitted -->(04d37)<!-- raw HTML omitted --></a></li>
<li>Default <code>attachmentsDir</code> from
<code>.vitest-attachements/</code> to <code>.vitest/attachments/</code>
 -  by <a
href="https://github.com/MdSadiqMd"><code>@​MdSadiqMd</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10186">vitest-dev/vitest#10186</a>
<a href="https://github.com/vitest-dev/vitest/commit/1ba7338c3"><!-- raw
HTML omitted -->(1ba73)<!-- raw HTML omitted --></a></li>
<li>Remove <code>sequential</code> test/suite options in favor of
<code>concurrent</code>  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a> and
<strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10198">vitest-dev/vitest#10198</a>
<a href="https://github.com/vitest-dev/vitest/commit/9229f2edc"><!-- raw
HTML omitted -->(9229f)<!-- raw HTML omitted --></a></li>
<li>Represent locator as an object instead of a string  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10212">vitest-dev/vitest#10212</a>
<a href="https://github.com/vitest-dev/vitest/commit/80f07edf6"><!-- raw
HTML omitted -->(80f07)<!-- raw HTML omitted --></a></li>
<li>Inline <code>expect</code> package  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10221">vitest-dev/vitest#10221</a>
<a href="https://github.com/vitest-dev/vitest/commit/ad16223e7"><!-- raw
HTML omitted -->(ad162)<!-- raw HTML omitted --></a></li>
<li>Remove deprecated entry points  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10222">vitest-dev/vitest#10222</a>
<a href="https://github.com/vitest-dev/vitest/commit/994c6ddb9"><!-- raw
HTML omitted -->(994c6)<!-- raw HTML omitted --></a></li>
<li>Require Node.js 22 and Vite 6.4  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10178">vitest-dev/vitest#10178</a>
<a href="https://github.com/vitest-dev/vitest/commit/3876283e8"><!-- raw
HTML omitted -->(38762)<!-- raw HTML omitted --></a></li>
<li>Fail <code>expect.poll</code> when function didn't resolve in time
 -  by <a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>
and <strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10233">vitest-dev/vitest#10233</a>
<a href="https://github.com/vitest-dev/vitest/commit/4df048c11"><!-- raw
HTML omitted -->(4df04)<!-- raw HTML omitted --></a></li>
<li>Throw an error if hoistable methods are outside the top level scope
 -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10460">vitest-dev/vitest#10460</a>
<a href="https://github.com/vitest-dev/vitest/commit/d0b4fddcb"><!-- raw
HTML omitted -->(d0b4f)<!-- raw HTML omitted --></a></li>
<li><code>toHaveTextContent</code> is strict, add
<code>toMatchTextContent</code> as alternative  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10473">vitest-dev/vitest#10473</a>
<a href="https://github.com/vitest-dev/vitest/commit/18f303079"><!-- raw
HTML omitted -->(18f30)<!-- raw HTML omitted --></a></li>
<li>Don't lookup config file from ancestor directories  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>OpenAI Codex</strong> and <strong>Hiroshi Ogawa</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10428">vitest-dev/vitest#10428</a>
<a href="https://github.com/vitest-dev/vitest/commit/945d9090e"><!-- raw
HTML omitted -->(945d9)<!-- raw HTML omitted --></a></li>
<li>Inline <code>@vitest/runner</code> package, do not publish it
anymore  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10511">vitest-dev/vitest#10511</a>
<a href="https://github.com/vitest-dev/vitest/commit/6d6e46b1e"><!-- raw
HTML omitted -->(6d6e4)<!-- raw HTML omitted --></a></li>
<li>Allow mutating happy-dom/jsdom window object  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10373">vitest-dev/vitest#10373</a>
<a href="https://github.com/vitest-dev/vitest/commit/206e8cff8"><!-- raw
HTML omitted -->(206e8)<!-- raw HTML omitted --></a></li>
<li>Expose <code>concurrencyId</code>/<code>workerId</code> on
TestModule's diagnostics, make id 1-based  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10516">vitest-dev/vitest#10516</a>
<a href="https://github.com/vitest-dev/vitest/commit/bdd985433"><!-- raw
HTML omitted -->(bdd98)<!-- raw HTML omitted --></a></li>
<li>Add <code>screenshotDirectory</code> config to
<code>browser.expect.toMatchScreenshot</code>  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10592">vitest-dev/vitest#10592</a>
<a href="https://github.com/vitest-dev/vitest/commit/a60ded0fb"><!-- raw
HTML omitted -->(a60de)<!-- raw HTML omitted --></a></li>
<li>Update <code>@sinonjs/fake-timers</code> and support mocking
<code>Temporal</code>  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(gpt-5.6-sol)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10654">vitest-dev/vitest#10654</a>
<a href="https://github.com/vitest-dev/vitest/commit/f8b1532fe"><!-- raw
HTML omitted -->(f8b15)<!-- raw HTML omitted --></a></li>
<li>Remove webdriverio package  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10675">vitest-dev/vitest#10675</a>
<a href="https://github.com/vitest-dev/vitest/commit/5fed68f72"><!-- raw
HTML omitted -->(5fed6)<!-- raw HTML omitted --></a></li>
<li>Clear mocks by default before each test  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10613">vitest-dev/vitest#10613</a>
<a href="https://github.com/vitest-dev/vitest/commit/0f6463bf2"><!-- raw
HTML omitted -->(0f646)<!-- raw HTML omitted --></a></li>
<li>Don't emit localStorage warnings on Node 26, fail gracefully when
worker fails to start  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10293">vitest-dev/vitest#10293</a>
<a href="https://github.com/vitest-dev/vitest/commit/334edef92"><!-- raw
HTML omitted -->(334ed)<!-- raw HTML omitted --></a></li>
<li>Separate config resolution from the server creation  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10554">vitest-dev/vitest#10554</a>
<a href="https://github.com/vitest-dev/vitest/commit/1c0ec3444"><!-- raw
HTML omitted -->(1c0ec)<!-- raw HTML omitted --></a></li>
<li>Inline projects extend the root config by default  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10750">vitest-dev/vitest#10750</a>
<a href="https://github.com/vitest-dev/vitest/commit/fec001ad3"><!-- raw
HTML omitted -->(fec00)<!-- raw HTML omitted --></a></li>
<li>Enable mocking Temporal without fake timers  -  by <a
href="https://github.com/fabon-f"><code>@​fabon-f</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(gpt-5.6-sol)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10757">vitest-dev/vitest#10757</a>
<a href="https://github.com/vitest-dev/vitest/commit/ac2d46b42"><!-- raw
HTML omitted -->(ac2d4)<!-- raw HTML omitted --></a></li>
<li>Support nested projects  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10846">vitest-dev/vitest#10846</a>
<a href="https://github.com/vitest-dev/vitest/commit/ec367cf2a"><!-- raw
HTML omitted -->(ec367)<!-- raw HTML omitted --></a></li>
<li>Use <code>&gt;</code> as separator in <code>-t</code>, calculate
<code>only</code> once  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10686">vitest-dev/vitest#10686</a>
<a href="https://github.com/vitest-dev/vitest/commit/a0b20bc86"><!-- raw
HTML omitted -->(a0b20)<!-- raw HTML omitted --></a></li>
<li>Fail the test when an asynchronous assertion is not awaited  -  by
<a href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a>
in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10868">vitest-dev/vitest#10868</a>
<a href="https://github.com/vitest-dev/vitest/commit/86d4a9da9"><!-- raw
HTML omitted -->(86d4a)<!-- raw HTML omitted --></a></li>
<li>Share the Vite server between inline projects  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10848">vitest-dev/vitest#10848</a>
<a href="https://github.com/vitest-dev/vitest/commit/d87c96ee4"><!-- raw
HTML omitted -->(d87c9)<!-- raw HTML omitted --></a></li>
<li>Parse files statically in vitest list by default  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11088">vitest-dev/vitest#11088</a>
<a href="https://github.com/vitest-dev/vitest/commit/51e949416"><!-- raw
HTML omitted -->(51e94)<!-- raw HTML omitted --></a></li>
<li><strong>benchmark</strong>:
<ul>
<li>Rewrite the public API  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10113">vitest-dev/vitest#10113</a>
<a href="https://github.com/vitest-dev/vitest/commit/19f6e8947"><!-- raw
HTML omitted -->(19f6e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>browser</strong>:
<ul>
<li>Iframe scale  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9745">vitest-dev/vitest#9745</a>
<a href="https://github.com/vitest-dev/vitest/commit/b639852cc"><!-- raw
HTML omitted -->(b6398)<!-- raw HTML omitted --></a></li>
<li>Enable <code>locators.exact</code> by default  -  by <a
href="https://github.com/sheremet-va"><code>@​sheremet-va</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10430">vitest-dev/vitest#10430</a>
<a href="https://github.com/vitest-dev/vitest/commit/e203202f9"><!-- raw
HTML omitted -->(e2032)<!-- raw HTML omitted --></a></li>
<li>Require <code>sessionId</code> for orchestrator html request  -  by
<a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenAI Codex</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10522">vitest-dev/vitest#10522</a>
<a href="https://github.com/vitest-dev/vitest/commit/79b7d8fcc"><!-- raw
HTML omitted -->(79b7d)<!-- raw HTML omitted --></a></li>
<li>Save failure screenshots in <code>attachmentsDir</code>  -  by <a
href="https://github.com/macarie"><code>@​macarie</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10917">vitest-dev/vitest#10917</a>
<a href="https://github.com/vitest-dev/vitest/commit/3b5bbd8b4"><!-- raw
HTML omitted -->(3b5bb)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>coverage</strong>:
<ul>
<li><code>include/exclude</code> globs too eager  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9818">vitest-dev/vitest#9818</a>
<a href="https://github.com/vitest-dev/vitest/commit/edacb0fd4"><!-- raw
HTML omitted -->(edacb)<!-- raw HTML omitted --></a></li>
<li>Allow <code>thresholds.perFile</code> to accept an object  -  by <a
href="https://github.com/vladlenskiy"><code>@​vladlenskiy</code></a> and
<a href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/10190">vitest-dev/vitest#10190</a>
<a href="https://github.com/vitest-dev/vitest/commit/13b78d98b"><!-- raw
HTML omitted -->(13b78)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>expect</strong>:
<ul>
<li>Fix <code>toThrow(&quot;&quot;)</code> behavior by reverting <a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/6710">#6710</a>
 -  by <a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>
in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/9643">vitest-dev/vitest#9643</a>
and <a
href="https://redirect.github.com/vitest-dev/vitest/issues/6710">vitest-dev/vitest#6710</a>
<a href="https://github.com/vitest-dev/vitest/commit/6c3e4bdbf"><!-- raw
HTML omitted -->(6c3e4)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>mocker</strong>:
<ul>
<li>Deserialize automock as automock  -  by <a
href="https://github.com/nami8824"><code>@​nami8824</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10192">vitest-dev/vitest#10192</a>
<a href="https://github.com/vitest-dev/vitest/commit/2f892712d"><!-- raw
HTML omitted -->(2f892)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporters</strong>:
<ul>
<li><code>blob</code> reporter and <code>--merge-reports</code> default
to <code>.vitest/blob/</code>  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10232">vitest-dev/vitest#10232</a>
<a href="https://github.com/vitest-dev/vitest/commit/d22b029ae"><!-- raw
HTML omitted -->(d22b0)<!-- raw HTML omitted --></a></li>
<li>Write json and junit reporter output files to <code>.vitest</code>
by default  -  by <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong>, <strong>OpenCode (gpt-5.6-sol)</strong>
and <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/10621">vitest-dev/vitest#10621</a>
<a href="https://github.com/vitest-dev/vitest/commit/58577290a"><!-- raw
HTML omitted -->(58577)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="f441c6fab2"><code>f441c6f</code></a>
chore: release v5.0.0 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11130">#11130</a>)</li>
<li><a
href="d46a747226"><code>d46a747</code></a>
fix: treat test.describe as a suite during static collection (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11128">#11128</a>)</li>
<li><a
href="584cf30843"><code>584cf30</code></a>
fix: add a warning if inline project has duplicate plugins due to
unexpected ...</li>
<li><a
href="f08ce4b714"><code>f08ce4b</code></a>
fix: apply queued mocks from doMock() in queue order (fixes <a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/10706">#10706</a>)
(<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11127">#11127</a>)</li>
<li><a
href="897f51fd24"><code>897f51f</code></a>
chore: release v5.0.0-rc.4 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11107">#11107</a>)</li>
<li><a
href="1339b0655d"><code>1339b06</code></a>
chore(deps): update all non-major dependencies (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11104">#11104</a>)</li>
<li><a
href="51e9494166"><code>51e9494</code></a>
feat!: parse files statically in vitest list by default (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11088">#11088</a>)</li>
<li><a
href="2122ffdfb4"><code>2122ffd</code></a>
fix: propagate --maxWorkers to projects (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11102">#11102</a>)</li>
<li><a
href="dc10f5f8fb"><code>dc10f5f</code></a>
fix(browser): report the action error when a task times out (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11101">#11101</a>)</li>
<li><a
href="d4fe1986fe"><code>d4fe198</code></a>
feat: promote clearCache out of experimental (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11086">#11086</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=vitest&package-manager=npm_and_yarn&previous-version=4.1.11&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-09-07 12:20:39 -07:00
25 changed files with 2723 additions and 815 deletions

View File

@@ -5,12 +5,20 @@ on:
- main
workflow_dispatch:
inputs:
channel:
description: Which channel to publish
required: true
default: stable
type: choice
options:
- stable
- preview
ref:
description: Tag or commit to publish
required: true
type: string
publish_npm:
description: Publish the package before updating the registry
description: Publish the package before updating the registry (stable only)
required: true
default: true
type: boolean
@@ -54,7 +62,7 @@ jobs:
name: Publish to npm
runs-on: ubuntu-latest
needs: [release-please]
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
environment: release # Optional: for enhanced security
permissions:
contents: read
@@ -72,9 +80,130 @@ jobs:
- run: npm run build
- run: npm publish
# Same shape as publish-npm. Tagging is a separate job, so nothing here needs
# write access to the repository.
publish-npm-preview:
name: Publish preview to npm
runs-on: ubuntu-latest
# Every push to main gets a preview without waiting for CI or release-please,
# except release-please's own release merge, which publishes a stable version.
# Check the commit author and the generated release subject to avoid publishing
# the same tree twice; either match is enough to skip the preview.
if: >-
${{
(github.event_name == 'workflow_dispatch' && inputs.channel == 'preview') ||
(github.event_name == 'push'
&& github.event.head_commit.author.name != 'acp-release-bot[bot]'
&& !startsWith(github.event.head_commit.message, 'chore(main): release '))
}}
# Same environment as the stable job, so the npm trusted-publisher binding holds.
# It carries no required reviewers, so previews never wait for an approval.
environment: release
permissions:
contents: read
id-token: write # npm OIDC trusted publishing
timeout-minutes: 15
# Serialise previews so two pushes cannot read the same registry state and compute
# the same -preview.N. Job-level rather than workflow-level: a workflow-level group
# would also serialise release-please, and cancelling a queued release-please run
# means a release PR that silently stops updating. The trade-off is that GitHub
# keeps only one run pending per group, so a third push landing while one preview
# runs and another waits drops the waiting one — that commit gets no preview, but a
# version is never reused.
concurrency:
group: publish-npm-preview
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
# Publish the exact pushed commit, or the explicitly requested manual ref.
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
# Brings the tags the version calculation reads as its floor. The repo is a
# few megabytes packed, so a full fetch is cheap and sidesteps every
# shallow-clone tag caveat.
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
# Ahead of the version bump, so this still validates the committed lockfile.
- run: npm ci
- name: Compute the preview version
id: preview
run: |
version="$(node scripts/next-preview-version.mjs)"
sha="$(git rev-parse HEAD)"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "Preview \`$version\` from \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
- name: Apply the version to the working tree only
# Never committed: package.json and .release-please-manifest.json on main stay
# release-please's to own, and this only changes what goes into the tarball.
# `npm version` keeps package-lock.json's version fields in step, and
# --no-git-tag-version skips every git operation.
run: npm version "${{ steps.preview.outputs.version }}" --no-git-tag-version
- run: npm run build
- name: Publish
id: publish
# --tag is mandatory: npm publish defaults to `latest` even for a semver
# prerelease, which would point every plain `npm install` at a preview.
run: |
npm publish --tag preview
echo "published=true" >> "$GITHUB_OUTPUT"
outputs:
published: ${{ steps.publish.outputs.published }}
version: ${{ steps.preview.outputs.version }}
sha: ${{ steps.preview.outputs.sha }}
publish-tag-preview:
name: Tag the published preview
runs-on: ubuntu-latest
needs: publish-npm-preview
if: ${{ needs.publish-npm-preview.outputs.published == 'true' }}
environment: release
permissions:
contents: write # create refs/tags/v<version>
timeout-minutes: 5
steps:
# A re-run carries over the outputs of jobs it did not re-run. If that ever stops
# holding, fail with the manual recipe rather than tagging the wrong commit.
- name: Check the publish handed over a version and a commit
env:
VERSION: ${{ needs.publish-npm-preview.outputs.version }}
SHA: ${{ needs.publish-npm-preview.outputs.sha }}
run: |
if [ -z "$VERSION" ] || [ -z "$SHA" ]; then
echo "::error::publish-npm-preview reported version='$VERSION' sha='$SHA'." \
"Tag it by hand — see docs/RELEASES.md, 'A preview published but the" \
"commit was not tagged'."
exit 1
fi
- name: Create the tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.publish-npm-preview.outputs.version }}
SHA: ${{ needs.publish-npm-preview.outputs.sha }}
run: |
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/v$VERSION" \
-f sha="$SHA"
echo "Tagged \`v$VERSION\` at \`$SHA\`" >> "$GITHUB_STEP_SUMMARY"
trigger-registry-update:
needs: publish-npm
if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }}
needs: [publish-npm, publish-npm-preview]
# Both channels dispatch this: the registry has its own handling for preview
# versions. The two never fire together — a release merge publishes stable and
# skips the preview, and every other push does the reverse — so the registry sees
# exactly one dispatch per published version. The payload is deliberately
# unchanged: it names no version, and the registry resolves what it needs itself.
if: >-
${{
always() && (
needs.publish-npm.result == 'success'
|| needs.publish-npm-preview.outputs.published == 'true'
|| (github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.publish_npm)
)
}}
runs-on: ubuntu-latest
environment: release
permissions: {}

View File

@@ -1,3 +1,3 @@
{
".": "0.75.1"
".": "0.76.0"
}

View File

@@ -28,7 +28,7 @@ patch, and `chore:`/`ci:`/`test:` and friends do not release at all.
## Releasing
Releases are fully automated by release-please. There is no manual release
Stable releases are fully automated by release-please. There is no manual release
workflow, and the version is never chosen by hand — it follows from the commit
history.
@@ -38,5 +38,11 @@ gh pr merge <pr-number> --squash
```
The preflight is the guard-list as code; if it exits non-zero, follow what it
prints rather than merging. Full runbook, including how to recover a stalled
release: [`docs/RELEASES.md`](docs/RELEASES.md).
prints rather than merging.
Every _other_ push to `main` publishes a preview to npm — `0.73.1-preview.4` and
so on — under the `preview` dist-tag, tags the commit it came from, and updates
the agent registry the same way a release does. Only `latest` is reserved for
real releases. So anything merged to `main` is published within minutes; there is
no staging branch. Full runbook, including how to recover a stalled release:
[`docs/RELEASES.md`](docs/RELEASES.md).

View File

@@ -1,5 +1,14 @@
# Changelog
## [0.76.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.75.1...v0.76.0) (2026-09-09)
### Features
* advertise recommended config values ([#1111](https://github.com/agentclientprotocol/claude-agent-acp/issues/1111)) ([2b666c3](https://github.com/agentclientprotocol/claude-agent-acp/commit/2b666c3d35566c60edafc5050d23cf2ce7ea1e95))
* **deps-dev:** Bump vitest from 4.1.11 to 5.0.0 ([#1099](https://github.com/agentclientprotocol/claude-agent-acp/issues/1099)) ([194e195](https://github.com/agentclientprotocol/claude-agent-acp/commit/194e195b057497311558fea2a5fbd1b4953ea29d))
* **deps:** Bump fast-uri from 3.1.6 to 3.1.7 ([#1100](https://github.com/agentclientprotocol/claude-agent-acp/issues/1100)) ([d870ebd](https://github.com/agentclientprotocol/claude-agent-acp/commit/d870ebd2fd14bda95f81ec4b86d76ef4ae258005))
## [0.75.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.75.0...v0.75.1) (2026-09-05)

View File

@@ -18,10 +18,19 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
- Client MCP servers
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md)
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md)
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/recommended-config-values-extension.md)
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md)
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
To try changes that have landed on `main` but are not released yet, install from the
`preview` channel — every push to `main` publishes one. See
[`docs/RELEASES.md`](docs/RELEASES.md#preview-releases).
```sh
npm install @agentclientprotocol/claude-agent-acp@preview
```
### Subagent sessions
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs

View File

@@ -12,6 +12,10 @@ publishes to npm, and dispatches a version update to the agent registry.
There is no manual release button, and versions are never typed in by hand: the
version is an output of the commit history, not an input.
Every _other_ push to `main` publishes a preview instead — see
[Preview releases](#preview-releases). Anything merged to `main` is on npm within
minutes; there is no staging branch.
## Releasing
```sh
@@ -41,6 +45,85 @@ gh release view "v<version>"
npm view "@agentclientprotocol/claude-agent-acp@<version>"
```
## Preview releases
Every push to `main` that is not a release merge publishes a preview from the
same workflow. There is no GitHub release — only an npm publish under the
`preview` dist-tag, a `v<version>` tag on the commit it came from, and the same
agent registry update a stable release dispatches, since the registry has its own
handling for preview versions.
Those are three jobs, in that order: `publish-npm-preview` mirrors `publish-npm`
and does nothing but publish; `publish-tag-preview` creates the tag; and
`trigger-registry-update` is shared with the stable path. The tag is a separate
job so that a tag failure can be retried on its own with **Re-run failed jobs**
— re-running the publish is not an option, because npm versions are immutable and
publishing the same one twice fails outright.
Both downstream jobs are gated on a `published` output that the publish step
sets only after `npm publish --tag preview` succeeds. This ensures tagging and
the registry update run only after the version has been published to npm.
A stable and a preview dispatch can never collide — a release merge publishes
stable and skips the preview, every other push does the reverse — so the registry
sees exactly one dispatch per published version.
```sh
npm install @agentclientprotocol/claude-agent-acp@preview
npm view @agentclientprotocol/claude-agent-acp dist-tags
git ls-remote --tags origin 'refs/tags/*preview*'
```
The version is the `package.json` version with the patch incremented, plus
`-preview.N`: with `main` at 0.73.0 the previews are `0.73.1-preview.1`,
`0.73.1-preview.2`, and so on. `N` restarts at 1 whenever release-please moves
`package.json`, which keeps the sequence monotonic whichever way the next release
goes — a patch release makes the next base 0.73.2, a minor makes it 0.74.1, and
both sort above every `0.73.1-preview.*`.
`0.73.1-preview.4` is **not** a promise that 0.73.1 will ship. The base is a
patch bump because that is the only choice depending solely on `package.json`,
which release-please only ever increases. Using release-please's predicted next
version would read better but that prediction moves mid-flight: a `fix:` opens a
0.73.1 release PR, a later `feat:` moves it to 0.74.0, and `N` would reset under
previews that were already published.
`N` comes from [`scripts/next-preview-version.mjs`](../scripts/next-preview-version.mjs),
which takes the larger of two sources. The npm registry says what is taken — npm
versions are immutable and stay reserved even after `npm unpublish`, so reusing
one is a hard failure — but it is CDN-served and can lag a publish by minutes.
The git tags this job writes are strongly consistent and cover that window. The
job publishes before it tags, so a version can exist on npm without a tag but
never the reverse; that is why a registry read failure aborts the run rather than
falling back to the tags alone.
Two pushes landing together cannot collide, because the job takes a concurrency
group. GitHub keeps only one run pending per group, so a third push arriving
while one preview runs and another waits drops the waiting one — that commit
simply gets no preview.
`latest` stays put because the job passes `npm publish --tag preview`. Without
it npm would move `latest` onto the preview: `--tag` defaults to `latest` even
for a semver prerelease. Right after a release the `preview` dist-tag can name a
version _below_ `latest` until the next push lands; that is cosmetic.
Release merges are excluded by checking the head commit's author and the subject
release-please generates. Both are checked, either is enough, and the cost of a
miss is one wasted version number plus a `preview` tag briefly pointing at
already-released code — `latest` is untouched. Previews start directly on pushes
to `main`, without waiting for CI or the `release-please` job. The commit checks
let previews run independently of release-please's outputs.
To publish a preview by hand from any commit:
```sh
gh workflow run publish.yml --ref main \
-f channel=preview -f ref=<commit-or-branch> -f publish_npm=false
```
`--ref main` is required: the `release` environment only accepts protected
branches, so a dispatch from anywhere else is rejected before the job starts.
## How the version is chosen
Squash merges use the PR title as the commit subject, so the PR title decides the
@@ -64,6 +147,14 @@ Note that `config-file` only takes effect while the workflow does **not** pass a
`release-type` input to the action — with `release-type` set, the action ignores
the config entirely. The release type is declared inside the config instead.
`release-type` also switches release-please from `Manifest.fromManifest` to
`Manifest.fromConfig`, which is a second and sharper reason never to set it. On
the manifest path the previous release is found by an exact string match against
the version in [`.release-please-manifest.json`](../.release-please-manifest.json),
which is why the `v<x>-preview.<n>` tags are invisible to it. On the config path
release-please instead sorts every candidate tag and release descending and takes
the highest — and there the preview tags _would_ be candidates.
Because the config is what is read, it also has to say
`"include-component-in-tag": false`. Left at its default, release-please derives a
component from the package name and tags `claude-agent-acp-vX.Y.Z` instead of
@@ -108,6 +199,31 @@ npm versions are immutable. If the package already published and only the
registry update failed, pass `-f publish_npm=false` so the run skips publishing
and only re-dispatches the registry update.
### A preview published but the commit was not tagged
Only the publish is irreversible, so re-run just the tag job:
```sh
gh run rerun <run-id> --failed
```
Or **Re-run failed jobs** on the run in the web or mobile UI. This re-runs
`publish-tag-preview` alone and leaves the successful publish untouched, which
matters because re-publishing an immutable npm version would fail.
If the re-run reports that it received no version or commit, the run's carried
over job outputs are gone and it cannot tag anything safely. Do it by hand
instead, taking the version from the publish job's log:
```sh
gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner)/git/refs" \
-f ref="refs/tags/v<version>" -f sha="<commit-sha>"
```
Either way nothing is broken in the meantime: the next preview still picks the
right `N` once the registry CDN catches up. The tag is how that number is known
immediately.
## Credentials
| Secret | Used for |
@@ -117,3 +233,9 @@ and only re-dispatches the registry update.
Publishing to npm uses OIDC trusted publishing, so there is no npm token. All
release jobs run in the `release` environment.
npm binds a trusted publisher to one repository, one **workflow filename** and
one environment, and a package may only have one such binding. That is why
preview publishing is another job inside `publish.yml` rather than a workflow of
its own: a separate file would fail to authenticate, and registering it would
cost the stable path its publisher.

View File

@@ -0,0 +1,118 @@
# Recommended config values extension
This document defines the experimental AIR `recommendedValue` session configuration extension
implemented by `claude-agent-acp`. It lets a client show concrete model and effort choices without
an ambiguous `default` row while preserving legacy config options for every client that does not
opt in.
Permission modes and all other session config options are outside this extension and remain
unchanged.
## Capability negotiation
A client opts in through the common AIR capability list in `initialize`:
```json
{
"clientCapabilities": {
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"capabilities": ["recommendedValue"]
}
}
}
}
}
```
The adapter enables the extension only when the AIR version is a finite integer greater than or
equal to `1` and `capabilities` contains the singular `recommendedValue` capability. Otherwise it
preserves the existing `default` rows, current values, and metadata exactly. The adapter advertises
the same capability in its top-level initialize-response `_meta.jetbrains.air.capabilities` list.
## Config option metadata
For each selector transformed by the extension, the adapter writes the concrete recommendation
through the common AIR metadata envelope at `_meta.jetbrains.air.recommendedValue`:
```json
{
"id": "model",
"type": "select",
"currentValue": "sonnet",
"options": [
{ "value": "opus", "name": "Claude Opus" },
{ "value": "sonnet", "name": "Claude Sonnet" },
{ "value": "haiku", "name": "Claude Haiku" }
],
"_meta": {
"jetbrains": {
"air": {
"version": 1,
"recommendedValue": "sonnet"
}
}
}
}
```
`recommendedValue` always names one of that selector's advertised option values. It is independent
from `currentValue`: a user's explicit selection remains current even when the SDK recommends a
different value.
## Model behavior
The SDK's `default` model entry may carry a `resolvedModel` identifying the model currently
recommended by Claude. The adapter matches that identifier exactly to a selectable named model
(accepting equivalent `-1m` and `[1m]` suffix spellings),
removes the `default` row, and advertises the named value in
`_meta.jetbrains.air.recommendedValue`. When the session itself is still using the SDK default, the
same concrete value is presented as `currentValue`.
If the SDK recommendation cannot be mapped to an advertised named model, the adapter retains the
legacy `default` row for that selector and omits `recommendedValue`. This prevents a client from
receiving a recommendation or current value it cannot select.
Terse SDK labels that match their underlying model family include the concrete version derived
from model metadata: for example, `Sonnet` becomes `Sonnet 5`, `Fable` becomes `Fable 5.1`, and
`Claude Haiku` becomes `Claude Haiku 4.5`.
Context suffixes such as `Opus (1M context)` are omitted from the label because the context remains
in the description. Custom labels are preserved, and normalization falls back to the original
labels if two selectable entries would otherwise collide. This presentation also applies to
legacy clients.
## Effort behavior
When the current model supports effort selection, the adapter removes the `default` effort row and
advertises `medium` as the recommendation. An existing explicit or settings-derived effort remains
the `currentValue`; an absent or legacy `default` current effort is presented as `medium`.
The adapter applies the displayed effort to the SDK on session creation and model switches, so
the concrete selection reflects the effort actually used. Explicit SDK `options.effort` and ACP
picker choices remain pinned across model switches while supported. Otherwise each switch
re-reads the new model's settings before falling back to the recommendation. Switching to a
model without effort support clears the flag override. Legacy clients continue to let the SDK
resolve automatic effort.
If effort synchronization fails after the model has already switched, the adapter still reports
the new model but never presents the unapplied effort as current. It retains the last successfully
applied value when that value is selectable for the new model; otherwise it temporarily omits the
effort selector until a later successful switch can rebuild truthful state.
If a future model exposes effort choices without `medium`, the first SDK-advertised effort level is
used so both `recommendedValue` and `currentValue` remain valid option values.
## SDK upgrade check
The ordinary test suite pins the SDK version whose Opus label was verified. When updating the
SDK, first run the live contract test in an authenticated environment:
```sh
RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts
```
Review any changes to the available Opus entries and their labels before updating the version
guard and, if necessary, the normalization. The live test only initializes the SDK; it sends no
model prompt.

424
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.75.1",
"version": "0.76.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.75.1",
"version": "0.76.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0",
@@ -29,7 +29,7 @@
"prettier": "3.9.6",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.0"
},
"engines": {
"node": ">=22"
@@ -479,19 +479,20 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.147.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz",
"integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==",
"version": "0.148.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
"integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz",
"integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz",
"integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==",
"cpu": [
"arm"
],
@@ -501,14 +502,15 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz",
"integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz",
"integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==",
"cpu": [
"arm64"
],
@@ -518,14 +520,15 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz",
"integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz",
"integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==",
"cpu": [
"arm64"
],
@@ -535,14 +538,15 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz",
"integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz",
"integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==",
"cpu": [
"x64"
],
@@ -552,14 +556,15 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz",
"integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz",
"integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==",
"cpu": [
"x64"
],
@@ -569,14 +574,15 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz",
"integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz",
"integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==",
"cpu": [
"arm"
],
@@ -586,116 +592,141 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz",
"integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz",
"integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz",
"integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz",
"integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz",
"integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz",
"integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz",
"integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz",
"integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz",
"integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz",
"integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz",
"integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz",
"integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz",
"integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz",
"integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==",
"cpu": [
"arm64"
],
@@ -705,14 +736,15 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz",
"integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz",
"integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==",
"cpu": [
"arm64"
],
@@ -722,14 +754,15 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz",
"integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz",
"integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==",
"cpu": [
"x64"
],
@@ -739,6 +772,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
@@ -748,7 +782,8 @@
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
@@ -756,13 +791,6 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz",
@@ -1080,34 +1108,17 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -1125,74 +1136,27 @@
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"node_modules/@vitest/mocker/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1432,13 +1396,6 @@
"node": ">= 0.6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -1538,6 +1495,7 @@
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -2019,9 +1977,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@@ -2157,6 +2115,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -2487,6 +2446,7 @@
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -2524,6 +2484,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2545,6 +2506,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2566,6 +2528,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2587,6 +2550,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2608,6 +2572,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2624,11 +2589,15 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2645,11 +2614,15 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2666,11 +2639,15 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2687,11 +2664,15 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2713,6 +2694,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2734,6 +2716,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -2759,9 +2742,9 @@
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2873,6 +2856,7 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -3068,19 +3052,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/picomatch": {
"version": "4.0.7",
@@ -3106,9 +3084,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
@@ -3125,8 +3103,9 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.17",
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3242,13 +3221,14 @@
}
},
"node_modules/rolldown": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz",
"integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
"integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.147.0",
"@oxc-project/types": "=0.148.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -3258,21 +3238,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.6",
"@rolldown/binding-android-arm64": "1.2.6",
"@rolldown/binding-darwin-arm64": "1.2.6",
"@rolldown/binding-darwin-x64": "1.2.6",
"@rolldown/binding-freebsd-x64": "1.2.6",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.6",
"@rolldown/binding-linux-arm64-gnu": "1.2.6",
"@rolldown/binding-linux-arm64-musl": "1.2.6",
"@rolldown/binding-linux-ppc64-gnu": "1.2.6",
"@rolldown/binding-linux-s390x-gnu": "1.2.6",
"@rolldown/binding-linux-x64-gnu": "1.2.6",
"@rolldown/binding-linux-x64-musl": "1.2.6",
"@rolldown/binding-openharmony-arm64": "1.2.6",
"@rolldown/binding-win32-arm64-msvc": "1.2.6",
"@rolldown/binding-win32-x64-msvc": "1.2.6"
"@rolldown/binding-android-arm-eabi": "1.2.7",
"@rolldown/binding-android-arm64": "1.2.7",
"@rolldown/binding-darwin-arm64": "1.2.7",
"@rolldown/binding-darwin-x64": "1.2.7",
"@rolldown/binding-freebsd-x64": "1.2.7",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.7",
"@rolldown/binding-linux-arm64-gnu": "1.2.7",
"@rolldown/binding-linux-arm64-musl": "1.2.7",
"@rolldown/binding-linux-ppc64-gnu": "1.2.7",
"@rolldown/binding-linux-s390x-gnu": "1.2.7",
"@rolldown/binding-linux-x64-gnu": "1.2.7",
"@rolldown/binding-linux-x64-musl": "1.2.7",
"@rolldown/binding-openharmony-arm64": "1.2.7",
"@rolldown/binding-win32-arm64-msvc": "1.2.7",
"@rolldown/binding-win32-x64-msvc": "1.2.7"
}
},
"node_modules/router": {
@@ -3476,6 +3456,7 @@
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3515,11 +3496,14 @@
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
@@ -3548,16 +3532,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -3741,6 +3715,7 @@
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
@@ -3814,38 +3789,31 @@
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -3853,16 +3821,16 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.75.1",
"version": "0.76.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -82,6 +82,6 @@
"prettier": "3.9.6",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "4.1.11"
"vitest": "5.0.0"
}
}

202
scripts/next-preview-version.mjs Executable file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env node
//
// Prints the next preview version for this package, e.g. `0.73.1-preview.4`.
//
// The base is package.json's version with the patch incremented, so a preview always
// sorts above the last release and below whatever release follows it. `-preview.N`
// counts from 1 per base and restarts whenever release-please moves package.json on.
//
// Basing the preview on release-please's *predicted* next version would be more
// truthful — it would give `0.74.0-preview.N` when a `feat:` is queued — but the
// prediction moves mid-flight: a `fix:` opens a 0.73.1 release PR, a later `feat:`
// moves it to 0.74.0, and N resets under previews that were already published. If the
// prediction ever moved down, the next preview would sort below the previous one.
// package.json only ever increases, so patch+1 is monotonic in every case:
// 0.73.1-preview.9 < 0.73.2-preview.1 and < 0.74.1-preview.1, whichever way the next
// release goes. The cost is that `0.73.1-preview.4` is not a promise that 0.73.1 will
// ship.
//
// N has to be a number nobody has used, and two sources are consulted because either
// alone is unsafe:
//
// * the npm registry, authoritative for what is taken — npm versions are immutable
// and stay reserved even after `npm unpublish`, so a taken N is a hard E403 at
// publish time — but served from a CDN, so it can lag a publish by minutes;
// * git tags, written by the workflow after each publish and strongly consistent,
// which cover that window and any unpublish.
//
// The workflow publishes before it tags, so a version can exist on npm without a tag
// but never the other way round. That asymmetry is why the registry is the authority
// and the tags are only a floor, and why a registry read failure is fatal here rather
// than falling back to the tags alone.
//
// See docs/RELEASES.md.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
const PREVIEW_ID = "preview";
const FETCH_ATTEMPTS = 3;
const FETCH_TIMEOUT_MS = 10_000;
const BACKOFF_MS = 500;
/** X.Y.Z: no prerelease, no build metadata, no leading zeros. */
const RELEASE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
/** The base a preview anticipates: package.json's version with the patch bumped. */
export function bumpPatch(version) {
const match = RELEASE_VERSION.exec(String(version ?? "").trim());
if (!match) {
throw new Error(
`expected a plain X.Y.Z release version, got ${JSON.stringify(version)}. ` +
`release-please owns package.json's version; a prerelease or a range here means ` +
`something else has edited it.`,
);
}
const [, major, minor, patch] = match;
return `${major}.${minor}.${Number(patch) + 1}`;
}
function escapeRegExp(literal) {
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Matcher for `<base>-preview.<n>`. `0731-preview.1` would match a base of `0.73.1`. `n`
* is a semver-legal numeric identifier, so leading zeros are rejected; npm would never
* have accepted `preview.01` in the first place.
*/
export function previewPattern(base) {
return new RegExp(`^${escapeRegExp(base)}-${PREVIEW_ID}\\.(0|[1-9]\\d*)$`);
}
/** The N in `<base>-preview.N`, or null for anything else. */
export function previewNumber(candidate, base) {
const match = previewPattern(base).exec(String(candidate ?? "").trim());
return match ? Number(match[1]) : null;
}
/** `refs/tags/v1.2.3`, `v1.2.3` and `1.2.3` all become `1.2.3`. */
export function versionFromTag(tag) {
return String(tag ?? "")
.trim()
.replace(/^refs\/tags\//, "")
.replace(/^v/, "");
}
/**
* Highest N among the candidates, or 0 when none match.
*/
export function highestPreviewNumber(candidates, base) {
let highest = 0;
for (const candidate of candidates) {
const n = previewNumber(candidate, base);
if (n !== null && n > highest) {
highest = n;
}
}
return highest;
}
export function nextPreviewVersion({ base, registryVersions = [], tags = [] }) {
const taken = Math.max(
highestPreviewNumber(registryVersions, base),
highestPreviewNumber(tags.map(versionFromTag), base),
);
return `${base}-${PREVIEW_ID}.${taken + 1}`;
}
/** Every version this package has ever published, per the registry. */
export async function fetchPublishedVersions(name, options = {}) {
const {
registry = DEFAULT_REGISTRY,
fetchImpl = fetch,
attempts = FETCH_ATTEMPTS,
backoffMs = BACKOFF_MS,
} = options;
const url = `${registry}/${name.replace("/", "%2f")}`;
let lastError;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const response = await fetchImpl(url, {
// The abbreviated packument is a fraction of the full one — tens of kilobytes
// rather than megabytes — and still carries every version key.
headers: { accept: "application/vnd.npm.install-v1+json" },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (response.status === 404) {
// Never published. A definite answer, not a failure.
return [];
}
if (!response.ok) {
throw new Error(`${url} answered ${response.status} ${response.statusText}`);
}
const packument = await response.json();
// `versions` on a packument is always an object keyed by version, so there is
// none of the "a single version comes back as a bare string" shape that
// `npm view <pkg> versions --json` has.
return Object.keys(packument.versions ?? {});
} catch (error) {
lastError = error;
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, backoffMs * 2 ** (attempt - 1)));
}
}
}
throw new Error(
`could not read published versions for ${name} after ${attempts} attempts: ${lastError}. ` +
`Refusing to guess a preview number: the registry is the only record of versions ` +
`that were published and then unpublished, and those can never be reused.`,
{ cause: lastError },
);
}
function localPreviewTags(base) {
try {
return execFileSync("git", ["tag", "--list", `v${base}-${PREVIEW_ID}.*`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
.split("\n")
.filter(Boolean);
} catch {
// No git, no repository, or a clone without tags. Tags are only a floor under the
// registry, so carry on rather than fail.
process.stderr.write("warning: could not list git tags; using the registry alone\n");
return [];
}
}
function readFlag(argv, name) {
const index = argv.indexOf(name);
return index === -1 ? undefined : argv[index + 1];
}
async function main(argv) {
const packageJsonPath = readFlag(argv, "--package-json") ?? "package.json";
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const base = bumpPatch(manifest.version);
const registryVersions = await fetchPublishedVersions(manifest.name, {
registry: readFlag(argv, "--registry") ?? DEFAULT_REGISTRY,
});
const tags = argv.includes("--no-git-tags") ? [] : localPreviewTags(base);
// Diagnostics on stderr so stdout stays a single machine-readable line.
process.stderr.write(
`${manifest.name} ${manifest.version} -> base ${base}; registry high-water ` +
`${highestPreviewNumber(registryVersions, base)}, tag high-water ` +
`${highestPreviewNumber(tags.map(versionFromTag), base)}\n`,
);
process.stdout.write(`${nextPreviewVersion({ base, registryVersions, tags })}\n`);
}
// Only when invoked as a program, so the helpers above can be imported by the test
// without doing any I/O.
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,249 @@
import { describe, it, expect, vi } from "vitest";
import {
bumpPatch,
previewNumber,
versionFromTag,
highestPreviewNumber,
nextPreviewVersion,
fetchPublishedVersions,
} from "./next-preview-version.mjs";
const PACKAGE = "@agentclientprotocol/claude-agent-acp";
/** A packument response, abbreviated the way the registry returns it. */
function packument(versions) {
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ name: PACKAGE, versions }),
};
}
function failure(status, statusText = "Internal Server Error") {
return { ok: false, status, statusText, json: async () => ({}) };
}
describe("bumpPatch", () => {
it("increments the patch", () => {
expect(bumpPatch("0.73.0")).toBe("0.73.1");
expect(bumpPatch("1.0.0")).toBe("1.0.1");
expect(bumpPatch("0.0.0")).toBe("0.0.1");
});
it("increments numerically rather than by concatenation", () => {
expect(bumpPatch("0.73.9")).toBe("0.73.10");
expect(bumpPatch("0.73.19")).toBe("0.73.20");
});
it("trims surrounding whitespace", () => {
expect(bumpPatch(" 0.73.0 ")).toBe("0.73.1");
});
// release-please owns package.json's version, so anything that is not a plain
// release means something else has edited it. Failing loudly beats guessing.
it.each([
["a two-part version", "0.73"],
["a four-part version", "0.73.0.1"],
["a prerelease", "0.73.0-preview.1"],
["build metadata", "0.73.0+build.1"],
["a v prefix", "v0.73.0"],
["a range", "^0.73.0"],
["leading zeros", "01.2.3"],
["the empty string", ""],
["undefined", undefined],
])("throws on %s", (_label, version) => {
expect(() => bumpPatch(version)).toThrow(/plain X\.Y\.Z release version/);
});
});
describe("previewNumber", () => {
it("reads N out of a matching version", () => {
expect(previewNumber("0.73.1-preview.1", "0.73.1")).toBe(1);
expect(previewNumber("0.73.1-preview.10", "0.73.1")).toBe(10);
});
it("treats zero as a number rather than as no match", () => {
expect(previewNumber("0.73.1-preview.0", "0.73.1")).toBe(0);
});
it.each([
["a plain release", "0.73.1"],
["a different base", "0.73.2-preview.1"],
["a missing number", "0.73.1-preview"],
["leading zeros in N", "0.73.1-preview.01"],
["a dotted N", "0.73.1-preview.1.2"],
["build metadata", "0.73.1-preview.1+build.5"],
["another prerelease id", "0.73.1-alpha.1"],
// Proves the base's dots are escaped rather than matching any character.
["a base whose dots are elided", "0731-preview.1"],
// Proves the pattern is anchored at the start.
["a base with a numeric prefix", "10.73.1-preview.1"],
])("returns null for %s", (_label, candidate) => {
expect(previewNumber(candidate, "0.73.1")).toBeNull();
});
});
describe("versionFromTag", () => {
it("strips a refs/tags/ prefix and a v", () => {
expect(versionFromTag("refs/tags/v0.73.1-preview.3")).toBe("0.73.1-preview.3");
expect(versionFromTag("v0.73.1")).toBe("0.73.1");
expect(versionFromTag("0.73.1")).toBe("0.73.1");
});
});
describe("highestPreviewNumber", () => {
it("is 0 when nothing matches", () => {
expect(highestPreviewNumber([], "0.73.1")).toBe(0);
expect(highestPreviewNumber(["0.73.1", "0.72.1-preview.4"], "0.73.1")).toBe(0);
});
// Sorted as strings, `preview.9` would beat `preview.10`.
it("compares numerically, not lexicographically", () => {
expect(highestPreviewNumber(["0.73.1-preview.9", "0.73.1-preview.10"], "0.73.1")).toBe(10);
expect(highestPreviewNumber(["0.73.1-preview.10", "0.73.1-preview.9"], "0.73.1")).toBe(10);
});
it("ignores order and duplicates", () => {
const candidates = [
"0.73.1-preview.2",
"0.73.1-preview.7",
"0.73.1-preview.2",
"0.73.1-preview.5",
];
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(7);
});
it("counts only the requested base", () => {
const candidates = ["0.72.1-preview.99", "0.73.1-preview.3", "0.74.1-preview.42"];
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(3);
});
});
describe("nextPreviewVersion", () => {
it("starts at 1 with no history", () => {
expect(nextPreviewVersion({ base: "0.73.1" })).toBe("0.73.1-preview.1");
});
it("continues from the registry", () => {
const registryVersions = ["0.73.0", "0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"];
expect(nextPreviewVersion({ base: "0.73.1", registryVersions })).toBe("0.73.1-preview.4");
});
// The registry is CDN-served and can lag a publish by minutes; the tag the previous
// run wrote is what closes that window.
it("uses the tags as a floor when the registry is behind", () => {
const next = nextPreviewVersion({
base: "0.73.1",
registryVersions: ["0.73.1-preview.1"],
tags: ["refs/tags/v0.73.1-preview.3"],
});
expect(next).toBe("0.73.1-preview.4");
});
// The converse: publish succeeded but the tag step did not, so the registry is
// ahead. It stays authoritative.
it("keeps the registry authoritative when the tags are behind", () => {
const next = nextPreviewVersion({
base: "0.73.1",
registryVersions: ["0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"],
tags: ["v0.73.1-preview.1"],
});
expect(next).toBe("0.73.1-preview.4");
});
it("restarts at 1 once a release moves the base", () => {
const next = nextPreviewVersion({
base: "0.73.1",
registryVersions: ["0.72.1-preview.7", "0.73.0"],
tags: ["v0.72.1-preview.7"],
});
expect(next).toBe("0.73.1-preview.1");
});
it("composes with bumpPatch", () => {
const next = nextPreviewVersion({
base: bumpPatch("0.73.0"),
registryVersions: ["0.73.0"],
tags: ["v0.73.0"],
});
expect(next).toBe("0.73.1-preview.1");
});
});
describe("fetchPublishedVersions", () => {
const options = (fetchImpl) => ({ fetchImpl, backoffMs: 0 });
it("returns the version keys of the packument", async () => {
const fetchImpl = vi.fn(async () => packument({ "0.73.0": {}, "0.73.1-preview.1": {} }));
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([
"0.73.0",
"0.73.1-preview.1",
]);
});
it("requests the abbreviated packument at the scope-encoded URL", async () => {
const fetchImpl = vi.fn(async () => packument({}));
await fetchPublishedVersions(PACKAGE, options(fetchImpl));
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe("https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp");
expect(init.headers.accept).toBe("application/vnd.npm.install-v1+json");
});
it("honours a custom registry", async () => {
const fetchImpl = vi.fn(async () => packument({}));
await fetchPublishedVersions(PACKAGE, {
...options(fetchImpl),
registry: "http://127.0.0.1:4873",
});
expect(fetchImpl.mock.calls[0][0]).toBe(
"http://127.0.0.1:4873/@agentclientprotocol%2fclaude-agent-acp",
);
});
it("tolerates a packument with no versions", async () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ name: PACKAGE }),
}));
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
});
// A 404 is a definite answer — the package has never been published — so it must not
// be retried or turned into a failure.
it("treats 404 as an empty history without retrying", async () => {
const fetchImpl = vi.fn(async () => failure(404, "Not Found"));
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("retries a server error and resolves", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(failure(500))
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("retries a network failure and resolves", async () => {
const fetchImpl = vi
.fn()
.mockRejectedValueOnce(new TypeError("fetch failed"))
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
// Guessing N after a failed read risks reusing a version that was published and then
// unpublished, which npm reserves forever.
it("gives up rather than guessing when every attempt fails", async () => {
const fetchImpl = vi.fn(async () => failure(500));
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).rejects.toThrow(
/could not read published versions for @agentclientprotocol\/claude-agent-acp after 3 attempts/,
);
expect(fetchImpl).toHaveBeenCalledTimes(3);
});
});

View File

@@ -59,7 +59,6 @@ import {
AgentInfo,
CanUseTool,
deleteSession,
EffortLevel,
FastModeDisabledReason,
FastModeState,
getSessionMessages,
@@ -112,7 +111,12 @@ import {
NativeSubagent,
NativeSubagentRuntime,
} from "./native-subagents.js";
import { AIR_ASYNC_TASKS_CAPABILITY, withAirMeta } from "./air-extension.js";
import {
AIR_ASYNC_TASKS_CAPABILITY,
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
clientSupportsAirCapability,
withAirMeta,
} from "./air-extension.js";
import {
AsyncTaskRuntime,
backgroundBashTaskFromToolResult,
@@ -239,12 +243,35 @@ import {
exitPlanModeRawOutput,
observeExitPlanToolResults,
} from "./exit-plan.js";
import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
import { DEFAULT_AGENT_ID } from "./session-config-ids.js";
import { parseToolResultMeta } from "./tool-result-meta.js";
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
export { DEFAULT_AGENT_ID } from "./session-config-ids.js";
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
import {
applyAvailableModelsAllowlist,
buildModelConfigOption,
getAvailableModels,
MODEL_CONFIG_ID,
resolveModelPreference,
type SessionModelState,
} from "./session-model.js";
import {
buildEffortConfigOption,
EFFORT_CONFIG_ID,
mergeEffortSettings,
settingsEffortForModel,
toSdkEffortLevel,
} from "./session-effort.js";
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
export {
applyAvailableModelsAllowlist,
matchResumedModel,
MODEL_CONFIG_ID,
resolveModelPreference,
} from "./session-model.js";
export const CLAUDE_CONFIG_DIR =
process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
@@ -529,15 +556,6 @@ function parseSteerRequest(params: unknown): SteerRequest {
};
}
/** Internal model-selection state. Mirrors the shape the ACP SDK exposed as
* `SessionModelState` before model selection moved entirely into
* `SessionConfigOption` (category "model"). Retained internally to track the
* current model and build the "model" config option. */
type SessionModelState = {
availableModels: Array<{ modelId: string; name: string; description?: string }>;
currentModelId: string;
};
/** One in-flight `prompt()` call. A persistent per-session consumer (see
* `runConsumer`) drains the SDK query stream for the whole session and settles
* each Turn's deferred when that turn's outcome is known, so `prompt()` itself
@@ -773,6 +791,9 @@ export type Session = {
/** Original ACP parameters used to recreate this query with a new provider. */
creationParams?: NewSessionRequest;
settingsManager: SettingsManager;
/** Higher-priority programmatic settings passed to query(). Retained so
* model switches resolve effort from the same effective settings as the SDK. */
effortSettingsOverride?: Settings;
/** This session's title state and the turn-end logic that maintains it. */
titles: SessionTitles;
accumulatedUsage: AccumulatedUsage;
@@ -805,14 +826,19 @@ export type Session = {
* user's intent so it persists across model switches; the Fast mode config
* option is only surfaced while the selected model supports it. */
fastModeEnabled: boolean;
/** Whether the user picked a non-default effort through the ACP picker this
/** The non-default effort the user picked through the ACP picker this
* session. A pin lives at the SDK's flag layer, which overrides the CLI's
* persisted effort (including the per-model `modelSettings` entries), so it
* follows the session across model switches; without one, the CLI resolves
* effort itself and the Effort option is display-only. Cleared when the
* follows the session across model switches. Without a pin, opted-in clients
* apply the settings-derived effort or concrete recommendation on each switch;
* legacy clients leave resolution to the CLI. Cleared when the
* user picks "Default" (the flag layer is cleared with it) or when a model
* switch clamps the pin away. */
effortPinnedByUser?: boolean;
effortPinnedLevel?: string;
/** Last concrete effort successfully written to the SDK flag layer. This is
* independent of user ownership: opted-in clients also apply automatic
* recommendations and settings-derived values. */
appliedEffortLevel?: string;
/** Why the SDK currently can't serve Fast mode, when the reason is one worth
* telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} —
* routine states like the SDK's own opt-in requirement normalize to
@@ -2100,6 +2126,7 @@ export class ClaudeAcpAgent {
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
AIR_ASYNC_TASKS_CAPABILITY,
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
),
steering: {
supported: true,
@@ -7380,9 +7407,28 @@ export class ClaudeAcpAgent {
const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
const currentEffort =
typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
const seedEffort = session.effortPinnedByUser
? currentEffort
: settingsEffortForModel(session.settingsManager.getSettings(), newModelInfo, value);
const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
const pinnedEffort = session.effortPinnedLevel;
const effortWasPinned = pinnedEffort !== undefined;
const appliedEffortBeforeSwitch =
session.appliedEffortLevel ??
(useRecommendedValue && typeof currentEffort === "string" && currentEffort !== "default"
? currentEffort
: pinnedEffort);
const effortPinnedForNewModel =
effortWasPinned &&
newModelInfo?.supportsEffort === true &&
newModelInfo.supportedEffortLevels?.some((level) => level === pinnedEffort) === true;
const seedEffort = effortPinnedForNewModel
? pinnedEffort
: settingsEffortForModel(
mergeEffortSettings(
session.settingsManager.getSettings(),
session.effortSettingsOverride,
),
newModelInfo,
value,
);
session.configOptions = buildConfigOptions(
session.modes,
session.models,
@@ -7399,27 +7445,72 @@ export class ClaudeAcpAgent {
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
disabledReason: session.fastModeDisabledReason,
},
{
useRecommendedValue,
},
);
// Sync effort with the SDK only when a user pin changed across the
// Opted-in clients apply the concrete displayed effort on every switch,
// including settings-derived values, so a previous automatic flag cannot
// shadow the new model's settings. This does not create a user pin.
// For legacy clients, sync only when a user pin changed across the
// switch — i.e. the new model clamped it away (buildConfigOptions
// validated the seed against the new model's levels), where the flag
// must be cleared too or the SDK would keep running the old pin
// invisibly. Settings-derived seeds are display-only: the CLI resolves
// persisted effort itself, and pinning it at the flag layer would
// shadow the per-model values on every later switch.
if (session.effortPinnedByUser) {
const shouldSyncEffort = useRecommendedValue || (effortWasPinned && !effortPinnedForNewModel);
if (shouldSyncEffort) {
const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
const newEffort =
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
if (newEffort !== currentEffort) {
try {
await session.query.applyFlagSettings({
effortLevel: toSdkEffortLevel(newEffort),
// A legacy client's unpinned effort is display-only: the CLI
// resolves the persisted value for the new model. When an old
// user pin is no longer supported, clear the flag layer instead
// of replacing it with that displayed value. Opted-in clients
// deliberately apply their concrete displayed effort.
effortLevel: useRecommendedValue ? toSdkEffortLevel(newEffort) : null,
});
if (newEffort === undefined || newEffort === "default") {
session.effortPinnedByUser = false;
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
session.appliedEffortLevel =
useRecommendedValue && newEffort !== "default" ? newEffort : undefined;
} catch (error) {
// setModel has already succeeded. Effort synchronization is a
// secondary, best-effort operation: propagating this error would
// make the RPC report failure (or suppress an external-switch
// notification) even though the SDK is already on the new model.
// Preserve the old SDK/pin bookkeeping and never advertise the
// unapplied value: restore the last applied value when the new model
// can select it, otherwise omit the effort option until a later
// successful switch rebuilds it.
session.effortPinnedLevel = pinnedEffort;
session.appliedEffortLevel = appliedEffortBeforeSwitch;
const appliedValueStillSelectable =
appliedEffortBeforeSwitch !== undefined &&
newEffortOpt?.type === "select" &&
newEffortOpt.options.some((option) =>
"value" in option
? option.value === appliedEffortBeforeSwitch
: option.options.some((nested) => nested.value === appliedEffortBeforeSwitch),
);
if (newEffortOpt?.type === "select" && appliedValueStillSelectable) {
newEffortOpt.currentValue = appliedEffortBeforeSwitch;
} else {
session.configOptions = session.configOptions.filter(
(option) => option.id !== EFFORT_CONFIG_ID,
);
}
this.logger.error(
`Failed to synchronize effort after model switch to "${value}":`,
error,
);
}
} else if (effortPinnedForNewModel) {
session.effortPinnedLevel = pinnedEffort;
session.appliedEffortLevel = pinnedEffort;
}
// Emit current_mode_update only after session.modes AND
@@ -7444,19 +7535,24 @@ export class ClaudeAcpAgent {
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
} else if (configId === EFFORT_CONFIG_ID) {
// Apply first so a rejected control request cannot leave the displayed
// value ahead of the SDK flag layer.
await session.query.applyFlagSettings({
effortLevel: toSdkEffortLevel(value),
});
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
session.appliedEffortLevel = value !== "default" ? value : undefined;
// "Default" clears the flag layer (toSdkEffortLevel → null), handing
// effort back to the CLI's persisted per-model resolution — so it
// un-pins; any other pick pins effort for the session.
session.effortPinnedLevel = value !== "default" ? value : undefined;
} else {
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
if (configId === EFFORT_CONFIG_ID) {
await session.query.applyFlagSettings({
effortLevel: toSdkEffortLevel(value),
});
// "Default" clears the flag layer (toSdkEffortLevel → null), handing
// effort back to the CLI's persisted per-model resolution — so it
// un-pins; any other pick pins effort for the session.
session.effortPinnedByUser = value !== "default";
}
}
}
@@ -7886,17 +7982,18 @@ export class ClaudeAcpAgent {
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
}
: undefined);
const configuredSettingsObject =
typeof configuredSettings === "string"
? (JSON.parse(
await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"),
) as Settings)
: configuredSettings;
// Claude Code applies env from settings.json after the subprocess env. Put
// an active ACP route in the programmatic settings tier too so user/project
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
let settings = configuredSettings;
if (resolvedProvider) {
const baseSettings =
typeof configuredSettings === "string"
? (JSON.parse(
await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"),
) as Exclude<Options["settings"], string | undefined>)
: configuredSettings;
const baseSettings = configuredSettingsObject;
settings = {
...baseSettings,
apiKeyHelper: "",
@@ -8241,23 +8338,31 @@ export class ClaudeAcpAgent {
disabledReason: fastModeDisabledReason,
};
// The Effort picker seed mirrors what the CLI itself resolves from the
// persisted settings — the current model's `modelSettings` entry first
// (the CLI persists /effort per model), then the legacy top-level value.
// Display-only: no applyFlagSettings here. The CLI reads the same
// settings, so pinning the seed at the flag layer was always redundant —
// and it would override the CLI's own per-model restore on every later
// model switch. Only a user's explicit ACP picker choice pins the flag
// (see applyConfigOptionValue / Session.effortPinnedByUser).
// Concrete effort must also be applied to the SDK: its automatic default
// need not be our "medium" recommendation. Preserve explicit SDK options
// and persisted settings; automatic values re-seed on every model switch.
// Legacy clients still leave effort resolution entirely to the CLI.
const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
const configOptions = buildConfigOptions(
modes,
models,
modelInfos,
settingsEffortForModel(settingsManager.getSettings(), currentModelInfo),
userProvidedOptions?.effort ??
settingsEffortForModel(
mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject),
currentModelInfo,
),
agents,
currentAgent,
fastMode,
{
useRecommendedValue,
},
);
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
await q.applyFlagSettings({ effortLevel: toSdkEffortLevel(initialEffort.currentValue) });
}
// Seed the context window without extra IPC. The cached authoritative
// window from a prior turn wins (`result.modelUsage`, cross-session),
// then the text heuristic, then the default. We deliberately do NOT issue
@@ -8293,6 +8398,7 @@ export class ClaudeAcpAgent {
sessionFingerprint: computeSessionFingerprint(params),
creationParams: params,
settingsManager,
effortSettingsOverride: configuredSettingsObject,
titles: new SessionTitles(this, sessionId),
accumulatedUsage: {
inputTokens: 0,
@@ -8308,6 +8414,18 @@ export class ClaudeAcpAgent {
autoModeFallbackWarningShown: false,
autoModeFallbackWarningPending,
configOptions,
effortPinnedLevel:
userProvidedOptions?.effort !== undefined &&
initialEffort?.currentValue === userProvidedOptions.effort
? userProvidedOptions.effort
: undefined,
appliedEffortLevel:
useRecommendedValue && typeof initialEffort?.currentValue === "string"
? initialEffort.currentValue
: userProvidedOptions?.effort !== undefined &&
initialEffort?.currentValue === userProvidedOptions.effort
? userProvidedOptions.effort
: undefined,
agents,
currentAgent,
fastModeEnabled,
@@ -8758,45 +8876,6 @@ function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string {
return parsed.protocol === "http:" || parsed.protocol === "https:";
}
// Translate a UI effort value into the flag-layer payload. The SDK
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
// and only clears a key when an explicit `null` is sent — see
// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
// `"default"` sentinel and `undefined` (effort option absent for the model) to
// `null` ensures any previously-applied flag is actually cleared. Typed as
// `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
// `supportedEffortLevels` reports, which includes the session-scoped `"max"`
// that the persisted Settings shape deliberately excludes.
function toSdkEffortLevel(value: string | undefined): EffortLevel | null {
return value === undefined || value === "default" ? null : (value as EffortLevel);
}
/** The effort level the CLI itself resolves for a model from the persisted
* settings: the per-model entry (`modelSettings`, keyed by canonical model
* name — the CLI persists /effort per model since 2.1.243) wins over the
* legacy top-level `effortLevel`. Alias picker rows are looked up by their
* resolved model id first, then the row value, then the raw config value
* for models not in the picker. Exact keys only — the CLI's canonical-form
* fallback matching isn't replicated, so a miss simply falls back to the
* top-level value (best-effort display; `buildConfigOptions` still
* validates the result against the model's supported levels). */
export function settingsEffortForModel(
settings: Settings,
modelInfo: ModelInfo | undefined,
modelId?: string,
): string | undefined {
const modelSettings = settings.modelSettings;
if (modelSettings) {
for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) {
const perModel = key !== undefined ? modelSettings[key]?.effortLevel : undefined;
if (typeof perModel === "string") {
return perModel;
}
}
}
return settings.effortLevel;
}
// `supportedAgents()` always returns Claude Code's built-in subagents — the
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
// has configured none of their own. Those aren't meaningful *main-thread*
@@ -8834,7 +8913,6 @@ export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
* same identifiers and can't drift apart. */
export { MODE_CONFIG_ID };
export const MODEL_CONFIG_ID = "model";
export const AGENT_CONFIG_ID = "agent";
export const FAST_MODE_CONFIG_ID = "fast";
@@ -8892,6 +8970,15 @@ export function clientSupportsBooleanConfigOptions(
return clientCapabilities?.session?.configOptions?.boolean != null;
}
/** Whether the Client advertised the AIR `recommendedValue` capability for
* select-style model and effort options. Missing or malformed AIR metadata
* retains the legacy `default` entries. */
export function clientSupportsRecommendedConfigValue(
clientCapabilities?: ClientCapabilities | null,
): boolean {
return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY);
}
/** Build the Fast mode config option. When the Client supports boolean config
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
* a two-value `select` ("on"/"off") so older Clients still get a usable
@@ -8961,6 +9048,12 @@ export type FastModeOptionState = {
disabledReason?: FastModeDisabledReason;
};
export type ConfigOptionPresentation = {
/** Replace ambiguous `default` rows with concrete values and advertise the
* SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */
useRecommendedValue: boolean;
};
export function buildConfigOptions(
modes: SessionModeState,
models: SessionModelState,
@@ -8969,68 +9062,19 @@ export function buildConfigOptions(
agents: AgentInfo[] = [],
currentAgent: string = DEFAULT_AGENT_ID,
fastMode?: FastModeOptionState,
presentation?: ConfigOptionPresentation,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [
SessionModeManager.configOption(modes),
{
id: MODEL_CONFIG_ID,
name: "Model",
description: "AI model to use",
category: "model",
type: "select",
currentValue: models.currentModelId,
options: models.availableModels.map((m) => {
if (m.modelId === "default") {
const defaultInfo = modelInfos.find((mi) => mi.value === "default");
const resolvedModel = defaultInfo?.resolvedModel;
if (resolvedModel) {
const namedMatch = modelInfos.find(
(mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel,
);
return {
value: m.modelId,
name: m.name,
description: namedMatch?.displayName ?? resolvedModel,
};
}
}
return { value: m.modelId, name: m.name, description: m.description ?? undefined };
}),
},
buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
];
// 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 = [
{ value: "default", name: "Default" },
...supportedLevels.map((level) => ({
value: level,
name: level
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" "),
})),
];
const includes = (l: string) => l === "default" || (supportedLevels as string[]).includes(l);
const validEffort =
currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
options.push({
id: EFFORT_CONFIG_ID,
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: validEffort,
options: effortOptions,
});
}
const effort = buildEffortConfigOption(
modelInfos,
models.currentModelId,
currentEffortLevel,
presentation?.useRecommendedValue === true,
);
if (effort) options.push(effort);
// Surface the Fast mode toggle only when the current model supports it. The
// option renders as a native boolean toggle for Clients that opted in, and a
@@ -9070,422 +9114,6 @@ export function buildConfigOptions(
return options;
}
// Claude Code CLI persists display strings like "opus[1m]" in settings,
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
// The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
// shared by the strip and canonicalize helpers below so the two can't drift.
const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
/** Remove context-window hints — the display form "[1m]" and the SDK id
* suffix form "-1m" — from a model string. Those digits describe context
* size, not model identity or generation version. */
function stripContextHints(s: string): string {
return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
}
/** Canonicalize a model id for exact comparison: trimmed, lowercased, with
* the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
* The hint itself is kept — bare and 1M ids must stay distinct. */
function canonicalizeModelId(s: string): string {
return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
}
/** The context hint a model string carries ("1m" for either spelling), or
* null for a bare id. */
function contextHintOf(s: string): string | null {
return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
}
// Captures a model family version: `4-6`/`4.7` for dated generations, or a
// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
// `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
function extractModelFamilyVersion(s: string): string | null {
const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
if (!match) return null;
return match[2] ? `${match[1]}.${match[2]}` : match[1];
}
function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean {
const preferred = extractModelFamilyVersion(preference);
if (!preferred) return true;
const candidateVersion =
extractModelFamilyVersion(candidate.value) ??
extractModelFamilyVersion(candidate.displayName) ??
extractModelFamilyVersion(candidate.description);
if (!candidateVersion) return true;
return preferred === candidateVersion;
}
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
const lower = model.trim().toLowerCase();
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
const normalized = lower.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ");
const rawTokens = normalized.split(/[^a-z0-9]+/).filter(Boolean);
const tokens = rawTokens
.map((token) => {
if (token === "opusplan") return "opus";
if (token === "best" || token === "default") return "";
return token;
})
.filter((token) => token && token !== "claude")
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
return { tokens, contextHint };
}
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
let score = 0;
let nonHintMatched = false;
for (const token of tokens) {
if (haystack.includes(token)) {
if (token !== contextHint) nonHintMatched = true;
score += token === contextHint ? 3 : 1;
}
}
if (contextHint && !nonHintMatched) return 0;
return score;
}
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
const trimmed = preference.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
// Exact match on value or display name. Values compare on the canonical
// hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
const canonicalPreference = canonicalizeModelId(trimmed);
const directMatch = models.find(
(model) =>
model.value === trimmed ||
canonicalizeModelId(model.value) === canonicalPreference ||
model.displayName.toLowerCase() === lower,
);
if (directMatch) return directMatch;
// Exact match on the alias's canonical resolved id (e.g. a pinned
// "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
// reported and unambiguous, so it's tried before the fuzzier tiers below.
// Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
// "[1m]"-spelled resolvedModel instead of falling into the substring tier
// (which would land on the bare 200k sibling). "default" is skipped first
// since it shares a resolvedModel with whichever alias the CLI currently
// recommends — a specific pin should land on that named alias, not
// "default".
const matchesResolved = (model: ModelInfo) =>
model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
const resolvedMatch =
models.find((model) => model.value !== "default" && matchesResolved(model)) ??
models.find(matchesResolved);
if (resolvedMatch) return resolvedMatch;
// Substring match. Skips candidates whose context hint disagrees with the
// preference's — a bare row must not absorb a 1M-hinted preference (nor
// vice versa); such pairs fall through to the tokenized tier, which
// weighs hints in its scoring and still finds the best same-family row.
const preferenceHint = contextHintOf(trimmed);
const includesMatch = models.find((model) => {
if (!modelVersionsCompatible(trimmed, model)) return false;
if (contextHintOf(model.value) !== preferenceHint) return false;
const value = model.value.toLowerCase();
const display = model.displayName.toLowerCase();
return value.includes(lower) || display.includes(lower) || lower.includes(value);
});
if (includesMatch) return includesMatch;
// Tokenized matching for aliases like "opus[1m]"
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
if (tokens.length === 0) return null;
let bestMatch: ModelInfo | null = null;
let bestScore = 0;
for (const model of models) {
if (!modelVersionsCompatible(trimmed, model)) continue;
const score = scoreModelMatch(model, tokens, contextHint);
if (0 < score && (!bestMatch || bestScore < score)) {
bestMatch = model;
bestScore = score;
}
}
return bestMatch;
}
/** Map the live model reported by a resumed session onto the picker's model
* list. The CLI restores a resumed session's model from the transcript's
* last assistant message, which records the concrete API id (e.g.
* "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
* 1. Exact match with the Default entry's resolution — when a named alias
* shares Default's resolvedModel verbatim, the live id can't tell the
* two apart, and a never-customized session should stay on Default.
* 2. Exact resolvedModel match on a named row. Checked before the
* hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
* on the "sonnet[1m]" row rather than a Default that resolves to the
* bare "claude-sonnet-5" — the two rows differ in context window, which
* drives `contextWindowSize` and capability gating downstream.
* 3. Hint-stripped match with Default's resolution — a session that never
* left the default resumes as the bare transcript id, and shouldn't show
* a concrete picker entry.
* 4. `resolveModelPreference` over the picker entries.
* 5. A model with no picker counterpart (e.g. excluded by an
* `availableModels` allowlist) is tracked verbatim, mirroring
* `syncModelAfterExternalSwitch`: the picker shows no selection, but the
* model-dependent bookkeeping stays truthful to what the SDK is running. */
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
const live = canonicalizeModelId(liveModel);
const defaultEntry = models.find((m) => m.value === "default");
const defaultResolved = defaultEntry?.resolvedModel
? canonicalizeModelId(defaultEntry.resolvedModel)
: undefined;
if (defaultEntry && defaultResolved === live) {
return defaultEntry;
}
// No default-row exclusion needed: a default row matching `live` exactly
// already returned at the tier above.
const exactMatch = models.find(
(m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live,
);
if (exactMatch) return exactMatch;
if (
defaultEntry &&
defaultResolved &&
stripContextHints(defaultResolved) === stripContextHints(live)
) {
return defaultEntry;
}
return (
resolveModelPreference(models, liveModel) ?? {
value: liveModel,
displayName: liveModel,
description: "",
}
);
}
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);
}
/**
* Restrict the SDK's model list to the user's `availableModels` allowlist
* (already merged-and-deduped across settings sources by `SettingsManager`).
* The user's exact entries become the model IDs surfaced via configOptions
* and passed to `setModel`, which prevents Claude Code from silently
* substituting a date-pinned variant (e.g. `haiku` →
* `claude-haiku-4-5-20251001`) that the user may not have access to.
*
* Display info and capability flags are copied from the closest SDK match so
* the UI still renders sensible names and effort levels.
*
* Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
* - `undefined` is handled by the caller (no allowlist applied).
* - The Default option is unaffected by `availableModels` — it always remains
* available, even when the allowlist is `[]`.
*/
export function applyAvailableModelsAllowlist(
sdkModels: ModelInfo[],
allowlist: string[],
settingsModelOverrides?: Record<string, string>,
): ModelInfo[] {
// Default is always preserved per the docs. Synthesize one if the SDK
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
// `models[0]` as a fallback) still has something to work with.
const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
value: "default",
displayName: "Default",
description: "",
};
const result: ModelInfo[] = [defaultModel];
const seen = new Set<string>([defaultModel.value]);
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
// Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
// in availableModels but require provider-specific IDs at the API. We still
// resolve `sdkMatch` against the alias (`trimmed`) — that's what the
// matching heuristics above are built for, and override targets (ARNs,
// opaque provider IDs) often won't textually resemble anything in
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
// override target, so it's what `setModel` ends up passing to the API.
for (const entry of allowlist) {
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) continue;
const overridden = settingsModelOverrides?.[trimmed];
const effective = overridden ?? trimmed;
if (seen.has(effective)) continue;
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
if (sdkMatch) {
result.push({ ...sdkMatch, value: effective });
} else {
result.push({ value: effective, displayName: trimmed, description: "" });
}
seen.add(effective);
}
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
// allowlist, the same way Default is. Per the model-config docs it adds an
// entry "without replacing the built-in aliases" and "appears at the bottom of
// the /model picker", so we append it last and skip the allowlist filter; this
// keeps a slim alias allowlist from hiding the custom model row.
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
if (customModelOption && !seen.has(customModelOption)) {
const customModel = sdkModels.find((m) => m.value === customModelOption);
if (customModel) {
result.push(customModel);
seen.add(customModel.value);
}
}
return result;
}
/** Whether a rejected `setModel` was vetoed by a user-configured
* PreModelSwitch hook. The CLI's rejection message is the stable,
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
* <reason>"); a format change makes this stop matching and the failure
* falls back to the ordinary fail-loud path, no worse than before. */
function isPreModelSwitchHookBlock(error: unknown): boolean {
return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
}
async function getAvailableModels(
query: Query,
models: ModelInfo[],
sdkModels: ModelInfo[],
settingsManager: SettingsManager,
logger: Logger,
isResumedSession: boolean,
sessionId: string,
resumedModelHint?: string,
): Promise<SessionModelState> {
const settings = settingsManager.getSettings();
let currentModel = models[0];
let resolvedFromInput: string | undefined;
// Model priority (highest to lowest):
// 1. ANTHROPIC_MODEL environment variable
// 2. settings.model (user configuration)
// 3. the resumed session's live model (resumed sessions only)
// 4. models[0] (default first model)
if (process.env.ANTHROPIC_MODEL) {
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
if (match) {
currentModel = match;
resolvedFromInput = process.env.ANTHROPIC_MODEL;
}
} else if (typeof settings.model === "string") {
const match = resolveSettingsModel(models, settings.model, logger);
if (match) {
currentModel = match;
resolvedFromInput = settings.model;
}
}
// A resumed session restores the model from its last real assistant
// transcript record. Use that same local record instead of getContextUsage:
// the latter is a control request to the live CLI process and can add tens
// of seconds to session/load before its first turn. No `setModel` here: the
// SDK is already running this model, and pushing a picker alias back (e.g.
// "opus[1m]") could change the live model rather than describe it.
if (resolvedFromInput === undefined && isResumedSession) {
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel;
}
// Skip the setModel round-trip when we can prove the SDK has already landed
// on the same model. Two cases qualify:
// (a) No override applied — currentModel is the SDK's own default (or, on
// resume, the live model read back from the SDK above); nothing to sync.
// (b) The resolver returned the user's input verbatim AND that value exists
// in the SDK's original model list — meaning no fuzzy match or
// allowlist rewrite was involved, and the SDK (which reads the same
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
// This only holds for fresh sessions: a resumed session lands on the
// transcript's model regardless of env/settings, so the override must
// be re-asserted to keep the reported model truthful.
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
// setModel call so we don't drift from the user's intended pin.
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
const skipSetModel =
resolvedFromInput === undefined ||
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
if (!skipSetModel) {
const setModelStartedAt = performance.now();
try {
await query.setModel(currentModel.value);
logger.log(
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`,
);
} catch (error) {
logger.log(
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`,
);
// On a fresh session the pin is a defining option — fail loudly. A
// resumed session already runs fine on the transcript's model, so
// failing the whole session/load over the re-assert would be worse
// than loading with the pin unapplied (mirrors the setPermissionMode
// containment in createSession). The SDK then stayed on the
// transcript's model, so read that back rather than reporting the
// pin the session isn't running.
//
// One fresh-session failure is advisory, not defining: a
// user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+;
// 'deny', or 'ask' — which headless sessions refuse), and the SDK
// rejects setModel with "Model switch blocked by a PreModelSwitch
// hook: …". Terminal Claude Code never lets a hook veto its startup
// model (the spawn model isn't a switch), so failing session/new here
// would make the same hook config fatal only over ACP. The session
// stays on the SDK's own default — report that. We can't read the
// live model back on this path: getContextUsage isn't serviced on a
// fresh session until the first prompt turn (issues #886/#880).
if (!isResumedSession) {
if (!isPreModelSwitchHookBlock(error)) throw error;
logger.error(
`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`,
error,
);
currentModel = models[0];
} else {
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
}
}
}
return {
availableModels: models.map((model) => ({
modelId: model.value,
name: model.displayName,
description: model.description,
})),
currentModelId: currentModel.value,
};
}
function getAvailableSlashCommands(
commands: SlashCommand[],
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in

View File

@@ -1,6 +1,7 @@
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
const JETBRAINS_META_KEY = "jetbrains";
const AIR_META_KEY = "air";

View File

@@ -5,7 +5,7 @@ import type {
PermissionMode,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
export type ClearContextReset = {
toolUseId: string;
@@ -39,6 +39,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
configOptions: SessionConfigOption[];
currentAgent: string;
fastModeEnabled: boolean;
effortPinnedLevel?: string;
activeTurn?: Turn | null;
turnQueue?: Turn[];
pendingExitPlanContextReset?: ClearContextReset;
@@ -69,9 +70,6 @@ export type ClearContextCoordinatorHost<
function restartParams<Session extends ClearContextSession>(session: Session): NewSessionRequest {
const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] };
const currentEffort = session.configOptions.find(
(option) => option.id === EFFORT_CONFIG_ID,
)?.currentValue;
const originalMeta = originalParams._meta as
({ claudeCode?: { options?: Options } } & Record<string, unknown>) | undefined;
const originalOptions = originalMeta?.claudeCode?.options;
@@ -92,8 +90,8 @@ function restartParams<Session extends ClearContextSession>(session: Session): N
? { model: session.models.currentModelId }
: {}),
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
...(typeof currentEffort === "string" && currentEffort !== "default"
? { effort: currentEffort as EffortLevel }
...(session.effortPinnedLevel !== undefined
? { effort: session.effortPinnedLevel as EffortLevel }
: {}),
},
},

118
src/session-effort.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { EffortLevel, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
import { EFFORT_CONFIG_ID } from "./session-config-ids.js";
export { EFFORT_CONFIG_ID } from "./session-config-ids.js";
// The SDK drops `undefined` during JSON transport and only clears a flag-layer
// setting when it receives an explicit `null`. Map both an absent picker and
// the legacy "default" row to null so a previously applied effort is cleared.
export function toSdkEffortLevel(value: string | undefined): EffortLevel | null {
return value === undefined || value === "default" ? null : (value as EffortLevel);
}
function canonicalizeModelSettingsKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/-(\d+m)$/i, "[$1]");
}
/** Resolve the effort the CLI will use: per-model settings first, then the
* legacy top-level effort setting. Model settings are keyed by canonical
* model name, so try the resolved SDK model before picker and raw IDs. */
export function settingsEffortForModel(
settings: Settings,
modelInfo: ModelInfo | undefined,
modelId?: string,
): string | undefined {
const modelSettings = settings.modelSettings;
if (modelSettings) {
for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) {
if (key === undefined) continue;
const exact = modelSettings[key]?.effortLevel;
if (typeof exact === "string") return exact;
const canonicalKey = canonicalizeModelSettingsKey(key);
const matchingEntry = Object.entries(modelSettings).find(
([candidate]) => canonicalizeModelSettingsKey(candidate) === canonicalKey,
);
const perModel = matchingEntry?.[1]?.effortLevel;
if (typeof perModel === "string") return perModel;
}
}
return settings.effortLevel;
}
/** Programmatic query settings have higher priority than file-backed settings,
* matching the SDK. Keep unrelated per-model entries from lower tiers while
* replacing entries supplied at the programmatic tier. */
export function mergeEffortSettings(base: Settings, override: Settings | undefined): Settings {
if (!override) return base;
const overriddenModelKeys = new Set(
Object.keys(override.modelSettings ?? {}).map(canonicalizeModelSettingsKey),
);
const unshadowedBaseModelSettings = Object.fromEntries(
Object.entries(base.modelSettings ?? {}).filter(
([key]) => !overriddenModelKeys.has(canonicalizeModelSettingsKey(key)),
),
);
return {
...base,
...override,
modelSettings:
base.modelSettings || override.modelSettings
? { ...unshadowedBaseModelSettings, ...override.modelSettings }
: undefined,
};
}
export function buildEffortConfigOption(
modelInfos: ModelInfo[],
currentModelId: string,
currentEffortLevel: string | undefined,
useRecommendedValue: boolean,
): SessionConfigOption | undefined {
const currentModelInfo = modelInfos.find((model) => model.value === currentModelId);
const supportedLevels = currentModelInfo?.supportsEffort
? (currentModelInfo.supportedEffortLevels ?? [])
: [];
if (supportedLevels.length === 0) return undefined;
const recommendedEffort = (supportedLevels as string[]).includes("medium")
? "medium"
: supportedLevels[0];
const options = [
...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]),
...supportedLevels.map((level) => ({
value: level,
name: level
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" "),
})),
];
const includes = (level: string) =>
(!useRecommendedValue && level === "default") || (supportedLevels as string[]).includes(level);
const currentValue =
currentEffortLevel && includes(currentEffortLevel)
? currentEffortLevel
: useRecommendedValue
? recommendedEffort
: "default";
return {
id: EFFORT_CONFIG_ID,
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue,
options,
...(useRecommendedValue
? {
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommendedEffort),
}
: {}),
};
}

466
src/session-model.ts Normal file
View File

@@ -0,0 +1,466 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
export const MODEL_CONFIG_ID = "model";
export type SessionModelState = {
availableModels: Array<{ modelId: string; name: string; description?: string }>;
currentModelId: string;
};
type ModelLogger = {
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
type ModelSettingsSource = { getSettings(): Settings };
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
const VERSIONABLE_MODEL_DISPLAY_PATTERN =
/^(?:Claude\s+)?([a-z][a-z0-9-]*)(?:\s+\d+(?:\.\d+)?)?(?:\s+\(\d+[mk]\s+context\))?$/i;
const DISPLAY_CONTEXT_SUFFIX_PATTERN = /\s+\((\d+[mk])\s+context\)$/i;
function stripContextHints(value: string): string {
return value.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
}
function canonicalizeModelId(value: string): string {
return value.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
}
function contextHintOf(value: string): string | null {
return canonicalizeModelId(value).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
}
function extractModelFamilyVersion(value: string): string | null {
const match = stripContextHints(value).match(MODEL_FAMILY_VERSION_PATTERN);
if (!match) return null;
return match[2] ? `${match[1]}.${match[2]}` : match[1];
}
function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean {
const preferred = extractModelFamilyVersion(preference);
if (!preferred) return true;
const candidateVersion =
extractModelFamilyVersion(candidate.value) ??
extractModelFamilyVersion(candidate.displayName) ??
extractModelFamilyVersion(candidate.description);
return candidateVersion ? preferred === candidateVersion : true;
}
function displayModelVersion(model: ModelInfo, family: string): string | undefined {
const familyVersion = new RegExp(`\\b${family}[-\\s]+(\\d+)(?:[-.](\\d+))?`, "i");
// The display name is the value we are deciding whether to rewrite, so it
// cannot validate its own normalization. Require independent SDK metadata.
for (const source of [model.resolvedModel, model.description, model.value]) {
const match = source?.match(familyVersion);
if (match) return match[2] ? `${match[1]}.${match[2]}` : match[1];
}
return undefined;
}
/** Add concrete versions when a terse SDK label names the same family as the
* underlying model metadata. Multi-word/custom labels remain untouched.
* Context size remains in the option description; if normalization would
* collide, callers fall back to the original labels. */
function versionedModelDisplayName(model: ModelInfo): string {
const versionable = model.displayName.match(VERSIONABLE_MODEL_DISPLAY_PATTERN);
if (!versionable) return model.displayName;
const version = displayModelVersion(model, versionable[1]);
if (!version) return model.displayName;
const contextSuffix = model.displayName.match(DISPLAY_CONTEXT_SUFFIX_PATTERN);
const descriptionKeepsContext =
contextSuffix !== null &&
new RegExp(`\\b${contextSuffix[1]}\\s+context\\b`, "i").test(model.description);
const withoutContext = model.displayName.replace(DISPLAY_CONTEXT_SUFFIX_PATTERN, "");
if (/\b\d+(?:\.\d+)?\b/.test(withoutContext)) {
return descriptionKeepsContext ? withoutContext : model.displayName;
}
if (descriptionKeepsContext || contextSuffix === null) return `${withoutContext} ${version}`;
// Keep the suffix in its original spelling and insert the version before it.
return `${withoutContext} ${version}${contextSuffix[0]}`;
}
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
const lower = model.trim().toLowerCase();
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
const tokens = lower
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
.split(/[^a-z0-9]+/)
.filter(Boolean)
.map((token) => {
if (token === "opusplan") return "opus";
if (token === "best" || token === "default") return "";
return token;
})
.filter((token) => token && token !== "claude")
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
return { tokens, contextHint };
}
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
let score = 0;
let nonHintMatched = false;
for (const token of tokens) {
if (!haystack.includes(token)) continue;
if (token !== contextHint) nonHintMatched = true;
score += token === contextHint ? 3 : 1;
}
return contextHint && !nonHintMatched ? 0 : score;
}
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
const trimmed = preference.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
const canonicalPreference = canonicalizeModelId(trimmed);
const directMatch = models.find(
(model) =>
model.value === trimmed ||
canonicalizeModelId(model.value) === canonicalPreference ||
model.displayName.toLowerCase() === lower,
);
if (directMatch) return directMatch;
const matchesResolved = (model: ModelInfo) =>
model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
const resolvedMatch =
models.find((model) => model.value !== "default" && matchesResolved(model)) ??
models.find(matchesResolved);
if (resolvedMatch) return resolvedMatch;
const preferenceHint = contextHintOf(trimmed);
const includesMatch = models.find((model) => {
if (!modelVersionsCompatible(trimmed, model)) return false;
if (contextHintOf(model.value) !== preferenceHint) return false;
const value = model.value.toLowerCase();
const display = model.displayName.toLowerCase();
return value.includes(lower) || display.includes(lower) || lower.includes(value);
});
if (includesMatch) return includesMatch;
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
if (tokens.length === 0) return null;
let bestMatch: ModelInfo | null = null;
let bestScore = 0;
for (const model of models) {
if (!modelVersionsCompatible(trimmed, model)) continue;
const score = scoreModelMatch(model, tokens, contextHint);
if (score > 0 && (!bestMatch || score > bestScore)) {
bestMatch = model;
bestScore = score;
}
}
return bestMatch;
}
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
const live = canonicalizeModelId(liveModel);
const defaultEntry = models.find((model) => model.value === "default");
const defaultResolved = defaultEntry?.resolvedModel
? canonicalizeModelId(defaultEntry.resolvedModel)
: undefined;
if (defaultEntry && defaultResolved === live) return defaultEntry;
const exactMatch = models.find(
(model) => model.resolvedModel && canonicalizeModelId(model.resolvedModel) === live,
);
if (exactMatch) return exactMatch;
if (
defaultEntry &&
defaultResolved &&
stripContextHints(defaultResolved) === stripContextHints(live)
) {
return defaultEntry;
}
return (
resolveModelPreference(models, liveModel) ?? {
value: liveModel,
displayName: liveModel,
description: "",
}
);
}
export function buildModelConfigOption(
models: SessionModelState,
modelInfos: ModelInfo[],
useRecommendedValue: boolean,
): SessionConfigOption {
const defaultInfo = modelInfos.find((model) => model.value === "default");
const selectableIds = new Set(models.availableModels.map((model) => model.modelId));
const concreteInfos = modelInfos.filter(
(model) => model.value !== "default" && selectableIds.has(model.value),
);
// Recommendations describe the running default, so fuzzy family matching
// must never substitute a different generation or context window.
const defaultResolved = defaultInfo?.resolvedModel;
const recommended = defaultResolved
? (concreteInfos.find(
(model) =>
canonicalizeModelId(model.resolvedModel ?? model.value) ===
canonicalizeModelId(defaultResolved),
) ?? null)
: null;
const concreteRecommendation = useRecommendedValue && recommended !== null;
const available = concreteRecommendation
? models.availableModels.filter((model) => model.modelId !== "default")
: models.availableModels;
return {
id: MODEL_CONFIG_ID,
name: "Model",
description: "AI model to use",
category: "model",
type: "select",
currentValue:
concreteRecommendation && models.currentModelId === "default"
? recommended.value
: models.currentModelId,
...(concreteRecommendation
? {
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
}
: {}),
options: available.map((model) => {
if (model.modelId === "default" && defaultInfo?.resolvedModel) {
const named = modelInfos.find(
(info) => info.value !== "default" && info.resolvedModel === defaultInfo.resolvedModel,
);
return {
value: model.modelId,
name: model.name,
description: named?.displayName ?? defaultInfo.resolvedModel,
};
}
return {
value: model.modelId,
name: model.name,
description: model.description ?? undefined,
};
}),
};
}
function resolveSettingsModel(
models: ModelInfo[],
settingsModel: unknown,
logger: ModelLogger,
): 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);
}
/**
* Restrict the SDK's model list to the user's `availableModels` allowlist
* (already merged-and-deduped across settings sources by `SettingsManager`).
* The user's exact entries become the model IDs surfaced via configOptions
* and passed to `setModel`, which prevents Claude Code from silently
* substituting a date-pinned variant (e.g. `haiku` →
* `claude-haiku-4-5-20251001`) that the user may not have access to.
*
* Display info and capability flags are copied from the closest SDK match so
* the UI still renders sensible names and effort levels.
*
* Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
* - `undefined` is handled by the caller (no allowlist applied).
* - The Default option is unaffected by `availableModels` — it always remains
* available, even when the allowlist is `[]`.
*/
export function applyAvailableModelsAllowlist(
sdkModels: ModelInfo[],
allowlist: string[],
settingsModelOverrides?: Record<string, string>,
): ModelInfo[] {
// Default is always preserved per the docs. Synthesize one if the SDK
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
// `models[0]` as a fallback) still has something to work with.
const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
value: "default",
displayName: "Default",
description: "",
};
const result: ModelInfo[] = [defaultModel];
const seen = new Set<string>([defaultModel.value]);
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
// Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
// in availableModels but require provider-specific IDs at the API. We still
// resolve `sdkMatch` against the alias (`trimmed`) — that's what the
// matching heuristics above are built for, and override targets (ARNs,
// opaque provider IDs) often won't textually resemble anything in
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
// override target, so it's what `setModel` ends up passing to the API.
for (const entry of allowlist) {
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) continue;
const overridden = settingsModelOverrides?.[trimmed];
const effective = overridden ?? trimmed;
if (seen.has(effective)) continue;
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
if (sdkMatch) {
result.push({ ...sdkMatch, value: effective });
} else {
result.push({ value: effective, displayName: trimmed, description: "" });
}
seen.add(effective);
}
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
// allowlist, the same way Default is. Per the model-config docs it adds an
// entry "without replacing the built-in aliases" and "appears at the bottom of
// the /model picker", so we append it last and skip the allowlist filter; this
// keeps a slim alias allowlist from hiding the custom model row.
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
if (customModelOption && !seen.has(customModelOption)) {
const customModel = sdkModels.find((m) => m.value === customModelOption);
if (customModel) {
result.push(customModel);
seen.add(customModel.value);
}
}
return result;
}
/** Whether a rejected `setModel` was vetoed by a user-configured
* PreModelSwitch hook. The CLI's rejection message is the stable,
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
* <reason>"); a format change makes this stop matching and the failure
* falls back to the ordinary fail-loud path, no worse than before. */
function isPreModelSwitchHookBlock(error: unknown): boolean {
return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
}
export async function getAvailableModels(
query: Query,
models: ModelInfo[],
sdkModels: ModelInfo[],
settingsManager: ModelSettingsSource,
logger: ModelLogger,
isResumedSession: boolean,
sessionId: string,
resumedModelHint?: string,
): Promise<SessionModelState> {
const settings = settingsManager.getSettings();
let currentModel = models[0];
let resolvedFromInput: string | undefined;
// Model priority (highest to lowest):
// 1. ANTHROPIC_MODEL environment variable
// 2. settings.model (user configuration)
// 3. the resumed session's live model (resumed sessions only)
// 4. models[0] (default first model)
if (process.env.ANTHROPIC_MODEL) {
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
if (match) {
currentModel = match;
resolvedFromInput = process.env.ANTHROPIC_MODEL;
}
} else if (typeof settings.model === "string") {
const match = resolveSettingsModel(models, settings.model, logger);
if (match) {
currentModel = match;
resolvedFromInput = settings.model;
}
}
// A resumed session restores the model from its last real assistant
// transcript record. Use that same local record instead of getContextUsage:
// the latter is a control request to the live CLI process and can add tens
// of seconds to session/load before its first turn. No `setModel` here: the
// SDK is already running this model, and pushing a picker alias back (e.g.
// "opus[1m]") could change the live model rather than describe it.
if (resolvedFromInput === undefined && isResumedSession) {
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel;
}
// Skip the setModel round-trip when we can prove the SDK has already landed
// on the same model. Two cases qualify:
// (a) No override applied — currentModel is the SDK's own default (or, on
// resume, the live model read back from the SDK above); nothing to sync.
// (b) The resolver returned the user's input verbatim AND that value exists
// in the SDK's original model list — meaning no fuzzy match or
// allowlist rewrite was involved, and the SDK (which reads the same
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
// This only holds for fresh sessions: a resumed session lands on the
// transcript's model regardless of env/settings, so the override must
// be re-asserted to keep the reported model truthful.
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
// setModel call so we don't drift from the user's intended pin.
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
const skipSetModel =
resolvedFromInput === undefined ||
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
if (!skipSetModel) {
const setModelStartedAt = performance.now();
try {
await query.setModel(currentModel.value);
logger.log(
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`,
);
} catch (error) {
logger.log(
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`,
);
// On a fresh session the pin is a defining option — fail loudly. A
// resumed session already runs fine on the transcript's model, so
// failing the whole session/load over the re-assert would be worse
// than loading with the pin unapplied (mirrors the setPermissionMode
// containment in createSession). The SDK then stayed on the
// transcript's model, so read that back rather than reporting the
// pin the session isn't running.
//
// One fresh-session failure is advisory, not defining: a
// user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+;
// 'deny', or 'ask' — which headless sessions refuse), and the SDK
// rejects setModel with "Model switch blocked by a PreModelSwitch
// hook: …". Terminal Claude Code never lets a hook veto its startup
// model (the spawn model isn't a switch), so failing session/new here
// would make the same hook config fatal only over ACP. The session
// stays on the SDK's own default — report that. We can't read the
// live model back on this path: getContextUsage isn't serviced on a
// fresh session until the first prompt turn (issues #886/#880).
if (!isResumedSession) {
if (!isPreModelSwitchHookBlock(error)) throw error;
logger.error(
`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`,
error,
);
currentModel = models[0];
} else {
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
}
}
}
const displayNames = models.map(versionedModelDisplayName);
const displayNameCounts = new Map<string, number>();
for (const name of displayNames) {
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
}
return {
availableModels: models.map((model, index) => ({
modelId: model.value,
name:
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
description: model.description,
})),
currentModelId: currentModel.value,
};
}

View File

@@ -97,6 +97,118 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.modes.currentModeId).toBe("default");
}, 15_000);
it.each([
{ recommended: false, setting: undefined, explicit: undefined, expected: "default" },
{ recommended: false, setting: "low", explicit: "high", expected: "high" },
{ recommended: true, setting: undefined, explicit: undefined, expected: "medium" },
{ recommended: true, setting: "high", explicit: undefined, expected: "high" },
{ recommended: true, setting: "high", explicit: "max", expected: "max" },
])(
"initial effort reflects SDK state: %j",
async ({ recommended, setting, explicit, expected }) => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({ effortLevel: setting }),
);
const applyFlagSettings = vi.fn();
querySpy.mockReturnValue(
makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "default",
displayName: "Default",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
},
],
}),
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
if (recommended) {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
}
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { disableBuiltInTools: true, claudeCode: { options: { effort: explicit } } },
});
expect(
response.configOptions.find((option: any) => option.id === "effort").currentValue,
).toBe(expected);
if (recommended) {
expect(applyFlagSettings).toHaveBeenCalledWith({ effortLevel: expected });
} else {
expect(applyFlagSettings).not.toHaveBeenCalled();
}
expect(agent.sessions[response.sessionId].effortPinnedLevel).toBe(explicit);
expect(agent.sessions[response.sessionId].appliedEffortLevel).toBe(
recommended ? expected : explicit,
);
},
);
it("seeds effort from higher-priority programmatic model settings", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
effortLevel: "high",
modelSettings: { "claude-sonnet-5[1m]": { effortLevel: "high" } },
}),
);
const applyFlagSettings = vi.fn();
querySpy.mockReturnValue(
makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "sonnet[1m]",
resolvedModel: "claude-sonnet-5[1m]",
displayName: "Sonnet",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
],
}),
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: {
disableBuiltInTools: true,
claudeCode: {
options: {
settings: {
effortLevel: "medium",
modelSettings: { "claude-sonnet-5-1m": { effortLevel: "low" } },
},
},
},
},
});
expect(response.configOptions.find((option: any) => option.id === "effort").currentValue).toBe(
"low",
);
expect(applyFlagSettings).toHaveBeenCalledWith({ effortLevel: "low" });
expect(agent.sessions[response.sessionId].effortSettingsOverride?.effortLevel).toBe("medium");
});
it("supports acceptEdits mode defaults", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),

View File

@@ -8385,6 +8385,7 @@ describe("logout", () => {
"agentFileChangeReport",
"nativeSubagentSessions",
"asyncTasks",
"recommendedValue",
],
});
});
@@ -8403,6 +8404,7 @@ describe("logout", () => {
"agentFileChangeReport",
"nativeSubagentSessions",
"asyncTasks",
"recommendedValue",
],
});
});

View File

@@ -79,6 +79,7 @@ describe("continuePlanInFreshContext", () => {
models: { currentModelId: "claude-sonnet" },
currentAgent: "reviewer",
fastModeEnabled: true,
effortPinnedLevel: "high",
configOptions: [
{
id: "effort",
@@ -145,6 +146,41 @@ describe("continuePlanInFreshContext", () => {
expect(host.ensureConsumer).toHaveBeenCalledWith(freshSession, "public-session");
});
it("does not turn an automatic displayed effort into a pin during restart", async () => {
const turn: ClearContextTurn = {
settled: false,
promptUuid: "00000000-0000-4000-8000-000000000000",
};
const oldSession = testSession({
activeTurn: turn,
configOptions: [
{
id: "effort",
name: "Effort",
type: "select",
currentValue: "high",
options: [{ value: "high", name: "High" }],
},
],
creationParams: {
cwd: "/workspace",
mcpServers: [],
_meta: { claudeCode: { options: { effort: "max" } } },
},
});
const host = testHost(oldSession, testSession());
await continuePlanInFreshContext(
"public-session",
oldSession,
{ toolUseId: "tool-plan", plan: "Ship it", mode: "default" },
host,
);
const params = vi.mocked(host.restartSession).mock.calls[0]?.[0];
expect((params?._meta as any)?.claudeCode?.options).not.toHaveProperty("effort");
});
it("does not resurrect original preferences after the session returns to defaults", async () => {
const turn: ClearContextTurn = {
settled: false,

View File

@@ -1078,9 +1078,22 @@ describe("createSession options merging", () => {
];
let originalAnthropicModel: string | undefined;
let originalClaudeConfigDir: string | undefined;
let configDir: string;
beforeEach(() => {
originalAnthropicModel = process.env.ANTHROPIC_MODEL;
delete process.env.ANTHROPIC_MODEL;
// These tests assert which model a fresh session lands on with no
// override in play, so both override tiers above the SDK's default have
// to be neutralized: ANTHROPIC_MODEL and `model` from the user
// settings tier. Without the second one, a developer whose own
// ~/.claude/settings.json pins a model (e.g. "opus[1m]") sees the
// session start on that model instead of models[0]. resolveSettings
// reads the user tier from CLAUDE_CONFIG_DIR at call time, so pointing
// it at an empty directory is enough.
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-model-config-"));
process.env.CLAUDE_CONFIG_DIR = configDir;
});
afterEach(() => {
if (originalAnthropicModel !== undefined) {
@@ -1088,6 +1101,12 @@ describe("createSession options merging", () => {
} else {
delete process.env.ANTHROPIC_MODEL;
}
if (originalClaudeConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
fs.rmSync(configDir, { recursive: true, force: true });
});
it("tolerates a PreModelSwitch hook vetoing the fresh-session model pin", async () => {

View File

@@ -0,0 +1,212 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { query, type ModelInfo, type Query } from "@anthropic-ai/claude-agent-sdk";
import { buildModelConfigOption, getAvailableModels } from "../session-model.js";
const OPUS: ModelInfo = {
value: "opus[1m]",
resolvedModel: "claude-opus-5[1m]",
displayName: "Opus (1M context)",
description: "Opus 5 with 1M context · Best for everyday, complex tasks",
};
describe("versioned model display names", () => {
beforeEach(() => vi.stubEnv("ANTHROPIC_MODEL", undefined));
afterEach(() => vi.unstubAllEnvs());
it("requires reviewing the live model contract whenever the SDK is upgraded", () => {
const require = createRequire(import.meta.url);
const sdkPackage = JSON.parse(
readFileSync(
join(dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), "package.json"),
"utf8",
),
);
expect(
sdkPackage.version,
"Run RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts " +
"and review the Opus name normalization before updating this version guard.",
).toBe("0.3.257");
});
it("adds standard-family versions and preserves model identity and recommendation", async () => {
const models: ModelInfo[] = [
{ ...OPUS, value: "default", displayName: "Default (recommended)" },
OPUS,
{ value: "sonnet[1m]", displayName: "Sonnet 5 (1M context)", description: "" },
{ value: "custom", displayName: "Custom Opus (1M context)", description: "" },
];
const setModel = vi.fn();
const state = await getAvailableModels(
{ setModel } as unknown as Query,
models,
models,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
expect(state.availableModels).toEqual(
models.map((model) => ({
modelId: model.value,
name: model === OPUS ? "Opus 5" : model.displayName,
description: model.description,
})),
);
expect(buildModelConfigOption(state, models, true)).toMatchObject({
currentValue: "opus[1m]",
_meta: { jetbrains: { air: { recommendedValue: "opus[1m]" } } },
options: expect.arrayContaining([
{ value: "opus[1m]", name: "Opus 5", description: OPUS.description },
]),
});
expect(OPUS.displayName).toBe("Opus (1M context)");
expect(setModel).not.toHaveBeenCalled();
});
it("adds major and minor versions without changing custom or already-versioned names", async () => {
const models: ModelInfo[] = [
{
value: "sonnet",
resolvedModel: "claude-sonnet-5",
displayName: "Sonnet",
description: "Sonnet 5 · Efficient for routine tasks",
},
{
value: "haiku",
resolvedModel: "claude-haiku-4-5-20251001",
displayName: "Claude Haiku",
description: "Fast",
},
{
value: "fable",
resolvedModel: "claude-fable-5-1",
displayName: "Fable",
description: "Fable 5.1 · Creative model",
},
{
value: "opus[1m]",
resolvedModel: "claude-opus-5[1m]",
displayName: "Opus 5 (1M context)",
description: "",
},
{ value: "custom", displayName: "My Sonnet", description: "" },
{ value: "future", displayName: "Haiku (1M context)", description: "" },
{ value: "custom-versioned", displayName: "Fable 5.1 (1M context)", description: "" },
];
const state = await getAvailableModels(
{} as Query,
models,
models,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
expect(state.availableModels.map((model) => model.name)).toEqual([
"Sonnet 5",
"Claude Haiku 4.5",
"Fable 5.1",
"Opus 5 (1M context)",
"My Sonnet",
"Haiku (1M context)",
"Fable 5.1 (1M context)",
]);
});
it("only moves the context hint out of the name when the description retains it", async () => {
const models: ModelInfo[] = [
{
value: "opus-described[1m]",
resolvedModel: "claude-opus-5[1m]",
displayName: "Opus 5 (1M context)",
description: "Opus 5 with 1M context",
},
{
value: "opus-undescribed[1m]",
resolvedModel: "claude-opus-5[1m]",
displayName: "Opus 5 (1M context)",
description: "",
},
{
value: "fable-undescribed[1m]",
resolvedModel: "claude-fable-5-1[1m]",
displayName: "Fable (1M context)",
description: "",
},
];
const state = await getAvailableModels(
{} as Query,
models,
models,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
expect(state.availableModels.map((model) => model.name)).toEqual([
"Opus 5",
"Opus 5 (1M context)",
"Fable 5.1 (1M context)",
]);
});
it("keeps the suffix if another selectable model is already named Opus", async () => {
const models = [OPUS, { ...OPUS, value: "opus", displayName: "Opus" }];
const state = await getAvailableModels(
{} as Query,
models,
models,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]);
});
});
// Account-dependent model availability requires the same authenticated environment
// as the adapter. No prompt is sent: inspect only the SDK initialization response.
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"the real SDK exposes one named Opus with the expected context suffix",
async () => {
let finishInput!: () => void;
const inputGate = new Promise<void>((resolve) => {
finishInput = resolve;
});
// Keep stdin open for initialization without ever submitting a prompt.
// eslint-disable-next-line require-yield
async function* input() {
await inputGate;
}
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), 25000);
const q = query({ prompt: input(), options: { persistSession: false, abortController } });
try {
const { models } = await q.initializationResult();
const opusModels = models.filter(
(model) =>
model.value !== "default" &&
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
);
expect(opusModels).toHaveLength(1);
expect(opusModels[0]).toMatchObject({
value: "opus[1m]",
displayName: "Opus (1M context)",
resolvedModel: expect.stringMatching(/^claude-opus-.*\[1m\]$/),
description: expect.stringContaining("1M context"),
});
} finally {
clearTimeout(timeout);
finishInput();
q.close();
}
},
30000,
);

View File

@@ -6,8 +6,8 @@ import {
resolveModelPreference,
applyAvailableModelsAllowlist,
matchResumedModel,
settingsEffortForModel,
} from "../acp-agent.js";
} from "../session-model.js";
import { settingsEffortForModel } from "../session-effort.js";
// Mirrors a real `supportedModels()` response: alias rows carry
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
@@ -296,6 +296,20 @@ describe("settingsEffortForModel", () => {
expect(settingsEffortForModel(byRawId, undefined, "claude-x")).toBe("xhigh");
});
it("matches equivalent -1m and [1m] modelSettings keys", () => {
const settings = {
effortLevel: "high" as const,
modelSettings: { "claude-sonnet-5-1m": { effortLevel: "low" as const } },
};
expect(
settingsEffortForModel(settings, {
...SONNET,
value: "sonnet[1m]",
resolvedModel: "claude-sonnet-5[1m]",
}),
).toBe("low");
});
it("falls back to the top-level effortLevel when no per-model entry matches", () => {
expect(settingsEffortForModel({ effortLevel: "high" }, SONNET)).toBe("high");
expect(

View File

@@ -0,0 +1,282 @@
import { describe, expect, it } from "vitest";
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../acp-agent.js";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
const MODES = {
currentModeId: "default",
availableModes: [
{ id: "default", name: "Manual", description: "Always ask before making changes" },
{ id: "plan", name: "Plan", description: "Create a plan before making changes" },
],
};
const MODELS = {
currentModelId: "default",
availableModels: [
{ modelId: "default", name: "Default" },
{ modelId: "opus", name: "Claude Opus" },
{ modelId: "sonnet", name: "Claude Sonnet" },
{ modelId: "haiku", name: "Claude Haiku" },
],
};
const MODEL_INFOS: ModelInfo[] = [
{
value: "default",
displayName: "Default",
description: "",
resolvedModel: "claude-sonnet-4-6",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "opus",
displayName: "Claude Opus",
description: "Most capable",
resolvedModel: "claude-opus-4-6",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "sonnet",
displayName: "Claude Sonnet",
description: "Balanced",
resolvedModel: "claude-sonnet-4-6",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
},
{
value: "haiku",
displayName: "Claude Haiku",
description: "Fastest",
resolvedModel: "claude-haiku-4-5",
},
];
const RECOMMENDED_PRESENTATION = { useRecommendedValue: true };
function recommendedMeta(value: string) {
return { jetbrains: { air: { version: 1, recommendedValue: value } } };
}
function selectOption(options: ReturnType<typeof buildConfigOptions>, id: string) {
const option = options.find((candidate) => candidate.id === id);
expect(option).toMatchObject({ id, type: "select" });
return option as Extract<(typeof options)[number], { type: "select" }>;
}
describe("recommended config value capability", () => {
it("is enabled through the common AIR capability list", () => {
const capabilities: ClientCapabilities = {
_meta: {
jetbrains: {
air: {
version: 1,
capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY],
},
},
},
};
expect(clientSupportsRecommendedConfigValue(capabilities)).toBe(true);
});
it("keeps legacy behavior for omitted and malformed AIR metadata", () => {
expect(clientSupportsRecommendedConfigValue(undefined)).toBe(false);
expect(clientSupportsRecommendedConfigValue(null)).toBe(false);
expect(clientSupportsRecommendedConfigValue({})).toBe(false);
expect(
clientSupportsRecommendedConfigValue({
_meta: {
jetbrains: {
air: { version: 1, capabilities: ["someOtherCapability"] },
},
},
}),
).toBe(false);
expect(
clientSupportsRecommendedConfigValue({
_meta: {
jetbrains: {
air: {
version: "1",
capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY],
},
},
},
}),
).toBe(false);
});
});
describe("buildConfigOptions recommended values presentation", () => {
it("retains default model and effort entries without the capability", () => {
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined);
const model = selectOption(options, "model");
expect(model.currentValue).toBe("default");
expect(model.options).toEqual(
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
);
expect(model._meta).toBeUndefined();
const effort = selectOption(options, "effort");
expect(effort.currentValue).toBe("default");
expect(effort.options).toEqual(
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
);
expect(effort._meta).toBeUndefined();
});
it("omits default entries and advertises concrete recommendations", () => {
const options = buildConfigOptions(
MODES,
MODELS,
MODEL_INFOS,
undefined,
[],
"default",
undefined,
RECOMMENDED_PRESENTATION,
);
const mode = selectOption(options, "mode");
expect(mode.currentValue).toBe("default");
expect(mode.options).toEqual(
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
);
expect(mode._meta).toBeUndefined();
const model = selectOption(options, "model");
expect(model.currentValue).toBe("sonnet");
expect(model.options).toHaveLength(3);
expect(model.options.map((option) => ("value" in option ? option.value : undefined))).toEqual([
"opus",
"sonnet",
"haiku",
]);
expect(model.options).not.toEqual(
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
);
expect(model._meta).toEqual(recommendedMeta("sonnet"));
const effort = selectOption(options, "effort");
expect(effort.currentValue).toBe("medium");
expect(effort.options).not.toEqual(
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
);
expect(effort._meta).toEqual(recommendedMeta("medium"));
});
it("preserves explicit selections while keeping independent recommendations", () => {
const options = buildConfigOptions(
MODES,
{ ...MODELS, currentModelId: "opus" },
MODEL_INFOS,
"high",
[],
"default",
undefined,
RECOMMENDED_PRESENTATION,
);
expect(selectOption(options, "model")).toMatchObject({
currentValue: "opus",
_meta: recommendedMeta("sonnet"),
});
expect(selectOption(options, "effort")).toMatchObject({
currentValue: "high",
_meta: recommendedMeta("medium"),
});
});
it("keeps the legacy model sentinel when the SDK recommendation is not selectable", () => {
const options = buildConfigOptions(
MODES,
MODELS,
MODEL_INFOS.filter((model) => model.value === "default"),
undefined,
[],
"default",
undefined,
RECOMMENDED_PRESENTATION,
);
const model = selectOption(options, "model");
expect(model.currentValue).toBe("default");
expect(model.options).toEqual(
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
);
expect(model._meta).toBeUndefined();
});
it.each(["claude-sonnet-4-6[1m]", "claude-sonnet-5"])(
"does not substitute a similar model for the unavailable default %s",
(resolvedModel) => {
const infos = MODEL_INFOS.map((model) =>
model.value === "default" ? { ...model, resolvedModel } : model,
);
const options = buildConfigOptions(
MODES,
MODELS,
infos,
undefined,
[],
"default",
undefined,
RECOMMENDED_PRESENTATION,
);
expect(selectOption(options, "model")).toMatchObject({ currentValue: "default" });
expect(selectOption(options, "model")._meta).toBeUndefined();
},
);
it("matches equivalent SDK context suffix spellings exactly", () => {
const infos = MODEL_INFOS.map((model) => ({
...model,
resolvedModel:
model.value === "default"
? "claude-sonnet-4-6-1m"
: model.value === "sonnet"
? "claude-sonnet-4-6[1m]"
: model.resolvedModel,
}));
const options = buildConfigOptions(
MODES,
MODELS,
infos,
undefined,
[],
"default",
undefined,
RECOMMENDED_PRESENTATION,
);
expect(selectOption(options, "model")).toMatchObject({
currentValue: "sonnet",
_meta: recommendedMeta("sonnet"),
});
});
it("uses an available effort when medium is not supported", () => {
const infos = MODEL_INFOS.map((model) => ({
...model,
supportedEffortLevels: ["high", "max"] as ModelInfo["supportedEffortLevels"],
}));
const options = buildConfigOptions(
MODES,
MODELS,
infos,
undefined,
[],
"default",
undefined,
RECOMMENDED_PRESENTATION,
);
expect(selectOption(options, "effort")).toMatchObject({
currentValue: "high",
_meta: recommendedMeta("high"),
});
});
});

View File

@@ -430,7 +430,7 @@ describe("session config options", () => {
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.effortPinnedByUser = true;
session.effortPinnedLevel = "max";
session.modelInfos = [
{
@@ -467,7 +467,7 @@ describe("session config options", () => {
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";
session.effortPinnedByUser = true;
session.effortPinnedLevel = "low";
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
@@ -565,6 +565,23 @@ describe("session config options", () => {
expect(effortOption?.currentValue).toBe("medium");
});
it("keeps effort state unchanged when the SDK rejects a direct selection", async () => {
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort update failed"));
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "effort",
value: "low",
}),
).rejects.toThrow("effort update failed");
const session = agent.sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("default");
expect(session.effortPinnedLevel).toBeUndefined();
expect(session.appliedEffortLevel).toBeUndefined();
});
it("throws for invalid effort value", async () => {
await expect(
agent.setSessionConfigOption({
@@ -607,6 +624,192 @@ describe("session config options", () => {
populateSession();
});
it("applies concrete defaults and re-seeds automatic effort from each model's settings", async () => {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
const session = agent.sessions[SESSION_ID];
session.settingsManager.getSettings = () => ({
modelSettings: { "claude-opus-4-5": { effortLevel: "high" } },
});
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(response.configOptions.find((o) => o.id === "effort")).toMatchObject({
currentValue: "medium",
_meta: { jetbrains: { air: { recommendedValue: "medium" } } },
});
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "medium" });
expect(session.effortPinnedLevel).toBeUndefined();
expect(session.appliedEffortLevel).toBe("medium");
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-opus-4-5",
});
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "high" });
expect(session.effortPinnedLevel).toBeUndefined();
expect(session.appliedEffortLevel).toBe("high");
session.modelInfos[1].supportsEffort = false;
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: null });
});
it("re-seeds switches from retained programmatic settings before file settings", async () => {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
const session = agent.sessions[SESSION_ID];
session.settingsManager.getSettings = () => ({ effortLevel: "high" });
session.effortSettingsOverride = {
effortLevel: "medium",
modelSettings: { "claude-sonnet-4-6": { effortLevel: "low" } },
};
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "low" });
});
it("clears an unsupported user pin before choosing the new model's concrete effort", async () => {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
const session = agent.sessions[SESSION_ID];
session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"];
session.settingsManager.getSettings = () => ({ effortLevel: "low" });
session.configOptions.find((o) => o.id === "effort")!.currentValue = "max";
session.effortPinnedLevel = "max";
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "low" });
expect(session.effortPinnedLevel).toBeUndefined();
expect(session.appliedEffortLevel).toBe("low");
});
it("clears a legacy pin without promoting persisted effort to a flag override", async () => {
const session = agent.sessions[SESSION_ID];
session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"];
session.settingsManager.getSettings = () => ({ effortLevel: "low" });
session.configOptions.find((o) => o.id === "effort")!.currentValue = "max";
session.effortPinnedLevel = "max";
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: null });
expect(session.effortPinnedLevel).toBeUndefined();
expect(session.appliedEffortLevel).toBeUndefined();
});
it("retains the original pin value when a legacy clamp fails", async () => {
const session = agent.sessions[SESSION_ID];
session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"];
session.settingsManager.getSettings = () => ({ effortLevel: "low" });
session.configOptions.find((o) => o.id === "effort")!.currentValue = "max";
session.effortPinnedLevel = "max";
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort clear failed"));
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(session.configOptions.find((o) => o.id === "effort")).toBeUndefined();
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-opus-4-5",
});
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("max");
expect(session.effortPinnedLevel).toBe("max");
});
it("restores the last applied effort when recommended-value synchronization fails", async () => {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
const session = agent.sessions[SESSION_ID];
session.configOptions.find((o) => o.id === "effort")!.currentValue = "low";
session.appliedEffortLevel = "low";
session.settingsManager.getSettings = () => ({ effortLevel: "high" });
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed"));
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
expect(session.appliedEffortLevel).toBe("low");
expect(session.effortPinnedLevel).toBeUndefined();
});
it("returns the new model state when effort synchronization fails", async () => {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed"));
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
expect(response.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
"claude-sonnet-4-6",
);
expect(agent.sessions[SESSION_ID].models.currentModelId).toBe("claude-sonnet-4-6");
});
it("publishes the new model state when external-switch effort synchronization fails", async () => {
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
};
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed"));
const session = agent.sessions[SESSION_ID];
await (agent as any).syncModelAfterExternalSwitch(SESSION_ID, session, "claude-sonnet-4-6");
expect(setModelSpy).not.toHaveBeenCalled();
expect(session.models.currentModelId).toBe("claude-sonnet-4-6");
expect(
sessionUpdates
.filter((notification) => notification.update.sessionUpdate === "config_option_update")
.at(-1)?.update,
).toMatchObject({
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-6" }),
]),
});
});
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];
@@ -670,7 +873,7 @@ describe("session config options", () => {
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
// The clamp un-pins: a later switch back re-seeds from settings.
expect(session.effortPinnedByUser).toBe(false);
expect(session.effortPinnedLevel).toBeUndefined();
});
it("adds effort option when switching to a model that supports effort", async () => {
@@ -713,7 +916,7 @@ describe("session config options", () => {
// pinned, as a user's ACP picker choice would be.
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
if (effortOpt) effortOpt.currentValue = "max";
session.effortPinnedByUser = true;
session.effortPinnedLevel = "max";
session.modelInfos = [
{

View File

@@ -5,7 +5,12 @@ export default defineConfig({
watch: false,
globals: true,
environment: "node",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
include: [
"src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}",
// Release tooling lives in scripts/ rather than src/ so it stays outside tsc's
// rootDir and out of the published tarball; its tests have to follow it there.
"scripts/**/*.{test,spec}.mjs",
],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],