Compare commits

..

16 Commits

Author SHA1 Message Date
Ben Brandt
6b69852cfc refactor: report each prompt turn through internal turn events (#1222)
First part of step 3 of opt-in ACP v2 support (step 1: #1217, step 2:
#1219). It's independent of #1219. It changes no ACP v1 behavior, apart
from one failure-only case noted below.

## Why

ACP v2 changes the prompt lifecycle. `session/prompt` is answered when
the prompt is *inserted* into the conversation, and progress and the
stop reason arrive as `state_update`s. In v1, the response comes at the
end of the turn. The agent only exposed the v1 shape: `prompt()` returns
at the end of the turn. So v2 can't be mapped from the outside.

Following the discussion on #1219, the adapter gets its own view of a
turn, and each ACP version maps to it, instead of v2 being translated
into v1.

## What

`src/turn-events.ts` defines the adapter's own turn lifecycle:

- `inserted(messageId)`: Claude Code took the prompt in as this user
message. At most once, and never after the turn ends.
- `awaitingUser()` / `resumed()`: a permission request or question is
open in the session / none is open any more.
- `ended(outcome)`: with a `TurnOutcome` (`stopReason`, `usage`,
`_meta`).
- `failed(error)`.

The agent calls these synchronously while it updates its own state, so
handlers must not throw or call back into the agent.

In the agent:

- `startTurn(params, events)` starts a turn. It resolves once the prompt
is queued for Claude Code, and rejects only if the prompt is refused
before that. Events can arrive before it resolves. `prompt()` is now the
v1 view of it: it awaits the turn's outcome.
- **inserted** is reported in `activateTurn`, the single place a queued
turn becomes active. That's when Claude Code takes the prompt in: its
echo, or for an echo-less command, its result. Two guards:
- a clear-context restart re-activates the same turn in the fresh
session, so it's reported once;
- a turn that a cancel settled during the previous turn's hand-off isn't
reported at all.
- **awaiting user / resumed** follow one session-level rule: the active
turn awaits the user exactly while any request in the session is open,
rechecked when a turn activates. A request carries no prompt id, and
Claude Code can ask for a queued prompt before the consumer reaches its
echo. So tying a request to the turn that was active when it opened
would misattribute it.
- **ended / failed** come from the existing settle points, each guarded
by `settled`, so a turn reports exactly one of them.
- `TurnOutcome` and `StopReason` are the adapter's own types. A turn's
outcome is field for field a v1 prompt response, so `PromptResponse` now
only appears at the v1 boundary.

**The one v1 difference:** a prompt that sets a goal publishes it to AIR
clients after the prompt is queued. If that notification fails,
`startTurn` now logs it rather than rejecting, because the turn is
already running. For v1 this only matters on a dead connection:
`prompt()` waits for the outcome instead of rejecting with the write
error.

## Notes for the v2 step

- A prompt sent while another turn is running is inserted only when
Claude Code takes it in, after the running turn. The open question about
queued prompts is going back to the v2 draft.
- An echo-less command (such as `/compact`) is inserted at its result,
after its output. On `msg_lifecycle_v1` CLIs, the `command_lifecycle`
"started" frame may be an earlier insertion point.
- A queued turn that a cancel ends before insertion may still be run by
Claude Code (the orphan accounting).
- Any open request counts towards `requires_action`, a background task's
included; `agentID` could refine that in the permission step.

## Testing

`src/tests/turn-events.test.ts` covers:
- a normal turn: inserted under the uuid of the message Claude Code
received, then ended;
- a permission request: awaiting user, then resumed;
- a request open from before the turn: the turn awaits the user from
insertion, through its own answered request, until the earlier one is
answered too;
- a cancel during the hand-off from the previous turn: the queued turn
ends as cancelled and is never inserted;
- cancelling while a second prompt is queued: the active turn is
inserted, then ended as cancelled, and the queued turn ends as cancelled
without being inserted;
- the stream ending with a turn queued: it fails without being inserted;
- a prompt for an unknown session: `startTurn` rejects and reports
nothing.

The existing clear-context restart test also checks that the restarted
turn is inserted once.

Each guard is checked by mutation: removing the `insertedReported`
guard, the `settled` guard, the activation recheck, or the change-only
dedupe; tying `resumed` to the request's own turn; or reporting
insertion at enqueue each fails at least one test.

The existing suite passes unchanged, including the 474 scenario tests
that compare outbound v1 traffic with recorded baselines. `npm run
build`, `npm run check`, `npm run test:run`.
2026-10-02 15:01:27 +02:00
Ben Brandt
c60fa48fd8 refactor: serve the ACP v2 session lifecycle without prompts (#1219)
Step 2 of opt-in ACP v2 support (step 1: #1217). Behind
`CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the v2 surface now serves
`session/new`, `session/list`, `session/resume` (without replay),
`session/close`, `session/delete`, and `session/set_config_option`. ACP
v1 is unchanged.

`capabilities.session` is still not advertised. Under v2, advertising it
commits the agent to the whole baseline, including `session/prompt` and
`replayFrom: start` replay, and those come in later steps. Until then
these methods are served but not advertised.

## Translation

As in step 1, each v2 request becomes the v1 request that
`ClaudeAcpAgent` already serves, and each v1 response or update becomes
its v2 form (`src/v2/session.ts`, `src/v2/session-update.ts`):

- **MCP servers:** v2 requires a `type` on every transport, but the
agent recognizes a v1 stdio server by its *missing* `type`. A v2 `type:
"stdio"` config passed through as is would be silently skipped, so the
tag is dropped. The lists v1 requires (`args`, `env`, `headers`) are
defaulted. A transport v1 can't express is rejected with invalid params
instead of being left out of the session.
- **Config options:** `id` → `configId`, a select group's `group` →
`groupId`. A select value in `session/set_config_option` drops its v2
`type: "id"`, and value types v1 can't express are rejected.
- **Modes:** responses drop `modes`, and `current_mode_update` is
dropped. The agent always lists the mode as the `mode` config option,
and every mode change also reaches the client as a
`config_option_update` or in the `set_config_option` response.
- **Commands:** `available_commands_update` inputs gain `type: "text"`.
- **Notices, session info, usage:** passed through unchanged. v2 needs
no capability for notices, so a v2 client is reported to the agent as
taking them, and advisories arrive as `notice` instead of transcript
messages.
- **Elicitation:** the same in v1 and v2, so it's forwarded. MCP OAuth
at `session/new` works.
- Update kinds that aren't translated yet (messages, tool calls, plans,
extensions) throw, so a gap shows up as an error, not a missing update.
- `replayFrom` is rejected until replayed history can be translated.

Also bumps `@agentclientprotocol/sdk`:
- to 1.6.1, whose router types `closed` as always present, so `serveAcp`
drops its `closed!`;
- to 1.7.0, which type-checks the v2 extensible-union values the adapter
builds, at any depth
([typescript-sdk#268](https://github.com/agentclientprotocol/typescript-sdk/pull/268)).
The v2 translations compile unchanged. The exception is
`createElicitation`, which relays the v1 request: v1 types a property
schema's tag as any string, and an MCP server's schema passes through
unchecked, as on v1. It gets a cast with a comment saying so.

## Testing

- `src/tests/acp-v2.test.ts` drives the router with the SDK's v2 client
app, which validates every response, session update, and elicitation
against the v2 schema. New cases cover session creation with v2 MCP
servers, config options, and commands; rejected MCP transports;
list/close/resume/delete and rejected `replayFrom`; setting the mode
with no `current_mode_update` reaching the client; notice support; MCP
OAuth through URL elicitation; and unit tests for grouped options,
rejected value types, and untranslated updates. Breaking the stdio or
command-input translation makes the tests fail.
- Ran the built binary over stdio against a real Claude Code session: v2
`initialize`, `session/new`, `list`, setting the mode, and `close` all
passed the v2 client's validation, and the 52 real commands arrived with
`type: "text"` inputs. `session/new` took ~0.7 s, the same as over v1.
- `npm run build`, `npm run check`, `npm run test:run`.
2026-10-02 15:00:11 +02:00
acp-release-bot[bot]
686c0c99b3 chore(main): release 0.85.1 (#1213)
🤖 I have created a release *beep* *boop*
---


##
[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](0c862e2949))
* fail turns with unfinished tools
([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212))
([7f8d547](7f8d5471e2))
* restore background task stops on replay instead of showing them as
prompts
([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205))
([4ffa625](4ffa625977))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-02 10:58:36 +02:00
Ben Brandt
8e893f51ea refactor: add an opt-in experimental ACP v2 handshake (#1217)
First step toward optional support for the [draft ACP v2
protocol](https://agentclientprotocol.com/protocol/v2/draft/overview).
ACP v1 stays the default and is unchanged.

## What it does

Set `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1` and the adapter routes each
connection by the protocol version the client requests in `initialize`
(via the SDK's `AgentProtocolRouter`):

- A client that requests version 2 or later gets the draft ACP v2
`initialize`, `auth/login`, and `auth/logout`, plus the
`_auth/status_update` push.
- Every other client gets ACP v1, served by the same handlers as today.

Without the variable, the adapter never builds the router and serves ACP
v1 exactly as before.

The v2 response does not advertise `capabilities.session` yet. A v2
agent that advertises it must serve the whole session baseline
(`session/new`, `list`, `resume`, `close`, `prompt`, `cancel`,
`update`), which follow-up PRs add. So for now a v2 client can
initialize and authenticate, but cannot open a session.

## How

`ClaudeAcpAgent` keeps speaking ACP v1 types. The v2 surface in
`src/v2/` is a thin translation layer around it, so v1 and v2 share one
implementation:

- `v2AgentApp` registers the v2 methods and translates each request to
the v1 request the agent already serves, and each response back. For
`initialize` that means `info`/`capabilities` instead of
`agentInfo`/`agentCapabilities`, and auth methods with `methodId` and a
required `type`, with terminal `env` as `{ name, value }` pairs. A v2
client gets no client `fs` or terminals, and boolean config options are
baseline.
- `V2ClientConnection` is the `AcpClient` of a v2 connection. It sends
the agent's v1-shaped messages in their v2 form. Only connection-level
messages can reach it so far; session-scoped methods reject until v2
sessions land.
- `v1AgentApp` is the existing v1 handler registration, pulled out of
`runAcp` so the router can reuse it. Each surface now creates the agent
of its connection in `onConnect`, because under the router the
connection opens only after `initialize` has been read. `runAcp` (a
public export) behaves as before.

Also bumps `@agentclientprotocol/sdk` to 1.6.0 (schema v2.0.0-alpha.7).
The bump changes nothing for v1: the full suite passes unchanged on it.

## Testing

- `src/tests/acp-v2.test.ts` drives the real router over in-memory
streams with the SDK's own v1 and v2 client apps (the v2 client
validates every response against the v2 schema). It covers the v1
handshake through the router, the v2 handshake, method-not-found for v2
session methods, the `_auth/status_update` push, and gateway
`auth/login` followed by `auth/logout`.
- Ran the built binary over stdio: v2 client with the flag, v1 client
with the flag, v2 client without the flag (gets the unchanged v1
response), and stdin EOF before `initialize` (exits 0).
- `npm run build`, `npm run check`, `npm run test:run`.
2026-10-01 16:46:57 +02:00
nikita-ashihmin
0c862e2949 fix: answer session/close without waiting for the interrupt reply (#1216)
## Summary

- `teardownSession` sends the interrupt but does not await its reply
- `query.close()` follows at once and ends the CLI process
- `session/cancel` still awaits the interrupt reply

On a fresh session, the background `getContextUsage` request from
`refreshContextWindowInBackground` is still in flight. The SDK
serializes control requests, so the CLI answered the interrupt only
after it. With a real CLI, `session/close` took 2.5 s in an empty
directory and 3.2 s in a large repository. ACP clients with a 3 s close
timeout timed out. With this change, close answers in about 2 ms, and
the CLI process exits.

## Tests

- `npm run test:run -- src/tests/create-session-options.test.ts`
- `npx tsc --noEmit`
- `npm run check`
2026-10-01 18:03:14 +04:00
nikita-ashihmin
4ffa625977 fix: restore background task stops on replay instead of showing them as prompts (#1205)
When a background task stops, the CLI persists a `<task-notification>`
user record. Live, the adapter hides it and handles the SDK task
lifecycle frame; replay previously showed the raw XML as a user prompt.

Replay now hides notification records and removes embedded notification
XML while preserving adjacent user text and delivered prompts with an
origin `subkind`. It restores known non-agent tasks through
`AsyncTaskRuntime`, including `killed` → `stopped` and duplicate
terminal-event suppression. Agent notifications override the launch
result's terminal state. Unknown tasks remain hidden without creating a
task card.

Task launches are recognized in `tool_use`, `server_tool_use`, and
`mcp_tool_use` blocks. Async task updates use the session that owns the
launching tool call, even when another session receives the
notification. History has no `task_started` frame, so tasks without a
persisted stop notification cannot be reconstructed.

The large Write patch test now uses a fixed clock to check patch
correctness independently of CI speed; a separate advancing-clock test
verifies the existing timeout fallback. The production 100 ms budget
remains unchanged.

Validation on current main (SDK 0.3.286):
- `npm run build`: passed.
- `npm run check`: passed.
- `env -u ANTHROPIC_BASE_URL npm run test:run`: 2119 passed, 29 skipped.
The local gateway environment otherwise changes the expected default URL
in four provider tests.
- Regression coverage includes origin/text notifications, adjacent user
text, unknown and repeated tasks, killed tasks, background Agents,
delivered subkind prompts, alternate tool block types, task session
ownership, and patch timeout fallback.
2026-10-01 17:07:49 +04:00
Mark Tkachenko
7f8d5471e2 fix: fail turns with unfinished tools (#1212)
Fixing silent agent turn completion in case of tools failure
2026-10-01 14:51:13 +02:00
acp-release-bot[bot]
c84845272f chore(main): release 0.85.0 (#1209)
🤖 I have created a release *beep* *boop*
---


##
[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](f53b0d7f40))


### Bug Fixes

* Adapt to SDK 0.3.284 replay and ultracode changes
([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208))
([4af8079](4af807968c))
* send the legacy custom answer key to AIR
([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210))
([af0b43d](af0b43de94))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-01 13:38:29 +02:00
Kirill Alekhnovich
af0b43de94 fix: send the legacy custom answer key to AIR (#1210)
Released AIR versions, such as 262.air/release-09, read the custom
answer companion only from the root key
_meta._askUserQuestionCustomAnswer. The adapter sent only
_meta.jetbrains.air.customAnswer, so these versions showed the companion
as a separate question.

For an AIR client the companion now carries both keys with the same
value. Other clients get no key, as before. Remove the root key when the
ACP registry no longer serves AIR versions that read only that key.

IJAI-2892
2026-10-01 13:25:01 +02:00
Ben Brandt
f53b0d7f40 feat(deps): Bump @anthropic-ai/claude-agent-sdk to 0.3.286 (#1211)
## Summary

- Bumps `@anthropic-ai/claude-agent-sdk` to 0.3.286 (Claude Code
2.1.286). No compile or test fixes were needed.
- Updates the steer comments for a CLI behavior change. A `priority:
'now'` steer that lands during a foreground tool call (Bash, Agent, MCP)
now moves the tool to the background instead of stopping the turn.
Depending on timing, the CLI then either:
- adds the steer to the running cycle, which ends in **one** result
stamped with both the prompt's and the steer's uuids, or
  - ends the cycle early and runs the steer as a second cycle.

  During plain text output it still stops the turn, as before.
- Adds a test for the case where the steer joins the running cycle, with
and without an echo of the steer. The case where the steer runs as a
second cycle was already covered.

I checked all of this against the live CLI 2.1.286. In each case the
result that answered the steer carried the steer's uuid, so the existing
steer settlement logic handles it without code changes.

## Review notes for 0.3.285 and 0.3.286

- **Raw XML in subagent replay:** `getSubagentMessages()` now returns
delivered `<task-notification>` rows as plain user messages (`origin:
{kind: "task-notification"}`, `isQueuedCommand: true`, no `is_meta`).
They reach child replay and show up as raw XML user messages. #1205
already hides this exact shape, so it fixes the subagent case once
merged.
- **Omitted `permissionMode`:** the SDK now leaves it to the CLI. The
adapter always passes an explicit mode, so this has no effect.
- **No changes needed:** the `fallback_credit` type, Artifact pin/unpin,
`provider_not_allowed`, the SDK MCP manifest fields, the background Bash
timeout (its stop notice goes through the existing task-stopped path),
and the `setModel` output-limit fix.

## Test plan

- [x] `npm run build`
- [x] `npm run test:run` (2095 passed)
- [x] `npm run check`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-01 13:23:51 +02:00
Ben Brandt
4af807968c fix: Adapt to SDK 0.3.284 replay and ultracode changes (#1208)
Follow-ups to the SDK 0.3.284 / CLI 2.1.284 bump (#1189), which landed
with only package changes.

## Replay showed messages from other agents and sessions as user text

Since 0.3.284, `getSessionMessages()` returns `is_meta` user messages
from other agents, sessions and channels (`origin.kind` peer, channel,
observer, observer-activity, slack-ping). Their content is the full
harness framing: the `<cross-session-message>` envelope plus the "not
typed by your user" preamble. `replayMessage` sent them as
`user_message_chunk`, while the live prompt loop never shows them.
Replay now skips them. Other `is_meta` messages (compact summaries)
still replay.

Verified against a live 0.3.284 transcript: the enveloped peer turn is
gone, and the user's own prompts still replay.

Fixes #1203. Complements #1205, which hides `task-notification` records
the same way. The two cover different origin kinds and merge with only a
trivial test-file conflict.

## Effort applies turned ultracode off

CLI 2.1.284 turns ultracode off when an `applyFlagSettings` call changes
`effortLevel` without an `ultracode` key. The adapter applies effort at
session start (recommended-value clients), after a model switch and from
the effort picker, so a settings-enabled `ultracode: true` was silently
cleared. The CLI's own `/effort` control now keeps ultracode on at any
level, so all three applies send `ultracode: true` when the settings
request it (`effortFlagSettings`).

## Checked, no change needed

Elicitation-hook `decision: 'block'`, the background-agent stdin fix
(the adapter never hit it; keep
`CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1`), MCP startup waits, Sonnet
5.5 (natively 1M, already covered by the background context-window
refresh), malformed-image handling (now done by the CLI), and notice
rewording (the adapter matches none of the changed strings).

## Testing

- New tests for both helpers, replay of the probe's peer-message shape,
and a session test checking that the startup and picker applies keep
ultracode.
- `npm run build`, `npm run check` and `npm run test:run` (2093 passed)
all pass.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:43:11 +00:00
acp-release-bot[bot]
bdb50ad984 chore(main): release 0.84.0 (#1190)
🤖 I have created a release *beep* *boop*
---


##
[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](d5e6f33046))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-09-28 21:15:22 +02:00
Andrey Bragin
d5e6f33046 feat(deps): update Claude SDK (#1189) 2026-09-28 21:12:52 +02:00
acp-release-bot[bot]
691328a919 chore(main): release 0.83.0 (#1188)
🤖 I have created a release *beep* *boop*
---


##
[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](49858b5273))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-09-28 17:55:23 +02:00
dependabot[bot]
49858b5273 feat(deps): Bump @anthropic-ai/claude-agent-sdk to 0.3.283 (#1186)
Bumps the minor group with 38 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.280` | `0.3.282` |
|
[@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript)
| `0.127.0` | `0.128.0` |
| [prettier](https://github.com/prettier/prettier) | `3.9.8` | `3.9.9` |
|
[vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)
| `5.0.1` | `5.0.2` |
| @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.280` | `0.3.282` |
| @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.280` | `0.3.282` |
| @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.280` | `0.3.282` |
| @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.280` |
`0.3.282` |
| @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.280` | `0.3.282` |
| @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.280` | `0.3.282`
|
| @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.280` | `0.3.282` |
| @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.280` | `0.3.282` |
|
[@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk)
| `1.30.0` | `1.30.1` |
|
[@oxc-project/types](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types)
| `0.150.0` | `0.151.0` |
|
[@rolldown/binding-android-arm-eabi](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-android-arm64](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-darwin-arm64](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-darwin-x64](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-freebsd-x64](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-arm-gnueabihf](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-arm64-gnu](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-arm64-musl](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-ppc64-gnu](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-s390x-gnu](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-x64-gnu](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-linux-x64-musl](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-openharmony-arm64](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-win32-arm64-msvc](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@rolldown/binding-win32-x64-msvc](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
|
[@vitest/mocker](https://github.com/vitest-dev/vitest/tree/HEAD/packages/mocker)
| `5.0.1` | `5.0.2` |
|
[@vitest/spy](https://github.com/vitest-dev/vitest/tree/HEAD/packages/spy)
| `5.0.1` | `5.0.2` |
| [hono](https://github.com/honojs/hono) | `4.13.8` | `4.13.10` |
| [magic-string](https://github.com/Rich-Harris/magic-string) | `1.4.1`
| `1.4.2` |
|
[rolldown](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown)
| `1.2.9` | `1.2.11` |
| [tinybench](https://github.com/tinylibs/tinybench) | `6.1.4` | `6.2.0`
|
| [tinyexec](https://github.com/tinylibs/tinyexec) | `1.3.0` | `1.3.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`8.3.0` | `8.3.1` |
|
[why-is-node-running](https://github.com/mafintosh/why-is-node-running)
| `2.3.0` | `3.2.2` |


Updates `@anthropic-ai/claude-agent-sdk` from 0.3.280 to 0.3.282
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/releases">@​anthropic-ai/claude-agent-sdk's
releases</a>.</em></p>
<blockquote>
<h2>v0.3.282</h2>
<h2>What's changed</h2>
<ul>
<li>Added support for <code>strictKnownMarketplaces</code> and
<code>blockedMarketplaces</code> in host-supplied
<code>managedSettings</code>: the allowlist applies only where admin
policy sets none; the blocklist adds to the admin's</li>
<li>Added <code>@anthropic-ai/claude-agent-sdk/core</code>, a smaller
entry point for apps that bundle the SDK (query, MCP tool helpers,
session mutations, <code>resolveSettings</code>); it uses your installed
zod and MCP SDK</li>
<li>Added <code>prewarm()</code> and <code>SpareProcess.claim()</code>
(alpha): start a Claude Code process before its session is known and
bind it to a folder and its per-session options later</li>
<li>Fixed <code>readMcpResource()</code> relaying content
<code>_meta</code> keys under the CLI-reserved
<code>com.anthropic/</code> prefix; they are now dropped, as for tool
results</li>
<li>Updated to parity with Claude Code v2.1.282</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.282
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.282
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.282
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.282
</code></pre>
<h2>v0.3.281</h2>
<h2>What's changed</h2>
<ul>
<li>Added optional <code>trigger</code>, <code>user_message_uuid</code>
and <code>timestamp</code> fields to the <code>conversation_reset</code>
message so clients can tell what reset the conversation, match a /clear
to its message, and show when the reset happened</li>
<li>Fixed permission and dialog callbacks still being invoked for
requests that arrived after <code>close()</code></li>
<li>Fixed control requests issued after a query closed hanging or
leaking, and permission prompts that could not be cancelled after a
cancelled request was redelivered</li>
<li>Fixed <code>session_state_changed</code> staying at
<code>requires_action</code> after an overlapping permission prompt and
sandbox network-access prompt were both answered</li>
<li>Improved SDK package size and load time: <code>sdk.mjs</code> no
longer bundles unused dependencies (1.47 MB → 0.97 MB)</li>
<li>Improved startup time for <code>query()</code> sessions with
in-process MCP servers (<code>createSdkMcpServer</code>) by running
their handshake inside the SDK; <code>initialize</code> may now wait up
to 250 ms for them</li>
<li>Improved SDK session start-up: the CLI now answers the host's
initialize request before starting its background start-up work</li>
<li>Changed the <code>Settings</code> type's <code>attribution</code>
field to <code>boolean | {...}</code>; TypeScript code that reads
<code>attribution.commit</code> from returned settings needs a type
narrow</li>
<li>Updated to parity with Claude Code v2.1.281</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.281
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.281
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.281
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.281
</code></pre>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/claude-agent-sdk's
changelog</a>.</em></p>
<blockquote>
<h2>0.3.282</h2>
<ul>
<li>Added support for <code>strictKnownMarketplaces</code> and
<code>blockedMarketplaces</code> in host-supplied
<code>managedSettings</code>: the allowlist applies only where admin
policy sets none; the blocklist adds to the admin's</li>
<li>Added <code>@anthropic-ai/claude-agent-sdk/core</code>, a smaller
entry point for apps that bundle the SDK (query, MCP tool helpers,
session mutations, <code>resolveSettings</code>); it uses your installed
zod and MCP SDK</li>
<li>Added <code>prewarm()</code> and <code>SpareProcess.claim()</code>
(alpha): start a Claude Code process before its session is known and
bind it to a folder and its per-session options later</li>
<li>Fixed <code>readMcpResource()</code> relaying content
<code>_meta</code> keys under the CLI-reserved
<code>com.anthropic/</code> prefix; they are now dropped, as for tool
results</li>
<li>Updated to parity with Claude Code v2.1.282</li>
</ul>
<h2>0.3.281</h2>
<ul>
<li>Added optional <code>trigger</code>, <code>user_message_uuid</code>
and <code>timestamp</code> fields to the <code>conversation_reset</code>
message so clients can tell what reset the conversation, match a /clear
to its message, and show when the reset happened</li>
<li>Fixed permission and dialog callbacks still being invoked for
requests that arrived after <code>close()</code></li>
<li>Fixed control requests issued after a query closed hanging or
leaking, and permission prompts that could not be cancelled after a
cancelled request was redelivered</li>
<li>Fixed <code>session_state_changed</code> staying at
<code>requires_action</code> after an overlapping permission prompt and
sandbox network-access prompt were both answered</li>
<li>Improved SDK package size and load time: <code>sdk.mjs</code> no
longer bundles unused dependencies (1.47 MB → 0.97 MB)</li>
<li>Improved startup time for <code>query()</code> sessions with
in-process MCP servers (<code>createSdkMcpServer</code>) by running
their handshake inside the SDK; <code>initialize</code> may now wait up
to 250 ms for them</li>
<li>Improved SDK session start-up: the CLI now answers the host's
initialize request before starting its background start-up work</li>
<li>Changed the <code>Settings</code> type's <code>attribution</code>
field to <code>boolean | {...}</code>; TypeScript code that reads
<code>attribution.commit</code> from returned settings needs a type
narrow</li>
<li>Updated to parity with Claude Code v2.1.281</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="9e477a178c"><code>9e477a1</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="fb04f66354"><code>fb04f66</code></a>
chore: Update CHANGELOG.md</li>
<li>See full diff in <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.280...v0.3.282">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/sdk` from 0.127.0 to 0.128.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/releases">@​anthropic-ai/sdk's
releases</a>.</em></p>
<blockquote>
<h2>sdk: v0.128.0</h2>
<h2>0.128.0 (2026-09-22)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.127.0...sdk-v0.128.0">sdk-v0.127.0...sdk-v0.128.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for claude-opus-5-5, inline tool
definitions and MCP tool-list pinning (beta) (<a
href="eff27483d2">eff2748</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>api:</strong> share one evaluated_permission enum across
Managed Agents events (<a
href="0c1cc0fdf8">0c1cc0f</a>)</li>
<li><strong>tools:</strong> addTools() takes effect straight away (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/773">#773</a>)
(<a
href="1ccdad0d1b">1ccdad0</a>)</li>
<li><strong>tools:</strong> leave reply-only params off the tool
runner's compaction request (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/769">#769</a>)
(<a
href="8afc3364dc">8afc336</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>docs:</strong> add descriptions to the Dreams API reference
(<a
href="ea4ddd1ee3">ea4ddd1</a>)</li>
<li><strong>docs:</strong> add descriptions to the User Profiles API
reference (<a
href="ea4ddd1ee3">ea4ddd1</a>)</li>
<li><strong>docs:</strong> add memory store descriptions to the Managed
Agents API reference (<a
href="ea4ddd1ee3">ea4ddd1</a>)</li>
<li><strong>docs:</strong> improve descriptions in the Dreams API
reference (<a
href="971c54a5dd">971c54a</a>)</li>
<li><strong>docs:</strong> simplify the session thread agent type
description (<a
href="ccf71a27bd">ccf71a2</a>)</li>
<li><strong>docs:</strong> update diagnostics field descriptions on beta
messages (<a
href="c0b8746679">c0b8746</a>)</li>
<li><strong>internal:</strong> add property-based tests for partial JSON
parsing (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/776">#776</a>)
(<a
href="3ffac0927f">3ffac09</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/sdk's
changelog</a>.</em></p>
<blockquote>
<h2>0.128.0 (2026-09-22)</h2>
<p>Full Changelog: <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.127.0...sdk-v0.128.0">sdk-v0.127.0...sdk-v0.128.0</a></p>
<h3>Features</h3>
<ul>
<li><strong>api:</strong> add support for claude-opus-5-5, inline tool
definitions and MCP tool-list pinning (beta) (<a
href="eff27483d2">eff2748</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>api:</strong> share one evaluated_permission enum across
Managed Agents events (<a
href="0c1cc0fdf8">0c1cc0f</a>)</li>
<li><strong>tools:</strong> addTools() takes effect straight away (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/773">#773</a>)
(<a
href="1ccdad0d1b">1ccdad0</a>)</li>
<li><strong>tools:</strong> leave reply-only params off the tool
runner's compaction request (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/769">#769</a>)
(<a
href="8afc3364dc">8afc336</a>)</li>
</ul>
<h3>Chores</h3>
<ul>
<li><strong>docs:</strong> add descriptions to the Dreams API reference
(<a
href="ea4ddd1ee3">ea4ddd1</a>)</li>
<li><strong>docs:</strong> add descriptions to the User Profiles API
reference (<a
href="ea4ddd1ee3">ea4ddd1</a>)</li>
<li><strong>docs:</strong> add memory store descriptions to the Managed
Agents API reference (<a
href="ea4ddd1ee3">ea4ddd1</a>)</li>
<li><strong>docs:</strong> improve descriptions in the Dreams API
reference (<a
href="971c54a5dd">971c54a</a>)</li>
<li><strong>docs:</strong> simplify the session thread agent type
description (<a
href="ccf71a27bd">ccf71a2</a>)</li>
<li><strong>docs:</strong> update diagnostics field descriptions on beta
messages (<a
href="c0b8746679">c0b8746</a>)</li>
<li><strong>internal:</strong> add property-based tests for partial JSON
parsing (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/776">#776</a>)
(<a
href="3ffac0927f">3ffac09</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1926adb4d2"><code>1926adb</code></a>
Merge pull request <a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/1208">#1208</a>
from anthropics/release-please--branches--main--chan...</li>
<li><a
href="162c8b125d"><code>162c8b1</code></a>
chore: release main</li>
<li><a
href="3ffac0927f"><code>3ffac09</code></a>
chore(internal): add property-based tests for partial JSON parsing (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/776">#776</a>)</li>
<li><a
href="e40ce277c2"><code>e40ce27</code></a>
test(ecosystem): check that unused imports tree-shake to nothing</li>
<li><a
href="1ccdad0d1b"><code>1ccdad0</code></a>
fix(tools): addTools() takes effect straight away (<a
href="https://redirect.github.com/anthropics/anthropic-sdk-typescript/issues/773">#773</a>)</li>
<li><a
href="8afc3364dc"><code>8afc336</code></a>
fix(tools): leave reply-only params off the tool runner's compaction
request ...</li>
<li><a
href="eff27483d2"><code>eff2748</code></a>
feat(api): add support for claude-opus-5-5, inline tool definitions and
MCP t...</li>
<li><a
href="06e5f9b5d1"><code>06e5f9b</code></a>
codegen metadata</li>
<li><a
href="0c1cc0fdf8"><code>0c1cc0f</code></a>
fix(api): share one evaluated_permission enum across Managed Agents
events</li>
<li><a
href="c0b8746679"><code>c0b8746</code></a>
chore(docs): update diagnostics field descriptions on beta messages</li>
<li>Additional commits viewable in <a
href="https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.127.0...sdk-v0.128.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `prettier` from 3.9.8 to 3.9.9
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/prettier/prettier/releases">prettier's
releases</a>.</em></p>
<blockquote>
<h2>3.9.9</h2>
<ul>
<li>Markdown: Fix text with <code>$</code> been incorrectly parsed as
math syntax (<a
href="https://redirect.github.com/prettier/prettier/pull/20140">#20140</a>
by <a href="https://github.com/fisker"><code>@​fisker</code></a>)</li>
</ul>
<p>🔗 <a
href="https://github.com/prettier/prettier/blob/3.9.9/CHANGELOG.md#399">Changelog</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/prettier/prettier/blob/main/CHANGELOG.md">prettier's
changelog</a>.</em></p>
<blockquote>
<h1>3.9.9</h1>
<p><a
href="https://github.com/prettier/prettier/compare/3.9.8...3.9.9">diff</a></p>
<h4>Markdown: Fix text with <code>$</code> been incorrectly parsed as
math syntax (<a
href="https://redirect.github.com/prettier/prettier/pull/20140">#20140</a>
by <a href="https://github.com/fisker"><code>@​fisker</code></a>)</h4>
<!-- raw HTML omitted -->
<pre lang="md"><code>&lt;!-- Input --&gt;
**Uses $FOO** from `a.sh` and `b.sh`, plus `$BAR` from `c.sh`, before
anything else runs here.
<p>&lt;!-- Prettier 3.9.8 --&gt;<br />
<strong>Uses $FOO</strong> from <code>a.sh</code> and <code>b.sh</code>,
plus <code>$BAR</code>from<code>c.sh</code>, before anything else runs
here.</p>
<p>&lt;!-- Prettier 3.9.9 --&gt;<br />
<strong>Uses $FOO</strong> from <code>a.sh</code> and <code>b.sh</code>,
plus <code>$BAR</code> from <code>c.sh</code>, before anything else runs
here.<br />
</code></pre></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="cdd17f2288"><code>cdd17f2</code></a>
Release 3.9.9</li>
<li><a
href="dc7b8968e6"><code>dc7b896</code></a>
Disable <code>singleDollarTextMath</code> in
<code>mdast-util-math</code> (<a
href="https://redirect.github.com/prettier/prettier/issues/20140">#20140</a>)</li>
<li><a
href="f9be58fb8e"><code>f9be58f</code></a>
Git blame ignore 3.9.8</li>
<li><a
href="88470cb79c"><code>88470cb</code></a>
Bump Prettier dependency to 3.9.8</li>
<li><a
href="9559cab39e"><code>9559cab</code></a>
Clean changelog_unreleased</li>
<li><a
href="4fc8ee8746"><code>4fc8ee8</code></a>
Update dependents count</li>
<li>See full diff in <a
href="https://github.com/prettier/prettier/compare/3.9.8...3.9.9">compare
view</a></li>
</ul>
</details>
<br />

Updates `vitest` from 5.0.1 to 5.0.2
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/vitest-dev/vitest/releases">vitest's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.2</h2>
<h3>   🐞 Bug Fixes</h3>
<ul>
<li>Bind <code>process</code> in case global is overwritten  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11343">vitest-dev/vitest#11343</a>
<a href="https://github.com/vitest-dev/vitest/commit/0b79231ad"><!-- raw
HTML omitted -->(0b792)<!-- raw HTML omitted --></a></li>
<li><strong>detect-async-leaks</strong>:
<ul>
<li>Ignore <code>process.stdio</code> handles  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11333">vitest-dev/vitest#11333</a>
<a href="https://github.com/vitest-dev/vitest/commit/0fd6b9790"><!-- raw
HTML omitted -->(0fd6b)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>expect</strong>:
<ul>
<li>Fix <code>toMatchObject</code> with asymmetric matchers  -  by <a
href="https://github.com/ShreeBohara"><code>@​ShreeBohara</code></a>,
<strong>Claude Opus 5</strong>, <a
href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>Codex (GPT-5)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11100">vitest-dev/vitest#11100</a>
<a href="https://github.com/vitest-dev/vitest/commit/42523289e"><!-- raw
HTML omitted -->(42523)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>jsdom</strong>:
<ul>
<li>Fix <code>Request</code> with <code>Blob</code> body on jsdom 28+
 -  by <a
href="https://github.com/harshit-d3v"><code>@​harshit-d3v</code></a> in
<a
href="https://redirect.github.com/vitest-dev/vitest/issues/11295">vitest-dev/vitest#11295</a>
<a href="https://github.com/vitest-dev/vitest/commit/d1c3ecc93"><!-- raw
HTML omitted -->(d1c3e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporter</strong>:
<ul>
<li><code>agent</code> to respect <code>--silent</code>  -  by <a
href="https://github.com/Raj4478"><code>@​Raj4478</code></a> and <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11271">vitest-dev/vitest#11271</a>
<a href="https://github.com/vitest-dev/vitest/commit/5b95efb6d"><!-- raw
HTML omitted -->(5b95e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>reporters</strong>:
<ul>
<li>Handle concurrent <code>createReport</code> calls  -  by <a
href="https://github.com/7rulnik"><code>@​7rulnik</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11278">vitest-dev/vitest#11278</a>
<a href="https://github.com/vitest-dev/vitest/commit/e8e556ff7"><!-- raw
HTML omitted -->(e8e55)<!-- raw HTML omitted --></a></li>
<li><code>hanging-process</code> to use ESM entrypoint  -  by <a
href="https://github.com/AriPerkkio"><code>@​AriPerkkio</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11316">vitest-dev/vitest#11316</a>
<a href="https://github.com/vitest-dev/vitest/commit/4e91e5668"><!-- raw
HTML omitted -->(4e91e)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>spy</strong>:
<ul>
<li>Fix stack overflow when spying <code>Set.prototype.add</code>  -  by
<a href="https://github.com/fengmk2"><code>@​fengmk2</code></a> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11299">vitest-dev/vitest#11299</a>
<a href="https://github.com/vitest-dev/vitest/commit/a0a939653"><!-- raw
HTML omitted -->(a0a93)<!-- raw HTML omitted --></a></li>
</ul>
</li>
<li><strong>ui</strong>:
<ul>
<li>Persist authentication cookie beyond current browser session  -  by
<a href="https://github.com/hi-ogawa"><code>@​hi-ogawa</code></a>,
<strong>Hiroshi Ogawa</strong> and <strong>OpenCode
(gpt-5.6-sol)</strong> in <a
href="https://redirect.github.com/vitest-dev/vitest/issues/11066">vitest-dev/vitest#11066</a>
<a href="https://github.com/vitest-dev/vitest/commit/f88195e16"><!-- raw
HTML omitted -->(f8819)<!-- raw HTML omitted --></a></li>
</ul>
</li>
</ul>
<h5>    <a
href="https://github.com/vitest-dev/vitest/compare/v5.0.1...v5.0.2">View
changes on GitHub</a></h5>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="428e2e5043"><code>428e2e5</code></a>
chore: release v5.0.2 (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11357">#11357</a>)</li>
<li><a
href="0b79231ad7"><code>0b79231</code></a>
fix: bind <code>process</code> in case global is overwritten (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11343">#11343</a>)</li>
<li><a
href="597df569ab"><code>597df56</code></a>
docs: correct stale config defaults (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11354">#11354</a>)</li>
<li><a
href="ea1c44fb58"><code>ea1c44f</code></a>
docs: <code>sequence.setupFiles</code> default is <code>'list'</code>
(<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11351">#11351</a>)</li>
<li><a
href="0fd6b97902"><code>0fd6b97</code></a>
fix(detect-async-leaks): ignore <code>process.stdio</code> handles (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11333">#11333</a>)</li>
<li><a
href="4e91e56687"><code>4e91e56</code></a>
fix(reporters): <code>hanging-process</code> to use ESM entrypoint (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11316">#11316</a>)</li>
<li><a
href="1a57929a49"><code>1a57929</code></a>
docs(browser): fix locators.exact default (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11338">#11338</a>)</li>
<li><a
href="5b95efb6df"><code>5b95efb</code></a>
fix(reporter): <code>agent</code> to respect <code>--silent</code> (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11271">#11271</a>)</li>
<li><a
href="f58a209779"><code>f58a209</code></a>
docs: correct benchmark.exclude and watch defaults (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11268">#11268</a>)</li>
<li><a
href="d1c3ecc932"><code>d1c3ecc</code></a>
fix(jsdom): fix <code>Request</code> with <code>Blob</code> body on
jsdom 28+ (<a
href="https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest/issues/11295">#11295</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/vitest-dev/vitest/commits/v5.0.2/packages/vitest">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.3.280 to
0.3.282

Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.3.280 to
0.3.282

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.3.280 to
0.3.282

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.3.280
to 0.3.282

Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.3.280 to
0.3.282

Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.3.280 to
0.3.282

Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.3.280 to
0.3.282

Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.3.280 to
0.3.282

Updates `@modelcontextprotocol/sdk` from 1.30.0 to 1.30.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/modelcontextprotocol/typescript-sdk/releases">@​modelcontextprotocol/sdk's
releases</a>.</em></p>
<blockquote>
<h2>1.30.1</h2>
<h2>What's Changed</h2>
<ul>
<li>[v1.x] fix(server): read HTTP request bodies with a size limit and
bound JSON-RPC batch length by <a
href="https://github.com/maxisbey"><code>@​maxisbey</code></a> in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2717">modelcontextprotocol/typescript-sdk#2717</a></li>
<li>fix(auth): preserve resource URI without trailing slash (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1968">#1968</a>)
by <a
href="https://github.com/MukundaKatta"><code>@​MukundaKatta</code></a>
in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1972">modelcontextprotocol/typescript-sdk#1972</a></li>
<li>chore: bump version to 1.30.1 by <a
href="https://github.com/claude"><code>@​claude</code></a>[bot] in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/2848">modelcontextprotocol/typescript-sdk#2848</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/MukundaKatta"><code>@​MukundaKatta</code></a>
made their first contribution in <a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/pull/1972">modelcontextprotocol/typescript-sdk#1972</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.30.0...1.30.1">https://github.com/modelcontextprotocol/typescript-sdk/compare/1.30.0...1.30.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="289ac2c3af"><code>289ac2c</code></a>
chore: bump version to 1.30.1 (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/2848">#2848</a>)</li>
<li><a
href="12b425678a"><code>12b4256</code></a>
fix(auth): preserve resource URI without trailing slash (<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1968">#1968</a>)
(<a
href="https://redirect.github.com/modelcontextprotocol/typescript-sdk/issues/1972">#1972</a>)</li>
<li><a
href="a9f6eb709b"><code>a9f6eb7</code></a>
[v1.x] fix(server): read HTTP request bodies with a size limit and bound
JSON...</li>
<li>See full diff in <a
href="https://github.com/modelcontextprotocol/typescript-sdk/compare/1.30.0...1.30.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `@oxc-project/types` from 0.150.0 to 0.151.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/oxc-project/oxc/releases">@​oxc-project/types's
releases</a>.</em></p>
<blockquote>
<h2>oxc crates_v0.151.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>1fac655 coverage: Support new TypeScript fixture layout (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26226">#26226</a>)
(camc314)</li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>cd7228f minifier: Add missing negation for boolean literal (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26847">#26847</a>)
(Armano)</li>
<li>32305e2 transformer: Skip enum reverse mappings for syntactic string
initializers (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26724">#26724</a>)
(Dunqing)</li>
<li>7811f0a parser: Preserve reparsed <code>await</code> tokens (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26619">#26619</a>)
(camc314)</li>
<li>9220158 transformer: Skip recovered using loop patterns (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26722">#26722</a>)
(camc314)</li>
<li>3130405 parser: Recover await using object binding patterns (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26721">#26721</a>)
(camc314)</li>
<li>827fdbd parser: Reject invalid modifiers on import aliases (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26622">#26622</a>)
(camc314)</li>
</ul>
<h3>⚡ Performance</h3>
<ul>
<li>3f1e3aa minifier: Preallocate stmts vec size to avoid excesive
reallocs (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26856">#26856</a>)
(Armano)</li>
<li>bd3f69e semantic: Reuse identifier hashes in enum member lookups (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26773">#26773</a>)
(camc314)</li>
<li>5f72954 minifier: Reduce allocations when processing if statements
(<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26666">#26666</a>)
(Armano)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bec650f15b"><code>bec650f</code></a>
release(crates): oxc v0.151.0 (<a
href="https://github.com/oxc-project/oxc/tree/HEAD/npm/oxc-types/issues/26875">#26875</a>)</li>
<li>See full diff in <a
href="https://github.com/oxc-project/oxc/commits/crates_v0.151.0/npm/oxc-types">compare
view</a></li>
</ul>
</details>
<br />

Updates `@rolldown/binding-android-arm-eabi` from 1.2.9 to 1.2.11
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/releases">@​rolldown/binding-android-arm-eabi's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.11</h2>
<h2>[1.2.11] - 2026-09-24</h2>
<h3>🚀 Features</h3>
<ul>
<li>watch: report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>sourcemap: preserve names through <code>collapse_sourcemaps</code>
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/9486">#9486</a>)
by <a href="https://github.com/eoin"><code>@​eoin</code></a></li>
<li>watch: map metadata events by kind and missing paths as deleted (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10973">#10973</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🚜 Refactor</h3>
<ul>
<li>rolldown_fs_watcher: hand the handler file events instead of notify
results (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10980">#10980</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: share one event handler adapter between the
notify watchers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10979">#10979</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: register every path with one watch mode (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10978">#10978</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: tweak the backend traits and the event mapping
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10977">#10977</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: translate notify events inside FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10972">#10972</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>watch: update the fs watcher layout and the dev coordinator message
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10981">#10981</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: document the event translation in FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10975">#10975</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>◀️ Revert</h3>
<ul>
<li>&quot;perf(dev/lazy): fetch a lazy route in one request&quot; (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10789">#10789</a>)
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10982">#10982</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/eoin"><code>@​eoin</code></a> made their
first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/9486">#9486</a></li>
</ul>
<p>Co-authored-by: shulaoda <a
href="mailto:165626830+shulaoda@users.noreply.github.com">165626830+shulaoda@users.noreply.github.com</a></p>
<h2>v1.2.10</h2>
<h2>[1.2.10] - 2026-09-23</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_esm_external_require: prefer 'module.exports' over a
namespace spread (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10901">#10901</a>)
by <a href="https://github.com/isker"><code>@​isker</code></a></li>
<li>watch: support directories in addWatchFile (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10944">#10944</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>label code-splitting timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10896">#10896</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>separate code-splitting group timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10892">#10892</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>clarify callback owners in the timing report (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10890">#10890</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>add checks.bundlerTimings with deprecated pluginTimings alias (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10883">#10883</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>code-splitting: initialize leaves behind a re-exported namespace
import (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10694">#10694</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md">@​rolldown/binding-android-arm-eabi's
changelog</a>.</em></p>
<blockquote>
<h2>[1.2.11] - 2026-09-24</h2>
<h3>🚀 Features</h3>
<ul>
<li>watch: report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>sourcemap: preserve names through <code>collapse_sourcemaps</code>
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/9486">#9486</a>)
by <a href="https://github.com/eoin"><code>@​eoin</code></a></li>
<li>watch: map metadata events by kind and missing paths as deleted (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10973">#10973</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🚜 Refactor</h3>
<ul>
<li>rolldown_fs_watcher: hand the handler file events instead of notify
results (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10980">#10980</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: share one event handler adapter between the
notify watchers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10979">#10979</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: register every path with one watch mode (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10978">#10978</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: tweak the backend traits and the event mapping
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10977">#10977</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: translate notify events inside FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10972">#10972</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>watch: update the fs watcher layout and the dev coordinator message
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10981">#10981</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: document the event translation in FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10975">#10975</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>◀️ Revert</h3>
<ul>
<li>&quot;perf(dev/lazy): fetch a lazy route in one request&quot; (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10789">#10789</a>)
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10982">#10982</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/eoin"><code>@​eoin</code></a> made their
first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/9486">#9486</a></li>
</ul>
<h2>[1.2.10] - 2026-09-23</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_esm_external_require: prefer 'module.exports' over a
namespace spread (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10901">#10901</a>)
by <a href="https://github.com/isker"><code>@​isker</code></a></li>
<li>watch: support directories in addWatchFile (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10944">#10944</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>label code-splitting timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10896">#10896</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>separate code-splitting group timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10892">#10892</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>clarify callback owners in the timing report (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10890">#10890</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>add checks.bundlerTimings with deprecated pluginTimings alias (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10883">#10883</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>code-splitting: initialize leaves behind a re-exported namespace
import (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10694">#10694</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>node: record the WASI target on the WebContainer binding fallback
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10952">#10952</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>dev: force incremental build on in DevEngine::new (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10958">#10958</a>)
by <a
href="https://github.com/o-alexandrov"><code>@​o-alexandrov</code></a></li>
<li>tree-shaking: propagate side effects through cycles (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10893">#10893</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="8df4219851"><code>8df4219</code></a>
release: v1.2.11 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10987">#10987</a>)</li>
<li><a
href="6f85455383"><code>6f85455</code></a>
revert: &quot;perf(dev/lazy): fetch a lazy route in one request&quot;
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10789">#10789</a>)
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10982">#10982</a>)</li>
<li><a
href="78699253af"><code>7869925</code></a>
feat(watch): report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)</li>
<li><a
href="3b8e06e99b"><code>3b8e06e</code></a>
release: v1.2.10 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10969">#10969</a>)</li>
<li><a
href="ec5bab8832"><code>ec5bab8</code></a>
feat(rolldown_plugin_esm_external_require): prefer 'module.exports' over
a na...</li>
<li><a
href="bb1db15db2"><code>bb1db15</code></a>
chore(deps): update napi (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10959">#10959</a>)</li>
<li><a
href="374639ef58"><code>374639e</code></a>
fix(node): record the WASI target on the WebContainer binding fallback
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10952">#10952</a>)</li>
<li><a
href="cfc9d19df0"><code>cfc9d19</code></a>
refactor(watch): let FsWatcher own the watched paths (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10948">#10948</a>)</li>
<li><a
href="1551cf8a09"><code>1551cf8</code></a>
feat(watch): support directories in addWatchFile (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10944">#10944</a>)</li>
<li><a
href="b12f077c66"><code>b12f077</code></a>
test: handle Windows paths in sourcemap callback tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10942">#10942</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/rolldown/rolldown/commits/v1.2.11/packages/rolldown">compare
view</a></li>
</ul>
</details>
<br />

Updates `@rolldown/binding-android-arm64` from 1.2.9 to 1.2.11
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/releases">@​rolldown/binding-android-arm64's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.11</h2>
<h2>[1.2.11] - 2026-09-24</h2>
<h3>🚀 Features</h3>
<ul>
<li>watch: report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>sourcemap: preserve names through <code>collapse_sourcemaps</code>
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/9486">#9486</a>)
by <a href="https://github.com/eoin"><code>@​eoin</code></a></li>
<li>watch: map metadata events by kind and missing paths as deleted (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10973">#10973</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🚜 Refactor</h3>
<ul>
<li>rolldown_fs_watcher: hand the handler file events instead of notify
results (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10980">#10980</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: share one event handler adapter between the
notify watchers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10979">#10979</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: register every path with one watch mode (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10978">#10978</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: tweak the backend traits and the event mapping
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10977">#10977</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: translate notify events inside FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10972">#10972</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>watch: update the fs watcher layout and the dev coordinator message
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10981">#10981</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: document the event translation in FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10975">#10975</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>◀️ Revert</h3>
<ul>
<li>&quot;perf(dev/lazy): fetch a lazy route in one request&quot; (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10789">#10789</a>)
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10982">#10982</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/eoin"><code>@​eoin</code></a> made their
first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/9486">#9486</a></li>
</ul>
<p>Co-authored-by: shulaoda <a
href="mailto:165626830+shulaoda@users.noreply.github.com">165626830+shulaoda@users.noreply.github.com</a></p>
<h2>v1.2.10</h2>
<h2>[1.2.10] - 2026-09-23</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_esm_external_require: prefer 'module.exports' over a
namespace spread (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10901">#10901</a>)
by <a href="https://github.com/isker"><code>@​isker</code></a></li>
<li>watch: support directories in addWatchFile (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10944">#10944</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>label code-splitting timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10896">#10896</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>separate code-splitting group timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10892">#10892</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>clarify callback owners in the timing report (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10890">#10890</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>add checks.bundlerTimings with deprecated pluginTimings alias (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10883">#10883</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>code-splitting: initialize leaves behind a re-exported namespace
import (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10694">#10694</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md">@​rolldown/binding-android-arm64's
changelog</a>.</em></p>
<blockquote>
<h2>[1.2.11] - 2026-09-24</h2>
<h3>🚀 Features</h3>
<ul>
<li>watch: report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>sourcemap: preserve names through <code>collapse_sourcemaps</code>
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/9486">#9486</a>)
by <a href="https://github.com/eoin"><code>@​eoin</code></a></li>
<li>watch: map metadata events by kind and missing paths as deleted (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10973">#10973</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🚜 Refactor</h3>
<ul>
<li>rolldown_fs_watcher: hand the handler file events instead of notify
results (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10980">#10980</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: share one event handler adapter between the
notify watchers (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10979">#10979</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: register every path with one watch mode (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10978">#10978</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>rolldown_fs_watcher: tweak the backend traits and the event mapping
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10977">#10977</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: translate notify events inside FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10972">#10972</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>📚 Documentation</h3>
<ul>
<li>watch: update the fs watcher layout and the dev coordinator message
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10981">#10981</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>watch: document the event translation in FsWatcher (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10975">#10975</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>◀️ Revert</h3>
<ul>
<li>&quot;perf(dev/lazy): fetch a lazy route in one request&quot; (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10789">#10789</a>)
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10982">#10982</a>)
by <a href="https://github.com/h-a-n-a"><code>@​h-a-n-a</code></a></li>
</ul>
<h3>❤️ New Contributors</h3>
<ul>
<li><a href="https://github.com/eoin"><code>@​eoin</code></a> made their
first contribution in <a
href="https://redirect.github.com/rolldown/rolldown/pull/9486">#9486</a></li>
</ul>
<h2>[1.2.10] - 2026-09-23</h2>
<h3>🚀 Features</h3>
<ul>
<li>rolldown_plugin_esm_external_require: prefer 'module.exports' over a
namespace spread (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10901">#10901</a>)
by <a href="https://github.com/isker"><code>@​isker</code></a></li>
<li>watch: support directories in addWatchFile (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10944">#10944</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>label code-splitting timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10896">#10896</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>separate code-splitting group timing rows (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10892">#10892</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>clarify callback owners in the timing report (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10890">#10890</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
<li>add checks.bundlerTimings with deprecated pluginTimings alias (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10883">#10883</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>code-splitting: initialize leaves behind a re-exported namespace
import (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10694">#10694</a>)
by <a href="https://github.com/hyfdev"><code>@​hyfdev</code></a></li>
<li>node: record the WASI target on the WebContainer binding fallback
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10952">#10952</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
<li>dev: force incremental build on in DevEngine::new (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10958">#10958</a>)
by <a
href="https://github.com/o-alexandrov"><code>@​o-alexandrov</code></a></li>
<li>tree-shaking: propagate side effects through cycles (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10893">#10893</a>)
by <a
href="https://github.com/IWANABETHATGUY"><code>@​IWANABETHATGUY</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="8df4219851"><code>8df4219</code></a>
release: v1.2.11 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10987">#10987</a>)</li>
<li><a
href="6f85455383"><code>6f85455</code></a>
revert: &quot;perf(dev/lazy): fetch a lazy route in one request&quot;
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10789">#10789</a>)
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10982">#10982</a>)</li>
<li><a
href="78699253af"><code>7869925</code></a>
feat(watch): report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)</li>
<li><a
href="3b8e06e99b"><code>3b8e06e</code></a>
release: v1.2.10 (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10969">#10969</a>)</li>
<li><a
href="ec5bab8832"><code>ec5bab8</code></a>
feat(rolldown_plugin_esm_external_require): prefer 'module.exports' over
a na...</li>
<li><a
href="bb1db15db2"><code>bb1db15</code></a>
chore(deps): update napi (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10959">#10959</a>)</li>
<li><a
href="374639ef58"><code>374639e</code></a>
fix(node): record the WASI target on the WebContainer binding fallback
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10952">#10952</a>)</li>
<li><a
href="cfc9d19df0"><code>cfc9d19</code></a>
refactor(watch): let FsWatcher own the watched paths (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10948">#10948</a>)</li>
<li><a
href="1551cf8a09"><code>1551cf8</code></a>
feat(watch): support directories in addWatchFile (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10944">#10944</a>)</li>
<li><a
href="b12f077c66"><code>b12f077</code></a>
test: handle Windows paths in sourcemap callback tests (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10942">#10942</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/rolldown/rolldown/commits/v1.2.11/packages/rolldown">compare
view</a></li>
</ul>
</details>
<br />

Updates `@rolldown/binding-darwin-arm64` from 1.2.9 to 1.2.11
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rolldown/rolldown/releases">@​rolldown/binding-darwin-arm64's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.11</h2>
<h2>[1.2.11] - 2026-09-24</h2>
<h3>🚀 Features</h3>
<ul>
<li>watch: report only file events (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/10974">#10974</a>)
by <a
href="https://github.com/shulaoda"><code>@​shulaoda</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>sourcemap: preserve names through <code>collapse_sourcemaps</code>
(<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown/issues/9486">#9486</a>)
by <a href="https://github.com/eoin"><code>@​eoin</code></a></li>
<li>watch: map metadata events by kind and missing paths as deleted (<a
href="https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown...

_Description has been truncated_

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-09-28 17:37:12 +02:00
dependabot[bot]
77ceab0339 chore(deps): Bump diff from 8.0.4 to 9.0.0 (#1187)
Bumps [diff](https://github.com/kpdecker/jsdiff) from 8.0.4 to 9.0.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/kpdecker/jsdiff/blob/master/release-notes.md">diff's
changelog</a>.</em></p>
<blockquote>
<h2>9.0.0</h2>
<p>(All changes part of PR <a
href="https://redirect.github.com/kpdecker/jsdiff/pull/672">#672</a>.)</p>
<ul>
<li>
<p><strong>ES5 support is dropped</strong>. <code>parsePatch</code> now
uses <code>TextDecoder</code> and <code>Uint8Array</code>, which are not
available in ES5, and TypeScript is now compiled with the
&quot;es6&quot; <code>target</code>. From now on, I intend to freely use
any features that are deemed &quot;Widely available&quot; by <a
href="https://web.dev/baseline">Baseline</a>. Users who need ES5 support
should stick to version 8.</p>
</li>
<li>
<p><strong>C-style quoted strings in filename headers are now properly
supported</strong>.</p>
<p>When the name of either the old or new file in a patch contains
&quot;special characters&quot;, both GNU <code>diff</code> and Git quote
the filename in the patch's headers and escape special characters using
the same escape sequences that are used in string literals in C,
including octal escapes for all non-ASCII characters. Previously, jsdiff
had very little support for this; <code>parsePatch</code> would remove
the quotes, and unescape any escaped backslashes, but would not unescape
other escape sequences. <code>formatPatch</code>, meanwhile, did not
quote or escape special characters at all.</p>
<p>Now, <code>parsePatch</code> parses all the possible escape sequences
that GNU diff (or Git) ever output, and <code>formatPatch</code> quotes
and escapes filenames containing special characters in the same way GNU
diff does.</p>
</li>
<li>
<p><strong><code>formatPatch</code> now omits file headers when
<code>oldFileName</code> or <code>newFileName</code> in the provided
patch object are <code>undefined</code></strong>, regardless of the
<code>headerOptions</code> parameter. (Previously, it would treat the
absence of <code>oldFileName</code> or <code>newFileName</code> as
indicating the filename was the word &quot;undefined&quot; and emit
headers <code>--- undefined</code> / <code>+++ undefined</code>.)</p>
</li>
<li>
<p><strong><code>formatPatch</code> no longer outputs trailing tab
characters at the end of <code>---</code>/<code>+++</code>
headers.</strong></p>
<p>Previously, if <code>formatPatch</code> was passed a patch object to
serialize that had empty strings for the <code>oldHeader</code> or
<code>newHeader</code> property, it would include a trailing tab
character after the filename in the <code>---</code> and/or
<code>+++</code> file header. Now, this scenario is treated the same as
when <code>oldHeader</code>/<code>newHeader</code> is
<code>undefined</code> - i.e. the trailing tab is omitted.</p>
</li>
<li>
<p><strong><code>formatPatch</code> no longer mutates its input</strong>
when serializing a patch containing a hunk where either the old or new
content contained zero lines. (Such a hunk occurs only when the hunk has
no context lines and represents a pure insertion or pure deletion, which
for instance will occur whenever one of the two files being diffed is
completely empty.) Previously <code>formatPatch</code> would provide the
correct output but also mutate the <code>oldLines</code> or
<code>newLines</code> property on the hunk, changing the meaning of the
underlying patch.</p>
</li>
<li>
<p><strong>Git-style patches are now supported by
<code>parsePatch</code>, <code>formatPatch</code>, and
<code>reversePatch</code></strong>.</p>
<p>Patches output by <code>git diff</code> can include some features
that are unlike those output by GNU <code>diff</code>, and therefore not
handled by an ordinary unified diff format parser. An ordinary diff
simply describes the differences between the <em>content</em> of two
files, but Git diffs can also indicate, via &quot;extended
headers&quot;, the creation or deletion of (potentially empty) files,
indicate that a file was renamed, and contain information about file
mode changes. Furthermore, when these changes appear in a diff in the
absence of a content change (e.g. when an empty file is created, or a
file is renamed without content changes), the patch will contain no
associated <code>---</code>/<code>+++</code> file headers nor any
hunks.</p>
<p>jsdiff previously did not support parsing Git's extended headers, nor
hunkless patches. Now <code>parsePatch</code> parses some of the
extended headers, parses hunkless Git patches, and can determine
filenames (e.g. from the extended headers) when parsing a patch that
includes no <code>---</code> or <code>+++</code> file headers. The
additional information conveyed by the extended headers we support is
recorded on new fields on the result object returned by
<code>parsePatch</code>. See <code>isGit</code> and subsequent
properties in the docs in the README.md file.</p>
<p><code>formatPatch</code> now outputs extended headers based on these
new Git-specific properties, and <code>reversePatch</code> respects them
as far as possible (with one unavoidable caveat noted in the README.md
file).</p>
</li>
<li>
<p><strong>Unpaired file headers now cause <code>parsePatch</code> to
throw</strong>.</p>
<p>It remains acceptable to have a patch with no file headers whatsoever
(e.g. one that begins with a <code>@@</code> hunk header on the very
first line), but a patch with <em>only</em> a <code>---</code> header or
only a <code>+++</code> header is now considered an error.</p>
</li>
<li>
<p><strong><code>parsePatch</code> is now more tolerant of
&quot;trailing garbage&quot;</strong></p>
<p>That is: after a patch, or between files/indexes in a patch, it is
now acceptable to have arbitrary lines of &quot;garbage&quot; (so long
as they unambiguously have no syntactic meaning - e.g. trailing garbage
that leads with a <code>+</code>, <code>-</code>, or <code> </code> and
thus is interpretable as part of a hunk still triggers a throw).</p>
<p>This means we no longer reject patches output by tools that include
extra data in &quot;garbage&quot; lines not understood by generic
unified diff parsers. (For example, SVN patches can include
&quot;Property changes on:&quot; lines that generic unified diff parsers
should discard as garbage; jsdiff previously threw errors when
encountering them.)</p>
<p>This change brings jsdiff's behaviour more in line with GNU
<code>patch</code>, which is highly permissive of
&quot;garbage&quot;.</p>
</li>
<li>
<p><strong>The <code>oldFileName</code> and <code>newFileName</code>
fields of <code>StructuredPatch</code> are now typed as <code>string |
undefined</code> instead of <code>string</code></strong>. This type
change reflects the (pre-existing) reality that <code>parsePatch</code>
can produce patches without filenames (e.g. when parsing a patch that
simply contains hunks with no file headers).</p>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="ed13aca03a"><code>ed13aca</code></a>
Update version in package.json and in release notes (<a
href="https://redirect.github.com/kpdecker/jsdiff/issues/683">#683</a>)</li>
<li><a
href="7a49317b50"><code>7a49317</code></a>
Bump dependencies again (<a
href="https://redirect.github.com/kpdecker/jsdiff/issues/682">#682</a>)</li>
<li><a
href="afe5aecad1"><code>afe5aec</code></a>
Add Git support, and otherwise variously improve &amp; fix parsePatch
(and other ...</li>
<li><a
href="2e46779d88"><code>2e46779</code></a>
Fix a typo (<a
href="https://redirect.github.com/kpdecker/jsdiff/issues/679">#679</a>)</li>
<li>See full diff in <a
href="https://github.com/kpdecker/jsdiff/compare/8.0.4...v9.0.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=diff&package-manager=npm_and_yarn&previous-version=8.0.4&new-version=9.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:56:18 +02:00
31 changed files with 3498 additions and 670 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.82.0"
".": "0.85.1"
}

View File

@@ -1,5 +1,41 @@
# Changelog
## [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)

View File

@@ -1204,6 +1204,10 @@ For AIR, the companion field carries `_meta.jetbrains.air.customAnswer`:
`questionId` names the select field of the same question.
A client can render the companion as the free-text choice of that question.
For AIR, the companion field also carries the same value under the root key `_meta._askUserQuestionCustomAnswer`.
Released AIR versions read only that key.
A client that is not AIR gets neither key.
## Session fork point
AIR can fork a session at one agent message.
@@ -1255,12 +1259,11 @@ The adapter sends the key only to an AIR client.
These keys moved into the AIR namespace. The adapter sends the new key only to AIR, and the old key to no client.
| Old key | New key |
| --------------------------------------------------------- | ---------------------------------------------- |
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
| elicitation property `_meta._askUserQuestionCustomAnswer` | `_meta.jetbrains.air.customAnswer`, same value |
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
| Old key | New key |
| --------------------------------------------------------- | --------------------------------------- |
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
The adapter does not send `_meta.claudeCode.title`, `claudeCode.subagent`, `claudeCode.skill`, or `claudeCode.skillPath` to any client.
AIR reads `_meta.jetbrains.air.commandTitle`, `subagent`, and `skill` instead.

828
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.82.0",
"version": "0.85.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -65,26 +65,26 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.5.0",
"@anthropic-ai/claude-agent-sdk": "0.3.280",
"diff": "^8.0.3",
"@agentclientprotocol/sdk": "1.7.0",
"@anthropic-ai/claude-agent-sdk": "0.3.286",
"diff": "9.0.0",
"zod": "4.6.5"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.127.0",
"@anthropic-ai/sdk": "0.131.0",
"@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",
"@types/node": "26.6.3",
"@typescript-eslint/eslint-plugin": "8.71.0",
"@typescript-eslint/parser": "8.71.0",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"eslint": "10.11.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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,12 @@ export const AIR_GOAL_KEY = "goal";
export const AIR_KIND_KEY = "kind";
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";

View File

@@ -12,7 +12,11 @@ import type {
AskUserQuestionInput,
AskUserQuestionOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { AIR_CUSTOM_ANSWER_KEY, withAirMeta } from "./air-extension.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
@@ -204,6 +208,7 @@ 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",
@@ -211,13 +216,15 @@ export function askUserQuestionsToCreateRequest(
? "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).",
// Marks the field as the custom answer companion of a select question,
// under `_meta.jetbrains.air.customAnswer`. Only AIR gets the marker.
// under `_meta.jetbrains.air.customAnswer` and under the root key that
// released AIR versions read. Only AIR gets the marker.
...(airClient
? {
_meta: withAirMeta(undefined, AIR_CUSTOM_ANSWER_KEY, {
questionId: questionFieldKey(index),
isCustomAnswer: true,
}),
_meta: withAirMeta(
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
AIR_CUSTOM_ANSWER_KEY,
customAnswer,
),
}
: {}),
};

View File

@@ -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);

51
src/serve.ts Normal file
View 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));
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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 };
@@ -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
*/

View File

@@ -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"),

View File

@@ -34,6 +34,7 @@ import {
toAcpNotifications,
promptToClaude,
isLocalCommandMetadata,
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
stripLocalCommandMetadata,
ClaudeAcpAgent,
@@ -1948,6 +1949,17 @@ describe("stripLocalCommandMetadata", () => {
).toBeNull();
});
it("strips a persisted task notification, keeping what the user typed", () => {
expect(
stripLocalCommandMetadata(
"<task-notification>\n<task-id>bg1</task-id>\n</task-notification>\nwhat now?",
),
).toBe("\nwhat now?");
expect(
stripLocalCommandMetadata("<task-notification><task-id>bg1</task-id></task-notification>"),
).toBeNull();
});
it("drops reminder-only blocks from mixed arrays, keeping real blocks", () => {
expect(
stripLocalCommandMetadata([
@@ -2527,6 +2539,365 @@ describe("synthetic login message (issue #863)", () => {
});
});
describe("task notification replay", () => {
type History = Awaited<ReturnType<typeof getSessionMessages>>;
const record = (fields: Record<string, unknown>) =>
({
session_id: "s1",
parent_tool_use_id: null,
parent_agent_id: null,
...fields,
}) as unknown as History[number];
const notificationText = (status: string, toolUseId = "toolu_bash") =>
"<task-notification>\n<task-id>bg1</task-id>\n" +
`<tool-use-id>${toolUseId}</tool-use-id>\n` +
"<output-file>/tmp/tasks/bg1.output</output-file>\n" +
`<status>${status}</status>\n` +
'<summary>Background command "npm test" finished</summary>\n' +
"<note>A task-notification fires each time the task stops.</note>\n" +
"<result><status>failed</status></result>\n</task-notification>";
const notification = (status: string, fields: Record<string, unknown> = {}) =>
record({
type: "user",
uuid: `notification-${status}`,
message: { role: "user", content: notificationText(status) },
origin: { kind: "task-notification" },
...fields,
});
const bashLaunch = [
record({ type: "user", uuid: "u1", message: { role: "user", content: "run the tests" } }),
record({
type: "assistant",
uuid: "a1",
message: {
id: "m1",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "npm test", run_in_background: true },
},
],
},
}),
record({
type: "user",
uuid: "u2",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash",
content: "Command running in background with ID: bg1.",
},
],
},
}),
];
async function replay(
history: History,
capabilities: ClientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
},
): Promise<AcpSessionNotification[]> {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) =>
updates.push(update as AcpSessionNotification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, capabilities);
vi.mocked(getSessionMessages).mockResolvedValueOnce(history);
await (
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
).replaySessionHistory("s1");
return updates;
}
const userChunks = (updates: AcpSessionNotification[]) =>
updates.flatMap(({ update }) =>
update.sessionUpdate === "user_message_chunk" && update.content.type === "text"
? [update.content.text]
: [],
);
const asyncTaskUpdates = (updates: AcpSessionNotification[]) =>
updates.flatMap(({ update }) =>
update.sessionUpdate.startsWith("async_task_") ? [update] : [],
);
it("hides the notification and restores the task state like the live frame", async () => {
const updates = await replay([...bashLaunch, notification("completed")]);
expect(userChunks(updates)).toEqual(["run the tests"]);
expect(JSON.stringify(updates)).not.toContain("task-notification");
expect(asyncTaskUpdates(updates)).toEqual([
{
sessionUpdate: "async_task_spawned",
asyncTaskId: "bg1",
name: "npm test",
taskType: "shell",
description: "npm test",
showInTranscript: true,
canStop: true,
outputFilePath: "/tmp/tasks/bg1.output",
toolCallId: "toolu_bash",
},
{
sessionUpdate: "async_task_state_update",
asyncTaskId: "bg1",
state: "completed",
summary: 'Background command "npm test" finished',
},
]);
});
it("detects a notification without an origin by its text and maps killed to stopped", async () => {
const updates = await replay([...bashLaunch, notification("killed", { origin: undefined })]);
expect(userChunks(updates)).toEqual(["run the tests"]);
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({
sessionUpdate: "async_task_state_update",
asyncTaskId: "bg1",
state: "stopped",
});
});
it.each(["server_tool_use", "mcp_tool_use"])(
"restores a background task launched by a %s block",
async (type) => {
const launch = record({
...bashLaunch[1],
message: {
role: "assistant",
content: [{ type, id: "toolu_bash", name: "Bash", input: { command: "npm test" } }],
},
});
const updates = await replay([
bashLaunch[0],
launch,
bashLaunch[2],
notification("completed"),
]);
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
"async_task_spawned",
"async_task_state_update",
]);
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "completed" });
},
);
it("routes a task to the session of its launching tool even when the root receives the stop", async () => {
const updates = await replay(
[
...bashLaunch.map((message, index) =>
index === 0 ? message : record({ ...message, parent_tool_use_id: "toolu_agent" }),
),
notification("completed"),
],
{
subagents: {},
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
} as ClientCapabilities,
);
const tasks = updates.filter(({ update }) => update.sessionUpdate.startsWith("async_task_"));
expect(tasks).toHaveLength(2);
expect(tasks.map(({ sessionId }) => sessionId)).toEqual([
"s1:replay-subagent:toolu_agent",
"s1:replay-subagent:toolu_agent",
]);
});
it("keeps the user text next to an embedded notification", async () => {
const updates = await replay([
...bashLaunch,
record({
type: "user",
uuid: "u3",
message: {
role: "user",
content: [
{ type: "text", text: notificationText("failed") },
{ type: "text", text: "why did it fail?" },
],
},
}),
]);
expect(userChunks(updates)).toEqual(["run the tests", "why did it fail?"]);
expect(JSON.stringify(updates)).not.toContain("task-notification");
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "failed" });
});
it("sends nothing for a task that no tool call in the history started", async () => {
const updates = await replay([
record({ type: "user", uuid: "u1", message: { role: "user", content: "hi" } }),
notification("completed"),
]);
expect(userChunks(updates)).toEqual(["hi"]);
expect(asyncTaskUpdates(updates)).toEqual([]);
});
it("sends one terminal state for a repeated notification", async () => {
const updates = await replay([
...bashLaunch,
notification("completed"),
notification("completed", { uuid: "notification-again" }),
]);
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
"async_task_spawned",
"async_task_state_update",
]);
});
it("restores the notified state of a background Agent after its launch result", async () => {
const updates = await replay(
[
record({
type: "assistant",
uuid: "a1",
message: {
id: "m1",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_agent",
name: "Agent",
input: { description: "Explore", prompt: "Look around", run_in_background: true },
},
],
},
}),
record({
type: "user",
uuid: "u2",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_agent", content: "Async agent launched." },
],
},
}),
record({
type: "user",
uuid: "u3",
message: { role: "user", content: notificationText("killed", "toolu_agent") },
origin: { kind: "task-notification" },
}),
],
{
subagents: {},
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
} as ClientCapabilities,
);
expect(userChunks(updates)).toEqual([]);
expect(asyncTaskUpdates(updates)).toEqual([]);
expect(updates.at(-1)?.update).toMatchObject({
sessionUpdate: "subagent_state_update",
subagentSessionId: "s1:replay-subagent:toolu_agent",
state: "cancelled",
});
});
it("keeps a task-notification delivery with a subkind as a prompt", async () => {
const updates = await replay([
record({
type: "user",
uuid: "u1",
message: { role: "user", content: "Run the nightly report" },
origin: { kind: "task-notification", subkind: "scheduled-trigger" },
}),
]);
expect(userChunks(updates)).toEqual(["Run the nightly report"]);
});
});
describe("meta messages from other agents, sessions and channels", () => {
// The shape SDK 0.3.284 getSessionMessages returns for a host-injected peer
// message (live probe): is_meta plus origin, content is the full framing.
const peerMessage = {
type: "user",
uuid: "peer-1",
session_id: "s1",
parent_tool_use_id: null,
parent_agent_id: null,
is_meta: true,
origin: { kind: "peer", from: "uds:/tmp/fake.sock", hostInjected: true },
message: {
role: "user",
content:
'Another Claude session sent a message:\n<cross-session-message from="uds:/tmp/fake.sock">enveloped hi</cross-session-message>\n\nThis came from another Claude session — not typed by your user.',
},
};
it("isReplayHiddenMetaMessage matches only meta messages with an inter-agent origin", () => {
expect(isReplayHiddenMetaMessage(peerMessage)).toBe(true);
for (const kind of ["channel", "observer", "observer-activity", "slack-ping"]) {
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind } })).toBe(true);
}
// Not meta: an un-framed message persisted as a normal user turn.
expect(isReplayHiddenMetaMessage({ ...peerMessage, is_meta: undefined })).toBe(false);
// Meta without an inter-agent origin, such as a compact summary.
expect(
isReplayHiddenMetaMessage({ ...peerMessage, origin: undefined, isCompactSummary: true }),
).toBe(false);
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind: "human" } })).toBe(false);
expect(isReplayHiddenMetaMessage({ ...peerMessage, type: "assistant" })).toBe(false);
expect(isReplayHiddenMetaMessage(undefined)).toBe(false);
});
it("loadSession replay skips them but keeps the user's own prompt", async () => {
const updates: SessionNotification[] = [];
const client = {
sessionUpdate: async (u: SessionNotification) => {
updates.push(u);
},
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
vi.mocked(getSessionMessages).mockResolvedValueOnce([
{
type: "user",
uuid: "u1",
session_id: "s1",
parent_tool_use_id: null,
parent_agent_id: null,
message: { role: "user", content: [{ type: "text", text: "hi, say one word" }] },
},
peerMessage,
] as Awaited<ReturnType<typeof getSessionMessages>>);
await (
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
).replaySessionHistory("s1");
expect(
updates.some(
(u) =>
u.update.sessionUpdate === "user_message_chunk" &&
u.update.content.type === "text" &&
u.update.content.text.includes("hi, say one word"),
),
).toBe(true);
expect(JSON.stringify(updates)).not.toContain("cross-session-message");
expect(JSON.stringify(updates)).not.toContain("Another Claude session");
});
});
describe("usage-limit failure replay", () => {
const usageLimitMessage = {
type: "assistant" as const,
@@ -16312,11 +16683,25 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
},
);
// The fresh query activates the same turn again; it is inserted once.
const inserted: string[] = [];
const startTurn = agent.startTurn.bind(agent);
vi.spyOn(agent, "startTurn").mockImplementation((params, events) =>
startTurn(params, {
...events,
inserted: (messageId) => {
inserted.push(messageId);
events.inserted(messageId);
},
}),
);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "plan" }],
});
expect(inserted).toHaveLength(1);
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/test" }),
expect.objectContaining({ publicSessionId: "test-session", permissionMode: "auto" }),
@@ -19310,6 +19695,59 @@ describe("turn steering (_session/steering)", () => {
await agent.sessions["test-session"]?.consumer;
});
// CLI 2.1.286: a steer that lands during a foreground tool call moves the tool
// to the background and joins the running cycle instead of aborting it, so
// the turn ends in ONE result stamped with both the prompt and the steer.
it.each([
{ name: "without an echo", echo: false },
{ name: "with a mid-cycle echo", echo: true },
])("settles a steer that joined the running cycle $name", async ({ echo }) => {
const timeline: string[] = [];
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
log: () => {},
error: () => {},
});
injectGeneratorSession(agent, (input) => {
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const u1 = await iter.next();
yield userEcho(u1.value);
yield createAssistantText("working on it");
const steered = await iter.next();
if (echo) yield userEcho(steered.value);
yield createAssistantText("STEERED-OK");
yield {
...createResultMessage(),
user_message_uuid: u1.value.uuid,
user_message_uuids: [u1.value.uuid, steered.value.uuid],
};
yield idleMessage();
}
return messageGenerator();
});
const turn = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
.then((response) => {
timeline.push(`prompt:${response.stopReason}`);
return response;
});
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
await expect(
agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also handle X" }] }),
).resolves.toEqual({ outcome: "injected" });
const response = await turn;
await agent.sessions["test-session"]?.consumer;
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
// One cycle ran, so usage is the single result's.
expect(response.usage?.inputTokens).toBe(10);
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
expect(agent.sessions["test-session"].owedTrailingIdles ?? 0).toBe(0);
});
// Issue #1114's other hang, and #1063's: the steer aborts an autonomous
// followup, whose result counts a trailing idle that the abort then folds into
// the one idle spanning both cycles. Absorbing that idle as debt would starve

View File

@@ -50,6 +50,7 @@ import {
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")>();
@@ -661,6 +662,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
(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 } },
},
@@ -689,7 +691,13 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
for (const scenario of SCENARIOS) {
for (const { at, meta } of metaObjects(air(scenario.name))) {
const duplicates = [
...Object.keys(meta).filter((k) => AIR_ONLY_META_KEYS.has(k) && k !== "jetbrains"),
// 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),
),

View File

@@ -3,7 +3,7 @@
{"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":{"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":"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}}}

513
src/tests/acp-v2.test.ts Normal file
View File

@@ -0,0 +1,513 @@
/**
* The experimental draft ACP v2 surface, driven through the protocol router
* with the SDK's own v1 and v2 client apps. The v2 client app validates every
* response, session update, and elicitation it receives against the v2
* schema, so a malformed v2 message fails the test.
*/
import { mkdtemp, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as v1 from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import type { AuthStatusUpdateNotification } from "../auth-status.js";
import { acpProtocolRouter } from "../serve.js";
import { clientSupportsNotices } from "../session-notices.js";
import { v2SessionUpdate } from "../v2/session-update.js";
import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js";
import packageJson from "../../package.json" with { type: "json" };
const execFileSpy = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, execFile: execFileSpy };
});
/** What the mocked Claude Agent SDK saw and answers. Reset before each test. */
const sdk = vi.hoisted(() => ({
queryOptions: [] as Options[],
mcpServerStatus: async (): Promise<Array<{ name: string; status: string }>> => [],
mcpAuthenticate: async (
_serverName: string,
): Promise<{ authUrl?: string; requiresUserAction: boolean; callbackExpected: boolean }> => ({
requiresUserAction: false,
callbackExpected: false,
}),
}));
const sdkDefaults = { ...sdk };
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
return {
...actual,
query: ({ options }: { options: Options }) => {
sdk.queryOptions.push(options);
return makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet",
description: "Fast",
supportsAutoMode: true,
},
],
}),
supportedCommands: async () => [
{ name: "review", description: "Review a change", argumentHint: "<pull request>" },
],
mcpServerStatus: () => sdk.mcpServerStatus(),
mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName),
});
},
listSessions: vi.fn(async () => [
{
sessionId: "11111111-2222-4333-8444-555555555555",
cwd: "/workspace/project",
summary: "Fix the build",
lastModified: Date.UTC(2026, 9, 1),
},
]),
getSessionMessages: vi.fn(async () => []),
deleteSession: vi.fn(async () => {}),
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
const CLI_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
apiProvider: "firstParty",
email: "user@example.com",
subscriptionType: "max",
});
const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" };
const agents: ClaudeAcpAgent[] = [];
/** An empty Claude config directory and a session cwd, so no test reads the real ones. */
let tempDir: string;
let cwd: string;
beforeEach(async () => {
// `claude auth status --json` reports a subscription, `claude auth logout` succeeds.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
callback(null, { stdout: args[1] === "status" ? CLI_SUBSCRIPTION : "", stderr: "" });
});
tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-"));
cwd = await mkdtemp(path.join(tempDir, "project-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude"));
Object.assign(sdk, sdkDefaults, { queryOptions: [] });
});
afterEach(async () => {
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
execFileSpy.mockReset();
vi.unstubAllEnvs();
await rm(tempDir, { recursive: true, force: true });
});
/** Connects a fresh router to a client, and returns the client end of the stream. */
function connectRouter(): v2.Stream {
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({
readable: toAgent.readable,
writable: toClient.writable,
});
return { readable: toClient.readable, writable: toAgent.writable };
}
/**
* A v2 client app that collects every `_auth/status_update`, session update,
* and elicitation it receives, and accepts every elicitation.
*/
function v2Client() {
const authUpdates: AuthStatusUpdateNotification[] = [];
const sessionUpdates: v2.UpdateSessionNotification[] = [];
const elicitations: v2.CreateElicitationRequest[] = [];
const completedElicitations: v2.CompleteElicitationNotification[] = [];
let notifyAuthUpdate = () => {};
const app = v2
.client({ name: V2_CLIENT_INFO.name })
.onNotification(
"_auth/status_update",
(params) => params as AuthStatusUpdateNotification,
({ params }) => {
authUpdates.push(params);
notifyAuthUpdate();
},
)
.onNotification(v2.methods.client.session.update, ({ params }) => {
sessionUpdates.push(params);
})
.onRequest(v2.methods.client.elicitation.create, ({ params }) => {
elicitations.push(params);
return { action: "accept" };
})
.onNotification(v2.methods.client.elicitation.complete, ({ params }) => {
completedElicitations.push(params);
});
/** Resolves once the client has received `count` auth status updates. */
const authUpdate = (count: number) =>
new Promise<AuthStatusUpdateNotification>((resolve) => {
notifyAuthUpdate = () => {
if (authUpdates.length >= count) {
resolve(authUpdates[count - 1]);
}
};
notifyAuthUpdate();
});
/** The session updates of one kind that the client has received. */
const updates = <Kind extends string>(kind: Kind) =>
sessionUpdates
.map((notification) => notification.update)
.filter(
(update): update is Extract<v2.SessionUpdate, { sessionUpdate: Kind }> =>
update.sessionUpdate === kind,
);
return { app, authUpdate, sessionUpdates, updates, elicitations, completedElicitations };
}
async function initializeV2(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) {
return agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities,
});
}
describe("ACP protocol routing", () => {
it("serves the unchanged v1 handshake to a v1 client", async () => {
let authUpdated!: () => void;
const authUpdate = new Promise<void>((resolve) => (authUpdated = resolve));
const response = await v1
.client({ name: "v1-test-client" })
.onNotification(
"_auth/status_update",
(params) => params,
() => authUpdated(),
)
.connectWith(connectRouter() as unknown as v1.Stream, async (agent) => {
const response = await agent.request(v1.methods.agent.initialize, {
protocolVersion: v1.PROTOCOL_VERSION,
clientCapabilities: { auth: { terminal: true } },
});
await authUpdate;
return response;
});
expect(response.protocolVersion).toBe(1);
expect(response.agentCapabilities?.loadSession).toBe(true);
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
expect(response.authMethods?.length).toBeGreaterThan(0);
for (const method of response.authMethods ?? []) {
expect(method).toHaveProperty("id");
}
});
it("serves the v2 handshake to a v2 client", async () => {
const { app, authUpdate } = v2Client();
const response = await app.connectWith(connectRouter(), async (agent) => {
const response = await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities: { auth: { terminal: {} } },
});
await authUpdate(1);
return response;
});
expect(response.protocolVersion).toBe(2);
expect(response.info).toEqual({
name: packageJson.name,
title: "Claude Agent",
version: packageJson.version,
});
// The whole v2 session baseline and the providers methods are not served yet.
expect(response.capabilities?.session).toBeUndefined();
expect(response.capabilities?.providers).toBeUndefined();
expect(response.authMethods?.length).toBeGreaterThan(0);
for (const method of response.authMethods ?? []) {
expect(method).not.toHaveProperty("id");
expect(method).toMatchObject({ methodId: expect.any(String), type: "terminal" });
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
}
});
it("rejects v2 prompts, which the v2 surface does not serve yet", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await expect(
agent.request(v2.methods.agent.session.prompt, {
sessionId,
prompt: [{ type: "text", text: "hello" }],
}),
).rejects.toMatchObject({ code: -32601 });
await authUpdate(1);
});
});
});
describe("ACP v2 sessions", () => {
it("creates a session with v2 MCP servers, config options, and commands", async () => {
const client = v2Client();
const response = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const response = await agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [
{ type: "stdio", name: "files", command: "/usr/local/bin/mcp-files" },
{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" },
],
});
await vi.waitFor(() => expect(client.updates("available_commands_update")).toHaveLength(1));
await client.authUpdate(1);
return response;
});
// A v2 stdio server has a `type`, which v1 omits, and may omit its lists.
expect(sdk.queryOptions[0].mcpServers).toMatchObject({
files: { type: "stdio", command: "/usr/local/bin/mcp-files", args: [] },
linear: { type: "http", url: "https://mcp.linear.app/mcp" },
});
expect(response).not.toHaveProperty("modes");
expect(response.configOptions?.map((option) => option.configId)).toEqual(["mode", "model"]);
for (const option of response.configOptions ?? []) {
expect(option).not.toHaveProperty("id");
}
expect(response.configOptions?.[0]).toMatchObject({ category: "mode", type: "select" });
expect(client.updates("available_commands_update")[0].availableCommands).toEqual([
{
name: "review",
description: "Review a change",
input: { type: "text", hint: "<pull request>" },
},
]);
});
it("rejects an MCP transport that v1 cannot express", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await expect(
agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "_custom", name: "custom" }],
}),
).rejects.toMatchObject({ code: -32602 });
await authUpdate(1);
});
expect(sdk.queryOptions).toHaveLength(0);
});
it("lists, resumes, closes, and deletes sessions", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
expect(await agent.request(v2.methods.agent.session.list, {})).toEqual({
sessions: [
{
sessionId: "11111111-2222-4333-8444-555555555555",
cwd: "/workspace/project",
title: "Fix the build",
updatedAt: "2026-10-01T00:00:00.000Z",
},
],
});
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.close, { sessionId });
// A closed session is resumed from its transcript.
const resumed = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
expect(sdk.queryOptions.at(-1)?.resume).toBe(sessionId);
expect(resumed).not.toHaveProperty("modes");
expect(resumed.configOptions?.[0].configId).toBe("mode");
await expect(
agent.request(v2.methods.agent.session.resume, {
sessionId,
cwd,
replayFrom: { type: "start" },
}),
).rejects.toMatchObject({ code: -32602 });
await agent.request(v2.methods.agent.session.delete, { sessionId });
await authUpdate(1);
});
});
it("sets a config option with a v2 value and reports modes only as config options", async () => {
const client = v2Client();
const response = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const response = await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "mode",
type: "id",
value: "plan",
});
await client.authUpdate(1);
return response;
});
expect(response.configOptions[0]).toMatchObject({ configId: "mode", currentValue: "plan" });
expect(client.updates("current_mode_update")).toEqual([]);
});
it("tells the agent that a v2 client takes notices", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await authUpdate(1);
});
expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true);
});
it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => {
let statusCall = 0;
sdk.mcpServerStatus = async () =>
statusCall++ === 0
? [{ name: "linear", status: "needs-auth" }]
: [{ name: "linear", status: "connected" }];
sdk.mcpAuthenticate = async () => ({
authUrl: "https://example.com/oauth/authorize",
requiresUserAction: true,
callbackExpected: true,
});
const client = v2Client();
const sessionId = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent, { elicitation: { url: {} } });
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
});
await vi.waitFor(() => expect(client.completedElicitations).toHaveLength(1));
await client.authUpdate(1);
return sessionId;
});
expect(client.elicitations).toEqual([
{
mode: "url",
sessionId,
message: "Authenticate with MCP server linear",
url: "https://example.com/oauth/authorize",
elicitationId: expect.stringMatching(/^mcp-oauth-/),
},
]);
expect(client.completedElicitations).toEqual([
{ elicitationId: (client.elicitations[0] as { elicitationId: string }).elicitationId },
]);
});
});
describe("ACP v2 session translation", () => {
it("renames the group id of grouped select options", () => {
expect(
v2ConfigOptions([
{
id: "model",
name: "Model",
type: "select",
currentValue: "sonnet",
options: [{ group: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
},
]),
).toEqual([
{
configId: "model",
name: "Model",
type: "select",
currentValue: "sonnet",
options: [{ groupId: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
},
]);
});
it("rejects a config option value type that v1 cannot express", () => {
expect(() =>
v1SetSessionConfigOptionRequest({
sessionId: "s",
configId: "c",
type: "_range",
value: 3,
}),
).toThrow("Config option values of type _range are not supported");
});
it("fails on session updates that it does not translate yet", () => {
expect(() =>
v2SessionUpdate({ sessionUpdate: "tool_call", toolCallId: "t", title: "Read" }),
).toThrow("does not translate tool_call session updates yet");
});
});
describe("ACP v2 auth", () => {
it("pushes the auth status of the connection after initialize", async () => {
const { app, authUpdate } = v2Client();
const update = await app.connectWith(connectRouter(), async (agent) => {
await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
});
return authUpdate(1);
});
expect(update.authStatus).toMatchObject({
kind: "account",
account: { email: "user@example.com" },
});
});
it("logs in to a gateway with auth/login and out with auth/logout", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
const response = await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities: { auth: { _meta: { gateway: true } } },
});
expect(response.authMethods).toContainEqual({
methodId: "gateway",
type: "agent",
name: "Custom model gateway",
description: "Use a custom gateway to authenticate and access models",
_meta: { gateway: { protocol: "anthropic" } },
});
await authUpdate(1);
await agent.request(v2.methods.agent.auth.login, {
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gateway.example.com", headers: {} } },
});
expect((await authUpdate(2)).authStatus).toMatchObject({
kind: "gateway",
detail: "gateway.example.com",
});
await agent.request(v2.methods.agent.auth.logout, {});
expect(execFileSpy).toHaveBeenCalledWith(
expect.any(String),
["auth", "logout"],
expect.any(Function),
);
expect((await authUpdate(3)).authStatus).toMatchObject({ kind: "account" });
});
});
});

View File

@@ -18,6 +18,8 @@ let sessionMessages: Record<string, unknown>[];
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
let initModels: Record<string, unknown>[] | undefined;
let setModelImpl: ((model: string) => Promise<void>) | undefined;
let interruptImpl: (() => Promise<undefined>) | undefined;
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
let mcpAuthenticateImpl: (serverName: string) => Promise<{
authUrl?: string;
@@ -34,8 +36,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.options;
return makeMockQuery({
interrupt: async () => undefined,
close: () => {},
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
close: () => closeSpy(),
initializationResult: async () => ({
models: initModels ?? [
{
@@ -86,6 +88,8 @@ describe("createSession options merging", () => {
vi.mocked(getSessionMessages).mockClear();
initModels = undefined;
setModelImpl = undefined;
interruptImpl = undefined;
closeSpy = vi.fn<() => void>();
mcpServerStatusResult = async () => [];
mcpAuthenticateImpl = async () => ({
requiresUserAction: false,
@@ -847,31 +851,75 @@ describe("createSession options merging", () => {
});
});
describe("session/close right after session/new", () => {
it("answers at once and closes the query", async () => {
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.closeSession({ sessionId });
expect(closeSpy).toHaveBeenCalledOnce();
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
undefined,
);
});
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
// SDK control requests are serialized. On a fresh session the background
// getContextUsage is still in flight, so a real CLI answers an interrupt
// only after it, which took ~3s and timed out the client's close.
contextUsageResult = () => new Promise<never>(() => {});
interruptImpl = () => new Promise<never>(() => {});
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const closed = await Promise.race([
agent.closeSession({ sessionId }).then(() => "closed"),
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
]);
expect(closed).toBe("closed");
expect(closeSpy).toHaveBeenCalledOnce();
});
});
describe("context window seeding", () => {
function sessionFor(sessionId: string) {
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
}
it("does not call getContextUsage during session creation", async () => {
// getContextUsage stalls until the session's first prompt turn has run
// (it is not serviced pre-turn), so session/new must never call it —
it("does not wait for getContextUsage during session creation", async () => {
// SDK control requests are serialized and getContextUsage used to stall
// until the first prompt turn, so session/new only kicks it off —
// awaiting it inline is what regressed session/new latency in 0.59.0.
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
contextUsageResult = ctxSpy;
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(ctxSpy).not.toHaveBeenCalled();
expect(ctxSpy).toHaveBeenCalledOnce();
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
});
it("seeds contextWindowSize from text inference, falling back to the default when it misses", async () => {
it("refines a guessed window from getContextUsage in the background", async () => {
// The mock model ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") carries
// no "1m" token anywhere, so inference misses and the window falls back to
// the default; the authoritative value arrives later via result.modelUsage.
// no "1m" token anywhere, so inference misses and the seed is the default
// until the background getContextUsage answers.
contextUsageResult = async () => ({ rawMaxTokens: 967000 });
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await vi.waitFor(() => expect(sessionFor(response.sessionId).contextWindowSize).toBe(967000));
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(true);
});
it("keeps the guessed window when getContextUsage reports a non-positive size", async () => {
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 0 }));
contextUsageResult = ctxSpy;
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await vi.waitFor(() => expect(ctxSpy).toHaveBeenCalled());
await new Promise((resolve) => setImmediate(resolve));
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
});
@@ -918,7 +966,7 @@ describe("createSession options merging", () => {
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
"haiku",
);
expect(ctxSpy).not.toHaveBeenCalled();
// Kicked off in the background, never awaited: it never answers here.
expect(sessionFor("resumed-model-probe").contextWindowAuthoritative).toBe(false);
expect(getSessionMessages).toHaveBeenCalledTimes(1);
expect(getSessionMessages).toHaveBeenCalledWith("resumed-model-probe");

View File

@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { applyPatch } from "diff";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -260,13 +260,32 @@ describe("approval patch previews", () => {
.join("");
const filePath = await temporaryFile(oldText);
// 200 changed lines. A diff that runs out of its time budget gives no
// patch, and patchText then throws.
const patch = patchText(
await previewPatchContent("Write", { file_path: filePath, content: newText }),
);
// Verify the 200-line patch independently of the runner's speed. The
// timeout fallback is exercised separately with an advancing clock.
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
try {
const patch = patchText(
await previewPatchContent("Write", { file_path: filePath, content: newText }),
);
expect(applyPatch(oldText, patch)).toBe(newText);
} finally {
clock.mockRestore();
}
});
expect(applyPatch(oldText, patch)).toBe(newText);
it("falls back to the standard Write diff when the patch budget expires", async () => {
const oldText = "before\n";
const newText = "after\n";
const filePath = await temporaryFile(oldText);
let now = 0;
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000));
try {
expect(await previewPatchContent("Write", { file_path: filePath, content: newText })).toEqual(
[{ type: "diff", path: filePath, oldText, newText }],
);
} finally {
clock.mockRestore();
}
});
it("builds patches that turn the old text into the new text", async () => {

View File

@@ -265,6 +265,7 @@ describe("askUserQuestionsToCreateRequest", () => {
type: "string",
title: "Other",
_meta: {
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
jetbrains: {
air: { customAnswer: { questionId: "question_0", isCustomAnswer: true } },
},
@@ -274,6 +275,7 @@ describe("askUserQuestionsToCreateRequest", () => {
type: "string",
title: "Other",
_meta: {
_askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true },
jetbrains: {
air: { customAnswer: { questionId: "question_1", isCustomAnswer: true } },
},

View File

@@ -0,0 +1,321 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
import { Pushable } from "../utils.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
const sessionId = "test-session";
const toolCallId = "unfinished-bash";
afterEach(() => clearHookCallbacks(sessionId));
describe("incomplete foreground tools", () => {
it.each(["result", "EOF"] as const)(
"fails the tool and prompt at %s even after the input stream closes",
async (ending) => {
const { prompt, updates, logError } = createTestSession((input) =>
unfinishedToolMessages(input, ending),
);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates).toContainEqual(
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId,
status: "failed",
}),
);
expect(logError).toHaveBeenCalledExactlyOnceWith(
expect.stringMatching(
/Session test-session, turn .+, stopReason=end_turn:.*unfinished-bash/,
),
);
const failed = updates.find((u) => u.toolCallId === toolCallId && u.status === "failed");
expect(failed.content[0].content.text).toContain("without returning a result");
expect(failed).not.toHaveProperty("rawOutput");
expect(hasHookCallback(toolCallId)).toBe(false);
},
);
it("detects a tool emitted before the user echo", async () => {
const { prompt } = createTestSession(toolBeforeUserEchoMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
});
it("detects a permission tool whose streamed tool_use never arrived", async () => {
const { prompt, updates } = createTestSession(permissionOnlyToolMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates).toContainEqual(expect.objectContaining({ toolCallId, status: "failed" }));
});
it("allows a completed tool with a late PostToolUse hook", async () => {
const { prompt, logError } = createTestSession(completedToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(hasHookCallback(toolCallId)).toBe(true);
await createPostToolUseHook()(
{
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command: "echo test" },
tool_response: { stdout: "test", stderr: "", interrupted: false },
} as any,
toolCallId,
{ signal: new AbortController().signal },
);
expect(logError).not.toHaveBeenCalled();
});
it("allows a tool explicitly handed off to a background task", async () => {
const { prompt, updates } = createTestSession(backgroundToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
});
it("does not claim a subagent's tool as a foreground tool", async () => {
const { prompt } = createTestSession(subagentToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
});
it("does not treat a request to run in the background as a confirmed handoff", async () => {
const { prompt } = createTestSession(unconfirmedBackgroundToolMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
});
it("waits for deferred settlement before checking unfinished tools", async () => {
const { prompt, logError } = createTestSession(deferredSettlementMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(logError).not.toHaveBeenCalled();
});
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates.filter((u) => u.status === "failed").map((u) => u.toolCallId)).toEqual([
toolCallId,
"second-tool",
]);
expect(agent.sessions[sessionId].emittedToolCalls.size).toBe(0);
expect(agent.sessions[sessionId].toolUseCache).toEqual({});
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
});
it("preserves cancellation and does not attribute its unfinished tool to the next turn", async () => {
const { prompt, updates } = createTestSession(cancelledThenSuccessfulTurnMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "cancelled" });
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
});
it("preserves an existing SDK failure", async () => {
const { prompt } = createTestSession(sdkFailureMessages);
await expect(prompt()).rejects.toThrow("original failure");
});
});
function toolStart(id = toolCallId, parent: string | null = null) {
return {
type: "stream_event",
session_id: sessionId,
uuid: "stream-message",
parent_tool_use_id: parent,
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id, name: "Bash", input: {} },
},
};
}
function toolResult(id = toolCallId) {
return {
type: "user",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: id, content: "done" }],
},
};
}
function createTestSession(
createSdkMessages: (input: Pushable<any>, agent: ClaudeAcpAgent) => AsyncGenerator<any>,
) {
const updates: any[] = [];
const logError = vi.fn();
const input = new Pushable<any>();
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => {
updates.push(notification.update);
},
requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } }),
} as unknown as AcpClient,
{ log: () => {}, error: logError },
);
agent.sessions[sessionId] = mockSessionState({
input,
// The real consumer reads the scripted SDK events from this generator.
query: wrapQuery(createSdkMessages(input, agent)),
});
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] });
return { agent, updates, logError, prompt };
}
// Wait for prompt() to submit input, then echo it as the SDK would.
async function* echoNextPrompt(input: Pushable<any>) {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
}
async function* unfinishedToolMessages(input: Pushable<any>, ending: "result" | "EOF") {
yield* echoNextPrompt(input);
yield toolStart();
yield {
...toolStart(),
event: { type: "content_block_stop", index: 0 },
};
// The input block closed, but no tool_result arrived. Returning ends the stream.
if (ending === "result") yield successfulResultMessage();
}
async function* toolBeforeUserEchoMessages(input: Pushable<any>) {
const { value } = await input[Symbol.asyncIterator]().next();
yield toolStart();
yield userEcho(value);
yield successfulResultMessage();
}
async function* permissionOnlyToolMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
yield* echoNextPrompt(input);
await agent.canUseTool(sessionId)("Bash", { command: "echo test" }, {
toolUseID: toolCallId,
signal: new AbortController().signal,
suggestions: [],
} as any);
yield successfulResultMessage();
}
async function* completedToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield toolResult();
yield successfulResultMessage();
}
async function* backgroundToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield {
type: "system",
subtype: "task_started",
session_id: sessionId,
task_id: "background-shell",
tool_use_id: toolCallId,
description: "dev server",
};
yield successfulResultMessage();
}
async function* subagentToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart("child-tool", "parent-agent");
yield successfulResultMessage();
}
async function* unconfirmedBackgroundToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield {
type: "assistant",
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [
{
type: "tool_use",
id: toolCallId,
name: "Bash",
input: { command: "sleep 100", run_in_background: true },
},
],
},
};
yield successfulResultMessage();
}
async function* deferredSettlementMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield {
type: "system",
subtype: "task_started",
session_id: sessionId,
task_id: "child",
tool_use_id: "parent-agent",
subagent_type: "Explore",
description: "investigate",
};
// The result arrives while the subagent is still running, so settlement waits.
yield successfulResultMessage();
yield toolResult();
yield {
type: "system",
subtype: "task_notification",
session_id: sessionId,
task_id: "child",
tool_use_id: "parent-agent",
status: "completed",
summary: "done",
};
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
const messages = input[Symbol.asyncIterator]();
yield userEcho((await messages.next()).value);
yield toolStart();
yield toolStart("second-tool");
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
// Keep the SDK stream open for the next prompt in the same session.
yield userEcho((await messages.next()).value);
yield successfulResultMessage();
}
async function* cancelledThenSuccessfulTurnMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
const messages = input[Symbol.asyncIterator]();
yield userEcho((await messages.next()).value);
yield toolStart();
await agent.cancel({ sessionId });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
yield userEcho((await messages.next()).value);
yield successfulResultMessage();
}
async function* sdkFailureMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield successfulResultMessage({
subtype: "error_during_execution",
is_error: true,
errors: ["original failure"],
});
}

View File

@@ -156,7 +156,7 @@ describe("versioned model display names", () => {
// Account-dependent model availability requires the same authenticated environment
// as the adapter. No prompt is sent: inspect only the SDK initialization response.
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
"the real SDK exposes one named Opus with the expected context suffix",
"the real SDK exposes one plain Opus row",
async () => {
let finishInput!: () => void;
const inputGate = new Promise<void>((resolve) => {
@@ -178,12 +178,14 @@ it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
);
expect(opusModels).toHaveLength(1);
// CLI 2.1.283 dropped the "[1m]" / "(1M context)" spelling from the Opus
// row: Opus is natively 1M, so the row names the plain model.
expect(opusModels[0]).toMatchObject({
value: "opus[1m]",
displayName: "Opus (1M context)",
resolvedModel: expect.stringMatching(/^claude-opus-.*\[1m\]$/),
description: expect.stringContaining("1M context"),
value: "opus",
displayName: "Opus",
resolvedModel: expect.stringMatching(/^claude-opus-[\d-]+$/),
});
expect(opusModels[0].description).not.toMatch(/\b1m\b/i);
} finally {
clearTimeout(timeout);
finishInput();

View File

@@ -5,9 +5,10 @@ import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import {
resolveModelPreference,
applyAvailableModelsAllowlist,
isDeniedModel,
matchResumedModel,
} from "../session-model.js";
import { settingsEffortForModel } from "../session-effort.js";
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
// Mirrors a real `supportedModels()` response: alias rows carry
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
@@ -234,6 +235,49 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
});
});
describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
it("drops an allowlisted model that deniedModels blocks, keeping Default", () => {
const result = applyAvailableModelsAllowlist(
LIVE_SHAPED_MODELS,
["claude-sonnet-5", "claude-opus-5-5"],
undefined,
["claude-opus-5"],
);
expect(result.map((m) => m.value)).toEqual(["default", "claude-sonnet-5"]);
});
it("checks an alias entry against the row it resolves to", () => {
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
"claude-sonnet-5",
]);
expect(result.map((m) => m.value)).toEqual(["default"]);
});
});
describe("isDeniedModel", () => {
it("blocks every spelling of a denied version and its later minors", () => {
const denied = ["claude-opus-5"];
expect(isDeniedModel("claude-opus-5", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5-20260101", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5[1m]", denied)).toBe(true);
expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true);
expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false);
});
it("blocks only that version when the entry names a minor", () => {
expect(isDeniedModel("claude-opus-5", ["claude-opus-5-5"])).toBe(false);
expect(isDeniedModel("claude-opus-5-5-fast", ["claude-opus-5-5"])).toBe(true);
});
it("blocks a whole family for a family alias and ignores settings-dependent aliases", () => {
expect(isDeniedModel("claude-haiku-4-5-20251001", ["haiku"])).toBe(true);
expect(isDeniedModel("haiku", ["haiku"])).toBe(true);
expect(isDeniedModel("claude-sonnet-5", ["haiku"])).toBe(false);
expect(isDeniedModel("claude-opus-5", ["default", "best", "opusplan"])).toBe(false);
});
});
// Runs against the real SDK (no mocks) so the fixture above is flagged the
// moment the SDK's actual model list shape drifts from it. Requires a usable
// ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.
@@ -325,3 +369,18 @@ describe("settingsEffortForModel", () => {
expect(settingsEffortForModel({ modelSettings: {} }, undefined)).toBeUndefined();
});
});
describe("effortFlagSettings", () => {
it("carries ultracode only when settings request it", () => {
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
effortLevel: "high",
ultracode: true,
});
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
effortLevel: null,
ultracode: true,
});
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
});
});

