mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 00:12:14 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43fec3fa3 | ||
|
|
0304f743f8 | ||
|
|
6a162baf8b | ||
|
|
b2dbc8f5a1 | ||
|
|
390b0b9f8b | ||
|
|
0724b17c58 | ||
|
|
0e83a06c4a | ||
|
|
7b5c61a4ed | ||
|
|
5598efbc8a | ||
|
|
07c7ed3752 | ||
|
|
3a61172e19 | ||
|
|
4ddda8f37e | ||
|
|
96acfeccf9 | ||
|
|
52a92be959 | ||
|
|
8589a9cf15 | ||
|
|
a44c486019 | ||
|
|
d599333b72 | ||
|
|
b1d6cfc6c9 | ||
|
|
3d9bae941c | ||
|
|
98b17c28c8 | ||
|
|
22139f73c4 | ||
|
|
bd8a161c2e | ||
|
|
63aa3b8428 | ||
|
|
b9a6ec00a6 | ||
|
|
c1c8c0377d | ||
|
|
23b8ebf8c2 | ||
|
|
2b7fab7d98 | ||
|
|
d3e673b263 | ||
|
|
d83f28a403 | ||
|
|
ba5ed170ae | ||
|
|
d24272c6b0 | ||
|
|
190a00f68f | ||
|
|
ddd5f54187 | ||
|
|
3d4017b15a | ||
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 | ||
|
|
c84845272f | ||
|
|
af0b43de94 | ||
|
|
f53b0d7f40 | ||
|
|
4af807968c | ||
|
|
bdb50ad984 | ||
|
|
d5e6f33046 | ||
|
|
691328a919 | ||
|
|
49858b5273 | ||
|
|
77ceab0339 | ||
|
|
18de376240 | ||
|
|
875d75f2a4 | ||
|
|
e6681d2a57 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
src/tests/acp-scenarios/__snapshots__/** linguist-generated=true
|
||||
src/tests/acp-scenarios/origin-main/** linguist-generated=true
|
||||
@@ -5,3 +5,7 @@ dist
|
||||
.release-please-manifest.json
|
||||
release-please-config.json
|
||||
CHANGELOG.md
|
||||
|
||||
# Recorded ACP traffic, written by src/tests/acp-scenarios.test.ts.
|
||||
src/tests/acp-scenarios/__snapshots__
|
||||
src/tests/acp-scenarios/origin-main
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.81.2"
|
||||
".": "0.88.0"
|
||||
}
|
||||
|
||||
89
CHANGELOG.md
89
CHANGELOG.md
@@ -1,5 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
## [0.88.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.87.0...v0.88.0) (2026-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1276](https://github.com/agentclientprotocol/claude-agent-acp/issues/1276)) ([0304f74](https://github.com/agentclientprotocol/claude-agent-acp/commit/0304f743f85e2505ad5a67c1fe7b7d24f2f9ad23))
|
||||
|
||||
## [0.87.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.86.0...v0.87.0) (2026-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump the minor group with 2 updates ([#1260](https://github.com/agentclientprotocol/claude-agent-acp/issues/1260)) ([0724b17](https://github.com/agentclientprotocol/claude-agent-acp/commit/0724b17c581e30b1242cfbec7bce44c21df99525))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* async task routing, Monitor tasks, structured task ids, and abandoned tool calls ([#1249](https://github.com/agentclientprotocol/claude-agent-acp/issues/1249)) ([0e83a06](https://github.com/agentclientprotocol/claude-agent-acp/commit/0e83a06c4a8825ba654bddf1ab909d1a773942e2))
|
||||
* settle prompts folded into a task-notification cycle ([#1233](https://github.com/agentclientprotocol/claude-agent-acp/issues/1233)) ([390b0b9](https://github.com/agentclientprotocol/claude-agent-acp/commit/390b0b9f8bf0cc0aa7d9273a7b614172c916d3a6))
|
||||
|
||||
## [0.86.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.1...v0.86.0) (2026-10-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* answer /mcp in chat with the MCP server status and a reconnect ([#1218](https://github.com/agentclientprotocol/claude-agent-acp/issues/1218)) ([190a00f](https://github.com/agentclientprotocol/claude-agent-acp/commit/190a00f68f773315e93630464397944e645616ff))
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.287 ([#1224](https://github.com/agentclientprotocol/claude-agent-acp/issues/1224)) ([ddd5f54](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddd5f541879118edb18df7ff09cd6682d455c710))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* count a subagent's permission requests as the turn waiting on the user ([#1241](https://github.com/agentclientprotocol/claude-agent-acp/issues/1241)) ([d599333](https://github.com/agentclientprotocol/claude-agent-acp/commit/d599333b722a951553c0efd40a76559b1f32e6ab))
|
||||
* drop queued prompts a cancel ends from Claude Code's queue ([#1228](https://github.com/agentclientprotocol/claude-agent-acp/issues/1228)) ([d83f28a](https://github.com/agentclientprotocol/claude-agent-acp/commit/d83f28a4035c5a5e54950fd58c7b73bfc2313664))
|
||||
* end a cancelled turn when the force-cancel fires during an update ([#1243](https://github.com/agentclientprotocol/claude-agent-acp/issues/1243)) ([a44c486](https://github.com/agentclientprotocol/claude-agent-acp/commit/a44c486019e98ad478c549c4902e7092f1418d0b))
|
||||
* end a closed session's turns before session/close answers ([#1242](https://github.com/agentclientprotocol/claude-agent-acp/issues/1242)) ([b1d6cfc](https://github.com/agentclientprotocol/claude-agent-acp/commit/b1d6cfc6c9fc9ec397e607b68ac100f4d48745b3))
|
||||
* end a held turn cancelled mid-followup at its interrupt's idle ([#1229](https://github.com/agentclientprotocol/claude-agent-acp/issues/1229)) ([d3e673b](https://github.com/agentclientprotocol/claude-agent-acp/commit/d3e673b263cf47603a35a50bb3da8b483199f690))
|
||||
* excessive message/token_count API calls when probing context window ([#1258](https://github.com/agentclientprotocol/claude-agent-acp/issues/1258)) ([5598efb](https://github.com/agentclientprotocol/claude-agent-acp/commit/5598efbc8ab16720ea79f5c97db40771ad0b317b))
|
||||
* replay a session with a Write on the experimental ACP v2 surface ([#1238](https://github.com/agentclientprotocol/claude-agent-acp/issues/1238)) ([22139f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/22139f73c48e621752089a91fb095269493896b1))
|
||||
* report a command's exit code only when the tool result says it ([#1252](https://github.com/agentclientprotocol/claude-agent-acp/issues/1252)) ([52a92be](https://github.com/agentclientprotocol/claude-agent-acp/commit/52a92be959f7f8ea90ab08d54bae6900596ab435))
|
||||
* restore main-thread agent config option ([#1250](https://github.com/agentclientprotocol/claude-agent-acp/issues/1250)) ([8589a9c](https://github.com/agentclientprotocol/claude-agent-acp/commit/8589a9cf150d8deca6d2f8ef24404d927b3d4828))
|
||||
|
||||
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
|
||||
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
|
||||
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.286 ([#1211](https://github.com/agentclientprotocol/claude-agent-acp/issues/1211)) ([f53b0d7](https://github.com/agentclientprotocol/claude-agent-acp/commit/f53b0d7f40bcd0e9bddace85f123ff4851afcb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt to SDK 0.3.284 replay and ultracode changes ([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208)) ([4af8079](https://github.com/agentclientprotocol/claude-agent-acp/commit/4af807968c7f0813bcb20fe2bcab2370320e9074))
|
||||
* send the legacy custom answer key to AIR ([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210)) ([af0b43d](https://github.com/agentclientprotocol/claude-agent-acp/commit/af0b43de944e36f81343f40b7aa1a96cd48c3218))
|
||||
|
||||
## [0.84.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.83.0...v0.84.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1189](https://github.com/agentclientprotocol/claude-agent-acp/issues/1189)) ([d5e6f33](https://github.com/agentclientprotocol/claude-agent-acp/commit/d5e6f33046614fca4798d49f0c77edba951a6771))
|
||||
|
||||
## [0.83.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.82.0...v0.83.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.283 ([#1186](https://github.com/agentclientprotocol/claude-agent-acp/issues/1186)) ([49858b5](https://github.com/agentclientprotocol/claude-agent-acp/commit/49858b527397a08d343fe2c148a5fe9e3eb84547))
|
||||
|
||||
## [0.82.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.2...v0.82.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* AIR tool call contract, exact git patches, plan file, and fixes for every client ([#1153](https://github.com/agentclientprotocol/claude-agent-acp/issues/1153)) ([875d75f](https://github.com/agentclientprotocol/claude-agent-acp/commit/875d75f2a4941d9c6313ed51f89da0f42443ac2f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use ACP auth errors without access updates ([#1179](https://github.com/agentclientprotocol/claude-agent-acp/issues/1179)) ([e6681d2](https://github.com/agentclientprotocol/claude-agent-acp/commit/e6681d2a5734857727352474c8c9aa848f9210ee))
|
||||
|
||||
## [0.81.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.1...v0.81.2) (2026-09-24)
|
||||
|
||||
|
||||
|
||||
14
README.md
14
README.md
@@ -9,6 +9,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Context @-mentions
|
||||
- Images
|
||||
- Tool calls (with permission requests)
|
||||
- Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch)
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
@@ -16,10 +17,13 @@ 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
|
||||
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md)
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/recommended-config-values-extension.md)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md)
|
||||
- `/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)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/air-extensions.md#permission-presentation)
|
||||
- One fact per field in every tool call report, as the [ACP tool call contract](docs/air-extensions.md#tool-call-contract) defines
|
||||
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md)
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
@@ -40,7 +44,7 @@ in its initialize response. The canonical field remains supported and takes prec
|
||||
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
|
||||
tool-call representation and child interactions stay on the root session. Clients that use the
|
||||
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
|
||||
the flattened child transcript behavior.
|
||||
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
1294
docs/air-extensions.md
Normal file
1294
docs/air-extensions.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
# AIR diff statistics
|
||||
|
||||
The adapter supplies counts in each ACP diff block through `_meta.jetbrains.air.diffStats`.
|
||||
This uses the same contract as the Codex adapter. No capability negotiation is required.
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"diffStats": { "version": 1, "added": 20, "removed": 30 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both counts are nonnegative integers. They describe the operations in that block's patch.
|
||||
Context lines and EOF markers do not increase the counts.
|
||||
The extension carries no navigation position.
|
||||
|
||||
For `Edit` and `Write`, the adapter counts operations while it builds diff texts from the SDK's `structuredPatch`.
|
||||
Each hunk produces one block with its own counts. The adapter does not compare or scan the full file texts for statistics.
|
||||
|
||||
When a patch has unsafe, unordered, or mutually inconsistent coordinates, inconsistent line counts,
|
||||
misplaced EOF markers, or unsupported prefixes, the adapter omits the statistics.
|
||||
Empty patches, initial tool inputs, and history without structured patches retain their existing behavior.
|
||||
They provide no statistics because the adapter has no patch operations to count.
|
||||
AIR uses its existing comparison when statistics are absent.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Goal extension
|
||||
|
||||
This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
An agent advertises support in its `initialize` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"goal": {
|
||||
"version": 1,
|
||||
"controlMethod": "_session/goal",
|
||||
"actions": ["clear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
|
||||
|
||||
## Session state
|
||||
|
||||
The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`.
|
||||
|
||||
```json
|
||||
{
|
||||
"objective": "Ship the change",
|
||||
"status": "active",
|
||||
"iterations": 3,
|
||||
"lastReason": "Tests still need work",
|
||||
"createdAt": 1710000000123,
|
||||
"controlMethod": "_session/goal"
|
||||
}
|
||||
```
|
||||
|
||||
Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds.
|
||||
|
||||
## Lifecycle architecture
|
||||
|
||||
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
|
||||
|
||||
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
|
||||
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
|
||||
- A later autonomous cycle may publish more session updates outside that completed prompt.
|
||||
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
|
||||
|
||||
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
|
||||
|
||||
## Claude mapping
|
||||
|
||||
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
|
||||
|
||||
Some Claude runtime versions persist the goal and begin the Stop-hook workflow without emitting the initial `active_goal` message on the SDK stream. To avoid leaving clients without visible state for the whole long-running prompt, the adapter publishes a minimal active snapshot as soon as it submits `/goal <condition>`, and publishes `goal: null` for `/goal clear`. While that command is pending, late updates for the previous goal are ignored. A matching runtime `active_goal` remains authoritative and enriches the optimistic snapshot with iteration, reason, and timestamp fields; a failed command restores the previously published state.
|
||||
|
||||
`set` and `clear` are advertised for out-of-band control; pause and resume are not. When a goal turn is running, the adapter injects the corresponding `/goal` command at steering priority `now` so it cannot remain queued behind the Stop-hook workflow; on an idle session it uses the ordinary prompt lifecycle. Cancelling a turn does not clear the session-scoped goal: clients use the goal control method when they intend to replace or remove it.
|
||||
@@ -1,253 +0,0 @@
|
||||
# Permission extension
|
||||
|
||||
This document defines the experimental permission presentation extension implemented by
|
||||
`claude-agent-acp`. The adapter uses standard ACP permission requests and responses, while `_meta`
|
||||
adds compact request presentation text.
|
||||
|
||||
The extension does not replace ACP permissions. `RequestPermissionRequest.toolCall`, `options`,
|
||||
`RequestPermissionResponse.outcome`, and each option's standard `kind` remain authoritative. A client
|
||||
that does not understand the extension can ignore `_meta` and render the ordinary ACP request.
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
When the Claude Agent SDK invokes `canUseTool`, the adapter:
|
||||
|
||||
1. stops immediately if the tool-call signal is already aborted;
|
||||
2. ensures the referenced ACP `tool_call` has been emitted;
|
||||
3. validates and snapshots any SDK `suggestions: PermissionUpdate[]`;
|
||||
4. builds a tool-specific presentation and ordered option list;
|
||||
5. sends `session/request_permission` with the tool-call cancellation signal;
|
||||
6. validates that the selected option was offered in the exact request;
|
||||
7. maps the selection to a Claude SDK `PermissionResult`.
|
||||
|
||||
The eager `tool_call` announcement always precedes the permission request. If the announcement fails,
|
||||
its de-duplication marker is removed so the normal streamed tool-use path may retry it.
|
||||
|
||||
Request-level presentation metadata requires no capability negotiation. It is an additive `_meta`
|
||||
record and may be ignored by clients that do not render it.
|
||||
|
||||
## Request presentation
|
||||
|
||||
The request-level record is placed under `RequestPermissionRequest._meta.permission`:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "session-1",
|
||||
"toolCall": {
|
||||
"toolCallId": "toolu_1",
|
||||
"name": "Bash",
|
||||
"status": "pending",
|
||||
"rawInput": { "command": "npm test" }
|
||||
},
|
||||
"options": [
|
||||
{ "optionId": "allow-once", "name": "Yes", "kind": "allow_once" },
|
||||
{ "optionId": "reject", "name": "No", "kind": "reject_once" }
|
||||
],
|
||||
"_meta": {
|
||||
"permission": {
|
||||
"version": 1,
|
||||
"title": "npm test",
|
||||
"description": "Reason: Needed to verify the change."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Type | Meaning |
|
||||
| ------------- | -------: | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | yes | integer `1` | Permission presentation schema version. |
|
||||
| `title` | yes | non-empty string | The standard tool-call operation title. |
|
||||
| `description` | no | string | Temporary diagnostic SDK decision reason. |
|
||||
| `defaultToNo` | no | `true` | The ask must not be approvable by a stray keystroke; the reject options are listed first and a client that pre-selects an option should focus the decline. |
|
||||
|
||||
The permission title normally duplicates `toolCall.title`: one operation has one heading across the
|
||||
tool card and approval UI. `ExitPlanMode` is the deliberate exception and uses the action-oriented
|
||||
permission heading `Ready to code?`. Commands, paths, URLs, and other structured details remain in
|
||||
`rawInput`, `content`, and `locations`.
|
||||
|
||||
`description` temporarily carries the SDK's non-blank `decisionReason` for diagnostics, prefixed
|
||||
with `Reason: `. The SDK `description` operation subtitle is not copied there.
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
The permission request carries the same standard ACP tool information used for normal tool updates:
|
||||
|
||||
- `name` is the SDK tool name (for example `Read` or `mcp__server__tool`), matching the standard
|
||||
`name` field on the initial `tool_call` update;
|
||||
- `kind`, `title`, `content`, and `locations` come from `toolInfoFromToolUse`;
|
||||
- `status` is `pending`;
|
||||
- `rawInput` is the original SDK input object;
|
||||
- `blockedPath` is appended to `locations` when it is valid and not already present;
|
||||
- subagent calls include `_meta.claudeCode.parentToolUseId` on the tool call;
|
||||
- Sandbox Network and Computer Use requests synthesize JSON content when the normal renderer has no
|
||||
content.
|
||||
|
||||
Compact text removes control characters, collapses whitespace where appropriate, and enforces length
|
||||
limits. Invalid optional presentation text is omitted instead of being truncated into misleading UI.
|
||||
Shell permission titles (`Bash` and `PowerShell`) preserve the full command verbatim, including
|
||||
whitespace and line breaks, without applying compact-text normalization or length limits.
|
||||
|
||||
Permission options are fixed. When a durable suggestion contains a command prefix, path, host, or
|
||||
other rule, the adapter includes that value directly in `PermissionOption.name`, for example
|
||||
`Yes, and don't ask again for npm test commands`. Selecting the option applies the exact snapshotted
|
||||
Claude SDK `PermissionUpdate`; the client cannot edit it. A selected reject is distinct from
|
||||
`{ "outcome": "cancelled" }`: cancellation aborts the tool use instead of returning a user rejection.
|
||||
|
||||
## Standard option ids
|
||||
|
||||
| Option id | Meaning |
|
||||
| ------------------------------ | ----------------------------------------------------------- |
|
||||
| `allow-once` | Allow this call without changing permission state. |
|
||||
| `allow-with-updates` | Allow and apply the displayed durable effect. |
|
||||
| `allow-skill-exact` | Allow the exact Skill invocation in local settings. |
|
||||
| `allow-skill-prefix` | Allow the parameterized Skill prefix in local settings. |
|
||||
| `exit-plan-auto` | Exit plan mode and set session mode to `auto`. |
|
||||
| `exit-plan-bypass` | Exit plan mode and set session mode to `bypassPermissions`. |
|
||||
| `exit-plan-accept-edits` | Exit plan mode and set session mode to `acceptEdits`. |
|
||||
| `exit-plan-default` | Exit plan mode and set session mode to `default`. |
|
||||
| `exit-plan-clear-auto` | Clear context, continue the plan, and use `auto`. |
|
||||
| `exit-plan-clear-bypass` | Clear context, continue the plan, and bypass permissions. |
|
||||
| `exit-plan-clear-accept-edits` | Clear context, continue the plan, and accept edits. |
|
||||
| `reject` | Deny the call, optionally returning feedback to Claude. |
|
||||
|
||||
The adapter stably groups options as `allow_once`, `allow_always`, then reject kinds. Options of the
|
||||
same kind retain their builder order. Clients must return the selected option id unchanged.
|
||||
|
||||
## Durable SDK suggestions
|
||||
|
||||
Claude may provide `suggestions: PermissionUpdate[]`. Before a suggestion can appear as a durable
|
||||
choice, the adapter validates the complete array and takes a structured clone. Validation recognizes:
|
||||
|
||||
- `addRules`, `replaceRules`, and `removeRules`;
|
||||
- `setMode` for known Claude permission modes;
|
||||
- `addDirectories` and `removeDirectories`;
|
||||
- known destinations and rule behaviors;
|
||||
- bounded array sizes and bounded non-empty strings.
|
||||
|
||||
Unknown update types, invalid values, oversized bundles, non-cloneable data, and empty suggestion
|
||||
arrays are omitted. The snapshot prevents the label shown to the user from diverging from the effect
|
||||
returned after a delayed response.
|
||||
|
||||
An SDK suggestion is offered only when the tool-specific builder can accurately describe the entire
|
||||
effect. The adapter never labels one part of a mixed bundle while silently applying the rest.
|
||||
|
||||
When `matchedAskRule` is present, all persistent choices are suppressed. A configured `ask` rule must
|
||||
continue asking; accepting a generated allow rule that cannot override it would be misleading.
|
||||
|
||||
## Tool-specific behavior
|
||||
|
||||
| Tool family | Permission choices and effects |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Bash / PowerShell | One-time allow; exact representable SDK bundle with its complete effect in the option label; reject. |
|
||||
| Read / Glob / Grep | One-time allow; matching session-scoped read grant; reject. |
|
||||
| Edit / Write / NotebookEdit | One-time allow; matching session edit grant, including `.claude` handling; reject. |
|
||||
| WebFetch | One-time allow; generated `domain:<hostname>` local rule; reject. |
|
||||
| Skill | One-time allow; exact skill and optional `prefix:*` local rules; reject. |
|
||||
| EnterPlanMode | Enter plan mode once, or reject and continue implementing. |
|
||||
| ExitPlanMode | One keep-context and one clear-context elevated mode (`auto` > bypass > accept edits), manual approval, or reject. |
|
||||
| SandboxNetworkAccess | One-time allow; exact SDK host rule when representable; reject. |
|
||||
| Computer Use MCP | One-time allow; exact representable SDK allow suggestion; reject. |
|
||||
| MCP | One-time allow; exact representable SDK allow suggestion for that tool; reject. |
|
||||
| WebSearch / Agent / Task | One-time allow; generated whole-tool local rule; reject. |
|
||||
| ReviewArtifact / Workflow | Generic fallback behavior until SDK renderer state is available. |
|
||||
| Monitor / unknown tools | Generic fallback behavior. |
|
||||
|
||||
Generated WebFetch, Skill, and non-MCP fallback effects use `localSettings`. Filesystem grants are
|
||||
offered only when the SDK bundle is session-scoped and covers the current path. Shell labels may
|
||||
describe command rules, read paths, additional directories, or a representable combination.
|
||||
|
||||
`AskUserQuestion` is not a permission dialog. With ACP form elicitation support it is routed through
|
||||
ACP elicitation; without that support it is disabled when the Claude session is created.
|
||||
|
||||
## Permission modes and bypass
|
||||
|
||||
The session exposes the Claude modes available for the current model using Claude Code's labels:
|
||||
`Manual`, `Accept edits`, `Plan`, conditional `Auto`, and conditional `Bypass permissions`. The
|
||||
internal `dontAsk` SDK mode is accepted from settings for compatibility but is not advertised as a
|
||||
user-selectable mode.
|
||||
|
||||
Hosts can remove `Bypass permissions` from a session by sending
|
||||
`_meta.claudeCode.options.allowDangerouslySkipPermissions: false` on `session/new` (and again on
|
||||
`session/load`); the mode leaves the catalog and a `bypassPermissions` settings default clamps to
|
||||
`default`.
|
||||
|
||||
The ACP mode ids are the Claude SDK wire ids: `default`, `acceptEdits`, `plan`, `auto`, and
|
||||
`bypassPermissions`. `Manual` deliberately retains the SDK id `default`; `manual` is only a settings
|
||||
input alias. The adapter does not invent parallel ids for presentation labels.
|
||||
|
||||
Like Claude Code, `ExitPlanMode` offers one elevated keep-context choice by priority: `auto`, then
|
||||
`bypassPermissions`, then `acceptEdits`. A non-empty plan also gets exactly one clear-context choice
|
||||
with the same priority. This requires no ACP capability or setting: after selection the adapter
|
||||
interrupts the old private Claude query, hides that internal rejection from the client, creates a
|
||||
new private query under the same public ACP session, and continues the pending turn with
|
||||
`Implement the following plan:` followed by the complete plan. Mode, model, agent, effort, Fast
|
||||
mode, and accumulated turn usage are preserved.
|
||||
|
||||
The SDK cannot reapply `allowedPrompts` through a public control request, so those renderer hints do
|
||||
not survive this internal handoff. Ultraplan remains unavailable because its required renderer state
|
||||
is not exposed by the SDK.
|
||||
|
||||
The adapter does not auto-allow a callback merely because the session advertises
|
||||
`bypassPermissions`. Claude Code applies bypass before invoking `canUseTool`. A request that still
|
||||
reaches the callback is bypass-immune, safety-sensitive, interactive, or forced by an explicit ask
|
||||
rule and must remain visible to the user.
|
||||
|
||||
## Cancellation and failures
|
||||
|
||||
The SDK tool-call `AbortSignal` is forwarded to the ACP client. The adapter also races the client
|
||||
request locally against the same signal, so a client that ignores cancellation cannot leave Claude's
|
||||
tool call waiting forever.
|
||||
|
||||
These cases all abort the tool use with `Tool use aborted`:
|
||||
|
||||
- the signal was already aborted;
|
||||
- the signal aborts while the permission request is open;
|
||||
- the client returns a cancelled outcome;
|
||||
- the client rejects after cancellation.
|
||||
|
||||
A normal selected reject returns `behavior: "deny"`, `decisionClassification: "user_reject"`, and
|
||||
the default refusal message.
|
||||
|
||||
## Client requirements
|
||||
|
||||
A client implementing version 1 should:
|
||||
|
||||
- render standard ACP tool-call content and locations as the action subject;
|
||||
- treat request `_meta.permission.title` and `description` as optional presentation hints;
|
||||
- preserve option ids;
|
||||
- ignore unknown `_meta` fields and future feature names;
|
||||
- settle the request when its cancellation signal fires.
|
||||
|
||||
The client must not infer storage scope, lifetime, or permission effects from `kind` or button text.
|
||||
The adapter owns the Claude `PermissionUpdate` and applies it only after validating the response.
|
||||
|
||||
## Current limits
|
||||
|
||||
The SDK does not expose enough renderer state to reproduce every Claude Code dialog. Version 1 does
|
||||
not implement:
|
||||
|
||||
- Bash output-redirection rewriting;
|
||||
- classifier-specific prompt state;
|
||||
- ExitPlanMode Ultraplan actions;
|
||||
- Computer Use macOS/TCC or application-allowlist dialogs;
|
||||
- structured permission lifetime or storage metadata for clients.
|
||||
|
||||
These omissions do not weaken the one-time choice. When a durable effect cannot be represented
|
||||
honestly, the adapter sends only one-time allow and reject.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives in:
|
||||
|
||||
- `src/tests/permission-presentation.test.ts` for capability and request presentation;
|
||||
- `src/tests/permission-options.test.ts` for tool-specific option construction;
|
||||
- `src/tests/permission-effects.test.ts` for response validation and effects;
|
||||
- `src/tests/acp-agent.test.ts` for request ordering, cancellation, bypass, and subagent attribution;
|
||||
- `src/tests/session-config-options.test.ts` for plan-mode integration;
|
||||
- `src/tests/live-capability-*-audit.test.ts` for opt-in live SDK audits.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
@@ -1,118 +0,0 @@
|
||||
# Recommended config values extension
|
||||
|
||||
This document defines the experimental AIR `recommendedValue` session configuration extension
|
||||
implemented by `claude-agent-acp`. It lets a client show concrete model and effort choices without
|
||||
an ambiguous `default` row while preserving legacy config options for every client that does not
|
||||
opt in.
|
||||
|
||||
Permission modes and all other session config options are outside this extension and remain
|
||||
unchanged.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
A client opts in through the common AIR capability list in `initialize`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"capabilities": ["recommendedValue"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter enables the extension only when the AIR version is a finite integer greater than or
|
||||
equal to `1` and `capabilities` contains the singular `recommendedValue` capability. Otherwise it
|
||||
preserves the existing `default` rows, current values, and metadata exactly. The adapter advertises
|
||||
the same capability in its top-level initialize-response `_meta.jetbrains.air.capabilities` list.
|
||||
|
||||
## Config option metadata
|
||||
|
||||
For each selector transformed by the extension, the adapter writes the concrete recommendation
|
||||
through the common AIR metadata envelope at `_meta.jetbrains.air.recommendedValue`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "model",
|
||||
"type": "select",
|
||||
"currentValue": "sonnet",
|
||||
"options": [
|
||||
{ "value": "opus", "name": "Claude Opus" },
|
||||
{ "value": "sonnet", "name": "Claude Sonnet" },
|
||||
{ "value": "haiku", "name": "Claude Haiku" }
|
||||
],
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"recommendedValue": "sonnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`recommendedValue` always names one of that selector's advertised option values. It is independent
|
||||
from `currentValue`: a user's explicit selection remains current even when the SDK recommends a
|
||||
different value.
|
||||
|
||||
## Model behavior
|
||||
|
||||
The SDK's `default` model entry may carry a `resolvedModel` identifying the model currently
|
||||
recommended by Claude. The adapter matches that identifier exactly to a selectable named model
|
||||
(accepting equivalent `-1m` and `[1m]` suffix spellings),
|
||||
removes the `default` row, and advertises the named value in
|
||||
`_meta.jetbrains.air.recommendedValue`. When the session itself is still using the SDK default, the
|
||||
same concrete value is presented as `currentValue`.
|
||||
|
||||
If the SDK recommendation cannot be mapped to an advertised named model, the adapter retains the
|
||||
legacy `default` row for that selector and omits `recommendedValue`. This prevents a client from
|
||||
receiving a recommendation or current value it cannot select.
|
||||
|
||||
Terse SDK labels that match their underlying model family include the concrete version derived
|
||||
from model metadata: for example, `Sonnet` becomes `Sonnet 5`, `Fable` becomes `Fable 5.1`, and
|
||||
`Claude Haiku` becomes `Claude Haiku 4.5`.
|
||||
Context suffixes such as `Opus (1M context)` are omitted from the label because the context remains
|
||||
in the description. Custom labels are preserved, and normalization falls back to the original
|
||||
labels if two selectable entries would otherwise collide. This presentation also applies to
|
||||
legacy clients.
|
||||
|
||||
## Effort behavior
|
||||
|
||||
When the current model supports effort selection, the adapter removes the `default` effort row and
|
||||
advertises `medium` as the recommendation. An existing explicit or settings-derived effort remains
|
||||
the `currentValue`; an absent or legacy `default` current effort is presented as `medium`.
|
||||
|
||||
The adapter applies the displayed effort to the SDK on session creation and model switches, so
|
||||
the concrete selection reflects the effort actually used. Explicit SDK `options.effort` and ACP
|
||||
picker choices remain pinned across model switches while supported. Otherwise each switch
|
||||
re-reads the new model's settings before falling back to the recommendation. Switching to a
|
||||
model without effort support clears the flag override. Legacy clients continue to let the SDK
|
||||
resolve automatic effort.
|
||||
|
||||
If effort synchronization fails after the model has already switched, the adapter still reports
|
||||
the new model but never presents the unapplied effort as current. It retains the last successfully
|
||||
applied value when that value is selectable for the new model; otherwise it temporarily omits the
|
||||
effort selector until a later successful switch can rebuild truthful state.
|
||||
|
||||
If a future model exposes effort choices without `medium`, the first SDK-advertised effort level is
|
||||
used so both `recommendedValue` and `currentValue` remain valid option values.
|
||||
|
||||
## SDK upgrade check
|
||||
|
||||
The ordinary test suite pins the SDK version whose Opus label was verified. When updating the
|
||||
SDK, first run the live contract test in an authenticated environment:
|
||||
|
||||
```sh
|
||||
RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts
|
||||
```
|
||||
|
||||
Review any changes to the available Opus entries and their labels before updating the version
|
||||
guard and, if necessary, the normalization. The live test only initializes the SDK; it sends no
|
||||
model prompt.
|
||||
@@ -1,214 +0,0 @@
|
||||
# Session failure extension
|
||||
|
||||
This document defines the provider-neutral experimental `sessionFailure` extension implemented by
|
||||
`claude-agent-acp`. Warnings and errors are durable transcript entries shown in order beside user,
|
||||
agent, and tool messages. They are not assistant-authored text and not ephemeral chat banners.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
The extension is opt-in. A client advertises it in `initialize`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"capabilities": ["sessionFailure"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter enables the extension only when `version` is a finite integer greater than or equal to
|
||||
`1` and `capabilities` contains `sessionFailure`. Otherwise it preserves the legacy ACP behavior.
|
||||
|
||||
## Wire record
|
||||
|
||||
The record is placed under `_meta.jetbrains.air.sessionFailure`:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"sessionFailure": {
|
||||
"id": "prompt-uuid:error",
|
||||
"revision": 1,
|
||||
"category": "limit",
|
||||
"severity": "error",
|
||||
"title": "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
|
||||
"actions": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Type | Meaning |
|
||||
| ---------- | -------: | -------------------- | ----------------------------------------------------------------------- |
|
||||
| `id` | yes | non-empty string | Stable identity of one logical incident. |
|
||||
| `revision` | yes | positive integer | Monotonically increasing version of that incident. |
|
||||
| `category` | yes | category enum | Broad machine-readable visual group. |
|
||||
| `severity` | yes | `warning` or `error` | Inline warning or error presentation. |
|
||||
| `title` | yes | string | Complete normal user-facing presentation. |
|
||||
| `details` | no | string | Long explanation used only when required text is too large for `title`. |
|
||||
| `actions` | yes | ordered string array | Recovery actions recommended by the adapter. |
|
||||
|
||||
There are no `phase`, `source`, `safeMessage`, `retryable`, `retryAfterMs`, `turnId`, retry-counter,
|
||||
or provider-code fields. Retry progress belongs in `title` when Claude supplies it. Transcript order
|
||||
and persistence time come from the receiving ACP event.
|
||||
|
||||
## Identity, revisions, and history
|
||||
|
||||
`id` identifies one occurrence, not an error type.
|
||||
|
||||
- The first record for an incident creates one transcript entry at the current stream position.
|
||||
- The same `id` with a higher `revision` updates that entry in place without moving it.
|
||||
- The same or a lower revision is idempotently ignored by the client.
|
||||
- A later independent occurrence uses a new `id`, even when its category and text are identical.
|
||||
- Consecutive updates for the same notice may reuse one id and increment its revision.
|
||||
- Different ids are never deduplicated by comparing title or category.
|
||||
|
||||
Live turn failures use `<turnId>:error`. Session-scoped incidents use an adapter-session epoch plus an
|
||||
incident sequence. Notices have their own `:notice:` namespace. Replayed usage-limit failures recover
|
||||
the persisted user-message UUID used as the live turn id, so the same occurrence keeps the same id.
|
||||
Malformed history without a preceding user message falls back to
|
||||
`<sessionId>:history-error:<messageUuid>`.
|
||||
|
||||
Resolved records remain in transcript history. Recovery removes only the adapter's internal active
|
||||
state; it does not publish a clear tombstone and does not delete or rewrite the historical entry. A
|
||||
producer may publish a higher revision only when it has a real new user-facing status to show.
|
||||
|
||||
## Delivery surfaces
|
||||
|
||||
A turn-terminal failure is attached to the successful ACP `PromptResponse` in `_meta`; the response
|
||||
uses `stopReason: end_turn`. Session-scoped, replay-restored, warning, and background incidents use a
|
||||
`session_info_update`. The schema is identical on both carriers.
|
||||
|
||||
A warning does not end a turn. An error record does not independently invent ACP lifecycle state;
|
||||
the prompt response or transport lifecycle remains authoritative. If no capable client negotiated
|
||||
the extension, existing JSON-RPC errors and transcript text remain unchanged.
|
||||
|
||||
## Categories
|
||||
|
||||
Categories are deliberately broad and drive only iconography and generic accessibility labels.
|
||||
|
||||
| Category | Covers |
|
||||
| ------------ | --------------------------------------------------------------------- |
|
||||
| `connection` | Lost transport, stopped or shutting-down worker, unavailable runtime. |
|
||||
| `access` | Authentication required, rejected credentials, denied access. |
|
||||
| `limit` | Rate, quota, token, context, turn, or configured budget limit. |
|
||||
| `request` | Invalid input, unsupported model or operation, rejected request. |
|
||||
| `service` | Provider overload, provider failure, adapter/internal failure. |
|
||||
| `unknown` | A warning or failure that does not fit another group. |
|
||||
|
||||
Claude SDK conditions map to these groups:
|
||||
|
||||
| Claude condition | Category |
|
||||
| ---------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `authentication_failed`, `oauth_org_not_allowed`, synthetic login message | `access` |
|
||||
| `verification_required`, `cloud_credential_error` | `access` (no `login` action) |
|
||||
| `billing_error`, `rate_limit`, `max_output_tokens`, usage/spend limit, budget/turn limit | `limit` |
|
||||
| `invalid_request`, `model_not_found` | `request` |
|
||||
| `overloaded`, `server_error`, unknown provider error, adapter internal error | `service` |
|
||||
| API retry without an HTTP response, query transport loss, worker shutdown | `connection` |
|
||||
| model fallback notice | `unknown` with `severity: warning` |
|
||||
|
||||
Unknown SDK error kinds degrade to `service`; they never become success. Category does not determine
|
||||
message text or client behavior beyond presentation.
|
||||
|
||||
The model fallback notice is the one advisory that also has a standard ACP home. When the client
|
||||
advertises `clientCapabilities.session.notices` (the Session Notices RFD), the adapter sends it as an
|
||||
ACP `notice` update instead and publishes no AIR advisory record, even if `sessionFailure` is also
|
||||
advertised.
|
||||
|
||||
## Severity
|
||||
|
||||
- `warning` means the operation may still succeed and normally has no actions while recovery is in
|
||||
progress. It does not terminate the turn. SDK `api_retry` progress reuses the active turn failure id;
|
||||
a later terminal failure updates that record with a higher revision.
|
||||
- `error` means the operation cannot continue without user action or another request.
|
||||
|
||||
Severity is always explicit.
|
||||
|
||||
## Title and details
|
||||
|
||||
`title` is the complete normal presentation: what happened, retry progress when present, and a short
|
||||
next step. The adapter must not replace a real Claude message with category-specific canned wording.
|
||||
|
||||
For Claude SDK failures the title is copied from the user-facing text Claude already emitted:
|
||||
|
||||
- top-level assistant error text, including synthetic usage-limit and login messages;
|
||||
- otherwise the terminal SDK result or error text;
|
||||
- the exact model-fallback notice for warning records.
|
||||
|
||||
Adapter-authored fallback text is allowed only when no Claude user-facing message exists, such as a
|
||||
query iterator throwing, worker-shutdown EOF, or an internal missing-result invariant. Raw thrown
|
||||
exceptions, stack traces, transport URLs, tokens, headers, environment values, and private paths are
|
||||
never used as titles.
|
||||
|
||||
`details` is reserved exclusively for required explanations too large to fit reasonably in `title`.
|
||||
If the complete message fits in `title`, `details` must be omitted. It is not a place for status text,
|
||||
retry counters, provider payloads, or diagnostics.
|
||||
|
||||
## Actions
|
||||
|
||||
Version 1 defines only:
|
||||
|
||||
| Action | Meaning |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `retry` | Retry the operation associated with this incident. |
|
||||
| `login` | Start authentication. |
|
||||
| `new_session` | Start a fresh agent session/runtime. |
|
||||
|
||||
The adapter supplies action ordering. The client filters actions it cannot safely execute and ignores
|
||||
unknown or duplicate values. The client must not infer actions from category.
|
||||
|
||||
Current Claude policies are:
|
||||
|
||||
- authentication: `login`;
|
||||
- provider overload/error and recoverable internal error: `retry`;
|
||||
- transport loss or worker shutdown: `new_session`;
|
||||
- context or configured session-budget exhaustion: `new_session`;
|
||||
- account quota and invalid request: no action.
|
||||
|
||||
## Internal recovery
|
||||
|
||||
Recovery policy is internal adapter state and is not serialized:
|
||||
|
||||
- restored quota remains active until a real model answer;
|
||||
- authentication remains active until successful `auth_status`;
|
||||
- transport loss and worker shutdown remain active until runtime replacement;
|
||||
- other turn-scoped failures stop being active at a later confirmed attempt boundary;
|
||||
- notices are not cleared merely by generic success.
|
||||
|
||||
Recovery never removes the transcript record. Publishing another incident is not evidence that an
|
||||
older one recovered.
|
||||
|
||||
## History replay
|
||||
|
||||
`session/load` scans top-level assistant history for SDK synthetic usage-limit messages. It recognizes
|
||||
only `<synthetic>` messages beginning with SDK-owned stable prefixes, not arbitrary model prose. Every
|
||||
recognized occurrence is replayed as a typed record at its original transcript position. The latest
|
||||
matching message stays active until a later real-model answer proves recovery; older and recovered
|
||||
records remain historical typed entries.
|
||||
|
||||
For a capable client, replay suppresses duplicate assistant prose and restores typed errors using the
|
||||
exact stored Claude text as `title`. Legacy clients receive the original transcript and no typed record.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives primarily in `src/tests/acp-agent.test.ts`, under `usage-limit failure
|
||||
replay`, `stop reason propagation`, and `model refusal fallback handling`.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
857
package-lock.json
generated
857
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.81.2",
|
||||
"version": "0.88.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,23 +65,26 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.280",
|
||||
"@agentclientprotocol/sdk": "1.7.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.127.0",
|
||||
"@anthropic-ai/sdk": "0.132.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.1",
|
||||
"@typescript-eslint/parser": "8.70.1",
|
||||
"eslint": "10.11.0",
|
||||
"@types/node": "26.6.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.1"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
3133
src/acp-agent.ts
3133
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,12 @@ export type SubagentSpawnedUpdate = {
|
||||
subagentSessionId: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* Adapter extension: the exact prompt of the subagent. A client can show it
|
||||
* as the first user message of the subagent session. It is absent when the
|
||||
* adapter does not know the prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
capabilities: SubagentSessionCapabilities;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,32 @@ export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
||||
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
||||
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
||||
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
||||
export const AIR_DIFF_STATS_KEY = "diffStats";
|
||||
export const AIR_DIFF_PATCH_CAPABILITY = "diffPatch";
|
||||
/** AIR renders `rawInput` itself and needs no display copy of the input. */
|
||||
export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering";
|
||||
/**
|
||||
* The plan of an ExitPlanMode is a file. `rawInput.planFilePath` names the
|
||||
* file, and AIR reads the plan from it.
|
||||
*/
|
||||
export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
||||
|
||||
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
|
||||
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
|
||||
export const AIR_SUBAGENT_KEY = "subagent";
|
||||
export const AIR_SKILL_KEY = "skill";
|
||||
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
|
||||
export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
/** The absolute path of the `SKILL.md` of a skill command in `available_commands_update`. */
|
||||
export const AIR_SKILL_PATH_KEY = "skillPath";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
@@ -42,6 +67,31 @@ export function withAirMeta(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client is JetBrains AIR: it declared `_meta.jetbrains.air` in
|
||||
* its capabilities.
|
||||
*
|
||||
* Only AIR gets the AIR extensions of `docs/air-extensions.md`. Every other
|
||||
* client, Zed too, gets the fields and the upstream `_meta` keys of the
|
||||
* upstream adapter, and no key that exists only for AIR.
|
||||
*/
|
||||
export function isAirClient(capabilities: unknown): boolean {
|
||||
return airExtensionMeta(asRecord(capabilities)._meta) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta` of an AIR client with one more AIR payload, or undefined for
|
||||
* every other client: a client that is not AIR gets no AIR key.
|
||||
*/
|
||||
export function airOnlyMeta(
|
||||
airClient: boolean,
|
||||
capability: string,
|
||||
payload: unknown,
|
||||
meta?: Record<string, unknown> | null,
|
||||
): Record<string, unknown> | undefined {
|
||||
return airClient ? withAirMeta(meta, capability, payload) : (meta ?? undefined);
|
||||
}
|
||||
|
||||
/** The `air` object inside a `_meta`, or undefined when the peer sent no AIR extension. */
|
||||
export function airExtensionMeta(meta: unknown): Record<string, unknown> | undefined {
|
||||
const air = asRecord(asRecord(meta)[JETBRAINS_META_KEY])[AIR_META_KEY];
|
||||
|
||||
@@ -4,6 +4,8 @@ import { AIR_ASYNC_TASKS_CAPABILITY, clientSupportsAirCapability } from "./air-e
|
||||
import { noticeOrTranscriptUpdate } from "./session-notices.js";
|
||||
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
/** Moves an update to its owner session, or drops it with `null`. */
|
||||
type Route = (notification: AcpSessionNotification) => AcpSessionNotification | null;
|
||||
type TerminalSource = "event" | "level" | "shutdown";
|
||||
|
||||
type AsyncTask = {
|
||||
@@ -14,7 +16,19 @@ type AsyncTask = {
|
||||
showInTranscript: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
/**
|
||||
* The route to the session of the tool call that started the task. The
|
||||
* spawn sets it, so every update of the task goes to one session. A task
|
||||
* without a route stays in the root session.
|
||||
*/
|
||||
route?: Route;
|
||||
announced: boolean;
|
||||
/**
|
||||
* Whether the spawn waits for the tool call id. The updates of a held task
|
||||
* wait in {@link AsyncTask.deferred} and follow the spawn in order.
|
||||
*/
|
||||
held: boolean;
|
||||
deferred: (() => Promise<void>)[];
|
||||
ignored: boolean;
|
||||
startedObserved: boolean;
|
||||
panelOnlyRecovery: boolean;
|
||||
@@ -23,10 +37,14 @@ type AsyncTask = {
|
||||
state: AsyncTaskState;
|
||||
terminalSummary?: string;
|
||||
terminalSource?: TerminalSource;
|
||||
/** The optional fields that the client holds now, as JSON. */
|
||||
published: Map<string, string>;
|
||||
};
|
||||
|
||||
type TaskIdentity = { taskId?: unknown; task_id?: unknown };
|
||||
|
||||
const MAX_TOOL_RESULT_PATHS = 500;
|
||||
|
||||
export type AsyncTaskStarted = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
@@ -59,6 +77,8 @@ type TaskPatch = {
|
||||
|
||||
export type AsyncTaskNotification = TaskIdentity & {
|
||||
status?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
@@ -66,6 +86,8 @@ export type AsyncTaskNotification = TaskIdentity & {
|
||||
|
||||
type TaskProgress = TaskIdentity & {
|
||||
description?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
lastToolName?: unknown;
|
||||
last_tool_name?: unknown;
|
||||
@@ -92,14 +114,29 @@ export class AsyncTaskRuntime {
|
||||
* a command was backgrounded can arrive after its terminal SDK edge.
|
||||
*/
|
||||
private readonly tasks = new Map<string, AsyncTask>();
|
||||
/**
|
||||
* The task output paths in the text of each tool result, by tool call
|
||||
* id. A task whose id and tool call are known takes its output path from
|
||||
* here. The tool result can come before or after the SDK names the task.
|
||||
* The oldest entries are dropped.
|
||||
*/
|
||||
private readonly outputPathsByToolCall = new Map<string, string[]>();
|
||||
|
||||
constructor(
|
||||
readonly enabled: boolean,
|
||||
private readonly sessionId: string,
|
||||
private readonly publish: Publish,
|
||||
/** `notices`: the client can present `notice` updates, so the stop
|
||||
* acknowledgement need not be a transcript line. */
|
||||
private readonly options: { notices?: boolean } = {},
|
||||
* acknowledgement need not be a transcript line. `routeOf`: the route of
|
||||
* 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. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
toolNameOf?: (toolCallId: string) => string | undefined;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async taskStarted(message: AsyncTaskStarted): Promise<void> {
|
||||
@@ -107,8 +144,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,7 +157,8 @@ export class AsyncTaskRuntime {
|
||||
isBackgroundTask(
|
||||
field(message, "isBackgrounded", "is_backgrounded"),
|
||||
field(message, "taskType", "task_type"),
|
||||
)
|
||||
) ||
|
||||
task.held
|
||||
) {
|
||||
await this.announce(task);
|
||||
}
|
||||
@@ -137,8 +175,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,7 +225,8 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
if (task.state !== state) {
|
||||
task.state = state;
|
||||
if (task.announced) await this.publishState(task, state, nonBlankString(patch.error));
|
||||
const error = nonBlankString(patch.error);
|
||||
await this.whenAnnounced(task, () => this.publishState(task, state, error));
|
||||
} else if (task.announced && task.outputFilePath !== previousOutputFilePath) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
@@ -202,10 +241,12 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task || !task.announced || task.ignored) return;
|
||||
if (!task || task.ignored || !(task.announced || task.held)) return;
|
||||
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
const previousToolCallId = task.toolCallId;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
this.mergeToolCallId(task, message);
|
||||
const outputChanged = task.outputFilePath !== previousOutputFilePath;
|
||||
if (isTerminal(task.state)) {
|
||||
if (outputChanged) await this.publishMetadata(task);
|
||||
@@ -216,13 +257,20 @@ export class AsyncTaskRuntime {
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
await this.publishProgress(task, {
|
||||
const progress = {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
});
|
||||
...(task.toolCallId !== previousToolCallId && task.toolCallId
|
||||
? { toolCallId: task.toolCallId }
|
||||
: {}),
|
||||
};
|
||||
await this.whenAnnounced(task, () => this.publishProgress(task, progress));
|
||||
// The progress brought the tool call id of a held task: send the spawn,
|
||||
// then the progress beats that waited for it.
|
||||
if (task.held && task.toolCallId) await this.announce(task);
|
||||
}
|
||||
|
||||
async taskNotification(message: AsyncTaskNotification): Promise<void>;
|
||||
@@ -257,6 +305,7 @@ export class AsyncTaskRuntime {
|
||||
const correctsLevelState = wasTerminal && task.terminalSource === "level";
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
this.mergeToolCallId(task, message);
|
||||
await this.finish(task, state, nonBlankString(message.summary), "event");
|
||||
if (
|
||||
wasTerminal &&
|
||||
@@ -293,8 +342,9 @@ export class AsyncTaskRuntime {
|
||||
live.add(taskId);
|
||||
const task = this.task(taskId);
|
||||
if (task.ignored || isTerminal(task.state)) continue;
|
||||
if (field(item, "taskType", "task_type") === "local_agent") {
|
||||
task.ignored = true;
|
||||
const levelTaskType = field(item, "taskType", "task_type");
|
||||
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
|
||||
this.ignore(task);
|
||||
continue;
|
||||
}
|
||||
if (!task.startedObserved) {
|
||||
@@ -310,11 +360,18 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state) && !live.has(task.id)) {
|
||||
if (
|
||||
(task.announced || task.held) &&
|
||||
!task.ignored &&
|
||||
!isTerminal(task.state) &&
|
||||
!live.has(task.id)
|
||||
) {
|
||||
// This replace-semantics level is itself the authoritative liveness
|
||||
// boundary. Close immediately so a lost terminal edge cannot leave a
|
||||
// permanent running card; a following task_notification may correct
|
||||
// the best-effort stopped state to completed/failed.
|
||||
// permanent running card. Since SDK 0.3.292 a finishing task's level
|
||||
// follows its terminal edges, so this is a lost or late edge; a late
|
||||
// task_notification still corrects the best-effort stopped state to
|
||||
// completed/failed.
|
||||
await this.finish(task, "stopped", undefined, "level");
|
||||
}
|
||||
}
|
||||
@@ -323,7 +380,7 @@ export class AsyncTaskRuntime {
|
||||
async finishAll(state: Extract<AsyncTaskState, "failed" | "stopped">): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state)) {
|
||||
if ((task.announced || task.held) && !task.ignored && !isTerminal(task.state)) {
|
||||
try {
|
||||
await this.finish(task, state, undefined, "shutdown");
|
||||
} catch (error) {
|
||||
@@ -372,17 +429,76 @@ export class AsyncTaskRuntime {
|
||||
// `showInTranscript` does not gate it either: that flag decides whether the
|
||||
// task owns a transcript *card* (a background Bash task is already drawn as
|
||||
// its tool call), not whether the agent may answer a direct user action.
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: noticeOrTranscriptUpdate(
|
||||
await this.send(
|
||||
task,
|
||||
noticeOrTranscriptUpdate(
|
||||
{ severity: "info", title: "Task stopped by user", description: `${task.name}.` },
|
||||
this.options.notices ?? false,
|
||||
),
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a task of the tool call went to the background, so the client gets it. */
|
||||
isBackgroundedToolCall(toolCallId: string): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId === toolCallId && !task.ignored && (task.announced || task.held)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the tool results in `content` for the output path of a known task.
|
||||
* Only a path that ends with `tasks/<task id>.output` in the result of
|
||||
* the task's own tool call counts. A tool result never creates a task.
|
||||
*/
|
||||
async toolResults(content: unknown): Promise<void> {
|
||||
if (!this.enabled || !Array.isArray(content)) return;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const text = textContent(result.content);
|
||||
const paths = text ? taskOutputPaths(text) : [];
|
||||
if (paths.length === 0) continue;
|
||||
this.outputPathsByToolCall.delete(result.toolUseId);
|
||||
this.outputPathsByToolCall.set(result.toolUseId, paths);
|
||||
if (this.outputPathsByToolCall.size > MAX_TOOL_RESULT_PATHS) {
|
||||
const oldest = this.outputPathsByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.outputPathsByToolCall.delete(oldest);
|
||||
}
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId !== result.toolUseId || task.ignored || task.outputFilePath) continue;
|
||||
this.mergeToolResultPath(task);
|
||||
if (task.outputFilePath && task.announced) await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the spawn of each held task without a tool call id. The prompt
|
||||
* result ends the model turn, and the result of the tool call that started a
|
||||
* task comes before it. So a held task is never held for longer than one turn.
|
||||
*/
|
||||
async releaseHeld(): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.held) await this.announce(task, { withoutToolCall: true });
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tasks.clear();
|
||||
this.outputPathsByToolCall.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task that the client never sees. Only a task that is not announced
|
||||
* can become ignored, so the client never holds a task without its end.
|
||||
*/
|
||||
private ignore(task: AsyncTask): void {
|
||||
if (task.announced) return;
|
||||
task.ignored = true;
|
||||
task.held = false;
|
||||
task.deferred = [];
|
||||
}
|
||||
|
||||
private task(taskId: string): AsyncTask {
|
||||
@@ -395,12 +511,15 @@ export class AsyncTaskRuntime {
|
||||
description: "Background task",
|
||||
showInTranscript: true,
|
||||
announced: false,
|
||||
held: false,
|
||||
deferred: [],
|
||||
ignored: false,
|
||||
startedObserved: false,
|
||||
panelOnlyRecovery: false,
|
||||
stopping: false,
|
||||
stopAnnounced: false,
|
||||
state: "running",
|
||||
published: new Map(),
|
||||
};
|
||||
this.tasks.set(taskId, task);
|
||||
return task;
|
||||
@@ -427,6 +546,32 @@ export class AsyncTaskRuntime {
|
||||
this.mergeOutputFilePath(task, message);
|
||||
const toolCallId = nonBlankString(field(message, "toolCallId", "tool_use_id"));
|
||||
if (toolCallId) task.toolCallId = toolCallId;
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/** Keeps the first tool call id that a later SDK message brings. */
|
||||
private mergeToolCallId(
|
||||
task: AsyncTask,
|
||||
value: { toolCallId?: unknown; tool_use_id?: unknown },
|
||||
): void {
|
||||
task.toolCallId ??= nonBlankString(field(value, "toolCallId", "tool_use_id"));
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the output path from the result of the task's tool call when no
|
||||
* structured source gave one. A structured path later replaces it.
|
||||
*/
|
||||
private mergeToolResultPath(task: AsyncTask): void {
|
||||
if (task.outputFilePath || !task.toolCallId) return;
|
||||
const paths = this.outputPathsByToolCall.get(task.toolCallId);
|
||||
if (paths) task.outputFilePath = outputFilePathOf(paths, task.id);
|
||||
}
|
||||
|
||||
/** Sends an update of an announced task. A held task sends it after its spawn. */
|
||||
private async whenAnnounced(task: AsyncTask, send: () => Promise<void>): Promise<void> {
|
||||
if (task.announced) await send();
|
||||
else if (task.held) task.deferred.push(send);
|
||||
}
|
||||
|
||||
private mergePatch(task: AsyncTask, patch: TaskPatch): void {
|
||||
@@ -456,23 +601,54 @@ export class AsyncTaskRuntime {
|
||||
if (outputFilePath) task.outputFilePath = outputFilePath;
|
||||
}
|
||||
|
||||
private async announce(task: AsyncTask): Promise<void> {
|
||||
/**
|
||||
* Sends the spawn of the task. The spawn names the tool call that started the
|
||||
* task, so it waits for the tool call id: the task is held until the id
|
||||
* arrives. The id comes as `tool_use_id` of the SDK `task_started`,
|
||||
* `task_progress`, or `task_notification`, or from the Bash result of a
|
||||
* backgrounded command. A held task gets its spawn without the id when it
|
||||
* 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.
|
||||
*/
|
||||
private async announce(
|
||||
task: AsyncTask,
|
||||
options: { withoutToolCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (task.announced || task.ignored) return;
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: task.id,
|
||||
name: task.name,
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
},
|
||||
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) task.route ??= this.options.routeOf?.(task.toolCallId);
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: task.id,
|
||||
name: task.name,
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
});
|
||||
task.announced = true;
|
||||
task.held = false;
|
||||
recordPublished(task, {
|
||||
description: task.description,
|
||||
outputFilePath: task.outputFilePath,
|
||||
toolCallId: task.toolCallId,
|
||||
});
|
||||
for (const send of task.deferred.splice(0)) await send();
|
||||
if (isTerminal(task.state)) {
|
||||
await this.publishState(task, task.state, task.terminalSummary);
|
||||
}
|
||||
@@ -486,8 +662,9 @@ export class AsyncTaskRuntime {
|
||||
): Promise<void> {
|
||||
if (task.ignored) return;
|
||||
if (isTerminal(task.state)) {
|
||||
// A level event may precede the authoritative terminal edge. Correct its
|
||||
// best-effort stopped state if that edge later arrives.
|
||||
// A level event can precede a late terminal edge (older CLIs sent the
|
||||
// level first). Correct its best-effort stopped state if that edge
|
||||
// later arrives.
|
||||
if (task.terminalSource !== "level" || source !== "event") return;
|
||||
}
|
||||
const previous = {
|
||||
@@ -500,6 +677,9 @@ export class AsyncTaskRuntime {
|
||||
task.terminalSource = source;
|
||||
try {
|
||||
if (task.announced) await this.publishState(task, state, summary);
|
||||
// The task ended before its tool call id arrived. The spawn goes out now
|
||||
// without the id, so the task still appears.
|
||||
else if (task.held) await this.announce(task);
|
||||
} catch (error) {
|
||||
task.state = previous.state;
|
||||
task.terminalSummary = previous.terminalSummary;
|
||||
@@ -521,6 +701,10 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the progress fields that changed since the last report of the
|
||||
* task. A beat with nothing new is not sent.
|
||||
*/
|
||||
private async publishProgress(
|
||||
task: AsyncTask,
|
||||
update: {
|
||||
@@ -532,36 +716,72 @@ export class AsyncTaskRuntime {
|
||||
toolCallId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...update,
|
||||
},
|
||||
const changed = changedFields(task, update);
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...changed,
|
||||
});
|
||||
recordPublished(task, changed);
|
||||
}
|
||||
|
||||
/** Publishes a state, with the output path and the tool call only when they changed. */
|
||||
private async publishState(
|
||||
task: AsyncTask,
|
||||
state: AsyncTaskState,
|
||||
summary?: string,
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: task.id,
|
||||
state,
|
||||
...(summary ? { summary } : {}),
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
},
|
||||
const changed = changedFields(task, {
|
||||
outputFilePath: task.outputFilePath,
|
||||
toolCallId: task.toolCallId,
|
||||
});
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: task.id,
|
||||
state,
|
||||
...(summary ? { summary } : {}),
|
||||
...changed,
|
||||
});
|
||||
recordPublished(task, changed);
|
||||
}
|
||||
|
||||
/** Publishes an update of the task in the session that owns the task. */
|
||||
private async send(task: AsyncTask, update: AcpSessionNotification["update"]): Promise<void> {
|
||||
const notification: AcpSessionNotification = { sessionId: this.sessionId, update };
|
||||
const routed = task.route ? task.route(notification) : notification;
|
||||
if (routed) await this.publish(routed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovers background Bash lifecycle data exposed only on its tool result. */
|
||||
/**
|
||||
* The fields whose value differs from what the client holds. An undefined
|
||||
* value is not a field. The caller records the fields with
|
||||
* {@link recordPublished} after the send succeeds, so a retry after a failed
|
||||
* send carries them again.
|
||||
*/
|
||||
function changedFields<T extends Record<string, unknown>>(task: AsyncTask, fields: T): Partial<T> {
|
||||
const changed: Partial<T> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === undefined) continue;
|
||||
if (task.published.get(key) === JSON.stringify(value)) continue;
|
||||
(changed as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Records the fields that the client holds now. An undefined value is not a field. */
|
||||
function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value !== undefined) task.published.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers a background Bash task from the structured `backgroundTaskId` of
|
||||
* its tool result. The text of a tool result never gives a task id: a
|
||||
* foreground command can print any text.
|
||||
*/
|
||||
export function backgroundBashTaskFromToolResult(
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
@@ -573,12 +793,7 @@ export function backgroundBashTaskFromToolResult(
|
||||
if (bashResults.length === 0) return undefined;
|
||||
|
||||
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
|
||||
const distinctTaskIds = new Set([
|
||||
...backgroundResults.map((result) => result.taskId),
|
||||
...bashResults
|
||||
.map((result) => backgroundTaskIdFromText(result.content))
|
||||
.filter((taskId): taskId is string => taskId !== undefined),
|
||||
]);
|
||||
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
|
||||
if (distinctTaskIds.size !== 1) return undefined;
|
||||
const taskId = [...distinctTaskIds][0];
|
||||
if (!taskId) return undefined;
|
||||
@@ -612,15 +827,36 @@ export function backgroundBashTaskFromToolResult(
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundTaskIdFromText(content: unknown): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Command running in background with ID: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(".", valueStart);
|
||||
return valueEnd < 0 ? undefined : nonBlankString(text.slice(valueStart, valueEnd));
|
||||
/**
|
||||
* The Bash tool calls in `content` whose command went to the background. Only
|
||||
* structured data counts: the task from the structured tool result, a task
|
||||
* that the runtime knows for the tool call, or a `run_in_background` input of
|
||||
* a call that did not fail.
|
||||
*/
|
||||
export function backgroundedBashToolCallIds(
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
runtime: AsyncTaskRuntime,
|
||||
structuredTask?: AsyncTaskStarted,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!runtime.enabled || !Array.isArray(content)) return ids;
|
||||
const structuredToolCallId = structuredTask
|
||||
? nonBlankString(field(structuredTask, "toolCallId", "tool_use_id"))
|
||||
: undefined;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const toolUse = toolUses[result.toolUseId];
|
||||
if (toolUse?.name !== "Bash") continue;
|
||||
const input = isRecord(toolUse.input) ? toolUse.input : undefined;
|
||||
if (
|
||||
result.toolUseId === structuredToolCallId ||
|
||||
runtime.isBackgroundedToolCall(result.toolUseId) ||
|
||||
(input?.run_in_background === true && !result.isError)
|
||||
) {
|
||||
ids.add(result.toolUseId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function structuredBackgroundResults(
|
||||
@@ -652,27 +888,73 @@ function structuredBackgroundResults(
|
||||
return results;
|
||||
}
|
||||
|
||||
function toolResultBlock(value: unknown): { toolUseId: string; content?: unknown }[] {
|
||||
function toolResultBlock(
|
||||
value: unknown,
|
||||
): { toolUseId: string; content?: unknown; isError: boolean }[] {
|
||||
if (!isRecord(value) || value.type !== "tool_result") return [];
|
||||
const toolUseId = nonBlankString(field(value, "toolUseId", "tool_use_id"));
|
||||
return toolUseId ? [{ toolUseId, content: value.content }] : [];
|
||||
return toolUseId
|
||||
? [
|
||||
{
|
||||
toolUseId,
|
||||
content: value.content,
|
||||
isError: field(value, "isError", "is_error") === true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function asyncTaskOutputFilePath(content: unknown, taskId: string): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Output is being written to: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(". You will be notified", valueStart);
|
||||
if (valueEnd < 0) return undefined;
|
||||
const path = text.slice(valueStart, valueEnd).trim();
|
||||
const expectedPosixSuffix = `/tasks/${taskId}.output`;
|
||||
const expectedWindowsSuffix = `\\tasks\\${taskId}.output`;
|
||||
return path.endsWith(expectedPosixSuffix) || path.endsWith(expectedWindowsSuffix)
|
||||
? path
|
||||
: undefined;
|
||||
return text ? outputFilePathOf(taskOutputPaths(text), taskId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a task output path: `tasks/<id>.output`, followed by a sentence
|
||||
* period, whitespace or the end of the text. Claude Code writes the output of
|
||||
* a background task there.
|
||||
*/
|
||||
const TASK_OUTPUT_SUFFIX = /[/\\]tasks[/\\][^\s/\\]+?\.output(?=\.(?!\w)|\s|$)/g;
|
||||
/** An absolute POSIX, Windows drive or UNC path. */
|
||||
const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
|
||||
|
||||
/**
|
||||
* The task output paths in a tool result text. Each text of Claude Code puts
|
||||
* the path after "Output is being written to: ", so a path starts after the
|
||||
* nearest `": "` before its end. A drive colon has no space after it, so a
|
||||
* path can hold spaces and a drive. The search for the start stops at a line
|
||||
* break and at the end of the previous path end, so a path never crosses a
|
||||
* line or holds another path. Each character is read at most once, so the
|
||||
* parse is linear in the text length. A path that is not absolute does not
|
||||
* count.
|
||||
*/
|
||||
function taskOutputPaths(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
let previousEnd = 0;
|
||||
for (const match of text.matchAll(TASK_OUTPUT_SUFFIX)) {
|
||||
const end = match.index + match[0].length;
|
||||
let start = -1;
|
||||
for (let i = match.index - 1; i >= previousEnd; i--) {
|
||||
const char = text[i];
|
||||
if (char === "\n" || char === "\r") break;
|
||||
if (char === ":" && text[i + 1] === " ") {
|
||||
start = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
previousEnd = end;
|
||||
if (start < 0) continue;
|
||||
const path = text.slice(start, end);
|
||||
if (ABSOLUTE_PATH.test(path)) paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** The output path of the task `taskId` among the paths, if one names it. */
|
||||
function outputFilePathOf(paths: readonly string[], taskId: string): string | undefined {
|
||||
const posix = `/tasks/${taskId}.output`;
|
||||
const windows = `\\tasks\\${taskId}.output`;
|
||||
return paths.find((path) => path.endsWith(posix) || path.endsWith(windows));
|
||||
}
|
||||
|
||||
function textContent(value: unknown): string | undefined {
|
||||
@@ -703,6 +985,13 @@ 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 ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
@@ -37,6 +37,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
accumulatedModelUsage?: Record<string, Usage>;
|
||||
models: { currentModelId: string };
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
@@ -88,6 +89,7 @@ function restartParams<Session extends ClearContextSession>(session: Session): N
|
||||
...(session.models.currentModelId !== DEFAULT_MODEL_ID
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
|
||||
...(session.effortPinnedLevel !== undefined
|
||||
? { effort: session.effortPinnedLevel as EffortLevel }
|
||||
: {}),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CONTEXT_COMPACTION_META_KEY = "contextCompaction";
|
||||
import { AIR_CONTEXT_COMPACTION_KEY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const CONTEXT_COMPACTION_META_VERSION = 1;
|
||||
|
||||
export type ContextCompactionTrigger = "manual" | "automatic";
|
||||
@@ -13,17 +14,17 @@ export interface ContextCompactionMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral metadata for a synthetic ACP context-compaction tool call.
|
||||
* The standard toolCallId and status fields own lifecycle identity and phase;
|
||||
* this extension carries only compaction-specific facts.
|
||||
* The `_meta` of a context compaction report:
|
||||
* `_meta.jetbrains.air.contextCompaction`, as `docs/air-extensions.md`
|
||||
* defines. The standard toolCallId and status fields own lifecycle identity
|
||||
* and phase; this extension carries only compaction-specific facts. Only AIR
|
||||
* gets it.
|
||||
*/
|
||||
export function createContextCompactionMeta(
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
): Record<typeof CONTEXT_COMPACTION_META_KEY, ContextCompactionMetadata> {
|
||||
return {
|
||||
[CONTEXT_COMPACTION_META_KEY]: {
|
||||
version: CONTEXT_COMPACTION_META_VERSION,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AIR_CONTEXT_COMPACTION_KEY, {
|
||||
version: CONTEXT_COMPACTION_META_VERSION,
|
||||
...metadata,
|
||||
} satisfies ContextCompactionMetadata);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ContextCompactionMetadata,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
import { compactionToolCall } from "./tool-calls/reporters/compaction.js";
|
||||
|
||||
type CompactionStatus = "completed" | "failed";
|
||||
type TerminalStatus = CompactionStatus | "cancelled";
|
||||
@@ -23,6 +24,8 @@ type CompactionState = {
|
||||
compactionId: string;
|
||||
terminalStatus?: TerminalStatus;
|
||||
heartbeatSent: boolean;
|
||||
/** The summary text that went out as chunks. */
|
||||
streamedSummary?: string;
|
||||
/** User-displayable summary reported by the PostCompact hook. While the
|
||||
* compaction is in progress it awaits the terminal update; on a `completed`
|
||||
* state it has been sent. */
|
||||
@@ -37,6 +40,8 @@ export type ContextCompactionLifecycleOptions = {
|
||||
/** Receives failures of the send that must not propagate: the turn-boundary
|
||||
* `cancelled` terminal. */
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
/** The client is AIR. Only AIR gets the compaction facts, under `_meta.jetbrains.air`. */
|
||||
airClient?: boolean;
|
||||
};
|
||||
|
||||
export function clientSupportsCompactionUpdates(capabilities?: ClientCapabilities | null): boolean {
|
||||
@@ -151,6 +156,7 @@ export class ContextCompactionLifecycle {
|
||||
private readonly sessionId: string;
|
||||
readonly presentation: CompactionPresentation;
|
||||
private readonly logError: (message: string, error: unknown) => void;
|
||||
private readonly airClient: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly sendUpdate: SendUpdate,
|
||||
@@ -159,6 +165,7 @@ export class ContextCompactionLifecycle {
|
||||
this.sessionId = options.sessionId;
|
||||
this.presentation = options.presentation ?? "tool_call";
|
||||
this.logError = options.logError ?? (() => {});
|
||||
this.airClient = options.airClient ?? false;
|
||||
}
|
||||
|
||||
get hasDeliveredOutput(): boolean {
|
||||
@@ -240,18 +247,11 @@ export class ContextCompactionLifecycle {
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "in_progress",
|
||||
_meta: createContextCompactionMeta(),
|
||||
...(this.airClient ? { _meta: createContextCompactionMeta() } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: compactionId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
});
|
||||
await this.send(compactionToolCall.started(compactionId, this.airClient));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,6 +269,7 @@ export class ContextCompactionLifecycle {
|
||||
if (this.presentation === "compaction_update") {
|
||||
const state = this.activeCompaction;
|
||||
if (!state || state.terminalStatus || !summaryChunk) return;
|
||||
state.streamedSummary = (state.streamedSummary ?? "") + summaryChunk;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_summary_chunk",
|
||||
compactionId: state.compactionId,
|
||||
@@ -281,12 +282,7 @@ export class ContextCompactionLifecycle {
|
||||
const state = this.activeCompaction;
|
||||
if (!state || state.terminalStatus || state.heartbeatSent) return;
|
||||
state.heartbeatSent = true;
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.compactionId,
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
});
|
||||
await this.send(compactionToolCall.inProgress(state.compactionId, this.airClient));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +336,14 @@ export class ContextCompactionLifecycle {
|
||||
const hasMetadata = Object.keys(metadata).length > 0;
|
||||
|
||||
if (this.presentation === "compaction_update") {
|
||||
const summary = firstTerminal && terminalStatus === "completed" ? state.summary : undefined;
|
||||
// A summary that went out as chunks is not sent again in full. The
|
||||
// chunks are the raw API text and the summary is the cleaned hook text,
|
||||
// so the summary is left out only when both hold the same text.
|
||||
const summaryStreamed = state.streamedSummary === state.summary;
|
||||
const summary =
|
||||
firstTerminal && terminalStatus === "completed" && !summaryStreamed
|
||||
? state.summary
|
||||
: undefined;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: state.compactionId,
|
||||
@@ -350,37 +353,24 @@ export class ContextCompactionLifecycle {
|
||||
// `_meta` is a replace-patch: seed it with the first terminal, then
|
||||
// only re-send it when the boundary adds facts, so a status-only
|
||||
// duplicate can't wipe the token counts.
|
||||
...(firstTerminal || hasMetadata ? { _meta: createContextCompactionMeta(metadata) } : {}),
|
||||
// The standard `error` field carries the error, so `_meta` does not.
|
||||
// Only AIR gets the compaction facts.
|
||||
...(this.airClient && (firstTerminal || hasMetadata)
|
||||
? { _meta: createContextCompactionMeta(withoutError(metadata)) }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rawOutput = hasMetadata ? metadata : undefined;
|
||||
const errorContent =
|
||||
status === "failed" && metadata.error
|
||||
? { content: [compactionErrorContent(metadata.error)] }
|
||||
: {};
|
||||
if (opened) {
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: state.compactionId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status,
|
||||
...errorContent,
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.compactionId,
|
||||
...(firstTerminal ? { status } : {}),
|
||||
...errorContent,
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
});
|
||||
await this.send(
|
||||
compactionToolCall.finished(
|
||||
state.compactionId,
|
||||
opened || firstTerminal ? status : undefined,
|
||||
metadata,
|
||||
opened,
|
||||
this.airClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Make `compactionId` the active entity, moving a pending hook summary onto
|
||||
@@ -411,6 +401,13 @@ export class ContextCompactionLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
function withoutError({
|
||||
error: _error,
|
||||
...facts
|
||||
}: Omit<ContextCompactionMetadata, "version">): Omit<ContextCompactionMetadata, "version"> {
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
trigger: "manual" | "auto";
|
||||
pre_tokens: number;
|
||||
@@ -428,22 +425,3 @@ export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function compactionToolMeta(
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...createContextCompactionMeta(metadata),
|
||||
claudeCode: { toolName: "compact" },
|
||||
};
|
||||
}
|
||||
|
||||
function compactionErrorContent(error: string) {
|
||||
return {
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `Compaction failed: ${error}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
691
src/diff.ts
691
src/diff.ts
@@ -1,7 +1,45 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { AIR_DIFF_STATS_KEY, withAirMeta } from "./air-extension.js";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { structuredPatch } from "diff";
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
|
||||
|
||||
interface DiffToolResponseHunk {
|
||||
/**
|
||||
* The largest file, in bytes, that the adapter turns into a git patch.
|
||||
*
|
||||
* A larger file, a larger new text, or an Edit whose result can be larger
|
||||
* gets no patch. The tool call then keeps its standard ACP content. The limit
|
||||
* bounds the file read, the replacement, and the line diff that run while
|
||||
* Claude waits for an approval.
|
||||
*/
|
||||
export const MAX_PATCH_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The wall-clock budget, in milliseconds, of one line diff for a patch.
|
||||
*
|
||||
* The diff runs synchronously, so the budget is also the longest block of the
|
||||
* event loop.
|
||||
*/
|
||||
const PATCH_DIFF_TIMEOUT_MS = 100;
|
||||
|
||||
/** The number of context lines around a change in a hunk. */
|
||||
const PATCH_CONTEXT_LINES = 3;
|
||||
|
||||
/** Git reads this many leading bytes to decide that a file is binary. */
|
||||
const BINARY_SNIFF_BYTES = 8000;
|
||||
|
||||
const NO_NEWLINE_MARKER = "\\ No newline at end of file";
|
||||
|
||||
/**
|
||||
* One unified-diff hunk in the `diff` package convention.
|
||||
*
|
||||
* A side with zero lines stores the number of the line after the change as its
|
||||
* start. {@link hunkHeader} converts that start to the git convention.
|
||||
*/
|
||||
interface PatchHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
@@ -11,23 +49,251 @@ interface DiffToolResponseHunk {
|
||||
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: DiffToolResponseHunk[];
|
||||
structuredPatch?: PatchHunk[];
|
||||
/** FileWriteOutput only (FileEditOutput carries no `type`): whether the
|
||||
* write created the file or overwrote an existing one. */
|
||||
type?: "create" | "update";
|
||||
/** FileWriteOutput only: the content that was written. */
|
||||
content?: string;
|
||||
/** FileWriteOutput only: the pre-write content — null on create, or on an
|
||||
* update whose previous content was too large to include. */
|
||||
/** The pre-change content. It is null on a Write create, or on a Write
|
||||
* update whose previous content was too large to include. An Edit that
|
||||
* creates a file reports an empty string, as for an existing empty file. */
|
||||
originalFile?: string | null;
|
||||
/** FileEditOutput only: the text that the Edit replaced. It is empty when
|
||||
* the Edit created the file or filled an existing empty file. */
|
||||
oldString?: string;
|
||||
}
|
||||
|
||||
interface EditPreviewInput {
|
||||
file_path?: unknown;
|
||||
old_string?: unknown;
|
||||
new_string?: unknown;
|
||||
replace_all?: unknown;
|
||||
}
|
||||
|
||||
/** The kind of file change that a git patch describes. */
|
||||
type FileChange = "create" | "update";
|
||||
|
||||
/** A file change and the hunks of its git patch. */
|
||||
interface FilePatch {
|
||||
change: FileChange;
|
||||
hunks: PatchHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured toolResponse provided by
|
||||
* the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
|
||||
* the plain unified diff string, this uses the pre-parsed structuredPatch
|
||||
* which supports multiple replacement sites (replaceAll) and always includes
|
||||
* context lines for better readability.
|
||||
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
|
||||
* diff, or an ACP v2 diff.
|
||||
*/
|
||||
export type PatchForm = "air" | "v2";
|
||||
|
||||
/**
|
||||
* How a git patch names its file. `git` is git's own form: the leading slash
|
||||
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
|
||||
*/
|
||||
type GitPatchPaths = "git" | "absolute";
|
||||
|
||||
/** An ACP v2 diff, as tool call content. */
|
||||
type V2DiffContent = v2.Diff & { type: "diff" };
|
||||
|
||||
/**
|
||||
* Builds the exact patch shown before Claude runs an Edit or Write tool.
|
||||
*
|
||||
* Returns undefined when the adapter cannot predict the file change exactly.
|
||||
* The caller then keeps the standard tool-call content. The adapter declines
|
||||
* a file that is missing for an Edit, too large, binary, or that has CR line
|
||||
* endings. Claude converts the line endings of an edited file, so an in-memory
|
||||
* replacement would not match the bytes that Claude writes. It also declines
|
||||
* an `old_string` that does not match exactly once, because Claude then
|
||||
* normalizes quotes or fails.
|
||||
*
|
||||
* A Write always gets content that shows the change: the patch, the standard
|
||||
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
|
||||
* a notice that the Write overwrites a file that the adapter cannot show.
|
||||
*
|
||||
* The preview uses the tool input as it is. Claude can still remove the
|
||||
* trailing whitespace of a line before it writes. The PostToolUse hook then
|
||||
* sends the patch of the written file.
|
||||
*/
|
||||
export async function previewPatchContent(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
form: PatchForm = "air",
|
||||
): Promise<ToolCallContent[] | undefined> {
|
||||
if (toolName === "Edit") {
|
||||
const edit = input as EditPreviewInput;
|
||||
if (
|
||||
typeof edit.file_path !== "string" ||
|
||||
typeof edit.old_string !== "string" ||
|
||||
typeof edit.new_string !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldString = edit.old_string;
|
||||
const filePath = resolveToolPath(edit.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) return undefined;
|
||||
const newString = edit.new_string;
|
||||
if (oldString === newString || !isPatchableText(newString)) return undefined;
|
||||
if (oldString.length === 0) {
|
||||
// An empty old_string creates the file, or fills an existing file that
|
||||
// holds only whitespace. `trim` also removes a byte order mark, whose
|
||||
// handling the adapter does not predict, so such a file is declined.
|
||||
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
|
||||
return undefined;
|
||||
}
|
||||
return exactPatch(filePath, oldText, newString, form);
|
||||
}
|
||||
if (oldText === null) return undefined;
|
||||
const occurrences = oldText.split(oldString).length - 1;
|
||||
if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1)) return undefined;
|
||||
if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
|
||||
return exactPatch(filePath, oldText, newText, form);
|
||||
}
|
||||
|
||||
if (toolName === "Write") {
|
||||
const write = normalizeWriteInput(input);
|
||||
if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
// The Write tool call shows no diff, so the approval is the only place that
|
||||
// shows what the Write changes.
|
||||
const content = write.content;
|
||||
const filePath = resolveToolPath(write.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) {
|
||||
return [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (oldText === content) return undefined;
|
||||
const standard: ToolCallContent[] = [
|
||||
form === "v2"
|
||||
? v2DiffContent(filePath, oldText === null ? "create" : "update")
|
||||
: { type: "diff", path: write.file_path, oldText, newText: content },
|
||||
];
|
||||
if (!isPatchableText(content)) return standard;
|
||||
// A dense change can exceed the diff budget. The approval then shows the standard diff.
|
||||
return exactPatch(filePath, oldText, content, form) ?? standard;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
|
||||
*
|
||||
* The caller checks the bound before it builds the text. A `replace_all` of
|
||||
* many short matches with a long `newString` can otherwise build a text of
|
||||
* gigabytes before the timed line diff starts.
|
||||
*/
|
||||
function replacedTextBytesBound(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
occurrences: number,
|
||||
): number {
|
||||
const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
|
||||
return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file text after Claude replaces `oldString` with `newString`.
|
||||
*
|
||||
* An empty `newString` deletes a line: when `oldString` does not end with a
|
||||
* line break and the file holds `oldString` and a line break, Claude also
|
||||
* removes that line break. With `replaceAll`, it then replaces only the
|
||||
* occurrences that a line break follows.
|
||||
*/
|
||||
function replacedText(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): string {
|
||||
const target =
|
||||
newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
|
||||
? `${oldString}\n`
|
||||
: oldString;
|
||||
return replaceAll
|
||||
? fileText.split(target).join(newString)
|
||||
: fileText.replace(target, () => newString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the git patch for a finished Edit or Write from the file on disk.
|
||||
*
|
||||
* The Claude SDK `structuredPatch` is display data: Claude converts leading
|
||||
* tabs to spaces and CRLF line endings to LF before it computes the hunks. A
|
||||
* patch built from those hunks does not match the file. This function diffs
|
||||
* `originalFile` against the written file instead. It returns undefined when
|
||||
* it cannot build an exact patch, and the caller then sends the standard diff.
|
||||
* A written file that contains a CR or a byte order mark is declined, because
|
||||
* Claude removed those bytes from `originalFile`.
|
||||
*/
|
||||
export async function patchUpdateFromDiffToolResponse(
|
||||
toolResponse: unknown,
|
||||
form: PatchForm = "air",
|
||||
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return undefined;
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (typeof response.filePath !== "string") return undefined;
|
||||
// An Edit with an empty old_string reports "" as the original of a created
|
||||
// file and of an existing empty file. The response does not tell the two
|
||||
// apart, so the caller keeps the standard diff.
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return undefined;
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing. The file on
|
||||
// disk can hold a later change, and that change is not the change of this Write.
|
||||
if (
|
||||
response.type === "update" &&
|
||||
typeof response.content === "string" &&
|
||||
response.originalFile === response.content
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldText =
|
||||
response.type === "create"
|
||||
? null
|
||||
: typeof response.originalFile === "string"
|
||||
? response.originalFile
|
||||
: undefined;
|
||||
if (oldText === undefined) return undefined;
|
||||
// The same size limit as the new side: a larger text gets the standard diff.
|
||||
if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
|
||||
return undefined;
|
||||
const newText = await readPatchSource(response.filePath);
|
||||
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
|
||||
if (response.type === "create" && newText !== response.content) return undefined;
|
||||
const patch = filePatch(oldText, newText);
|
||||
if (!patch) return undefined;
|
||||
return {
|
||||
content: [patchContent(response.filePath, patch, form)],
|
||||
// A created file keeps the location of its Write tool call.
|
||||
locations:
|
||||
oldText === null
|
||||
? [{ path: response.filePath }]
|
||||
: patch.hunks.map(({ newStart }) => ({ path: response.filePath!, line: newStart })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds standard ACP diff content from the structured toolResponse provided
|
||||
* by the PostToolUse hook for diff-producing tools (Edit, Write). Unlike
|
||||
* parsing the plain unified diff string, this uses the pre-parsed
|
||||
* structuredPatch which supports multiple replacement sites (replaceAll) and
|
||||
* always includes context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
@@ -39,67 +305,17 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
let previousOldEnd = 0;
|
||||
let previousNewEnd = 0;
|
||||
let accumulatedDelta = 0;
|
||||
let coordinatesRemainConsistent = true;
|
||||
|
||||
for (const { lines, oldStart, newStart, oldLines, newLines } of response.structuredPatch) {
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let validPrefixes = true;
|
||||
let validEofMarkers = true;
|
||||
const oldPosition = oldStart + (oldLines === 0 ? 1 : 0);
|
||||
const newPosition = newStart + (newLines === 0 ? 1 : 0);
|
||||
const oldEnd = oldPosition + oldLines;
|
||||
const newEnd = newPosition + newLines;
|
||||
const nextDelta = accumulatedDelta + newLines - oldLines;
|
||||
const validCoordinates: boolean =
|
||||
coordinatesRemainConsistent &&
|
||||
Number.isSafeInteger(oldStart) &&
|
||||
Number.isSafeInteger(newStart) &&
|
||||
Number.isSafeInteger(oldLines) &&
|
||||
Number.isSafeInteger(newLines) &&
|
||||
oldStart >= (oldLines === 0 ? 0 : 1) &&
|
||||
newStart >= (newLines === 0 ? 0 : 1) &&
|
||||
oldLines >= 0 &&
|
||||
newLines >= 0 &&
|
||||
Number.isSafeInteger(oldPosition) &&
|
||||
Number.isSafeInteger(newPosition) &&
|
||||
Number.isSafeInteger(oldEnd) &&
|
||||
Number.isSafeInteger(newEnd) &&
|
||||
Number.isSafeInteger(nextDelta) &&
|
||||
oldPosition >= previousOldEnd &&
|
||||
newPosition >= previousNewEnd &&
|
||||
newPosition - oldPosition === accumulatedDelta;
|
||||
coordinatesRemainConsistent = validCoordinates;
|
||||
if (validCoordinates) {
|
||||
previousOldEnd = oldEnd;
|
||||
previousNewEnd = newEnd;
|
||||
accumulatedDelta = nextDelta;
|
||||
}
|
||||
for (const [index, line] of lines.entries()) {
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
removed++;
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
added++;
|
||||
} else if (line === "\\ No newline at end of file") {
|
||||
const previousLine = lines[index - 1];
|
||||
if (
|
||||
typeof previousLine !== "string" ||
|
||||
(!previousLine.startsWith("-") &&
|
||||
!previousLine.startsWith("+") &&
|
||||
!previousLine.startsWith(" "))
|
||||
) {
|
||||
validEofMarkers = false;
|
||||
}
|
||||
} else if (line === NO_NEWLINE_MARKER) {
|
||||
continue;
|
||||
} else {
|
||||
if (!line.startsWith(" ")) validPrefixes = false;
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
@@ -111,13 +327,6 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
...(validCoordinates &&
|
||||
validPrefixes &&
|
||||
validEofMarkers &&
|
||||
oldText.length === oldLines &&
|
||||
newText.length === newLines
|
||||
? { _meta: withAirMeta(undefined, AIR_DIFF_STATS_KEY, { version: 1, added, removed }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -130,7 +339,9 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
// of a large existing file would render as creating it. Emit a truthful
|
||||
// replacement instead. Gated on `type` so Edit (whose output carries no
|
||||
// `type` and whose optimistic old/new diff is already truthful) keeps the
|
||||
// empty-return behavior.
|
||||
// empty-return behavior. A `create` needs nothing here: the
|
||||
// tool_use-time diff shows the created file, and a client with diffPatch
|
||||
// gets it from the Write reporter.
|
||||
if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
|
||||
locations.push({ path: response.filePath });
|
||||
content.push(
|
||||
@@ -156,3 +367,341 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a finished Edit or Write: the exact patch of
|
||||
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
|
||||
* change with a patch from what the toolResponse holds.
|
||||
*
|
||||
* The fallback patch of an update comes from the `structuredPatch`, which is
|
||||
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
|
||||
* for a v2 patch, which is renderable text, not a patch to apply. An update
|
||||
* without hunks, such as one whose previous content was too large to diff,
|
||||
* gets the change alone. An Edit with an empty `old_string` reports the same
|
||||
* response for a created file and a filled empty one, so it gets no diff.
|
||||
*/
|
||||
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
}> {
|
||||
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
|
||||
if (exact) return exact;
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
const filePath = response.filePath;
|
||||
if (typeof filePath !== "string") return {};
|
||||
if (response.type === "create") {
|
||||
const hunk =
|
||||
typeof response.content === "string" && isPatchableText(response.content)
|
||||
? wholeFileHunk(response.content)
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
|
||||
locations: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return {};
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing.
|
||||
if (response.type === "update" && response.content === response.originalFile) return {};
|
||||
const hunks =
|
||||
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
|
||||
? response.structuredPatch
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "update", hunks)],
|
||||
locations: hunks
|
||||
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
|
||||
: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of one git patch for `filePath`.
|
||||
*
|
||||
* The headers follow `git diff`: the path is quoted when git would quote it,
|
||||
* and a created file gets its mode line and a `/dev/null` side. With `git`
|
||||
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
|
||||
*/
|
||||
export function gitPatchText(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks: PatchHunk[],
|
||||
paths: GitPatchPaths = "git",
|
||||
): string {
|
||||
return [
|
||||
...gitPatchHeader(filePath, change, paths),
|
||||
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
|
||||
// A Windows path gets forward slashes so the header names the same file on
|
||||
// every platform.
|
||||
const slashed = filePath.replaceAll("\\", "/");
|
||||
// git drops one leading slash of an absolute path.
|
||||
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
|
||||
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
|
||||
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
|
||||
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
|
||||
const tab = name.includes(" ") ? "\t" : "";
|
||||
return [
|
||||
`diff --git ${oldName} ${newName}`,
|
||||
...(change === "create" ? ["new file mode 100644"] : []),
|
||||
`--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
|
||||
`+++ ${newName}${tab}`,
|
||||
];
|
||||
}
|
||||
|
||||
const GIT_PATH_ESCAPES: Record<number, string> = {
|
||||
0x07: "a",
|
||||
0x08: "b",
|
||||
0x09: "t",
|
||||
0x0a: "n",
|
||||
0x0b: "v",
|
||||
0x0c: "f",
|
||||
0x0d: "r",
|
||||
0x22: '"',
|
||||
0x5c: "\\",
|
||||
};
|
||||
|
||||
/**
|
||||
* Quotes `prefix + name` like git with the default `core.quotePath`.
|
||||
*
|
||||
* A double quote, a backslash, a control byte, or a non-ASCII byte makes git
|
||||
* put the whole name in double quotes. git then writes a C escape or a
|
||||
* three-digit octal escape for each such UTF-8 byte.
|
||||
*/
|
||||
function quoteGitPath(prefix: string, name: string): string {
|
||||
const full = `${prefix}${name}`;
|
||||
let quoted = "";
|
||||
let needsQuotes = false;
|
||||
for (const byte of Buffer.from(full, "utf8")) {
|
||||
const escape = GIT_PATH_ESCAPES[byte];
|
||||
if (escape !== undefined) {
|
||||
quoted += `\\${escape}`;
|
||||
needsQuotes = true;
|
||||
} else if (byte < 0x20 || byte >= 0x7f) {
|
||||
quoted += `\\${byte.toString(8).padStart(3, "0")}`;
|
||||
needsQuotes = true;
|
||||
} else {
|
||||
quoted += String.fromCharCode(byte);
|
||||
}
|
||||
}
|
||||
return needsQuotes ? `"${quoted}"` : full;
|
||||
}
|
||||
|
||||
function hunkHeader({ oldStart, oldLines, newStart, newLines }: PatchHunk): string {
|
||||
return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
|
||||
}
|
||||
|
||||
/** A git hunk range. A side with zero lines names the line before the change. */
|
||||
function hunkRange(start: number, count: number): string {
|
||||
if (count === 0) return `${Math.max(0, start - 1)},0`;
|
||||
return count === 1 ? String(start) : `${start},${count}`;
|
||||
}
|
||||
|
||||
/** The hunk that adds every line of `text` to an empty file. */
|
||||
function wholeFileHunk(text: string): PatchHunk | undefined {
|
||||
if (text.length === 0) return undefined;
|
||||
const terminated = text.endsWith("\n");
|
||||
const lines = (terminated ? text.slice(0, -1) : text).split("\n");
|
||||
return {
|
||||
oldStart: 1,
|
||||
oldLines: 0,
|
||||
newStart: 1,
|
||||
newLines: lines.length,
|
||||
lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The line-diff hunks between two texts. Returns undefined when the diff runs
|
||||
* out of its time budget.
|
||||
*/
|
||||
function diffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
|
||||
if (oldText === null) {
|
||||
const hunk = wholeFileHunk(newText);
|
||||
return hunk ? [hunk] : [];
|
||||
}
|
||||
const oldLines = textLines(oldText);
|
||||
const newLines = textLines(newText);
|
||||
// The diff gets only the changed middle and its context lines. An edit
|
||||
// usually changes a small part of a large file.
|
||||
const common = Math.min(oldLines.length, newLines.length);
|
||||
let prefix = 0;
|
||||
while (prefix < common && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < common - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
|
||||
const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
|
||||
const patch = structuredPatch(
|
||||
"",
|
||||
"",
|
||||
oldLines.slice(skipped, oldLines.length - skippedEnd).join(""),
|
||||
newLines.slice(skipped, newLines.length - skippedEnd).join(""),
|
||||
"",
|
||||
"",
|
||||
{ context: PATCH_CONTEXT_LINES, timeout: PATCH_DIFF_TIMEOUT_MS },
|
||||
);
|
||||
return patch?.hunks.map((hunk) => ({
|
||||
...hunk,
|
||||
oldStart: hunk.oldStart + skipped,
|
||||
newStart: hunk.newStart + skipped,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The lines of a text, each with its line break. The last line can have none. */
|
||||
function textLines(text: string): string[] {
|
||||
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch of a change from `oldText`, null for a missing file, to
|
||||
* `newText`. Returns undefined when nothing changed or the diff runs out of
|
||||
* its time budget.
|
||||
*/
|
||||
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
||||
const hunks = diffHunks(oldText, newText);
|
||||
if (!hunks || hunks.length === 0) return undefined;
|
||||
return { change: oldText === null ? "create" : "update", hunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file as patch input.
|
||||
*
|
||||
* Returns null when the file does not exist. Returns undefined when the file
|
||||
* cannot be read, is not a regular file, is larger than
|
||||
* {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
|
||||
* CR.
|
||||
*/
|
||||
async function readPatchSource(filePath: string): Promise<string | null | undefined> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
// O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
|
||||
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
return isMissingFileError(error) ? null : undefined;
|
||||
}
|
||||
try {
|
||||
// The checks and the read use one open file, so a swap of the path between them has no effect.
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
// One byte more than the checked size shows a file that grew after the check.
|
||||
const buffer = Buffer.alloc(stats.size + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > stats.size) return undefined;
|
||||
const text = decodeFileText(buffer.subarray(0, length));
|
||||
return text?.includes("\r") ? undefined : text;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
|
||||
function decodeFileText(bytes: Uint8Array): string | undefined {
|
||||
if (bytes.length > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tool input text can be the new side of an exact patch. */
|
||||
function isPatchableText(text: string): boolean {
|
||||
return (
|
||||
Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
|
||||
!text.includes("\0") &&
|
||||
!text.includes("\r")
|
||||
);
|
||||
}
|
||||
|
||||
/** The exact patch content of a change, or undefined when it has no patch. */
|
||||
function exactPatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
form: PatchForm,
|
||||
): ToolCallContent[] | undefined {
|
||||
const patch = filePatch(oldText, newText);
|
||||
return patch && [patchContent(filePath, patch, form)];
|
||||
}
|
||||
|
||||
/** The content that carries a patch, in the given form. */
|
||||
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
|
||||
return form === "v2"
|
||||
? v2DiffContent(filePath, patch.change, patch.hunks)
|
||||
: airPatchContent(filePath, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* A diff block in the AIR patch form.
|
||||
*
|
||||
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
|
||||
* sends this form only to a client that advertised `diffPatch`.
|
||||
*/
|
||||
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
|
||||
return {
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText: null,
|
||||
newText: "",
|
||||
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
|
||||
version: 1,
|
||||
format: "git_patch",
|
||||
text: gitPatchText(filePath, change, hunks),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a change to the text file `filePath`: the change and,
|
||||
* given hunks, its git patch.
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks?: PatchHunk[],
|
||||
): ToolCallContent {
|
||||
const fileChange: v2.DiffChange =
|
||||
change === "create"
|
||||
? { operation: "add", path: filePath, fileType: "text" }
|
||||
: { operation: "modify", path: filePath, fileType: "text" };
|
||||
const diff: V2DiffContent = {
|
||||
type: "diff",
|
||||
changes: [fileChange],
|
||||
...(hunks?.length
|
||||
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
|
||||
: {}),
|
||||
};
|
||||
return diff as unknown as ToolCallContent;
|
||||
}
|
||||
|
||||
function resolveToolPath(filePath: string, cwd?: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
@@ -148,14 +153,6 @@ function questionCustomFieldKey(index: number): string {
|
||||
*/
|
||||
const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
||||
|
||||
/**
|
||||
* Shared `_meta` key for marking a per-question free-text field as the custom
|
||||
* answer companion for a select question. This intentionally has no
|
||||
* agent-specific namespace so ACP clients can recognize the same marker across
|
||||
* Codex, Claude, and other AskUserQuestion bridges.
|
||||
*/
|
||||
const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
*
|
||||
@@ -178,6 +175,7 @@ export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
airClient = false,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
@@ -210,18 +208,25 @@ export function askUserQuestionsToCreateRequest(
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: question.multiSelect
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
_meta: {
|
||||
[CUSTOM_ANSWER_META_KEY]: {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
// Marks the field as the custom answer companion of a select question,
|
||||
// under `_meta.jetbrains.air.customAnswer` and under the root key that
|
||||
// released AIR versions read. Only AIR gets the marker.
|
||||
...(airClient
|
||||
? {
|
||||
_meta: withAirMeta(
|
||||
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
customAnswer,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionFailureController } from "./session-failure-extension.js";
|
||||
|
||||
export function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
@@ -144,7 +143,7 @@ export function claudeLoginRequiredError(): RequestError {
|
||||
|
||||
/**
|
||||
* The state the guard keeps for one session. It lives on the session so that a
|
||||
* concurrent pair of turns shares one account read and one failure row, and so
|
||||
* concurrent pair of turns shares one account read, and so
|
||||
* that each degraded-guard warning is logged once.
|
||||
*/
|
||||
export type ClaudeSubscriptionGuardState = {
|
||||
@@ -190,8 +189,6 @@ type GuardOptions = {
|
||||
sessionId: string;
|
||||
query: Pick<Query, "accountInfo">;
|
||||
guardState: ClaudeSubscriptionGuardState;
|
||||
/** Built lazily: most turns pass and never need a controller. */
|
||||
sessionFailures: () => SessionFailureController;
|
||||
logger: GuardLogger;
|
||||
/** Display hook: called with the account the guard just read, before the
|
||||
* guard decides on it. It exists so a refused turn still reports which
|
||||
@@ -219,8 +216,7 @@ type GuardOptions = {
|
||||
* mid-session. The refusal mirrors the signed-out path exactly (see
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
|
||||
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
|
||||
* flow, and a capable client additionally gets one session-scoped access
|
||||
* failure whose `login` action stays the way back in.
|
||||
* flow. The guard sends no separate access message.
|
||||
*
|
||||
* Fails open when no account can be read at all: session creation already
|
||||
* refused every account without a usable credential, and an unavailable probe
|
||||
@@ -241,8 +237,8 @@ export async function refuseClaudeSubscriptionTurn(options: GuardOptions): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the account, publish at most one failure row, and return the error the
|
||||
* turn must be rejected with. Never throws: an unreadable account fails open. */
|
||||
/** Read the account and return the error for the refused turn.
|
||||
* Never throws: an unreadable account fails open. */
|
||||
async function evaluateGuard(options: GuardOptions): Promise<RequestError | undefined> {
|
||||
const account = await readGuardAccount(options);
|
||||
if (!account) {
|
||||
@@ -259,14 +255,9 @@ async function evaluateGuard(options: GuardOptions): Promise<RequestError | unde
|
||||
);
|
||||
}
|
||||
if (billsClaudeSubscription(account)) {
|
||||
await publishRefusal(options.sessionFailures(), {
|
||||
details: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
|
||||
reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON,
|
||||
});
|
||||
return claudeSubscriptionNotSupportedError();
|
||||
}
|
||||
if (!holdsNonSubscriptionCredential(account)) {
|
||||
await publishRefusal(options.sessionFailures(), { details: CLAUDE_LOGIN_REQUIRED_MESSAGE });
|
||||
return claudeLoginRequiredError();
|
||||
}
|
||||
return undefined;
|
||||
@@ -293,19 +284,3 @@ async function readGuardAccount(options: GuardOptions): Promise<AccountInfo | un
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The refusal state does not change between turns: publish once, and skip a
|
||||
* republish while the previous failure with this reason is still active. */
|
||||
async function publishRefusal(
|
||||
sessionFailures: SessionFailureController,
|
||||
failure: { details: string; reason?: string },
|
||||
): Promise<void> {
|
||||
if (sessionFailures.hasActiveSessionError("auth_required", failure.reason)) {
|
||||
return;
|
||||
}
|
||||
await sessionFailures.publish("auth_required", {
|
||||
sessionScoped: true,
|
||||
details: failure.details,
|
||||
...(failure.reason ? { reason: failure.reason } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -82,10 +83,13 @@ if (process.argv.includes("--cli")) {
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
// The draft ACP v2 is opt-in while it is a draft.
|
||||
const acp = serveAcp(logger, {
|
||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
await acp.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
@@ -94,7 +98,7 @@ if (process.argv.includes("--cli")) {
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
acp.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
388
src/mcp-command.ts
Normal file
388
src/mcp-command.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { escapeMarkdown } from "./usage-markdown.js";
|
||||
import { raceTimeoutAndAbort } from "./utils.js";
|
||||
|
||||
/** The `/mcp` entry of `available_commands_update`. The adapter shows the
|
||||
* servers as a list, and runs a reconnect, an enable, or a disable through
|
||||
* the SDK control API. */
|
||||
export const MCP_AVAILABLE_COMMAND: AvailableCommand = {
|
||||
name: "mcp",
|
||||
description: "Show the MCP servers and their status, or reconnect, enable, or disable a server",
|
||||
input: { hint: "[reconnect|enable|disable [<server>|all]]" },
|
||||
};
|
||||
|
||||
/** The time that the status read after a `/mcp` command can take. */
|
||||
const MCP_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The time that one reconnect, enable, or disable can take. */
|
||||
const MCP_ACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is
|
||||
* true for `all` and for a reconnect without a server name, as in Claude
|
||||
* Code. `server` is undefined for an enable or a disable without a name. */
|
||||
export type McpCommand =
|
||||
| { action: "status" }
|
||||
| { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string };
|
||||
|
||||
/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`,
|
||||
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
|
||||
* argument gives null, and the prompt gets no replacement. */
|
||||
export function parseMcpCommand(text: string): McpCommand | null {
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words[0] !== "/mcp") return null;
|
||||
if (words.length === 1) return { action: "status" };
|
||||
const action = words[1]?.toLowerCase();
|
||||
if (action !== "reconnect" && action !== "enable" && action !== "disable") return null;
|
||||
const server = words.slice(2).join(" ");
|
||||
if (server === "all" || (server === "" && action === "reconnect")) {
|
||||
return { action, all: true };
|
||||
}
|
||||
return server === "" ? { action, all: false } : { action, all: false, server };
|
||||
}
|
||||
|
||||
/** Read the MCP server status for the list after a `/mcp` command. Resolves
|
||||
* to null when the read fails, takes too long, or `signal` aborts. Then the
|
||||
* turn keeps the text of Claude Code. */
|
||||
export async function readMcpServerStatus(
|
||||
query: Pick<Query, "mcpServerStatus">,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpServerStatus[] | null> {
|
||||
if (signal.aborted) return null;
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(
|
||||
query.mcpServerStatus(),
|
||||
MCP_STATUS_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (outcome.type === "timeout") {
|
||||
logError("The MCP server status read timed out; keeping the Claude Code text of /mcp");
|
||||
}
|
||||
return outcome.type === "done" ? outcome.value : null;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The known statuses in the order of the groups and of the summary counts.
|
||||
* A disabled server is not a group. It is on one line at the end. */
|
||||
const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"];
|
||||
|
||||
const GROUP_TITLES: Partial<Record<McpServerStatus["status"], string>> = {
|
||||
failed: "Failed",
|
||||
"needs-auth": "Needs authentication",
|
||||
pending: "Connecting",
|
||||
connected: "Connected",
|
||||
};
|
||||
|
||||
const SUMMARY_LABELS: Record<McpServerStatus["status"], string> = {
|
||||
connected: "connected",
|
||||
failed: "failed",
|
||||
"needs-auth": "need authentication",
|
||||
pending: "connecting",
|
||||
disabled: "disabled",
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"];
|
||||
|
||||
const MAX_ERROR_LENGTH = 160;
|
||||
|
||||
/** The longest segment that can be a wrapper. A longer segment is content. */
|
||||
const MAX_WRAPPER_LENGTH = 60;
|
||||
|
||||
/** True for a server that a `/mcp reconnect` without a name must retry. */
|
||||
function needsReconnect(status: McpServerStatus): boolean {
|
||||
return (
|
||||
status.status === "failed" || status.status === "pending" || status.status === "needs-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/** The text as a Markdown code span. The fence is longer than the longest
|
||||
* backtick run in the text, so the span shows the text unchanged. The user
|
||||
* can copy a server name from it into `/mcp reconnect`. */
|
||||
function codeSpan(text: string): string {
|
||||
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
|
||||
return `${fence}${pad}${text}${pad}${fence}`;
|
||||
}
|
||||
|
||||
function isErrorCode(segment: string): boolean {
|
||||
return /^-?\d+$/.test(segment);
|
||||
}
|
||||
|
||||
/** True for a segment that only wraps the real error, such as
|
||||
* `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a
|
||||
* Rust or TypeScript type path. */
|
||||
function isWrapperSegment(segment: string): boolean {
|
||||
return (
|
||||
isErrorCode(segment) ||
|
||||
segment.includes("::") ||
|
||||
(segment.length <= MAX_WRAPPER_LENGTH &&
|
||||
!/[.!?]/.test(segment) &&
|
||||
/(failed|error|exception)(\s+-?\d+)?$/i.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */
|
||||
function truncate(text: string): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= MAX_ERROR_LENGTH) return text;
|
||||
const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join("");
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Make an MCP error short and readable for one list item. The result has
|
||||
* no leading wrapper segments, at most two sentences, and at most
|
||||
* `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so
|
||||
* an error code is never alone. The result is empty for an error with only
|
||||
* whitespace. */
|
||||
export function cleanMcpError(error: string): string {
|
||||
const segments = error.replace(/\s+/g, " ").trim().split(": ");
|
||||
let start = 0;
|
||||
while (
|
||||
start < segments.length - 1 &&
|
||||
isWrapperSegment(segments[start]!) &&
|
||||
!segments.slice(start + 1).every(isErrorCode)
|
||||
) {
|
||||
start++;
|
||||
}
|
||||
const content = segments.slice(start).join(": ");
|
||||
return truncate(
|
||||
content
|
||||
.split(/(?<=[.!?]) /)
|
||||
.slice(0, 2)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function toolCount(status: McpServerStatus): string | undefined {
|
||||
if (!status.tools) return undefined;
|
||||
return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`;
|
||||
}
|
||||
|
||||
/** The server name as inline code. Claude Code keys the servers by name, so
|
||||
* two servers have the same name only from different scopes. Then the
|
||||
* scope tells them apart. */
|
||||
function serverName(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const origin = status.scope ?? status.source;
|
||||
return duplicates.has(status.name) && origin
|
||||
? `${codeSpan(status.name)} (${escapeMarkdown(origin)})`
|
||||
: codeSpan(status.name);
|
||||
}
|
||||
|
||||
function serverItem(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const error = status.error ? cleanMcpError(status.error) : "";
|
||||
const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)]
|
||||
.filter((detail) => detail !== undefined)
|
||||
.join("; ");
|
||||
const name = serverName(status, duplicates);
|
||||
return details === "" ? `- ${name}` : `- ${name}: ${details}`;
|
||||
}
|
||||
|
||||
function summaryLine(statuses: McpServerStatus[]): string {
|
||||
// The counts follow the group order. An unknown status also counts, so the counts add up to the total.
|
||||
const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"]
|
||||
.map((state) => ({
|
||||
state,
|
||||
count: statuses.filter((status) => status.status === state).length,
|
||||
}))
|
||||
.filter(({ count }) => count > 0);
|
||||
const parts = counts.map(({ state, count }) =>
|
||||
count === 1 && state === "needs-auth"
|
||||
? "1 needs authentication"
|
||||
: `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`,
|
||||
);
|
||||
return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
/** The statuses that this adapter does not know, in the SDK order. The SDK
|
||||
* can report a new status before the adapter knows it. */
|
||||
function unknownStatuses(statuses: McpServerStatus[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
statuses
|
||||
.map((status) => status.status as string)
|
||||
.filter((state) => !KNOWN_STATUSES.includes(state)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */
|
||||
function unknownTitle(state: string): string {
|
||||
return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1));
|
||||
}
|
||||
|
||||
/** Markdown for `/mcp`: a summary line, one group of servers for each
|
||||
* status, and the disabled servers on one line. A list reads better than a
|
||||
* table in a narrow chat. */
|
||||
export function formatMcpStatus(statuses: McpServerStatus[]): string {
|
||||
if (statuses.length === 0) return "No MCP servers are configured.";
|
||||
const blocks: string[] = [];
|
||||
const names = statuses.map((status) => status.name);
|
||||
const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index));
|
||||
blocks.push(summaryLine(statuses));
|
||||
const groups: [string, string][] = [
|
||||
...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]),
|
||||
...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]),
|
||||
];
|
||||
for (const [state, title] of groups) {
|
||||
const members = statuses.filter((status) => status.status === state);
|
||||
if (members.length === 0) continue;
|
||||
blocks.push(
|
||||
[`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"),
|
||||
);
|
||||
}
|
||||
const disabled = statuses.filter((status) => status.status === "disabled");
|
||||
if (disabled.length > 0) {
|
||||
blocks.push(
|
||||
`**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (statuses.some(needsReconnect)) {
|
||||
blocks.push(
|
||||
"Run `/mcp reconnect <server>` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.",
|
||||
);
|
||||
}
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/** The SDK query methods that `/mcp` uses. */
|
||||
export type McpControlQuery = Pick<
|
||||
Query,
|
||||
"mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer"
|
||||
>;
|
||||
|
||||
/** The result of {@link runMcpCommand}. `markdown` is null when the turn
|
||||
* keeps the text of Claude Code. `reconnected` holds the servers that the
|
||||
* adapter tried to reconnect. */
|
||||
export type McpCommandOutcome = { markdown: string | null; reconnected: string[] };
|
||||
|
||||
type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" };
|
||||
|
||||
/** Wait for one control call. The wait stops when `signal` aborts or the
|
||||
* call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */
|
||||
async function awaitCall(call: () => Promise<void>, signal: AbortSignal): Promise<CallResult> {
|
||||
if (signal.aborted) return { type: "aborted" };
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal);
|
||||
if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." };
|
||||
return outcome.type === "done" ? { type: "done" } : outcome;
|
||||
} catch (error) {
|
||||
return signal.aborted
|
||||
? { type: "aborted" }
|
||||
: { type: "failed", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" };
|
||||
|
||||
/** The Claude Code sentence that refuses an action in SDK mode. */
|
||||
const UNAVAILABLE_SENTENCE = /aren't available in this session/i;
|
||||
|
||||
/** A Claude Code sentence that points to a terminal, which an ACP client
|
||||
* does not have. */
|
||||
function isTerminalSentence(sentence: string): boolean {
|
||||
return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here");
|
||||
}
|
||||
|
||||
function sentences(text: string): string[] {
|
||||
return text
|
||||
.trim()
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.filter((sentence) => sentence !== "");
|
||||
}
|
||||
|
||||
/** The servers that the action changes. A named server is always a target,
|
||||
* so the control API reports its own error. */
|
||||
function actionTargets(
|
||||
command: Extract<McpCommand, { all: boolean }>,
|
||||
statuses: McpServerStatus[],
|
||||
): string[] {
|
||||
if (!command.all) return command.server === undefined ? [] : [command.server];
|
||||
const names = statuses
|
||||
.filter((status) => {
|
||||
if (command.action === "reconnect") return needsReconnect(status);
|
||||
if (command.action === "enable") return status.status === "disabled";
|
||||
return status.status !== "disabled";
|
||||
})
|
||||
.map((status) => status.name);
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
function nothingToDoLine(action: McpCommand["action"]): string {
|
||||
if (action === "enable") return "Every MCP server is already enabled.";
|
||||
if (action === "disable") return "Every MCP server is already disabled.";
|
||||
return "Every MCP server is connected or disabled. There is nothing to reconnect.";
|
||||
}
|
||||
|
||||
/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for
|
||||
* the command. Claude Code refuses a reconnect, an enable, and a disable in
|
||||
* SDK mode, so the adapter runs them through the SDK control API. The result
|
||||
* of each server goes above the server list. An error of one server does
|
||||
* not stop the others. When the first status read fails or `signal`
|
||||
* aborts, the turn keeps the text of Claude Code. */
|
||||
export async function runMcpCommand(
|
||||
query: McpControlQuery,
|
||||
command: McpCommand,
|
||||
originalOutput: string,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpCommandOutcome> {
|
||||
const reconnected: string[] = [];
|
||||
const keep: McpCommandOutcome = { markdown: null, reconnected };
|
||||
if (command.action === "status") {
|
||||
// The list replaces the Claude Code summary line.
|
||||
const statuses = await readMcpServerStatus(query, signal, logError);
|
||||
return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected };
|
||||
}
|
||||
const before = await readMcpServerStatus(query, signal, logError);
|
||||
if (!before) return keep;
|
||||
|
||||
const cliSentences = sentences(originalOutput).filter(
|
||||
(sentence) => !isTerminalSentence(sentence),
|
||||
);
|
||||
const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence));
|
||||
const results: string[] = [];
|
||||
if (!command.all && command.server === undefined) {
|
||||
results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} <server>\`.`);
|
||||
}
|
||||
const known = new Set(before.map((status) => status.name));
|
||||
const targets = actionTargets(command, before);
|
||||
if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action));
|
||||
for (const server of targets) {
|
||||
if (!known.has(server)) {
|
||||
// Claude Code checks the name first. Its sentence then stays.
|
||||
if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`);
|
||||
continue;
|
||||
}
|
||||
if (command.action === "reconnect") reconnected.push(server);
|
||||
const call =
|
||||
command.action === "reconnect"
|
||||
? () => query.reconnectMcpServer(server)
|
||||
: () => query.toggleMcpServer(server, command.action === "enable");
|
||||
const result = await awaitCall(call, signal);
|
||||
if (result.type === "aborted") return keep;
|
||||
if (result.type === "done") {
|
||||
results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`);
|
||||
} else {
|
||||
const error = cleanMcpError(result.error);
|
||||
const failure = `- Couldn't ${command.action} ${codeSpan(server)}`;
|
||||
results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence));
|
||||
if (kept.length > 0) blocks.push(kept.join(" "));
|
||||
if (results.length > 0) blocks.push(results.join("\n"));
|
||||
const after = await readMcpServerStatus(query, signal, logError);
|
||||
if (signal.aborted) return keep;
|
||||
if (after) blocks.push(formatMcpStatus(after));
|
||||
return { markdown: blocks.join("\n\n"), reconnected };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
|
||||
export type NativeSubagent = {
|
||||
sessionId: string;
|
||||
@@ -6,6 +7,11 @@ export type NativeSubagent = {
|
||||
parentToolUseId?: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* The exact prompt of this generation, sent as `prompt` in
|
||||
* `subagent_spawned`. It is absent when the adapter has no prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
announced?: boolean;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
@@ -41,6 +47,8 @@ type SubagentIdentity = {
|
||||
const MAX_PENDING_PARENTS = 64;
|
||||
const MAX_PENDING_UPDATES = 256;
|
||||
const MAX_PENDING_UPDATES_PER_PARENT = 32;
|
||||
/** The number of child tool calls whose owning child session the runtime remembers. */
|
||||
const MAX_CHILD_TOOL_CALLS = 2048;
|
||||
|
||||
/**
|
||||
* Owns the connection-local native subagent registry and all ACP lifecycle
|
||||
@@ -55,6 +63,12 @@ export class NativeSubagentRuntime {
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
* update of that tool call can lose `parentToolUseId`, for example a progress
|
||||
* beat after the child finished. It still belongs to the child session.
|
||||
*/
|
||||
private readonly childByToolCall = new Map<string, NativeSubagent>();
|
||||
private readonly taskFinishPromises = new Map<string, Promise<void>>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
@@ -85,7 +99,7 @@ export class NativeSubagentRuntime {
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const { update } = notification;
|
||||
const claudeMeta = update._meta?.claudeCode as
|
||||
{ parentToolUseId?: string | null; subagent?: true; toolName?: string } | undefined;
|
||||
{ parentToolUseId?: string | null; toolName?: string } | undefined;
|
||||
const isControl = isNativeSubagentControlUpdate(update);
|
||||
|
||||
if (!this.enabled) return notification;
|
||||
@@ -126,11 +140,23 @@ export class NativeSubagentRuntime {
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
|
||||
? update.toolCallId
|
||||
: undefined;
|
||||
|
||||
// A permission request may have had to create the tool call before native
|
||||
// child ownership was known. Keep every later update in that original ACP
|
||||
// session; moving a lifecycle after its initial call creates an orphan in
|
||||
// both transcripts.
|
||||
if (forcedSessionId) return { ...notification, sessionId: forcedSessionId };
|
||||
if (forcedSessionId) {
|
||||
const forcedChild = this.childOfSession(forcedSessionId);
|
||||
if (toolCallId && forcedChild) this.rememberToolCallOwner(toolCallId, forcedChild);
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
|
||||
const owner = toolCallId ? this.childByToolCall.get(toolCallId) : undefined;
|
||||
if (owner) return this.toChild(owner, notification, toolCallId);
|
||||
|
||||
if (this.enabled && claudeMeta?.parentToolUseId) {
|
||||
const child = this.childByParentToolUse.get(claudeMeta.parentToolUseId);
|
||||
@@ -138,13 +164,7 @@ export class NativeSubagentRuntime {
|
||||
this.buffer(claudeMeta.parentToolUseId, notification);
|
||||
return null;
|
||||
}
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
return this.toChild(child, notification, toolCallId);
|
||||
}
|
||||
|
||||
return notification;
|
||||
@@ -163,47 +183,63 @@ export class NativeSubagentRuntime {
|
||||
if (previous && previous.terminalState === undefined) return;
|
||||
|
||||
// A SendMessage resume reuses the finished generation's tool id, whose
|
||||
// control state is already cleaned up. Keep its parent while that session
|
||||
// is still live; a finished parent can no longer host the new generation.
|
||||
const resumedParentSessionId =
|
||||
previous &&
|
||||
(this.isLiveSession(previous.parentSessionId)
|
||||
? previous.parentSessionId
|
||||
: this.rootSessionId);
|
||||
// control state is already cleaned up.
|
||||
const knownParentSessionId =
|
||||
(task.toolUseId ? this.parentByToolUse.get(task.toolUseId) : undefined) ??
|
||||
resumedParentSessionId;
|
||||
(previous && this.resumedParentSessionId(previous));
|
||||
const identity = task.toolUseId ? this.identityByToolUse.get(task.toolUseId) : undefined;
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(task.taskId, previous),
|
||||
parentSessionId: knownParentSessionId ?? this.rootSessionId,
|
||||
parentToolUseId: task.toolUseId ?? undefined,
|
||||
name: subagentDisplayName(
|
||||
identity?.name,
|
||||
identity?.description ?? task.description,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
task.taskId,
|
||||
),
|
||||
task: subagentDescription(
|
||||
identity?.prompt ?? task.prompt,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
};
|
||||
this.children.set(task.taskId, child);
|
||||
if (task.toolUseId) {
|
||||
this.taskByToolUse.set(task.toolUseId, task.taskId);
|
||||
this.childByParentToolUse.set(task.toolUseId, child);
|
||||
this.controlByToolUse.delete(task.toolUseId);
|
||||
}
|
||||
|
||||
// A nested child must wait for the spawning Agent/Task frame to establish
|
||||
// its immediate parent. Root children without a tool id can be announced.
|
||||
if (knownParentSessionId || !task.toolUseId) {
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of task.toolUseId ? this.takePending(task.toolUseId) : []) {
|
||||
await deliver(pending);
|
||||
}
|
||||
}
|
||||
await this.openGeneration(
|
||||
task.taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: knownParentSessionId ?? this.rootSessionId,
|
||||
parentToolUseId: task.toolUseId ?? undefined,
|
||||
name: subagentDisplayName(
|
||||
identity?.name,
|
||||
identity?.description ?? task.description,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
task.taskId,
|
||||
),
|
||||
task: subagentDescription(
|
||||
identity?.prompt ?? task.prompt,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
...promptField(promptText(task.prompt) ?? identity?.prompt),
|
||||
},
|
||||
!!knownParentSessionId || !task.toolUseId,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new generation of a finished child when the SDK resumes the same
|
||||
* agent id. The SDK can resume a child without a new `task_started`, so a
|
||||
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
|
||||
* signal. A child that did not finish is not changed. The `prompt` is the
|
||||
* SendMessage text that resumed the child, when the adapter knows it.
|
||||
*/
|
||||
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const previous = this.children.get(taskId);
|
||||
if (!previous) return;
|
||||
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
|
||||
await this.openGeneration(
|
||||
taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: this.resumedParentSessionId(previous),
|
||||
parentToolUseId: previous.parentToolUseId,
|
||||
name: previous.name,
|
||||
task: previous.task,
|
||||
...promptField(promptText(prompt)),
|
||||
},
|
||||
true,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
async finishTask(
|
||||
@@ -266,8 +302,74 @@ export class NativeSubagentRuntime {
|
||||
this.takePending(parentToolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an update to the child session. An update of a finished child is
|
||||
* dropped: it never goes to the root session.
|
||||
*/
|
||||
private toChild(
|
||||
child: NativeSubagent,
|
||||
notification: AcpSessionNotification,
|
||||
toolCallId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (toolCallId) this.rememberToolCallOwner(toolCallId, child);
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The route of the work that a child tool call started, for example an async
|
||||
* task. The route sends each update to the child generation that owned the
|
||||
* tool call when the work started. An async task can outlive that child: a
|
||||
* subagent can start a background command and end its turn. So the route
|
||||
* still sends `async_task_spawned`, `async_task_progress` and
|
||||
* `async_task_state_update` to the child generation after it finished. The route drops every other update
|
||||
* after the child finished. `undefined` means that the root session owns the
|
||||
* tool call. `eagerSessionId` is the session where a permission request
|
||||
* created the tool call before the stream routed it.
|
||||
*/
|
||||
routeOfToolCall(
|
||||
toolCallId: string,
|
||||
eagerSessionId?: string,
|
||||
): ((notification: AcpSessionNotification) => AcpSessionNotification | null) | undefined {
|
||||
if (!this.enabled) return undefined;
|
||||
const owner =
|
||||
this.childByToolCall.get(toolCallId) ??
|
||||
(eagerSessionId ? this.childOfSession(eagerSessionId) : undefined);
|
||||
return (
|
||||
owner &&
|
||||
((notification) =>
|
||||
isLateAsyncTaskUpdate(notification)
|
||||
? { ...notification, sessionId: owner.sessionId }
|
||||
: this.toChild(owner, notification, undefined))
|
||||
);
|
||||
}
|
||||
|
||||
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
|
||||
this.childByToolCall.delete(toolCallId);
|
||||
this.childByToolCall.set(toolCallId, child);
|
||||
if (this.childByToolCall.size > MAX_CHILD_TOOL_CALLS) {
|
||||
const oldest = this.childByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.childByToolCall.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** The child generation with the ACP session `sessionId`, if one exists. */
|
||||
private childOfSession(sessionId: string): NativeSubagent | undefined {
|
||||
if (sessionId === this.rootSessionId) return undefined;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
this.childByToolCall.clear();
|
||||
this.taskByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
@@ -311,6 +413,13 @@ export class NativeSubagentRuntime {
|
||||
this.parentByToolUse.delete(toolUseId);
|
||||
}
|
||||
|
||||
/** The parent of a resumed generation: the old parent while it is live, else the root. */
|
||||
private resumedParentSessionId(previous: NativeSubagent): string {
|
||||
return this.isLiveSession(previous.parentSessionId)
|
||||
? previous.parentSessionId
|
||||
: this.rootSessionId;
|
||||
}
|
||||
|
||||
private isLiveSession(sessionId: string): boolean {
|
||||
if (sessionId === this.rootSessionId) return true;
|
||||
for (const child of this.children.values()) {
|
||||
@@ -319,6 +428,37 @@ export class NativeSubagentRuntime {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new child session for the task and makes it the owner of its
|
||||
* parent tool call. With `announce`, it publishes `subagent_spawned` and
|
||||
* delivers the updates that waited for the child.
|
||||
*/
|
||||
private async openGeneration(
|
||||
taskId: string,
|
||||
previous: NativeSubagent | undefined,
|
||||
fields: Pick<
|
||||
NativeSubagent,
|
||||
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
|
||||
>,
|
||||
announce: boolean,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(taskId, previous),
|
||||
...fields,
|
||||
};
|
||||
const toolUseId = child.parentToolUseId;
|
||||
this.children.set(taskId, child);
|
||||
if (toolUseId) {
|
||||
this.taskByToolUse.set(toolUseId, taskId);
|
||||
this.childByParentToolUse.set(toolUseId, child);
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
}
|
||||
if (!announce) return;
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
@@ -344,6 +484,7 @@ export async function announceNativeSubagent(
|
||||
subagentSessionId: child.sessionId,
|
||||
name: child.name,
|
||||
task: child.task,
|
||||
...promptField(child.prompt),
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
@@ -386,6 +527,41 @@ export async function finishNativeSubagent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent id that a successful SendMessage result resumed. The SDK puts it
|
||||
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
|
||||
*/
|
||||
export function resumedNativeSubagentId(toolUseResult: unknown): string | undefined {
|
||||
if (typeof toolUseResult !== "object" || toolUseResult === null) return undefined;
|
||||
const result = toolUseResult as { success?: unknown; resumedAgentId?: unknown };
|
||||
return result.success === true ? nonBlankString(result.resumedAgentId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage text that resumed the agent `agentId`. The tool uses of
|
||||
* `resultToolUseIds` come first: they are the SendMessage calls whose result
|
||||
* carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
|
||||
*/
|
||||
export function sendMessageResumePrompt(
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
agentId: string,
|
||||
resultToolUseIds: readonly string[] = [],
|
||||
): string | undefined {
|
||||
for (const toolUseId of resultToolUseIds) {
|
||||
const toolUse = toolUses[toolUseId];
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
|
||||
if (text) return text;
|
||||
}
|
||||
if (resultToolUseIds.length > 0) return undefined;
|
||||
for (const toolUse of Object.values(toolUses).reverse()) {
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
|
||||
if (input?.to === agentId) return promptText(input.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function nativeSubagentState(status: unknown): SubagentState | undefined {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
@@ -403,14 +579,31 @@ export function isNativeSubagentControlUpdate(
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const claudeMeta = update._meta?.claudeCode as { subagent?: true; toolName?: string } | undefined;
|
||||
return claudeMeta?.subagent === true || isNativeSubagentControlTool(claudeMeta?.toolName);
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
return (
|
||||
airExtensionMeta(update._meta)?.[AIR_SUBAGENT_KEY] === true ||
|
||||
isNativeSubagentControlTool(claudeMeta?.toolName)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlTool(toolName: unknown): boolean {
|
||||
return toolName === "Agent" || toolName === "Task";
|
||||
}
|
||||
|
||||
/**
|
||||
* An async task update that may reach a child generation after it finished.
|
||||
* The spawn is one of them: a held task can get its tool call id, and so its
|
||||
* spawn, after the child finished.
|
||||
*/
|
||||
function isLateAsyncTaskUpdate(notification: AcpSessionNotification): boolean {
|
||||
const kind = notification.update.sessionUpdate;
|
||||
return (
|
||||
kind === "async_task_spawned" ||
|
||||
kind === "async_task_progress" ||
|
||||
kind === "async_task_state_update"
|
||||
);
|
||||
}
|
||||
|
||||
function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boolean {
|
||||
return (
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
@@ -482,8 +675,14 @@ function ordinaryToolMeta(
|
||||
(value) => (value?.claudeCode as Record<string, unknown> | null | undefined) ?? {},
|
||||
),
|
||||
);
|
||||
delete claudeCode.subagent;
|
||||
return { ...merged, claudeCode };
|
||||
const result: Record<string, unknown> = { ...merged, claudeCode };
|
||||
const air = airExtensionMeta(merged);
|
||||
if (air && AIR_SUBAGENT_KEY in air) {
|
||||
const rest = { ...air };
|
||||
delete rest[AIR_SUBAGENT_KEY];
|
||||
result.jetbrains = { ...(merged.jetbrains as Record<string, unknown>), air: rest };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function subagentDisplayName(
|
||||
@@ -512,7 +711,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
const identity: SubagentIdentity = {
|
||||
name: nonBlankString(value.name),
|
||||
description: nonBlankString(value.description),
|
||||
prompt: nonBlankString(value.prompt),
|
||||
prompt: promptText(value.prompt),
|
||||
subagentType: nonBlankString(value.subagent_type),
|
||||
};
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
@@ -546,6 +745,16 @@ function applySubagentIdentity(
|
||||
if (identity.prompt || identity.description) {
|
||||
child.task = subagentDescription(identity.prompt, identity.description);
|
||||
}
|
||||
child.prompt ??= identity.prompt;
|
||||
}
|
||||
|
||||
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
|
||||
function promptText(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function promptField(prompt: string | undefined): { prompt?: string } {
|
||||
return prompt === undefined ? {} : { prompt };
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
|
||||
7
src/paths.ts
Normal file
7
src/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** The Claude configuration directory: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
|
||||
export function claudeConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
import type { RequestPermissionRequest, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { toolInfoFromToolUse } from "../tools.js";
|
||||
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk";
|
||||
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
|
||||
/**
|
||||
* A permission request as the agent builds it. For a v2 client it also
|
||||
* carries the heading and the explanation of the prompt, which ACP v2 sends as
|
||||
* the request's `title` and `description`. v1 has no field for them; AIR gets
|
||||
* them in `_meta`.
|
||||
*/
|
||||
export type AcpPermissionRequest = RequestPermissionRequest & {
|
||||
/** The heading of the prompt. It is not the title of the tool call. */
|
||||
title?: string;
|
||||
/** Why the permission is asked. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface ClaudePermissionPresentationInput {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
cwd?: string;
|
||||
supportsTerminalOutput?: boolean;
|
||||
capabilities?: ClientCapabilities;
|
||||
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
|
||||
previewContent?: ToolCallContent[];
|
||||
blockedPath?: string;
|
||||
title?: string;
|
||||
displayName?: string;
|
||||
@@ -39,46 +56,33 @@ function compactText(value: unknown): string | undefined {
|
||||
return humanText(value, 160, true);
|
||||
}
|
||||
|
||||
function withBlockedPath(
|
||||
locations: ToolCallLocation[] | undefined,
|
||||
blockedPath: unknown,
|
||||
): ToolCallLocation[] | undefined {
|
||||
const path = humanText(blockedPath, 4_096, true);
|
||||
if (!path) return locations;
|
||||
const result = [...(locations ?? [])];
|
||||
if (!result.some((location) => location.path === path)) result.push({ path });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission request presentation.
|
||||
*
|
||||
* The request `toolCall` carries `toolCallId`, `title`, and `rawInput`. The
|
||||
* adapter emits the `tool_call` before the request, so the client already
|
||||
* holds the rest. The request adds only what it shows new: the exact preview
|
||||
* patch, and the blocked path when the tool call has no such location.
|
||||
*
|
||||
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
|
||||
* v2 client as `title` and `description`.
|
||||
*/
|
||||
export function buildClaudePermissionPresentation(
|
||||
value: ClaudePermissionPresentationInput,
|
||||
): Pick<RequestPermissionRequest, "toolCall" | "_meta"> {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ id: value.toolUseID, name: value.toolName, input: value.input },
|
||||
value.supportsTerminalOutput ?? false,
|
||||
value.cwd,
|
||||
);
|
||||
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
|
||||
const capabilities = value.capabilities ?? new ClientCapabilities();
|
||||
const renderer = new AcpToolCallRenderer(capabilities);
|
||||
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
|
||||
const facts = renderer.facts(toolUse, value.cwd);
|
||||
const host =
|
||||
value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined;
|
||||
const isComputerUse = value.toolName.startsWith("mcp__computer-use__");
|
||||
const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined);
|
||||
const subjectContent =
|
||||
(host || isComputerUse) && info.content.length === 0
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: info.content;
|
||||
// Reuse the exact standard tool-call heading as the permission heading so
|
||||
// the approval never maintains a second, divergent name for the operation.
|
||||
// decisionReason is temporarily exposed as the permission description so
|
||||
// its actual SDK values can be inspected; it remains diagnostic policy text.
|
||||
const toolCallTitle = subjectTitle ?? info.title;
|
||||
const toolCallTitle = subjectTitle ?? facts.title;
|
||||
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
|
||||
// Shell titles are executable input: compacting whitespace changes quoted
|
||||
// arguments and comment boundaries, and length limits can hide the command.
|
||||
@@ -88,31 +92,46 @@ export function buildClaudePermissionPresentation(
|
||||
: (humanText(permissionTitle, 4_000, true) ?? "Use tool?");
|
||||
const decisionReason = humanText(value.decisionReason, 4_000);
|
||||
const description = decisionReason ? `Reason: ${decisionReason}` : undefined;
|
||||
const blockedPath = humanText(value.blockedPath, 4_096, true);
|
||||
const extraLocations =
|
||||
blockedPath && !(facts.locations ?? []).some((location) => location.path === blockedPath)
|
||||
? [{ path: blockedPath }]
|
||||
: undefined;
|
||||
return {
|
||||
toolCall: {
|
||||
toolCallId: value.toolUseID,
|
||||
name: value.toolName,
|
||||
status: "pending",
|
||||
rawInput: value.input,
|
||||
...info,
|
||||
toolCall: renderer.permissionToolCall(toolUse, {
|
||||
cwd: value.cwd,
|
||||
title: toolCallTitle,
|
||||
content: subjectContent,
|
||||
locations: withBlockedPath(info.locations, value.blockedPath),
|
||||
},
|
||||
...(title
|
||||
previewContent: value.previewContent,
|
||||
extraLocations,
|
||||
// The upstream request shows the input of a network or computer-use
|
||||
// request that has no content of its own.
|
||||
fallbackContent:
|
||||
host || isComputerUse
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}),
|
||||
// Only AIR gets the permission presentation.
|
||||
...(title && capabilities.air.client
|
||||
? {
|
||||
_meta: {
|
||||
permission: {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
},
|
||||
},
|
||||
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { access, open, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
/** The size of one backward read of a transcript. */
|
||||
const TAIL_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
type ResumeLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
@@ -27,14 +33,19 @@ export function resumedModelFromTranscript(messages: SessionMessage[]): string |
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const model = (entry.message as { model?: unknown }).model;
|
||||
if (typeof model === "string" && model.trim().length > 0 && !/^<[^>]+>$/.test(model.trim())) {
|
||||
return model.trim();
|
||||
}
|
||||
const model = concreteModel((entry.message as { model?: unknown }).model);
|
||||
if (model) return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model name, or undefined for a missing or a synthetic `<…>` model. */
|
||||
function concreteModel(model: unknown): string | undefined {
|
||||
if (typeof model !== "string") return undefined;
|
||||
const name = model.trim();
|
||||
return name.length > 0 && !/^<[^>]+>$/.test(name) ? name : undefined;
|
||||
}
|
||||
|
||||
/** Read the resume model from the local transcript without starting a Claude
|
||||
* control request. This is intentionally on the load critical path. */
|
||||
export async function readResumedSession(
|
||||
@@ -56,3 +67,177 @@ export async function readResumedSession(
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** What a resume restores from the end of the local transcript. */
|
||||
export type ResumedTail = {
|
||||
model?: string;
|
||||
/** The last permission mode of the main thread. See {@link TailScan}. */
|
||||
permissionMode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the resume model and the permission mode of a session from the end
|
||||
* of its local transcript.
|
||||
*
|
||||
* A resume needs only these two values, not the messages. The transcript file
|
||||
* is read backwards until both are known, so the cost does not grow with the
|
||||
* length of the session. A session without a local transcript file falls back
|
||||
* to {@link readResumedSession} for the model.
|
||||
*
|
||||
* A caller that already has the model passes `withModel: false`. The read then
|
||||
* looks for the permission mode only and has no fallback.
|
||||
*/
|
||||
export async function readResumedTail(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
withModel = true,
|
||||
): Promise<ResumedTail> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
const filePath = await findTranscript(sessionId);
|
||||
if (filePath) {
|
||||
const tail = await lastTailRecords(filePath, withModel);
|
||||
timing.phase(
|
||||
"read-transcript-tail",
|
||||
` model=${tail.model ?? "unknown"} permissionMode=${tail.permissionMode ?? "unknown"}`,
|
||||
);
|
||||
return tail;
|
||||
}
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript-tail", " outcome=error");
|
||||
logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error);
|
||||
}
|
||||
return withModel ? { model: (await readResumedSession(sessionId, logger)).model } : {};
|
||||
}
|
||||
|
||||
/** The local transcript of a session in any project directory, as the SDK looks it up. */
|
||||
export async function findTranscript(sessionId: string): Promise<string | undefined> {
|
||||
const projects = path.join(claudeConfigDir(), "projects");
|
||||
let directories: string[];
|
||||
try {
|
||||
directories = await readdir(projects);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(projects, directory, `${sessionId}.jsonl`);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not in this project directory.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model of the last real assistant record and the last permission mode
|
||||
* of the main thread in a JSONL transcript. */
|
||||
async function lastTailRecords(filePath: string, withModel: boolean): Promise<ResumedTail> {
|
||||
const handle = await open(filePath, "r");
|
||||
const scan = new TailScan(withModel);
|
||||
try {
|
||||
let end = (await handle.stat()).size;
|
||||
// The bytes of the line whose start is not read yet, in file order.
|
||||
let pending: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const start = Math.max(0, end - TAIL_CHUNK_BYTES);
|
||||
let chunk = Buffer.alloc(end - start);
|
||||
await handle.read(chunk, 0, chunk.length, start);
|
||||
for (let at = chunk.lastIndexOf(0x0a); at >= 0; at = chunk.lastIndexOf(0x0a)) {
|
||||
if (scan.visit(Buffer.concat([chunk.subarray(at + 1), ...pending]))) return scan.tail;
|
||||
pending = [];
|
||||
chunk = chunk.subarray(0, at);
|
||||
}
|
||||
pending.unshift(chunk);
|
||||
end = start;
|
||||
}
|
||||
scan.visit(Buffer.concat(pending));
|
||||
return scan.tail;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
type TranscriptRecord = {
|
||||
type?: unknown;
|
||||
isSidechain?: unknown;
|
||||
message?: { model?: unknown };
|
||||
permissionMode?: unknown;
|
||||
origin?: { kind?: unknown };
|
||||
attachment?: { type?: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the lines of a transcript from the end and keeps what a resume needs.
|
||||
*
|
||||
* Claude Code records the permission mode on each prompt record of type `user`.
|
||||
* The `permission-mode` records of the CLI metadata block are not used: they
|
||||
* can disagree with the prompts, and the SDK does not write them.
|
||||
* The SDK writes no record when it leaves plan mode through ExitPlanMode.
|
||||
* It writes a `plan_mode_exit` attachment only, without the new mode. Thus a
|
||||
* `plan` mode before a later plan exit is not the current mode, and the scan
|
||||
* gives no mode.
|
||||
*
|
||||
* The mode search stops at the first mode record or at the last human prompt.
|
||||
* Each human prompt of a current Claude Code has the mode, so a prompt without
|
||||
* it means that the transcript does not record modes. A compaction does not
|
||||
* stop the search: the mode of the prompt before it is still current.
|
||||
*/
|
||||
class TailScan {
|
||||
readonly tail: ResumedTail = {};
|
||||
private modeDone = false;
|
||||
private planExited = false;
|
||||
|
||||
constructor(private readonly withModel: boolean) {}
|
||||
|
||||
/** Visit the previous line. Return true when the scan has all it needs. */
|
||||
visit(line: Buffer): boolean {
|
||||
let parsed: TranscriptRecord | null | undefined;
|
||||
// Most lines have no marker of interest. The check skips their JSON parse.
|
||||
const record = (marker: string): TranscriptRecord | undefined => {
|
||||
if (line.indexOf(marker) < 0) return undefined;
|
||||
if (parsed === undefined) parsed = parseMainThreadRecord(line);
|
||||
return parsed ?? undefined;
|
||||
};
|
||||
if (this.withModel && this.tail.model === undefined) {
|
||||
const entry = record('"assistant"');
|
||||
if (entry?.type === "assistant") this.tail.model = concreteModel(entry.message?.model);
|
||||
}
|
||||
if (!this.modeDone) this.visitForMode(record);
|
||||
return (!this.withModel || this.tail.model !== undefined) && this.modeDone;
|
||||
}
|
||||
|
||||
private visitForMode(record: (marker: string) => TranscriptRecord | undefined): void {
|
||||
const modeEntry = record('"permissionMode"');
|
||||
if (
|
||||
modeEntry?.type === "user" &&
|
||||
typeof modeEntry.permissionMode === "string" &&
|
||||
modeEntry.permissionMode.trim() !== ""
|
||||
) {
|
||||
const mode = modeEntry.permissionMode.trim();
|
||||
this.modeDone = true;
|
||||
if (!(this.planExited && mode === "plan")) this.tail.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
if (record('"plan_mode_exit"')?.attachment?.type === "plan_mode_exit") {
|
||||
this.planExited = true;
|
||||
return;
|
||||
}
|
||||
const human = record('"human"');
|
||||
if (human?.type === "user" && human.origin?.kind === "human") this.modeDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed record of one line, or null for a subagent record or a bad line. */
|
||||
function parseMainThreadRecord(line: Buffer): TranscriptRecord | null {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as TranscriptRecord;
|
||||
return record.isSidechain === true ? null : record;
|
||||
}
|
||||
|
||||
51
src/serve.ts
Normal file
51
src/serve.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
|
||||
@@ -12,6 +12,18 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
return value === undefined || value === "default" ? null : (value as EffortLevel);
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -99,7 +99,7 @@ const AIR_FAILURE_POLICY: Record<
|
||||
// credentials must be refreshed outside Claude. No `login` action, and not
|
||||
// sticky like `auth_required` (nothing clears at `auth_status`).
|
||||
access_denied: {
|
||||
category: "access",
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude could not access the account or provider credentials.",
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
PermissionResult,
|
||||
Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_KIND_KEY, airOnlyMeta } from "./air-extension.js";
|
||||
import { resolvePermissionMode } from "./permissions/modes.js";
|
||||
import {
|
||||
noticeOrTranscriptUpdate,
|
||||
noticeTranscriptText,
|
||||
@@ -56,6 +58,8 @@ export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
* known after `initialize`. */
|
||||
supportsNotices?(): boolean;
|
||||
logError(...args: unknown[]): void;
|
||||
/** Whether the client is AIR. Only AIR gets the mode kind, under `_meta.jetbrains.air`. */
|
||||
airClient?(): boolean;
|
||||
};
|
||||
|
||||
type ModeConfigSession = SessionMode & {
|
||||
@@ -71,6 +75,37 @@ type InitializeSessionModeParams = {
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
allowBypass: boolean,
|
||||
): PermissionMode {
|
||||
if (sources.explicit !== undefined) {
|
||||
return resolvePermissionMode(sources.explicit, logger, allowBypass);
|
||||
}
|
||||
if (sources.resumed !== undefined) {
|
||||
let rejected = false;
|
||||
const mode = resolvePermissionMode(
|
||||
sources.resumed,
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
export class SessionModeManager<S extends SessionMode> {
|
||||
constructor(private readonly options: SessionModeManagerOptions<S>) {}
|
||||
@@ -313,30 +348,36 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
|
||||
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const air = this.options.airClient?.() ?? false;
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
_meta: { kind: "standard" },
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
_meta: { kind: "standard" },
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
_meta: { kind: "plan" },
|
||||
...kind("plan"),
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
_meta: { kind: "auto_review" },
|
||||
...kind("auto_review"),
|
||||
},
|
||||
];
|
||||
if (allowBypass) {
|
||||
@@ -344,7 +385,7 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
|
||||
@@ -260,6 +260,39 @@ function resolveSettingsModel(
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** A model id without the spellings `deniedModels` ignores: provider
|
||||
* prefixes, dates, version suffixes, `-fast` and context hints. */
|
||||
function normalizeDeniedModelId(value: string): string {
|
||||
let id = stripContextHints(value.trim().toLowerCase());
|
||||
const providerPrefix = id.lastIndexOf("anthropic.");
|
||||
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
|
||||
return id
|
||||
.replace(/@.*$/, "")
|
||||
.replace(/-v\d+(?::\d+)?$/, "")
|
||||
.replace(/-fast$/, "")
|
||||
.replace(/-\d{8}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
|
||||
* closely enough for the picker: a family alias blocks the whole family, and a
|
||||
* model id blocks that version in every spelling plus the later minor versions
|
||||
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays
|
||||
* the enforcer; this only keeps the picker from offering a refused row.
|
||||
*/
|
||||
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
|
||||
const id = normalizeDeniedModelId(modelId);
|
||||
if (!id) return false;
|
||||
return deniedModels.some((entry) => {
|
||||
const denied = normalizeDeniedModelId(entry);
|
||||
if (!denied) return false;
|
||||
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
|
||||
return id === denied || id.startsWith(`${denied}-`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
||||
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
||||
@@ -275,11 +308,14 @@ function resolveSettingsModel(
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
|
||||
* dropped even though the CLI would otherwise leave it unmatched.
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
deniedModels: readonly string[] = [],
|
||||
): ModelInfo[] {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
@@ -310,6 +346,13 @@ export function applyAvailableModelsAllowlist(
|
||||
if (seen.has(effective)) continue;
|
||||
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (
|
||||
[trimmed, effective, sdkMatch?.resolvedModel].some(
|
||||
(id) => id != null && isDeniedModel(id, deniedModels),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: effective });
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
* Permission rule format examples:
|
||||
@@ -49,6 +49,7 @@ export interface SettingsManagerOptions {
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -91,7 +92,7 @@ export class SettingsManager {
|
||||
*/
|
||||
private getWatchedPaths(): string[] {
|
||||
return [
|
||||
path.join(CLAUDE_CONFIG_DIR, "settings.json"),
|
||||
path.join(claudeConfigDir(), "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.local.json"),
|
||||
getManagedSettingsPath(),
|
||||
@@ -106,9 +107,14 @@ export class SettingsManager {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
// The CLI reads `deniedModels` from managed settings only.
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,14 @@ export class SettingsManager {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
*/
|
||||
getManagedDeniedModels(): string[] {
|
||||
return this.managedDeniedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
|
||||
54
src/subagent-history.ts
Normal file
54
src/subagent-history.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { findTranscript } from "./resumed-session.js";
|
||||
|
||||
/** Where the subagent transcripts of a session are. */
|
||||
export interface SubagentHistory {
|
||||
/** The subagent id of each Agent or Task tool use that launched a subagent. */
|
||||
ids: Map<string, string>;
|
||||
/**
|
||||
* The project directory of the session, as the SDK takes it in `dir`. With
|
||||
* it, the SDK reads a subagent transcript without a search of every project.
|
||||
*/
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the subagent transcripts of a session.
|
||||
*
|
||||
* Claude keeps the history of a subagent in its own transcript,
|
||||
* `<session>/subagents/agent-<id>.jsonl`, next to the session transcript.
|
||||
* The `agent-<id>.meta.json` file beside it names the launching tool use.
|
||||
* The ids are empty for a session without a local transcript.
|
||||
*/
|
||||
export async function subagentHistory(sessionId: string): Promise<SubagentHistory> {
|
||||
const ids = new Map<string, string>();
|
||||
const transcript = await findTranscript(sessionId);
|
||||
if (!transcript) return { ids };
|
||||
const directory = path.join(path.dirname(transcript), sessionId, "subagents");
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
} catch {
|
||||
return { ids };
|
||||
}
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const match = /^agent-(.+)\.meta\.json$/.exec(name);
|
||||
if (!match) return;
|
||||
try {
|
||||
const meta: unknown = JSON.parse(await readFile(path.join(directory, name), "utf8"));
|
||||
const toolUseId = (meta as { toolUseId?: unknown } | null)?.toolUseId;
|
||||
if (typeof toolUseId === "string") ids.set(toolUseId, match[1]!);
|
||||
} catch {
|
||||
// A damaged meta file leaves its subagent without history.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (ids.size === 0) return { ids };
|
||||
// The folder name encodes the project directory, and the encoding loses
|
||||
// characters. The session info reads it back from the transcript.
|
||||
const dir = (await getSessionInfo(sessionId).catch(() => undefined))?.cwd;
|
||||
return dir ? { ids, dir } : { ids };
|
||||
}
|
||||
@@ -154,6 +154,51 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a settings-requested ultracode on when applying effort", async () => {
|
||||
// Since CLI 2.1.284 an effortLevel apply without an ultracode key turns
|
||||
// ultracode off; the CLI's own effort control keeps it on.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ ultracode: true }),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high", ultracode: true });
|
||||
});
|
||||
|
||||
it("seeds effort from higher-priority programmatic model settings", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
@@ -354,6 +399,142 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
);
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
const transcriptDir = path.join(tempDir, "projects", "-project");
|
||||
await fs.promises.mkdir(transcriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(transcriptDir, `${sessionId}.jsonl`),
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup(records);
|
||||
const response = await (agent as any).createSession(
|
||||
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true, ...meta } },
|
||||
{ resume: sessionId },
|
||||
);
|
||||
return { permissionMode: getCapturedOptions().permissionMode, response };
|
||||
}
|
||||
|
||||
let previousUuid: string | null = null;
|
||||
const user = (permissionMode?: string) => {
|
||||
const uuid = crypto.randomUUID();
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid,
|
||||
parentUuid: previousUuid,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isSidechain: false,
|
||||
origin: { kind: "human" },
|
||||
message: { role: "user", content: "hello" },
|
||||
...(permissionMode ? { permissionMode } : {}),
|
||||
};
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
|
||||
it("continues in the last permission mode of the transcript", async () => {
|
||||
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
|
||||
|
||||
expect(permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when the transcript has no mode", async () => {
|
||||
const { permissionMode, response } = await resume([user()]);
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when bypass is not available now", async () => {
|
||||
const { permissionMode, response } = await resume([user("bypassPermissions")], {
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
});
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("continues in the last permission mode on session/load", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([
|
||||
user("plan"),
|
||||
user("acceptEdits"),
|
||||
]);
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes?.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("keeps the live mode when it recreates the session", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
const params = { sessionId, cwd: projectDir, _meta: { disableBuiltInTools: true } };
|
||||
await agent.resumeSession({ ...params, mcpServers: [] });
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
|
||||
// Other MCP servers change the fingerprint, so the agent recreates the query.
|
||||
const response = await agent.resumeSession({
|
||||
...params,
|
||||
mcpServers: [{ name: "other", command: "true", args: [], env: [] }],
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes?.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("keeps the live mode when a sign-out respawn starts a fresh conversation", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
await agent.resumeSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
const { RequestError } = await import("@agentclientprotocol/sdk");
|
||||
const createSession = (agent as any).createSession.bind(agent);
|
||||
const creations: object[] = [];
|
||||
(agent as any).createSession = async (params: object, options: { resume?: string }) => {
|
||||
creations.push(options);
|
||||
// The CLI never wrote the conversation, so the resume fails.
|
||||
if (options.resume) throw RequestError.resourceNotFound(sessionId);
|
||||
return createSession(params, options);
|
||||
};
|
||||
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]);
|
||||
|
||||
expect(creations).toEqual([
|
||||
{ resume: sessionId, permissionMode: "default" },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default" },
|
||||
]);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
@@ -488,30 +669,27 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
// A client that is not AIR gets no mode kind.
|
||||
expect(response.modes.availableModes.slice(0, 4)).toEqual([
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
_meta: { kind: "plan" },
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
_meta: { kind: "auto_review" },
|
||||
},
|
||||
]);
|
||||
const bypass = response.modes.availableModes[4];
|
||||
@@ -520,7 +698,6 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
});
|
||||
}
|
||||
expect(modeIds).not.toContain("dontAsk");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
98
src/tests/acp-scenarios-compare.test.ts
Normal file
98
src/tests/acp-scenarios-compare.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareWithBaseline } from "./acp-scenarios/compare.js";
|
||||
import type { Recorded } from "./acp-scenarios/harness.js";
|
||||
|
||||
function update(fields: Record<string, unknown>): Recorded {
|
||||
return { kind: "sessionUpdate", payload: { sessionId: "s", update: fields } };
|
||||
}
|
||||
|
||||
const toolCall = update({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "pending",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
});
|
||||
|
||||
const result = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "completed",
|
||||
rawOutput: "text",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
};
|
||||
|
||||
const baseline = [toolCall, update(result)];
|
||||
|
||||
/** A copy of `value` without `key`. */
|
||||
function omit(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const copy = { ...value };
|
||||
delete copy[key];
|
||||
return copy;
|
||||
}
|
||||
|
||||
describe("compareWithBaseline", () => {
|
||||
it("accepts the baseline itself", () => {
|
||||
expect(compareWithBaseline(baseline, baseline)).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts an update without a field that did not change", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "title"))])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a dropped _meta key", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "_meta"))])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"toolName":"Read"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed rawOutput", () => {
|
||||
const changed = update({ ...result, rawOutput: "other" });
|
||||
expect(compareWithBaseline(baseline, [toolCall, changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"rawOutput":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an extra update", () => {
|
||||
const extra = update({ sessionUpdate: "tool_call_update", toolCallId: "t", status: "failed" });
|
||||
expect(compareWithBaseline(baseline, [...baseline, extra])).toEqual([
|
||||
expect.stringMatching(/^origin\/main did not send /u),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("available_commands_update", () => {
|
||||
const commands = (...names: string[]) =>
|
||||
update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: names.map((name) => ({ name, description: name })),
|
||||
});
|
||||
|
||||
it("accepts the mcp command of the adapter", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("compact", "mcp")])).toEqual([]);
|
||||
expect(compareWithBaseline([commands()], [commands("mcp")])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports any other extra command", () => {
|
||||
expect(compareWithBaseline([commands()], [commands("mcp", "other")])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"name":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a dropped command", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("mcp")])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"name":"compact"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed mcp command that origin/main listed", () => {
|
||||
const changed = update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "mcp", description: "other" }],
|
||||
});
|
||||
expect(compareWithBaseline([commands("mcp")], [changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"description":"other"/u),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/tests/acp-scenarios-v2.test.ts
Normal file
171
src/tests/acp-scenarios-v2.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
* a scenario that the v2 surface cannot run yet goes in the table.
|
||||
* - Schema: every message the agent sends, also in a scenario that stops, is
|
||||
* valid against the draft v2 schema of the SDK.
|
||||
* - Tool calls: the first report of each tool call has a title. v2 has no
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, 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 { fileURLToPath } from "node:url";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
type ScenarioRunV2,
|
||||
type WireRecorded,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import { canonical } from "./acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "./acp-scenarios/schema.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
const statusOf = (scenario: string) => V2_STOPS[scenario] ?? PASSES;
|
||||
|
||||
const runs = new Map<string, ScenarioRunV2>();
|
||||
let configDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
await Promise.all(agents.map((agent) => agent.dispose()));
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(scenario: string): ScenarioRunV2 {
|
||||
const recorded = runs.get(scenario);
|
||||
if (!recorded) throw new Error(`no v2 run of ${scenario}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function toJsonLines(recorded: WireRecorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
describe("ACP v2 scenarios", () => {
|
||||
it("pass, or stop as the table says", () => {
|
||||
const table = (status: (scenario: string) => string) =>
|
||||
Object.fromEntries(SCENARIOS.map((scenario) => [scenario.name, status(scenario.name)]));
|
||||
expect(table((scenario) => run(scenario).status)).toEqual(table(statusOf));
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const chunk = (update: Record<string, unknown>): WireRecorded => ({
|
||||
kind: "notification",
|
||||
method: "session/update",
|
||||
payload: { sessionId: "s", update: { sessionUpdate: "agent_message_chunk", ...update } },
|
||||
});
|
||||
|
||||
it("rejects a message that breaks the v2 schema", () => {
|
||||
const content = { type: "text", text: "hi" };
|
||||
expect(validateV2Message(chunk({ messageId: "m", content }))).toEqual([]);
|
||||
// v2 requires the id of the message a chunk belongs to.
|
||||
expect(validateV2Message(chunk({ content }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
expect(run(scenario).raw.flatMap((message) => validateV2Message(message))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls are reported with a title first", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
const reported = new Set<string>();
|
||||
const untitled: string[] = [];
|
||||
for (const message of run(scenario).raw) {
|
||||
if (message.kind !== "notification" || message.method !== "session/update") continue;
|
||||
const { sessionId, update } = message.payload as {
|
||||
sessionId: string;
|
||||
update: { sessionUpdate: string; toolCallId?: string; title?: unknown };
|
||||
};
|
||||
if (update.sessionUpdate !== "tool_call_update") continue;
|
||||
const key = `${sessionId} ${update.toolCallId}`;
|
||||
if (!reported.has(key) && typeof update.title !== "string") untitled.push(key);
|
||||
reported.add(key);
|
||||
}
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 golden files", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
994
src/tests/acp-scenarios.test.ts
Normal file
994
src/tests/acp-scenarios.test.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
/**
|
||||
* The outbound ACP traffic of every scenario in `acp-scenarios/scenarios.ts`,
|
||||
* for three client profiles: a plain ACP client, Zed, and JetBrains AIR.
|
||||
*
|
||||
* - Recordings are JSON Lines: one outbound message on each line, with sorted
|
||||
* keys. `normalize` in `harness.ts` replaces only exact run-specific values:
|
||||
* the generated ids, the paths, and the argv and version of this process.
|
||||
* - AIR golden files: `acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
|
||||
* Run `npx vitest run src/tests/acp-scenarios.test.ts -u` to update them.
|
||||
* - Schema: every message is valid against the ACP schema of the SDK.
|
||||
* - Plain and Zed: the same information as origin/main
|
||||
* (`acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
|
||||
* allows only the documented differences. These profiles have no golden
|
||||
* files of their own. `origin-main/zed/` holds a scenario only when its
|
||||
* recording differs from the plain client.
|
||||
* - Zed: the Zed conventions and the upstream `_meta` keys.
|
||||
* - AIR: the AIR extensions of `docs/air-extensions.md`, each fact once.
|
||||
*
|
||||
* To record the origin/main baseline again, copy `src/tests/acp-scenarios/`
|
||||
* and this file into a checkout of origin/main, and run this file there with
|
||||
* `ACP_SCENARIO_BASELINE_DIR=<dir>`. Then copy `<dir>/plain` and `<dir>/zed`
|
||||
* to `acp-scenarios/origin-main/`.
|
||||
*/
|
||||
import { afterAll, beforeAll, 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 { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AIR_CAPABILITY_NAMES,
|
||||
assistantTurn,
|
||||
normalize,
|
||||
PROFILES,
|
||||
type Profile,
|
||||
type Recorded,
|
||||
resetIds,
|
||||
result,
|
||||
runScenario,
|
||||
type Scenario,
|
||||
type ScenarioRun,
|
||||
streamMessage,
|
||||
toolCall,
|
||||
toolResult,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import {
|
||||
AIR_ONLY_CLAUDE_CODE_KEYS,
|
||||
AIR_ONLY_META_KEYS,
|
||||
canonical,
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baselineDir = process.env.ACP_SCENARIO_BASELINE_DIR;
|
||||
const profiles = Object.values(PROFILES);
|
||||
const runs = new Map<string, ScenarioRun>();
|
||||
const key = (profile: Profile["name"], scenario: string) => `${profile}/${scenario}`;
|
||||
let configDir: string;
|
||||
let Agent: Parameters<typeof runScenario>[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
Agent = ClaudeAcpAgent;
|
||||
for (const profile of profiles) {
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
runs.set(
|
||||
key(profile.name, scenario.name),
|
||||
await runScenario(ClaudeAcpAgent, profile, scenario),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A recording as JSON Lines: one message on each line, with sorted keys. */
|
||||
function toJsonLines(recorded: Recorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
function fromJsonLines(text: string): Recorded[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Recorded);
|
||||
}
|
||||
|
||||
function run(profile: Profile["name"], scenario: string): ScenarioRun {
|
||||
const recorded = runs.get(key(profile, scenario));
|
||||
if (!recorded) throw new Error(`no run of ${scenario} for ${profile}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Runs one more scenario for AIR without some AIR capabilities. */
|
||||
async function runAir(scenario: Scenario, without: string[] = []): Promise<Recorded[]> {
|
||||
resetIds();
|
||||
const profile: Profile = {
|
||||
name: "air",
|
||||
capabilities: {
|
||||
...PROFILES.air.capabilities,
|
||||
_meta: {
|
||||
...PROFILES.air.capabilities._meta,
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: AIR_CAPABILITY_NAMES.filter((name) => !without.includes(name)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return (await runScenario(Agent, profile, scenario)).raw;
|
||||
}
|
||||
|
||||
function updates(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => (record.payload as { update: Record<string, any> }).update);
|
||||
}
|
||||
|
||||
function toolCallReports(recorded: Recorded[], toolCallId?: string): Record<string, any>[] {
|
||||
return updates(recorded).filter(
|
||||
(update) =>
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
(toolCallId === undefined || update.toolCallId === toolCallId),
|
||||
);
|
||||
}
|
||||
|
||||
function permissionRequests(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "requestPermission")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
}
|
||||
|
||||
/** Every `_meta` object in a message, with the path to it. */
|
||||
function metaObjects(value: unknown, at = "$"): { at: string; meta: Record<string, unknown> }[] {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
if (Array.isArray(value)) return value.flatMap((item, i) => metaObjects(item, `${at}[${i}]`));
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([k, item]) => [
|
||||
...(k === "_meta" && item && typeof item === "object" && !Array.isArray(item)
|
||||
? [{ at: `${at}._meta`, meta: item as Record<string, unknown> }]
|
||||
: []),
|
||||
...metaObjects(item, `${at}.${k}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The origin/main recording of a scenario. Zed has a file only where it differs from the plain client. */
|
||||
function readBaseline(profile: "plain" | "zed", scenario: string): string {
|
||||
const file = (name: string) =>
|
||||
path.join(here, "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
|
||||
return fs.readFileSync(
|
||||
profile === "zed" && !fs.existsSync(file("zed")) ? file("plain") : file(profile),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe.runIf(baselineDir)("the origin/main baseline", () => {
|
||||
it("writes the recordings of the plain and Zed profiles", () => {
|
||||
for (const profile of ["plain", "zed"] as const) {
|
||||
fs.mkdirSync(path.join(baselineDir!, profile), { recursive: true });
|
||||
for (const scenario of SCENARIOS) {
|
||||
const text = toJsonLines(run(profile, scenario.name).normalized);
|
||||
if (profile === "zed" && text === toJsonLines(run("plain", scenario.name).normalized)) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(path.join(baselineDir!, profile, `${scenario.name}.jsonl`), text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
describe("normalize", () => {
|
||||
const cwd = "/tmp/acp-scenario-x";
|
||||
const session = "aaaaaaaa-0000-4000-8000-000000000001";
|
||||
const first = "bbbbbbbb-0000-4000-8000-000000000002";
|
||||
const second = "cccccccc-0000-4000-8000-000000000003";
|
||||
const scripted = "00000000-0000-4000-8000-00000000c001";
|
||||
const version = JSON.parse(
|
||||
fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"),
|
||||
).version;
|
||||
const argv = process.argv.slice(1);
|
||||
|
||||
it("keeps a UUID that the run did not generate", () => {
|
||||
expect(normalize({ toolCallId: scripted }, cwd, session, new Set([first]))).toEqual({
|
||||
toolCallId: scripted,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the generated ids in the order of their first appearance", () => {
|
||||
const generated = new Set([first, second]);
|
||||
const reports = (ids: string[]) =>
|
||||
normalize(
|
||||
ids.map((id) => ({ sessionId: session, toolCallId: id })),
|
||||
cwd,
|
||||
session,
|
||||
generated,
|
||||
);
|
||||
expect(reports([first, first])).toEqual([
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
]);
|
||||
// A report that names another generated id, or the session id, still differs.
|
||||
expect(reports([first, second])).not.toEqual(reports([first, first]));
|
||||
expect(reports([first, session])).not.toEqual(reports([first, first]));
|
||||
});
|
||||
|
||||
it("replaces only the exact argv prefix of this process", () => {
|
||||
expect(argv.length).toBeGreaterThan(0);
|
||||
expect(normalize({ args: [...argv, "--cli", "login"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--cli", "login"],
|
||||
});
|
||||
// An extra argument stays in the recording, before or after the prefix.
|
||||
expect(normalize({ args: [...argv, "--inspect", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--inspect", "--cli"],
|
||||
});
|
||||
const before = ["--inspect", ...argv, "--cli"];
|
||||
const { args } = normalize({ args: before }, cwd, session) as { args: string[] };
|
||||
expect(args).toHaveLength(before.length);
|
||||
expect(args).not.toContain("<argv>");
|
||||
expect(normalize({ args: ["other.js", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["other.js", "--cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces only the version of this adapter and the path of this Node binary", () => {
|
||||
expect(normalize({ version, command: process.execPath }, cwd, session)).toEqual({
|
||||
version: "<version>",
|
||||
command: "<executable>",
|
||||
});
|
||||
expect(normalize({ version: "9.9.9", command: "/usr/bin/node" }, cwd, session)).toEqual({
|
||||
version: "9.9.9",
|
||||
command: "/usr/bin/node",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps durations and other numbers", () => {
|
||||
expect(normalize({ durationMs: 7, totalDurationMs: 10 }, cwd, session)).toEqual({
|
||||
durationMs: 7,
|
||||
totalDurationMs: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const none = new Set<string>();
|
||||
const elicitation = (url: string): Recorded => ({
|
||||
kind: "createElicitation",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
message: "Sign in",
|
||||
mode: "url",
|
||||
elicitationId: "e",
|
||||
url,
|
||||
},
|
||||
});
|
||||
const location = (line: number): Recorded => ({
|
||||
kind: "sessionUpdate",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
locations: [{ path: "/a.ts", line }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects a value that breaks a standard format", () => {
|
||||
expect(validateRecorded(elicitation("https://example.com/login"), none)).toEqual([]);
|
||||
expect(validateRecorded(elicitation("not a uri"), none)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a value that breaks an ACP integer format", () => {
|
||||
expect(validateRecorded(location(2 ** 32 - 1), none)).toEqual([]);
|
||||
expect(validateRecorded(location(2 ** 32), none)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("air golden files", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run("air", scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s ACP schema", (profile) => {
|
||||
const extensions = profile === "air" ? EXTENSION_SESSION_UPDATES : new Set<string>();
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends only valid messages",
|
||||
(scenario) => {
|
||||
const errors = run(profile, scenario).raw.flatMap((record) =>
|
||||
validateRecorded(record, extensions),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s session ids", (profile) => {
|
||||
// Only AIR negotiates native subagent sessions. There a child session
|
||||
// gets updates after its parent announced it with `subagent_spawned`.
|
||||
const nativeSubagents = profile === "air";
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends every session/update to the ACP session",
|
||||
(scenario) => {
|
||||
const { sessionId, raw } = run(profile, scenario);
|
||||
const sessions = new Set([sessionId]);
|
||||
const wrong: string[] = [];
|
||||
for (const record of raw) {
|
||||
if (record.kind !== "sessionUpdate") continue;
|
||||
const payload = record.payload as { sessionId: string; update: Record<string, any> };
|
||||
if (!sessions.has(payload.sessionId)) {
|
||||
wrong.push(`${payload.sessionId}: ${payload.update.sessionUpdate}`);
|
||||
continue;
|
||||
}
|
||||
if (nativeSubagents && payload.update.sessionUpdate === "subagent_spawned") {
|
||||
sessions.add(payload.update.subagentSessionId);
|
||||
}
|
||||
}
|
||||
expect(wrong).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(["plain", "zed"] as const)("%s client", (profile) => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s carries the same information as origin/main",
|
||||
(scenario) => {
|
||||
const baseline = fromJsonLines(readBaseline(profile, scenario));
|
||||
expect(compareWithBaseline(baseline, run(profile, scenario).normalized)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends no key that exists only for AIR",
|
||||
(scenario) => {
|
||||
const found = metaObjects(run(profile, scenario).raw).flatMap(({ at, meta }) => [
|
||||
...Object.keys(meta)
|
||||
.filter((k) => AIR_ONLY_META_KEYS.has(k))
|
||||
.map((k) => `${at}.${k}`),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {})
|
||||
.filter((k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k))
|
||||
.map((k) => `${at}.claudeCode.${k}`),
|
||||
]);
|
||||
expect(found).toEqual([]);
|
||||
expect(
|
||||
updates(run(profile, scenario).raw).filter((update) =>
|
||||
EXTENSION_SESSION_UPDATES.has(update.sessionUpdate),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("plain client", () => {
|
||||
it("gets the Bash output as a code block and the description as content", () => {
|
||||
const reports = toolCallReports(run("plain", "bash-foreground").raw, "toolu_bash");
|
||||
expect(reports[0]).not.toHaveProperty("_meta.terminal_info");
|
||||
expect(reports.some((r) => JSON.stringify(r.content ?? []).includes("List files"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reports.at(-2)).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "```console\na.ts\nb.ts\n```" } },
|
||||
],
|
||||
rawOutput: "a.ts\nb.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets the streamed partial input as rawInput, and the Write text in rawInput", () => {
|
||||
const reports = toolCallReports(run("plain", "write-new").raw, "toolu_write");
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call", rawInput: {} });
|
||||
expect(reports.some((r) => r.rawInput?.file_path && !("content" in r.rawInput))).toBe(true);
|
||||
expect(reports.some((r) => r.rawInput?.content === "export const x = 1;\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("gets the whole tool call again in a permission request", () => {
|
||||
const [request] = permissionRequests(run("plain", "edit-with-permission").raw);
|
||||
expect(request.toolCall).toMatchObject({
|
||||
toolCallId: "toolu_edit",
|
||||
name: "Edit",
|
||||
status: "pending",
|
||||
kind: "edit",
|
||||
title: "Edit src/app.ts",
|
||||
rawInput: {
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
content: [{ type: "diff", oldText: "const value = 1;", newText: "const value = 2;" }],
|
||||
});
|
||||
expect(request).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("gets the question of a single AskUserQuestion as the title", () => {
|
||||
const reports = toolCallReports(run("plain", "ask-user-question").raw, "toolu_ask");
|
||||
expect(reports.map((r) => r.title)).toContain("Which database?");
|
||||
});
|
||||
|
||||
it("gets the upstream NotebookEdit rendering and the result text", () => {
|
||||
const reports = toolCallReports(run("plain", "notebook-edit").raw, "toolu_nb");
|
||||
expect(reports[0]).toMatchObject({ title: "NotebookEdit", kind: "other", content: [] });
|
||||
expect(reports.at(-1)).toMatchObject({
|
||||
status: "completed",
|
||||
rawOutput: "Updated cell cell-1 with print('hi')",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets every Task* plan, also a repeated one", () => {
|
||||
const plans = updates(run("plain", "task-create-update").raw).filter(
|
||||
(update) => update.sessionUpdate === "plan",
|
||||
);
|
||||
expect(plans.map((plan) => plan.entries[0]?.status)).toEqual([
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zed", () => {
|
||||
const zed = (scenario: string) => run("zed", scenario).raw;
|
||||
|
||||
it("keeps terminal_info, terminal_output, and terminal_exit", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
content: [{ type: "terminal", terminalId: "toolu_bash" }],
|
||||
_meta: { terminal_info: { terminal_id: "toolu_bash" } },
|
||||
});
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
_meta: expect.objectContaining({
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
for (const report of reports) {
|
||||
expect(report._meta ?? {}).not.toHaveProperty("terminal_output_delta");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the upstream claudeCode keys and the full PostToolUse toolResponse", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]._meta.claudeCode).toEqual({ toolName: "Bash" });
|
||||
expect(reports.at(-1)?._meta.claudeCode).toEqual({
|
||||
toolName: "Bash",
|
||||
toolResponse: { stdout: "a.ts\nb.ts", stderr: "", interrupted: false, isImage: false },
|
||||
});
|
||||
const child = toolCallReports(zed("subagent-task-legacy"), "toolu_sub_read");
|
||||
expect(child[0]._meta.claudeCode).toEqual({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
// ACP does not merge `_meta` keys, so each update names the parent again.
|
||||
for (const report of child) {
|
||||
expect(report._meta.claudeCode).toMatchObject({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
}
|
||||
const [childRequest] = permissionRequests(zed("subagent-task-legacy"));
|
||||
expect(childRequest.toolCall._meta).toEqual({
|
||||
claudeCode: { toolName: "Bash", parentToolUseId: "toolu_task" },
|
||||
});
|
||||
const [mcpRequest] = permissionRequests(zed("mcp-tool"));
|
||||
expect(mcpRequest.toolCall._meta).toEqual({
|
||||
claudeCode: {
|
||||
toolName: "mcp__docs__search",
|
||||
mcpServer: { name: "docs", source: "project" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the full permission denial toolResponse", () => {
|
||||
const denial = toolCallReports(zed("permission-denied"), "toolu_denied").find(
|
||||
(report) => report.status === "failed",
|
||||
);
|
||||
expect(denial?._meta.claudeCode.toolResponse).toEqual({
|
||||
decisionReasonType: "rule",
|
||||
decisionReason: "Denied by rule Bash(rm:*)",
|
||||
message: "Permission to use Bash has been denied.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the _claude/* keys", () => {
|
||||
const [elicitation] = zed("ask-user-question")
|
||||
.filter((record) => record.kind === "createElicitation")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
const options = elicitation.requestedSchema.properties.question_0.oneOf;
|
||||
expect(options[0]._meta).toHaveProperty(["_claude/askUserQuestionOption"]);
|
||||
const usage = updates(zed("rate-limit-and-origin")).filter(
|
||||
(update) => update.sessionUpdate === "usage_update",
|
||||
);
|
||||
expect(usage.some((update) => update._meta?.["_claude/rateLimit"])).toBe(true);
|
||||
expect(usage.some((update) => update._meta?.["_claude/origin"])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps promptQueueing and steering", () => {
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize.agentCapabilities._meta.claudeCode).toEqual({ promptQueueing: true });
|
||||
expect(initialize._meta).toEqual({ steering: { supported: true } });
|
||||
});
|
||||
|
||||
it("sends terminal-auth commands that rerun this adapter with --cli", () => {
|
||||
// The raw traffic, because the recordings normalize the machine paths.
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
const argv = process.argv.slice(1);
|
||||
expect(initialize.authMethods).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-ai-login",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "console-login",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AIR", () => {
|
||||
const air = (scenario: string) => run("air", scenario).raw;
|
||||
const airMeta = (meta: Record<string, any> | undefined) => meta?.jetbrains?.air;
|
||||
|
||||
it("gets the AIR capabilities and the goal capability under jetbrains.air", () => {
|
||||
const initialize = air("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: expect.arrayContaining(
|
||||
AIR_CAPABILITY_NAMES.filter((name) => name !== "rawInputRendering"),
|
||||
),
|
||||
goal: expect.objectContaining({ version: 1 }),
|
||||
},
|
||||
},
|
||||
steering: { supported: true },
|
||||
});
|
||||
const session = air("session-setup").find((record) => record.kind === "newSession")!
|
||||
.payload as Record<string, any>;
|
||||
for (const mode of session.modes.availableModes) {
|
||||
expect(mode._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, kind: expect.any(String) } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("gets commandTitle, subagent, and skill under jetbrains.air, each once", async () => {
|
||||
const bash = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(bash.filter((r) => airMeta(r._meta)?.commandTitle === "List files")).toHaveLength(1);
|
||||
// A native subagent session replaces the tool call of the subagent, so
|
||||
// the marker shows without native subagent sessions.
|
||||
const [nested] = toolCallReports(
|
||||
await runAir(
|
||||
{
|
||||
name: "nested-agent",
|
||||
turns: [
|
||||
async function* () {
|
||||
yield* assistantTurn("msg_nested", [
|
||||
{ type: "tool_use", id: "toolu_nested", name: "Agent", input: { prompt: "Go" } },
|
||||
]);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
},
|
||||
["nativeSubagentSessions"],
|
||||
),
|
||||
"toolu_nested",
|
||||
);
|
||||
expect(airMeta(nested?._meta)?.subagent).toBe(true);
|
||||
const skill = toolCallReports(air("skill"), "toolu_skill");
|
||||
expect(skill.filter((r) => airMeta(r._meta)?.skill)).toEqual([
|
||||
expect.objectContaining({
|
||||
_meta: expect.objectContaining({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
skill: { name: "commits", path: expect.stringMatching(/SKILL\.md$/) },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("gets the permission, customAnswer, goal, and contextCompaction keys", () => {
|
||||
const [request] = permissionRequests(air("bash-foreground"));
|
||||
expect(request._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, permission: { version: 1, title: "ls -la" } } },
|
||||
});
|
||||
const elicitation = air("ask-user-question").find(
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
});
|
||||
const goal = updates(air("goal")).find((u) => u.sessionUpdate === "session_info_update");
|
||||
expect(goal?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
goal: expect.objectContaining({ objective: "Ship the feature", status: "active" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const compaction = toolCallReports(air("compaction-legacy"));
|
||||
expect(compaction.at(-1)?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
contextCompaction: expect.objectContaining({ version: 1, preTokens: 1000 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(compaction.at(-1)).not.toHaveProperty("rawOutput");
|
||||
});
|
||||
|
||||
it("sends no upstream copy of an AIR key", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
];
|
||||
expect(duplicates, `${scenario.name} ${at}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sends each tool fact in one field", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const report of toolCallReports(air(scenario.name))) {
|
||||
const where = `${scenario.name} ${report.toolCallId}`;
|
||||
// Output is never copied into rawOutput when content carries it.
|
||||
if (report.rawOutput !== undefined) {
|
||||
expect(report.content ?? [], where).toEqual([]);
|
||||
}
|
||||
// Terminal output goes to deltas, never to snapshots.
|
||||
expect(report._meta ?? {}, where).not.toHaveProperty("terminal_output");
|
||||
}
|
||||
}
|
||||
const write = toolCallReports(air("write-new"), "toolu_write");
|
||||
for (const report of write) expect(report.rawInput ?? {}).not.toHaveProperty("content");
|
||||
expect(write[0]).not.toHaveProperty("rawInput");
|
||||
});
|
||||
|
||||
describe("ExitPlanMode with a plan file", () => {
|
||||
const plan = "# Plan\n1. Do it";
|
||||
/**
|
||||
* The CLI streams the input that the model wrote, which has no plan.
|
||||
* The complete message and canUseTool get the text and the path of the
|
||||
* plan file. The structured result names the file again.
|
||||
*/
|
||||
const planFileScenario = (options: { file: boolean; reject?: boolean }): Scenario => ({
|
||||
name: "exit-plan-file",
|
||||
...(options.file ? { files: { "plans/plan.md": plan } } : {}),
|
||||
...(options.reject ? { permission: "reject_once" } : {}),
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const planFilePath = path.join(ctx.cwd, "plans", "plan.md");
|
||||
const input = { plan, planFilePath };
|
||||
yield* streamMessage("msg_toolu_plan", [
|
||||
{ type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input: {} },
|
||||
]);
|
||||
yield* toolCall(
|
||||
ctx,
|
||||
{ id: "toolu_plan", name: "ExitPlanMode", input },
|
||||
options.reject
|
||||
? {
|
||||
ask: true,
|
||||
isError: true,
|
||||
content: "```\nThe user doesn't want to proceed with this tool use.\n```",
|
||||
}
|
||||
: {
|
||||
ask: true,
|
||||
content: `User has approved your plan.\n\n## Approved Plan:\n${plan}`,
|
||||
structured: { plan, isAgent: false, filePath: planFilePath },
|
||||
},
|
||||
);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
});
|
||||
it("sends the path and no plan text in every report", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
const withInput = [...reports, request.toolCall].filter((r) => "rawInput" in r);
|
||||
expect(withInput.length).toBeGreaterThan(0);
|
||||
for (const report of withInput) {
|
||||
expect(report.rawInput).toEqual({ planFilePath: expect.stringMatching(/plan\.md$/) });
|
||||
expect(path.isAbsolute(report.rawInput.planFilePath)).toBe(true);
|
||||
}
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call" });
|
||||
expect(reports[0]).not.toHaveProperty("rawInput");
|
||||
expect(reports.at(-1)).toMatchObject({ status: "completed", title: "Exited Plan Mode" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the path with a rejection", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true, reject: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ planFilePath: expect.any(String) });
|
||||
expect(reports.at(-1)).toMatchObject({ status: "failed" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the plan text when the plan file does not exist", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: false }));
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the plan text to an AIR client without planFile", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }), ["planFile"]);
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the whole input to a client that is not AIR", async () => {
|
||||
for (const profile of [PROFILES.plain, PROFILES.zed]) {
|
||||
resetIds();
|
||||
const recorded = (await runScenario(Agent, profile, planFileScenario({ file: true })))
|
||||
.raw;
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
expect(request.toolCall.content).toEqual([
|
||||
{ type: "content", content: { type: "text", text: plan } },
|
||||
]);
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
expect(reports.some((r) => r.rawInput?.plan === plan)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("drops every update of a tool call of a finished child session", () => {
|
||||
const recorded = air("subagent-late-child-update");
|
||||
const finished = recorded.findIndex(
|
||||
(record) =>
|
||||
record.kind === "sessionUpdate" &&
|
||||
(record.payload as { update: Record<string, any> }).update.sessionUpdate ===
|
||||
"subagent_state_update",
|
||||
);
|
||||
expect(finished).toBeGreaterThan(0);
|
||||
const late = recorded
|
||||
.slice(finished)
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => record.payload as { sessionId: string; update: Record<string, any> })
|
||||
.filter(({ update }) => update.toolCallId === "toolu_late_read");
|
||||
expect(late).toEqual([]);
|
||||
for (const record of recorded.filter((r) => r.kind === "sessionUpdate")) {
|
||||
const { sessionId, update } = record.payload as {
|
||||
sessionId: string;
|
||||
update: Record<string, any>;
|
||||
};
|
||||
if (update.toolCallId === "toolu_late_read") expect(sessionId).toBe("agent_late");
|
||||
}
|
||||
});
|
||||
|
||||
it("gets the Write patch, but no file text in the PostToolUse toolResponse", async () => {
|
||||
const scenario = writtenFileScenario();
|
||||
const hookReport = (recorded: Recorded[]) =>
|
||||
toolCallReports(recorded, "toolu_write").find(
|
||||
(report) =>
|
||||
report._meta?.claudeCode?.toolResponse !== undefined || report.content?.[0]?._meta,
|
||||
);
|
||||
const airReport = hookReport(await runAir(scenario));
|
||||
expect(airReport?.content?.[0]?._meta?.jetbrains?.air?.diffPatch?.text).toContain(
|
||||
"-export const x = 0;\n+export const x = 1;\n",
|
||||
);
|
||||
expect(airReport?._meta?.claudeCode?.toolResponse).toBeUndefined();
|
||||
expect(JSON.stringify(airReport)).not.toContain("originalFile");
|
||||
// A client that is not AIR keeps the full toolResponse of origin/main.
|
||||
resetIds();
|
||||
const plain = (await runScenario(Agent, PROFILES.plain, scenario)).raw;
|
||||
expect(hookReport(plain)?._meta?.claudeCode?.toolResponse).toMatchObject({
|
||||
type: "update",
|
||||
content: "export const x = 1;\n",
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no creation patch over a file and no Edit patch before the preview", async () => {
|
||||
const patches = (reports: Record<string, any>[]) =>
|
||||
reports.flatMap((report) =>
|
||||
(report.content ?? [])
|
||||
.map((block: any) => block._meta?.jetbrains?.air?.diffPatch?.text)
|
||||
.filter((text: unknown) => text !== undefined),
|
||||
);
|
||||
// Streamed: the Write of an existing file gets only the update patch of
|
||||
// its hook, and the Edit gets no patch before its approval.
|
||||
const writePatches = patches(toolCallReports(air("write-existing"), "toolu_write"));
|
||||
expect(writePatches).toHaveLength(1);
|
||||
expect(writePatches[0]).not.toContain("new file mode");
|
||||
expect(writePatches[0]).toContain("-export const x = 0;\n+export const x = 1;");
|
||||
const edit = air("edit-with-permission");
|
||||
const approval = edit.findIndex((record) => record.kind === "requestPermission");
|
||||
expect(approval).toBeGreaterThan(0);
|
||||
expect(patches(toolCallReports(edit.slice(0, approval), "toolu_edit"))).toEqual([]);
|
||||
// Replayed: session/load renders the same tool uses from the transcript.
|
||||
const replay = await runAir(replayedEditsScenario());
|
||||
expect(patches(toolCallReports(replay))).toEqual([]);
|
||||
// A replay does not read old.ts: the disk shows a later state than the history.
|
||||
expect(toolCallReports(replay, "toolu_r_write")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/old\.ts$/),
|
||||
oldText: null,
|
||||
newText: "export const x = 1;\n",
|
||||
},
|
||||
]);
|
||||
expect(toolCallReports(replay, "toolu_r_edit")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/app\.ts$/),
|
||||
oldText: "const value = 1;",
|
||||
newText: "const value = 2;",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the Bash output as terminal deltas", () => {
|
||||
const reports = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output_delta: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** A Write that changes the file on disk before its PostToolUse hook runs. */
|
||||
function writtenFileScenario(): Scenario {
|
||||
return {
|
||||
name: "write-on-disk",
|
||||
files: { "old.ts": "export const x = 0;\n" },
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const file = path.join(ctx.cwd, "old.ts");
|
||||
const input = { file_path: file, content: "export const x = 1;\n" };
|
||||
yield* assistantTurn("msg_write", [
|
||||
{ type: "tool_use", id: "toolu_write", name: "Write", input },
|
||||
]);
|
||||
fs.writeFileSync(file, input.content);
|
||||
yield toolResult("toolu_write", `The file ${file} has been updated successfully.`);
|
||||
await ctx.postToolUse("toolu_write", "Write", input, {
|
||||
type: "update",
|
||||
filePath: file,
|
||||
content: input.content,
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ["-export const x = 0;", "+export const x = 1;"],
|
||||
},
|
||||
],
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A session/load of a Write over an existing file and of an Edit. */
|
||||
function replayedEditsScenario(): Scenario {
|
||||
return {
|
||||
name: "replayed-edits",
|
||||
files: { "old.ts": "export const x = 0;\n", "app.ts": "line 1\nconst value = 1;\n" },
|
||||
transcript: ({ cwd, sessionId }) => [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "00000000-0000-4000-8000-00000000f001",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: { role: "user", content: "Change both files" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "00000000-0000-4000-8000-00000000f002",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg_r_edits",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_write",
|
||||
name: "Write",
|
||||
input: { file_path: path.join(cwd, "old.ts"), content: "export const x = 1;\n" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_edit",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: path.join(cwd, "app.ts"),
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
turns: [],
|
||||
};
|
||||
}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"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":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"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":{"asyncTaskId":"workflow_1","canStop":true,"description":"Build assets","name":"Build assets","outputFilePath":"/tmp/tasks/workflow_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"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}}}
|
||||
5
src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl
generated
Normal file
5
src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +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":{"asyncTaskId":"workflow_3","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow","toolCallId":"toolu_workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}}
|
||||
{"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}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"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":{"asyncTaskId":"workflow_2","canStop":true,"description":"Build assets","name":"Build assets","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"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}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bg"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Wait","version":1}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"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":{"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","canStop":true,"description":"sleep 100","name":"sleep 100","outputFilePath":"/tmp/tasks/bash_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"shell","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"asyncTasks":{"backgrounded":true}}},"terminal_output_delta":{"data":"Command running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.","terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_progress","summary":"still running","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"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":{"_meta":{"jetbrains":{"air":{"commandTitle":"Fail","version":1}}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Exit code 1\nboom","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"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":{"_meta":{"jetbrains":{"air":{"commandTitle":"List files","version":1}}},"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"ls -la","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"a.ts\nb.ts","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"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":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"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":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"error":"Context too small","version":1}}}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"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}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"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":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"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}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"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":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"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}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"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":{"content":[{"newText":"value = 2","oldText":"value = 1","path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit src/app.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/src/app.ts b<cwd>/src/app.ts\n--- a<cwd>/src/app.ts\n+++ b<cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","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"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"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":{"content":[{"newText":"const value = 2;","oldText":"const value = 1;","path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit src/app.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/src/app.ts b<cwd>/src/app.ts\n--- a<cwd>/src/app.ts\n+++ b<cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"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_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"line 1\nconst value = 2;\nline 3","oldText":"line 1\nconst value = 1;\nline 3","path":"<cwd>/src/app.ts","type":"diff"}],"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"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":{"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Ready to code?","version":1},"version":1}}},"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>","toolCall":{"rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"currentModeId":"default","sessionUpdate":"current_mode_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"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"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"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":{"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Ready to code?","version":1},"version":1}}},"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>","toolCall":{"rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/goal.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/goal.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"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":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","objective":"Ship the feature","status":"active"},"version":1}}},"sessionUpdate":"session_info_update"}}}
|
||||
{"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}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"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":{"rawInput":{"-n":true,"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep -n \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","sessionUpdate":"tool_call","status":"pending","title":"Find","toolCallId":"toolu_glob"}}}
|
||||
{"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":{"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"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":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"mcp__docs__search","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"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":"memory_recall","toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"sessionUpdate":"tool_call","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","sessionUpdate":"tool_call","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
|
||||
{"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}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"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":"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}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"edit","locations":[],"name":"NotebookEdit","sessionUpdate":"tool_call","status":"pending","title":"Edit notebook","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/n.ipynb"}],"sessionUpdate":"tool_call_update","title":"Edit cell in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"Edit cell cell-1 in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"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":{"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":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit cell cell-1 in n.ipynb","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"title":"Edit cell cell-1 in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_denied"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}}
|
||||
{"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":{"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Permission to use Bash has been denied.","terminal_id":"toolu_denied"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_denied"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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
src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Work","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ing.","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"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":{"_meta":{"_claude/model":"claude-sonnet-4-6","_claude/rateLimit":{"resetsAt":1700000000,"status":"allowed_warning","utilization":0.9}},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6","_claude/origin":{"kind":"human"}},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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
src/tests/acp-scenarios/__snapshots__/air/read.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/read.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_read"}}}
|
||||
{"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":{"locations":[{"line":1,"path":"<cwd>/a.ts"}],"rawInput":{"file_path":"<cwd>/a.ts"},"sessionUpdate":"tool_call_update","title":"Read a.ts","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
15
src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl
generated
Normal file
15
src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"jetbrains":{"air":{"commandTitle":"List","version":1}},"terminal_info":{"terminal_id":"toolu_r_bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[{"newText":"b","oldText":"a","path":"<cwd>/src/app.ts","type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call","status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore","sessionUpdate":"subagent_spawned","subagentSessionId":"<session>:replay-subagent:toolu_r_task","task":"Look around"}}}
|
||||
{"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":{"_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>","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"}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"newSession","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>","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"}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/skill.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/skill.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"content":[],"kind":"other","name":"Skill","sessionUpdate":"tool_call","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}}
|
||||
{"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":{"_meta":{"jetbrains":{"air":{"skill":{"name":"commits","path":"<cwd>/.claude/skills/commits/SKILL.md"},"version":1}}},"rawInput":{"skill":"commits"},"sessionUpdate":"tool_call_update","title":"Load skill: commits","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"Launching skill: commits","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":{"_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":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
|
||||
{"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"}},"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"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"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":{"_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","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"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
19
src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{"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":{"_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"}},"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"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","title":"grep \"TODO\"","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"rawInput":{"path":"/src","pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\" /src","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_native_bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Build","version":1}}},"rawInput":{"command":"make","description":"Build"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"make","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","toolCall":{"rawInput":{"command":"make","description":"Build"},"title":"make","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"terminal_output_delta":{"data":"make: *** Error 2","terminal_id":"toolu_native_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_native_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"failed","subagentSessionId":"agent_n"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"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":{"_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":{"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","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"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_inner"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":"Plan:","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":" do X.","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_outer"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
27
src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl
generated
Normal file
27
src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{"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":{"_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"}},"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"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_sub_bash"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"tool_call_update","title":"rg parser","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Search","version":1}}},"rawInput":{"command":"rg parser","description":"Search"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"rg parser","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"command":"rg parser","description":"Search"},"title":"rg parser","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"terminal_output_delta":{"data":"x.ts","terminal_id":"toolu_sub_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_sub_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"content":[{"newText":"b","oldText":"a","path":"<cwd>/x.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/x.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit x.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"file_path":"<cwd>/x.ts"},"title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"status":"completed"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_1"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"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":{"_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"}},"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"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"two packages.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript","toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","sessionUpdate":"tool_call","status":"pending","title":"Find","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"rawInput":{"pattern":"*"},"sessionUpdate":"tool_call_update","title":"Find `*`","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"content":[{"content":{"text":"a\nb","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_t"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"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":{"_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":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
{"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":{"entries":[{"content":"Write tests","priority":"medium","status":"in_progress"}],"sessionUpdate":"plan"}}}
|
||||
{"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":{"entries":[{"content":"Write tests","priority":"medium","status":"completed"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
{"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":{"content":{"text":"All ","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"done.","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"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":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[],"kind":"other","name":"TaskOutput","sessionUpdate":"tool_call","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}}
|
||||
{"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":{"rawInput":{"block":true,"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"<retrieval_status>success</retrieval_status>\n<output>done</output>","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskStop"}},"content":[],"kind":"other","name":"TaskStop","sessionUpdate":"tool_call","status":"pending","title":"TaskStop","toolCallId":"toolu_stop"}}}
|
||||
{"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":{"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Successfully stopped task: bash_1","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Let me thin","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"k about it.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Here is t","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"he answer.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"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":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
6
src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{"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":{"_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":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_slow"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}}
|
||||
{"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":{"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":1}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":2}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":3}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"built","terminal_id":"toolu_slow"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_slow"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[],"kind":"fetch","name":"WebFetch","sessionUpdate":"tool_call","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
|
||||
{"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":{"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Fetch https://example.com","version":1},"version":1}}},"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>","toolCall":{"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"content":[],"kind":"fetch","name":"WebSearch","sessionUpdate":"tool_call","status":"pending","title":"Web search","toolCallId":"toolu_search"}}}
|
||||
{"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":{"rawInput":{"query":"acp protocol"},"sessionUpdate":"tool_call_update","title":"Search \"acp protocol\"","toolCallId":"toolu_search"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"ACP (https://agentclientprotocol.com)\nA summary.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_search"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}}
|
||||
{"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":{"rawInput":{"file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/old.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/old.ts b<cwd>/old.ts\n--- a<cwd>/old.ts\n+++ b<cwd>/old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/old.ts","type":"diff"}],"locations":[{"line":1,"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"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":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/new.ts"}],"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}}
|
||||
{"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":{"rawInput":{"file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Write new.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/new.ts b<cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ b<cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/new.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/new.ts"},"title":"Write new.ts","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"File created successfully at: <cwd>/new.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/v2/ask-user-question.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/v2/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"Which database?","type":"text"},"type":"content"}],"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","title":"Which database?","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"elicitation/create","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"rawOutput":"User has answered your questions: \"Which database?\"=\"Postgres\".","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"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}}}
|
||||
{"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}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-ends-before-tool-id.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"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}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-held-until-tool-id.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"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}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-released-at-turn-end.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"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}}}}
|
||||
19
src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"sleep 100","sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100"},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"Q29tbWFuZCBydW5uaW5nIGluIGJhY2tncm91bmQgd2l0aCBJRDogYmFzaF8xLiBPdXRwdXQgaXMgYmVpbmcgd3JpdHRlbiB0bzogL3RtcC90YXNrcy9iYXNoXzEub3V0cHV0LiBZb3Ugd2lsbCBiZSBub3RpZmllZCB3aGVuIGl0IGNvbXBsZXRlcy4="},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"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}}}
|
||||
{"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}}}}
|
||||
18
src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl
generated
Normal file
18
src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"false","sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false"},"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"RXhpdCBjb2RlIDEKYm9vbQ=="},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"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}}}
|
||||
{"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}}}}
|
||||
25
src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl
generated
Normal file
25
src/tests/acp-scenarios/__snapshots__/v2/bash-foreground.jsonl
generated
Normal file
@@ -0,0 +1,25 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"ls -la","sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"ls -la"},"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Bash"}},"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":"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"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"interrupted":false,"isImage":false,"stderr":"","stdout":"a.ts\nb.ts"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"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":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"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}}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/v2/compaction-failed-legacy.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","error":"Context too small","sessionUpdate":"compaction_update","status":"failed"}}}
|
||||
{"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}}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/v2/compaction-legacy.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"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}}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/v2/compaction-update.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"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}}}}
|
||||
18
src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl
generated
Normal file
18
src/tests/acp-scenarios/__snapshots__/v2/edit-rejected.jsonl
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"value = 1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Edit"}},"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":{"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":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"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}}}
|
||||
{"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}}}}
|
||||
19
src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/v2/edit-with-permission.jsonl
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"locations":[{"path":"<cwd>/src/app.ts"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"rawInput":{"file_path":"<cwd>/src/app.ts","old_string":"const value = 1;"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Edit"}},"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":{"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":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"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":{"_meta":{"claudeCode":{"toolName":"Edit","toolResponse":{"filePath":"<cwd>/src/app.ts","newString":"const value = 2;","oldString":"const value = 1;","originalFile":"line 1\nconst value = 1;\nline 3\n","replaceAll":false,"structuredPatch":[{"lines":[" line 1","-const value = 1;","+const value = 2;"," line 3"],"newLines":3,"newStart":1,"oldLines":3,"oldStart":1}],"userModified":false}}},"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","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}}}
|
||||
{"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}}}}
|
||||
17
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-approve.jsonl
generated
Normal file
17
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-approve.jsonl
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"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":"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":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"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"}}}
|
||||
{"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}}}
|
||||
{"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}}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-reject.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/v2/exit-plan-reject.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"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":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"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}}}
|
||||
{"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}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/goal.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/goal.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"/goal Ship the feature","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"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}}}}
|
||||
20
src/tests/acp-scenarios/__snapshots__/v2/grep-and-glob.jsonl
generated
Normal file
20
src/tests/acp-scenarios/__snapshots__/v2/grep-and-glob.jsonl
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_grep"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Grep"}},"rawInput":{"-n":true,"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep -n \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[{"content":{"text":"src/app.ts:2:const value = 1;","type":"text"},"type":"content"}],"rawOutput":"src/app.ts:2:const value = 1;","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Find","toolCallId":"toolu_glob"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"Glob"}},"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"rawOutput":"src/app.ts\nsrc/b.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
|
||||
{"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}}}
|
||||
{"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}}}}
|
||||
17
src/tests/acp-scenarios/__snapshots__/v2/mcp-tool.jsonl
generated
Normal file
17
src/tests/acp-scenarios/__snapshots__/v2/mcp-tool.jsonl
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"content":[],"kind":"other","name":"mcp__docs__search","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"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":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"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"},"toolName":"mcp__docs__search"}},"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":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"}},"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":{"claudeCode":{"toolName":"mcp__docs__search","toolResponse":[{"text":"Found 2 documents.","type":"text"}]}},"sessionUpdate":"tool_call_update","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}}}
|
||||
{"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}}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/v2/memory-recall.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/v2/memory-recall.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"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"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","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]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"sessionUpdate":"tool_call_update","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","sessionUpdate":"tool_call_update","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
|
||||
{"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}}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user