Compare commits

...

17 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
acp-release-bot[bot]
3e23c5b960 chore(main): release 0.75.1 (#1091)
🤖 I have created a release *beep* *boop*
---


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


### Bug Fixes

* restore session forks and speed up loading
([#1089](https://github.com/agentclientprotocol/claude-agent-acp/issues/1089))
([f5e79f5](f5e79f55f8))

---
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-05 18:35:31 +04:00
nikita-ashihmin
f5e79f55f8 fix: restore session forks and speed up loading (#1089)
## Summary

- resolve AIR fork points from the complete persisted transcript when
the target is outside the active parent chain
- support message fingerprints and repeated assistant content when
resolving persisted fork points
- restore resumed models from the same local assistant transcript record
Claude Code uses
- remove all automatic `getContextUsage` control requests; use
`compact_metadata.post_tokens` after compaction
- add structured timing logs for fork, load, replay, and session
creation phases

## Performance

Cold-loading the reproduced 69-message forked session dropped from 20–29
seconds to 1.9 seconds. Transcript model lookup took 5 ms and replay
took 14 ms; the remaining time was SDK initialization.

## Tests

- `env -u ANTHROPIC_BASE_URL npm run test:run` (1230 passed, 27 skipped)
- `npm run build`
- `npm run lint`
- Prettier check for all changed files
- real ACP cold-load probe against the reproduced forked session
2026-09-05 16:43:25 +04:00
acp-release-bot[bot]
a0122dae4c chore(main): release 0.75.0 (#1083)
🤖 I have created a release *beep* *boop*
---


##
[0.75.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.74.0...v0.75.0)
(2026-09-05)


### Features

* **auth:** report the agent's auth identity over ACP (authStatus
extension)
([#1080](https://github.com/agentclientprotocol/claude-agent-acp/issues/1080))
([eb5ca9f](eb5ca9fd17))
* render usage statistics as Markdown
([#1085](https://github.com/agentclientprotocol/claude-agent-acp/issues/1085))
([2081767](20817678f6))
* surface context compaction as an ACP tool lifecycle
([#991](https://github.com/agentclientprotocol/claude-agent-acp/issues/991))
([f74a517](f74a51758d))

---
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-05 10:53:44 +02:00
nikita-ashihmin
20817678f6 feat: render usage statistics as Markdown (#1085)
## Summary

- recognize Claude Code local /usage output
- render subscription limits and MCP contributions as structured
Markdown with progress bars
- keep usage parsing and rendering in a dedicated module with unit
coverage
- preserve existing handling for other local commands and unknown future
usage formats

## Testing

- npm run check
- npm run build
- env -u ANTHROPIC_BASE_URL npm run test:run (1210 passed, 27 skipped)
2026-09-05 12:00:43 +04:00
nikita-ashihmin
f74a51758d feat: surface context compaction as an ACP tool lifecycle (#991)
## Summary

- surface Claude context compaction as a synthetic ACP tool lifecycle
instead of assistant text
- emit provider-neutral `_meta.contextCompaction` metadata with schema
version 1
- handle status, stream heartbeat, compact-boundary, terminal-only,
failure, and duplicate terminal event shapes
- keep the generated compaction summary internal to the agent

## Protocol

Compaction tool calls use `kind: "think"`, title `Compact conversation`,
standard ACP lifecycle statuses, and optional metadata fields `trigger`,
`preTokens`, `postTokens`, `durationMs`, and `error`.

## Testing

- `npm run test:run` (769 passed, 26 skipped)
- `npm run build`
- `npm run check`
2026-09-04 16:28:48 +04:00
Dmitry Kozhevnikov
eb5ca9fd17 feat(auth): report the agent's auth identity over ACP (authStatus extension) (#1080) 2026-09-04 11:55:57 +00:00
acp-release-bot[bot]
847a3b6429 chore(main): release 0.74.0 (#1082) 2026-09-04 13:03:43 +02:00
Dmitry Kozhevnikov
da9f7e5695 feat(auth): refuse claude.ai subscriptions under --hide-claude-auth (#1079)
Stacked on #1071. The first three commits are that PR; review the last
commit here.

## What

`--hide-claude-auth` hid the claude.ai login method from `initialize`,
but nothing stopped a session from billing a subscription that was
already stored. This moves the decision into `src/hide-claude-auth.ts`
with two layers:

1. Session creation refuses an account that holds no credential this
integration accepts, so a logged-out session never exists.
2. A sign-out during the session ends the query. The next turn recreates
it, so the next `initialize` reports the real account.

A per-turn guard repeats the check on the account the SDK cached at
`initialize`. It reads no process and costs microseconds. It covers a
session created under a provider override that `providers/disable` later
removed.

"Bills a subscription" is an allowlist of `apiKeySource` values that
outrank a stored subscription, so an unknown SDK value keeps the guard
on. The guard fails open when the account cannot be read at all, and
warns once per session.

## Client-visible additions (all additive)

- The `authRequired` JSON-RPC error carries `data.reason:
"claude_subscription_not_supported"` when the guard blocks a turn. Same
error code and same message string as before. A plain sign-out still
carries no `data`.
- `PublishedSessionFailure` gains an optional `reason`, emitted in the
failure `_meta` only when set. `hasActiveSessionError` matches on it, so
the subscription refusal and a plain sign-out cannot suppress each
other.

## Behavior changes not behind the flag

- `providers/set` and `providers/disable` no longer reject the whole
call when one session cannot be recreated. The session gets a
per-session failure row, the switch goes on to the next session. The
affected session is the one signal for that case; AIR never reaches it,
because it sends `providers/set` only while a pooled client is created,
before any session exists.
- `authenticate` validates a gateway payload that is present. A call
with no `_meta.gateway` keeps its historical meaning: no override,
success.
- A failed session creation closes the query it spawned instead of
leaking the CLI process.

## Compatibility

Checked against Zed and the JetBrains clients (`plugins/llm`, AIR main
and stable). No consumer sends a gateway `authenticate` without a
payload, none passes `--hide-claude-auth` by default, every one already
handles the `auth_required` JSON-RPC rejection, and every `_meta` /
error `data` parser ignores unknown fields.

## Test plan

- `npm run build`, `npm run check`: clean.
- `npm run test:run`: 1153 passed, 27 skipped.
- New tests in `src/tests/authorization.test.ts` cover the guard end to
end: creation refusal, per-turn refusal, sign-out respawn, provider
override on/off, gateway payload validation, degraded guard.

## Follow-up commit in this PR

- `fix(auth): start a fresh query when a signed-out session cannot be
resumed` — when the CLI never persisted the session, `resume` answers
`resourceNotFound`; the respawn now starts a fresh query under the same
ACP session id instead of failing every later prompt.

Tests after it: 1156 passed, 27 skipped.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:56:28 +00:00
acp-release-bot[bot]
ea7076c0bc chore(main): release 0.73.0 (#1067)
🤖 I have created a release *beep* *boop*
---


##
[0.73.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.72.0...v0.73.0)
(2026-09-01)


### Features

* **deps:** update Claude SDK
([#1066](https://github.com/agentclientprotocol/claude-agent-acp/issues/1066))
([b4e3aaa](b4e3aaa727))

---
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-01 22:25:37 +02:00
Andrey Bragin
b4e3aaa727 feat(deps): update Claude SDK (#1066) 2026-09-01 22:22:35 +02:00
39 changed files with 9103 additions and 1302 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.72.0"
".": "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,44 @@
# 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)
### Bug Fixes
* restore session forks and speed up loading ([#1089](https://github.com/agentclientprotocol/claude-agent-acp/issues/1089)) ([f5e79f5](https://github.com/agentclientprotocol/claude-agent-acp/commit/f5e79f55f8ba8d1cd0a72cbc78bfea726bea2f00))
## [0.75.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.74.0...v0.75.0) (2026-09-05)
### Features
* **auth:** report the agent's auth identity over ACP (authStatus extension) ([#1080](https://github.com/agentclientprotocol/claude-agent-acp/issues/1080)) ([eb5ca9f](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb5ca9fd17b6af4a4566be4ad75b64588ce60b52))
* render usage statistics as Markdown ([#1085](https://github.com/agentclientprotocol/claude-agent-acp/issues/1085)) ([2081767](https://github.com/agentclientprotocol/claude-agent-acp/commit/20817678f68083a2997d1bc45ef20241a9097220))
* surface context compaction as an ACP tool lifecycle ([#991](https://github.com/agentclientprotocol/claude-agent-acp/issues/991)) ([f74a517](https://github.com/agentclientprotocol/claude-agent-acp/commit/f74a51758dc42896addbcdaf7611a29ec1d1db17))
## [0.74.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.73.0...v0.74.0) (2026-09-04)
### Features
* **auth:** refuse claude.ai subscriptions under `--hide-claude-auth` ([#1079](https://github.com/agentclientprotocol/claude-agent-acp/issues/1079)) ([da9f7e5](https://github.com/agentclientprotocol/claude-agent-acp/commit/da9f7e56956c770bd1339151d6166a011fe39174))
## [0.73.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.72.0...v0.73.0) (2026-09-01)
### Features
* **deps:** update Claude SDK ([#1066](https://github.com/agentclientprotocol/claude-agent-acp/issues/1066)) ([b4e3aaa](https://github.com/agentclientprotocol/claude-agent-acp/commit/b4e3aaa727821a85cef6dcf51452381d35fd68a5))
## [0.72.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.71.0...v0.72.0) (2026-09-01)

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.

496
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.72.0",
"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",
@@ -66,7 +66,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.252",
"@anthropic-ai/claude-agent-sdk": "0.3.257",
"zod": "^4.0.0"
},
"devDependencies": {
@@ -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);
});
});

File diff suppressed because it is too large Load Diff

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";

337
src/auth-status.ts Normal file
View File

@@ -0,0 +1,337 @@
/**
* `authStatus` — interim `_meta`-based ACP extension that reports which auth
* identity the agent process itself uses (Claude subscription, API key,
* gateway, external cloud credentials, or nothing).
*
* Push only. One carrier, connection-scoped (never per session): the
* notification `_auth/status_update` → `{authStatus}`. The client never asks;
* there is no request method. The empty `agentCapabilities._meta.authStatus`
* marker means "this agent pushes its identity", nothing more.
*
* The agent reads the identity at these moments:
* - `initialize`, with an asynchronous CLI probe. Nothing waits for it, and its
* result is the first push of the connection;
* - a completed `authenticate` or a `logout`;
* - each `createSession`, from the session account. A refused session reports
* too: the client must know which account was refused;
* - each turn the `--hide-claude-auth` guard checks. The guard reads the
* account anyway, so this costs nothing;
* - the START of each user prompt, with an asynchronous CLI probe. This finds a
* login or a logout done in another terminal, including the sign-in a client
* performs after a refusal, because the retried prompt is a new prompt.
* Nothing waits for it and the push goes out mid-turn if that is when the
* read lands.
*
* A probe whose kind differs from a live session's account marks that session
* for recreation under `--hide-claude-auth`. The mark is a flag, consumed at
* the next turn boundary: a running turn is never interrupted. The probe judges
* nothing itself — the next turn runs a new `initialize`, and the creation
* guard decides on the account it reports.
*
* A read does not always cause a push. The agent sends `_auth/status_update`
* only when the payload is different from the last one it sent.
*
* "Cannot determine" is expressed by silence: an agent that cannot read its own
* identity pushes nothing, and the client shows "not reported". A known
* logged-out state is a payload of its own (`kind: "none"`), which is what
* distinguishes the two.
*
* Reporting only — there is no write path in v1. When the upstream RFD lands,
* the same payload moves from `_meta` to first-class fields and this module
* is retired.
*/
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
/** Change notification, agent → client. Fire and forget: clients that do not
* know the method drop it silently, so it is sent unconditionally. */
export const AUTH_STATUS_UPDATE_METHOD = "_auth/status_update";
/** Upper bound on one `claude auth status --json` run, so a hung CLI cannot
* wedge every later probe that joins it. */
export const AUTH_STATUS_PROBE_TIMEOUT_MS = 5000;
export type AuthStatusKind = "account" | "api_key" | "gateway" | "external" | "none";
export type AuthStatusAccount = {
email?: string;
organization?: string;
/** Vendor plan/license string, not normalized. */
plan?: string;
};
export type AuthStatus = {
kind: AuthStatusKind;
/** Human-readable and usable as a UI string on its own. The "type" line:
* "Claude Max", "Anthropic API key", "AWS Bedrock". */
label: string;
/** Optional second line carrying the specifics (key source, gateway host, …).
* Clients render it under `label`, falling back to `account.email`. */
detail?: string;
account?: AuthStatusAccount;
/** Vendor-namespaced extras, e.g. `{claudeCode: {...}}`. */
vendor?: Record<string, unknown>;
};
export type AuthStatusUpdateNotification = { authStatus: AuthStatus };
/** Advertisement carried in `agentCapabilities._meta.authStatus`, never a
* status payload: its mere presence means "this agent pushes its identity",
* and it stays empty. Informational — a client that never saw it still
* accepts `_auth/status_update`, because there is nothing to ask for. */
export type AuthStatusCapability = Record<string, never>;
export function authStatusCapability(): AuthStatusCapability {
return {};
}
/** Shape of `claude auth status --json`. Absent fields are omitted, not null,
* and the logged-out case exits 1 while still printing valid JSON. */
export type CliAuthStatus = {
loggedIn?: boolean;
authMethod?: string;
apiProvider?: string;
apiKeySource?: string;
email?: string;
orgId?: string;
orgName?: string;
subscriptionType?: string;
};
const NOT_LOGGED_IN: AuthStatus = {
kind: "none",
label: "Not logged in",
};
/** Display names for the non-firstParty backends, where auth lives outside the
* agent (AWS credentials, gcloud ADC, …). */
const EXTERNAL_PROVIDER_LABELS: Record<string, string> = {
bedrock: "AWS Bedrock",
vertex: "Google Vertex AI",
foundry: "Azure AI Foundry",
anthropicAws: "Anthropic on AWS",
anthropicGoogleCloud: "Anthropic on Google Cloud",
mantle: "Mantle",
};
export function notLoggedInAuthStatus(): AuthStatus {
return { ...NOT_LOGGED_IN };
}
/** ACP-level gateway auth (`authenticate` with `gateway`/`gateway-bedrock`).
* The gateway owns the credentials, so it wins over anything the CLI reports. */
export function gatewayAuthStatus(baseUrl?: string): AuthStatus {
let host: string | undefined;
if (baseUrl) {
try {
host = new URL(baseUrl).host;
} catch {
host = baseUrl;
}
}
return {
kind: "gateway",
label: "Custom model gateway",
...(host ? { detail: host } : {}),
};
}
/**
* Does this `AccountInfo` say anything about the identity?
*
* The SDK always fills `apiProvider`, but the identity fields come from the
* claude.ai profile: with an `apiKeyHelper` and no subscription login the
* session reports `{apiProvider: "firstParty"}` and nothing else. That is "no
* information", not "logged out" — an empty read must never be mapped to
* `none`, or it destroys what the CLI probe already established.
*
* `tokenSource` is deliberately not a signal: the CLI sets it to the OAuth
* token's origin (`CLAUDE_CODE_OAUTH_TOKEN`,
* `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, …), never to a key source, so it
* cannot attribute a key and carries no plan of its own.
*/
export function accountInfoHasIdentitySignal(account: AccountInfo | undefined): boolean {
if (!account) return false;
return Boolean(
account.apiKeySource ||
account.subscriptionType ||
(account.apiProvider && account.apiProvider !== "firstParty"),
);
}
/** Session-time mapping from the SDK's `AccountInfo`, the richest source when
* it is populated. Returns `undefined` when the account carries no identity
* signal, so callers keep the status they already know. */
export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | undefined {
if (!account || !accountInfoHasIdentitySignal(account)) {
return undefined;
}
return mapAuthFields({
apiProvider: account.apiProvider,
subscriptionType: account.subscriptionType,
apiKeySource: account.apiKeySource,
email: account.email,
organization: account.organization,
});
}
/** Pre-session mapping from `claude auth status --json` stdout. Returns
* `undefined` when the output is not the expected JSON object, so callers can
* report "not known" instead of guessing. */
export function fromCliStatus(stdout: string): AuthStatus | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return undefined;
}
const status = parsed as CliAuthStatus;
if (typeof status.loggedIn !== "boolean") {
return undefined;
}
// `loggedIn` tracks the claude.ai login only. On a 3P backend (Bedrock,
// Vertex, …) it is false while the credentials live outside the agent, so the
// backend must decide the kind before the logged-out shortcut runs.
const externalBackend = Boolean(status.apiProvider && status.apiProvider !== "firstParty");
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
return notLoggedInAuthStatus();
}
return mapAuthFields({
apiProvider: status.apiProvider,
subscriptionType: status.subscriptionType,
apiKeySource: status.apiKeySource,
email: status.email,
organization: status.orgName,
});
}
/** Single mapping table shared by both sources; see the `kind`/`label` table in
* `extension-authstatus-meta.md`. Order matters: a 3P backend or a gateway
* decides the kind before any firstParty identity fields are considered, and
* an API key outranks a stored subscription login.
*
* Both can be reported at once — an `apiKeyHelper` in settings.json next to a
* claude.ai OAuth login yields `apiKeySource` AND `subscriptionType`. Claude
* Code's authentication precedence puts `apiKeyHelper` above the `/login`
* subscription, so the key is what actually pays for the turn and is what we
* report. */
function mapAuthFields(fields: {
apiProvider?: string;
subscriptionType?: string;
apiKeySource?: string;
email?: string;
organization?: string;
}): AuthStatus {
const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields;
if (apiProvider === "gateway") {
return gatewayAuthStatus();
}
if (apiProvider && apiProvider !== "firstParty") {
return {
kind: "external",
label: EXTERNAL_PROVIDER_LABELS[apiProvider] ?? apiProvider,
};
}
if (apiKeySource) {
return {
kind: "api_key",
label: "Anthropic API key",
// The source ("apiKeyHelper", "env", …) is the specifics line.
detail: apiKeySource,
};
}
if (subscriptionType) {
// `account.plan` keeps the raw vendor string; only the label is titled.
const account: AuthStatusAccount = { plan: subscriptionType };
if (email) account.email = email;
if (organization) account.organization = organization;
return {
kind: "account",
label: planLabel(subscriptionType),
// No detail: the client falls back to `account.email` for line 2.
account,
};
}
return notLoggedInAuthStatus();
}
/** Names the plan with exactly one "Claude". A newer CLI already reports
* "Claude Max", an older one reports "max", and both must read "Claude Max".
* Each word is title-cased; a word that already carries capitals is kept. */
function planLabel(plan: string): string {
const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
}
/**
* Do two payloads describe the same login? Compared on the field that
* identifies each kind — the key source for `api_key`, the email for
* `account`, the kind alone for the rest, which carry no per-identity key.
*/
export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
if (a.kind !== b.kind) {
return false;
}
switch (a.kind) {
case "api_key":
return a.detail === b.detail;
case "account":
return (a.account?.email ?? "") === (b.account?.email ?? "");
default:
return true;
}
}
/**
* Folds a fresh read into what is already known. Sources differ in richness for
* the very same login: the SDK's session `AccountInfo` can carry an
* organization the CLI probe omits. So when the read describes the same
* identity, its values win field by field but the fields it lacks are kept;
* when it describes a different identity, it replaces the old one wholesale.
*/
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
if (!previous || !sameIdentity(previous, next)) {
return next;
}
const merged: AuthStatus = { ...next };
if (merged.detail === undefined && previous.detail !== undefined) {
merged.detail = previous.detail;
}
if (previous.account || next.account) {
merged.account = { ...previous.account, ...next.account };
}
if (merged.vendor === undefined && previous.vendor !== undefined) {
merged.vendor = previous.vendor;
}
return merged;
}
/**
* Do two payloads carry the same information? Unlike {@link sameIdentity},
* which asks "is this the same login", this compares every rendered field, so
* a richer read of one login is NOT the same. Callers use it to suppress a
* push that would tell the client nothing new.
*
* `a` undefined means "nothing reported yet", which never equals a payload.
*/
export function sameAuthStatus(a: AuthStatus | undefined, b: AuthStatus): boolean {
if (!a) {
return false;
}
return (
a.kind === b.kind &&
a.label === b.label &&
a.detail === b.detail &&
a.account?.email === b.account?.email &&
a.account?.organization === b.account?.organization &&
a.account?.plan === b.account?.plan &&
// Vendor extras are an open bag, so compare them structurally. Both sides
// are built by this module from the same key order, so a string compare is
// enough and costs nothing on the usual `undefined`/`undefined` pair.
JSON.stringify(a.vendor) === JSON.stringify(b.vendor)
);
}

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 }
: {}),
},
},

View File

@@ -0,0 +1,29 @@
export const CONTEXT_COMPACTION_META_KEY = "contextCompaction";
export const CONTEXT_COMPACTION_META_VERSION = 1;
export type ContextCompactionTrigger = "manual" | "automatic";
export interface ContextCompactionMetadata {
version: typeof CONTEXT_COMPACTION_META_VERSION;
trigger?: ContextCompactionTrigger;
preTokens?: number;
postTokens?: number;
durationMs?: number;
error?: string;
}
/**
* Provider-neutral metadata for a synthetic ACP context-compaction tool call.
* The standard toolCallId and status fields own lifecycle identity and phase;
* this extension carries only compaction-specific facts.
*/
export function createContextCompactionMeta(
metadata: Omit<ContextCompactionMetadata, "version"> = {},
): Record<typeof CONTEXT_COMPACTION_META_KEY, ContextCompactionMetadata> {
return {
[CONTEXT_COMPACTION_META_KEY]: {
version: CONTEXT_COMPACTION_META_VERSION,
...metadata,
},
};
}

190
src/context-compaction.ts Normal file
View File

@@ -0,0 +1,190 @@
import { SessionNotification } from "@agentclientprotocol/sdk";
import {
ContextCompactionMetadata,
createContextCompactionMeta,
} from "./context-compaction-meta.js";
type CompactionStatus = "completed" | "failed";
type CompactionState = {
toolCallId: string;
terminalStatus?: CompactionStatus;
heartbeatSent: boolean;
};
type SendUpdate = (notification: SessionNotification) => Promise<void>;
/**
* Translates Claude's compaction signals into one idempotent ACP tool lifecycle.
*
* The SDK can duplicate terminal compact_result messages and can omit the
* opening status on replay. State therefore lives until the owning turn's
* result (or abort), while a new compacting status after a terminal outcome
* starts a fresh lifecycle.
*/
export class ContextCompactionLifecycle {
private activeCompaction: CompactionState | undefined;
private outputDelivered = false;
private duplicateErrorOutput: string | undefined;
constructor(private readonly sendUpdate: SendUpdate) {}
get hasDeliveredOutput(): boolean {
return this.outputDelivered;
}
reset(): void {
this.activeCompaction = undefined;
this.outputDelivered = false;
this.duplicateErrorOutput = undefined;
}
/**
* Claude also emits a failed manual compaction's error as local-command
* stdout. Consume that one duplicate after the tool lifecycle carried it,
* without hiding unrelated command output.
*/
consumeDuplicateErrorOutput(content: string): boolean {
if (
this.duplicateErrorOutput === undefined ||
content.trim() !== this.duplicateErrorOutput.trim()
) {
return false;
}
this.duplicateErrorOutput = undefined;
return true;
}
async start(sessionId: string, toolCallId: string): Promise<CompactionState> {
if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
return this.activeCompaction;
}
this.activeCompaction = { toolCallId, heartbeatSent: false };
this.outputDelivered = true;
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Compact conversation",
kind: "think",
status: "in_progress",
_meta: compactionToolMeta(),
},
});
return this.activeCompaction;
}
async heartbeat(sessionId: string, fallbackId: string): Promise<void> {
const state = this.activeCompaction ?? (await this.start(sessionId, fallbackId));
if (state.terminalStatus || state.heartbeatSent) return;
state.heartbeatSent = true;
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: state.toolCallId,
status: "in_progress",
_meta: compactionToolMeta(),
},
});
}
async finish(
sessionId: string,
fallbackId: string,
status: CompactionStatus,
metadata: Omit<ContextCompactionMetadata, "version"> = {},
enrichTerminal = false,
): Promise<void> {
const rawOutput = Object.keys(metadata).length > 0 ? metadata : undefined;
if (!this.activeCompaction) {
this.activeCompaction = {
toolCallId: fallbackId,
heartbeatSent: false,
terminalStatus: status,
};
this.outputDelivered = true;
if (status === "failed" && metadata.error) {
this.duplicateErrorOutput = metadata.error;
}
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: fallbackId,
title: "Compact conversation",
kind: "think",
status,
...(status === "failed" && metadata.error
? { content: [compactionErrorContent(metadata.error)] }
: {}),
...(rawOutput ? { rawOutput } : {}),
_meta: compactionToolMeta(metadata),
},
});
return;
}
const state = this.activeCompaction;
if (state.terminalStatus && !enrichTerminal) return;
const firstTerminal = state.terminalStatus === undefined;
if (firstTerminal) state.terminalStatus = status;
if (status === "failed" && metadata.error) {
this.duplicateErrorOutput = metadata.error;
}
await this.sendUpdate({
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: state.toolCallId,
...(firstTerminal ? { status } : {}),
...(status === "failed" && metadata.error
? { content: [compactionErrorContent(metadata.error)] }
: {}),
...(rawOutput ? { rawOutput } : {}),
_meta: compactionToolMeta(metadata),
},
});
}
}
export function contextCompactionMetadataFromBoundary(compactMetadata: {
trigger: "manual" | "auto";
pre_tokens: number;
post_tokens?: number;
duration_ms?: number;
}): Omit<ContextCompactionMetadata, "version"> {
return {
trigger: compactMetadata.trigger === "auto" ? "automatic" : "manual",
preTokens: compactMetadata.pre_tokens,
...(compactMetadata.post_tokens !== undefined
? { postTokens: compactMetadata.post_tokens }
: {}),
...(compactMetadata.duration_ms !== undefined
? { durationMs: compactMetadata.duration_ms }
: {}),
};
}
function compactionToolMeta(
metadata: Omit<ContextCompactionMetadata, "version"> = {},
): Record<string, unknown> {
return {
...createContextCompactionMeta(metadata),
claudeCode: { toolName: "compact" },
};
}
function compactionErrorContent(error: string) {
return {
type: "content" as const,
content: {
type: "text" as const,
text: `Compaction failed: ${error}`,
},
};
}

View File

@@ -2,7 +2,13 @@ import { ForkSessionRequest, ForkSessionResponse, RequestError } from "@agentcli
import {
forkSession as forkClaudeSession,
getSessionMessages,
importSessionToStore,
type SessionStore,
type SessionStoreEntry,
} from "@anthropic-ai/claude-agent-sdk";
import { createHash } from "node:crypto";
import { assistantMessageText } from "./session-failure-extension.js";
import { SessionTiming } from "./session-timing.js";
type ForkSessionMeta = {
[key: string]: unknown;
@@ -11,6 +17,8 @@ type ForkSessionMeta = {
fork?: {
version?: number;
messageId?: string;
messageFingerprint?: string;
messageOccurrence?: number;
};
};
};
@@ -18,6 +26,7 @@ type ForkSessionMeta = {
type ForkSessionDependencies = {
liveMessageIdToUuid?: ReadonlyMap<string, string>;
logger?: { log: (...args: unknown[]) => void };
messageIdForGrouping: (message: {
type?: string;
uuid?: string | null;
@@ -25,11 +34,29 @@ type ForkSessionDependencies = {
}) => string | undefined;
};
function forkPointMessageId(meta: unknown): string | undefined {
type ForkPoint = {
messageId: string;
messageFingerprint?: string;
messageOccurrence?: number;
};
function forkPoint(meta: unknown): ForkPoint | undefined {
const fork = (meta as ForkSessionMeta | null | undefined)?.jetbrains?.air?.fork;
if (fork?.version !== 1) return undefined;
const messageId = fork.messageId?.trim();
return messageId || undefined;
if (!messageId) return undefined;
const messageFingerprint = fork.messageFingerprint?.trim();
const messageOccurrence = fork.messageOccurrence;
return {
messageId,
...(messageFingerprint ? { messageFingerprint } : {}),
// AIR computes this with count(...) over the visible prefix, so it is 1-based.
...(typeof messageOccurrence === "number" &&
Number.isSafeInteger(messageOccurrence) &&
messageOccurrence > 0
? { messageOccurrence }
: {}),
};
}
function forkPointMessageIdCandidates(messageId: string): string[] {
@@ -38,43 +65,171 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
}
async function loadFullSessionHistory(
sessionId: string,
cwd: string,
): Promise<SessionStoreEntry[]> {
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
const entries: SessionStoreEntry[] = [];
const store: SessionStore = {
append: async (_key, batch) => {
entries.push(...batch);
},
load: async () => null,
};
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
return entries;
}
function assistantGroups(
entries: SessionStoreEntry[],
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
) {
const groups = new Map<string, { uuid?: string; text: string }>();
for (const entry of entries) {
if (
entry.type !== "assistant" ||
entry.isSidechain === true ||
entry.parent_tool_use_id != null ||
entry.parent_agent_id != null
) {
continue;
}
const messageId = messageIdForGrouping(entry);
if (!messageId) continue;
const group = groups.get(messageId) ?? { text: "" };
if (typeof entry.uuid === "string" && entry.uuid.length > 0) group.uuid = entry.uuid;
group.text += assistantMessageText(entry.message) ?? "";
groups.set(messageId, group);
}
return groups;
}
function fingerprint(text: string): string {
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
}
function fingerprintOccurrenceOnBranch(
targetUuid: string,
expectedFingerprint: string,
entries: SessionStoreEntry[],
groups: ReturnType<typeof assistantGroups>,
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
): number {
const entriesByUuid = new Map(
entries.flatMap((entry) => (typeof entry.uuid === "string" ? [[entry.uuid, entry]] : [])),
);
const seenGroups = new Set<string>();
let occurrence = 0;
let cursor: string | undefined = targetUuid;
while (cursor) {
const entry = entriesByUuid.get(cursor);
if (!entry) break;
if (
entry.type === "assistant" &&
entry.isSidechain !== true &&
entry.parent_tool_use_id == null &&
entry.parent_agent_id == null
) {
const messageId = messageIdForGrouping(entry);
if (messageId && !seenGroups.has(messageId)) {
seenGroups.add(messageId);
const group = groups.get(messageId);
if (group && fingerprint(group.text) === expectedFingerprint) occurrence++;
}
}
cursor = typeof entry.parentUuid === "string" ? entry.parentUuid : undefined;
}
return occurrence;
}
function resolveFromFullHistory(
entries: SessionStoreEntry[],
candidates: string[],
point: ForkPoint,
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
): string | undefined {
const groups = assistantGroups(entries, messageIdForGrouping);
const exact = candidates.map((candidate) => groups.get(candidate)?.uuid).find(Boolean);
if (exact) return exact;
if (!point.messageFingerprint || !point.messageOccurrence) return undefined;
const fingerprintMatches = [...groups.values()].filter(
(group) => group.uuid && fingerprint(group.text) === point.messageFingerprint,
);
// If only one persisted message has this content, the fingerprint already
// identifies it even when an older client supplied an incompatible index.
if (fingerprintMatches.length === 1) return fingerprintMatches[0]?.uuid;
const occurrenceMatches = fingerprintMatches.filter(
(group) =>
group.uuid &&
fingerprintOccurrenceOnBranch(
group.uuid,
point.messageFingerprint!,
entries,
groups,
messageIdForGrouping,
) === point.messageOccurrence,
);
return occurrenceMatches.length === 1 ? occurrenceMatches[0]?.uuid : undefined;
}
export async function forkSession(
params: ForkSessionRequest,
dependencies: ForkSessionDependencies,
): Promise<ForkSessionResponse> {
const messageId = forkPointMessageId(params._meta);
if (!messageId) {
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
const point = forkPoint(params._meta);
if (!point) {
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
timing.phase("sdk-fork", " resolution=latest");
return { sessionId: forked.sessionId };
}
const candidateIds = forkPointMessageIdCandidates(messageId);
const candidateIds = forkPointMessageIdCandidates(point.messageId);
const liveUuid = candidateIds
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
.find(Boolean);
const history = liveUuid
? undefined
: await getSessionMessages(params.sessionId, { dir: params.cwd });
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
const messageUuid =
liveUuid ??
candidateIds
.map(
(candidateId) =>
history?.find((message) => dependencies.messageIdForGrouping(message) === candidateId)
?.uuid,
history
?.slice()
.reverse()
.find((message) => dependencies.messageIdForGrouping(message) === candidateId)?.uuid,
)
.find(Boolean);
const fullHistoryUuid = messageUuid
? undefined
: resolveFromFullHistory(
await loadFullSessionHistory(params.sessionId, params.cwd),
candidateIds,
point,
dependencies.messageIdForGrouping,
);
timing.phase(
"full-history",
` resolution=${liveUuid ? "live" : messageUuid ? "active" : fullHistoryUuid ? "full" : "missing"}`,
);
if (!messageUuid) {
if (!messageUuid && !fullHistoryUuid) {
throw RequestError.invalidParams(
{ messageId },
`Fork point message ${messageId} was not found in session ${params.sessionId}`,
{ messageId: point.messageId },
`Fork point message ${point.messageId} was not found in session ${params.sessionId}`,
);
}
const forked = await forkClaudeSession(params.sessionId, {
dir: params.cwd,
upToMessageId: messageUuid,
upToMessageId: messageUuid ?? fullHistoryUuid,
});
timing.phase("sdk-fork");
return { sessionId: forked.sessionId };
}

311
src/hide-claude-auth.ts Normal file
View File

@@ -0,0 +1,311 @@
/**
* `--hide-claude-auth`: this integration must never bill a claude.ai
* subscription. The flag hides the claude.ai login method from `initialize`
* and makes the agent behave like a logged-out CLI whenever a subscription
* would pay for a turn. `acp-agent.ts` decides *when* the guard applies (flag
* on, no provider override) and calls in here for the *what*.
*
* The account the CLI reports at `initialize` is the source of truth. The SDK
* memoizes it, so it is a constant for the life of a query, and two layers
* keep it honest:
*
* 1. Session creation refuses an account that holds no credential this
* integration accepts. A logged-out session never exists, so the client's
* sign-in always leads to a new session with a fresh `initialize`. The
* per-turn guard repeats the check on the same cached account, which costs
* microseconds and covers a session created under a provider override that
* `providers/disable` later removed.
* 2. A sign-out during the session ends the query. The next turn recreates it,
* so the next `initialize` reports the real account and layer 1 judges it.
* The recreation resumes the stored conversation. When the CLI never wrote
* one, because the first turn was the one that signed out, the recreation
* starts a fresh query under the same session id instead.
*
* A file-based credential replaced by a subscription between two turns is seen
* by the CLI probe the agent fires at the START of every user prompt. That read
* is never awaited, so it usually lands mid-turn; all it does there is mark the
* session. The guard consumes the mark at the turn boundary — the next prompt
* recreates the query and layer 1 judges the account the new `initialize`
* reports — so a probe never interrupts a running turn. A swap that happens
* after the last probe stays unseen until the next prompt reads the store
* again. Closing that window would cost a blocking read per turn.
*/
import { RequestError } from "@agentclientprotocol/sdk";
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import type { SessionFailureController } from "./session-failure-extension.js";
export function shouldHideClaudeAuth(): boolean {
return process.argv.includes("--hide-claude-auth");
}
/**
* `data.reason` carried by the `authRequired` JSON-RPC error when the flag
* blocks a turn. From the client's point of view the agent is logged out (same
* error code and flow as a signed-out CLI); the reason lets it render a
* different hint. A string enum so further reasons can be added without a
* shape change.
*/
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON = "claude_subscription_not_supported";
export type AuthRequiredReason = typeof CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON;
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE =
"This integration does not support using claude.ai subscriptions.";
/** The message of the plain sign-out refusal. It carries no `reason`: the
* account holds nothing this integration can bill, which is the ordinary
* signed-out state every ACP client already knows how to answer. */
export const CLAUDE_LOGIN_REQUIRED_MESSAGE =
"Sign in with an API key or a Console account to use this integration.";
/**
* The `apiKeySource` values that outrank a stored subscription.
*
* The SDK documents its `ApiKeySource` type as the origin of the credential
* used for API requests. `ANTHROPIC_API_KEY` is the environment variable,
* `apiKeyHelper` is the configured helper command, and `/login managed key` is
* a key that `/login` created with an Anthropic Console account. Each of the
* three is an API key that pays instead of the subscription. Every other
* member means no API key is in use: `none`, and the legacy `user`, `project`,
* `org`, `temporary` and `oauth` that current CLIs never emit.
*/
const API_KEY_SOURCES_ABOVE_SUBSCRIPTION: ReadonlySet<string> = new Set([
"ANTHROPIC_API_KEY",
"apiKeyHelper",
"/login managed key",
]);
/**
* True when a turn on this account is paid by a claude.ai subscription.
*
* The SDK types `apiKeySource` and `tokenSource` as an open `string`, so this
* is an allowlist. An account bills the subscription only when every field
* proves it, and an unknown value keeps the guard on.
*
* - `subscriptionType` must be set. Without it the CLI reports no subscription.
* - `tokenSource` must be absent. The CLI sets it in place of the subscription
* when a bearer or OAuth environment token pays for the turn.
* - `apiProvider` must be absent or `firstParty`. The SDK documents that the
* Anthropic OAuth login applies only to `firstParty`; for a third-party
* backend such as `bedrock`, `vertex`, `foundry` or `gateway` the other
* fields are absent and the authentication is external.
* - `apiKeySource` must not be one of {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
* Claude Code's credential precedence puts those keys above the `/login`
* subscription, so the key pays and the stored subscription is inert.
*/
export function billsClaudeSubscription(account: AccountInfo | undefined): boolean {
if (!account?.subscriptionType) {
return false;
}
if (account.tokenSource) {
return false;
}
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
return false;
}
return !API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
}
/**
* True when the account already holds a credential this integration accepts:
* a third-party backend, a bearer or OAuth environment token, or one of the
* API key sources in {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
*
* False therefore covers both the signed-out CLI and the account whose only
* credential is a claude.ai subscription. {@link billsClaudeSubscription} runs
* first and separates the two, so a caller that reaches a `false` here is
* looking at a session with no way to pay.
*/
export function holdsNonSubscriptionCredential(account: AccountInfo | undefined): boolean {
if (!account) {
return false;
}
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
return true;
}
if (account.tokenSource) {
return true;
}
return API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
}
export function claudeSubscriptionNotSupportedError(): RequestError {
return RequestError.authRequired(
{ reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON satisfies AuthRequiredReason },
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
);
}
/** The signed-out refusal. Deliberately without `data`, so a client that reads
* `data.reason` sees the plain ACP sign-out it already handles. */
export function claudeLoginRequiredError(): RequestError {
return RequestError.authRequired(undefined, CLAUDE_LOGIN_REQUIRED_MESSAGE);
}
/**
* The state the guard keeps for one session. It lives on the session so that a
* concurrent pair of turns shares one account read and one failure row, and so
* that each degraded-guard warning is logged once.
*/
export type ClaudeSubscriptionGuardState = {
/** The in-flight guard run. Set before the first `await`. */
pendingRun?: Promise<RequestError | undefined>;
/** True after the "no account source" warning was logged for this session. */
degradedWarningLogged?: boolean;
};
export type GuardLogger = {
error: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
};
function warn(logger: GuardLogger, message: string): void {
(logger.warn ?? logger.error).call(logger, message);
}
/**
* Report once per session that the guard cannot read the account at all. The
* guard is then absent, not passing. The CLI build decides this, not the user,
* so a message on every turn would only add noise.
*/
export function warnClaudeSubscriptionGuardDegraded(options: {
sessionId: string;
guardState: ClaudeSubscriptionGuardState;
logger: GuardLogger;
cause: string;
}): void {
const { guardState } = options;
if (guardState.degradedWarningLogged) {
return;
}
guardState.degradedWarningLogged = true;
warn(
options.logger,
`Session ${options.sessionId}: the --hide-claude-auth subscription guard is degraded ` +
`(${options.cause}). This session cannot detect a claude.ai subscription.`,
);
}
type GuardOptions = {
sessionId: string;
query: Pick<Query, "accountInfo">;
guardState: ClaudeSubscriptionGuardState;
/** Built lazily: most turns pass and never need a controller. */
sessionFailures: () => SessionFailureController;
logger: GuardLogger;
/** Display hook: called with the account the guard just read, before the
* guard decides on it. It exists so a refused turn still reports which
* account it was refused for. Purely observational — this module knows
* nothing about what the caller does with it, and a throw from here never
* changes the verdict. */
onAccount?: (account: AccountInfo) => void;
};
/**
* The per-turn guard for a live session.
*
* It decides on the account the SDK cached at `initialize`, which is this
* integration's source of truth. That account is a constant for the life of
* the query, so the check costs microseconds. It still earns its place: it
* covers a session created under a provider override that `providers/disable`
* later removed, so the create-time check never ran for the routing in force
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
* real account. This guard does not see the residual case the module header
* names either.
*
* A turn is refused when the account bills a subscription, and also when it
* holds nothing this integration accepts, which is the user signing out in
* mid-session. The refusal mirrors the signed-out path exactly (see
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
* flow, and a capable client additionally gets one session-scoped access
* failure whose `login` action stays the way back in.
*
* Fails open when no account can be read at all: session creation already
* refused every account without a usable credential, and an unavailable probe
* must not block a session that passed that check.
*/
export async function refuseClaudeSubscriptionTurn(options: GuardOptions): Promise<void> {
// Concurrent turns must not each re-read the account and each publish a row.
// Store the run before the first await, and let the others await the same one.
const { guardState } = options;
const run = (guardState.pendingRun ??= evaluateGuard(options).finally(() => {
if (guardState.pendingRun === run) {
guardState.pendingRun = undefined;
}
}));
const refusal = await run;
if (refusal) {
throw refusal;
}
}
/** Read the account, publish at most one failure row, and return the error the
* turn must be rejected with. Never throws: an unreadable account fails open. */
async function evaluateGuard(options: GuardOptions): Promise<RequestError | undefined> {
const account = await readGuardAccount(options);
if (!account) {
return undefined;
}
// Before the decision, so the client learns the identity even when the next
// lines refuse the turn. A display hook must never fail a guard.
try {
options.onAccount?.(account);
} catch (error) {
options.logger.error(
`Session ${options.sessionId}: the guard's onAccount hook threw:`,
error instanceof Error ? error.message : String(error),
);
}
if (billsClaudeSubscription(account)) {
await publishRefusal(options.sessionFailures(), {
details: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON,
});
return claudeSubscriptionNotSupportedError();
}
if (!holdsNonSubscriptionCredential(account)) {
await publishRefusal(options.sessionFailures(), { details: CLAUDE_LOGIN_REQUIRED_MESSAGE });
return claudeLoginRequiredError();
}
return undefined;
}
/** The account the SDK cached at `initialize`. `undefined` when the query
* cannot answer at all. */
async function readGuardAccount(options: GuardOptions): Promise<AccountInfo | undefined> {
const { query } = options;
if (typeof query.accountInfo !== "function") {
warnClaudeSubscriptionGuardDegraded({
...options,
cause: "the SDK query has no accountInfo method",
});
return undefined;
}
try {
return await query.accountInfo();
} catch (error) {
options.logger.error(
`Session ${options.sessionId}: accountInfo failed; skipping the subscription check for this turn:`,
error instanceof Error ? error.message : String(error),
);
return undefined;
}
}
/** The refusal state does not change between turns: publish once, and skip a
* republish while the previous failure with this reason is still active. */
async function publishRefusal(
sessionFailures: SessionFailureController,
failure: { details: string; reason?: string },
): Promise<void> {
if (sessionFailures.hasActiveSessionError("auth_required", failure.reason)) {
return;
}
await sessionFailures.publish("auth_required", {
sessionScoped: true,
details: failure.details,
...(failure.reason ? { reason: failure.reason } : {}),
});
}

View File

@@ -18,6 +18,15 @@ export {
toolUpdateFromToolResult,
} from "./tools.js";
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
// The `authStatus` extension's wire surface: the notification's method name and
// the payload types needed to read the traffic. The mappers stay internal.
export {
AUTH_STATUS_UPDATE_METHOD,
type AuthStatus,
type AuthStatusAccount,
type AuthStatusKind,
type AuthStatusUpdateNotification,
} from "./auth-status.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

58
src/resumed-session.ts Normal file
View File

@@ -0,0 +1,58 @@
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
import { SessionTiming } from "./session-timing.js";
type ResumeLogger = {
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
export type ResumedSessionSnapshot = {
messages?: SessionMessage[];
model?: string;
};
/** Return the concrete model recorded by the last real assistant response.
* Claude Code restores a resumed query from this same transcript field.
* Synthetic assistant records use angle-bracket placeholders and do not
* describe a model the resumed query can run. */
export function resumedModelFromTranscript(messages: SessionMessage[]): string | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
const entry = messages[index];
if (
entry?.type !== "assistant" ||
entry.parent_tool_use_id != null ||
entry.parent_agent_id != null ||
!entry.message ||
typeof entry.message !== "object"
) {
continue;
}
const model = (entry.message as { model?: unknown }).model;
if (typeof model === "string" && model.trim().length > 0 && !/^<[^>]+>$/.test(model.trim())) {
return model.trim();
}
}
return undefined;
}
/** Read the resume model from the local transcript without starting a Claude
* control request. This is intentionally on the load critical path. */
export async function readResumedSession(
sessionId: string,
logger?: ResumeLogger,
): Promise<ResumedSessionSnapshot> {
const timing = new SessionTiming(logger, "models", sessionId);
try {
// Deliberately search all project directories, matching replaySessionHistory.
// A client may reopen a session from a worktree or normalized path that is
// different from the directory under which Claude persisted the transcript.
const messages = await getSessionMessages(sessionId);
const model = resumedModelFromTranscript(messages);
timing.phase("read-transcript", ` messages=${messages.length} model=${model ?? "unknown"}`);
return { messages, model };
} catch (error) {
timing.phase("read-transcript", " outcome=error");
logger?.error(`Failed to read transcript for resumed session ${sessionId}:`, error);
return {};
}
}

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),
}
: {}),
};
}

