mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-01 15:01:13 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e4815029 | ||
|
|
2b666c3d35 | ||
|
|
45f8b34020 | ||
|
|
cc528fb5e9 | ||
|
|
0dd58685b1 | ||
|
|
d870ebd2fd | ||
|
|
194e195b05 | ||
|
|
3e23c5b960 | ||
|
|
f5e79f55f8 | ||
|
|
a0122dae4c | ||
|
|
20817678f6 | ||
|
|
f74a51758d | ||
|
|
eb5ca9fd17 | ||
|
|
847a3b6429 | ||
|
|
da9f7e5695 | ||
|
|
ea7076c0bc | ||
|
|
b4e3aaa727 |
137
.github/workflows/publish.yml
vendored
137
.github/workflows/publish.yml
vendored
@@ -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: {}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.72.0"
|
||||
".": "0.76.0"
|
||||
}
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -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).
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
122
docs/RELEASES.md
122
docs/RELEASES.md
@@ -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.
|
||||
|
||||
118
docs/recommended-config-values-extension.md
Normal file
118
docs/recommended-config-values-extension.md
Normal 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
496
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
202
scripts/next-preview-version.mjs
Executable 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);
|
||||
});
|
||||
}
|
||||
249
scripts/next-preview-version.test.mjs
Normal file
249
scripts/next-preview-version.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
2368
src/acp-agent.ts
2368
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -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
337
src/auth-status.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
|
||||
29
src/context-compaction-meta.ts
Normal file
29
src/context-compaction-meta.ts
Normal 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
190
src/context-compaction.ts
Normal 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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
311
src/hide-claude-auth.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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
58
src/resumed-session.ts
Normal 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
118
src/session-effort.ts
Normal 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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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
466
src/session-model.ts
Normal 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
27
src/session-timing.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
947
src/tests/auth-status.test.ts
Normal file
947
src/tests/auth-status.test.ts
Normal 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
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
212
src/tests/model-presentation.test.ts
Normal file
212
src/tests/model-presentation.test.ts
Normal 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,
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
282
src/tests/recommended-config-values.test.ts
Normal file
282
src/tests/recommended-config-values.test.ts
Normal 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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/tests/resumed-session.test.ts
Normal file
49
src/tests/resumed-session.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
17
src/tests/session-timing.test.ts
Normal file
17
src/tests/session-timing.test.ts
Normal 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
214
src/usage-markdown.ts
Normal 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");
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user