mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
Compare commits
17 Commits
v0.95.1-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ce42f2c31 | ||
|
|
782e463ff4 | ||
|
|
c9374abfc7 | ||
|
|
d52c7c64d8 | ||
|
|
de96cef382 | ||
|
|
a4957d17c1 | ||
|
|
bed795d261 | ||
|
|
434be13a33 | ||
|
|
2a1525e57c | ||
|
|
54f1e21bc4 | ||
|
|
01b686713d | ||
|
|
6a33dedcbb | ||
|
|
e369479f93 | ||
|
|
c26e640915 | ||
|
|
f6958db1ee | ||
|
|
2f3c6bb7b3 | ||
|
|
9a8bf71d45 |
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-please-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Generate token scoped to the registry repo
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
id: registry-token
|
||||
with:
|
||||
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.95.0"
|
||||
".": "0.97.0"
|
||||
}
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0) (2026-10-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ultracode session config option ([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438)) ([de96cef](https://github.com/agentclientprotocol/claude-agent-acp/commit/de96cef382d8daf27301f1f90ae4b5e7f665f73a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump body-parser from 2.3.0 to 2.4.0 ([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436)) ([c26e640](https://github.com/agentclientprotocol/claude-agent-acp/commit/c26e640915739dc7a3cd0664f8f5879098e0fd83))
|
||||
* distinguish model options with duplicate labels ([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286)) ([434be13](https://github.com/agentclientprotocol/claude-agent-acp/commit/434be13a33141af8655c6f7c30f574841bf2279c))
|
||||
* keep the usage-limit error when it arrives after compaction ([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377)) ([2a1525e](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a1525e57cd89246d1a50993fd6ba335c4e634ca))
|
||||
* pass --settings before the auth status subcommand ([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392)) ([6a33ded](https://github.com/agentclientprotocol/claude-agent-acp/commit/6a33dedcbb90e7bf9db91207f0edbf19ca6d705e))
|
||||
* preserve foreground fork reports until launch completes ([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431)) ([e369479](https://github.com/agentclientprotocol/claude-agent-acp/commit/e369479f93b34f84a11103323d097c44df42e02e))
|
||||
* settle advisor tool results before completing turns ([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247)) ([bed795d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bed795d261620e6d5113e427a7196d94e66e7034))
|
||||
* settle live steers folded into autonomous cycles ([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185)) ([a4957d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4957d17c1e1a925ba3b72b29c5dcde4faabbe17))
|
||||
|
||||
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* report SendMessage delegations as linked session messages ([#1424](https://github.com/agentclientprotocol/claude-agent-acp/issues/1424)) ([739dde3](https://github.com/agentclientprotocol/claude-agent-acp/commit/739dde3ae5bb848bdb90e2c9150e2cc4687d480c))
|
||||
* report the model of each native subagent ([#1432](https://github.com/agentclientprotocol/claude-agent-acp/issues/1432)) ([c319e5e](https://github.com/agentclientprotocol/claude-agent-acp/commit/c319e5e542aabe353bb2c5e177e4c5853392b8c4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* do not hold an AIR load replay until the CLI starts ([#1433](https://github.com/agentclientprotocol/claude-agent-acp/issues/1433)) ([eb9d63e](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb9d63e5fde9b8fd8fe692dfb3280faba6626a42))
|
||||
* refine RFD child models after a load, and retry a failed model update ([#1434](https://github.com/agentclientprotocol/claude-agent-acp/issues/1434)) ([9a8bf71](https://github.com/agentclientprotocol/claude-agent-acp/commit/9a8bf71d454fa85d03f55d4101a77e3ea7a149d0))
|
||||
* send subagents' current state after a load response ([#1423](https://github.com/agentclientprotocol/claude-agent-acp/issues/1423)) ([17a967b](https://github.com/agentclientprotocol/claude-agent-acp/commit/17a967b4dab78bbb9cf9fedae60e6821578dc5cb))
|
||||
* update to Claude Code 2.1.310 ([#1435](https://github.com/agentclientprotocol/claude-agent-acp/issues/1435)) ([2f3c6bb](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f3c6bb7b3451a00cd77d7bb31c09e690ca220b3))
|
||||
|
||||
## [0.95.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.94.0...v0.95.0) (2026-10-23)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- An Ultracode session config option, which turns on Claude Code's dynamic workflows for every task. It appears when dynamic workflows are enabled and the model supports ultracode (any model with `xhigh` effort)
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
|
||||
@@ -141,8 +141,8 @@ The `initialize` response of an AIR client carries the agent side of the extensi
|
||||
}
|
||||
```
|
||||
|
||||
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The agent list does not depend on the capabilities that AIR declares, except `sessionIndex`: the agent lists it, together with `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`, only for a client that declared `sessionIndex`.
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
|
||||
### Capabilities
|
||||
@@ -158,6 +158,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
|
||||
| `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) |
|
||||
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
|
||||
| `sessionBatchGet` | with `sessionIndex` | Supports `_session/batch_get` for metadata of known session IDs. | [Batch get](#batch-get) |
|
||||
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
|
||||
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
|
||||
| `sessionListSubscribe` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/list/subscribe`. | [List subscription](#list-subscription) |
|
||||
@@ -707,7 +708,7 @@ A non-empty plan also gets one clear-context choice with the same priority.
|
||||
After that choice, the adapter stops the old private Claude query and hides that internal rejection.
|
||||
It starts a new private query under the same ACP session.
|
||||
It continues the turn with `Implement the following plan:` and the complete plan.
|
||||
The mode, model, agent, effort, Fast mode, and turn usage stay the same.
|
||||
The mode, model, agent, effort, Fast mode, ultracode, and turn usage stay the same.
|
||||
The SDK cannot apply `allowedPrompts` again, so those hints do not survive the handoff.
|
||||
|
||||
The adapter does not allow a callback only because the session mode is `bypassPermissions`.
|
||||
@@ -1282,8 +1283,8 @@ The session index makes `session/list` a bounded, ordered page with row metadata
|
||||
|
||||
AIR declares `sessionIndex` in `_meta.jetbrains.air.capabilities`.
|
||||
Unlike the other capabilities, the agent advertises `sessionIndex` only to a client that declared it, and never under ACP v2.
|
||||
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
|
||||
The client does not declare these four; declaring them without `sessionIndex` enables nothing.
|
||||
Exactly when it advertises `sessionIndex`, the agent also advertises `sessionBatchGet` (it supports `_session/batch_get`), `sessionArchive` (it supports `_session/archive` and `_session/unarchive`), `sessionRename` (it supports `_session/rename`), `sessionListSubscribe` (it supports `_session/list/subscribe` and `_session/list/unsubscribe`) and `sessionWorktrees` (rows carry `agentWorktree`, see [List response](#list-response)).
|
||||
The client does not declare these five; declaring them without `sessionIndex` enables nothing.
|
||||
Everything in this section applies only to such a client, except where [Delete and close](#delete-and-close) says otherwise.
|
||||
A client without the capability gets the `session/list` of before: the SDK `listSessions` call, pages of 1000 by file mtime, `offset:N` cursors, and no row `_meta`.
|
||||
It gets no new notification, and the adapter starts no watcher for it.
|
||||
@@ -1442,6 +1443,71 @@ In all of the session index methods and in `session/delete`, a session id matche
|
||||
Loading an archived session does not unarchive it.
|
||||
The CLI cleanup still deletes old transcripts (`cleanupPeriodDays`), archived or not.
|
||||
|
||||
### Batch get
|
||||
|
||||
`_session/batch_get` refreshes metadata for retained session IDs, such as pinned
|
||||
sessions and folder members outside the recent list page. Check for
|
||||
`sessionBatchGet` in `initialize.result._meta.jetbrains.air.capabilities` before
|
||||
calling it.
|
||||
|
||||
The params object must contain only `sessionIds`: an array of distinct,
|
||||
non-blank strings. IDs match exactly, without trimming or case normalization.
|
||||
Malformed params, duplicates or extra fields return `Invalid params` (`-32602`).
|
||||
The agent sets no limit on the number of IDs; the client chooses the batch size
|
||||
for the latency it accepts. Archived sessions and sessions in other projects
|
||||
or worktrees are eligible.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"method": "_session/batch_get",
|
||||
"params": { "sessionIds": ["oldA", "oldB", "oldC"] }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 7,
|
||||
"result": {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "oldA",
|
||||
"cwd": "/project",
|
||||
"title": "Fix the build",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"_meta": { "jetbrains": { "air": { "version": 1, "archived": false } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The example IDs are placeholders for stored Claude session UUIDs. `sessions`
|
||||
contains the same full rows as `session/list` without a project filter, including
|
||||
archived sessions and `_meta.jetbrains.air`. Copies use the same
|
||||
newest-readable-copy selection as the list. Returned sessions preserve their
|
||||
relative request order. Non-UUID IDs and IDs without a listable transcript are
|
||||
omitted: absent, empty, unreadable or malformed transcripts, sidechains, sessions
|
||||
whose continuation has history, and transcripts whose cwd cannot be recovered.
|
||||
A sidecar alone does not supply a row.
|
||||
|
||||
`{ "sessionIds": [] }` returns `{ "sessions": [] }` without reading the store.
|
||||
A store-wide failure, such as an unreadable projects root, rejects the request.
|
||||
Individual results reflect the store as each ID is read.
|
||||
|
||||
Lookup normally addresses only the requested transcript filenames in each project
|
||||
and reads their metadata. A transcript without a cwd can use a sibling's cwd,
|
||||
as the list does. This fallback enumerates and stats sibling transcripts only
|
||||
in the directories of rows still missing a cwd, then reads at most 64 siblings
|
||||
per directory, newest mtime first. A failed recovery is cached for 60 seconds
|
||||
unless the directory mtime changes. Other project histories are not enumerated.
|
||||
The agent looks up 100 IDs at a time; the client may request any number of IDs.
|
||||
Merge returned rows with the recent page by `sessionId`. A batch read does not
|
||||
expand an existing list subscription's scope; refresh retained IDs outside that
|
||||
scope explicitly.
|
||||
|
||||
### List subscription
|
||||
|
||||
`_session/list/subscribe { "cwd": "…" }` returns `{ "subscriptionId": "…" }`.
|
||||
@@ -1465,6 +1531,7 @@ From then on the agent sends `_session/list/changes` notifications for the sessi
|
||||
|
||||
- `cwd` is required and must be an absolute path; a missing, non-string or relative `cwd` is `-32602`. Other parameters are ignored.
|
||||
- The scope is the sessions of `cwd` and of the same subdirectory in every existing linked worktree of its repository, as a list with `includeWorktrees: true` resolves it, in any archive state. Sidechain and subagent transcripts are left out, as in the list.
|
||||
- There is no page or recent-session limit: changes to older pinned or folder sessions within that scope are reported even outside the page AIR loaded. Pinned IDs outside the subscribed project and worktrees are not tracked; `_session/batch_get` does not add them to the subscription. AIR must subscribe to their project scope or refresh them explicitly.
|
||||
- `sessions` holds the full current row of each session in scope that appeared, or whose `title`, `lastPromptAt`, `state`, `lastTurnEndedAt`, `cost`, `model`, `forkedFrom`, `agentWorktree` or `archived` changed since the last row sent for it on this subscription. A row is exactly the row of `session/list`, `_meta.jetbrains.air` included.
|
||||
- After subscribe, the first change of each session sends its row in full, even when only `updatedAt` changed. From then on, a change of `updatedAt` alone sends nothing; the new `updatedAt` comes with the next change.
|
||||
- `removed` holds the ids of sessions that left the scope: deleted (the transcript is gone), or moved to a path outside it. It may name a session the client does not list (one it never listed, one the list hides, such as a session continued in another one when that one changes); the client ignores such an id.
|
||||
@@ -1517,6 +1584,7 @@ Names and semantics follow them; the transport differs, and so do the `archived`
|
||||
| capability `sessionIndex` (`_meta.jetbrains.air`) | `sessionCapabilities.list.limit` |
|
||||
| capability `sessionArchive` (`_meta.jetbrains.air`) | `sessionCapabilities.archive` (#2161) |
|
||||
| capability `sessionRename` (`_meta.jetbrains.air`) | `sessionCapabilities.setTitle` (#1987) |
|
||||
| capability `sessionBatchGet`, `_session/batch_get` | none |
|
||||
| `_meta.jetbrains.air.list.limit` | `session/list` `limit` |
|
||||
| `_meta.jetbrains.air.list.includeWorktrees` | `session/list` `includeWorktrees` |
|
||||
| `_meta.jetbrains.air.list.archived` (`"unarchived"`, `"archived"`, `"all"`) | `session/list` `archived` (#2161), a boolean there |
|
||||
|
||||
278
package-lock.json
generated
278
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.95.0",
|
||||
"version": "0.97.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.95.0",
|
||||
"version": "0.97.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.309",
|
||||
"@agentclientprotocol/sdk": "2.0.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
@@ -18,7 +18,7 @@
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "1.1.0",
|
||||
"@anthropic-ai/sdk": "1.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.5",
|
||||
@@ -38,31 +38,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.0.1.tgz",
|
||||
"integrity": "sha512-v4E/NcBTGFEKmorGso8ysAjJTkNh5UMJPP4aDznyMNmnP9t10g/aCCo/Djy7RYaYZy5ZROFK3G5cEj9tDgGOTQ==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.0.2.tgz",
|
||||
"integrity": "sha512-jLywRB42hZMRMc4aUgt0uRjkY9FQnMdybv4XgLl+ddi75QuJn8Vbl0+CHcVHJDC0+WDfi1KQXRWtHaR5vp6gHw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.309.tgz",
|
||||
"integrity": "sha512-Tou0fXBkxKJDcgT09v3Iv/Jd7bNgapgfAiHxGPctAADPFQhidfhZbcv7at1cVL0Lon5sbs+OzP/pEOigw2TI0A==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.310.tgz",
|
||||
"integrity": "sha512-fo+sr+9Ks73P001f6XKRQlyzfIBAJMgXArLUkH5Tu98NikRDZeGtFQVj23d5/hj2ijpFgzffrr4fHywY9qUkoQ==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.309",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.309"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.310"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -71,9 +71,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.309.tgz",
|
||||
"integrity": "sha512-qC93iDGEioaXqWsYazcZzNv3hd8d4VHkCFgb+AzvbkCWOzZHnRu1EqF4Iz5kCTLDs4VdMvCa1NkzlUwtTedBVw==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.310.tgz",
|
||||
"integrity": "sha512-gpzMjDZdcdA2Webuqm9j76exG8yNK/755ZYWbS3MM4vVBlAsm+enEO+hhNt0Mx1VMil28RWetvTO4ZJ3YODVMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -84,9 +84,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.309.tgz",
|
||||
"integrity": "sha512-RvP0UOcYxn42c1lgByoMufHaMOH86U2ThGGFupOxBKATY4Q6WJd6M1CukQ/qygfgJ8lcxvK8GOOd3SIny4kQ8Q==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.310.tgz",
|
||||
"integrity": "sha512-ZRQ4ZrzE2OOfR+Fx8h+CM4NKh0UiYqlh5TowP7HUhn5KUoYMjoSR/xIhix8ow42X2IDiBy0FjEVaA/Awhu2KAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -97,9 +97,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.309.tgz",
|
||||
"integrity": "sha512-+8rQwzycuXTW2e/C+yotvP/SDiEDHxItdPvIriGAf2ixh7nPRjVuzHOj1tamgVO0J8nmpPvIAO4edzJ7VZCcAg==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.310.tgz",
|
||||
"integrity": "sha512-l6SPgUmJknjx+6/Pb7OR353fm3xBNAWqodFoC/Cflg0yMcLfz9iwNIc3Drgos2DqfHnid+ODqDzSO93AgQ7RmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -113,9 +113,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.309.tgz",
|
||||
"integrity": "sha512-14ZD9hdUeobw+jr166YvCZyZwSYG3hDWi5S4yQxpSCrRuY/1z4Kl1AfymUlugiN3UT4Q1rif32K6GnjRJBvQdA==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.310.tgz",
|
||||
"integrity": "sha512-viXuvvtli3pfHzTEI+oeJzk3nldkfHvj757Xh/uAeximUT9ANZIq4SMZw2cSqbhASjni0qcO6B1x4BGx2LuQpw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -129,9 +129,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.309.tgz",
|
||||
"integrity": "sha512-96G8/uTArlvTTg0qGtiP36W76Ug/YT0gMBh8KX7J2RigdOTrk6QkHtUeCzrw6yff5hEIzecN7EWNw6uh9USo4A==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.310.tgz",
|
||||
"integrity": "sha512-jx7awHPoofXuE6cRRQSzLUFyW6aUXbYD9AtnNIDUHmmPPrgJ9nSnqjPaqcNhOdaZH7onUUujEAq26N2tx29fYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -145,9 +145,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.309.tgz",
|
||||
"integrity": "sha512-9ViOt4MGMiE2h8V+JUPR5l9F0ZTFuhKC3jawIL/MrMfYY1m2Gt3gcMuP7h7YK914a4tmpXrmITWrpTOu1WOZQQ==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.310.tgz",
|
||||
"integrity": "sha512-/qFgN5LcofB8PayxBXoa+xDxkYPPQeo/eCyN0DsyyeI67ifH/l49pz9J+sH0+ctDe9und0vMCJPzng70bx2K+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -161,9 +161,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.309.tgz",
|
||||
"integrity": "sha512-lDsmw5hguDF7KNMG2OvvKlG0Zgs9bKlrX9Gs2Rv/NjLVl0PIy1TUsh7byOsUR4pXwoMU1toyU1CKqBc/Au8hHw==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.310.tgz",
|
||||
"integrity": "sha512-tRmDMhZpoWAIrZxFBTh6vyZB8W5gldU5/Rfo+cyq/OvBKTxxbim+/0tIa9QlJQncoVLXVN9eySYeeB9KMjio7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -174,9 +174,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.309",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.309.tgz",
|
||||
"integrity": "sha512-otGHQimz6F++6SajGqSAKBclRPGTn60cx+8u3dnhMa5yovKZ5SgChFgstFsiDIWr/ZBqpJysvFemSgHX61mlmQ==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.310.tgz",
|
||||
"integrity": "sha512-zvrIApxPccTJc6gCOtw5hj27QjWhhTi3z5x5iMmf9f0LZAKR+9mrgENVeFL7PP7zRGdYVhhX2SahbzxN83L/eA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -187,9 +187,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.1.0.tgz",
|
||||
"integrity": "sha512-Op/fKMsfVwFCfObc87leLOFlORE0RjOvLzRzPCWWLI7Ww6bobpKR6YmYC8ZYgulRh+m2a/3/61VUXoWHFtCXIw==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz",
|
||||
"integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
@@ -527,9 +527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
|
||||
"integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==",
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -538,9 +538,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz",
|
||||
"integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -556,9 +556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz",
|
||||
"integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -574,9 +574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz",
|
||||
"integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -592,9 +592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz",
|
||||
"integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -610,9 +610,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz",
|
||||
"integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -628,9 +628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz",
|
||||
"integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -646,13 +646,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -664,13 +667,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz",
|
||||
"integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -682,13 +688,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -700,13 +709,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -718,13 +730,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz",
|
||||
"integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -736,13 +751,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz",
|
||||
"integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -754,9 +772,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz",
|
||||
"integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -772,9 +790,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz",
|
||||
"integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -790,9 +808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz",
|
||||
"integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1250,19 +1268,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -2927,9 +2945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
|
||||
"integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2937,6 +2955,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
@@ -3231,14 +3253,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
|
||||
"integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.154.0",
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3248,21 +3270,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.14",
|
||||
"@rolldown/binding-android-arm64": "1.2.14",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.14",
|
||||
"@rolldown/binding-darwin-x64": "1.2.14",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.14",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.14",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.14",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.14",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.14",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.14",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.14",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.14"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3655,17 +3677,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3682,7 +3704,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.95.0",
|
||||
"version": "0.97.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,13 +65,13 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.309",
|
||||
"@agentclientprotocol/sdk": "2.0.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "1.1.0",
|
||||
"@anthropic-ai/sdk": "1.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.5",
|
||||
|
||||
188
src/acp-agent.ts
188
src/acp-agent.ts
@@ -147,7 +147,11 @@ import {
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
import * as sessionIndex from "./session-index/connection.js";
|
||||
import type { RenameSessionRequest, SessionIdRequest } from "./session-index/service.js";
|
||||
import type {
|
||||
BatchGetSessionsRequest,
|
||||
RenameSessionRequest,
|
||||
SessionIdRequest,
|
||||
} from "./session-index/service.js";
|
||||
import type {
|
||||
ListSubscribeRequest,
|
||||
ListUnsubscribeRequest,
|
||||
@@ -365,6 +369,15 @@ import {
|
||||
mergeEffortSettings,
|
||||
settingsEffortForModel,
|
||||
} from "./session-effort.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
readUltracodeSettings,
|
||||
resolveUltracodeEnabled,
|
||||
ULTRACODE_CONFIG_ID,
|
||||
ultracodeSupported,
|
||||
ultracodeWorkflowsEnabled,
|
||||
type UltracodeOptionState,
|
||||
} from "./session-ultracode.js";
|
||||
|
||||
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
|
||||
export {
|
||||
@@ -1191,6 +1204,15 @@ export type Session = {
|
||||
* user's intent so it persists across model switches; the Fast mode config
|
||||
* option is only surfaced while the selected model supports it. */
|
||||
fastModeEnabled: boolean;
|
||||
/** Whether ultracode is turned on for this session. Tracked as the user's
|
||||
* intent, like `fastModeEnabled`: the CLI keeps the request across model
|
||||
* switches and applies it whenever the model supports ultracode, while the
|
||||
* config option is only surfaced on supporting models. */
|
||||
ultracodeEnabled: boolean;
|
||||
/** Whether dynamic workflows are enabled, learned from the CLI's
|
||||
* `get_settings` at session start; undefined when unknown. Combined with
|
||||
* the model's support to decide whether to show the ultracode option. */
|
||||
ultracodeWorkflowsEnabled?: boolean;
|
||||
/** The non-default effort the user picked through the ACP picker this
|
||||
* session. A pin lives at the SDK's flag layer, which overrides the CLI's
|
||||
* persisted effort (including the per-model `modelSettings` entries), so it
|
||||
@@ -2801,6 +2823,7 @@ export class ClaudeAcpAgent {
|
||||
return session;
|
||||
},
|
||||
applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
|
||||
applyUltracode: (session, enabled) => this.applyUltracode(session, enabled),
|
||||
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
|
||||
supportsPlanOperations: () => this.clientCapabilities?.plan != null,
|
||||
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
|
||||
@@ -3116,7 +3139,10 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
timing.phase("session-ready");
|
||||
const replayed = await replay;
|
||||
if (!replayed.ok) throw replayed.error;
|
||||
if (!replayed.ok) {
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
throw replayed.error;
|
||||
}
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
modes: response.modes,
|
||||
@@ -3227,7 +3253,7 @@ export class ClaudeAcpAgent {
|
||||
return this.sessionIndex.clientTitle(title);
|
||||
}
|
||||
|
||||
/** `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
|
||||
/** `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`, `_session/rename`,
|
||||
* `_session/archive`, `_session/unarchive`. */
|
||||
async subscribeSessionList(params: ListSubscribeRequest) {
|
||||
return this.sessionIndex.subscribeList(params);
|
||||
@@ -3235,6 +3261,9 @@ export class ClaudeAcpAgent {
|
||||
async unsubscribeSessionList(params: ListUnsubscribeRequest) {
|
||||
return this.sessionIndex.unsubscribeList(params);
|
||||
}
|
||||
async batchGetSessions(params: BatchGetSessionsRequest) {
|
||||
return this.sessionIndex.batchGet(params);
|
||||
}
|
||||
renameSessionTitle(params: RenameSessionRequest) {
|
||||
return this.sessionIndex.rename(params);
|
||||
}
|
||||
@@ -3427,7 +3456,9 @@ export class ClaudeAcpAgent {
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
const args = ["auth", "status", "--json"];
|
||||
if (context?.override) args.push("--settings", JSON.stringify(context.override));
|
||||
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
|
||||
// it with "unknown option '--settings'", so it has to come first.
|
||||
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
|
||||
({ stdout } = await execFileAsync(cliPath, args, {
|
||||
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
cwd: context?.manager.getCwd() ?? process.cwd(),
|
||||
@@ -5084,6 +5115,8 @@ export class ClaudeAcpAgent {
|
||||
* marked as a background result, but it lists the uuids of the prompts it
|
||||
* answered.
|
||||
*
|
||||
* A steer belongs to the live active turn even though it has no queued
|
||||
* Turn of its own. Its stamped result can answer that turn too.
|
||||
* A turn held open for its background subagents already has its result,
|
||||
* so a result that names it does not answer it. */
|
||||
const answersPendingPrompt = (message: {
|
||||
@@ -5099,8 +5132,15 @@ export class ClaudeAcpAgent {
|
||||
answeredPromptUuids = [message.user_message_uuid];
|
||||
}
|
||||
|
||||
const activeTurn = session.activeTurn;
|
||||
const liveActiveTurn =
|
||||
activeTurn && !activeTurn.settled && !isHeldOpen(activeTurn) ? activeTurn : undefined;
|
||||
for (const promptUuid of answeredPromptUuids) {
|
||||
const turn = findUnsettledTurn(promptUuid);
|
||||
// Keep queued prompt matching, including folded sends with no echo.
|
||||
// Steers qualify only through their live active owner.
|
||||
const turn =
|
||||
findUnsettledTurn(promptUuid) ??
|
||||
(liveActiveTurn?.steeredUuids?.has(promptUuid) ? liveActiveTurn : undefined);
|
||||
if (turn === undefined) {
|
||||
// Not a prompt of this session, or it was answered already.
|
||||
continue;
|
||||
@@ -8042,6 +8082,13 @@ export class ClaudeAcpAgent {
|
||||
message.message.model === "<synthetic>" &&
|
||||
compaction.hasDeliveredOutput
|
||||
) {
|
||||
if (isSyntheticUsageLimitMessage(message.message)) {
|
||||
lastAssistantWasUsageLimit = true;
|
||||
lastAssistantFailureTitle = assistantMessageText(message.message);
|
||||
if (message.error) {
|
||||
lastAssistantError = message.error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -8197,6 +8244,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
let content: typeof message.message.content;
|
||||
let toolUseResult = message.type === "user" ? message.tool_use_result : undefined;
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
(message.parent_tool_use_id === null || forwardsSubagentText())
|
||||
@@ -8217,6 +8265,20 @@ export class ClaudeAcpAgent {
|
||||
(item) => item.type !== "text" && item.type !== "thinking",
|
||||
);
|
||||
streamedBlocksOf(message.parent_tool_use_id).length = 0;
|
||||
} else if (
|
||||
message.parent_tool_use_id !== null &&
|
||||
Array.isArray(message.message.content)
|
||||
) {
|
||||
// A foreground fork inherits a placeholder result for its own
|
||||
// launch. Only the launching side can complete that Agent call
|
||||
// with the fork's actual report.
|
||||
const launchId = message.parent_tool_use_id;
|
||||
content = message.message.content.filter(
|
||||
(block) => block.type !== "tool_result" || block.tool_use_id !== launchId,
|
||||
);
|
||||
// The message-level sidecar cannot describe a remaining result
|
||||
// after its launch result was removed.
|
||||
if (content.length !== message.message.content.length) toolUseResult = undefined;
|
||||
} else {
|
||||
content = message.message.content;
|
||||
}
|
||||
@@ -8234,7 +8296,7 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const backgroundBashTask = backgroundBashTaskFromToolResult(
|
||||
content,
|
||||
message.tool_use_result,
|
||||
toolUseResult,
|
||||
session.toolUseCache,
|
||||
);
|
||||
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
|
||||
@@ -8263,7 +8325,7 @@ export class ClaudeAcpAgent {
|
||||
asyncTasks,
|
||||
backgroundBashTask,
|
||||
);
|
||||
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
|
||||
const resumedAgentId = resumedNativeSubagentId(toolUseResult);
|
||||
if (resumedAgentId) {
|
||||
const resume = sendMessageResume(
|
||||
session.toolUseCache,
|
||||
@@ -8304,7 +8366,7 @@ export class ClaudeAcpAgent {
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
messageId: messageIdForGrouping(message),
|
||||
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
|
||||
toolUseResult,
|
||||
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
|
||||
// type, hence the cast. Validated by parseToolResultMeta.
|
||||
toolResultMeta:
|
||||
@@ -9167,13 +9229,18 @@ export class ClaudeAcpAgent {
|
||||
throw new Error(`Unknown config option: ${params.configId}`);
|
||||
}
|
||||
|
||||
// Fast mode carries a boolean value (for Clients that opted into boolean
|
||||
// config options) or the "on"/"off" select fallback, so it bypasses the
|
||||
// Fast mode and ultracode carry a boolean value (for Clients that opted
|
||||
// into boolean config options) or the "on"/"off" select fallback, so they
|
||||
// bypass the
|
||||
// string-only validation the select-style options below rely on.
|
||||
if (params.configId === FAST_MODE_CONFIG_ID) {
|
||||
await this.applyFastMode(session, resolveFastModeEnabled(params));
|
||||
return { configOptions: session.configOptions };
|
||||
}
|
||||
if (params.configId === ULTRACODE_CONFIG_ID) {
|
||||
await this.applyUltracode(session, resolveUltracodeEnabled(params));
|
||||
return { configOptions: session.configOptions };
|
||||
}
|
||||
|
||||
if (typeof params.value !== "string") {
|
||||
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
||||
@@ -9449,7 +9516,8 @@ export class ClaudeAcpAgent {
|
||||
? new RfdSubagentReplay(
|
||||
rfdHistory,
|
||||
(notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
replayModels,
|
||||
this.subagentModels(sessionId),
|
||||
!pending,
|
||||
)
|
||||
: undefined;
|
||||
rfdReplay?.scan(messages, sessionId);
|
||||
@@ -11001,6 +11069,13 @@ export class ClaudeAcpAgent {
|
||||
{
|
||||
useRecommendedValue,
|
||||
},
|
||||
{
|
||||
// Like Fast mode, the toggle hides on models without ultracode
|
||||
// support and comes back with the retained intent on one that has it.
|
||||
supported: ultracodeSupported(session.ultracodeWorkflowsEnabled, newModelInfo),
|
||||
enabled: session.ultracodeEnabled,
|
||||
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
||||
},
|
||||
);
|
||||
|
||||
// Opted-in clients apply the concrete displayed effort on every switch,
|
||||
@@ -11027,10 +11102,7 @@ export class ClaudeAcpAgent {
|
||||
// deliberately apply their concrete displayed effort.
|
||||
effortFlagSettings(
|
||||
useRecommendedValue ? newEffort : undefined,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
session.ultracodeEnabled,
|
||||
),
|
||||
);
|
||||
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
|
||||
@@ -11099,15 +11171,7 @@ export class ClaudeAcpAgent {
|
||||
} else if (configId === EFFORT_CONFIG_ID) {
|
||||
// Apply first so a rejected control request cannot leave the displayed
|
||||
// value ahead of the SDK flag layer.
|
||||
await session.query.applyFlagSettings(
|
||||
effortFlagSettings(
|
||||
value,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
),
|
||||
);
|
||||
await session.query.applyFlagSettings(effortFlagSettings(value, session.ultracodeEnabled));
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
@@ -11189,6 +11253,22 @@ export class ClaudeAcpAgent {
|
||||
this.refreshFastModeOption(session, enabled);
|
||||
}
|
||||
|
||||
/** Turn ultracode on or off for a session. Sends an explicit boolean, which
|
||||
* leaves the effort level alone (`null` would reset the flag layer instead).
|
||||
* The SDK flag is applied first so a rejected request leaves the session
|
||||
* state and option untouched. */
|
||||
private async applyUltracode(session: Session, enabled: boolean): Promise<void> {
|
||||
await session.query.applyFlagSettings({ ultracode: enabled });
|
||||
session.ultracodeEnabled = enabled;
|
||||
const refreshed = createUltracodeConfigOption(
|
||||
enabled,
|
||||
clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
||||
);
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === ULTRACODE_CONFIG_ID ? refreshed : o,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reconcile the session's Fast mode toggle with an SDK-reported
|
||||
* `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
|
||||
* The SDK can flip fast mode independently of the user — e.g. back to `on`
|
||||
@@ -12027,6 +12107,21 @@ export class ClaudeAcpAgent {
|
||||
settingsManager.getSettings(),
|
||||
configuredSettingsObject,
|
||||
);
|
||||
|
||||
// The CLI reports whether ultracode can run (workflows enabled and a
|
||||
// supporting model) and whether the session already requests it (an
|
||||
// `ultracode: true` setting). Without a report, fall back to settings.
|
||||
const ultracodeReport = await readUltracodeSettings(q);
|
||||
timing.phase("ultracode");
|
||||
const ultracodeEnabled = ultracodeReport?.requested ?? effortSettings.ultracode === true;
|
||||
const workflowsEnabled = ultracodeWorkflowsEnabled(ultracodeReport, currentModelInfo);
|
||||
const ultracode: UltracodeOptionState = {
|
||||
supported:
|
||||
ultracodeReport?.available ?? ultracodeSupported(workflowsEnabled, currentModelInfo),
|
||||
enabled: ultracodeEnabled,
|
||||
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
||||
};
|
||||
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
@@ -12038,10 +12133,11 @@ export class ClaudeAcpAgent {
|
||||
{
|
||||
useRecommendedValue,
|
||||
},
|
||||
ultracode,
|
||||
);
|
||||
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
|
||||
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, effortSettings));
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, ultracodeEnabled));
|
||||
}
|
||||
// Seed the context window without awaited IPC. The cached authoritative
|
||||
// window from a prior turn wins (`result.modelUsage`, cross-session),
|
||||
@@ -12109,6 +12205,8 @@ export class ClaudeAcpAgent {
|
||||
currentAgent,
|
||||
fastModeEnabled,
|
||||
fastModeDisabledReason,
|
||||
ultracodeEnabled,
|
||||
ultracodeWorkflowsEnabled: workflowsEnabled,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
forwardSubagentText,
|
||||
@@ -12938,6 +13036,7 @@ export function buildConfigOptions(
|
||||
currentAgent: string = DEFAULT_AGENT_ID,
|
||||
fastMode?: FastModeOptionState,
|
||||
presentation?: ConfigOptionPresentation,
|
||||
ultracode?: UltracodeOptionState,
|
||||
): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
|
||||
// A gateway that lists none of the SDK's models leaves no model to offer, so
|
||||
@@ -12968,6 +13067,12 @@ export function buildConfigOptions(
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the ultracode toggle only while the CLI can run it: dynamic
|
||||
// workflows are enabled and the current model supports it.
|
||||
if (ultracode?.supported) {
|
||||
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
|
||||
}
|
||||
|
||||
// Only surface the Agent picker when there's a real choice — i.e. the user
|
||||
// has configured at least one custom agent (built-ins are filtered out in
|
||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||
@@ -13610,6 +13715,38 @@ export function toAcpNotifications(
|
||||
break;
|
||||
}
|
||||
|
||||
case "advisor_tool_result": {
|
||||
// Advisor results arrive in assistant content, including encrypted advice.
|
||||
// Resolve the server tool through the normal result path without rendering
|
||||
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
|
||||
const result = chunk.content;
|
||||
unregisterHookCallback(chunk.tool_use_id);
|
||||
output.push(
|
||||
...toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: chunk.tool_use_id,
|
||||
is_error: result.type === "advisor_tool_result_error",
|
||||
content:
|
||||
result.type === "advisor_result"
|
||||
? result.text
|
||||
: result.type === "advisor_redacted_result"
|
||||
? "Advisor guidance applied server-side."
|
||||
: `Advisor error: ${result.error_code}`,
|
||||
},
|
||||
],
|
||||
role,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
options,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
case "tool_search_tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
@@ -13779,7 +13916,6 @@ export function toAcpNotifications(
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
case "fallback":
|
||||
case "mcp_tool_listing":
|
||||
break;
|
||||
|
||||
@@ -22,6 +22,9 @@ export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
||||
* subscription. Advertised only to a client that declares it.
|
||||
*/
|
||||
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
|
||||
/** Metadata lookup of known ids with `_session/batch_get`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is negotiated. */
|
||||
export const AIR_SESSION_BATCH_GET_CAPABILITY = "sessionBatchGet";
|
||||
/**
|
||||
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
|
||||
|
||||
@@ -39,6 +39,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
ultracodeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
turnQueue?: Turn[];
|
||||
@@ -59,6 +60,7 @@ export type ClearContextCoordinatorHost<
|
||||
options: { publicSessionId: string; permissionMode: PermissionMode },
|
||||
): Promise<Session>;
|
||||
applyFastMode(session: Session, enabled: boolean): Promise<void>;
|
||||
applyUltracode(session: Session, enabled: boolean): Promise<void>;
|
||||
publishSessionState(
|
||||
sessionId: string,
|
||||
mode: PermissionMode,
|
||||
@@ -146,6 +148,16 @@ export async function continuePlanInFreshContext<
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
// The fresh query seeds ultracode from settings only, so a toggle made
|
||||
// during the old session would otherwise be lost.
|
||||
if (oldSession.ultracodeEnabled !== freshSession.ultracodeEnabled) {
|
||||
try {
|
||||
await host.applyUltracode(freshSession, oldSession.ultracodeEnabled);
|
||||
} catch (error) {
|
||||
host.logError("Failed to restore ultracode after clearing context:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = [];
|
||||
|
||||
@@ -90,6 +90,8 @@ export type SubagentModels = {
|
||||
type ChildConfiguration = {
|
||||
options: Map<string, SessionConfigOption>;
|
||||
published?: string;
|
||||
/** The `model` option value and name that the client last got. */
|
||||
publishedModel?: ModelEntry;
|
||||
publishing: Promise<void>;
|
||||
};
|
||||
|
||||
@@ -285,13 +287,13 @@ export class NativeSubagentRuntime {
|
||||
if (!entry) return;
|
||||
// Every response of a child reports its model: most repeat the model that
|
||||
// the client already has, and need no routing.
|
||||
// A publication that failed sent nothing, so only the published model counts.
|
||||
const child = this.childByParentToolUse.get(parentToolUseId!);
|
||||
const current = child?.announced
|
||||
? this.configBySession.get(child.sessionId)?.options.get(MODEL_CONFIG_ID)
|
||||
: undefined;
|
||||
if (current?.type === "select" && current.currentValue === entry.value) {
|
||||
const shown = current.options[0];
|
||||
if (shown && "name" in shown && shown.name === entry.name) return;
|
||||
if (
|
||||
child?.announced &&
|
||||
sameModelEntry(this.configBySession.get(child.sessionId)?.publishedModel, entry)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await deliver({
|
||||
sessionId: this.rootSessionId,
|
||||
@@ -384,6 +386,11 @@ export class NativeSubagentRuntime {
|
||||
},
|
||||
});
|
||||
config.published = snapshot;
|
||||
const model = configOptions.find((option) => option.id === MODEL_CONFIG_ID);
|
||||
const shown = model?.type === "select" ? model.options[0] : undefined;
|
||||
if (model && shown && "name" in shown && typeof model.currentValue === "string") {
|
||||
config.publishedModel = { value: model.currentValue, name: shown.name };
|
||||
}
|
||||
});
|
||||
// The caller receives the error; later observations must still be able to retry.
|
||||
config.publishing = publishing.catch(() => {});
|
||||
@@ -1418,12 +1425,15 @@ export class NativeSubagentRuntime {
|
||||
|
||||
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
|
||||
const updates = this.pending.get(parentToolUseId);
|
||||
// A child reports its model with each response before its announcement.
|
||||
// Only the latest configuration waits, so they do not fill the buffer.
|
||||
if (notification.update.sessionUpdate === "config_option_update" && updates) {
|
||||
const index = updates.findIndex(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
// A child reports its model with each response before its announcement
|
||||
// (see `reportModel`). Only the latest such report waits, so they do not
|
||||
// fill the buffer. Any other configuration update waits as it is.
|
||||
const modelOnly = ({ update }: AcpSessionNotification) =>
|
||||
update.sessionUpdate === "config_option_update" &&
|
||||
update.configOptions.length === 1 &&
|
||||
update.configOptions[0]?.id === MODEL_CONFIG_ID;
|
||||
if (updates && modelOnly(notification)) {
|
||||
const index = updates.findIndex(modelOnly);
|
||||
if (index >= 0) {
|
||||
updates[index] = notification;
|
||||
return;
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
export const ULTRACODE_CONFIG_ID = "ultracode";
|
||||
|
||||
@@ -14,14 +14,15 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
// ultracode on at any level, so send `ultracode: true` with every effort apply
|
||||
// while the session has ultracode on. When it is off, the key is omitted: an
|
||||
// effort change then leaves ultracode off, as it already was.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
ultracode: boolean,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, Logger, Session } from "../acp-agent.js";
|
||||
import {
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
renameTo,
|
||||
parseRenameSessionRequest,
|
||||
parseSessionIdRequest,
|
||||
parseBatchGetSessionsRequest,
|
||||
SESSION_BATCH_GET_METHOD,
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
SESSION_RENAME_METHOD,
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
@@ -54,6 +57,8 @@ import {
|
||||
type RetitleOptions,
|
||||
type SessionIdRequest,
|
||||
type TitleChange,
|
||||
type BatchGetSessionsRequest,
|
||||
type BatchGetSessionsResponse,
|
||||
} from "./service.js";
|
||||
|
||||
/** What the session index keeps on each `Session`. */
|
||||
@@ -144,12 +149,13 @@ export class SessionIndexConnection {
|
||||
}
|
||||
|
||||
/** The AIR capabilities the agent advertises for the index: the index
|
||||
* itself, archive, rename, the list subscription and the `agentWorktree`
|
||||
* itself, batch get, archive, rename, the list subscription and the `agentWorktree`
|
||||
* row field, all only to a client that declared `sessionIndex`. */
|
||||
capabilities(): string[] {
|
||||
return this.service
|
||||
? [
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_BATCH_GET_CAPABILITY,
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
@@ -230,6 +236,12 @@ export class SessionIndexConnection {
|
||||
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
|
||||
}
|
||||
|
||||
batchGet(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse> {
|
||||
return this.requireService(SESSION_BATCH_GET_METHOD).batchGet(params, (sessionId) =>
|
||||
this.ownSessionState(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/** An AIR client archives with session/delete (see {@link deleteSession}):
|
||||
* its archived sessions (an archived custom title) stay hidden from the
|
||||
* old list, as when the delete removed them. */
|
||||
@@ -448,6 +460,7 @@ export class SessionIndexConnection {
|
||||
|
||||
/** The agent methods behind the session index extension methods. */
|
||||
type SessionIndexMethods = {
|
||||
batchGetSessions(params: BatchGetSessionsRequest): Promise<BatchGetSessionsResponse>;
|
||||
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
|
||||
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
|
||||
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
|
||||
@@ -455,10 +468,15 @@ type SessionIndexMethods = {
|
||||
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
};
|
||||
|
||||
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
/** Routes `_session/batch_get`, `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
|
||||
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
|
||||
return app
|
||||
.onRequest<BatchGetSessionsRequest, BatchGetSessionsResponse>(
|
||||
SESSION_BATCH_GET_METHOD,
|
||||
{ parse: parseBatchGetSessionsRequest },
|
||||
(ctx) => agent().batchGetSessions(ctx.params),
|
||||
)
|
||||
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
{ parse: parseListSubscribeRequest },
|
||||
|
||||
@@ -104,6 +104,17 @@ export async function canonicalPath(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the path exists, including a symlink whose target is missing. */
|
||||
export async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the project directories of `cwd` among `rootEntries`. */
|
||||
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RequestError,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { airExtensionMeta } from "../air-extension.js";
|
||||
@@ -37,7 +38,7 @@ import {
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
projectDirMatches,
|
||||
@@ -65,6 +66,7 @@ import { sessionInfoOf } from "./session-info.js";
|
||||
export const SESSION_RENAME_METHOD = "_session/rename";
|
||||
export const SESSION_ARCHIVE_METHOD = "_session/archive";
|
||||
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
|
||||
export const SESSION_BATCH_GET_METHOD = "_session/batch_get";
|
||||
|
||||
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
@@ -73,6 +75,27 @@ const CURSOR_VERSION = 5;
|
||||
|
||||
export type SessionIdRequest = { sessionId: string };
|
||||
export type RenameSessionRequest = { sessionId: string; title: string };
|
||||
export type BatchGetSessionsRequest = { sessionIds: string[] };
|
||||
export type BatchGetSessionsResponse = {
|
||||
sessions: SessionInfo[];
|
||||
};
|
||||
|
||||
export function parseBatchGetSessionsRequest(value: unknown): BatchGetSessionsRequest {
|
||||
const record = asRecord(value);
|
||||
const ids = record.sessionIds;
|
||||
if (
|
||||
Object.keys(record).length !== 1 ||
|
||||
!Array.isArray(ids) ||
|
||||
ids.some((id) => typeof id !== "string" || !id.trim()) ||
|
||||
new Set(ids).size !== ids.length
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"params require only sessionIds: distinct, non-blank strings",
|
||||
);
|
||||
}
|
||||
return { sessionIds: ids };
|
||||
}
|
||||
|
||||
export function sessionNotFound(sessionId: string): RequestError {
|
||||
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
|
||||
@@ -364,16 +387,6 @@ function isSdkNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The error the SDK `deleteSession` gives for a session id it rejects or
|
||||
* does not find, which a client without `sessionIndex` got before. */
|
||||
function sdkDeleteError(sessionId: string): Error {
|
||||
@@ -520,6 +533,22 @@ export class SessionIndexService {
|
||||
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
|
||||
}
|
||||
|
||||
/** Metadata for known ids, independent of list pages and project filters. */
|
||||
async batchGet(
|
||||
{ sessionIds }: BatchGetSessionsRequest,
|
||||
own: (sessionId: string) => OwnSessionState | undefined,
|
||||
): Promise<BatchGetSessionsResponse> {
|
||||
if (sessionIds.length === 0) return { sessions: [] };
|
||||
const live = await this.registry.snapshot();
|
||||
const now = this.now();
|
||||
const rows = await this.index.getSessions(sessionIds);
|
||||
return {
|
||||
sessions: rows.map((row) =>
|
||||
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
|
||||
@@ -26,6 +26,8 @@ import { effectiveTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
encodeProjectPath,
|
||||
errorCode,
|
||||
exists,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
normalizePath,
|
||||
@@ -58,11 +60,15 @@ import { isInLinkedWorktree, worktreeCounterparts } from "./worktrees.js";
|
||||
|
||||
export const DEFAULT_LIST_LIMIT = 50;
|
||||
export const MAX_LIST_LIMIT = 200;
|
||||
/** Discovery could not establish which sessions the store contains. */
|
||||
export class SessionStoreUnavailableError extends Error {}
|
||||
/** Entries of the metadata cache: a few hundred bytes each, so the
|
||||
* transcripts of a large history (tens of thousands) stay cached and deep
|
||||
* pages do not read them again. */
|
||||
const METADATA_CACHE_SIZE = 50_000;
|
||||
const READ_BATCH_SIZE = 16;
|
||||
/** Session ids looked up together by {@link SessionIndex.getSessions}. */
|
||||
const LOOKUP_CHUNK_SIZE = 100;
|
||||
/** Transcripts read at most per directory to recover a sibling's cwd. */
|
||||
const MAX_CWD_PROBES_PER_DIR = 64;
|
||||
/** How long a directory that gave no cwd is not probed again. */
|
||||
@@ -97,6 +103,24 @@ export type TranscriptCandidate = {
|
||||
ino: number;
|
||||
};
|
||||
|
||||
type TranscriptFile = Pick<
|
||||
TranscriptCandidate,
|
||||
"sessionId" | "filePath" | "dirName" | "projectPath"
|
||||
>;
|
||||
|
||||
/** Non-empty transcript files, with bounded stats shared by lists and id lookups. */
|
||||
async function transcriptCandidates(
|
||||
files: readonly TranscriptFile[],
|
||||
): Promise<TranscriptCandidate[]> {
|
||||
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||
return files.flatMap((file, i) => {
|
||||
const current = stats[i];
|
||||
return current?.isFile() && current.size > 0
|
||||
? [{ ...file, mtimeMs: current.mtimeMs, size: current.size, ino: current.ino }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The cached metadata of one transcript. */
|
||||
type TranscriptMetadata = {
|
||||
/** The visible title: without the archive prefix. */
|
||||
@@ -526,25 +550,16 @@ export class SessionIndex {
|
||||
const listed = await this.listings.namesOfAll(dirPaths);
|
||||
const files = dirs.flatMap(({ dirName, projectPath }, i) =>
|
||||
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6))).map(
|
||||
(name) => ({ name, filePath: path.join(root, dirName, name), dirName, projectPath }),
|
||||
(name) => ({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath: path.join(root, dirName, name),
|
||||
dirName,
|
||||
projectPath,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const stats = await statFiles(files.map(({ filePath }) => filePath));
|
||||
const all: TranscriptCandidate[] = [];
|
||||
files.forEach(({ name, filePath, dirName, projectPath }, i) => {
|
||||
const fileStats = stats[i];
|
||||
if (!fileStats?.isFile() || fileStats.size === 0) return;
|
||||
all.push({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath,
|
||||
dirName,
|
||||
projectPath,
|
||||
mtimeMs: fileStats.mtimeMs,
|
||||
size: fileStats.size,
|
||||
ino: fileStats.ino,
|
||||
});
|
||||
});
|
||||
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates: all };
|
||||
const candidates = await transcriptCandidates(files);
|
||||
return { dirs: [root, ...dirPaths], names: [rootNames, ...listed], candidates };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -580,6 +595,90 @@ export class SessionIndex {
|
||||
};
|
||||
}
|
||||
|
||||
/** Known ids in request order, using the same rows as the list.
|
||||
* Addresses exact filenames; only rows without a cwd need sibling discovery. */
|
||||
async getSessions(sessionIds: readonly string[]): Promise<IndexRow[]> {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const root = projectsRoot();
|
||||
let projectEntries: string[];
|
||||
try {
|
||||
projectEntries = await fs.readdir(root);
|
||||
} catch (cause) {
|
||||
if (errorCode(cause) === "ENOENT" && !(await exists(root))) {
|
||||
return [];
|
||||
}
|
||||
throw new SessionStoreUnavailableError("Session store could not be read", { cause });
|
||||
}
|
||||
const ids = sessionIds.filter(isSessionId);
|
||||
const byId = new Map<string, IndexRow>();
|
||||
// Paths are project directories × ids: bound them per chunk, not per request.
|
||||
for (let start = 0; start < ids.length; start += LOOKUP_CHUNK_SIZE) {
|
||||
const chunk = ids.slice(start, start + LOOKUP_CHUNK_SIZE);
|
||||
for (const row of await this.lookupRows(root, projectEntries, chunk)) {
|
||||
byId.set(row.sessionId, row);
|
||||
}
|
||||
}
|
||||
return sessionIds.flatMap((id) => {
|
||||
const row = byId.get(id);
|
||||
return row ? [row] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The rows of `ids` found under `projectEntries`. All copies of an id are
|
||||
* in the same call, so copy selection is unchanged by chunking. */
|
||||
private async lookupRows(
|
||||
root: string,
|
||||
projectEntries: readonly string[],
|
||||
ids: readonly string[],
|
||||
): Promise<IndexRow[]> {
|
||||
let candidates = await transcriptCandidates(
|
||||
projectEntries.flatMap((dirName) =>
|
||||
ids.map((sessionId) => ({
|
||||
sessionId,
|
||||
filePath: path.join(root, dirName, `${sessionId}.jsonl`),
|
||||
dirName,
|
||||
})),
|
||||
),
|
||||
);
|
||||
if (CASE_INSENSITIVE_PATHS) {
|
||||
// On a case-insensitive file system, stat of `ABC.jsonl` also finds
|
||||
// `abc.jsonl`. Keep a file only when its real name is the requested one,
|
||||
// or differs by more than case (a symlink, as the list would read it).
|
||||
// A failed realpath means the file is unreadable, so the id is omitted.
|
||||
const exact = await Promise.all(
|
||||
candidates.map(async ({ filePath }) => {
|
||||
try {
|
||||
const actual = path.basename(await fs.realpath(filePath));
|
||||
const expected = path.basename(filePath);
|
||||
return actual === expected || actual.toLowerCase() !== expected.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
candidates = candidates.filter((_, i) => exact[i]);
|
||||
}
|
||||
return this.collectRows(candidates.sort(byMtimeDescending), {
|
||||
inScope: scopeOf(undefined),
|
||||
archived: "all",
|
||||
wanted: Infinity,
|
||||
unread: async (read, dirNames) => {
|
||||
const dirs = [...dirNames];
|
||||
const listed = await this.listings.namesOfAll(dirs.map((dir) => path.join(root, dir)));
|
||||
const siblings = dirs.flatMap((dirName, i) =>
|
||||
listed[i]!.filter((name) => name.endsWith(".jsonl") && isSessionId(name.slice(0, -6)))
|
||||
.map((name) => ({
|
||||
sessionId: name.slice(0, -6),
|
||||
filePath: path.join(root, dirName, name),
|
||||
dirName,
|
||||
}))
|
||||
.filter(({ filePath }) => !read.has(filePath)),
|
||||
);
|
||||
return (await transcriptCandidates(siblings)).sort(byMtimeDescending);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Every transcript file of `sessionId` (see {@link scanSession}). Empty
|
||||
* files only with `includeEmpty`. */
|
||||
async findTranscripts(
|
||||
@@ -778,9 +877,12 @@ export class SessionIndex {
|
||||
archived: ArchivedFilter;
|
||||
after?: ListCursor;
|
||||
wanted: number;
|
||||
/** The transcripts not read (by path), which may supply a sibling's
|
||||
* cwd. */
|
||||
unread: (read: ReadonlySet<string>) => readonly TranscriptCandidate[];
|
||||
/** The unread transcripts of the directories still eligible for cwd
|
||||
* recovery. Called only after checking the negative cache. */
|
||||
unread?: (
|
||||
read: ReadonlySet<string>,
|
||||
dirNames: ReadonlySet<string>,
|
||||
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>;
|
||||
restat?: boolean;
|
||||
},
|
||||
): Promise<IndexRow[]> {
|
||||
@@ -852,14 +954,15 @@ export class SessionIndex {
|
||||
}
|
||||
await settlePending();
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const unread = options.unread;
|
||||
if (pending.length > 0 && unread) {
|
||||
// A row recovered after the scan stopped still sorts into the page: it
|
||||
// was read before the stop, and more rows only raise the bound. A
|
||||
// sibling that the archive filter or the cursor leaves out may still
|
||||
// supply the cwd.
|
||||
await this.learnDirCwds(
|
||||
new Set(pending.map(({ candidate }) => candidate.dirName)),
|
||||
options.unread(read),
|
||||
(dirNames) => unread(read, dirNames),
|
||||
options.restat ?? false,
|
||||
);
|
||||
await settlePending();
|
||||
@@ -893,7 +996,9 @@ export class SessionIndex {
|
||||
* changes. `restat` as in {@link collectRows}. */
|
||||
private async learnDirCwds(
|
||||
dirNames: ReadonlySet<string>,
|
||||
unread: readonly TranscriptCandidate[],
|
||||
unread: (
|
||||
dirNames: ReadonlySet<string>,
|
||||
) => readonly TranscriptCandidate[] | Promise<readonly TranscriptCandidate[]>,
|
||||
restatFirst: boolean,
|
||||
): Promise<void> {
|
||||
const root = projectsRoot();
|
||||
@@ -909,8 +1014,9 @@ export class SessionIndex {
|
||||
if (known && known.mtimeMs === mtimeMs && now - known.at < NO_CWD_RETRY_MS) continue;
|
||||
mtimes.set(dirName, mtimeMs);
|
||||
}
|
||||
if (mtimes.size === 0) return;
|
||||
const probes = new Map<string, number>();
|
||||
let remaining = unread.filter((candidate) => {
|
||||
let remaining = (await unread(new Set(mtimes.keys()))).filter((candidate) => {
|
||||
if (!mtimes.has(candidate.dirName)) return false;
|
||||
const count = probes.get(candidate.dirName) ?? 0;
|
||||
if (count >= MAX_CWD_PROBES_PER_DIR) return false;
|
||||
|
||||
@@ -595,12 +595,20 @@ export async function getAvailableModels(
|
||||
for (const name of displayNames) {
|
||||
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
const names = models.map((model, index) =>
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
);
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const name of names) {
|
||||
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
availableModels: models.map((model, index) => ({
|
||||
modelId: model.value,
|
||||
name:
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
// Allowlisted aliases can inherit the same SDK label. Keep every ID
|
||||
// selectable, but distinguish the rows for clients that show only names.
|
||||
name: nameCounts.get(names[index]) === 1 ? names[index] : `${names[index]} (${model.value})`,
|
||||
description: model.description,
|
||||
})),
|
||||
currentModelId: currentModel.value,
|
||||
|
||||
136
src/session-ultracode.ts
Normal file
136
src/session-ultracode.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
export { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
/** Select-fallback values for clients without boolean config options. */
|
||||
export const ULTRACODE_ON = "on";
|
||||
export const ULTRACODE_OFF = "off";
|
||||
const ULTRACODE_DESCRIPTION = "Dynamic workflows on every task, at any effort level";
|
||||
|
||||
/** Ultracode state from the CLI's `get_settings` control response
|
||||
* (`applied.ultracodeAvailable` / `applied.ultracodeRequested`). Each field is
|
||||
* undefined when the CLI does not report it. */
|
||||
export type UltracodeSettingsReport = {
|
||||
/** Workflows are enabled and the current model supports ultracode. */
|
||||
available?: boolean;
|
||||
/** Ultracode is turned on for the session, whether or not it can take
|
||||
* effect on the current model. */
|
||||
requested?: boolean;
|
||||
};
|
||||
|
||||
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
|
||||
// SDK's type declarations as of 0.3.310.
|
||||
type GetSettingsQuery = Query & {
|
||||
getSettings?: () => Promise<{
|
||||
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Ask the CLI whether ultracode is available and requested for the session.
|
||||
* Returns undefined when the SDK has no `getSettings` or the request fails;
|
||||
* callers then fall back to {@link modelSupportsUltracode}. */
|
||||
export async function readUltracodeSettings(
|
||||
query: Query,
|
||||
): Promise<UltracodeSettingsReport | undefined> {
|
||||
const getSettings = (query as GetSettingsQuery).getSettings;
|
||||
if (typeof getSettings !== "function") return undefined;
|
||||
try {
|
||||
const applied = (await getSettings.call(query))?.applied;
|
||||
return {
|
||||
available:
|
||||
typeof applied?.ultracodeAvailable === "boolean" ? applied.ultracodeAvailable : undefined,
|
||||
requested:
|
||||
typeof applied?.ultracodeRequested === "boolean" ? applied.ultracodeRequested : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The CLI supports ultracode on exactly the models that support `xhigh`
|
||||
* effort (both use the same capability check), and `supportedEffortLevels`
|
||||
* lists `xhigh` only for those models. E.g. Opus 4.7+, Sonnet 5+ and Haiku
|
||||
* 5.5 qualify; Opus 4.6 and Haiku 4.5 do not. */
|
||||
export function modelSupportsUltracode(modelInfo: ModelInfo | undefined): boolean {
|
||||
return (
|
||||
modelInfo?.supportsEffort === true &&
|
||||
modelInfo.supportedEffortLevels?.includes("xhigh") === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether dynamic workflows are enabled for the session, inferred from the
|
||||
* CLI's `ultracodeAvailable` for the model the session started on. This is
|
||||
* the part of availability that does not change with the model: the plan
|
||||
* default (off on Pro), `enableWorkflows`/`disableWorkflows` and org policy.
|
||||
* Undefined when the report can't tell, e.g. `ultracodeAvailable: false` on a
|
||||
* model that lacks ultracode support anyway. */
|
||||
export function ultracodeWorkflowsEnabled(
|
||||
report: UltracodeSettingsReport | undefined,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
): boolean | undefined {
|
||||
if (report?.available === true) return true;
|
||||
if (report?.available === false && modelSupportsUltracode(modelInfo)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether to offer the ultracode toggle for `modelInfo`. An unknown workflows
|
||||
* state shows the toggle: the CLI keeps an unavailable request without
|
||||
* error, so the toggle is merely inert there. */
|
||||
export function ultracodeSupported(
|
||||
workflowsEnabled: boolean | undefined,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
): boolean {
|
||||
return workflowsEnabled !== false && modelSupportsUltracode(modelInfo);
|
||||
}
|
||||
|
||||
/** Ultracode state threaded into `buildConfigOptions`. The option is only
|
||||
* surfaced while `supported`. */
|
||||
export type UltracodeOptionState = {
|
||||
supported: boolean;
|
||||
enabled: boolean;
|
||||
/** Whether the client opted into boolean config options. */
|
||||
useBooleanOption: boolean;
|
||||
};
|
||||
|
||||
/** Build the ultracode toggle: a native boolean for clients that opted into
|
||||
* boolean config options, otherwise an "on"/"off" select. */
|
||||
export function createUltracodeConfigOption(
|
||||
enabled: boolean,
|
||||
useBooleanOption: boolean,
|
||||
): SessionConfigOption {
|
||||
const base = {
|
||||
id: ULTRACODE_CONFIG_ID,
|
||||
name: "Ultracode",
|
||||
description: ULTRACODE_DESCRIPTION,
|
||||
category: "model_config",
|
||||
} as const;
|
||||
|
||||
if (useBooleanOption) {
|
||||
return { ...base, type: "boolean", currentValue: enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
currentValue: enabled ? ULTRACODE_ON : ULTRACODE_OFF,
|
||||
options: [
|
||||
{ value: ULTRACODE_ON, name: "On" },
|
||||
{ value: ULTRACODE_OFF, name: "Off" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the requested ultracode value from a `session/set_config_option`
|
||||
* request: a boolean, or the "on"/"off" select-fallback strings. */
|
||||
export function resolveUltracodeEnabled(params: SetSessionConfigOptionRequest): boolean {
|
||||
const value = params.value;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === ULTRACODE_ON) return true;
|
||||
if (value === ULTRACODE_OFF) return false;
|
||||
throw new Error(`Invalid value for config option ${ULTRACODE_CONFIG_ID}: ${value}`);
|
||||
}
|
||||
@@ -112,6 +112,12 @@ export class RfdSubagentReplay {
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -189,7 +195,7 @@ export class RfdSubagentReplay {
|
||||
* whose model changes gets its `model` option again.
|
||||
*/
|
||||
async responseModel(owner: ReplayChild, model: string | undefined): Promise<void> {
|
||||
const entry = responseModelEntry(model, this.models);
|
||||
const entry = responseModelEntry(model, this.mapsDuringReplay ? this.models : undefined);
|
||||
if (!entry || !owner.announced) return;
|
||||
owner.responseModel = model;
|
||||
await this.sendModel(owner, entry);
|
||||
|
||||
@@ -3596,11 +3596,19 @@ describe("subagent transcript replay", () => {
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce(replayHistory);
|
||||
// A load replays while the CLI starts: the session and its model list come later.
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
agent as unknown as {
|
||||
replaySessionHistory(
|
||||
sessionId: string,
|
||||
messages: unknown[],
|
||||
pending: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
).replaySessionHistory("s1", replayHistory, {
|
||||
state: { cwd: "/tmp", taskState: new Map(), messageIdToUuid: new Map() },
|
||||
stopped: false,
|
||||
});
|
||||
const child = "s1:replay-subagent:parent-agent-call";
|
||||
const models = () =>
|
||||
updates.flatMap(({ sessionId, update }) =>
|
||||
@@ -3877,7 +3885,14 @@ describe("subagent transcript replay", () => {
|
||||
/** The session's config options and model list, known after the response. */
|
||||
models?: { configOptions: SessionConfigOption[]; modelInfos: ModelInfo[] };
|
||||
},
|
||||
{ traceModels = false }: { traceModels?: boolean } = {},
|
||||
{
|
||||
traceModels = false,
|
||||
load = false,
|
||||
}: {
|
||||
traceModels?: boolean;
|
||||
/** Replay as a load does, while the CLI starts: without the session record. */
|
||||
load?: boolean;
|
||||
} = {},
|
||||
): Promise<string[]> {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-rfd-replay-"));
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -3915,9 +3930,21 @@ describe("subagent transcript replay", () => {
|
||||
);
|
||||
await initializeClient(agent, capabilities);
|
||||
vi.mocked(getSessionInfo).mockResolvedValueOnce({ cwd: "/tmp/proj" } as any);
|
||||
const pending = load
|
||||
? {
|
||||
state: { cwd: "/tmp/proj", taskState: new Map(), messageIdToUuid: new Map() },
|
||||
stopped: false,
|
||||
}
|
||||
: undefined;
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory(sessionId);
|
||||
agent as unknown as {
|
||||
replaySessionHistory(
|
||||
sessionId: string,
|
||||
messages?: unknown[],
|
||||
pending?: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
).replaySessionHistory(sessionId, undefined, pending);
|
||||
const responseAt = updates.length;
|
||||
if (afterResponse) {
|
||||
// Stands in for the live session's runtime, which knows its running children.
|
||||
@@ -4268,7 +4295,7 @@ describe("subagent transcript replay", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ traceModels: true },
|
||||
{ traceModels: true, load: true },
|
||||
);
|
||||
|
||||
const opus = JSON.stringify([{ value: "opus", name: "Opus 5" }]);
|
||||
@@ -12604,6 +12631,56 @@ describe("stop reason propagation", () => {
|
||||
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
|
||||
});
|
||||
|
||||
it.each(
|
||||
[false, true].flatMap((typedFailures) =>
|
||||
[undefined, "authentication_failed" as const].map((staleError) => ({
|
||||
typedFailures,
|
||||
staleError,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"preserves a usage-limit failure after compaction (typed: $typedFailures, stale error: $staleError)",
|
||||
async ({ typedFailures, staleError }) => {
|
||||
const agent = createMockAgent();
|
||||
if (typedFailures) await initializeClient(agent, airSessionFailureCapabilities);
|
||||
const staleFrame = createAssistantError(staleError, "stale compaction output");
|
||||
staleFrame.message.model = "<synthetic>";
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: null,
|
||||
compact_result: "success",
|
||||
uuid: "compact-completed",
|
||||
session_id: "test-session",
|
||||
},
|
||||
createUsageLimitAssistantError(),
|
||||
staleFrame,
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "private provider detail",
|
||||
}),
|
||||
]);
|
||||
|
||||
const prompt = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
if (typedFailures) {
|
||||
expect(sessionFailureFromResponse(await prompt)).toMatchObject({
|
||||
category: "limit",
|
||||
actions: [],
|
||||
title:
|
||||
"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
|
||||
});
|
||||
} else {
|
||||
await expect(prompt).rejects.toMatchObject({ data: { errorKind: "rate_limit" } });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
@@ -13901,6 +13978,7 @@ describe("session/close", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -14022,6 +14100,7 @@ describe("session/delete", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -14181,6 +14260,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -15937,6 +16017,132 @@ describe("usage_update computation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreground fork tool results", () => {
|
||||
it.each([
|
||||
["plain", {}, null],
|
||||
["AIR", AIR_CLIENT_CAPABILITIES, null],
|
||||
["plain nested", {}, "toolu_outer"],
|
||||
["AIR nested", AIR_CLIENT_CAPABILITIES, "toolu_outer"],
|
||||
] as const)(
|
||||
"waits for the launching side's result for a %s client",
|
||||
async (_, capabilities, parent) => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const logger = { log: vi.fn(), error: vi.fn() };
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
logger,
|
||||
);
|
||||
await initializeClient(agent, capabilities);
|
||||
const input = new Pushable<any>();
|
||||
const assistant = (content: unknown[], parentToolUseId: string | null) => ({
|
||||
type: "assistant",
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: randomUUID(),
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content,
|
||||
usage: successfulResultMessage().usage,
|
||||
},
|
||||
});
|
||||
const user = (content: unknown[], parentToolUseId: string | null) => ({
|
||||
type: "user",
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: { role: "user", content },
|
||||
});
|
||||
const toolResult = (id: string, text: string) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: id,
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
async function* messages() {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(value);
|
||||
yield assistant(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_fork",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "fork",
|
||||
run_in_background: false,
|
||||
prompt: "Inspect the parser",
|
||||
},
|
||||
},
|
||||
],
|
||||
parent,
|
||||
);
|
||||
// The fork inherits a result for its own launch. It cannot complete
|
||||
// that call: the launching side sends the actual report later.
|
||||
yield user([toolResult("toolu_fork", "Fork started — that fork is you.")], "toolu_fork");
|
||||
yield assistant(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_child",
|
||||
name: "Agent",
|
||||
input: { subagent_type: "general-purpose", prompt: "Read the parser" },
|
||||
},
|
||||
],
|
||||
"toolu_fork",
|
||||
);
|
||||
// Keep results of tools the fork itself called, even in a message
|
||||
// that also carries the copied launch result.
|
||||
yield {
|
||||
...user(
|
||||
[
|
||||
toolResult("toolu_fork", "Fork started — processing in background"),
|
||||
toolResult("toolu_child", "child report"),
|
||||
],
|
||||
"toolu_fork",
|
||||
),
|
||||
// Filtering must not attribute the inherited launch sidecar to
|
||||
// the one result that remains in this message.
|
||||
tool_use_result: {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "inherited launch report" }],
|
||||
},
|
||||
};
|
||||
yield user([toolResult("toolu_fork", "fork report")], parent);
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(messages()), input });
|
||||
|
||||
await expect(
|
||||
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Inspect it" }] }),
|
||||
).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
|
||||
const completed = updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "tool_call_update" && update.status === "completed"
|
||||
? [{ id: update.toolCallId, content: update.content, rawOutput: update.rawOutput }]
|
||||
: [],
|
||||
);
|
||||
expect(completed.map(({ id }) => id)).toEqual(["toolu_child", "toolu_fork"]);
|
||||
expect(completed.map(({ content }) => content)).toEqual([
|
||||
[{ type: "content", content: { type: "text", text: "child report" } }],
|
||||
[{ type: "content", content: { type: "text", text: "fork report" } }],
|
||||
]);
|
||||
if (!("_meta" in capabilities)) {
|
||||
expect(completed.map(({ rawOutput }) => rawOutput)).toEqual([
|
||||
[{ type: "text", text: "child report" }],
|
||||
[{ type: "text", text: "fork report" }],
|
||||
]);
|
||||
}
|
||||
expect(logger.error.mock.calls.flat().join("\n")).not.toContain("wasn't tracked");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("assembled assistant text fallback", () => {
|
||||
const ZERO_USAGE = {
|
||||
input_tokens: 0,
|
||||
@@ -18509,6 +18715,7 @@ describe("post-error recovery", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -26865,6 +27072,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -28772,6 +28980,7 @@ describe("agent selection config option", () => {
|
||||
agents,
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
|
||||
@@ -153,7 +153,7 @@ afterEach(async () => {
|
||||
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
|
||||
execFileSpy.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
||||
|
||||
@@ -22,6 +22,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
|
||||
* Tests set `execFileResults` per invoked subcommand. */
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
/** `auth status` probes, wherever global options such as `--settings` put the subcommand. */
|
||||
function isStatusProbe(args: string[]): boolean {
|
||||
const auth = args.indexOf("auth");
|
||||
return auth >= 0 && args[auth + 1] === "status";
|
||||
}
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
@@ -409,7 +414,7 @@ describe("auth status over ACP", () => {
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const args = invocation[1] as string[];
|
||||
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
if (args[1] === "status") {
|
||||
if (isStatusProbe(args)) {
|
||||
const stdout = statusStdout;
|
||||
const fails = statusFails;
|
||||
const answer = () => {
|
||||
@@ -451,7 +456,7 @@ describe("auth status over ACP", () => {
|
||||
});
|
||||
|
||||
function statusCalls() {
|
||||
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
|
||||
return execFileSpy.mock.calls.filter((call) => isStatusProbe(call[1] as string[]));
|
||||
}
|
||||
|
||||
function updates() {
|
||||
@@ -542,11 +547,11 @@ describe("auth status over ACP", () => {
|
||||
env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" },
|
||||
});
|
||||
expect(statusCalls().at(-1)?.[1]).toEqual([
|
||||
"--settings",
|
||||
JSON.stringify({ apiKeyHelper: "central proxy start" }),
|
||||
"auth",
|
||||
"status",
|
||||
"--json",
|
||||
"--settings",
|
||||
JSON.stringify({ apiKeyHelper: "central proxy start" }),
|
||||
]);
|
||||
await promptOnce(sessionId);
|
||||
await flushStatus();
|
||||
|
||||
@@ -31,6 +31,7 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
|
||||
configOptions: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
input: { push: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
@@ -49,6 +50,7 @@ function testHost(
|
||||
return freshSession;
|
||||
}),
|
||||
applyFastMode: vi.fn(async () => {}),
|
||||
applyUltracode: vi.fn(async () => {}),
|
||||
publishSessionState: vi.fn(async () => {}),
|
||||
continuationMessage: vi.fn((sessionId, plan, promptUuid) => ({
|
||||
type: "user" as const,
|
||||
@@ -252,6 +254,25 @@ describe("continuePlanInFreshContext", () => {
|
||||
expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, false);
|
||||
});
|
||||
|
||||
it("restores ultracode turned on before clearing context", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({ activeTurn: turn, ultracodeEnabled: true });
|
||||
const freshSession = testSession({ ultracodeEnabled: false });
|
||||
const host = testHost(oldSession, freshSession);
|
||||
|
||||
await continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(host.applyUltracode).toHaveBeenCalledWith(freshSession, true);
|
||||
});
|
||||
|
||||
it("does not publish or continue a restart cancelled while creating the fresh session", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
|
||||
298
src/tests/folded-steer.test.ts
Normal file
298
src/tests/folded-steer.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const refused = () => {
|
||||
throw new Error("Unexpected real SDK call");
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
query: vi.fn(refused),
|
||||
getSessionMessages: vi.fn(async () => []),
|
||||
getSubagentMessages: vi.fn(async () => []),
|
||||
listSessions: vi.fn(async () => []),
|
||||
getSessionInfo: vi.fn(async () => undefined),
|
||||
deleteSession: vi.fn(refused),
|
||||
forkSession: vi.fn(refused),
|
||||
importSessionToStore: vi.fn(refused),
|
||||
};
|
||||
});
|
||||
|
||||
async function untilDone<T>(promise: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Prompt did not settle")), 3000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function usage(inputTokens: number, outputTokens: number) {
|
||||
return {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function modelUsage(inputTokens: number, outputTokens: number) {
|
||||
return {
|
||||
"claude-sonnet-4-6": {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stamp(uuid: string, fields: string) {
|
||||
return {
|
||||
...(fields !== "plural" ? { user_message_uuid: uuid } : {}),
|
||||
...(fields !== "singular" ? { user_message_uuids: [uuid] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
describe("a steer folded into an autonomous cycle", () => {
|
||||
it.each([
|
||||
["singular", true],
|
||||
["plural", true],
|
||||
["both", true],
|
||||
["singular", false],
|
||||
["plural", false],
|
||||
["both", false],
|
||||
] as const)("settles from its own %s UUID stamp (echo: %s)", async (fields, echo) => {
|
||||
const agent = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
const echoed = Promise.withResolvers<void>();
|
||||
const interrupted = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const processed = Promise.withResolvers<void>();
|
||||
const finish = Promise.withResolvers<void>();
|
||||
async function* messages() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: original } = await iter.next();
|
||||
yield userEcho(original);
|
||||
echoed.resolve();
|
||||
const { value: steered } = await iter.next();
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "human" },
|
||||
...stamp(original.uuid, "both"),
|
||||
queued_turn_count: 1,
|
||||
stop_reason: "tool_use",
|
||||
usage: usage(10, 5),
|
||||
modelUsage: modelUsage(10, 5),
|
||||
});
|
||||
interrupted.resolve();
|
||||
await resume.promise;
|
||||
if (echo) yield userEcho(steered);
|
||||
// Only the steer's UUID is named; it has no queued Turn of its own.
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "task-notification" },
|
||||
...stamp(steered.uuid, fields),
|
||||
queued_turn_count: 0,
|
||||
stop_reason: "max_tokens",
|
||||
usage: usage(20, 7),
|
||||
modelUsage: modelUsage(30, 12),
|
||||
});
|
||||
yield idle;
|
||||
processed.resolve();
|
||||
// Stream shutdown must not supply the prompt's response.
|
||||
await finish.promise;
|
||||
}
|
||||
const session = mockSessionState({ input, query: wrapQuery(messages()) });
|
||||
agent.sessions["test-session"] = session;
|
||||
let response: unknown;
|
||||
const prompt = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
|
||||
.then((value) => (response = value));
|
||||
try {
|
||||
await untilDone(echoed.promise);
|
||||
await expect(
|
||||
agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle this" }],
|
||||
}),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
await untilDone(interrupted.promise);
|
||||
expect(response).toBeUndefined();
|
||||
resume.resolve();
|
||||
await untilDone(processed.promise);
|
||||
await untilDone(prompt);
|
||||
const tokenCount = {
|
||||
totalTokens: 42,
|
||||
inputTokens: 30,
|
||||
cachedInputTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
outputTokens: 12,
|
||||
reasoningOutputTokens: 0,
|
||||
};
|
||||
expect(response).toEqual({
|
||||
stopReason: "max_tokens",
|
||||
usage: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 12,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 42,
|
||||
},
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: tokenCount,
|
||||
model_usage: [{ model: "claude-sonnet-4-6", token_count: tokenCount }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(session.activeTurn).toBeNull();
|
||||
expect(session.turnQueue).toHaveLength(0);
|
||||
expect(session.owedTrailingIdles).toBe(0);
|
||||
} finally {
|
||||
resume.resolve();
|
||||
finish.resolve();
|
||||
await untilDone(session.consumer);
|
||||
await untilDone(prompt);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["unrelated", "held", "settled", "plural-unrelated", "plural-empty"] as const)(
|
||||
"does not attribute a result to a steer in the %s state",
|
||||
async (state) => {
|
||||
const agent = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
const echoed = Promise.withResolvers<void>();
|
||||
const processed = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const finish = Promise.withResolvers<void>();
|
||||
let response: unknown;
|
||||
let owner: any;
|
||||
let steerUuid = "";
|
||||
const heldOutcome = { stopReason: "end_turn" };
|
||||
async function* messages() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: original } = await iter.next();
|
||||
yield userEcho(original);
|
||||
echoed.resolve();
|
||||
const { value: steered } = await iter.next();
|
||||
steerUuid = steered.uuid;
|
||||
const session = agent.sessions["test-session"];
|
||||
owner = session.activeTurn;
|
||||
// Constructed defensive states: these do not model normal settlement.
|
||||
if (state === "held") {
|
||||
owner.deferredSettle = heldOutcome;
|
||||
owner.spawnedTaskIds = new Set(["still-running"]);
|
||||
session.liveBackgroundTasks.set("still-running", { isSubagent: true });
|
||||
} else if (state === "settled") {
|
||||
owner.settled = true;
|
||||
}
|
||||
const unrelated = randomUUID();
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "task-notification" },
|
||||
user_message_uuid: state === "unrelated" ? unrelated : steerUuid,
|
||||
user_message_uuids:
|
||||
state === "plural-empty" ? [] : [state.includes("unrelated") ? unrelated : steerUuid],
|
||||
stop_reason: "refusal",
|
||||
usage: usage(100, 50),
|
||||
modelUsage: modelUsage(100, 50),
|
||||
});
|
||||
processed.resolve();
|
||||
await resume.promise;
|
||||
owner.settled = false;
|
||||
owner.deferredSettle = undefined;
|
||||
owner.spawnedTaskIds = undefined;
|
||||
session.liveBackgroundTasks.clear();
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "human" },
|
||||
...stamp(steerUuid, "both"),
|
||||
usage: usage(10, 5),
|
||||
});
|
||||
yield idle; // the excluded autonomous result's trailer
|
||||
yield idle; // the human result's trailer
|
||||
await finish.promise;
|
||||
}
|
||||
const session = mockSessionState({ input, query: wrapQuery(messages()) });
|
||||
agent.sessions["test-session"] = session;
|
||||
const prompt = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
|
||||
.then((value) => (response = value));
|
||||
try {
|
||||
await untilDone(echoed.promise);
|
||||
await expect(
|
||||
agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle this" }],
|
||||
}),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
await untilDone(processed.promise);
|
||||
expect(response).toBeUndefined();
|
||||
expect(session.activeTurn).toBe(owner);
|
||||
expect(session.accumulatedUsage).toEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
});
|
||||
expect(session.accumulatedModelUsage).toEqual({});
|
||||
expect(owner.steeredUuids).toEqual(new Set([steerUuid]));
|
||||
expect(owner.steeredEchoes).toEqual(new Set([steerUuid]));
|
||||
expect(owner.steeredSettle).toBeUndefined();
|
||||
if (state === "held") expect(owner.deferredSettle).toBe(heldOutcome);
|
||||
if (state === "settled") expect(owner.settled).toBe(true);
|
||||
resume.resolve();
|
||||
await untilDone(prompt);
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 15,
|
||||
},
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
totalTokens: 15,
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
outputTokens: 5,
|
||||
reasoningOutputTokens: 0,
|
||||
},
|
||||
model_usage: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
resume.resolve();
|
||||
finish.resolve();
|
||||
await untilDone(session.consumer);
|
||||
await untilDone(prompt);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -31,6 +31,9 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
|
||||
supportedCommands: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
// No ultracode fields: session creation falls back to settings and the
|
||||
// model's effort levels, as with a CLI that does not report them.
|
||||
getSettings: async () => ({ applied: {} }),
|
||||
close: () => {},
|
||||
interrupt: async () => undefined,
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
|
||||
@@ -97,6 +97,76 @@ describe("incomplete foreground tools", () => {
|
||||
expect(agent.sessions[sessionId].toolCallParents?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
content: { type: "advisor_result", text: "Check the queue ordering." },
|
||||
status: "completed",
|
||||
text: "Check the queue ordering.",
|
||||
},
|
||||
{
|
||||
content: { type: "advisor_redacted_result", encrypted_content: "opaque-advice" },
|
||||
status: "completed",
|
||||
text: "Advisor guidance applied server-side.",
|
||||
},
|
||||
{
|
||||
content: { type: "advisor_tool_result_error", error_code: "overloaded" },
|
||||
status: "failed",
|
||||
text: "Advisor error: overloaded",
|
||||
},
|
||||
] as const)(
|
||||
"settles an advisor call with a $content.type result",
|
||||
async ({ content, status, text }) => {
|
||||
const advisorId = "srvtoolu_advisor";
|
||||
const { prompt, updates, logError, agent } = createTestSession(async function* (input) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield advisorStart(advisorId);
|
||||
yield advisorToolUse(advisorId);
|
||||
yield advisorResult(advisorId, content);
|
||||
expect(hasHookCallback(advisorId)).toBe(false);
|
||||
yield successfulResultMessage();
|
||||
});
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
const terminal = updates.filter(
|
||||
(update) =>
|
||||
update.toolCallId === advisorId &&
|
||||
(update.status === "completed" || update.status === "failed"),
|
||||
);
|
||||
expect(terminal).toHaveLength(1);
|
||||
expect(terminal[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: advisorId,
|
||||
status,
|
||||
});
|
||||
expect(JSON.stringify(terminal[0])).toContain(text);
|
||||
expect(JSON.stringify(updates)).not.toContain("opaque-advice");
|
||||
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
|
||||
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("fails a dispatched advisor call without a result", async () => {
|
||||
const advisorId = "srvtoolu_advisor";
|
||||
const { prompt, updates, agent } = createTestSession(async function* (input) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield advisorStart(advisorId);
|
||||
yield advisorToolUse(advisorId);
|
||||
yield successfulResultMessage();
|
||||
});
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
const terminal = updates.filter(
|
||||
(update) =>
|
||||
update.toolCallId === advisorId &&
|
||||
(update.status === "completed" || update.status === "failed"),
|
||||
);
|
||||
expect(terminal).toHaveLength(1);
|
||||
expect(terminal[0]).toMatchObject({ status: "failed" });
|
||||
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
|
||||
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a tool explicitly handed off to a background task", async () => {
|
||||
const { prompt, updates } = createTestSession(backgroundToolMessages);
|
||||
|
||||
@@ -1123,6 +1193,44 @@ function toolUse(
|
||||
};
|
||||
}
|
||||
|
||||
function advisorStart(id: string) {
|
||||
return {
|
||||
...toolStart(id),
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "server_tool_use", id, name: "advisor", input: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The complete assistant message that dispatches the advisor server tool. */
|
||||
function advisorToolUse(id: string) {
|
||||
return {
|
||||
type: "assistant",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage: successfulResultMessage().usage,
|
||||
content: [{ type: "server_tool_use", id, name: "advisor", input: {} }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function advisorResult(id: string, content: unknown) {
|
||||
return {
|
||||
type: "assistant",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage: successfulResultMessage().usage,
|
||||
content: [{ type: "advisor_tool_result", tool_use_id: id, content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantText(text: string) {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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";
|
||||
import {
|
||||
applyAvailableModelsAllowlist,
|
||||
buildModelConfigOption,
|
||||
getAvailableModels,
|
||||
} from "../session-model.js";
|
||||
|
||||
const OPUS: ModelInfo = {
|
||||
value: "opus[1m]",
|
||||
@@ -151,6 +155,68 @@ describe("versioned model display names", () => {
|
||||
);
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]);
|
||||
});
|
||||
|
||||
it("distinguishes allowlisted aliases and pins that inherit the same SDK label", async () => {
|
||||
const sdkModels: ModelInfo[] = [
|
||||
{ value: "default", displayName: "Default (recommended)", description: "" },
|
||||
{
|
||||
value: "opus",
|
||||
resolvedModel: "claude-opus-5-5",
|
||||
displayName: "Opus 5.5",
|
||||
description: "For complex work and everyday tasks",
|
||||
},
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
];
|
||||
const ids = ["claude-opus-5-5[1m]", "claude-opus-5-5", "opus[1m]", "opus", "haiku"];
|
||||
const models = applyAvailableModelsAllowlist(sdkModels, ids);
|
||||
const setModel = vi.fn();
|
||||
const state = await getAvailableModels(
|
||||
{ setModel } as unknown as Query,
|
||||
models,
|
||||
sdkModels,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
|
||||
const expected = [
|
||||
"Default (recommended)",
|
||||
...ids.slice(0, 4).map((id) => `Opus 5.5 (${id})`),
|
||||
"Haiku",
|
||||
];
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual(expected);
|
||||
expect(state.availableModels.map((model) => model.modelId)).toEqual(["default", ...ids]);
|
||||
expect(state.currentModelId).toBe("default");
|
||||
expect(state.availableModels.slice(1, 5).map((model) => model.description)).toEqual(
|
||||
Array(4).fill(sdkModels[1].description),
|
||||
);
|
||||
const option = buildModelConfigOption(state, models, false);
|
||||
expect(option).toMatchObject({ currentValue: "default" });
|
||||
expect(option.type === "select" && option.options.map((model) => model.name)).toEqual(expected);
|
||||
expect(sdkModels[1].displayName).toBe("Opus 5.5");
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes identical custom labels without changing their model IDs", async () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ value: "provider-a", displayName: "My model", description: "First endpoint" },
|
||||
{ value: "provider-b", displayName: "My model", description: "Second endpoint" },
|
||||
];
|
||||
const state = await getAvailableModels(
|
||||
{} as Query,
|
||||
models,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
expect(state.availableModels).toEqual([
|
||||
{ modelId: "provider-a", name: "My model (provider-a)", description: "First endpoint" },
|
||||
{ modelId: "provider-b", name: "My model (provider-b)", description: "Second endpoint" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Account-dependent model availability requires the same authenticated environment
|
||||
@@ -172,19 +238,18 @@ it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
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}`),
|
||||
);
|
||||
// Only alias rows ("opus", "opus[1m]"). The catalogue also lists pinned
|
||||
// older versions such as "claude-opus-4-8"; those are separate models.
|
||||
const opusModels = models.filter((model) => /^opus\b/i.test(model.value));
|
||||
expect(opusModels).toHaveLength(1);
|
||||
// CLI 2.1.283 dropped the "[1m]" / "(1M context)" spelling from the Opus
|
||||
// row: Opus is natively 1M, so the row names the plain model.
|
||||
expect(opusModels[0]).toMatchObject({
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
displayName: expect.stringMatching(/^Opus\b/),
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-[\d-]+$/),
|
||||
});
|
||||
expect(opusModels[0].displayName).not.toMatch(/\b1m\b/i);
|
||||
expect(opusModels[0].description).not.toMatch(/\b1m\b/i);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -381,17 +381,17 @@ describe("settingsEffortForModel", () => {
|
||||
});
|
||||
|
||||
describe("effortFlagSettings", () => {
|
||||
it("carries ultracode only when settings request it", () => {
|
||||
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
|
||||
it("carries ultracode only while the session has it on", () => {
|
||||
expect(effortFlagSettings("high", true)).toEqual({
|
||||
effortLevel: "high",
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
|
||||
expect(effortFlagSettings("default", true)).toEqual({
|
||||
effortLevel: null,
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
expect(effortFlagSettings("high", false)).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, false)).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2678,6 +2678,44 @@ describe("NativeSubagentRuntime child models", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends a model again after its publication failed",
|
||||
async (form) => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
let failures = 1;
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
if (notification.update.sessionUpdate === "config_option_update" && failures-- > 0) {
|
||||
throw new Error("client gone");
|
||||
}
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await expect(runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver)).rejects.toThrow(
|
||||
"client gone",
|
||||
);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
},
|
||||
);
|
||||
|
||||
it("air: forgets the configuration of an ended child", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
|
||||
@@ -16,13 +16,18 @@ import {
|
||||
listSessions,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { spawn } from "node:child_process";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js";
|
||||
import {
|
||||
client as acpClient,
|
||||
type InitializeResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
archiveTo,
|
||||
renameTo,
|
||||
parseBatchGetSessionsRequest,
|
||||
SessionIndexService,
|
||||
writeCustomTitleSidecar,
|
||||
} from "../session-index/service.js";
|
||||
@@ -45,6 +50,11 @@ import {
|
||||
} from "./session-doubles.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof fs>();
|
||||
return { ...actual, readdir: vi.fn(actual.readdir) };
|
||||
});
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
return {
|
||||
@@ -69,10 +79,15 @@ beforeEach(async () => {
|
||||
configDir = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-cfg-")));
|
||||
workspace = fsSync.realpathSync(await fs.mkdtemp(path.join(os.tmpdir(), "session-index-ws-")));
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
// Integration runs leave CLAUDE_CODE_EXECUTABLE unset, so initialize would run
|
||||
// the real `claude auth status` in the background. It writes into configDir
|
||||
// while afterEach removes it, and the rm fails with ENOTEMPTY.
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
@@ -257,6 +272,7 @@ const airCapabilities = (response: { _meta?: Record<string, unknown> | null }) =
|
||||
describe("sessionIndex negotiation", () => {
|
||||
const indexCapabilities = [
|
||||
"sessionIndex",
|
||||
"sessionBatchGet",
|
||||
"sessionArchive",
|
||||
"sessionRename",
|
||||
"sessionListSubscribe",
|
||||
@@ -290,7 +306,7 @@ describe("sessionIndex negotiation", () => {
|
||||
// Declaring archive or rename alone enables nothing.
|
||||
const withoutIndex = await createAgent().agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: air("sessionArchive", "sessionRename"),
|
||||
clientCapabilities: air("sessionArchive", "sessionRename", "sessionBatchGet"),
|
||||
});
|
||||
expect(airCapabilities(withoutIndex)).toEqual(baseline);
|
||||
|
||||
@@ -314,7 +330,7 @@ describe("sessionIndex negotiation", () => {
|
||||
it("answers method-not-found to the new methods without the capability", async () => {
|
||||
// Declaring sessionArchive or sessionRename without sessionIndex enables
|
||||
// nothing either.
|
||||
for (const capabilities of [[], ["sessionArchive", "sessionRename"]]) {
|
||||
for (const capabilities of [[], ["sessionArchive", "sessionRename", "sessionBatchGet"]]) {
|
||||
const { agent } = createAgent();
|
||||
await initializeClient(agent, air(...capabilities));
|
||||
const sessionId = randomUUID();
|
||||
@@ -323,10 +339,423 @@ describe("sessionIndex negotiation", () => {
|
||||
});
|
||||
await expect(agent.archiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 });
|
||||
await expect(agent.unarchiveSession({ sessionId })).rejects.toMatchObject({ code: -32601 });
|
||||
await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toMatchObject({
|
||||
code: -32601,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("_session/batch_get", () => {
|
||||
it("routes and validates the extension through the ACP connection", async () => {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
const connection = acpClient({ name: "batch-get-test" }).connect(
|
||||
v1AgentApp({ log: () => {}, error: () => {} }, (created) => {
|
||||
agent = created;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const response = await connection.agent.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: air("sessionIndex"),
|
||||
});
|
||||
expect(airCapabilities(response)).toContain("sessionBatchGet");
|
||||
expect(await connection.agent.request("_session/batch_get", { sessionIds: [] })).toEqual({
|
||||
sessions: [],
|
||||
});
|
||||
await expect(
|
||||
connection.agent.request("_session/batch_get", { sessionIds: [], cwd: "/repo" }),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
} finally {
|
||||
connection.close();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only distinct, non-blank string ids and no other fields", () => {
|
||||
for (const params of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ sessionIds: null },
|
||||
{ sessionIds: "id" },
|
||||
{ sessionIds: [""] },
|
||||
{ sessionIds: [" "] },
|
||||
{ sessionIds: [1] },
|
||||
{ sessionIds: ["id", "id"] },
|
||||
{ sessionIds: [], cwd: null },
|
||||
]) {
|
||||
expect(() => parseBatchGetSessionsRequest(params)).toThrow(
|
||||
expect.objectContaining({ code: -32602 }),
|
||||
);
|
||||
}
|
||||
const sessionIds = Array.from({ length: 1000 }, (_, i) => `id-${i}`);
|
||||
expect(parseBatchGetSessionsRequest({ sessionIds })).toEqual({ sessionIds });
|
||||
// Ids are exact, not trimmed or otherwise normalized.
|
||||
expect(parseBatchGetSessionsRequest({ sessionIds: [" id ", "id"] })).toEqual({
|
||||
sessionIds: [" id ", "id"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty results without touching the store", async () => {
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const lookup = vi.spyOn(index, "getSessions").mockRejectedValue(new Error("store down"));
|
||||
expect(await agent.batchGetSessions({ sessionIds: [] })).toEqual({
|
||||
sessions: [],
|
||||
});
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("returns no sessions when the store is absent", async () => {
|
||||
const { agent } = await indexAgent();
|
||||
const sessionIds = [randomUUID(), "retained-id"];
|
||||
expect(await agent.batchGetSessions({ sessionIds })).toEqual({
|
||||
sessions: [],
|
||||
});
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("finds ids older than the recent 50 without listing or reading unrelated history", async () => {
|
||||
const base = Date.parse("2026-03-01T00:00:00Z");
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 55; i++) {
|
||||
ids.push(
|
||||
(
|
||||
await writeTranscript({
|
||||
cwd: path.join(workspace, `project-${i % 2}`),
|
||||
lastMessageAt: base - i * 60_000,
|
||||
})
|
||||
).id,
|
||||
);
|
||||
}
|
||||
const recent = await indexAgent();
|
||||
const page = await recent.agent.listSessions({});
|
||||
expect(page.sessions).toHaveLength(50);
|
||||
expect(page.sessions.map((s) => s.sessionId)).not.toContain(ids[54]);
|
||||
await recent.agent.dispose();
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const list = vi.spyOn(index, "list").mockRejectedValue(new Error("must not list"));
|
||||
const names = vi.spyOn(index.listings, "namesOfAll").mockRejectedValue(new Error("history"));
|
||||
const wanted = [ids[54]!, ids[53]!, ids[0]!];
|
||||
const readdir = vi.mocked(fs.readdir);
|
||||
readdir.mockClear();
|
||||
try {
|
||||
const result = await agent.batchGetSessions({ sessionIds: wanted });
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual(wanted);
|
||||
expect(transcriptsRead(agent)).toBe(wanted.length);
|
||||
const root = path.join(configDir, "projects");
|
||||
const storeReads = readdir.mock.calls
|
||||
.map(([dir]) => String(dir))
|
||||
.filter((dir) => dir === root || dir.startsWith(root + path.sep));
|
||||
expect(storeReads).toEqual([root]);
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
expect(names).not.toHaveBeenCalled();
|
||||
expect(listSessions).not.toHaveBeenCalled();
|
||||
expect(getSessionInfo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
readdir.mockClear();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps request order and copy selection across lookup chunks", async () => {
|
||||
const base = Date.parse("2026-03-01T00:00:00Z");
|
||||
const first = await writeTranscript({ lastMessageAt: base });
|
||||
const last = await writeTranscript({ lastMessageAt: base - 60_000 });
|
||||
const copyId = randomUUID();
|
||||
await writeTranscript({ sessionId: copyId, prompt: "Old copy", lastMessageAt: base - 120_000 });
|
||||
await writeTranscript({
|
||||
sessionId: copyId,
|
||||
cwd: path.join(workspace, "other"),
|
||||
prompt: "New copy",
|
||||
lastMessageAt: base - 30_000,
|
||||
});
|
||||
// 250 ids: three lookup chunks of 100, with found ids in the first, second and third.
|
||||
const sessionIds: string[] = Array.from({ length: 250 }, () => randomUUID());
|
||||
sessionIds[0] = last.id;
|
||||
sessionIds[120] = copyId;
|
||||
sessionIds[249] = first.id;
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
const result = await agent.batchGetSessions({ sessionIds });
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual([last.id, copyId, first.id]);
|
||||
expect(result.sessions[1]!.title).toBe("New copy");
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers a cold batch row from a sibling and keeps it in the next list", async () => {
|
||||
const base = Date.parse("2026-04-01T00:00:00Z");
|
||||
const other = path.join(workspace, "other");
|
||||
const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base });
|
||||
const fillers: string[] = [];
|
||||
for (let i = 1; i <= 16; i++) {
|
||||
fillers.push((await writeTranscript({ lastMessageAt: base - i * 1000 })).id);
|
||||
}
|
||||
// This sibling is older than the transcripts needed to fill the page.
|
||||
await writeTranscript({ cwd: other, lastMessageAt: base - 3600_000 });
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const names = vi.spyOn(index.listings, "namesOfAll");
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
||||
try {
|
||||
const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] });
|
||||
expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]);
|
||||
expect(transcriptsRead(agent)).toBe(2);
|
||||
expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]);
|
||||
names.mockRestore();
|
||||
|
||||
// Use the same index without changing files or letting the negative cache expire.
|
||||
const page = await agent.listSessions({ _meta: listMeta({ limit: 2 }) });
|
||||
expect(page.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([
|
||||
[noCwd.id, other],
|
||||
[fillers[0], workspace],
|
||||
]);
|
||||
expect(page.sessions[0]).toEqual(result.sessions[0]);
|
||||
} finally {
|
||||
names.mockRestore();
|
||||
now.mockRestore();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds sibling cwd probes and retries a negative result after its cache expires", async () => {
|
||||
const base = Date.parse("2026-04-01T00:00:00Z");
|
||||
const other = path.join(workspace, "other");
|
||||
const noCwd = await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base });
|
||||
const siblings: { id: string; file: string }[] = [];
|
||||
for (let i = 1; i <= 70; i++) {
|
||||
siblings.push(
|
||||
await writeTranscript({ cwd: other, recordCwd: null, lastMessageAt: base - i * 1000 }),
|
||||
);
|
||||
}
|
||||
await writeTranscript({ lastMessageAt: base });
|
||||
const { agent } = await indexAgent();
|
||||
const index = (agent as any).sessionIndex.service.index;
|
||||
const names = vi.spyOn(index.listings, "namesOfAll");
|
||||
const at = Date.now();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(at);
|
||||
try {
|
||||
expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] });
|
||||
expect(transcriptsRead(agent)).toBe(1 + 64);
|
||||
expect(names.mock.calls).toEqual([[[path.dirname(noCwd.file)]]]);
|
||||
|
||||
// Rewriting an existing sibling does not change the directory mtime.
|
||||
await writeTranscript({ sessionId: siblings[0]!.id, cwd: other, lastMessageAt: base });
|
||||
expect(await agent.batchGetSessions({ sessionIds: [noCwd.id] })).toEqual({ sessions: [] });
|
||||
expect(transcriptsRead(agent)).toBe(1 + 64);
|
||||
expect(names).toHaveBeenCalledTimes(1);
|
||||
|
||||
now.mockReturnValue(at + 60_001);
|
||||
const result = await agent.batchGetSessions({ sessionIds: [noCwd.id] });
|
||||
expect(result.sessions.map((s) => [s.sessionId, s.cwd])).toEqual([[noCwd.id, other]]);
|
||||
expect(transcriptsRead(agent)).toBe(1 + 64 + 1);
|
||||
expect(names.mock.calls).toEqual([
|
||||
[[path.dirname(noCwd.file)]],
|
||||
[[path.dirname(noCwd.file)]],
|
||||
]);
|
||||
} finally {
|
||||
names.mockRestore();
|
||||
now.mockRestore();
|
||||
await agent.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the same unscoped list rows for archived, worktree and duplicate copies in request order", async () => {
|
||||
const repo = path.join(workspace, "repo");
|
||||
const worktree = path.join(workspace, "worktree");
|
||||
await linkWorktrees(repo, worktree);
|
||||
const archivedId = randomUUID();
|
||||
const archived = await writeTranscript({
|
||||
sessionId: archivedId,
|
||||
cwd: repo,
|
||||
trailer: titleRecords(archivedId, "[archived] Done"),
|
||||
});
|
||||
const worktreeId = randomUUID();
|
||||
await writeTranscript({
|
||||
sessionId: worktreeId,
|
||||
cwd: worktree,
|
||||
trailer: [worktreeState(worktreeId, { path: worktree })],
|
||||
});
|
||||
const copyId = randomUUID();
|
||||
await writeTranscript({ sessionId: copyId, cwd: repo, prompt: "Old copy" });
|
||||
await writeTranscript({
|
||||
sessionId: copyId,
|
||||
cwd: worktree,
|
||||
prompt: "New copy",
|
||||
mtimeMs: Date.parse("2026-02-01"),
|
||||
});
|
||||
const { agent } = await indexAgent();
|
||||
const requested = [worktreeId, archived.id, copyId];
|
||||
const result = await agent.batchGetSessions({ sessionIds: requested });
|
||||
const read = transcriptsRead(agent);
|
||||
const listed = await agent.listSessions({ _meta: listMeta({ archived: "all" }) });
|
||||
expect(transcriptsRead(agent)).toBe(read);
|
||||
expect(result.sessions).toEqual(
|
||||
requested.map((id) => listed.sessions.find((s) => s.sessionId === id)),
|
||||
);
|
||||
expect(airRow(result.sessions[1]!).archived).toBe(true);
|
||||
expect(agentWorktreeOfRow(result.sessions[0])).toBe(true);
|
||||
expect(result.sessions[2]).toMatchObject({ title: "New copy", cwd: worktree });
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"returns readable sessions in request order and omits unavailable ids",
|
||||
async () => {
|
||||
const first = await writeTranscript({});
|
||||
const found = await writeTranscript({});
|
||||
const unreadable = await writeTranscript({});
|
||||
await fs.chmod(unreadable.file, 0);
|
||||
const absent = randomUUID();
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
const result = await agent.batchGetSessions({
|
||||
sessionIds: [absent, first.id, unreadable.id, found.id],
|
||||
});
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual([first.id, found.id]);
|
||||
expect(Object.keys(result)).toEqual(["sessions"]);
|
||||
} finally {
|
||||
await fs.chmod(unreadable.file, 0o600);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"uses a readable copy when another copy cannot be read or stat'ed, as the unscoped list does",
|
||||
async () => {
|
||||
const sessionId = randomUUID();
|
||||
await writeTranscript({ sessionId, prompt: "Readable copy" });
|
||||
const hiddenProject = path.join(workspace, "inaccessible");
|
||||
const unreadable = await writeTranscript({
|
||||
sessionId,
|
||||
cwd: hiddenProject,
|
||||
prompt: "Unreadable newer copy",
|
||||
mtimeMs: Date.parse("2026-02-01"),
|
||||
});
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
// A failed metadata read, then a failed stat of the same copy.
|
||||
for (const target of [unreadable.file, path.dirname(unreadable.file)]) {
|
||||
await fs.chmod(target, 0);
|
||||
const result = await agent.batchGetSessions({ sessionIds: [sessionId] });
|
||||
const listed = await agent.listSessions({});
|
||||
expect(result).toEqual({ sessions: listed.sessions });
|
||||
expect(result.sessions[0]?.title).toBe("Readable copy");
|
||||
await fs.chmod(target, 0o700);
|
||||
}
|
||||
} finally {
|
||||
await fs.chmod(path.dirname(unreadable.file), 0o700);
|
||||
await fs.chmod(unreadable.file, 0o600);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"omits failed stats, dangling links and non-file transcripts",
|
||||
async () => {
|
||||
const unreadable = await writeTranscript({ cwd: path.join(workspace, "inaccessible") });
|
||||
const dangling = await writeTranscript({});
|
||||
await fs.rm(dangling.file);
|
||||
await fs.symlink(path.join(workspace, "not-there"), dangling.file);
|
||||
const nonFile = await writeTranscript({});
|
||||
await fs.rm(nonFile.file);
|
||||
await fs.mkdir(nonFile.file);
|
||||
const { agent } = await indexAgent();
|
||||
const projectDir = path.dirname(unreadable.file);
|
||||
await fs.chmod(projectDir, 0);
|
||||
try {
|
||||
const result = await agent.batchGetSessions({
|
||||
sessionIds: [unreadable.id, dangling.id, nonFile.id],
|
||||
});
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
} finally {
|
||||
await fs.chmod(projectDir, 0o700);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("matches UUIDs exactly without case or whitespace normalization", async () => {
|
||||
const sessionId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
|
||||
await writeTranscript({ sessionId });
|
||||
const { agent } = await indexAgent();
|
||||
const result = await agent.batchGetSessions({
|
||||
sessionIds: [sessionId.toUpperCase(), ` ${sessionId} `, sessionId],
|
||||
});
|
||||
expect(result.sessions.map((row) => row.sessionId)).toEqual([sessionId]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("omits empty, malformed, sidecar-only and unresolved-cwd records", async () => {
|
||||
const empty = await writeTranscript({});
|
||||
await fs.writeFile(empty.file, "");
|
||||
const malformed = await writeTranscript({});
|
||||
await fs.writeFile(malformed.file, "not json\n");
|
||||
const sidecar = randomUUID();
|
||||
const sidecarDir = path.join(configDir, "projects", encodeProjectPath(workspace), sidecar);
|
||||
await fs.mkdir(sidecarDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sidecarDir, "custom-title.json"), '{"customTitle":"Saved"}');
|
||||
const unresolved = await writeTranscript({
|
||||
cwd: path.join(workspace, "unknown"),
|
||||
recordCwd: null,
|
||||
});
|
||||
const { agent } = await indexAgent();
|
||||
const ids = [empty.id, malformed.id, sidecar, unresolved.id];
|
||||
const result = await agent.batchGetSessions({ sessionIds: ids });
|
||||
expect(result).toEqual({ sessions: [] });
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it
|
||||
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
|
||||
.each(["unreadable", "unreadable after a cached list", "a file", "a dangling symlink"])(
|
||||
"rejects a projects root that is %s",
|
||||
async (kind) => {
|
||||
const found = await writeTranscript({});
|
||||
const root = path.join(configDir, "projects");
|
||||
const { agent } = await indexAgent();
|
||||
try {
|
||||
if (kind === "unreadable after a cached list") {
|
||||
const old = new Date(Date.now() - 60_000);
|
||||
await fs.utimes(root, old, old);
|
||||
await agent.listSessions({});
|
||||
}
|
||||
if (kind.startsWith("unreadable")) {
|
||||
await fs.chmod(root, 0);
|
||||
} else {
|
||||
await fs.rm(root, { recursive: true });
|
||||
if (kind === "a file") await fs.writeFile(root, "not a store");
|
||||
else await fs.symlink(path.join(workspace, "missing-store"), root);
|
||||
}
|
||||
for (const sessionId of [found.id, "retained-id"]) {
|
||||
await expect(agent.batchGetSessions({ sessionIds: [sessionId] })).rejects.toThrow(
|
||||
"Session store could not be read",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (kind.startsWith("unreadable")) await fs.chmod(root, 0o700);
|
||||
await agent.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects under ACP v2 even when sessionIndex was declared", async () => {
|
||||
const { agent } = createAgent({ v2: true });
|
||||
await initializeClient(agent, air("sessionIndex"));
|
||||
await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({
|
||||
code: -32601,
|
||||
});
|
||||
await agent.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/list of a sessionIndex client", () => {
|
||||
const base = Date.parse("2026-03-01T00:00:00Z");
|
||||
|
||||
@@ -3528,7 +3957,8 @@ describe("paths that share a project directory", () => {
|
||||
.filter((s) => s.sessionId === mine.id);
|
||||
const removed = () =>
|
||||
changesFor(notifications, fromDotted.subscriptionId).flatMap((c) => c.removed);
|
||||
expect(await waitFor(() => added().length > 0 && removed().length > 0)).toBe(true);
|
||||
// Wait for mine.id itself: the other path's session can be removed first.
|
||||
expect(await waitFor(() => added().length > 0 && removed().includes(mine.id))).toBe(true);
|
||||
expect(added().map((s) => [s.sessionId, s.cwd])).toEqual([[mine.id, dashed]]);
|
||||
// It may also name the other path's session, never listed here.
|
||||
expect(removed()).toContain(mine.id);
|
||||
|
||||
292
src/tests/ultracode-config.test.ts
Normal file
292
src/tests/ultracode-config.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
modelSupportsUltracode,
|
||||
readUltracodeSettings,
|
||||
resolveUltracodeEnabled,
|
||||
ULTRACODE_CONFIG_ID,
|
||||
ULTRACODE_OFF,
|
||||
ULTRACODE_ON,
|
||||
ultracodeSupported,
|
||||
ultracodeWorkflowsEnabled,
|
||||
} from "../session-ultracode.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({ querySpy: vi.fn() }));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
|
||||
return { ...actual, query: querySpy };
|
||||
});
|
||||
|
||||
const OPUS: ModelInfo = {
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
|
||||
};
|
||||
const FABLE: ModelInfo = {
|
||||
value: "fable",
|
||||
displayName: "Fable",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
|
||||
};
|
||||
// Opus 4.6 and Haiku 4.5 have no `xhigh` effort, so the CLI has no ultracode
|
||||
// for them either.
|
||||
const OLD_OPUS: ModelInfo = {
|
||||
value: "claude-opus-4-6",
|
||||
displayName: "Opus 4.6",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
};
|
||||
|
||||
function getSettingsQuery(getSettings: unknown): Query {
|
||||
return { getSettings } as unknown as Query;
|
||||
}
|
||||
|
||||
describe("ultracode helpers", () => {
|
||||
it("builds a boolean option or an on/off select", () => {
|
||||
expect(createUltracodeConfigOption(true, true)).toEqual({
|
||||
id: ULTRACODE_CONFIG_ID,
|
||||
name: "Ultracode",
|
||||
description: "Dynamic workflows on every task, at any effort level",
|
||||
category: "model_config",
|
||||
type: "boolean",
|
||||
currentValue: true,
|
||||
});
|
||||
expect(createUltracodeConfigOption(false, false)).toMatchObject({
|
||||
type: "select",
|
||||
currentValue: ULTRACODE_OFF,
|
||||
options: [
|
||||
{ value: ULTRACODE_ON, name: "On" },
|
||||
{ value: ULTRACODE_OFF, name: "Off" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts booleans and the select strings", () => {
|
||||
const request = (value: unknown) =>
|
||||
({ sessionId: "s", configId: ULTRACODE_CONFIG_ID, value }) as never;
|
||||
expect(resolveUltracodeEnabled(request(true))).toBe(true);
|
||||
expect(resolveUltracodeEnabled(request(ULTRACODE_OFF))).toBe(false);
|
||||
expect(() => resolveUltracodeEnabled(request("maybe"))).toThrow(
|
||||
"Invalid value for config option ultracode: maybe",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a model as supporting ultracode when it supports xhigh effort", () => {
|
||||
expect(modelSupportsUltracode(OPUS)).toBe(true);
|
||||
expect(modelSupportsUltracode(OLD_OPUS)).toBe(false);
|
||||
expect(modelSupportsUltracode({ ...OPUS, supportsEffort: false })).toBe(false);
|
||||
expect(modelSupportsUltracode(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("infers the workflows gate only when the start model can tell", () => {
|
||||
expect(ultracodeWorkflowsEnabled({ available: true }, OLD_OPUS)).toBe(true);
|
||||
expect(ultracodeWorkflowsEnabled({ available: false }, OPUS)).toBe(false);
|
||||
// Opus 4.6 is unavailable regardless of workflows.
|
||||
expect(ultracodeWorkflowsEnabled({ available: false }, OLD_OPUS)).toBeUndefined();
|
||||
expect(ultracodeWorkflowsEnabled(undefined, OPUS)).toBeUndefined();
|
||||
|
||||
expect(ultracodeSupported(undefined, OPUS)).toBe(true);
|
||||
expect(ultracodeSupported(false, OPUS)).toBe(false);
|
||||
expect(ultracodeSupported(true, OLD_OPUS)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the CLI's ultracode report and tolerates CLIs without one", async () => {
|
||||
await expect(
|
||||
readUltracodeSettings(
|
||||
getSettingsQuery(async () => ({
|
||||
applied: { ultracodeAvailable: true, ultracodeRequested: false },
|
||||
})),
|
||||
),
|
||||
).resolves.toEqual({ available: true, requested: false });
|
||||
await expect(
|
||||
readUltracodeSettings(getSettingsQuery(async () => ({ applied: {} }))),
|
||||
).resolves.toEqual({ available: undefined, requested: undefined });
|
||||
await expect(readUltracodeSettings(getSettingsQuery(undefined))).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readUltracodeSettings(
|
||||
getSettingsQuery(async () => {
|
||||
throw new Error("unsupported control request");
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ultracode session config option", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-ultracode-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
querySpy.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalClaudeConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createSession(opts: {
|
||||
models?: ModelInfo[];
|
||||
getSettings?: () => Promise<unknown>;
|
||||
settings?: Record<string, unknown>;
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
}) {
|
||||
if (opts.settings) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify(opts.settings),
|
||||
);
|
||||
}
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({ models: opts.models ?? [OPUS, FABLE, OLD_OPUS] }),
|
||||
applyFlagSettings,
|
||||
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient);
|
||||
(agent as any).clientCapabilities = opts.clientCapabilities ?? {
|
||||
session: { configOptions: { boolean: {} } },
|
||||
};
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
const sessionId: string = response.sessionId;
|
||||
const ultracodeOption = () =>
|
||||
agent.sessions[sessionId].configOptions.find((o) => o.id === ULTRACODE_CONFIG_ID);
|
||||
return { agent, sessionId, applyFlagSettings, ultracodeOption };
|
||||
}
|
||||
|
||||
it("seeds the option from the CLI's report", async () => {
|
||||
const { ultracodeOption, agent, sessionId } = await createSession({
|
||||
getSettings: async () => ({
|
||||
applied: { ultracodeAvailable: true, ultracodeRequested: true },
|
||||
}),
|
||||
});
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, true));
|
||||
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the option while dynamic workflows are off, across model switches", async () => {
|
||||
const { agent, sessionId, ultracodeOption } = await createSession({
|
||||
getSettings: async () => ({
|
||||
applied: { ultracodeAvailable: false, ultracodeRequested: false },
|
||||
}),
|
||||
});
|
||||
expect(ultracodeOption()).toBeUndefined();
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
|
||||
expect(ultracodeOption()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the model's support and keeps the toggle across switches", async () => {
|
||||
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({
|
||||
clientCapabilities: {},
|
||||
});
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, false));
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
value: ULTRACODE_ON,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "claude-opus-4-6" });
|
||||
expect(ultracodeOption()).toBeUndefined();
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, false));
|
||||
});
|
||||
|
||||
it("keeps a toggled-on ultracode when effort changes, and not a toggled-off one", async () => {
|
||||
// An effortLevel apply without an ultracode key turns ultracode off, so
|
||||
// the effort picker must carry the session's toggle.
|
||||
const { agent, sessionId, applyFlagSettings } = await createSession({
|
||||
settings: { ultracode: true },
|
||||
});
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: false });
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "high" });
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high" });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "low" });
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "low", ultracode: true });
|
||||
});
|
||||
|
||||
it("keeps ultracode on through the effort apply that follows a model switch", async () => {
|
||||
// recommendedValue clients apply the new model's effort on every switch.
|
||||
const { agent, sessionId, applyFlagSettings } = await createSession({
|
||||
clientCapabilities: {
|
||||
session: { configOptions: { boolean: {} } },
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
},
|
||||
});
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
});
|
||||
|
||||
it("does not change session state when the SDK rejects the flag", async () => {
|
||||
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({});
|
||||
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(false);
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, true));
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,9 @@ export default defineConfig({
|
||||
// tests off the machine's real CLI and Claude config, and stops a run
|
||||
// from outliving its test (it wrote into temp config dirs being deleted).
|
||||
env: usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" },
|
||||
// Real-CLI runs may want a real gateway listing.
|
||||
setupFiles: usesRealCli ? [] : ["src/tests/setup-no-network.ts"],
|
||||
// Applies to real-CLI runs too: the gateway listing runs in the test process,
|
||||
// and tests point `providers/set` at fake URLs like gateway.example.com.
|
||||
setupFiles: ["src/tests/setup-no-network.ts"],
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user