View File

@@ -48,6 +48,10 @@ export type PublishedSessionFailure = {
severity: AirSessionFailureSeverity;
title: string;
details?: string;
/** A machine-readable refinement of `kind`, e.g. the `--hide-claude-auth`
* subscription guard's reason on an `auth_required` failure. Absent for a
* plain sign-out. */
reason?: string;
actions: AirSessionFailureAction[];
recoveryPolicy: SessionFailureRecoveryPolicy;
};
@@ -58,6 +62,7 @@ type SessionFailureOptions = {
title?: string;
details?: string;
severity?: AirSessionFailureSeverity;
reason?: string;
};
export type SessionFailureState = {
@@ -158,6 +163,7 @@ export function sessionFailureMeta(failure: PublishedSessionFailure) {
severity: failure.severity,
title: failure.title,
...(failure.details ? { details: failure.details } : {}),
...(failure.reason ? { reason: failure.reason } : {}),
actions: failure.actions,
});
}
@@ -296,6 +302,29 @@ export class SessionFailureController {
}
}
/** Whether a session-scoped error of `kind` is active. A turn-scoped
* warning of the same kind (an `api_retry` notice) does not count: it
* reports a retry in progress, not the state the error would publish.
*
* `reason` refines `kind`, so the active record must carry the same one,
* and an omitted `reason` matches only a record with no reason. A plain
* sign-out and the `--hide-claude-auth` subscription refusal are both
* `auth_required`, but they tell the user different things. Neither may
* suppress the other. */
hasActiveSessionError(kind: ClaudeFailureKind, reason?: string): boolean {
for (const failure of this.state.active.values()) {
if (
failure.kind === kind &&
failure.severity === "error" &&
failure.turnId === undefined &&
failure.reason === reason
) {
return true;
}
}
return false;
}
recordActive(failure: PublishedSessionFailure): void {
this.state.revisions.set(failure.id, failure.revision);
this.state.active.set(failure.id, failure);
@@ -357,6 +386,7 @@ export class SessionFailureController {
severity: failureOptions.severity ?? "error",
title,
...(failureOptions.details ? { details: failureOptions.details } : {}),
...(failureOptions.reason ? { reason: failureOptions.reason } : {}),
actions: failureOptions.severity === "warning" ? [] : policy.actions,
recoveryPolicy: sessionFailureRecoveryPolicy(
kind,

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,
};
}

27
src/session-timing.ts Normal file
View File

@@ -0,0 +1,27 @@
type TimingLogger = {
log: (...args: unknown[]) => void;
};
/** Small phase timer for session lifecycle diagnostics. */
export class SessionTiming {
private readonly startedAt: number;
private phaseStartedAt: number;
constructor(
private readonly logger: TimingLogger | undefined,
private readonly operation: string,
private readonly sessionId: string,
startedAt = performance.now(),
) {
this.startedAt = startedAt;
this.phaseStartedAt = startedAt;
}
phase(name: string, detail = ""): void {
const finishedAt = performance.now();
this.logger?.log(
`[session/${this.operation}] sessionId=${this.sessionId} phase=${name} durationMs=${Math.round(finishedAt - this.phaseStartedAt)} totalMs=${Math.round(finishedAt - this.startedAt)}${detail}`,
);
this.phaseStartedAt = finishedAt;
}
}

View File

@@ -95,6 +95,118 @@ describe("ClaudeAcpAgent settings", () => {
expect(getCapturedOptions().permissionMode).toBe("dontAsk");
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
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 () => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,947 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../acp-agent.js";
import type { AuthStatus } from "../auth-status.js";
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
import { randomUUID } from "node:crypto";
const mockQuery = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: mockQuery,
}));
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
* Tests set `execFileResults` per invoked subcommand. */
const execFileSpy = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: execFileSpy };
});
/** CLI outputs verified against the real binary (see the extension spec). */
const CLI_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "user@example.com",
orgId: "org-1",
orgName: "ACME",
subscriptionType: "max",
});
const CLI_API_KEY = JSON.stringify({
loggedIn: true,
authMethod: "none",
apiProvider: "firstParty",
apiKeySource: "apiKeyHelper",
});
/** An `apiKeyHelper` in settings.json next to a stored claude.ai login: the CLI
* reports both identities at once, and the helper key is what actually bills. */
const CLI_KEY_HELPER_AND_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
apiKeySource: "apiKeyHelper",
email: "user@example.com",
orgName: "ACME",
subscriptionType: "max",
});
const CLI_LOGGED_OUT = JSON.stringify({
loggedIn: false,
authMethod: "none",
apiProvider: "firstParty",
});
/** The same login as CLI_SUBSCRIPTION, read from a source that omits the org. */
const CLI_SUBSCRIPTION_NO_ORG = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "user@example.com",
subscriptionType: "max",
});
/** A different person logged in since the last read. */
const CLI_OTHER_ACCOUNT = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "other@example.com",
subscriptionType: "pro",
});
/** A 3P backend: no claude.ai login, yet the agent is authenticated through
* credentials it does not own (AWS). `loggedIn` is false here. */
const CLI_BEDROCK = JSON.stringify({
loggedIn: false,
authMethod: "none",
apiProvider: "bedrock",
});
const MOCK_MODELS = [
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
];
/** One scripted SDK query: it echoes each pushed user message and answers it
* with a successful result, so a real `newSession` + `prompt` round trip runs
* against it and the turn-boundary probes fire. */
function scriptedTurnQuery(args: any, account: Record<string, unknown>) {
const input = args.prompt;
async function* messages() {
const iterator = input[Symbol.asyncIterator]();
for (;;) {
const { value, done } = await iterator.next();
if (done || !value) return;
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: "test-session",
isReplay: true,
};
yield {
type: "result",
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "done",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
}
}
const query: any = messages();
query.initializationResult = async () => ({ models: MOCK_MODELS, account });
query.accountInfo = async () => account;
query.setModel = async () => {};
query.setPermissionMode = async () => {};
query.supportedCommands = async () => [];
query.mcpServerStatus = async () => [];
query.getContextUsage = async () => DEFAULT_CONTEXT_USAGE;
query.close = vi.fn();
query.interrupt = vi.fn(async () => {});
query.stopTask = vi.fn(async () => {});
return query;
}
describe("auth status mappers", () => {
it("maps a first-party subscription account", () => {
expect(
fromAccountInfo({
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
}),
).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
});
it.each([
["max", "Claude Max"],
["Claude Max", "Claude Max"],
["claude max", "Claude Max"],
["Team Premium", "Claude Team Premium"],
])("names the plan %j once, as %j", (subscriptionType, label) => {
// Regression: a newer CLI reports the plan already prefixed, and the label
// read "Claude Claude Max". The plan itself stays the raw vendor string.
expect(fromAccountInfo({ apiProvider: "firstParty", subscriptionType })).toEqual({
kind: "account",
label,
account: { plan: subscriptionType },
});
});
it("maps an API key source", () => {
expect(
fromAccountInfo({ apiProvider: "firstParty", apiKeySource: "ANTHROPIC_API_KEY" }),
).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "ANTHROPIC_API_KEY",
});
});
it("prefers the API key over a stored subscription when both are reported", () => {
// Claude Code's authentication precedence ranks an apiKeyHelper key above
// the `/login` subscription, so the key is the identity that pays.
expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION)).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
expect(
fromAccountInfo({
apiProvider: "firstParty",
apiKeySource: "apiKeyHelper",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
}),
).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
});
it("maps third-party backends to external auth", () => {
expect(fromAccountInfo({ apiProvider: "bedrock" })).toEqual({
kind: "external",
label: "AWS Bedrock",
});
expect(fromAccountInfo({ apiProvider: "vertex" })).toEqual({
kind: "external",
label: "Google Vertex AI",
});
});
it("maps the gateway backend", () => {
expect(fromAccountInfo({ apiProvider: "gateway" })).toEqual({
kind: "gateway",
label: "Custom model gateway",
});
});
it("reports an account with no identity signal as no information", () => {
// The SDK always fills apiProvider; under an apiKeyHelper that is all a
// session account carries. Mapping it to `none` would claim "logged out".
expect(fromAccountInfo({ apiProvider: "firstParty" })).toBeUndefined();
expect(fromAccountInfo({})).toBeUndefined();
expect(fromAccountInfo(undefined)).toBeUndefined();
// `tokenSource` names the OAuth token's origin, never a key source.
expect(
fromAccountInfo({ apiProvider: "firstParty", tokenSource: "CLAUDE_CODE_OAUTH_TOKEN" }),
).toBeUndefined();
});
it("maps CLI probe output", () => {
expect(fromCliStatus(CLI_SUBSCRIPTION)).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
expect(fromCliStatus(CLI_API_KEY)).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
expect(fromCliStatus(CLI_LOGGED_OUT)).toEqual({
kind: "none",
label: "Not logged in",
});
});
it("lets a third-party backend outrank the logged-out flag", () => {
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
// are external and the agent is authenticated all the same.
expect(fromCliStatus(CLI_BEDROCK)).toEqual({ kind: "external", label: "AWS Bedrock" });
});
it("reports unparseable CLI output as not known", () => {
expect(fromCliStatus("")).toBeUndefined();
expect(fromCliStatus("Usage: claude auth status")).toBeUndefined();
expect(fromCliStatus("[]")).toBeUndefined();
expect(fromCliStatus('{"foo": 1}')).toBeUndefined();
});
it("merges a poorer read into the same identity and replaces a different one", () => {
const stored = {
kind: "account" as const,
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
};
// Same login, read from a source without the organization: it survives.
expect(
mergeAuthStatus(stored, {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com" },
}),
).toEqual(stored);
// A different login replaces everything, organization included.
expect(
mergeAuthStatus(stored, {
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
}),
).toEqual({
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
});
// A different kind never merges, even when it looks adjacent.
expect(
mergeAuthStatus(stored, { kind: "api_key", label: "Anthropic API key", detail: "env" }),
).toEqual({ kind: "api_key", label: "Anthropic API key", detail: "env" });
// Key sources identify api_key payloads.
expect(
sameIdentity(
{ kind: "api_key", label: "Anthropic API key", detail: "apiKeyHelper" },
{ kind: "api_key", label: "Anthropic API key", detail: "env" },
),
).toBe(false);
});
});
describe("auth status over ACP", () => {
let agent: ClaudeAcpAgentType;
let extNotification: ReturnType<typeof vi.fn>;
/** stdout the fake `claude auth status --json` prints, and whether the exec
* fails (the logged-out case exits 1 with JSON still on stdout). */
let statusStdout: string;
let statusFails: boolean;
/** While true, probes hang until `flushStatus()` releases them, so a test can
* interleave an `authenticate`/`logout` with a probe that is still running.
* Each probe captures the output configured when it was spawned. */
let deferStatus: boolean;
let pendingStatus: Array<() => void>;
/** Let every already-scheduled promise chain run to completion. */
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/** Release one pending probe by spawn order, so a test can decide which of
* two in-flight reads lands first. */
async function releaseStatus(index: number) {
const [release] = pendingStatus.splice(index, 1);
release();
await settle();
}
/** Release the probes spawned so far and let their promises settle, repeating
* so a probe spawned by that settling (e.g. logout's fresh read) is released
* too. */
async function flushStatus(rounds = 3) {
for (let round = 0; round < rounds; round++) {
const pending = pendingStatus;
pendingStatus = [];
for (const release of pending) release();
await settle();
}
}
beforeEach(() => {
// Skip native-binary resolution; the exec itself is mocked.
process.env.CLAUDE_CODE_EXECUTABLE = "claude";
statusStdout = CLI_SUBSCRIPTION;
statusFails = false;
deferStatus = false;
pendingStatus = [];
// The probe passes an options object and `logout` does not, so the
// callback is read off the end rather than from a fixed position.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] === "status") {
const stdout = statusStdout;
const fails = statusFails;
const answer = () => {
if (fails) {
cb(Object.assign(new Error("exit 1"), { stdout, stderr: "" }));
} else {
cb(null, { stdout, stderr: "" });
}
};
if (deferStatus) {
pendingStatus.push(answer);
} else {
answer();
}
return;
}
cb(null, { stdout: "", stderr: "" });
});
mockQuery.mockImplementation(() => makeMockQuery());
extNotification = vi.fn().mockResolvedValue(undefined);
agent = new ClaudeAcpAgent({
sessionUpdate: async () => {},
extNotification,
} as unknown as AcpClient);
});
afterEach(() => {
delete process.env.CLAUDE_CODE_EXECUTABLE;
vi.resetAllMocks();
});
function statusCalls() {
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
}
function updates() {
return extNotification.mock.calls.filter((call) => call[0] === "_auth/status_update");
}
/** The last payload pushed, which is also what the connection now holds. */
function lastPush() {
return (updates().at(-1)?.[1] as { authStatus: AuthStatus } | undefined)?.authStatus;
}
/** `initialize` is where the connection learns its identity. It fires the
* probe and never waits for it, so let the push land before asserting. */
async function initialize() {
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await settle();
return response;
}
/** A session whose scripted query answers every prompt, so a real `prompt()`
* round trip runs and its start-of-prompt probe fires. */
async function scriptedSession(account: Record<string, unknown>) {
mockQuery.mockImplementation((args: any) => scriptedTurnQuery(args, account));
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
return sessionId;
}
async function promptOnce(sessionId: string) {
const response = await agent.prompt({
sessionId,
prompt: [{ type: "text" as const, text: "hi" }],
});
await settle();
return response;
}
it("pushes the logged-out CLI verdict even though the probe exits non-zero", async () => {
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
await initialize();
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
it("pushes nothing when the probe cannot be read", async () => {
statusStdout = "";
statusFails = true;
await initialize();
// "Cannot determine" is silence: no push at all, and the client keeps
// showing "not reported". A known logged-out state would push a payload
// of kind "none" instead.
expect(updates()).toHaveLength(0);
expect(agent.currentAuthStatus).toBeUndefined();
});
it("advertises the extension in agentCapabilities._meta", async () => {
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
// Presence is the whole advertisement — "this agent pushes its identity".
// The marker stays empty, and a status payload there would violate the
// contract.
expect(response.agentCapabilities?._meta?.authStatus).toEqual({});
// The marker lives in agentCapabilities only — never at the top level.
expect(response._meta?.authStatus).toBeUndefined();
});
it("pushes the first identity after the initialize response, never before it", async () => {
// The client registers its receiver around `initialize`; a push that
// overtook the response could be dropped. The probe is asynchronous, so
// the response always wins the race.
const order: string[] = [];
extNotification.mockImplementation(async (method: string) => {
order.push(method);
});
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
order.push("initialize:returned");
await vi.waitFor(() => expect(updates()).toHaveLength(1));
expect(order).toEqual(["initialize:returned", "_auth/status_update"]);
// No snapshot rides in the initialize response — only the notification.
expect(response._meta?.authStatus).toBeUndefined();
});
it("shares one in-flight probe between initialize and a start-of-prompt read", async () => {
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
deferStatus = true;
const init = agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
const turn = agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
await settle();
// Two readers, one CLI process.
expect(statusCalls()).toHaveLength(1);
await flushStatus();
await Promise.all([init, turn]);
expect(lastPush()?.kind).toBe("account");
});
it("re-probes on every prompt, so an external logout is seen", async () => {
// The start of a prompt is the connection's "re-check now": a logout
// performed in another terminal shows up without a session create.
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
await promptOnce(sessionId);
expect(lastPush()).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
await promptOnce(sessionId);
expect(statusCalls()).toHaveLength(2);
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
it("keeps richer stored fields when a poorer probe reports the same identity", async () => {
// The session AccountInfo knows the organization; the CLI probe here does
// not. Re-reading the same login must not regress the payload.
const sessionId = await scriptedSession({
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
});
statusStdout = CLI_SUBSCRIPTION_NO_ORG;
await promptOnce(sessionId);
expect(agent.currentAuthStatus).toEqual({
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
});
});
it("lets a probe of a different identity replace the stored one wholesale", async () => {
const sessionId = await scriptedSession({
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
organization: "ACME",
});
// Someone logged in as another user in a terminal: no field survives.
statusStdout = CLI_OTHER_ACCOUNT;
await promptOnce(sessionId);
expect(lastPush()).toEqual({
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "other@example.com" },
});
});
it("pushes _auth/status_update when the initialize probe resolves", async () => {
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await vi.waitFor(() => expect(updates()).toHaveLength(1));
expect(updates()).toEqual([
[
"_auth/status_update",
{
authStatus: {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com", organization: "ACME" },
},
},
],
]);
});
it("upgrades the identity from AccountInfo on session create", async () => {
statusStdout = CLI_API_KEY;
await initialize();
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: MOCK_MODELS,
account: { apiProvider: "firstParty", subscriptionType: "pro", email: "u@x.com" },
}),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates().map((call) => call[1])).toEqual([
{
authStatus: {
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
},
},
{
authStatus: {
kind: "account",
label: "Claude Pro",
account: { plan: "pro", email: "u@x.com" },
},
},
]);
});
it.each([
["only apiProvider", { apiProvider: "firstParty" }],
["an empty object", {}],
])("keeps the probed API key when the session account carries %s", async (_name, account) => {
// Regression: an apiKeyHelper setup yields a truthy but empty account, and
// mapping it to `none` used to overwrite the correct api_key status ~0.5 s
// after connect.
statusStdout = CLI_API_KEY;
await initialize();
expect(updates()).toHaveLength(1);
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({ models: MOCK_MODELS, account }),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates()).toHaveLength(1);
expect(agent.currentAuthStatus).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
});
it("ignores the session account while a client provider override is active", async () => {
// `providers/set` routing is the client's, not the agent's login: the
// session then reports `apiProvider: "gateway"`, which must stay invisible
// to authStatus. The CLI probe keeps describing the agent-owned store.
statusStdout = CLI_API_KEY;
await initialize();
expect(updates()).toHaveLength(1);
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://client-gateway.example/v1",
headers: {},
});
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: MOCK_MODELS,
account: { apiProvider: "gateway" },
}),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates()).toHaveLength(1);
expect(agent.currentAuthStatus).toEqual({
kind: "api_key",
label: "Anthropic API key",
detail: "apiKeyHelper",
});
});
it("still probes the agent's own login under a client provider override", async () => {
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://client-gateway.example/v1",
headers: {},
});
await initialize();
expect(statusCalls()).toHaveLength(1);
expect(updates()).toHaveLength(1);
});
it("pushes an unchanged identity once, however often it is read", async () => {
// The identity is read on many occasions and almost all of them see the
// same login. A repeat tells the client nothing, so it is not sent.
const authStatus = {
kind: "account",
label: "Claude Max",
account: { plan: "max", email: "user@example.com" },
};
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: MOCK_MODELS,
account: {
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
},
}),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(updates().map((call) => call[1])).toEqual([{ authStatus }]);
});
it("pushes again as soon as a field of the payload changes", async () => {
let account: Record<string, unknown> = {
apiProvider: "firstParty",
subscriptionType: "max",
email: "user@example.com",
};
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({ models: MOCK_MODELS, account }),
}),
);
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
// Same person, upgraded plan: the label changes, so the client hears it.
account = { ...account, subscriptionType: "enterprise" };
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(
updates().map((call) => (call[1] as { authStatus: { label: string } }).authStatus.label),
).toEqual(["Claude Max", "Claude Enterprise"]);
});
it("reads the store once per user prompt, at the start", async () => {
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
// Session creation reads the account the SDK already has; it spawns no CLI.
expect(statusCalls()).toHaveLength(0);
await expect(promptOnce(sessionId)).resolves.toMatchObject({ stopReason: "end_turn" });
// Exactly one process per user prompt, spawned before the turn runs so a
// sign-in done between two prompts is seen by the prompt that follows it.
expect(statusCalls()).toHaveLength(1);
await promptOnce(sessionId);
expect(statusCalls()).toHaveLength(2);
});
it("never makes a prompt wait for the probe", async () => {
// The read is fire-and-forget: a CLI that has not answered yet — or never
// answers — must not hold the turn.
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
deferStatus = true;
await expect(promptOnce(sessionId)).resolves.toMatchObject({ stopReason: "end_turn" });
expect(statusCalls()).toHaveLength(1);
// Still pending when the turn is long over; its result lands afterwards.
expect(pendingStatus).toHaveLength(1);
await flushStatus();
expect(lastPush()?.kind).toBe("account");
});
it("reports the gateway identity after gateway authentication", async () => {
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gw.example.com/v1", headers: {} } },
} as never);
expect(updates().map((call) => call[1])).toEqual([
{
authStatus: {
kind: "gateway",
label: "Custom model gateway",
detail: "gw.example.com",
},
},
]);
});
it("discards a probe that a gateway authenticate overtook", async () => {
// The initialize-time probe is slower than the login: releasing it after
// `authenticate` must not repaint the gateway identity with the CLI store's.
deferStatus = true;
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gw.example.com/v1", headers: {} } },
} as never);
await flushStatus();
const gateway = {
kind: "gateway",
label: "Custom model gateway",
detail: "gw.example.com",
};
// The overtaken read published nothing: one push, the gateway's.
expect(updates().map((call) => call[1])).toEqual([{ authStatus: gateway }]);
expect(agent.currentAuthStatus).toEqual(gateway);
});
it("ignores a pre-logout probe and pushes the fresh one", async () => {
// A probe started before `logout` still describes the logged-in world: it
// must neither be reused by logout nor be published when it lands.
deferStatus = true;
void agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await settle();
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
const logout = agent.logout({});
await flushStatus();
await logout;
// Two reads: the stale one, plus the fresh one logout started itself
// instead of joining it.
expect(statusCalls()).toHaveLength(2);
// The logged-in identity the stale probe carried was never published.
expect(updates().map((call) => call[1])).toEqual([
{ authStatus: { kind: "none", label: "Not logged in" } },
]);
expect(agent.currentAuthStatus).toEqual({ kind: "none", label: "Not logged in" });
});
it("discards the pre-logout read even when it lands after the logout's own", async () => {
// Both reads are in flight at once. The logout's lands first and pushes
// "logged out"; the older one lands after and must publish nothing, or a
// slow read would resurrect the identity the logout destroyed.
deferStatus = true;
void agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await settle();
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
const logout = agent.logout({});
await vi.waitFor(() => expect(pendingStatus).toHaveLength(2));
await releaseStatus(1);
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
await releaseStatus(0);
await logout;
expect(updates()).toHaveLength(1);
expect(agent.currentAuthStatus).toEqual({ kind: "none", label: "Not logged in" });
});
it("keeps the last known state when a probe is readable but empty", async () => {
// The CLI cannot be read at all, yet a session already told us who we are:
// keep that rather than pretending the state is unknown.
const published = { kind: "api_key" as const, label: "Anthropic API key", detail: "env" };
agent.setAuthStatus(published);
await settle();
extNotification.mockClear();
statusStdout = "";
statusFails = true;
await initialize();
expect(updates()).toHaveLength(0);
expect(agent.currentAuthStatus).toEqual(published);
});
it("drops a stale identity when the post-logout probe is unreadable", async () => {
await initialize();
statusStdout = "";
statusFails = true;
await agent.logout({});
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
it("re-probes after logout and reports the resulting state", async () => {
await initialize();
statusStdout = CLI_LOGGED_OUT;
statusFails = true;
await agent.logout({});
expect(statusCalls()).toHaveLength(2);
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
});
describe("when the CLI never answers", () => {
/** The last known state, so a timed-out probe has something to fall back
* to and a wrong "cannot determine" answer would be visible. */
const KNOWN: AuthStatus = { kind: "api_key", label: "Anthropic API key", detail: "env" };
let logger: { log: Mock; error: Mock; warn: Mock };
beforeEach(() => {
vi.useFakeTimers();
// A wedged `claude auth status --json`: it never answers by itself, and
// the fake child dies the way Node kills a timed-out exec — the error
// carries `killed`/`signal`, and any partial stdout is a fragment.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const options = invocation[2] as { timeout?: number } | undefined;
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] !== "status") {
cb(null, { stdout: "", stderr: "" });
return;
}
if (options?.timeout === undefined) return;
setTimeout(() => {
cb(
Object.assign(new Error("Command failed"), {
killed: true,
signal: "SIGTERM",
stdout: "",
stderr: "",
}),
);
}, options.timeout);
});
logger = { log: vi.fn(), error: vi.fn(), warn: vi.fn() };
extNotification = vi.fn().mockResolvedValue(undefined);
agent = new ClaudeAcpAgent(
{ sessionUpdate: async () => {}, extNotification } as unknown as AcpClient,
logger as Logger,
);
agent.setAuthStatus({ ...KNOWN });
});
afterEach(() => {
vi.useRealTimers();
});
it("bounds the probe at 5 s and keeps the last known state", async () => {
agent.setAuthStatus({ ...KNOWN });
const pushesBefore = updates().length;
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
// The exec is spawned on a microtask (`claudeCliPath` is awaited first),
// so let those run before the timer that kills it.
await vi.advanceTimersByTimeAsync(0);
expect(statusCalls()).toHaveLength(1);
expect(statusCalls()[0]?.[2]).toMatchObject({ timeout: 5000 });
await vi.advanceTimersByTimeAsync(5000);
// Node killed the child, so the read reports nothing of its own: the
// state stays what was known before it, and no push goes out.
expect(agent.currentAuthStatus).toEqual(KNOWN);
expect(updates()).toHaveLength(pushesBefore);
expect(logger.error).toHaveBeenCalledWith(
"claude auth status did not answer within 5 s; the identity is not refreshed",
);
});
it("warns once however many probes time out", async () => {
// A wedged CLI stays wedged: every later prompt would repeat the same
// line otherwise.
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await vi.advanceTimersByTimeAsync(5000);
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await vi.advanceTimersByTimeAsync(5000);
expect(statusCalls()).toHaveLength(2);
expect(
logger.error.mock.calls.filter((call) => String(call[0]).includes("did not answer")),
).toHaveLength(1);
});
});
});