View File

@@ -1155,13 +1155,11 @@ describe("session config options", () => {
return (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
}
it("sets the window from text inference on model switch, without any getContextUsage IPC", async () => {
// getContextUsage stalls until the session's first prompt turn, so the
// switch path must never call it; the window is seeded from the text
// heuristic (here via the new model's resolvedModel) and later confirmed
// by result.modelUsage.
it("seeds the window from text inference on model switch", async () => {
// The window is seeded synchronously from the text heuristic (here via
// the new model's resolvedModel); the background refresh never answers.
const session = getSession();
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
session.query.getContextUsage = vi.fn(() => new Promise<never>(() => {}));
session.modelInfos = session.modelInfos.map((m: ModelInfo) =>
m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m,
);
@@ -1172,15 +1170,17 @@ describe("session config options", () => {
value: "claude-sonnet-4-6",
});
expect(session.query.getContextUsage).not.toHaveBeenCalled();
expect(session.contextWindowSize).toBe(1_000_000);
expect(session.contextWindowAuthoritative).toBe(false);
});
it("falls back to the default window when inference misses, without any getContextUsage IPC", async () => {
it("falls back to the default window when inference misses, then refines it in the background", async () => {
const session = getSession();
session.contextWindowSize = 1_000_000;
// Present but must NOT be called; the switch never consults it.
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
let answer!: (usage: { rawMaxTokens: number }) => void;
session.query.getContextUsage = vi.fn(
() => new Promise<{ rawMaxTokens: number }>((resolve) => (answer = resolve)),
);
// claude-sonnet-4-6 carries no "1m" token in its id, resolvedModel,
// displayName, or description, so inference misses → default window.
@@ -1190,22 +1190,40 @@ describe("session config options", () => {
value: "claude-sonnet-4-6",
});
expect(session.query.getContextUsage).not.toHaveBeenCalled();
// The switch resolved without waiting for getContextUsage.
expect(session.query.getContextUsage).toHaveBeenCalledOnce();
expect(session.contextWindowSize).toBe(200000);
answer({ rawMaxTokens: 967000 });
await vi.waitFor(() => expect(session.contextWindowSize).toBe(967000));
expect(session.contextWindowAuthoritative).toBe(true);
});
it("does not call getContextUsage even when switching to a fresh model", async () => {
it("drops a background answer that arrives after another switch", async () => {
const session = getSession();
const spy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
session.query.getContextUsage = spy;
let answer!: (usage: { rawMaxTokens: number }) => void;
session.query.getContextUsage = vi.fn(
() => new Promise<{ rawMaxTokens: number }>((resolve) => (answer = resolve)),
);
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
const staleAnswer = answer;
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-opus-4-5",
});
const windowAfterSecondSwitch = session.contextWindowSize;
expect(spy).not.toHaveBeenCalled();
staleAnswer({ rawMaxTokens: 967000 });
await new Promise((resolve) => setImmediate(resolve));
expect(session.contextWindowSize).toBe(windowAfterSecondSwitch);
expect(session.contextWindowAuthoritative).toBe(false);
});
it("keeps the learned window when re-asserting the current model", async () => {

View File

@@ -0,0 +1,400 @@
/**
* The turn lifecycle that the agent reports through `TurnEvents`, which each
* ACP version maps to its own messages. A scripted SDK query stands in for
* Claude Code: it echoes each prompt, then runs the turn's script.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import type { TurnEvents } from "../turn-events.js";
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
import { randomUUID } from "node:crypto";
const mockQuery = vi.hoisted(() => vi.fn());
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: mockQuery,
}));
// The agent reads the CLI's auth status on each prompt; keep it off the real CLI.
vi.mock("node:child_process", async () => ({
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
execFile: (...args: unknown[]) =>
(args[args.length - 1] as (error: Error) => void)(new Error("no CLI in tests")),
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
type QueryOptions = {
sessionId?: string;
canUseTool: (
toolName: string,
input: Record<string, unknown>,
extra: Record<string, unknown>,
) => Promise<unknown>;
};
/** The SDK side of one turn, after the echo of its prompt. */
type TurnScript = (options: QueryOptions) => AsyncGenerator<Record<string, unknown>>;
function result(sessionId: string) {
return {
type: "result",
subtype: "success",
stop_reason: "end_turn",
is_error: false,
result: "done",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: sessionId,
};
}
/** Scripts the query of the next session: each prompt runs the next turn script. */
function scriptTurns(turns: TurnScript[], end: "stay open" | "end stream" = "stay open") {
const promptUuids: string[] = [];
mockQuery.mockImplementation(
({ prompt, options }: { prompt: AsyncIterable<any>; options: QueryOptions }) => {
const sessionId = options.sessionId ?? "session";
async function* run() {
const input = prompt[Symbol.asyncIterator]();
for (const turn of turns) {
const { value, done } = await input.next();
if (done) return;
promptUuids.push(value.uuid);
yield {
type: "user",
message: value.message,
parent_tool_use_id: null,
uuid: value.uuid,
session_id: sessionId,
isReplay: true,
};
for await (const message of turn(options)) yield message;
}
if (end === "stay open") await input.next();
}
return Object.assign(run(), {
initializationResult: async () => ({
models: [{ value: "default", displayName: "Default", description: "" }],
}),
setModel: async () => {},
setPermissionMode: async () => {},
supportedCommands: async () => [],
mcpServerStatus: async () => [],
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
close: () => {},
interrupt: async () => {},
stopTask: async () => {},
});
},
);
return promptUuids;
}
/** Records the events of one turn as readable strings. */
function recordEvents() {
const log: string[] = [];
const done = Promise.withResolvers<void>();
const events: TurnEvents = {
inserted: (messageId) => log.push(`inserted ${messageId}`),
awaitingUser: () => log.push("awaitingUser"),
resumed: () => log.push("resumed"),
ended: (outcome) => {
log.push(`ended ${outcome.stopReason}`);
done.resolve();
},
failed: (error) => {
log.push(`failed ${error instanceof Error ? error.message : String(error)}`);
done.resolve();
},
};
return { log, events, done: done.promise };
}
describe("turn events", () => {
let agent: ClaudeAcpAgent;
let permissionRequests: RequestPermissionRequest[];
/** The user answers a request for this tool call only once it resolves. */
let backgroundAnswer: PromiseWithResolvers<void>;
/** Sees each session update; the agent waits until it returns. */
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
beforeEach(async () => {
vi.resetModules();
permissionRequests = [];
backgroundAnswer = Promise.withResolvers<void>();
onSessionUpdate = () => {};
const { ClaudeAcpAgent } = await import("../acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
extNotification: async () => {},
requestPermission: async (request: RequestPermissionRequest) => {
permissionRequests.push(request);
await new Promise((resolve) => setTimeout(resolve, 1));
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
const allow = request.options.find((option) => option.kind === "allow_once");
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
},
} as unknown as AcpClient);
});
afterEach(async () => {
await agent.dispose();
});
async function newSession() {
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
return sessionId;
}
const prompt = (sessionId: string, text: string) => ({
sessionId,
prompt: [{ type: "text" as const, text }],
});
it("reports a turn as inserted under the uuid of its user message, then ended", async () => {
const promptUuids = scriptTurns([
async function* (options) {
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "hello"), turn.events);
await turn.done;
expect(turn.log).toEqual([`inserted ${promptUuids[0]}`, "ended end_turn"]);
});
it("reports waiting on a permission request, then resuming", async () => {
scriptTurns([
async function* (options) {
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
const turn = recordEvents();
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
await turn.done;
expect(permissionRequests).toHaveLength(1);
expect(turn.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
});
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
const first = recordEvents();
const second = recordEvents();
let backgroundRequest: Promise<unknown> | undefined;
let atStart: string[] = [];
let afterOwnAnswer: string[] | undefined;
scriptTurns([
async function* (options) {
yield result(options.sessionId!);
await first.done;
// A request that no active turn opened, such as a background task's.
backgroundRequest = options.canUseTool(
"Bash",
{ command: "sleep 60" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
);
},
async function* (options) {
atStart = [...second.log];
await options.canUseTool(
"Bash",
{ command: "ls" },
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
);
afterOwnAnswer = [...second.log];
await backgroundRequest;
yield {
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
yield result(options.sessionId!);
},
]);
const sessionId = await newSession();
await agent.startTurn(prompt(sessionId, "start a background task"), first.events);
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "list files"), second.events);
await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined());
backgroundAnswer.resolve();
await second.done;
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
// Waiting from insertion, through its own answered request, until the
// earlier request is answered too.
expect(atStart.slice(1)).toEqual(["awaitingUser"]);
expect(afterOwnAnswer!.slice(1)).toEqual(["awaitingUser"]);
expect(second.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
});
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {
const release = Promise.withResolvers<void>();
scriptTurns([
async function* (options) {
// Claude Code is busy with the first prompt when the cancel arrives.
yield {
type: "assistant",
message: {
id: "msg_working",
type: "message",
role: "assistant",
model: "default",
content: [{ type: "text", text: "Working on it" }],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
await release.promise;
},
]);
agent.forceCancelGraceMs = 10;
const sessionId = await newSession();
const first = recordEvents();
const queued = recordEvents();
await agent.startTurn(prompt(sessionId, "first"), first.events);
await vi.waitFor(() => expect(first.log).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "second"), queued.events);
await agent.cancel({ sessionId });
await Promise.all([first.done, queued.done]);
release.resolve();
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended cancelled"]);
expect(queued.log).toEqual(["ended cancelled"]);
});
it("does not insert a turn that a cancel ended while the previous turn was handed off", async () => {
scriptTurns([
async function* (options) {
// The turn ends with a tool call that never got its result.
yield {
type: "assistant",
message: {
id: "msg_tool",
type: "message",
role: "assistant",
model: "default",
content: [
{ type: "tool_use", id: "toolu_open", name: "Bash", input: { command: "ls" } },
],
stop_reason: "tool_use",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: options.sessionId!,
};
},
async function* () {},
]);
agent.forceCancelGraceMs = 10;
// The hand-off to the second turn fails the open tool call; hold that update.
const failedToolCall = Promise.withResolvers<void>();
const releaseUpdate = Promise.withResolvers<void>();
onSessionUpdate = async ({ update }) => {
if (update.sessionUpdate === "tool_call_update" && update.status === "failed") {
failedToolCall.resolve();
await releaseUpdate.promise;
}
};
const sessionId = await newSession();
const first = recordEvents();
const second = recordEvents();
await agent.startTurn(prompt(sessionId, "first"), first.events);
await vi.waitFor(() => expect(first.log).toHaveLength(1));
await agent.startTurn(prompt(sessionId, "second"), second.events);
await failedToolCall.promise;
const cancelling = agent.cancel({ sessionId });
await vi.waitFor(() => expect(second.log).toEqual(["ended cancelled"]));
releaseUpdate.resolve();
await Promise.all([cancelling, first.done]);
// The hand-off then activates the second turn in promise continuations,
// which all run before a timer.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(second.log).toEqual(["ended cancelled"]);
});
it("fails a queued turn without inserting it when the query ends", async () => {
scriptTurns(
[
async function* (options) {
yield result(options.sessionId!);
},
],
"end stream",
);
const sessionId = await newSession();
const first = recordEvents();
const queued = recordEvents();
await agent.startTurn(prompt(sessionId, "first"), first.events);
await agent.startTurn(prompt(sessionId, "second"), queued.events);
await Promise.all([first.done, queued.done]);
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended end_turn"]);
expect(queued.log).toEqual([expect.stringMatching(/^failed /)]);
});
it("rejects a prompt that it refuses before starting a turn, and reports nothing", async () => {
const turn = recordEvents();
await expect(agent.startTurn(prompt("no-such-session", "hello"), turn.events)).rejects.toThrow(
"Session not found",
);
expect(turn.log).toEqual([]);
});
});

67
src/turn-events.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* A prompt turn as the adapter understands it, independent of the ACP version.
*
* The agent reports each turn through {@link TurnEvents}, and each ACP version
* maps the events to its own messages. ACP v1 answers `session/prompt` when the
* turn ends or fails. ACP v2 answers it when the prompt is inserted, and
* reports the rest as session state.
*
* The agent calls these synchronously while it updates its own state, so a
* handler must not throw, nor call back into the agent before it returns.
*/
export interface TurnEvents {
/**
* Claude Code took the prompt in as the user message `messageId` of the
* conversation. Reported at most once per turn, and never after
* {@link ended} or {@link failed}. For a prompt Claude Code echoes, that is
* the echo; for a command it does not echo (such as `/compact`), it is the
* command's result, after any output the command streamed.
*/
inserted(messageId: string): void;
/**
* The turn waits for the user: a permission request or a question is open in
* the session. Requests do not say which prompt they are for, so this
* includes one opened before the turn was inserted. Only reported after
* {@link inserted}.
*/
awaitingUser(): void;
/**
* The turn works again: no request is open any more. A turn that ends or
* fails while it waits reports that instead.
*/
resumed(): void;
/**
* The turn ended. A queued turn that is cancelled before it is inserted
* ends too, without {@link inserted}; Claude Code may still run its prompt.
*/
ended(outcome: TurnOutcome): void;
/**
* The turn failed. Without {@link inserted} before, Claude Code was not seen
* taking the prompt in.
*/
failed(error: unknown): void;
}
/** Why a turn ended. Every ACP version has these reasons. */
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
/** How a turn ended. */
export type TurnOutcome = {
stopReason: StopReason;
/** The tokens that the main agent loop of the turn used. */
usage?: TurnUsage;
/**
* Extension data for the client, such as the quota breakdown of the turn
* and a terminal session failure.
*/
_meta?: Record<string, unknown>;
};
export type TurnUsage = {
totalTokens: number;
inputTokens: number;
outputTokens: number;
thoughtTokens?: number;
cachedReadTokens?: number;
cachedWriteTokens?: number;
};

132
src/v2/agent.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* The experimental draft ACP v2 surface of the adapter.
*
* `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2
* request into the v1 request that the agent serves, and each v1 message that
* the agent sends into its v2 form, so v1 and v2 share one implementation.
*/
import type {
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
import {
v1NewSessionRequest,
v1ResumeSessionRequest,
v1SetSessionConfigOptionRequest,
v2ConfigOptions,
v2NewSessionResponse,
v2ResumeSessionResponse,
} from "./session.js";
import { v2SessionUpdate } from "./session-update.js";
/**
* The ACP v2 surface, for one connection.
*
* As in `v1AgentApp`, the agent of the connection is created when the
* connection opens, before the connection processes any inbound message.
* `onAgent` receives it for the owner of the process (shutdown).
*/
export function v2AgentApp(
logger: Logger | undefined,
onAgent: (agent: ClaudeAcpAgent) => void,
): v2.AgentApp {
let agent!: ClaudeAcpAgent;
return v2
.agent({ name: "claude-code-acp" })
.onConnect((connection) => {
agent = new ClaudeAcpAgent(new V2ClientConnection(connection.client), logger);
onAgent(agent);
})
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))),
)
.onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params))
.onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params))
.onRequest(v2.methods.agent.session.new, async ({ params }) =>
v2NewSessionResponse(await agent.newSession(v1NewSessionRequest(params))),
)
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
.onRequest(v2.methods.agent.session.resume, async ({ params }) =>
v2ResumeSessionResponse(await agent.resumeSession(v1ResumeSessionRequest(params))),
)
.onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params))
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
.onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => {
const { configOptions, ...response } = await agent.setSessionConfigOption(
v1SetSessionConfigOptionRequest(params),
);
return { ...response, configOptions: v2ConfigOptions(configOptions) };
});
}
/**
* The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of
* the agent as v2 messages.
*
* The v2 surface does not serve prompts yet, so permission requests, which
* only a prompt turn makes, still reject.
*/
class V2ClientConnection implements AcpClient {
constructor(private readonly ctx: v2.AgentContext) {}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
if (!isExtensionMethod(method)) {
return Promise.reject(new Error(`${method} is not an ACP extension method`));
}
return this.ctx.notify(method, params);
}
async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise<void> {
const v2Update = v2SessionUpdate(update);
if (v2Update) {
await this.ctx.notify(v2.methods.client.session.update, {
...notification,
update: v2Update,
});
}
}
requestPermission(): Promise<never> {
return Promise.reject(
new Error("The ACP v2 surface does not serve session/request_permission yet"),
);
}
// Elicitation is the same in v1 and v2.
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(
v2.methods.client.elicitation.create,
// The v1 types accept a property schema with any tag; the v2 types accept
// one only in a received value. As on v1, an MCP server's schema is
// relayed here unchanged, so this cast does not check it.
params as v2.CreateElicitationRequest,
{ cancellationSignal: signal },
);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
}
// v2 has no client file system. The agent never calls these on v2, because
// `v1InitializeRequest` reports no `fs` capability.
readTextFile(): Promise<never> {
return Promise.reject(new Error("ACP v2 has no client file system"));
}
writeTextFile(): Promise<never> {
return Promise.reject(new Error("ACP v2 has no client file system"));
}
}
function isExtensionMethod(method: string): method is v2.ExtensionMethod {
return method.startsWith("_");
}

82
src/v2/initialize.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* The ACP v2 `initialize` handshake, expressed through the v1 handshake of
* `ClaudeAcpAgent`.
*
* The agent decides its capabilities and auth methods in v1 terms. The v2
* request becomes the v1 request that the agent reads, and the v1 response of
* the agent becomes the v2 response, so both protocol versions advertise from
* one source.
*/
import {
PROTOCOL_VERSION as V1_PROTOCOL_VERSION,
type AuthMethod,
type InitializeRequest,
type InitializeResponse,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
/** The v1 `initialize` request that a v2 client means. */
export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest {
const capabilities = request.capabilities;
return {
protocolVersion: V1_PROTOCOL_VERSION,
clientInfo: request.info,
clientCapabilities: {
// v2 has no client file system and no client terminals: the agent
// reads, writes, and runs commands itself.
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
// Every v2 client handles boolean config options and notices; v1 asks
// for a marker. With notices, the agent reports advisories as notices
// rather than as transcript messages.
session: { configOptions: { boolean: {} }, notices: {} },
// v1 marks terminal auth support with a boolean, v2 with an object.
auth: {
terminal: capabilities?.auth?.terminal != null,
...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}),
},
...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}),
...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}),
},
...(request._meta != null ? { _meta: request._meta } : {}),
};
}
/**
* The v2 `initialize` response for the v1 response of the agent.
*
* It advertises no `session` capability yet. A v2 agent that advertises
* `session` must serve the whole session baseline (`session/new`, `list`,
* `resume`, `close`, `prompt`, `cancel`, and `update`), and the v2 surface of
* the adapter does not serve it yet. Nor does it advertise the unstable
* `providers` methods, which the v2 surface does not serve yet either.
*/
export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse {
if (!response.agentInfo) {
throw new Error("ACP v2 requires agentInfo, and the agent reported none");
}
const meta = response.agentCapabilities?._meta;
return {
protocolVersion: v2.PROTOCOL_VERSION,
info: response.agentInfo,
// v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth
// methods must serve `auth/logout`.
capabilities: meta != null ? { _meta: meta } : {},
authMethods: (response.authMethods ?? []).map(v2AuthMethod),
...(response._meta != null ? { _meta: response._meta } : {}),
};
}
/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */
function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
if ("type" in method && method.type === "terminal") {
const { id, env, ...terminal } = method;
return {
...terminal,
methodId: id,
...(env ? { env: Object.entries(env).map(([name, value]) => ({ name, value })) } : {}),
};
}
const { id, ...agent } = method;
return { ...agent, methodId: id, type: "agent" };
}

38
src/v2/session-update.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* The v2 form of the session updates that `ClaudeAcpAgent` sends as v1
* updates.
*/
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
/**
* The v2 form of a v1 session update, or `undefined` when v2 has no
* counterpart and the update tells a v2 client nothing new.
*
* Update kinds that the v2 surface does not translate yet throw, so a gap is
* an error rather than a silently missing update.
*/
export function v2SessionUpdate(
update: AcpSessionNotification["update"],
): v2.SessionUpdate | undefined {
switch (update.sessionUpdate) {
case "available_commands_update":
return { ...update, availableCommands: v2AvailableCommands(update.availableCommands) };
case "config_option_update":
return { ...update, configOptions: v2ConfigOptions(update.configOptions) };
case "session_info_update":
case "usage_update":
case "notice":
return update;
case "current_mode_update":
// v2 has no modes: the mode is the `mode` config option. Every mode
// change also reaches the client as a `config_option_update` or in the
// `configOptions` of a `session/set_config_option` response.
return undefined;
default:
throw new Error(
`The ACP v2 surface does not translate ${update.sessionUpdate} session updates yet`,
);
}
}

131
src/v2/session.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* The ACP v2 session lifecycle, expressed through the v1 session methods of
* `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves,
* and its v1 responses become v2 responses.
*/
import {
RequestError,
type AvailableCommand,
type McpServer,
type NewSessionRequest,
type NewSessionResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionConfigOption,
type SessionConfigSelectGroup,
type SessionConfigSelectOptions,
type SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
const { mcpServers, ...request } = params;
// v1 requires the list; v2 treats an omitted list as an empty one.
return { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) };
}
/**
* `replayFrom` is rejected until the v2 surface can translate the replayed
* history, which needs the message and tool call updates.
*/
export function v1ResumeSessionRequest(params: v2.ResumeSessionRequest): ResumeSessionRequest {
const { replayFrom, mcpServers, ...request } = params;
if (replayFrom != null) {
throw RequestError.invalidParams(
{ replayFrom },
"The ACP v2 surface does not replay session history yet",
);
}
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
}
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse {
return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) };
}
export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse {
return {
...v2ConfigOptionsField(response.configOptions),
...(response._meta != null ? { _meta: response._meta } : {}),
};
}
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
export function v1SetSessionConfigOptionRequest(
params: v2.SetSessionConfigOptionRequest,
): SetSessionConfigOptionRequest {
const request = {
sessionId: params.sessionId,
configId: params.configId,
...(params._meta != null ? { _meta: params._meta } : {}),
};
if (v2.SetSessionConfigOptionRequest.isId(params)) {
return { ...request, value: params.value };
}
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
return { ...request, type: "boolean", value: params.value };
}
throw RequestError.invalidParams(
{ type: params.type },
`Config option values of type ${params.type} are not supported`,
);
}
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
return options.map((option) => {
const { id, ...rest } = option;
if (rest.type === "select") {
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
}
return { ...rest, configId: id };
});
}
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
if (!isGrouped(options)) return options;
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
}
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
return options.some((entry) => "group" in entry);
}
/** v2 tags the free-text input of a command with `type: "text"`. */
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
return commands.map(({ input, ...command }) => ({
...command,
...(input != null ? { input: { type: "text", ...input } } : {}),
}));
}
/** v1 responses may send `null`; v2 omits the field instead. */
function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): {
configOptions?: v2.SessionConfigOption[];
} {
return options != null ? { configOptions: v2ConfigOptions(options) } : {};
}
/**
* v2 requires a `type` on every transport and makes the empty lists optional.
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
* is dropped. A transport that v1 cannot express is rejected rather than
* silently left out of the session.
*/
function v1McpServer(server: v2.McpServer): McpServer {
if (v2.McpServer.isStdio(server)) {
const { name, command, args, env, _meta } = server;
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
}
if (v2.McpServer.isHttp(server)) {
return { ...server, headers: server.headers ?? [] };
}
if (v2.McpServer.isAcp(server)) {
return server;
}
throw RequestError.invalidParams(
{ type: server.type },
`MCP servers of type ${server.type} are not supported`,
);
}