mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 04:51:50 +00:00
Compare commits
32 Commits
experiment
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90f856c11 | ||
|
|
059d76e14a | ||
|
|
4324dce4be | ||
|
|
c7c80f7b26 | ||
|
|
8f43ac8089 | ||
|
|
0ce42f2c31 | ||
|
|
782e463ff4 | ||
|
|
c9374abfc7 | ||
|
|
d52c7c64d8 | ||
|
|
de96cef382 | ||
|
|
a4957d17c1 | ||
|
|
bed795d261 | ||
|
|
434be13a33 | ||
|
|
2a1525e57c | ||
|
|
54f1e21bc4 | ||
|
|
01b686713d | ||
|
|
6a33dedcbb | ||
|
|
e369479f93 | ||
|
|
c26e640915 | ||
|
|
f6958db1ee | ||
|
|
2f3c6bb7b3 | ||
|
|
9a8bf71d45 | ||
|
|
eb9d63e5fd | ||
|
|
c319e5e542 | ||
|
|
739dde3ae5 | ||
|
|
17a967b4da | ||
|
|
ebfbfbdaa9 | ||
|
|
6ab670baae | ||
|
|
f7d6030c04 | ||
|
|
28a09b1008 | ||
|
|
a1521b6196 | ||
|
|
0fe22ae5a1 |
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.94.0"
|
||||
".": "0.98.0"
|
||||
}
|
||||
|
||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -1,5 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
## [0.98.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.97.0...v0.98.0) (2026-10-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add batch session metadata lookup ([#1408](https://github.com/agentclientprotocol/claude-agent-acp/issues/1408)) ([0ce42f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0ce42f2c312d190f78024d8b4b1ae5f00d9e5939))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* publish Monitor async tasks as monitors ([#1452](https://github.com/agentclientprotocol/claude-agent-acp/issues/1452)) ([4324dce](https://github.com/agentclientprotocol/claude-agent-acp/commit/4324dce4be877c55385b3389cebac1c63d08f456))
|
||||
* settle the tool call of a SandboxNetworkAccess permission request ([#1448](https://github.com/agentclientprotocol/claude-agent-acp/issues/1448)) ([8f43ac8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f43ac808931a3f14719bbed37b9b153c9933139))
|
||||
* **v2:** leave the tool call status out of permission subjects ([#1447](https://github.com/agentclientprotocol/claude-agent-acp/issues/1447)) ([c7c80f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7c80f7b26f8d90f7f1c828036db64bc46dddddf))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* end a turn at its result for AIR clients with backgroundSubagents ([#1262](https://github.com/agentclientprotocol/claude-agent-acp/issues/1262)) ([9b3a5c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/9b3a5c891c75e78b985adb31f4e4b59a16ad2ebc))
|
||||
* report read-only subagent configuration ([#1417](https://github.com/agentclientprotocol/claude-agent-acp/issues/1417)) ([6ab670b](https://github.com/agentclientprotocol/claude-agent-acp/commit/6ab670baaefb30aaac2606d8ee6524e08243e5b5))
|
||||
* report RFD delegations as linked session messages ([#1405](https://github.com/agentclientprotocol/claude-agent-acp/issues/1405)) ([59e292e](https://github.com/agentclientprotocol/claude-agent-acp/commit/59e292e29f773cc6f03c0999993cbe8a2d97a7e3))
|
||||
* serve experimental ACP v2 ([#1400](https://github.com/agentclientprotocol/claude-agent-acp/issues/1400)) ([4d378f9](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d378f93f36d37ba5ef64af87b617706dd9c6e82))
|
||||
* show messages typed in other Remote Control clients ([#1371](https://github.com/agentclientprotocol/claude-agent-acp/issues/1371)) ([5b11f47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5b11f470b1c05b114c876b3b6e5b4df9441b1087))
|
||||
* update to Claude Code 2.1.309 ([#1413](https://github.com/agentclientprotocol/claude-agent-acp/issues/1413)) ([a1521b6](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1521b6196c58dbc5065f9c19fc9694273d3d435))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump fast-uri from 3.1.8 to 3.1.9 ([#1410](https://github.com/agentclientprotocol/claude-agent-acp/issues/1410)) ([0fe22ae](https://github.com/agentclientprotocol/claude-agent-acp/commit/0fe22ae5a172b228a574665bb7136b900746041d))
|
||||
* bump postcss from 8.5.30 to 8.5.31 in the minor group ([#1403](https://github.com/agentclientprotocol/claude-agent-acp/issues/1403)) ([ecaa2c9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecaa2c935b9f2b63c1846ca17c6c3398390fb0ef))
|
||||
* bump the minor group across 1 directory with 2 updates ([#1416](https://github.com/agentclientprotocol/claude-agent-acp/issues/1416)) ([f7d6030](https://github.com/agentclientprotocol/claude-agent-acp/commit/f7d6030c04af23171058772480d55f6d2aee5298))
|
||||
* IJAI-3744 keep a session running while its background tasks are open ([#1399](https://github.com/agentclientprotocol/claude-agent-acp/issues/1399)) ([e9f986b](https://github.com/agentclientprotocol/claude-agent-acp/commit/e9f986b8a941f5c1789078a39e474f247bd0db60))
|
||||
* publish the session title as soon as the CLI has it ([#1406](https://github.com/agentclientprotocol/claude-agent-acp/issues/1406)) ([50cd76e](https://github.com/agentclientprotocol/claude-agent-acp/commit/50cd76e78dd9bb1ab0e4850a8d95431175ab5947))
|
||||
* publish turns that Claude Code starts after end_turn as an async task in v1 ([#1390](https://github.com/agentclientprotocol/claude-agent-acp/issues/1390)) ([a1d0eee](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1d0eeeaad3d67a0765f4b89e3d81c1af6ca0dbb))
|
||||
|
||||
## [0.94.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.93.0...v0.94.0) (2026-10-19)
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
586
package-lock.json
generated
586
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.94.0",
|
||||
"version": "0.98.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,25 +65,24 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.303",
|
||||
"@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": "0.135.0",
|
||||
"@anthropic-ai/sdk": "1.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
||||
"@typescript-eslint/parser": "8.71.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||
"@typescript-eslint/parser": "8.72.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.10",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
|
||||
532
src/acp-agent.ts
532
src/acp-agent.ts
@@ -116,14 +116,19 @@ import {
|
||||
type SubagentState,
|
||||
} from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
isNativeSubagentControlTool,
|
||||
isNativeSubagentControlUpdate,
|
||||
NativeSubagent,
|
||||
NativeSubagentRuntime,
|
||||
nativeSubagentState,
|
||||
type ModelEntry,
|
||||
responseModelEntry,
|
||||
resumedNativeSubagentId,
|
||||
sameModelEntry,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
} from "./native-subagents.js";
|
||||
import {
|
||||
AIR_ASYNC_TASKS_CAPABILITY,
|
||||
@@ -142,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,
|
||||
@@ -203,6 +212,7 @@ import {
|
||||
import {
|
||||
type ReplayChild,
|
||||
RfdSubagentReplay,
|
||||
firstResponseModel,
|
||||
replaySubagentTerminalState,
|
||||
} from "./subagent-replay.js";
|
||||
import {
|
||||
@@ -338,6 +348,8 @@ import {
|
||||
buildModelConfigOption,
|
||||
getAvailableModels,
|
||||
MODEL_CONFIG_ID,
|
||||
modelDisplayName,
|
||||
modelEntryFor,
|
||||
narrowToGatewayModels,
|
||||
resolveModelPreference,
|
||||
type SessionModelState,
|
||||
@@ -353,9 +365,19 @@ import {
|
||||
buildEffortConfigOption,
|
||||
EFFORT_CONFIG_ID,
|
||||
effortFlagSettings,
|
||||
effortLevelName,
|
||||
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 {
|
||||
@@ -1182,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
|
||||
@@ -2714,6 +2745,20 @@ export class ClaudeAcpAgent {
|
||||
* express: see {@link ToolCallClientCapabilities.v2}.
|
||||
*/
|
||||
private readonly v2: boolean;
|
||||
/**
|
||||
* The subagent replay of each session's last load, until
|
||||
* {@link publishCurrentSubagentStates} sends its children's current states.
|
||||
*/
|
||||
private readonly replayedSubagents = new Map<string, RfdSubagentReplay>();
|
||||
/**
|
||||
* AIR's draft: the model that the last load of each session replayed to each
|
||||
* child session, as the model id and the entry that went out. See
|
||||
* {@link publishReplayedChildModels}.
|
||||
*/
|
||||
private readonly replayedChildModels = new Map<
|
||||
string,
|
||||
Map<string, { model: string; sent: ModelEntry }>
|
||||
>();
|
||||
/** See the `foreground` constructor option. */
|
||||
private readonly foregroundEvents?: (sessionId: string) => ForegroundEvents;
|
||||
/**
|
||||
@@ -2778,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),
|
||||
@@ -2792,6 +2838,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(id);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(id);
|
||||
if (this.sessions[id] === session) delete this.sessions[id];
|
||||
},
|
||||
settleCancelledTurn: (original, session, turn) => {
|
||||
@@ -3087,11 +3134,15 @@ export class ClaudeAcpAgent {
|
||||
} catch (error) {
|
||||
pending.stopped = true;
|
||||
await replay;
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
throw error;
|
||||
}
|
||||
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,
|
||||
@@ -3202,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);
|
||||
@@ -3210,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);
|
||||
}
|
||||
@@ -3402,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(),
|
||||
@@ -4543,6 +4599,7 @@ export class ClaudeAcpAgent {
|
||||
this.logger,
|
||||
(agentId) => subagentLineage(params.sessionId, agentId, this.logger),
|
||||
(launchId) => subagentLaunchedBy(params.sessionId, launchId),
|
||||
this.subagentModels(params.sessionId),
|
||||
));
|
||||
// Adapter-composed advisories (hook feedback, model fallbacks) are live
|
||||
// events, not something the model said. Clients on the notice contract get
|
||||
@@ -5058,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: {
|
||||
@@ -5073,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;
|
||||
@@ -5876,9 +5942,28 @@ export class ClaudeAcpAgent {
|
||||
// turn activation and settlement stay driven by echoes/results/idle.
|
||||
// (Raw-mode emission above still forwards these frames.)
|
||||
if ((message as { type: string }).type === "command_lifecycle") {
|
||||
const frame = message as unknown as { command_uuid: string; state: string };
|
||||
const frame = message as unknown as {
|
||||
command_uuid: string;
|
||||
state: string;
|
||||
wakeup_source?: string;
|
||||
scheduled_for?: number;
|
||||
};
|
||||
switch (frame.state) {
|
||||
case "started": {
|
||||
// CLIs 2.1.307+ (capability lifecycle_wakeup_source_v1) mark a
|
||||
// command that a scheduler fire started. With no prompt of ours
|
||||
// open, it starts an autonomous turn: name the fire as its cause,
|
||||
// whether `session_state_changed` already opened the task or not.
|
||||
const cause = scheduledFireCause(frame);
|
||||
if (
|
||||
cause &&
|
||||
asyncTasks.enabled &&
|
||||
!session.cancelled &&
|
||||
!session.activeTurn &&
|
||||
!firstUnsettledQueuedTurn()
|
||||
) {
|
||||
await asyncTasks.autonomousTurnStarted(cause);
|
||||
}
|
||||
// Remember dispatch on the live turn so a cancel() that orphans
|
||||
// it seeds the right state (see Turn.commandStarted)...
|
||||
const queued = findUnsettledTurn(frame.command_uuid);
|
||||
@@ -7629,6 +7714,11 @@ export class ClaudeAcpAgent {
|
||||
// it) can all be tagged with the same, replay-stable id.
|
||||
if (message.event.type === "message_start") {
|
||||
currentStreamMessageId = message.event.message.id || undefined;
|
||||
await subagents.reportModel(
|
||||
message.parent_tool_use_id,
|
||||
message.event.message.model,
|
||||
sendUpdate,
|
||||
);
|
||||
// A new top-level message starts: clear any streamed-content
|
||||
// residue from a prior message that never reached its
|
||||
// consolidated reset — a cancelled turn breaks out before the
|
||||
@@ -7992,9 +8082,24 @@ 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;
|
||||
}
|
||||
|
||||
if (message.type === "assistant") {
|
||||
await subagents.reportModel(
|
||||
message.parent_tool_use_id,
|
||||
message.message.model,
|
||||
sendUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot the latest top-level assistant usage and model so the
|
||||
// next `result` can emit a usage_update tied to the right context
|
||||
// window. Subagent messages are excluded to keep the snapshot
|
||||
@@ -8139,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())
|
||||
@@ -8159,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;
|
||||
}
|
||||
@@ -8176,7 +8296,7 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const backgroundBashTask = backgroundBashTaskFromToolResult(
|
||||
content,
|
||||
message.tool_use_result,
|
||||
toolUseResult,
|
||||
session.toolUseCache,
|
||||
);
|
||||
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
|
||||
@@ -8205,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,
|
||||
@@ -8246,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:
|
||||
@@ -8454,6 +8574,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(params.sessionId);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
delete this.sessions[params.sessionId];
|
||||
} else {
|
||||
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
||||
@@ -9032,6 +9153,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(sessionId);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(sessionId);
|
||||
delete this.sessions[sessionId];
|
||||
this.sessionIndex.onTeardown(sessionId);
|
||||
const ended = await raceTimeoutAndAbort(
|
||||
@@ -9107,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}`);
|
||||
@@ -9246,8 +9373,17 @@ export class ClaudeAcpAgent {
|
||||
/** The state of the last persisted task notification of the child.
|
||||
* It comes after the launch tool_result, so it wins. */
|
||||
notifiedState?: SubagentState;
|
||||
/** The model id of the child's latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
/** The `model` option entry that the replay sent to the child. */
|
||||
model?: ModelEntry;
|
||||
}
|
||||
>();
|
||||
// A load replays while the CLI starts, and the session's model list comes
|
||||
// with the CLI at an unknown point of the replay. So a pending replay maps
|
||||
// no model to the list, and each child's model is its id until the load
|
||||
// response refines it (see `publishCurrentSubagentStates`).
|
||||
const replayModels = pending ? undefined : this.subagentModels(sessionId);
|
||||
|
||||
// Registers the Agent and Task launches of `list`, and the terminal state
|
||||
// of each launch from its tool_result.
|
||||
@@ -9377,8 +9513,11 @@ export class ClaudeAcpAgent {
|
||||
: { agents: new Map() };
|
||||
const rfdReplay =
|
||||
form === "rfd"
|
||||
? new RfdSubagentReplay(rfdHistory, (notification) =>
|
||||
this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
? new RfdSubagentReplay(
|
||||
rfdHistory,
|
||||
(notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
this.subagentModels(sessionId),
|
||||
!pending,
|
||||
)
|
||||
: undefined;
|
||||
rfdReplay?.scan(messages, sessionId);
|
||||
@@ -9501,6 +9640,29 @@ export class ClaudeAcpAgent {
|
||||
: nativeReplayEnabled && parentToolUseId
|
||||
? await announceReplayChild(parentToolUseId)
|
||||
: sessionId;
|
||||
if (message.type === "assistant") {
|
||||
const model = (message.message as { model?: string }).model;
|
||||
if (rfdReplay && child) {
|
||||
await rfdReplay.responseModel(child, model);
|
||||
} else if (nativeReplayEnabled && parentToolUseId) {
|
||||
// AIR's draft: the entry of the model list that is known now. A load
|
||||
// can lack the list until the CLI started, so the entry goes again
|
||||
// after the load response when the list maps it to another one.
|
||||
const replayChild = replayChildren.get(parentToolUseId);
|
||||
const entry = responseModelEntry(model, replayModels);
|
||||
if (replayChild?.announced && entry && !sameModelEntry(replayChild.model, entry)) {
|
||||
replayChild.responseModel = model;
|
||||
replayChild.model = entry;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: replayChild.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
parentToolUseId &&
|
||||
@@ -9631,13 +9793,13 @@ export class ClaudeAcpAgent {
|
||||
for (const toolUseId of subagentLaunchIds(content)) {
|
||||
const launched = rfdReplay.childOfLaunch(toolUseId);
|
||||
if (!launched || launched.announced) continue;
|
||||
await rfdReplay.announce(launched);
|
||||
const childMessages = await readSubagentMessages(
|
||||
sessionId,
|
||||
launched.sessionId,
|
||||
rfdHistory,
|
||||
this.logger,
|
||||
);
|
||||
await rfdReplay.announce(launched, firstResponseModel(childMessages));
|
||||
rfdReplay.scan(childMessages, launched.sessionId);
|
||||
for (const childMessage of childMessages) await replayMessage(childMessage, launched);
|
||||
await rfdReplay.childReplayed(launched, childMessages);
|
||||
@@ -9647,9 +9809,17 @@ export class ClaudeAcpAgent {
|
||||
for (const message of messages) await replayMessage(message);
|
||||
if (rfdReplay && !pending?.stopped) {
|
||||
await rfdReplay.finish(clientSupportsNotices(this.clientCapabilities), sessionId);
|
||||
this.replayedSubagents.set(sessionId, rfdReplay);
|
||||
}
|
||||
|
||||
if (nativeReplayEnabled && !pending?.stopped) {
|
||||
const childModels = new Map<string, { model: string; sent: ModelEntry }>();
|
||||
for (const child of replayChildren.values()) {
|
||||
if (child.responseModel && child.model) {
|
||||
childModels.set(child.sessionId, { model: child.responseModel, sent: child.model });
|
||||
}
|
||||
}
|
||||
if (childModels.size > 0) this.replayedChildModels.set(sessionId, childModels);
|
||||
// Claude history persists sidechain messages and Agent/Task tool uses,
|
||||
// but not task_started/task_updated lifecycle frames. Recover terminal
|
||||
// state from the launch tool_result. Missing results and malformed or
|
||||
@@ -10018,6 +10188,35 @@ export class ClaudeAcpAgent {
|
||||
return raceWithAbort(this.client.createElicitation(request, signal), signal);
|
||||
}
|
||||
|
||||
/** Settle the tool call `ensureToolCallEmitted` emitted for a
|
||||
* SandboxNetworkAccess request once the request is answered. The callback
|
||||
* asks for permission and runs nothing, so the SDK never sends a
|
||||
* `tool_result` for its synthetic id, and the call would otherwise stay
|
||||
* pending in the client. */
|
||||
private async settleSandboxNetworkCall(
|
||||
sessionId: string,
|
||||
toolCallSessionId: string,
|
||||
toolCallId: string,
|
||||
allowed: boolean,
|
||||
): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
const update: SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: allowed ? "completed" : "failed",
|
||||
};
|
||||
try {
|
||||
if (session.nativeSubagentDeliver) {
|
||||
await session.nativeSubagentDeliver({ sessionId, update });
|
||||
} else {
|
||||
await this.client.sessionUpdate({ sessionId: toolCallSessionId, update });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to settle the SandboxNetworkAccess tool call:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit the `tool_call` a permission request references if it hasn't been sent
|
||||
* yet, so the client has the tool call before being asked to approve it. The
|
||||
* matching streamed tool_use chunk later refines it with a `tool_call_update`
|
||||
@@ -10120,6 +10319,84 @@ export class ClaudeAcpAgent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The models of the session `sessionId`, for the `model` option of its
|
||||
* subagents: the entries of the session's own `model` option.
|
||||
*/
|
||||
private subagentModels(sessionId: string): SubagentModels {
|
||||
return {
|
||||
entry: (model) => {
|
||||
const session = this.sessions[sessionId];
|
||||
return session
|
||||
? modelEntryFor(session.configOptions, session.modelInfos, model)
|
||||
: undefined;
|
||||
},
|
||||
name: (model) => {
|
||||
const session = this.sessions[sessionId];
|
||||
return session ? modelDisplayName(session.modelInfos, model) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private subagentConfigurationHook(sessionId: string): HookCallback {
|
||||
return async (input) => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session || !input.agent_id) return { continue: true };
|
||||
const options: SessionConfigOption[] = [];
|
||||
if (input.permission_mode) {
|
||||
const mode = session.modes.availableModes.find((mode) => mode.id === input.permission_mode);
|
||||
options.push(
|
||||
SessionModeManager.configOption({
|
||||
currentModeId: input.permission_mode,
|
||||
availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const switchedModel =
|
||||
input.hook_event_name === "PostModelSwitch"
|
||||
? responseModelEntry(input.to_model, this.subagentModels(sessionId))
|
||||
: undefined;
|
||||
// The entry of the root session's `model` option, as for a child response.
|
||||
if (switchedModel) options.push(childModelOption(switchedModel));
|
||||
if (input.effort?.level) {
|
||||
options.push({
|
||||
id: EFFORT_CONFIG_ID,
|
||||
name: "Effort",
|
||||
description: "Current reasoning effort",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: input.effort.level,
|
||||
options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }],
|
||||
});
|
||||
}
|
||||
if (input.agent_type) {
|
||||
const agent = session.agents.find((agent) => agent.name === input.agent_type);
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Subagent type",
|
||||
type: "select",
|
||||
currentValue: input.agent_type,
|
||||
options: [
|
||||
{
|
||||
value: input.agent_type,
|
||||
name: input.agent_type,
|
||||
...(agent?.description ? { description: agent.description } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
await session.nativeSubagentRuntime
|
||||
?.reportConfiguration(input.agent_id, options)
|
||||
.catch((error) =>
|
||||
this.logger.error(
|
||||
`Session ${sessionId}: failed to report subagent configuration: ${error}`,
|
||||
),
|
||||
);
|
||||
return { continue: true };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
|
||||
* shows the exact patch of the change before the tool runs.
|
||||
@@ -10389,6 +10666,14 @@ export class ClaudeAcpAgent {
|
||||
let permissionResult = decodedPermission.permissionResult;
|
||||
const autoFallback = this.sessionModes.applyPermissionFallback(session, permissionResult);
|
||||
permissionResult = autoFallback.permissionResult;
|
||||
if (toolName === "SandboxNetworkAccess") {
|
||||
await this.settleSandboxNetworkCall(
|
||||
sessionId,
|
||||
permissionSessionId,
|
||||
toolUseID,
|
||||
permissionResult.behavior === "allow",
|
||||
);
|
||||
}
|
||||
if (autoFallback.fallbackApplied) {
|
||||
await this.sessionModes.publishFallbackWarning(sessionId, session);
|
||||
}
|
||||
@@ -10599,16 +10884,75 @@ export class ClaudeAcpAgent {
|
||||
|
||||
/**
|
||||
* The work that follows a session setup response: the session's commands,
|
||||
* and MCP server authentication. A v2 client gets the commands in the setup
|
||||
* response itself (`src/v2/agent.ts`), so it gets no update with them here.
|
||||
* the current states of the subagents a load replayed, and MCP server
|
||||
* authentication. A v2 client gets the commands in the setup response itself,
|
||||
* and the subagent states from the v2 surface once it has sent its own
|
||||
* response (`src/v2/agent.ts`), so it gets neither here.
|
||||
*/
|
||||
private afterSetupResponse(sessionId: string, mcpServers: McpServer[]): void {
|
||||
setTimeout(() => {
|
||||
if (!this.v2) this.sendAvailableCommandsUpdate(sessionId);
|
||||
if (!this.v2) {
|
||||
this.sendAvailableCommandsUpdate(sessionId);
|
||||
void this.publishCurrentSubagentStates(sessionId);
|
||||
}
|
||||
startMcpAuthentication(this, sessionId, mcpServers);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the current state of each subagent that the last load of
|
||||
* `sessionId` replayed (see {@link RfdSubagentReplay.currentStates}). Run it
|
||||
* only after the load response: before it, the client treats subagent states
|
||||
* as history.
|
||||
*/
|
||||
async publishCurrentSubagentStates(sessionId: string): Promise<void> {
|
||||
await this.publishReplayedChildModels(sessionId);
|
||||
const replay = this.replayedSubagents.get(sessionId);
|
||||
if (!replay) return;
|
||||
this.replayedSubagents.delete(sessionId);
|
||||
const runtime = this.sessions[sessionId]?.nativeSubagentRuntime;
|
||||
try {
|
||||
for (const notification of replay.currentStates(
|
||||
(child) => runtime?.liveWorkState(child),
|
||||
(child) => runtime?.configurationOf?.(child),
|
||||
)) {
|
||||
await this.client.sessionUpdate(asSdkSessionNotification(notification));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: failed to send current subagent states:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AIR's draft: a load replays while the CLI starts, so the replay can lack
|
||||
* the session's model list and send a child's model as its id. After the
|
||||
* load response the list is known, and each replayed child whose model the
|
||||
* list maps to another entry gets that entry. The replay already ended the
|
||||
* child, and AIR's draft allows no other update after a child's end, but it
|
||||
* takes this more exact snapshot of the same configuration.
|
||||
*/
|
||||
private async publishReplayedChildModels(sessionId: string): Promise<void> {
|
||||
const childModels = this.replayedChildModels.get(sessionId);
|
||||
if (!childModels) return;
|
||||
this.replayedChildModels.delete(sessionId);
|
||||
const models = this.subagentModels(sessionId);
|
||||
try {
|
||||
for (const [childSessionId, { model, sent }] of childModels) {
|
||||
const entry = responseModelEntry(model, models);
|
||||
if (!entry || sameModelEntry(entry, sent)) continue;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: childSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: failed to send subagent models:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateConfigOption(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
@@ -10762,6 +11106,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,
|
||||
@@ -10788,10 +11139,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;
|
||||
@@ -10860,15 +11208,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,
|
||||
);
|
||||
@@ -10950,6 +11290,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`
|
||||
@@ -11382,6 +11738,10 @@ export class ClaudeAcpAgent {
|
||||
`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`,
|
||||
);
|
||||
|
||||
// Both forms of native subagent sessions report the configuration of a child.
|
||||
const subagentConfigHook = subagentForm(this.clientCapabilities)
|
||||
? this.subagentConfigurationHook(sessionId)
|
||||
: undefined;
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
@@ -11450,14 +11810,33 @@ export class ClaudeAcpAgent {
|
||||
tools,
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
...(subagentConfigHook
|
||||
? {
|
||||
SubagentStart: [
|
||||
...(userProvidedOptions?.hooks?.SubagentStart ?? []),
|
||||
{ hooks: [subagentConfigHook] },
|
||||
],
|
||||
SubagentStop: [
|
||||
...(userProvidedOptions?.hooks?.SubagentStop ?? []),
|
||||
{ hooks: [subagentConfigHook] },
|
||||
],
|
||||
PostToolUseFailure: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUseFailure ?? []),
|
||||
{ hooks: [subagentConfigHook] },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
// On v2, an Edit or Write shows its exact patch before it runs, also
|
||||
// when no permission request shows it (`fileChangePreviewHook`): a v2
|
||||
// diff cannot hold the input's snippet.
|
||||
...(this.v2
|
||||
...(this.v2 || subagentConfigHook
|
||||
? {
|
||||
PreToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
||||
{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] },
|
||||
...(subagentConfigHook ? [{ hooks: [subagentConfigHook] }] : []),
|
||||
...(this.v2
|
||||
? [{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
@@ -11465,6 +11844,7 @@ export class ClaudeAcpAgent {
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
hooks: [
|
||||
...(subagentConfigHook ? [subagentConfigHook] : []),
|
||||
createPostToolUseHook({
|
||||
onEnterPlanMode: async () => {
|
||||
await this.sessionModes.publishCurrent(sessionId, "plan");
|
||||
@@ -11488,9 +11868,11 @@ export class ClaudeAcpAgent {
|
||||
...(userProvidedOptions?.hooks?.PostModelSwitch || []),
|
||||
{
|
||||
hooks: [
|
||||
...(subagentConfigHook ? [subagentConfigHook] : []),
|
||||
async (input) => {
|
||||
if (
|
||||
input.hook_event_name === "PostModelSwitch" &&
|
||||
!input.agent_id &&
|
||||
input.source !== "sdk" &&
|
||||
input.source !== "resume"
|
||||
) {
|
||||
@@ -11762,6 +12144,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,
|
||||
@@ -11773,10 +12170,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),
|
||||
@@ -11844,6 +12242,8 @@ export class ClaudeAcpAgent {
|
||||
currentAgent,
|
||||
fastModeEnabled,
|
||||
fastModeDisabledReason,
|
||||
ultracodeEnabled,
|
||||
ultracodeWorkflowsEnabled: workflowsEnabled,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
forwardSubagentText,
|
||||
@@ -12673,6 +13073,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
|
||||
@@ -12703,6 +13104,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
|
||||
@@ -13345,6 +13752,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":
|
||||
@@ -13514,7 +13953,6 @@ export function toAcpNotifications(
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
case "fallback":
|
||||
case "mcp_tool_listing":
|
||||
break;
|
||||
@@ -13973,3 +14411,29 @@ function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentMo
|
||||
return { key: bestKey, usage: modelUsage[bestKey] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a `command_lifecycle` "started" frame says started the command: a
|
||||
* ScheduleWakeup (dynamic `/loop`) wakeup, with the time it was set for in
|
||||
* epoch milliseconds, or another scheduled task such as a cron job. Undefined
|
||||
* for any other command, and for a value this adapter does not know. The time
|
||||
* is given in UTC because the agent does not know the client's time zone.
|
||||
*/
|
||||
function scheduledFireCause(frame: {
|
||||
wakeup_source?: string;
|
||||
scheduled_for?: number;
|
||||
}): string | undefined {
|
||||
switch (frame.wakeup_source) {
|
||||
case "loop_wakeup": {
|
||||
const scheduledFor =
|
||||
typeof frame.scheduled_for === "number" ? new Date(frame.scheduled_for) : undefined;
|
||||
return scheduledFor && !Number.isNaN(scheduledFor.getTime())
|
||||
? `A scheduled wakeup fired (set for ${scheduledFor.toISOString()}).`
|
||||
: "A scheduled wakeup fired.";
|
||||
}
|
||||
case "schedule_wakeup":
|
||||
return "A scheduled task fired.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,8 @@ type AsyncTask = {
|
||||
taskType: string;
|
||||
description: string;
|
||||
showInTranscript: boolean;
|
||||
/** A Monitor tool call or an SDK `local_monitor` task: published as a monitor the client cannot stop. */
|
||||
monitor: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
/**
|
||||
@@ -142,7 +144,8 @@ export class AsyncTaskRuntime {
|
||||
* the tasks that a tool call in another session started, for example in
|
||||
* a native subagent session. `toolNameOf`: the name of the tool of a tool
|
||||
* call, or `undefined` when the tool call is unknown. A task that a
|
||||
* `Monitor` tool call started is ignored. */
|
||||
* `Monitor` tool call started is published as a monitor: the SDK reports
|
||||
* it as `local_bash`, so the adapter names it a monitor. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
@@ -155,7 +158,7 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
if (isSubagentTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
@@ -186,7 +189,7 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
if (isSubagentTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
@@ -355,7 +358,7 @@ export class AsyncTaskRuntime {
|
||||
const task = this.task(taskId);
|
||||
if (task.ignored || isTerminal(task.state)) continue;
|
||||
const levelTaskType = field(item, "taskType", "task_type");
|
||||
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
|
||||
if (levelTaskType === "local_agent") {
|
||||
this.ignore(task);
|
||||
continue;
|
||||
}
|
||||
@@ -408,7 +411,13 @@ export class AsyncTaskRuntime {
|
||||
|
||||
canStop(taskId: string): boolean {
|
||||
const task = this.tasks.get(taskId);
|
||||
return task?.announced === true && !task.ignored && !task.stopping && !isTerminal(task.state);
|
||||
return (
|
||||
task?.announced === true &&
|
||||
!task.monitor &&
|
||||
!task.ignored &&
|
||||
!task.stopping &&
|
||||
!isTerminal(task.state)
|
||||
);
|
||||
}
|
||||
|
||||
claimStop(taskId: string): boolean {
|
||||
@@ -458,18 +467,25 @@ export class AsyncTaskRuntime {
|
||||
* example to answer a task notification. ACP v1 has no session state, and
|
||||
* the client already got the stop reason of its last prompt, so this task is
|
||||
* the only sign that the agent works. The tool calls of the turn are in the
|
||||
* transcript already, so the task is not shown there. Does nothing while
|
||||
* such a task is open.
|
||||
* transcript already, so the task is not shown there.
|
||||
*
|
||||
* [cause] names what started the turn when Claude Code says so, for example
|
||||
* "A scheduled wakeup fired." It replaces the description of a task that is
|
||||
* already open; without it, an open task is left as it is.
|
||||
*/
|
||||
async autonomousTurnStarted(): Promise<void> {
|
||||
if (!this.enabled || this.autonomousTurn) return;
|
||||
async autonomousTurnStarted(cause?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
if (this.autonomousTurn) {
|
||||
if (cause) await this.publishProgress(this.autonomousTurn, { description: cause });
|
||||
return;
|
||||
}
|
||||
const task = this.task(`autonomous-turn-${randomUUID()}`);
|
||||
task.autonomousTurn = true;
|
||||
task.startedObserved = true;
|
||||
task.name = "Claude is working";
|
||||
task.taskType = "turn";
|
||||
task.description =
|
||||
this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
|
||||
cause ?? this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
|
||||
task.showInTranscript = false;
|
||||
this.lastNotificationSummary = undefined;
|
||||
this.autonomousTurn = task;
|
||||
@@ -568,6 +584,7 @@ export class AsyncTaskRuntime {
|
||||
taskType: "task",
|
||||
description: "Background task",
|
||||
showInTranscript: true,
|
||||
monitor: false,
|
||||
announced: false,
|
||||
held: false,
|
||||
deferred: [],
|
||||
@@ -588,6 +605,7 @@ export class AsyncTaskRuntime {
|
||||
task.startedObserved = true;
|
||||
const rawTaskType = field(message, "taskType", "task_type");
|
||||
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
|
||||
if (rawTaskType === "local_monitor") task.monitor = true;
|
||||
const description = nonBlankString(message.description);
|
||||
if (description) task.description = description;
|
||||
else if (task.description === "Background task")
|
||||
@@ -645,6 +663,7 @@ export class AsyncTaskRuntime {
|
||||
private mergeLevel(task: AsyncTask, level: BackgroundTaskLevel): void {
|
||||
const rawTaskType = field(level, "taskType", "task_type");
|
||||
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
|
||||
if (rawTaskType === "local_monitor") task.monitor = true;
|
||||
const description = nonBlankString(level.description);
|
||||
if (description) {
|
||||
task.description = description;
|
||||
@@ -669,25 +688,29 @@ export class AsyncTaskRuntime {
|
||||
* ends first, or when the prompt result ends the model turn
|
||||
* ({@link releaseHeld}). No guess by the command text binds a task.
|
||||
*
|
||||
* A task that a `Monitor` tool call started is ignored: Monitor streams its
|
||||
* output to the model only, so the task has nothing for the client. The
|
||||
* tool_use streams before the tool runs, so the tool name is known when the
|
||||
* spawn has the tool call id.
|
||||
* A task that a `Monitor` tool call started is published as a monitor. The
|
||||
* SDK reports it as `local_bash`, so the adapter names it a monitor instead of
|
||||
* a shell. It has no transcript card because Monitor streams its output to
|
||||
* the model only, and no Stop because stopping it only ends the agent's wait
|
||||
* (the agent's own TaskStop still works). The tool_use streams before the
|
||||
* tool runs, so the tool name is known when the spawn has the tool call id.
|
||||
*/
|
||||
private async announce(
|
||||
task: AsyncTask,
|
||||
options: { withoutToolCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (task.announced || task.ignored) return;
|
||||
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
|
||||
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
|
||||
task.held = true;
|
||||
return;
|
||||
}
|
||||
if (task.toolCallId && this.options.toolNameOf?.(task.toolCallId) === "Monitor") {
|
||||
task.monitor = true;
|
||||
}
|
||||
if (task.monitor) {
|
||||
task.taskType = "monitor";
|
||||
task.showInTranscript = false;
|
||||
}
|
||||
if (task.toolCallId) task.route ??= this.options.routeOf?.(task.toolCallId);
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
@@ -696,7 +719,7 @@ export class AsyncTaskRuntime {
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
canStop: !task.monitor,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
});
|
||||
@@ -1044,13 +1067,6 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
|
||||
return taskType !== "local_bash" && taskType !== "local_agent";
|
||||
}
|
||||
|
||||
/** The tools whose async tasks the client never sees. */
|
||||
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
|
||||
|
||||
function isMonitorTask(message: AsyncTaskStarted): boolean {
|
||||
return field(message, "taskType", "task_type") === "local_monitor";
|
||||
}
|
||||
|
||||
function isSubagentTask(message: AsyncTaskStarted): boolean {
|
||||
return (
|
||||
field(message, "subagentType", "subagent_type") !== undefined ||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
RequestError,
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
import { MODEL_CONFIG_ID } from "./session-model.js";
|
||||
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
|
||||
|
||||
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
|
||||
@@ -64,6 +69,32 @@ export type NativeSubagentSession = {
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type Logger = { log(message: string): void };
|
||||
|
||||
/** An entry of a session's `model` select option. */
|
||||
export type ModelEntry = { value: string; name: string };
|
||||
|
||||
/**
|
||||
* The models of the root session. A child's model is an entry of the root
|
||||
* session's `model` option when one matches, so that a client can compare the
|
||||
* two sessions' models.
|
||||
*/
|
||||
export type SubagentModels = {
|
||||
/**
|
||||
* The entry of the root session's `model` option for `model`, a model id
|
||||
* that a response reported. Undefined when no entry matches.
|
||||
*/
|
||||
entry(model: string): ModelEntry | undefined;
|
||||
/** The display name of the model id `model` in the session's model list, if the list has it. */
|
||||
name(model: string): string | undefined;
|
||||
};
|
||||
|
||||
type ChildConfiguration = {
|
||||
options: Map<string, SessionConfigOption>;
|
||||
published?: string;
|
||||
/** The `model` option value and name that the client last got. */
|
||||
publishedModel?: ModelEntry;
|
||||
publishing: Promise<void>;
|
||||
};
|
||||
|
||||
/** What the SDK says about the end of a task, beyond its status. */
|
||||
export type TaskEnd = {
|
||||
/** The SDK's text for a failure. */
|
||||
@@ -137,6 +168,7 @@ export class NativeSubagentRuntime {
|
||||
private readonly parentByToolUse: Map<string, string>;
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly configBySession = new Map<string, ChildConfiguration>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
@@ -153,6 +185,15 @@ export class NativeSubagentRuntime {
|
||||
* closes an unfinished call has no `toolName` to recognize it by.
|
||||
*/
|
||||
private readonly hiddenLaunches = new Map<string, { background: boolean }>();
|
||||
/**
|
||||
* The RFD form: the SendMessage calls that the client is not shown, until
|
||||
* their final update, with their first report and the session that made
|
||||
* them. A message that reaches an announced child replaces its call.
|
||||
*/
|
||||
private readonly hiddenSendMessages = new Map<
|
||||
string,
|
||||
{ initial: AcpSessionNotification; sender: string; delivered: boolean }
|
||||
>();
|
||||
/**
|
||||
* The RFD form: the SubagentHandback calls of announced children that the
|
||||
* client is not shown yet, with their first report, the report from their
|
||||
@@ -184,6 +225,8 @@ export class NativeSubagentRuntime {
|
||||
* `form` is undefined for a client without native subagent sessions. The RFD
|
||||
* form restores an agent from before a load or resume with `lineage`. The AIR
|
||||
* form does too, and `launchedAgent` finds the agent that a launch call made.
|
||||
* `models` maps the model of a child response to the root session's entry
|
||||
* (see {@link reportModel}).
|
||||
*/
|
||||
constructor(
|
||||
form: SubagentForm | undefined,
|
||||
@@ -193,6 +236,7 @@ export class NativeSubagentRuntime {
|
||||
private readonly logger: Logger,
|
||||
private readonly lineage?: SubagentLineage,
|
||||
private readonly launchedAgent?: LaunchedAgent,
|
||||
private readonly models?: SubagentModels,
|
||||
) {
|
||||
this.form = form;
|
||||
this.enabled = form !== undefined;
|
||||
@@ -212,8 +256,145 @@ export class NativeSubagentRuntime {
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
|
||||
if (routed) this.rememberSendMessage(routed);
|
||||
return routed;
|
||||
if (!routed) return routed;
|
||||
this.rememberSendMessage(routed);
|
||||
return this.form === "rfd" ? this.routeRfdSendMessage(routed, forcedSessionId) : routed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the work state that the client holds for the announced child
|
||||
* `sessionId` of this live session, or undefined when no such child runs here.
|
||||
*/
|
||||
liveWorkState(sessionId: string): WorkState["state"] | undefined {
|
||||
if (this.form !== "rfd") return undefined;
|
||||
const child = this.children.get(sessionId);
|
||||
return child?.announced ? child.workState : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the model of a child response, an exact model id, as the entry of
|
||||
* the root session's `model` option that matches it, else as the id (see
|
||||
* {@link responseModelEntry}). The child gets no model before its first
|
||||
* response: the adapter cannot tell the model that Claude Code picks for it.
|
||||
* The client gets a new configuration only when the entry changes.
|
||||
*/
|
||||
async reportModel(
|
||||
parentToolUseId: string | null,
|
||||
model: string | undefined,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
const entry = this.form && parentToolUseId ? responseModelEntry(model, this.models) : undefined;
|
||||
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!);
|
||||
if (
|
||||
child?.announced &&
|
||||
sameModelEntry(this.configBySession.get(child.sessionId)?.publishedModel, entry)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await deliver({
|
||||
sessionId: this.rootSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The configuration that the client last got for the announced child `sessionId`, if any. */
|
||||
configurationOf(sessionId: string): SessionConfigOption[] | undefined {
|
||||
const published = this.configBySession.get(sessionId)?.published;
|
||||
if (!this.childOfSession(sessionId)?.announced || !published) return undefined;
|
||||
return JSON.parse(published) as SessionConfigOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes configuration options of the child `agentId`: an SDK agent id, as a
|
||||
* hook names it, or a child session id. Hook observations can precede the
|
||||
* task event that establishes a child's parent.
|
||||
*/
|
||||
async reportConfiguration(
|
||||
agentId: string,
|
||||
options: SessionConfigOption[],
|
||||
meta?: SessionNotification["update"]["_meta"],
|
||||
): Promise<void> {
|
||||
if (!this.form || agentId === this.rootSessionId || options.length === 0) return;
|
||||
// In AIR's draft, the session id of a resumed or restored agent is
|
||||
// `<agent id>:generation:<n>`, so the agent id names its current generation.
|
||||
const known = this.children.get(agentId) ?? this.childOfSession(agentId);
|
||||
const sessionId = known?.sessionId ?? agentId;
|
||||
// AIR's draft takes no configuration of an ended child (see `finishTask`).
|
||||
if (
|
||||
this.form === "air" &&
|
||||
known &&
|
||||
(known.terminalState !== undefined || known.terminalPromise)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let config = this.configBySession.get(sessionId);
|
||||
if (!config) {
|
||||
const pending = [...this.configBySession.keys()].filter(
|
||||
(id) => !this.childOfSession(id),
|
||||
).length;
|
||||
if (!known && pending >= MAX_PENDING_PARENTS) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: dropping configuration for unannounced subagent ${agentId}; pending buffer limit reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
config = { options: new Map(), publishing: Promise.resolve() };
|
||||
this.configBySession.set(sessionId, config);
|
||||
}
|
||||
for (const option of options) config.options.set(option.id, option);
|
||||
if (known?.announced) await this.publishConfiguration(known, meta);
|
||||
}
|
||||
|
||||
private async publishConfiguration(
|
||||
child: NativeSubagent,
|
||||
meta?: SessionNotification["update"]["_meta"],
|
||||
): Promise<void> {
|
||||
const config = this.configBySession.get(child.sessionId);
|
||||
// AIR's draft ends a child for good, so it gets no update after its end,
|
||||
// also when the end comes while this publication waits for an earlier one.
|
||||
const ended = () =>
|
||||
this.form === "air" && (child.terminalState !== undefined || !!child.terminalPromise);
|
||||
if (!config || ended()) return;
|
||||
// SDK hooks can run concurrently with streamed responses. Serialize snapshots
|
||||
// so a delayed publication cannot replace newer settings with older ones.
|
||||
const publishing = config.publishing.then(async () => {
|
||||
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
return index < 0 ? priority.length : index;
|
||||
};
|
||||
const configOptions = [...config.options.values()].sort(
|
||||
(left, right) => rank(left) - rank(right),
|
||||
);
|
||||
const snapshot = JSON.stringify(configOptions);
|
||||
if (config.published === snapshot) return;
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions,
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
},
|
||||
});
|
||||
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(() => {});
|
||||
await publishing;
|
||||
}
|
||||
|
||||
private async routeUpdate(
|
||||
@@ -315,6 +496,10 @@ export class NativeSubagentRuntime {
|
||||
(claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
|
||||
: undefined);
|
||||
if (announcedChild?.announced && update.sessionUpdate === "config_option_update") {
|
||||
await this.reportConfiguration(announcedChild.sessionId, update.configOptions, update._meta);
|
||||
return null;
|
||||
}
|
||||
if (this.form === "rfd" && announcedChild?.announced) {
|
||||
const handback = await this.routeRfdHandback(notification, announcedChild);
|
||||
if (handback !== undefined) return handback;
|
||||
@@ -557,6 +742,8 @@ export class NativeSubagentRuntime {
|
||||
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
|
||||
} else {
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
// AIR's draft takes no configuration of an ended child.
|
||||
this.configBySession.delete(child.sessionId);
|
||||
}
|
||||
} finally {
|
||||
if (child.parentToolUseId) {
|
||||
@@ -595,6 +782,7 @@ export class NativeSubagentRuntime {
|
||||
this.pendingCount = 0;
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.configBySession.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.forgetRestores();
|
||||
}
|
||||
@@ -888,13 +1076,16 @@ export class NativeSubagentRuntime {
|
||||
});
|
||||
}
|
||||
if (prompt && toolUseId) {
|
||||
const sendMessage = this.hiddenSendMessages.get(toolUseId);
|
||||
await publishPrompt(
|
||||
child,
|
||||
prompt,
|
||||
toolUseId,
|
||||
this.senderByToolUse.get(toolUseId),
|
||||
sendMessage?.sender ?? this.senderByToolUse.get(toolUseId),
|
||||
this.publish,
|
||||
{ replacesCall: sendMessage !== undefined },
|
||||
);
|
||||
if (sendMessage) sendMessage.delivered = true;
|
||||
}
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
@@ -908,6 +1099,7 @@ export class NativeSubagentRuntime {
|
||||
*/
|
||||
private withdraw(taskId: string, child: NativeSubagent): void {
|
||||
this.children.delete(taskId);
|
||||
this.configBySession.delete(child.sessionId);
|
||||
if (child.parentToolUseId) {
|
||||
this.takePending(child.parentToolUseId);
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
@@ -929,9 +1121,14 @@ export class NativeSubagentRuntime {
|
||||
await this.reportState(child, rfdEndState("failed", { error }));
|
||||
}
|
||||
|
||||
/** Announces `child` in the client's form. A runtime without one never does. */
|
||||
/**
|
||||
* Announces `child` in the client's form, then its configuration. A runtime
|
||||
* without a form never does.
|
||||
*/
|
||||
private async announce(child: NativeSubagent): Promise<void> {
|
||||
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
|
||||
if (!this.form) return;
|
||||
await announceNativeSubagent(child, this.publish, this.form);
|
||||
await this.publishConfiguration(child);
|
||||
}
|
||||
|
||||
/** The RFD form: publishes the work state of `child` on its parent. */
|
||||
@@ -1100,6 +1297,44 @@ export class NativeSubagentRuntime {
|
||||
return unexposedControlFallback(handback.initial, notification, child.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: an update of a SendMessage call, which its message replaces
|
||||
* once it reaches an announced child: the sender's outgoing view and the
|
||||
* child's incoming one, see {@link linkedSessionMessages}. The SDK reports
|
||||
* that delivery before the call's final update. A call whose message reached
|
||||
* no announced child, such as one to the parent, is shown after all, with its
|
||||
* final status. Any other update is returned as is.
|
||||
*/
|
||||
private routeRfdSendMessage(
|
||||
notification: AcpSessionNotification,
|
||||
forcedSessionId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return notification;
|
||||
}
|
||||
const toolCallId = update.toolCallId;
|
||||
// A permission request already showed the call to the client, so it keeps
|
||||
// getting its updates, and its message goes only to the child.
|
||||
if (forcedSessionId) {
|
||||
this.hiddenSendMessages.delete(toolCallId);
|
||||
return notification;
|
||||
}
|
||||
let sendMessage = this.hiddenSendMessages.get(toolCallId);
|
||||
if (!sendMessage) {
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
if (update.sessionUpdate !== "tool_call" || claudeMeta?.toolName !== "SendMessage") {
|
||||
return notification;
|
||||
}
|
||||
sendMessage = { initial: notification, sender: notification.sessionId, delivered: false };
|
||||
this.hiddenSendMessages.set(toolCallId, sendMessage);
|
||||
}
|
||||
if (!isFinalToolCallStatus(update.status)) return null;
|
||||
this.hiddenSendMessages.delete(toolCallId);
|
||||
if (sendMessage.delivered) return null;
|
||||
return unexposedControlFallback(sendMessage.initial, notification, sendMessage.sender);
|
||||
}
|
||||
|
||||
/** Whether `update` is of an Agent/Task call, by its tool name or, in the
|
||||
* RFD form, by the id of a call the client is not shown. */
|
||||
private isControlUpdate(
|
||||
@@ -1157,10 +1392,12 @@ export class NativeSubagentRuntime {
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.configBySession.clear();
|
||||
this.childByParentToolUse.clear();
|
||||
this.taskFinishPromises.clear();
|
||||
this.senderByToolUse.clear();
|
||||
this.hiddenLaunches.clear();
|
||||
this.hiddenSendMessages.clear();
|
||||
this.handbacks.clear();
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
@@ -1188,6 +1425,20 @@ 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
|
||||
// (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;
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.pendingCount >= MAX_PENDING_UPDATES ||
|
||||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
|
||||
@@ -1301,6 +1552,40 @@ export class NativeSubagentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `model` option of a child session. The client cannot change a child's
|
||||
* configuration, so the select offers only the current model.
|
||||
*/
|
||||
export function childModelOption(entry: ModelEntry): SessionConfigOption {
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: entry.value,
|
||||
options: [{ value: entry.value, name: entry.name }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two model entries have the same value and name. */
|
||||
export function sameModelEntry(left: ModelEntry | undefined, right: ModelEntry): boolean {
|
||||
return left?.value === right.value && left.name === right.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model entry for `model`, the model id of a child's response: the entry
|
||||
* of the root session's `model` option that matches it, else the id, with its
|
||||
* display name when the session's model list has one. Undefined for no model,
|
||||
* and for `<synthetic>`, which marks a message that no model wrote.
|
||||
*/
|
||||
export function responseModelEntry(
|
||||
model: unknown,
|
||||
models: SubagentModels | undefined,
|
||||
): ModelEntry | undefined {
|
||||
if (typeof model !== "string" || !model || model === "<synthetic>") return undefined;
|
||||
return models?.entry(model) ?? { value: model, name: models?.name(model) ?? model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the child on its parent, once, before any update of the child.
|
||||
* The RFD form announces it as running and then sends the prompt as the
|
||||
@@ -1339,7 +1624,7 @@ export async function announceNativeSubagent(
|
||||
child.parentToolUseId,
|
||||
child.parentSessionId,
|
||||
publish,
|
||||
{ replacesLaunch: true },
|
||||
{ replacesCall: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -1401,9 +1686,9 @@ export async function finishNativeSubagent(
|
||||
* transcript is the same delegation and is not sent again. `sender` is the
|
||||
* session that made that call, when known.
|
||||
*
|
||||
* A launch's prompt (`replacesLaunch`) also goes to the parent, see
|
||||
* {@link linkedSessionMessages}. A SendMessage call stays a tool call of the
|
||||
* sender, so its message has only the incoming view.
|
||||
* When the message replaces its call (`replacesCall`), a launch's or a
|
||||
* SendMessage call the client is not shown, it also goes to the sender, see
|
||||
* {@link linkedSessionMessages}. Otherwise it has only the incoming view.
|
||||
*/
|
||||
async function publishPrompt(
|
||||
child: NativeSubagent,
|
||||
@@ -1411,13 +1696,13 @@ async function publishPrompt(
|
||||
toolUseId: string | undefined,
|
||||
sender: string | undefined,
|
||||
publish: Publish,
|
||||
{ replacesLaunch = false }: { replacesLaunch?: boolean } = {},
|
||||
{ replacesCall = false }: { replacesCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
child.promptIds ??= new Set();
|
||||
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
|
||||
if (child.promptIds.has(messageId)) return;
|
||||
child.promptIds.add(messageId);
|
||||
if (replacesLaunch && sender !== undefined && toolUseId !== undefined) {
|
||||
if (replacesCall && sender !== undefined && toolUseId !== undefined) {
|
||||
for (const view of linkedSessionMessages(toolUseId, sender, child.sessionId, prompt)) {
|
||||
await publish(view);
|
||||
}
|
||||
@@ -1439,8 +1724,9 @@ async function publishPrompt(
|
||||
* The RFD form: a message that a tool call sent, which the client is not
|
||||
* shown, as two session messages: the sender's outgoing view, then the
|
||||
* recipient's incoming one. That call is a launch's Agent/Task call, whose
|
||||
* prompt goes from the parent to the child, or a child's SubagentHandback
|
||||
* call, whose report goes from the child to the parent. Both views use the
|
||||
* prompt goes from the parent to the child, a SendMessage call, whose message
|
||||
* goes from its sender to a child, or a child's SubagentHandback call, whose
|
||||
* report goes from the child to the parent. Both views use the
|
||||
* call's tool use id, and each names the other with the draft RFD's
|
||||
* `recipientMessageId` and `senderMessageId`.
|
||||
*/
|
||||
@@ -1710,7 +1996,8 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
|
||||
* A tool call that the client was not shown, as one `tool_call` with its
|
||||
* final status, shown after all: an Agent/Task call whose child was never
|
||||
* exposed (AIR's draft shows only a failed one, the RFD form also a completed
|
||||
* one), or a failed SubagentHandback call.
|
||||
* one), a SendMessage call whose message reached no announced child, or a
|
||||
* failed SubagentHandback call.
|
||||
*/
|
||||
function unexposedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
|
||||
@@ -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 {
|
||||
@@ -79,6 +80,13 @@ export function mergeEffortSettings(base: Settings, override: Settings | undefin
|
||||
};
|
||||
}
|
||||
|
||||
export function effortLevelName(level: string): string {
|
||||
return level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function buildEffortConfigOption(
|
||||
modelInfos: ModelInfo[],
|
||||
currentModelId: string,
|
||||
@@ -98,10 +106,7 @@ export function buildEffortConfigOption(
|
||||
...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]),
|
||||
...supportedLevels.map((level) => ({
|
||||
value: level,
|
||||
name: level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
name: effortLevelName(level),
|
||||
})),
|
||||
];
|
||||
const includes = (level: 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,14 +595,107 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
/** The display name of the model id `model` in `modelInfos`, if it is there. */
|
||||
export function modelDisplayName(modelInfos: ModelInfo[], model: string): string | undefined {
|
||||
let byModel = displayNameCache.get(modelInfos);
|
||||
if (!byModel) displayNameCache.set(modelInfos, (byModel = new Map()));
|
||||
if (!byModel.has(model)) {
|
||||
const info = modelInfos.find((info) => info.value === model || info.resolvedModel === model);
|
||||
byModel.set(model, info?.displayName);
|
||||
}
|
||||
return byModel.get(model);
|
||||
}
|
||||
|
||||
/** {@link modelDisplayName} per model list, which a session replaces and never edits. */
|
||||
const displayNameCache = new WeakMap<ModelInfo[], Map<string, string | undefined>>();
|
||||
|
||||
/** The flat entries of a session's `model` select option. */
|
||||
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
|
||||
if (option.type !== "select") return [];
|
||||
return option.options.flatMap((entry) =>
|
||||
"value" in entry ? [{ value: entry.value, name: entry.name }] : entry.options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry of the `model` option among `configOptions` for `model`, a model
|
||||
* id that a response reported, so that a client can compare it with the
|
||||
* session's own model. Best first:
|
||||
*
|
||||
* 1. the current entry, when it runs that model up to a context hint: a
|
||||
* session on `opus[1m]` or `default` that answers as `claude-opus-4-6`;
|
||||
* 2. the entry whose value or resolved model is that id;
|
||||
* 3. an entry that runs that model up to a context hint.
|
||||
*
|
||||
* Among several entries for one model, a concrete entry comes before
|
||||
* `default`, as in {@link resolveModelPreference}. Undefined when no entry
|
||||
* matches. A model's resolved id comes from `modelInfos`
|
||||
* (`ModelInfo.resolvedModel`), the wire id that a response reports, as for
|
||||
* {@link matchResumedModel}. It is compared as it is: a dated id such as
|
||||
* `claude-haiku-4-5-20251001` matches only the same dated id.
|
||||
*/
|
||||
export function modelEntryFor(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
// Each response of a subagent reports its model, so the result is kept per
|
||||
// list. A session replaces its lists on a change and never edits them.
|
||||
let byInfos = modelEntryCache.get(configOptions);
|
||||
if (!byInfos) modelEntryCache.set(configOptions, (byInfos = new WeakMap()));
|
||||
let byModel = byInfos.get(modelInfos);
|
||||
if (!byModel) byInfos.set(modelInfos, (byModel = new Map()));
|
||||
if (byModel.has(model)) return byModel.get(model);
|
||||
const entry = findModelEntry(configOptions, modelInfos, model);
|
||||
byModel.set(model, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
const modelEntryCache = new WeakMap<
|
||||
readonly SessionConfigOption[],
|
||||
WeakMap<ModelInfo[], Map<string, { value: string; name: string } | undefined>>
|
||||
>();
|
||||
|
||||
function findModelEntry(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
const option = configOptions.find((candidate) => candidate.id === MODEL_CONFIG_ID);
|
||||
if (option?.type !== "select") return undefined;
|
||||
// Concrete entries first; `sort` keeps the list order otherwise.
|
||||
const entries = modelOptionEntries(option).sort(
|
||||
(left, right) => Number(left.value === "default") - Number(right.value === "default"),
|
||||
);
|
||||
const observed = canonicalizeModelId(model);
|
||||
const resolved = (value: string) =>
|
||||
canonicalizeModelId(modelInfos.find((info) => info.value === value)?.resolvedModel ?? value);
|
||||
const sameModel = (value: string) =>
|
||||
stripContextHints(resolved(value)) === stripContextHints(observed);
|
||||
const current = entries.find((entry) => entry.value === option.currentValue);
|
||||
if (current && sameModel(current.value)) return current;
|
||||
return (
|
||||
entries.find((entry) => entry.value === model || resolved(entry.value) === observed) ??
|
||||
entries.find((entry) => sameModel(entry.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}`);
|
||||
}
|
||||
@@ -292,9 +292,13 @@ function isForkDirectiveMessage(message: { type?: unknown; message?: unknown }):
|
||||
* The transcript of a `subagent_type: "fork"` child without the context it
|
||||
* inherited. A fork transcript starts with a copy of the parent's launching
|
||||
* assistant message (its text, thinking and every tool_use, including the
|
||||
* Agent call itself), then a user message with a "Fork started — processing
|
||||
* in background" tool_result for each of those tool_uses and the text
|
||||
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. A fork
|
||||
* Agent call itself), then a user message with a placeholder tool_result for
|
||||
* each of those tool_uses and the text
|
||||
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. Since
|
||||
* Claude Code 2.1.307 the fork's own Agent call gets "Fork started — that fork
|
||||
* is you." and the other tool_uses get "This is the parent's call, not
|
||||
* yours. …"; before that, every one got "Fork started — processing in
|
||||
* background". Only the directive text is matched here. A fork
|
||||
* launched from inside a subagent also gets a copy of that subagent's history
|
||||
* before them. None of it is the child's own work: replayed as is, the
|
||||
* parent's text and tool calls would show again in the child.
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* `subagentHistory`). The replay announces each child right after that call,
|
||||
* under the session id it had live, the SDK agent id, and sends the child's
|
||||
* history to the child session. Every child's end goes out before the replay
|
||||
* ends, since no subagent from before the reload still runs.
|
||||
* ends. These states are history to the client, so each child's current
|
||||
* state goes out again after the load response (`currentStates`).
|
||||
*
|
||||
* As live, the parent is not shown the Agent or Task call of a child that the
|
||||
* replay announces: the launch's prompt is the parent's outgoing message, and
|
||||
@@ -15,21 +16,38 @@
|
||||
* Unlike live, the replay does not tell runs apart, so a child that handed
|
||||
* back any report gets no reply from its launch's result.
|
||||
*
|
||||
* A SendMessage call whose message the replay delivered to a child before it
|
||||
* reached the call is that message too: the sender's outgoing view, at the
|
||||
* call, and the child's incoming one, in the child's history. A child's
|
||||
* history follows its launch, so a call to a child from its parent is always
|
||||
* delivered first. Any other SendMessage call stays a tool call.
|
||||
*
|
||||
* Right after its announcement, each child gets its `model` option: the model
|
||||
* of its first response, in its transcript. A later response with another
|
||||
* model sends it again. A child without responses gets none. After the load
|
||||
* response, a model goes again only when the session's model list, which the
|
||||
* replay can lack, maps it to another entry.
|
||||
*
|
||||
* A launch without metadata has no known id, so it is not announced: the RFD
|
||||
* forbids inventing one. Its Agent call stays an ordinary tool call of the
|
||||
* parent, and a notice says that subagent history is missing.
|
||||
*/
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
HANDBACK_TOOL,
|
||||
handbackMessage,
|
||||
isNativeSubagentControlTool,
|
||||
launchReply,
|
||||
linkedSessionMessages,
|
||||
type ModelEntry,
|
||||
nativeSubagentState,
|
||||
responseModelEntry,
|
||||
rfdEndState,
|
||||
rfdLabels,
|
||||
type SubagentModels,
|
||||
type WorkState,
|
||||
} from "./native-subagents.js";
|
||||
import { noticeUpdate } from "./session-notices.js";
|
||||
@@ -56,6 +74,10 @@ export type ReplayChild = {
|
||||
promptSent: boolean;
|
||||
/** Whether it handed a report to its parent with a SubagentHandback call. */
|
||||
handedBack?: boolean;
|
||||
/** The `model` option entry that the replay sent. */
|
||||
model?: ModelEntry;
|
||||
/** The model id of the latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
@@ -63,7 +85,12 @@ type SendMessageCall = {
|
||||
to: string;
|
||||
message: string;
|
||||
senderSessionId: string;
|
||||
delivered: boolean;
|
||||
/** The child the replay delivered the message to. */
|
||||
recipientSessionId?: string;
|
||||
/** The replay reached the call before the delivery, so it stays a tool call. */
|
||||
shownAsToolCall: boolean;
|
||||
/** The sender's view of the message went out, at the call. */
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
@@ -84,6 +111,13 @@ export class RfdSubagentReplay {
|
||||
constructor(
|
||||
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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -107,7 +141,8 @@ export class RfdSubagentReplay {
|
||||
to: input.to,
|
||||
message: input.message,
|
||||
senderSessionId: ownerSessionId,
|
||||
delivered: false,
|
||||
shownAsToolCall: false,
|
||||
outgoingSent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -129,8 +164,12 @@ export class RfdSubagentReplay {
|
||||
return this.children.get(toolUseId);
|
||||
}
|
||||
|
||||
/** Announces `child` on its parent, as live: running, with the parent's labels. */
|
||||
async announce(child: ReplayChild): Promise<void> {
|
||||
/**
|
||||
* Announces `child` on its parent, as live: running, with the parent's
|
||||
* labels. Then it sends `firstModel`, the model of the child's first
|
||||
* response, when the transcript has one.
|
||||
*/
|
||||
async announce(child: ReplayChild, firstModel?: string): Promise<void> {
|
||||
if (child.announced) return;
|
||||
const labels = rfdLabels(
|
||||
child.record.name ?? child.input.name,
|
||||
@@ -148,6 +187,30 @@ export class RfdSubagentReplay {
|
||||
},
|
||||
});
|
||||
child.announced = true;
|
||||
await this.responseModel(child, firstModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the model of a replayed response of the child `owner`. A child
|
||||
* whose model changes gets its `model` option again.
|
||||
*/
|
||||
async responseModel(owner: ReplayChild, model: string | undefined): Promise<void> {
|
||||
const entry = responseModelEntry(model, this.mapsDuringReplay ? this.models : undefined);
|
||||
if (!entry || !owner.announced) return;
|
||||
owner.responseModel = model;
|
||||
await this.sendModel(owner, entry);
|
||||
}
|
||||
|
||||
private async sendModel(child: ReplayChild, model: ModelEntry): Promise<void> {
|
||||
if (child.model?.value === model.value && child.model.name === model.name) return;
|
||||
child.model = model;
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(model)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,10 +256,11 @@ export class RfdSubagentReplay {
|
||||
/**
|
||||
* Takes a replayed update of a tool call, from the transcript of `owner`
|
||||
* when it is a child's. Returns whether the client is not shown it: an
|
||||
* update of the launch of a child that the replay shows, or of a successful
|
||||
* update of the launch of a child that the replay shows, of a SendMessage
|
||||
* call whose message the replay already delivered, or of a successful
|
||||
* SubagentHandback call of `owner`. A foreground launch's completed result
|
||||
* goes out as the child's reply, and a hand-back as the child's message to
|
||||
* its parent.
|
||||
* goes out as the child's reply, a delivered SendMessage as the sender's
|
||||
* outgoing message, and a hand-back as the child's message to its parent.
|
||||
*/
|
||||
async toolCallUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
@@ -205,6 +269,24 @@ export class RfdSubagentReplay {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
return false;
|
||||
}
|
||||
if (!sendMessage.outgoingSent) {
|
||||
sendMessage.outgoingSent = true;
|
||||
const [outgoing] = linkedSessionMessages(
|
||||
sendMessage.toolUseId,
|
||||
sendMessage.senderSessionId,
|
||||
sendMessage.recipientSessionId,
|
||||
sendMessage.message,
|
||||
);
|
||||
if (outgoing) await this.publish(outgoing);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (!handback.sent) {
|
||||
@@ -247,19 +329,19 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the end of every announced child, and a notice when subagent history
|
||||
* is missing. The end is the state of the child's last task notification,
|
||||
* else `cancelled` for a child the user stopped, else the result of its
|
||||
* foreground launch, else `idle` with no stop reason.
|
||||
* Sends the end of every announced child (see `endState`), and a notice when
|
||||
* subagent history is missing.
|
||||
*/
|
||||
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
|
||||
for (const child of [...this.children.values()].reverse()) {
|
||||
if (!child.announced) continue;
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
const state: WorkState = end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: endState(child),
|
||||
},
|
||||
});
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
@@ -278,6 +360,56 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of each announced child, to send after the load
|
||||
* response. The RFD makes that response the freshness boundary: a client
|
||||
* treats the replayed states before it as history, so they show no current
|
||||
* activity and allow no cancel.
|
||||
*
|
||||
* `live` gives the work state of a child that still runs in this process, as
|
||||
* when a live session is loaded again. Such a child reports that state and
|
||||
* can be cancelled, as live. Any other child cannot be running, because the
|
||||
* process that ran it is gone, so its replayed end is its current state.
|
||||
*/
|
||||
currentStates(
|
||||
live: (sessionId: string) => WorkState["state"] | undefined,
|
||||
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.children.values()]
|
||||
.filter((child) => child.announced)
|
||||
.flatMap((child): AcpSessionNotification[] => {
|
||||
const liveState = live(child.sessionId);
|
||||
const state: WorkState =
|
||||
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
|
||||
// The session's model list may be known only now, after the load response.
|
||||
const entry = responseModelEntry(child.responseModel, this.models);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
const current =
|
||||
liveConfiguration?.(child.sessionId) ?? (entry ? [childModelOption(entry)] : undefined);
|
||||
// A configuration that the replay already sent does not go again.
|
||||
const configOptions = current && JSON.stringify(current) !== replayed ? current : undefined;
|
||||
return [
|
||||
{
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
|
||||
state,
|
||||
},
|
||||
},
|
||||
...(configOptions
|
||||
? [
|
||||
{
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "config_option_update" as const, configOptions },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
@@ -322,6 +454,9 @@ export class RfdSubagentReplay {
|
||||
* first undelivered SendMessage to the child whose message the delivered
|
||||
* text contains. With that call, the message has the call's id and text and
|
||||
* its sender, as live; without it, its transcript id and the delivered text.
|
||||
* Unless the replay already showed the call as a tool call, the message
|
||||
* replaces it, so this view names the sender's view, which goes out at the
|
||||
* call (see {@link toolCallUpdate}).
|
||||
*/
|
||||
private async sendDelivery(
|
||||
child: ReplayChild,
|
||||
@@ -331,13 +466,15 @@ export class RfdSubagentReplay {
|
||||
const names = [child.sessionId, child.record.name, child.input.name];
|
||||
const call = this.sendMessages.find(
|
||||
(candidate) =>
|
||||
!candidate.delivered &&
|
||||
candidate.recipientSessionId === undefined &&
|
||||
names.includes(candidate.to) &&
|
||||
text.includes(candidate.message.trim()),
|
||||
);
|
||||
if (call) {
|
||||
call.delivered = true;
|
||||
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId);
|
||||
call.recipientSessionId = child.sessionId;
|
||||
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId, {
|
||||
linked: !call.shownAsToolCall,
|
||||
});
|
||||
} else {
|
||||
await this.sendMessage(child, message.uuid, text, undefined);
|
||||
}
|
||||
@@ -348,6 +485,7 @@ export class RfdSubagentReplay {
|
||||
messageId: string,
|
||||
text: string,
|
||||
sender: string | undefined,
|
||||
{ linked = false }: { linked?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
@@ -356,12 +494,23 @@ export class RfdSubagentReplay {
|
||||
messageId,
|
||||
...(sender ? { senderSessionId: sender } : {}),
|
||||
recipientSessionId: child.sessionId,
|
||||
...(linked ? { senderMessageId: messageId } : {}),
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The model of the first response in `messages`, a subagent's transcript. */
|
||||
export function firstResponseModel(messages: readonly SessionMessage[]): string | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant") continue;
|
||||
const model = (message.message as { model?: unknown } | undefined)?.model;
|
||||
if (typeof model === "string" && model && model !== "<synthetic>") return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a foreground launch from its tool result: an error result that
|
||||
* names a stop is a cancellation, any other error a failure.
|
||||
@@ -376,6 +525,16 @@ export function replaySubagentTerminalState(
|
||||
: "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* How `child`'s work ended: the state of its last task notification, else
|
||||
* `cancelled` for a child the user stopped, else the result of its foreground
|
||||
* launch, else `idle` with no stop reason.
|
||||
*/
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
|
||||
function replayContentText(value: unknown, seen = new Set<unknown>()): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || seen.has(value)) return "";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_net"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"app.ts","terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Background review","prompt":"Review the diff","sessionUpdate":"subagent_spawned","subagentSessionId":"a2","task":"Review the diff"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"a2"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_late"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"capabilities":{},"name":"Read the spec","prompt":"Read spec.md","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_inner","task":"Read spec.md"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"status":"pending","title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"},"type":"tool_call"},"title":"ls -la"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"YS50cwpiLnRz"},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"value = 2","old_string":"value = 1"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use. The tool use was rejected.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"modify","path":"<cwd>/src/app.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/src/app.ts <cwd>/src/app.ts\n--- <cwd>/src/app.ts\n+++ <cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts","new_string":"const value = 2;","old_string":"const value = 1;"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"},"type":"tool_call"},"title":"Edit src/app.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"User has approved your plan. You can now start coding.\n\n## Approved Plan:\n# Plan\n1. Do it","sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"# Plan\n1. Do it","type":"text"},"type":"content"}],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"},"type":"tool_call"},"title":"Ready to code?"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\n```\nThe user doesn't want to proceed with this tool use.\n```\n```","type":"text"},"type":"content"}],"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"},"type":"tool_call"},"title":"mcp__docs__search"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"rawOutput":[{"text":"Found 2 documents.","type":"text"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call_update","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"description":"Reason: Sandbox blocks network","options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"```json\n{\n \"host\": \"example.com\"\n}\n```","type":"text"},"type":"content"}],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"status":"pending","title":"example.com","toolCallId":"toolu_net"},"type":"tool_call"},"title":"example.com"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"description":"Reason: Sandbox blocks network","options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"```json\n{\n \"host\": \"example.com\"\n}\n```","type":"text"},"type":"content"}],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"},"type":"tool_call"},"title":"example.com"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_net"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[],"kind":"other","name":"NotebookEdit","rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"status":"pending","title":"NotebookEdit","toolCallId":"toolu_nb"},"type":"tool_call"},"title":"NotebookEdit"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[],"kind":"other","name":"NotebookEdit","rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"title":"NotebookEdit","toolCallId":"toolu_nb"},"type":"tool_call"},"title":"NotebookEdit"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Updated cell cell-1 with print('hi')","type":"text"},"type":"content"}],"rawOutput":"Updated cell cell-1 with print('hi')","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientMessageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderMessageId":"toolu_agent","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientMessageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderMessageId":"toolu_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"The logs are clean.","type":"text"}],"messageId":"toolu_late:reply","recipientSessionId":"<session>","senderSessionId":"agent_late","sessionUpdate":"session_message"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientMessageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderMessageId":"toolu_native","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
@@ -23,7 +24,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"requires_action"}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"status":"pending","title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"running"}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientMessageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderMessageId":"toolu_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Read the spec","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientMessageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderMessageId":"toolu_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientMessageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderMessageId":"toolu_task","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
@@ -22,7 +23,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"requires_action"}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"status":"pending","title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"rg parser","description":"Search"},"title":"rg parser","toolCallId":"toolu_sub_bash"},"type":"tool_call"},"title":"rg parser"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
@@ -36,7 +37,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"requires_action"}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/x.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"status":"pending","title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"edit","locations":[{"path":"<cwd>/x.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/x.ts","new_string":"b","old_string":"a"},"title":"Edit x.ts","toolCallId":"toolu_sub_edit"},"type":"tool_call"},"title":"Edit x.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"}}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientMessageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderMessageId":"toolu_transcript","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"kind":"fetch","name":"WebFetch","rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"status":"pending","title":"Fetch https://example.com","toolCallId":"toolu_fetch"},"type":"tool_call"},"title":"Fetch https://example.com"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"content":{"text":"Summarize the page","type":"text"},"type":"content"}],"kind":"fetch","name":"WebFetch","rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"title":"Fetch https://example.com","toolCallId":"toolu_fetch"},"type":"tool_call"},"title":"Fetch https://example.com"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"rawOutput":"The page is an example.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"kind":"fetch","name":"WebSearch","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Web search","toolCallId":"toolu_search"}}}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"status":"pending","title":"Write new.ts","toolCallId":"toolu_write"},"type":"tool_call"},"title":"Write new.ts"}}
|
||||
{"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","subject":{"toolCall":{"content":[{"changes":[{"fileType":"text","operation":"add","path":"<cwd>/new.ts"}],"patch":{"format":"git_patch","text":"diff --git <cwd>/new.ts <cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ <cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n"},"type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/new.ts"}],"name":"Write","rawInput":{"content":"export const x = 1;\n","file_path":"<cwd>/new.ts"},"title":"Write new.ts","toolCallId":"toolu_write"},"type":"tool_call"},"title":"Write new.ts"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"rawOutput":"File created successfully at: <cwd>/new.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
* - The `initialize` response also advertises
|
||||
* `agentCapabilities.sessionCapabilities.systemPrompt`, which origin/main
|
||||
* did not: the agent now applies the `systemPrompt` of session requests.
|
||||
* - A SandboxNetworkAccess tool call is settled by a `tool_call_update` with
|
||||
* only its final `status` once its permission request is answered. The
|
||||
* callback runs nothing, so origin/main never settled the call (see
|
||||
* {@link withoutSandboxNetworkSettlements}).
|
||||
* - A `terminal_exit` has a `null` exit code where origin/main sent a code
|
||||
* that the tool result does not say (see `CommandOutput.exitCode`): 1 for a
|
||||
* failure that names no code or an interrupted command, 0 for a
|
||||
@@ -126,6 +130,23 @@ function flatten(update: Json): Map<string, string> {
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** `traffic` without the updates that settle a SandboxNetworkAccess tool call. */
|
||||
function withoutSandboxNetworkSettlements(traffic: Recorded[]): Recorded[] {
|
||||
const sandboxCalls = new Set<unknown>();
|
||||
return traffic.filter((record) => {
|
||||
const update = updateOf(record);
|
||||
if (update?.sessionUpdate === "tool_call" && update.name === "SandboxNetworkAccess") {
|
||||
sandboxCalls.add(update.toolCallId);
|
||||
}
|
||||
const settles =
|
||||
update?.sessionUpdate === "tool_call_update" &&
|
||||
sandboxCalls.has(update.toolCallId) &&
|
||||
Object.keys(update).sort().join() === "sessionUpdate,status,toolCallId" &&
|
||||
(update.status === "completed" || update.status === "failed");
|
||||
return !settles;
|
||||
});
|
||||
}
|
||||
|
||||
/** A copy of `value` without `key`. */
|
||||
function without(value: Json, key: string): Json {
|
||||
const copy = { ...value };
|
||||
@@ -232,7 +253,9 @@ export function compareWithBaseline(baseline: Recorded[], traffic: Recorded[]):
|
||||
const update = updateOf(record);
|
||||
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);
|
||||
});
|
||||
const current = traffic.map((record) => withoutSessionCapability(record, "systemPrompt"));
|
||||
const current = withoutSandboxNetworkSettlements(
|
||||
traffic.map((record) => withoutSessionCapability(record, "systemPrompt")),
|
||||
);
|
||||
const violations: string[] = [];
|
||||
const state = new Map<string, Map<string, string>>();
|
||||
const summaryChunks = new Map<string, string>();
|
||||
|
||||
@@ -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. */
|
||||
@@ -1116,15 +1116,37 @@ describe("ACP v2 prompts", () => {
|
||||
cwd,
|
||||
replayFrom: { type: "start" },
|
||||
});
|
||||
client.sessionUpdates.push({
|
||||
sessionId: "response",
|
||||
update: { sessionUpdate: "response" },
|
||||
} as never);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
client.sessionUpdates.filter(
|
||||
({ sessionId, update }) =>
|
||||
sessionId === "abc123" && update.sessionUpdate === "state_update",
|
||||
),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
|
||||
const child = client.sessionUpdates.flatMap(({ sessionId, update }) =>
|
||||
sessionId === "abc123" ? [update] : [],
|
||||
sessionId === "abc123" || sessionId === "response" ? [update] : [],
|
||||
);
|
||||
// The child's past `running` does not replay; its last state goes once,
|
||||
// after the replay.
|
||||
// after the replay. That state is history to the client, so the child's
|
||||
// current state follows the response: its process is gone, so it is idle.
|
||||
// The child's model, from its transcript, goes right after its
|
||||
// announcement. It does not go again after the response: it is unchanged.
|
||||
const model = expect.objectContaining({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
expect.objectContaining({ configId: "model", currentValue: "claude-haiku-4-5" }),
|
||||
],
|
||||
});
|
||||
expect(child).toEqual([
|
||||
model,
|
||||
expect.objectContaining({ sessionUpdate: "session_message", content: text("Look around") }),
|
||||
// The client may still hold the child's message, so the replay clears it.
|
||||
{ sessionUpdate: "agent_message", messageId: "msg_child", content: [] },
|
||||
@@ -1134,7 +1156,16 @@ describe("ACP v2 prompts", () => {
|
||||
content: text("Child report.")[0],
|
||||
}),
|
||||
{ sessionUpdate: "state_update", state: "idle", stopReason: "end_turn" },
|
||||
{ sessionUpdate: "response" },
|
||||
{ sessionUpdate: "state_update", state: "idle", stopReason: "end_turn" },
|
||||
]);
|
||||
// The current state on the parent grants nothing: the child cannot run again here.
|
||||
expect(
|
||||
client.sessionUpdates.filter(
|
||||
({ update }) =>
|
||||
update.sessionUpdate === "subagent_update" && (update as any).capabilities != null,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("ends a resume with replay with one end event for a client with loadReplayEnd", async () => {
|
||||
|
||||
@@ -1221,10 +1221,28 @@ describe("AsyncTaskRuntime", () => {
|
||||
return { runtime, published };
|
||||
};
|
||||
|
||||
it("never announces a task that a Monitor tool call started", async () => {
|
||||
const updateKinds = (published: AcpSessionNotification[]) =>
|
||||
published.map(({ update }) => update.sessionUpdate);
|
||||
const MONITOR_SPAWN = {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
taskType: "monitor",
|
||||
showInTranscript: false,
|
||||
canStop: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The SDK starts a `local_bash` task for a Monitor tool call.
|
||||
* 2. The task reports progress, then its notification completes it.
|
||||
* 3. The client asks to stop the task.
|
||||
* Expected:
|
||||
* - The spawn is a monitor with no transcript card and no Stop.
|
||||
* - Progress and the completed state follow.
|
||||
* - `canStop` and `claimStop` are false.
|
||||
*/
|
||||
it("publishes a task that a Monitor tool call started as a monitor", async () => {
|
||||
const { runtime, published } = monitorRuntime();
|
||||
|
||||
// The SDK reports a Monitor task as a background shell.
|
||||
await runtime.taskStarted({
|
||||
task_id: "monitor",
|
||||
task_type: "local_bash",
|
||||
@@ -1233,22 +1251,38 @@ describe("AsyncTaskRuntime", () => {
|
||||
tool_use_id: "monitor-tool",
|
||||
});
|
||||
await runtime.taskProgress({ task_id: "monitor", summary: "First line" });
|
||||
await runtime.taskUpdated("monitor", { status: "paused" });
|
||||
await runtime.backgroundTasksChanged([{ task_id: "monitor", task_type: "local_bash" }]);
|
||||
expect(runtime.canStop("monitor")).toBe(false);
|
||||
expect(runtime.claimStop("monitor")).toBe(false);
|
||||
await runtime.taskStopped("monitor");
|
||||
const stoppable = runtime.canStop("monitor");
|
||||
const claimed = runtime.claimStop("monitor");
|
||||
await runtime.taskNotification({
|
||||
task_id: "monitor",
|
||||
status: "completed",
|
||||
tool_use_id: "monitor-tool",
|
||||
});
|
||||
await runtime.finishAll("stopped");
|
||||
|
||||
expect(published).toEqual([]);
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
...MONITOR_SPAWN,
|
||||
asyncTaskId: "monitor",
|
||||
toolCallId: "monitor-tool",
|
||||
});
|
||||
expect(updateKinds(published)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_progress",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(published[2]?.update).toMatchObject({ state: "completed" });
|
||||
expect(stoppable).toBe(false);
|
||||
expect(claimed).toBe(false);
|
||||
});
|
||||
|
||||
it("never announces a held task whose tool call id names a Monitor", async () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. A task starts without a tool call id and is held.
|
||||
* 2. A later progress carries the Monitor tool call id.
|
||||
* Expected:
|
||||
* - The task is spawned as a monitor once the id arrives, not before.
|
||||
* - It is not stoppable.
|
||||
*/
|
||||
it("spawns a held task as a monitor when its tool call id names a Monitor", async () => {
|
||||
const { runtime, published } = monitorRuntime();
|
||||
|
||||
await runtime.taskStarted({
|
||||
@@ -1257,18 +1291,26 @@ describe("AsyncTaskRuntime", () => {
|
||||
description: "Watch the logs",
|
||||
});
|
||||
await runtime.taskProgress({ task_id: "monitor", summary: "First line" });
|
||||
const spawnedBeforeId = published.length;
|
||||
await runtime.taskProgress({
|
||||
task_id: "monitor",
|
||||
summary: "Second line",
|
||||
tool_use_id: "monitor-tool",
|
||||
});
|
||||
await runtime.releaseHeld();
|
||||
await runtime.taskNotification({ task_id: "monitor", status: "stopped" });
|
||||
|
||||
expect(published).toEqual([]);
|
||||
expect(spawnedBeforeId).toBe(0);
|
||||
expect(published[0]?.update).toMatchObject({ ...MONITOR_SPAWN, toolCallId: "monitor-tool" });
|
||||
expect(runtime.canStop("monitor")).toBe(false);
|
||||
});
|
||||
|
||||
it("never announces a held task that a Monitor ends", async () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. A task starts without a tool call id and is held.
|
||||
* 2. A notification with the Monitor tool call id completes it.
|
||||
* Expected:
|
||||
* - The task is spawned as a monitor, then reaches the completed state.
|
||||
*/
|
||||
it("spawns a held task that a Monitor ends as a monitor", async () => {
|
||||
const { runtime, published } = monitorRuntime();
|
||||
|
||||
await runtime.taskStarted({ task_id: "monitor", task_type: "local_workflow" });
|
||||
@@ -1278,23 +1320,47 @@ describe("AsyncTaskRuntime", () => {
|
||||
tool_use_id: "monitor-tool",
|
||||
});
|
||||
|
||||
expect(published).toEqual([]);
|
||||
expect(updateKinds(published)).toEqual(["async_task_spawned", "async_task_state_update"]);
|
||||
expect(published[0]?.update).toMatchObject(MONITOR_SPAWN);
|
||||
expect(published[1]?.update).toMatchObject({ state: "completed" });
|
||||
});
|
||||
|
||||
it("ignores a local_monitor task without a tool call", async () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The SDK starts a `local_monitor` task for a Bash tool call.
|
||||
* 2. The SDK reports another `local_monitor` task only in the task level.
|
||||
* 3. The turn ends (`releaseHeld`), which spawns the level-only task without a tool call.
|
||||
* Expected:
|
||||
* - Both tasks are spawned as monitors and neither is stoppable.
|
||||
*/
|
||||
it("spawns a local_monitor task as a monitor", async () => {
|
||||
const { runtime, published } = monitorRuntime();
|
||||
|
||||
await runtime.backgroundTasksChanged([{ task_id: "level", task_type: "local_monitor" }]);
|
||||
await runtime.taskStarted({ task_id: "level", task_type: "local_monitor" });
|
||||
await runtime.taskStarted({ task_id: "started", task_type: "local_monitor" });
|
||||
await runtime.taskProgress({ task_id: "started", summary: "line" });
|
||||
await runtime.taskStarted({
|
||||
task_id: "started",
|
||||
task_type: "local_monitor",
|
||||
tool_use_id: "bash-tool",
|
||||
});
|
||||
await runtime.backgroundTasksChanged([
|
||||
{ task_id: "started", task_type: "local_monitor" },
|
||||
{ task_id: "level", task_type: "local_monitor" },
|
||||
]);
|
||||
await runtime.releaseHeld();
|
||||
await runtime.taskNotification({ task_id: "started", status: "completed" });
|
||||
|
||||
expect(published).toEqual([]);
|
||||
const spawns = published.map(({ update }) => update);
|
||||
expect(spawns).toHaveLength(2);
|
||||
expect(spawns[0]).toMatchObject({ ...MONITOR_SPAWN, asyncTaskId: "started" });
|
||||
expect(spawns[1]).toMatchObject({ ...MONITOR_SPAWN, asyncTaskId: "level" });
|
||||
expect(runtime.canStop("started")).toBe(false);
|
||||
expect(runtime.canStop("level")).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The SDK starts a background `local_bash` task for a Bash tool call.
|
||||
* Expected:
|
||||
* - The spawn is a shell that stays in the transcript and can be stopped.
|
||||
*/
|
||||
it("still announces a task of another tool", async () => {
|
||||
const { runtime, published } = monitorRuntime();
|
||||
|
||||
@@ -1307,6 +1373,11 @@ describe("AsyncTaskRuntime", () => {
|
||||
});
|
||||
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["async_task_spawned"]);
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
taskType: "shell",
|
||||
showInTranscript: true,
|
||||
canStop: true,
|
||||
});
|
||||
expect(runtime.canStop("shell")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { ALLOW_BYPASS } from "../permissions/modes.js";
|
||||
import { NativeSubagentRuntime } from "../native-subagents.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -185,6 +187,211 @@ describe("createSession options merging", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
|
||||
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
const parentOptions = structuredClone(session.configOptions);
|
||||
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
sessionId,
|
||||
session,
|
||||
(notification) => client.sessionUpdate(notification),
|
||||
{ log: () => {} },
|
||||
));
|
||||
const invoke = async (
|
||||
event: "PreToolUse" | "PostToolUse" | "PostModelSwitch" | "SubagentStart" | "SubagentStop",
|
||||
fields: Record<string, unknown>,
|
||||
) => {
|
||||
const matcher = capturedOptions!.hooks![event]!.find((matcher) => !matcher.matcher);
|
||||
expect(matcher).toBeDefined();
|
||||
for (const hook of matcher!.hooks) {
|
||||
await expect(
|
||||
hook(
|
||||
{
|
||||
hook_event_name: event,
|
||||
session_id: sessionId,
|
||||
cwd: process.cwd(),
|
||||
transcript_path: "/tmp/transcript",
|
||||
...fields,
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
).resolves.toMatchObject({ continue: true });
|
||||
}
|
||||
};
|
||||
// A main-thread --agent persona has agent_type too, but no agent_id.
|
||||
await invoke("PreToolUse", {
|
||||
agent_type: "reviewer",
|
||||
permission_mode: "plan",
|
||||
effort: { level: "max" },
|
||||
});
|
||||
expect(updates).toEqual([]);
|
||||
await invoke("SubagentStart", {
|
||||
agent_id: "child",
|
||||
agent_type: "Explore",
|
||||
permission_mode: "acceptEdits",
|
||||
});
|
||||
expect(updates).toEqual([]);
|
||||
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, async () => {});
|
||||
const configs = () =>
|
||||
updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
|
||||
);
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.map((option) => [option.id, option.currentValue]),
|
||||
).toEqual([
|
||||
["mode", "acceptEdits"],
|
||||
["agent", "Explore"],
|
||||
]);
|
||||
await invoke("PreToolUse", {
|
||||
agent_id: "child",
|
||||
agent_type: "Explore",
|
||||
effort: { level: "high" },
|
||||
});
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.find((option) => option.id === "effort"),
|
||||
).toMatchObject({
|
||||
category: "thought_level",
|
||||
currentValue: "high",
|
||||
options: [{ value: "high", name: "High" }],
|
||||
});
|
||||
await invoke("PostToolUse", {
|
||||
agent_id: "child",
|
||||
tool_name: "EnterPlanMode",
|
||||
permission_mode: "plan",
|
||||
});
|
||||
await invoke("PostModelSwitch", {
|
||||
agent_id: "child",
|
||||
to_model: "child-model",
|
||||
source: "auto",
|
||||
effort: { level: "low" },
|
||||
});
|
||||
await invoke("SubagentStop", { agent_id: "child", agent_type: "Explore" });
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.map((option) => [option.id, option.currentValue]),
|
||||
).toEqual([
|
||||
["mode", "plan"],
|
||||
["model", "child-model"],
|
||||
["effort", "low"],
|
||||
["agent", "Explore"],
|
||||
]);
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.every((option) => option.type === "select" && option.options.length === 1),
|
||||
).toBe(true);
|
||||
// A model of the root session's list goes as that list's entry, with its name.
|
||||
const rootModel = session.configOptions.find((option) => option.id === "model");
|
||||
const listed =
|
||||
rootModel?.type === "select"
|
||||
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
||||
(entry) => entry.value !== "default",
|
||||
)
|
||||
: undefined;
|
||||
expect(listed).toBeDefined();
|
||||
await invoke("PostModelSwitch", { agent_id: "child", to_model: listed!.value, source: "auto" });
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.find((option) => option.id === "model"),
|
||||
).toMatchObject({
|
||||
currentValue: listed!.value,
|
||||
options: [{ value: listed!.value, name: listed!.name }],
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(session.configOptions).toEqual(parentOptions);
|
||||
expect(
|
||||
updates
|
||||
.filter(({ update }) => update.sessionUpdate === "config_option_update")
|
||||
.every(({ sessionId: target }) => target === "child"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an AIR child's model switch under its current generation", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client);
|
||||
await observedAgent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
|
||||
} as ClientCapabilities,
|
||||
});
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
sessionId,
|
||||
session,
|
||||
(notification) => client.sessionUpdate(notification),
|
||||
{ log: () => {} },
|
||||
));
|
||||
const matcher = capturedOptions!.hooks!.PostModelSwitch!.find((matcher) => !matcher.matcher);
|
||||
const switchModel = async (fields: Record<string, unknown>) => {
|
||||
for (const hook of matcher!.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostModelSwitch",
|
||||
session_id: sessionId,
|
||||
cwd: process.cwd(),
|
||||
transcript_path: "/tmp/transcript",
|
||||
source: "auto",
|
||||
...fields,
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
};
|
||||
const deliver = async () => {};
|
||||
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, deliver);
|
||||
await runtime.finishTask("child", "completed", deliver);
|
||||
await runtime.taskResumed("child", deliver, "Continue");
|
||||
// The hook names the SDK agent id; AIR's draft runs the resumed agent in a new session.
|
||||
await switchModel({ agent_id: "child", to_model: "claude-sonnet-4-6" });
|
||||
const rootModel = session.configOptions.find((option) => option.id === "model");
|
||||
const rootEntryName =
|
||||
rootModel?.type === "select"
|
||||
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
||||
(entry) => entry.value === "claude-sonnet-4-6",
|
||||
)?.name
|
||||
: undefined;
|
||||
expect(rootEntryName).toBeDefined();
|
||||
const models = updates.flatMap(({ sessionId: target, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => [target, option.currentValue, option])
|
||||
: [],
|
||||
);
|
||||
expect(models).toEqual([
|
||||
[
|
||||
"child:generation:2",
|
||||
"claude-sonnet-4-6",
|
||||
expect.objectContaining({
|
||||
id: "model",
|
||||
// The name of the root session's entry for the model.
|
||||
options: [{ value: "claude-sonnet-4-6", name: rootEntryName }],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
@@ -1951,6 +2158,66 @@ describe("createSession options merging", () => {
|
||||
await loading;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without subagents", [] as Record<string, unknown>[]],
|
||||
[
|
||||
"with a subagent",
|
||||
[
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "child-message",
|
||||
session_id: "replay-probe",
|
||||
parent_tool_use_id: "agent-call",
|
||||
message: {
|
||||
id: "api-child-message",
|
||||
model: "claude-sonnet-4-6",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "child report" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
])(
|
||||
"sends the end event before the CLI start finishes for native subagent sessions (%s)",
|
||||
async (_label, history) => {
|
||||
let releaseInit!: () => void;
|
||||
initGate = new Promise((resolve) => (releaseInit = resolve));
|
||||
sessionMessages = [userMessage("u1", "first"), ...history];
|
||||
const updates: SessionNotification[] = [];
|
||||
const loading = (
|
||||
await endAgent(updates, undefined, {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: ["loadReplayEnd", "nativeSubagentSessions"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClientCapabilities)
|
||||
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
// The child's model goes as the replay reaches it, with no model list yet.
|
||||
await vi.waitFor(() =>
|
||||
expect(kinds(updates)).toEqual(
|
||||
history.length === 0
|
||||
? ["user_message_chunk", "end"]
|
||||
: [
|
||||
"user_message_chunk",
|
||||
"subagent_spawned",
|
||||
"config_option_update",
|
||||
"agent_message_chunk",
|
||||
"subagent_state_update",
|
||||
"end",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
releaseInit();
|
||||
await loading;
|
||||
},
|
||||
);
|
||||
|
||||
it("sends the end event for an empty history", async () => {
|
||||
sessionMessages = [];
|
||||
const updates: SessionNotification[] = [];
|
||||
|
||||
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);
|
||||
|
||||
@@ -2,11 +2,13 @@ import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
matchResumedModel,
|
||||
modelEntryFor,
|
||||
} from "../session-model.js";
|
||||
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
@@ -379,16 +381,133 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelEntryFor", () => {
|
||||
const infos: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Default (recommended)",
|
||||
description: "",
|
||||
},
|
||||
{ value: "opus", resolvedModel: "claude-opus-4-6", displayName: "Opus 4.6", description: "" },
|
||||
{
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Opus 4.6 (1M context)",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
/** The session's `model` option with `current` selected. */
|
||||
const options = (current: string): SessionConfigOption[] => [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: current,
|
||||
options: infos.map((info) => ({ value: info.value, name: info.displayName })),
|
||||
},
|
||||
];
|
||||
const entryOf = (current: string, model: string) => modelEntryFor(options(current), infos, model);
|
||||
|
||||
it("prefers the entry that the user picked when it runs the model up to a context hint", () => {
|
||||
// A response reports the model id without the context hint.
|
||||
expect(entryOf("opus[1m]", "claude-opus-4-6")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
expect(entryOf("default", "claude-opus-4-6")).toEqual({
|
||||
value: "default",
|
||||
name: "Default (recommended)",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the listed entry of another model", () => {
|
||||
expect(entryOf("opus[1m]", "claude-haiku-4-5")).toEqual({ value: "haiku", name: "Haiku 4.5" });
|
||||
expect(entryOf("haiku", "claude-opus-4-6")).toEqual({ value: "opus", name: "Opus 4.6" });
|
||||
// A concrete entry comes before `default` when both run the model.
|
||||
expect(entryOf("haiku", "claude-opus-4-6[1m]")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a model again when the session replaces its option list", () => {
|
||||
const before = options("haiku");
|
||||
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus",
|
||||
name: "Opus 4.6",
|
||||
});
|
||||
// The user picks `opus[1m]`: the session replaces its option list.
|
||||
expect(modelEntryFor(options("opus[1m]"), infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus",
|
||||
name: "Opus 4.6",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares a dated or a provider model id as it is", () => {
|
||||
// `resolvedModel` is the wire id that a response reports, date and provider prefix included.
|
||||
const dated: ModelInfo[] = [
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5-20251001",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "eu.anthropic.claude-sonnet-5",
|
||||
displayName: "Sonnet 5",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
const datedOptions: SessionConfigOption[] = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: dated.map((info) => ({ value: info.value, name: info.displayName })),
|
||||
},
|
||||
];
|
||||
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5-20251001")).toEqual({
|
||||
value: "haiku",
|
||||
name: "Haiku 4.5",
|
||||
});
|
||||
expect(modelEntryFor(datedOptions, dated, "eu.anthropic.claude-sonnet-5")).toEqual({
|
||||
value: "sonnet",
|
||||
name: "Sonnet 5",
|
||||
});
|
||||
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches no entry for a model that the list does not have", () => {
|
||||
expect(entryOf("opus", "claude-sonnet-4-6")).toBeUndefined();
|
||||
expect(entryOf("opus", "claude-opus-4-7")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
} from "../native-subagents.js";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
@@ -814,6 +815,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
const sendMessageResult = (
|
||||
toolCallId: string,
|
||||
status: "completed" | "failed",
|
||||
): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status,
|
||||
_meta: { claudeCode: { toolName: "SendMessage" } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
|
||||
async function runningWorker(input: Record<string, unknown> = {}) {
|
||||
@@ -842,6 +856,218 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [],
|
||||
);
|
||||
|
||||
it("reports only the observed child model and refreshes it on a model change", async () => {
|
||||
const { runtime, published } = await runningWorker({ model: "inherit" });
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await runtime.reportModel(null, "parent-model", deliver);
|
||||
await runtime.reportModel("launch-1", "<synthetic>", deliver);
|
||||
expect(published.some(({ update }) => update.sessionUpdate === "config_option_update")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "resume-1", prompt: "Continue" },
|
||||
deliver,
|
||||
);
|
||||
await runtime.reportModel("resume-1", "fallback-model", deliver);
|
||||
|
||||
const configs = published.filter(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configs).toHaveLength(2);
|
||||
for (const [index, model] of ["child-model", "fallback-model"].entries()) {
|
||||
expect(configs[index]).toMatchObject({
|
||||
sessionId: "worker-1",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: model,
|
||||
options: [{ value: model, name: model }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("buffers model observations until the child is announced", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
expect(published).toEqual([]);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
expect(published).toEqual([]);
|
||||
await deliver(launch("launch-1", {}));
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual([
|
||||
"subagent_update",
|
||||
"config_option_update",
|
||||
]);
|
||||
expect(published[1]?.sessionId).toBe("worker-1");
|
||||
});
|
||||
|
||||
const effortOption = (level: string) => ({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
category: "thought_level",
|
||||
type: "select" as const,
|
||||
currentValue: level,
|
||||
options: [{ value: level, name: level }],
|
||||
});
|
||||
|
||||
it("merges hook observations with model snapshots without inheriting other sessions", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
effortOption("high"),
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "plan",
|
||||
options: [{ value: "plan", name: "Plan" }],
|
||||
},
|
||||
]);
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("low")]);
|
||||
await runtime.reportModel("launch-1", "fallback-model", deliver);
|
||||
const configs = published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
|
||||
);
|
||||
expect(configs).toHaveLength(4);
|
||||
expect(configs.at(-1)?.map((option) => [option.id, option.currentValue])).toEqual([
|
||||
["mode", "plan"],
|
||||
["model", "fallback-model"],
|
||||
["effort", "low"],
|
||||
]);
|
||||
expect(
|
||||
configs.at(-1)?.every((option) => option.type === "select" && option.options.length === 1),
|
||||
).toBe(true);
|
||||
await runtime.reportConfiguration("root", [effortOption("max")]);
|
||||
expect(
|
||||
published.filter(({ update }) => update.sessionUpdate === "config_option_update"),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps hook observations pending until truthful parentage is announced", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
expect(published).toEqual([]);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
expect(published).toEqual([]);
|
||||
await runtime.route(launch("launch-1", {}), async () => {});
|
||||
expect(published.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
|
||||
["root", "subagent_update"],
|
||||
["worker-1", "config_option_update"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes concurrent config publications and retries a failed snapshot", async () => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const value = child({ announced: true });
|
||||
let fail = false;
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
sessionWith(value),
|
||||
async (notification) => {
|
||||
if (fail) throw new Error("transport failed");
|
||||
published.push(notification);
|
||||
if (published.length === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const first = runtime.reportConfiguration(value.sessionId, [effortOption("high")]);
|
||||
await started.promise;
|
||||
const second = runtime.reportConfiguration(value.sessionId, [effortOption("low")]);
|
||||
release.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(
|
||||
published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => option.currentValue)
|
||||
: [],
|
||||
),
|
||||
).toEqual(["high", "low"]);
|
||||
fail = true;
|
||||
await expect(
|
||||
runtime.reportConfiguration(value.sessionId, [effortOption("max")]),
|
||||
).rejects.toThrow("transport failed");
|
||||
fail = false;
|
||||
await runtime.reportConfiguration(value.sessionId, [effortOption("max")]);
|
||||
expect(published).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("forgets unannounced configuration when the runtime is cleared", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
runtime.clear();
|
||||
await runtime.taskStarted({ taskId: "worker-1", subagentType: "Explore" }, async () => {});
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["subagent_update"]);
|
||||
});
|
||||
|
||||
it("does not report child config without native subagent sessions", async () => {
|
||||
const deliver = vi.fn(async () => {});
|
||||
const runtime = new NativeSubagentRuntime(undefined, "root", {}, deliver, { log: () => {} });
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the Agent tool call, which the parent's session messages replace", async () => {
|
||||
const { runtime, control } = await runningWorker();
|
||||
|
||||
@@ -1085,10 +1311,15 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
|
||||
sessionId: "worker-1",
|
||||
});
|
||||
await runtime.route(sendMessage("send-1"), async () => {});
|
||||
// The SendMessage call is not shown: its message replaces it.
|
||||
await expect(runtime.route(sendMessage("send-1"), async () => {})).resolves.toBeNull();
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
// A repeated resume signal of the running child changes nothing.
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
// The SDK reports the delivery before the call's result, which stays hidden.
|
||||
await expect(
|
||||
runtime.route(sendMessageResult("send-1", "completed"), async () => {}),
|
||||
).resolves.toBeNull();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {});
|
||||
|
||||
expect(states(published)).toEqual([
|
||||
@@ -1097,21 +1328,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
]);
|
||||
// The SendMessage call stays a tool call of its sender, so its message has
|
||||
// only the child's view, with no reference.
|
||||
// As a launch's prompt, the message goes to both, each view naming the other.
|
||||
const message = {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "worker-1",
|
||||
content: [{ type: "text", text: "Check the tests too" }],
|
||||
};
|
||||
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
|
||||
expect.objectContaining({ sessionId: "root" }),
|
||||
expect.objectContaining({ sessionId: "worker-1" }),
|
||||
{
|
||||
sessionId: "worker-1",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "worker-1",
|
||||
content: [{ type: "text", text: "Check the tests too" }],
|
||||
},
|
||||
},
|
||||
{ sessionId: "root", update: { ...message, recipientMessageId: "send-1" } },
|
||||
{ sessionId: "worker-1", update: { ...message, senderMessageId: "send-1" } },
|
||||
]);
|
||||
expect(
|
||||
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
|
||||
@@ -1156,7 +1385,22 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
.map(
|
||||
({ sessionId, update }) => `${sessionId} ${(update as { messageId: string }).messageId}`,
|
||||
),
|
||||
).toEqual(["root launch-1", "worker-1 launch-1", "worker-1 send-1"]);
|
||||
).toEqual(["root launch-1", "worker-1 launch-1", "root send-1", "worker-1 send-1"]);
|
||||
});
|
||||
|
||||
it("shows a SendMessage call whose message reached no announced child", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
published.length = 0;
|
||||
// A message to the parent, or to an agent this client was never shown,
|
||||
// resumes no announced child.
|
||||
await expect(runtime.route(sendMessage("send-2"), async () => {})).resolves.toBeNull();
|
||||
await expect(
|
||||
runtime.route(sendMessageResult("send-2", "completed"), async () => {}),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: "root",
|
||||
update: { sessionUpdate: "tool_call", toolCallId: "send-2", status: "completed" },
|
||||
});
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
|
||||
@@ -1331,6 +1575,17 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
state: { state: "running" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "echoer-1",
|
||||
recipientMessageId: "send-1",
|
||||
content: [{ type: "text", text: "Say PING" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "echoer-1",
|
||||
update: {
|
||||
@@ -1338,6 +1593,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "echoer-1",
|
||||
senderMessageId: "send-1",
|
||||
content: [{ type: "text", text: "Say PING" }],
|
||||
},
|
||||
},
|
||||
@@ -1415,7 +1671,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
|
||||
expect(
|
||||
published.map(({ sessionId, update }) => `${sessionId} ${update.sessionUpdate}`),
|
||||
).toEqual(["root subagent_update", "echoer-1 session_message"]);
|
||||
).toEqual(["root subagent_update", "root session_message", "echoer-1 session_message"]);
|
||||
});
|
||||
|
||||
it("keeps an agent that the history cannot place, or not read, unexposed", async () => {
|
||||
@@ -2212,3 +2468,279 @@ describe("NativeSubagentRuntime in the AIR form, for an agent from before a load
|
||||
expect(spawned(published)).toEqual(["root -> worker-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeSubagentRuntime child models", () => {
|
||||
const entries = {
|
||||
default: { value: "default", name: "Default (recommended)" },
|
||||
sonnet: { value: "sonnet", name: "Sonnet 4.6" },
|
||||
};
|
||||
/** The root session's `model` option, as `modelEntryFor` maps a model id to it. */
|
||||
const models: SubagentModels = {
|
||||
entry: (model) => (model === "claude-sonnet-4-6" ? entries.sonnet : undefined),
|
||||
name: (model) => (model === "claude-opus-5" ? "Opus 5" : undefined),
|
||||
};
|
||||
const agentCall = (toolCallId: string, input: Record<string, unknown>): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "Agent",
|
||||
status: "pending",
|
||||
rawInput: { description: "Fix the build", prompt: "Make it pass", ...input },
|
||||
_meta: {
|
||||
claudeCode: { toolName: "Agent" },
|
||||
jetbrains: { air: { version: 1, subagent: true } },
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A runtime of `form` whose child `worker-1` the call `launch-1` with `input` launched. */
|
||||
async function spawn(form: "rfd" | "air", input: Record<string, unknown> = {}) {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
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", input));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
return { runtime, published, deliver };
|
||||
}
|
||||
/** Each child `model` option sent: its session, value, and name, the only choice. */
|
||||
const modelUpdates = (published: AcpSessionNotification[]) =>
|
||||
published.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => {
|
||||
const choices = option.type === "select" ? option.options : [];
|
||||
expect(option).toMatchObject({ id: "model", category: "model", type: "select" });
|
||||
expect(choices).toEqual([{ value: option.currentValue, name: expect.any(String) }]);
|
||||
return [sessionId, option.currentValue, (choices[0] as { name: string }).name];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends no model before the child's first response, whatever the launch asks for",
|
||||
async (form) => {
|
||||
const { runtime, published, deliver } = await spawn(form, { model: "sonnet" });
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "<synthetic>", deliver);
|
||||
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.each(["rfd", "air"] as const)(
|
||||
"%s: sends the model again only when the entry changes",
|
||||
async (form) => {
|
||||
const { runtime, published, deliver } = await spawn(form);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
// The root session's own responses do not change the child.
|
||||
await runtime.reportModel(null, "claude-opus-5", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
// A model id that matches no entry goes as the id, with its display name.
|
||||
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
expect(modelUpdates(published)).toEqual([
|
||||
["worker-1", "sonnet", "Sonnet 4.6"],
|
||||
["worker-1", "claude-opus-5", "Opus 5"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("air: drops a model update that waits for an earlier one when the child ends", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const firstSent = deferred();
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
// The client takes the first model update slowly.
|
||||
if (notification.update.sessionUpdate === "config_option_update") await firstSent.promise;
|
||||
},
|
||||
{ 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,
|
||||
);
|
||||
const first = runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
await vi.waitFor(() => expect(modelUpdates(published)).toHaveLength(1));
|
||||
// The child ends while the client has not taken the first update yet, and
|
||||
// a model update comes in meanwhile.
|
||||
const finished = runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
const second = runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
firstSent.resolve();
|
||||
await Promise.all([first, second, finished]);
|
||||
// Nothing follows the end.
|
||||
expect(published.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
|
||||
});
|
||||
|
||||
it("air: reports a hook's configuration under the agent's current generation", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
// The SDK resumes the agent: AIR's draft opens a new generation session.
|
||||
await runtime.taskResumed("worker-1", deliver, "Continue");
|
||||
const generation = published
|
||||
.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "subagent_spawned" ? [update.subagentSessionId] : [],
|
||||
)
|
||||
.at(-1);
|
||||
expect(generation).toBe("worker-1:generation:2");
|
||||
// A hook names the SDK agent id, not the generation session.
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
|
||||
},
|
||||
]);
|
||||
expect(modelUpdates(published)).toEqual([[generation, "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: keeps only the latest model of a child that is not announced yet",
|
||||
async (form) => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
// More responses than the buffer of one parent holds (32), before the launch call.
|
||||
for (let index = 0; index < 40; index++) {
|
||||
await runtime.reportModel(
|
||||
"launch-1",
|
||||
index % 2 ? "claude-opus-5" : "claude-sonnet-4-6",
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
await runtime.route(
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "working" },
|
||||
_meta: { claudeCode: { parentToolUseId: "launch-1" } },
|
||||
},
|
||||
} as AcpSessionNotification,
|
||||
deliver,
|
||||
);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "claude-opus-5", "Opus 5"]]);
|
||||
// The child's output is not crowded out of the buffer.
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toContain("agent_message_chunk");
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
expect(runtime.configurationOf("worker-1")).toHaveLength(1);
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
expect(runtime.configurationOf("worker-1")).toBeUndefined();
|
||||
// A hook after the end stores nothing either.
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
|
||||
},
|
||||
]);
|
||||
expect(runtime.configurationOf("worker-1")).toBeUndefined();
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it("air: sends no model to a child after its end", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,7 +41,8 @@ const launch = (uuid: string, launchId: string) => ({
|
||||
});
|
||||
/**
|
||||
* The directive message that Claude Code writes after the copy of the launching
|
||||
* message in a `subagent_type: "fork"` child's transcript.
|
||||
* message in a `subagent_type: "fork"` child's transcript, with the tool_result
|
||||
* text of Claude Code before 2.1.307.
|
||||
*/
|
||||
const forkDirective = (uuid: string, launchIds: string[], prompt: string) => ({
|
||||
...base,
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -319,13 +319,16 @@ export class AcpToolCallRenderer {
|
||||
const facts = this.facts(toolUse, options.cwd);
|
||||
if (!this.capabilities.air.client) {
|
||||
// The upstream shape: the whole tool call again. A v2 client also gets
|
||||
// the exact preview patch, which the tool call does not show.
|
||||
// the exact preview patch, which the tool call does not show, but not the
|
||||
// status: its subject is an update of the call, which was emitted before
|
||||
// the request (`ensureToolCallEmitted`), and a `pending` there would move
|
||||
// a call that already runs back.
|
||||
const content = options.previewContent ?? this.toolUseContent(toolUse.id, facts);
|
||||
const locations = [...(facts.locations ?? []), ...(options.extraLocations ?? [])];
|
||||
return {
|
||||
toolCallId: toolUse.id,
|
||||
name: toolUse.name,
|
||||
status: "pending",
|
||||
...(this.capabilities.v2 ? {} : { status: "pending" as const }),
|
||||
rawInput: toolUse.input,
|
||||
title: options.title ?? facts.title,
|
||||
kind: facts.kind,
|
||||
|
||||
@@ -431,7 +431,7 @@ export const createPostToolUseHook =
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
|
||||
if (input.tool_name === "EnterPlanMode" && !input.agent_id && options?.onEnterPlanMode) {
|
||||
await options.onEnterPlanMode();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,18 @@ const usageWindowSchema = z
|
||||
.nullable()
|
||||
.optional();
|
||||
const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
|
||||
// Claude Code 2.1.305+ suggests one change per notable source, largest share first.
|
||||
const recommendationSchema = z.object({
|
||||
key: z.string(),
|
||||
pct: percentSchema,
|
||||
title: z.string(),
|
||||
detail: z.string().optional(),
|
||||
});
|
||||
const behaviorPeriodSchema = z.object({
|
||||
request_count: countSchema,
|
||||
session_count: countSchema,
|
||||
mcp_servers: z.array(contributionSchema),
|
||||
recommendations: z.array(recommendationSchema).optional(),
|
||||
});
|
||||
const modelUsageSchema = z.object({
|
||||
inputTokens: countSchema,
|
||||
@@ -133,12 +141,23 @@ function appendContributions(
|
||||
request_count: number;
|
||||
session_count: number;
|
||||
mcp_servers: { name: string; pct: number }[];
|
||||
recommendations?: { pct: number; title: string; detail?: string }[];
|
||||
},
|
||||
): void {
|
||||
lines.push(
|
||||
"",
|
||||
`**${label}** · ${period.request_count} requests · ${period.session_count} sessions`,
|
||||
);
|
||||
// Rendered in the order Claude Code sends them, as its own `/usage` does. The
|
||||
// window's `fix` is left out: its messages must be queued at priority "later"
|
||||
// with `client_composed` set, which an ACP prompt cannot express.
|
||||
if (period.recommendations?.length) {
|
||||
lines.push("");
|
||||
for (const recommendation of period.recommendations) {
|
||||
const detail = recommendation.detail ? ` · ${escapeMarkdown(recommendation.detail)}` : "";
|
||||
lines.push(`- **${recommendation.pct}%** ${escapeMarkdown(recommendation.title)}${detail}`);
|
||||
}
|
||||
}
|
||||
if (period.mcp_servers.length === 0) return;
|
||||
lines.push("", "| MCP server | Usage |", "|:--|--:|");
|
||||
for (const server of [...period.mcp_servers].sort((a, b) => b.pct - a.pct).slice(0, 3)) {
|
||||
|
||||
@@ -95,10 +95,11 @@ export function v2AgentApp(
|
||||
sessionId: params.sessionId,
|
||||
update: stateSnapshot(agent.foregroundState(params.sessionId)),
|
||||
});
|
||||
return {
|
||||
...v2ResumeSessionResponse(response),
|
||||
...(await setupCommands(agent, params.sessionId, logger)),
|
||||
};
|
||||
const commands = await setupCommands(agent, params.sessionId, logger);
|
||||
// The response ends a replay. The subagents RFD treats the replayed
|
||||
// subagent states before it as history, so their current states follow it.
|
||||
setTimeout(() => void agent.publishCurrentSubagentStates(params.sessionId), 0);
|
||||
return { ...v2ResumeSessionResponse(response), ...commands };
|
||||
})
|
||||
.onRequest(v2.methods.agent.session.fork, ({ params }) =>
|
||||
agent.forking(params.sessionId, async () => {
|
||||
|
||||
@@ -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