File diff suppressed because it is too large Load Diff

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

@@ -5,7 +5,7 @@ import {
SessionNotification,
} from "@agentclientprotocol/sdk";
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import * as fs from "node:fs";
import * as os from "node:os";
@@ -13,6 +13,8 @@ import * as path from "node:path";
let capturedOptions: Options | undefined;
let contextUsageResult: (() => Promise<{ rawMaxTokens: number; model?: string }>) | undefined;
let sessionMessages: Record<string, unknown>[];
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
let initModels: Record<string, unknown>[] | undefined;
let setModelImpl: ((model: string) => Promise<void>) | undefined;
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
@@ -48,6 +50,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
mcpAuthenticate: (serverName: string) => mcpAuthenticateImpl(serverName),
});
},
getSessionMessages: vi.fn(() => sessionMessagesResult()),
};
});
@@ -75,6 +78,9 @@ describe("createSession options merging", () => {
beforeEach(async () => {
capturedOptions = undefined;
contextUsageResult = undefined;
sessionMessages = [];
sessionMessagesResult = async () => sessionMessages;
vi.mocked(getSessionMessages).mockClear();
initModels = undefined;
setModelImpl = undefined;
mcpServerStatusResult = async () => [];
@@ -769,25 +775,106 @@ describe("createSession options merging", () => {
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
});
it("session/load seeds the window from the resumed session's getContextUsage report", async () => {
// Resumed sessions get getContextUsage serviced pre-turn (issue #845 uses
// it to restore the live model), and the same response carries the
// authoritative window (`rawMaxTokens`). After a process restart the
// module cache is empty and text inference misses natively-1M aliases, so
// discarding this in-hand value would replay the issue-#596 flicker on
// every reload — the flagship scenario. 888_000 can only come from the
// report: inference on the mock model yields null → 200_000 default.
contextUsageResult = async () => ({ rawMaxTokens: 888_000, model: "claude-sonnet-4-6" });
it("session/load restores the transcript model without waiting for getContextUsage", async () => {
// getContextUsage is a live CLI control request. On a real, large resumed
// session it can take tens of seconds before returning, so it must stay
// off the load critical path. Claude restores from this same last
// assistant model field, which is available through a local transcript
// read in milliseconds.
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
contextUsageResult = ctxSpy;
initModels = [
{
value: "default",
displayName: "Default",
description: "Default model",
resolvedModel: "claude-sonnet-4-6",
},
{
value: "haiku",
displayName: "Haiku",
description: "Fast",
resolvedModel: "claude-haiku-4-5",
},
];
sessionMessages = [
{
type: "assistant",
uuid: "assistant-uuid",
session_id: "resumed-model-probe",
parent_tool_use_id: null,
parent_agent_id: null,
message: { model: "claude-haiku-4-5", role: "assistant", content: [] },
},
];
await (
agent as unknown as {
createSession: (params: object, opts: { resume?: string }) => Promise<unknown>;
}
).createSession({ cwd: process.cwd(), mcpServers: [] }, { resume: "resumed-window-probe" });
const response = await agent.loadSession({
sessionId: "resumed-model-probe",
cwd: process.cwd(),
mcpServers: [],
});
const session = sessionFor("resumed-window-probe");
expect(session.contextWindowSize).toBe(888_000);
expect(session.contextWindowAuthoritative).toBe(true);
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
"haiku",
);
expect(ctxSpy).not.toHaveBeenCalled();
expect(sessionFor("resumed-model-probe").contextWindowAuthoritative).toBe(false);
expect(getSessionMessages).toHaveBeenCalledTimes(1);
expect(getSessionMessages).toHaveBeenCalledWith("resumed-model-probe");
});
it("resume remains best-effort when the transcript hint cannot be read", async () => {
sessionMessagesResult = async () => {
throw new Error("unreadable transcript");
};
await expect(
agent.resumeSession({
sessionId: "unreadable-resume-probe",
cwd: process.cwd(),
mcpServers: [],
}),
).resolves.toMatchObject({ sessionId: "unreadable-resume-probe" });
});
it("restores the transcript model for direct session/new resume metadata", async () => {
initModels = [
{
value: "default",
displayName: "Default",
description: "Default model",
resolvedModel: "claude-sonnet-4-6",
},
{
value: "haiku",
displayName: "Haiku",
description: "Fast",
resolvedModel: "claude-haiku-4-5",
},
];
sessionMessages = [
{
type: "assistant",
uuid: "assistant-uuid",
session_id: "direct-resume-probe",
parent_tool_use_id: null,
parent_agent_id: null,
message: { model: "claude-haiku-4-5", role: "assistant", content: [] },
},
];
const response = await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { claudeCode: { options: { resume: "direct-resume-probe" } } },
});
expect(response.sessionId).toBe("direct-resume-probe");
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
"haiku",
);
expect(getSessionMessages).toHaveBeenCalledOnce();
expect(getSessionMessages).toHaveBeenCalledWith("direct-resume-probe");
});
it("scopes providerCacheKey by per-session env routing", async () => {
@@ -991,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) {
@@ -1001,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

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
import { resumedModelFromTranscript } from "../resumed-session.js";
function assistant(
model: unknown,
nesting: Pick<SessionMessage, "parent_tool_use_id" | "parent_agent_id"> = {
parent_tool_use_id: null,
parent_agent_id: null,
},
): SessionMessage {
return {
type: "assistant",
uuid: crypto.randomUUID(),
session_id: "session-id",
...nesting,
message: { model },
};
}
describe("resumedModelFromTranscript", () => {
it("returns the last real assistant model", () => {
expect(
resumedModelFromTranscript([assistant("claude-sonnet-5"), assistant("claude-opus-5")]),
).toBe("claude-opus-5");
});
it("skips synthetic assistant records after the real response", () => {
expect(resumedModelFromTranscript([assistant("claude-opus-5"), assistant("<synthetic>")])).toBe(
"claude-opus-5",
);
});
it("skips nested assistant records that can use a different model", () => {
expect(
resumedModelFromTranscript([
assistant("claude-opus-5"),
assistant("claude-haiku-4-5", {
parent_tool_use_id: "task-tool-use",
parent_agent_id: null,
}),
]),
).toBe("claude-opus-5");
});
it("returns undefined when the transcript has no real assistant model", () => {
expect(resumedModelFromTranscript([assistant("<synthetic>")])).toBeUndefined();
});
});

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

@@ -0,0 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionTiming } from "../session-timing.js";
describe("SessionTiming", () => {
afterEach(() => vi.restoreAllMocks());
it("includes work completed before construction when given a start time", () => {
vi.spyOn(performance, "now").mockReturnValue(175);
const log = vi.fn();
new SessionTiming({ log }, "create", "session-id", 100).phase("validate-cwd");
expect(log).toHaveBeenCalledWith(
"[session/create] sessionId=session-id phase=validate-cwd durationMs=75 totalMs=75",
);
});
});

214
src/usage-markdown.ts Normal file
View File

@@ -0,0 +1,214 @@
import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const countSchema = z.number().finite().nonnegative();
const percentSchema = countSchema.max(100);
const usageWindowSchema = z
.object({ utilization: percentSchema.nullable(), resets_at: z.string().nullable() })
.nullable()
.optional();
const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
const behaviorPeriodSchema = z.object({
request_count: countSchema,
session_count: countSchema,
mcp_servers: z.array(contributionSchema),
});
const modelUsageSchema = z.object({
inputTokens: countSchema,
outputTokens: countSchema,
cacheReadInputTokens: countSchema,
cacheCreationInputTokens: countSchema,
});
const usageResponseSchema = z.object({
session: z.object({
total_cost_usd: countSchema,
total_api_duration_ms: countSchema,
total_duration_ms: countSchema,
model_usage: z.record(z.string(), modelUsageSchema),
}),
subscription_type: z.string().nullable(),
rate_limits_available: z.boolean(),
rate_limits: z
.object({
five_hour: usageWindowSchema,
seven_day: usageWindowSchema,
seven_day_oauth_apps: usageWindowSchema,
seven_day_opus: usageWindowSchema,
seven_day_sonnet: usageWindowSchema,
model_scoped: z
.array(
z.object({
display_name: z.string(),
utilization: percentSchema.nullable(),
resets_at: z.string().nullable(),
}),
)
.optional(),
extra_usage: z
.object({
is_enabled: z.boolean(),
monthly_limit: countSchema.nullable(),
used_credits: countSchema.nullable(),
utilization: percentSchema.nullable(),
currency: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
.nullable(),
behaviors: z.object({ day: behaviorPeriodSchema, week: behaviorPeriodSchema }).nullable(),
});
/** Validate the experimental SDK response at the runtime boundary. */
export function parseUsageResponse(value: unknown): SDKControlGetUsageResponse | null {
const parsed = usageResponseSchema.safeParse(value);
// Validate only the fields the renderer reads, but preserve the complete
// response. The experimental API may add values to unused subtrees without
// making the useful, already-validated portion unsafe to render.
return parsed.success ? (value as SDKControlGetUsageResponse) : null;
}
export function isUsageCommandText(text: string): boolean {
return text.trim() === "/usage";
}
function usageBar(percent: number): string {
const cells = 20;
const clamped = Math.max(0, Math.min(100, percent));
const filled = clamped === 0 ? 0 : Math.max(1, Math.round((clamped / 100) * cells));
return `${"█".repeat(filled)}${"░".repeat(cells - filled)}`;
}
function escapeMarkdown(value: string): string {
return value.replace(/([\\`*_[\]<>|])/g, "\\$1").replace(/[\r\n]+/g, " ");
}
function formatDuration(milliseconds: number): string {
const seconds = Math.max(0, Math.round(milliseconds / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
}
function formatCount(value: number): string {
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(
value,
);
}
function formatReset(value: string | null): string {
if (!value) return "";
const reset = new Date(value);
if (Number.isNaN(reset.getTime())) return ` · Resets ${escapeMarkdown(value)}`;
return ` · Resets ${new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
}).format(reset)}`;
}
function appendLimit(
lines: string[],
label: string,
window: { utilization: number | null; resets_at: string | null } | null | undefined,
): void {
if (!window || window.utilization === null) return;
lines.push(
`**${escapeMarkdown(label)}** — **${window.utilization}%**${formatReset(window.resets_at)}`,
"",
`\`${usageBar(window.utilization)}\``,
"",
);
}
function appendContributions(
lines: string[],
label: string,
period: {
request_count: number;
session_count: number;
mcp_servers: { name: string; pct: number }[];
},
): void {
lines.push(
"",
`**${label}** · ${period.request_count} requests · ${period.session_count} sessions`,
);
if (period.mcp_servers.length === 0) return;
lines.push("", "| MCP server | Usage |", "|:--|--:|");
for (const server of [...period.mcp_servers].sort((a, b) => b.pct - a.pct).slice(0, 3)) {
lines.push(`| ${escapeMarkdown(server.name)} | \`${usageBar(server.pct)}\` ${server.pct}% |`);
}
}
/** Render the SDK's structured `/usage` response as Markdown. */
export function formatUsageResponse(usage: SDKControlGetUsageResponse): string {
const lines = ["## Usage"];
if (usage.subscription_type) {
lines.push("", `> Claude ${escapeMarkdown(usage.subscription_type)} subscription usage`);
}
if (usage.rate_limits_available && usage.rate_limits) {
const limitLines: string[] = [];
appendLimit(limitLines, "5-hour limit", usage.rate_limits.five_hour);
appendLimit(limitLines, "Weekly · all models", usage.rate_limits.seven_day);
const modelWindows = usage.rate_limits.model_scoped ?? [];
for (const model of modelWindows) {
appendLimit(limitLines, `Weekly · ${model.display_name}`, model);
}
if (modelWindows.length === 0) {
appendLimit(limitLines, "Weekly · Opus", usage.rate_limits.seven_day_opus);
appendLimit(limitLines, "Weekly · Sonnet", usage.rate_limits.seven_day_sonnet);
}
if (limitLines.length > 0) {
if (limitLines.at(-1) === "") limitLines.pop();
lines.push("", "### Limits", "", ...limitLines);
}
}
const models = Object.values(usage.session.model_usage);
const totals = models.reduce(
(sum, model) => ({
input: sum.input + model.inputTokens,
output: sum.output + model.outputTokens,
cacheRead: sum.cacheRead + model.cacheReadInputTokens,
cacheWrite: sum.cacheWrite + model.cacheCreationInputTokens,
}),
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
);
lines.push(
"",
"---",
"",
"### This session",
"",
"| Cost | API time | Active |",
"|:--|:--|:--|",
`| $${usage.session.total_cost_usd.toFixed(2)} | ${formatDuration(usage.session.total_api_duration_ms)} | ${formatDuration(usage.session.total_duration_ms)} |`,
"",
"| Breakdown | Tokens |",
"|:--|--:|",
`| Input | ${formatCount(totals.input)} |`,
`| Output | ${formatCount(totals.output)} |`,
`| Cache read | ${formatCount(totals.cacheRead)} |`,
`| Cache write | ${formatCount(totals.cacheWrite)} |`,
);
if (usage.behaviors) {
lines.push(
"",
"---",
"",
"### What’s using your limits?",
"",
"> Approximate, overlapping measures · this machine only · excludes claude.ai",
);
appendContributions(lines, "Last 24h", usage.behaviors.day);
appendContributions(lines, "Last 7d", usage.behaviors.week);
}
return lines.join("\n");
}

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"],