Compare commits

...

11 Commits

Author SHA1 Message Date
acp-release-bot[bot]
8e18eb7c75 chore(main): release 0.102.0 (#1553)
🤖 I have created a release *beep* *boop*
---


##
[0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0)
(2026-11-09)


### Features

* list workflow phases and agents on the workflow card
([#1552](https://github.com/agentclientprotocol/claude-agent-acp/issues/1552))
([176ae06](176ae0634c))
* send the checklist as a plan operation and remove plans on reset
([#1554](https://github.com/agentclientprotocol/claude-agent-acp/issues/1554))
([766fd14](766fd142f6))


### Bug Fixes

* report v2 background tasks without a tool call as their own tool call
([#1556](https://github.com/agentclientprotocol/claude-agent-acp/issues/1556))
([d098fb0](d098fb08d5))
* update to @anthropic-ai/claude-agent-sdk 0.3.321
([#1557](https://github.com/agentclientprotocol/claude-agent-acp/issues/1557))
([855447c](855447c7cd))

---
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-11-09 20:52:05 +01:00
Ben Brandt
855447c7cd fix: update to @anthropic-ai/claude-agent-sdk 0.3.321 (#1557)
## Summary

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.319 to 0.3.321. It
bundles Claude Code 2.1.321, which adds Fable 5.2 (`claude-fable-5-2`)
as the new default `fable` model. Also bumps these dev dependencies:
`@anthropic-ai/sdk` 1.6.0 → 1.7.0 and `@typescript-eslint/*` 8.72.1 →
8.72.2. `typescript` 7 is left for its own PR.

## Fable 5.2

Checked against the real CLI:

- A new session lists `fable` → `claude-fable-5-2`, and
`claude-fable-5-1` as its own row.
- A resumed Fable 5.2 session lists `claude-fable-5-2` and no `fable`
row, the same pattern 5.1 had.

`aliasResumedModelRow` already handles any version, so the code is
unchanged. Its doc comment and test fixture now describe the 2.1.321
lists. The fixture also covers a resumed Fable 5.1 session keeping
`claude-fable-5-1`, because a newer Fable is listed.

## Changelog review

- No SDK type changes that affect us. The only changes are doc text and
a new optional `endedLoopWakeup` field in `sdk-tools.d.ts`.
- Queued subagent reports in auto mode now share one model call, and all
but the last end in a `num_turns: 0` placeholder result. These results
carry `task-notification` or `peer` origins, which we already treat as
placeholders. That handling dates from CLI 2.1.274, which did the same
for background-task notifications.
- Fixes we pick up: one `undefined` entry in `hooks` no longer drops
every hook; a PreToolUse `ask` is no longer lost when another hook's
`defer` finishes first; project skills and commands no longer go missing
after entering a worktree; MCP OAuth no longer sends refresh tokens to
another host after a redirect.

## Testing

- `npm run build`, `npm run check`, `npm run test:run`
- Real CLI: listed the models for a new session and for a resumed Fable
5.2 session

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-09 19:43:47 +00:00
Ben Brandt
d098fb08d5 fix: report v2 background tasks without a tool call as their own tool call (#1556)
## Summary

Background tasks that have no tool call or terminal on v2 now get a tool
call of their own. Before, they showed up as a `_claude/task` vendor
item. This follows the spec rule in
agentclientprotocol/agent-client-protocol#2786.

Examples: a workflow, or an MCP task that starts after its tool call
already returned, or a task a hook started.

- `BackgroundWork.adoptTasks` starts a tool call `task_<taskId>` for
each such task. It uses the task description as title, `kind: "other"`,
and `in_progress` or `paused`. A subagent's task goes on the subagent's
stream. The message loop sends these calls before `sync`, so a call is
always announced before the first `background` list names it. The same
path covers the `state_update` snapshot on `session/resume`.
- These calls are listed as `tool_call` items, so `tool_call/cancel`
stops the task. When the task ends they get a final status like any
other listed call (`completed`, `failed`, or `cancelled`).
- The `_claude/task` item type is gone. `BackgroundItem` is now
`terminal | tool_call | subagent`.
- Ambient tasks such as the monitor, and tasks whose own tool call is
still open, are unchanged.

`adoptTasks` is synchronous and returns the notifications, so the loop
only awaits when there is something to send. An extra await on every
pass broke the steering-cancel timing in `acp-agent.test.ts`.

## Compatibility

- ACP v1 clients, including JetBrains AIR's `_session/async_task/*`
extension, are unaffected. Background lists, and therefore this path,
only run when the v2 background sink exists and the session has
`perTaskStopAffordance`. On v1, `adoptTasks` returns `[]`.
- v2 clients that saw `_claude/task` items (v0.100.0 to v0.101.1) now
see `task_<id>` tool calls instead.

## Tests

`acp-v2.test.ts` replaces the `_claude/task` tests. New tests cover:

- starting and listing a call for a task that starts after its tool call
returned
- cancelling such a task with `tool_call/cancel`
- the call being sent before the `session/resume` snapshot
- a subagent's task appearing on the subagent's stream

`npm run check`, `build` and `test:run` pass.
2026-11-09 20:40:39 +01:00
Ben Brandt
766fd142f6 feat: send the checklist as a plan operation and remove plans on reset (#1554)
## Summary

Follow-up to the [plan operations
RFD](https://agentclientprotocol.com/rfds/plan-operations). The adapter
already sent approved ExitPlanMode plans as `markdown` plans to clients
with the `plan` capability, but two parts of the RFD were unused:

- **The checklist is now a plan operation on v1 too.** Clients with
`plan` (every v2 client, and v1 clients that opt in) get the
TodoWrite/Task checklist as `plan_update` with type `items` and the ID
`plan`. Before, v1 clients with the capability still got an ID-less
legacy `plan` next to the `plan-mode` plan. Clients without the
capability still get the legacy `plan`.
- **`plan_removed` is now sent.**
- An empty checklist sends `plan_removed` for `plan`. v2 clients used to
get a `plan_update` with no entries.
- A conversation reset (`/clear`) removes the checklist and the old
conversation's approved `plan-mode` plan. AIR keeps its approved plans,
which are joined to their approval cards by ID (#1469).

The new `src/plans.ts` builds the checklist updates. All three places
that send the checklist use it, and so does the v2 conversion of a
legacy `plan`, so v1 and v2 follow one rule.

The reset sends `plan_removed` for `plan-mode` even when the session
never published an approved plan. Tracking whether one was published
would be state that a restart loses, so the reset would behave
differently before and after one.

`file` plans are left out. A `file://` URI only works for a client on
the same machine, and v2 has no client filesystem.

## Tests

- `tools.test.ts`: TodoWrite with and without todos, and a TaskCreate
snapshot, for a v1 client with `plan`.
- `acp-agent.test.ts`: the reset test now runs for a client without plan
operations, a client with them, and AIR.
- `acp-v2.test.ts`: v2 end to end, TodoWrite then `/clear`, and an empty
legacy plan converting to `plan_removed`.
2026-11-09 14:58:36 +00:00
Ben Brandt
176ae0634c feat: list workflow phases and agents on the workflow card (#1552)
## Summary

Show the phases and agents of a running workflow on its ACP v2 tool
card, not only the current activity and totals.

Claude Code attaches a `workflow_progress` snapshot to some of a
workflow's `task_progress` events: every phase, and every agent with its
label, phase, state, tokens, tool calls, duration and last tool. #1550
deliberately left it out. This change renders it under the existing card
body:

```
**Count**
- two: done · 14,629 tokens · 0 tool calls · 1s
- three: running · Bash: echo 3

**Sum**
- not started
```

### Why an untyped field

The SDK (0.3.319) does not type `workflow_progress`. Workflow agents
also don't stream their messages through the root query, so this
snapshot is the only per-agent data the adapter gets.
`src/workflow-progress.ts` reads the shape the CLI sends and skips
entries that don't match it. If the CLI changes the format, the card
loses the list but still renders. The right long-term fix is for the SDK
to type this field, or to stream workflow agents with a parent id so
each one can become a child session.

### Behavior

- The snapshot is a full list, and the CLI leaves it out of some
reports. A report without one keeps the last list.
- A running agent shows its last tool (e.g. `Bash: echo 3`). A finished
one shows tokens, tool calls and duration.
- Agents with no known phase are listed first. A phase with no agent yet
shows `not started`. A retry shows its attempt. A state other than
`start`/`done` is shown as the CLI sends it.
- The existing duplicate suppression and the `session/resume` snapshot
cover the list too.
- AIR `async_task_progress` is unchanged. Like the rest of the card
progress from #1550, this only reaches v2 clients that get workflow
progress on the card.

Result previews, models and prompt previews are left out to keep the
card short.

## Validation

- Live probe on Claude Code 2.1.319 (SDK `query()`, two-phase
three-agent workflow, permissions limited to `Workflow` and one `echo
3`). It captured the snapshot shape used in the test doubles. Every
captured snapshot rendered through the built module as shown above. This
checked the protocol output, not the Delta UI.
- New v2 scenario built from the captured payload, plus unit cases in
`src/tests/workflow-progress.test.ts`.
- `npm run check`, `npm run build`, `npm run test:run` (3,691 passed, 30
skipped).
2026-11-09 14:49:48 +00:00
dependabot[bot]
fcb7e175ee ci(deps): Bump amannn/action-semantic-pull-request from 6.1.1 to 6.1.2 (#1551)
Bumps
[amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request)
from 6.1.1 to 6.1.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/amannn/action-semantic-pull-request/releases">amannn/action-semantic-pull-request's
releases</a>.</em></p>
<blockquote>
<h2>v6.1.2</h2>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.1...v6.1.2">6.1.2</a>
(2026-11-05)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)
(<a
href="fc7da0cece">fc7da0c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md">amannn/action-semantic-pull-request's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.1...v6.1.2">6.1.2</a>
(2026-11-05)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)
(<a
href="fc7da0cece">fc7da0c</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.0...v6.1.1">6.1.1</a>
(2025-08-22)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Parse <code>headerPatternCorrespondence</code> properly (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/295">#295</a>)
(<a
href="800da4c97f">800da4c</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.0.1...v6.1.0">6.1.0</a>
(2025-08-19)</h2>
<h3>Features</h3>
<ul>
<li>Support providing regexps for types (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/292">#292</a>)
(<a
href="a30288bf13">a30288b</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>Remove trailing whitespace from &quot;unknown release type&quot;
error message (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/291">#291</a>)
(<a
href="afa4edb1c4">afa4edb</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.0.0...v6.0.1">6.0.1</a>
(2025-08-13)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Actually execute action (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/289">#289</a>)
(<a
href="58e4ab40f5">58e4ab4</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.3...v6.0.0">6.0.0</a>
(2025-08-13)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>Upgrade action to use Node.js 24 and ESM (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/287">#287</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>Upgrade action to use Node.js 24 and ESM (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/287">#287</a>)
(<a
href="bc0c9a79ab">bc0c9a7</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.2...v5.5.3">5.5.3</a>
(2024-06-28)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Bump <code>braces</code> dependency (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/269">#269</a>.
by <a href="https://github.com/EelcoLos"><code>@​EelcoLos</code></a>)
(<a
href="2d952a1bf9">2d952a1</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.1...v5.5.2">5.5.2</a>
(2024-04-24)</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="2252a14e81"><code>2252a14</code></a>
chore: Release 6.1.2 [skip ci]</li>
<li><a
href="af97545064"><code>af97545</code></a>
ci: pin conventional-changelog-conventionalcommits to v9 (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/323">#323</a>)</li>
<li><a
href="fc7da0cece"><code>fc7da0c</code></a>
fix: Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)</li>
<li><a
href="4e8becf171"><code>4e8becf</code></a>
ci: bump the npm-packages group with 10 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/319">#319</a>)</li>
<li><a
href="b3fa6edcb1"><code>b3fa6ed</code></a>
ci: bump actions/setup-node from 6 to 7 in the github-action-workflows
group ...</li>
<li><a
href="2c9480cf28"><code>2c9480c</code></a>
chore: bump the npm-packages group with 12 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/315">#315</a>)</li>
<li><a
href="58e675f6ba"><code>58e675f</code></a>
ci: bump the github-action-workflows group with 2 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/313">#313</a>)</li>
<li><a
href="45b9ed7cf2"><code>45b9ed7</code></a>
chore: bump the npm-packages group with 12 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/308">#308</a>)</li>
<li><a
href="9d28649546"><code>9d28649</code></a>
ci: Bump the github-action-workflows group with 2 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/309">#309</a>)</li>
<li><a
href="ac7e3fc035"><code>ac7e3fc</code></a>
docs: Fix snippet (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/306">#306</a>)</li>
<li>Additional commits viewable in <a
href="48f256284b...2252a14e81">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=amannn/action-semantic-pull-request&package-manager=github_actions&previous-version=6.1.1&new-version=6.1.2)](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>
2026-11-09 15:39:29 +01:00
acp-release-bot[bot]
e9e0d414fe chore(main): release 0.101.1 (#1539)
🤖 I have created a release *beep* *boop*
---


##
[0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1)
(2026-11-09)


### Bug Fixes

* keep autonomous turn tasks open across owed trailing idles
([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455))
([1ee2422](1ee2422934))
* list a resumed Fable 5.1 session's model as `fable`
([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548))
([4cceb06](4cceb06b31))
* list a subagent's background Bash commands
([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540))
([6597626](6597626d86))
* list Monitor calls in the session's background work
([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538))
([ba16234](ba162346c9))
* relay a rate_limit_event that arrives before the turn's first
assistant frame
([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534))
([54392e1](54392e144d))
* render workflow tool cards and progress correctly in ACP v2
([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550))
([30600ba](30600ba348))
* replay a compaction summary by its flag, not its framing
([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536))
([aa0c123](aa0c1234b5))
* report setup-token logins as logged in
([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376))
([7d1ef76](7d1ef76c1d))
* start transcript notices on their own line for clients without
session.notices
([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362))
([47b64c0](47b64c04de))
* **test:** assert the blockquoted MCP startup transcript line
([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543))
([3b22586](3b22586cf6))
* update to @agentclientprotocol/sdk 2.5.0
([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549))
([ef65ab3](ef65ab31b4))
* update to @anthropic-ai/claude-agent-sdk 0.3.319
([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545))
([a58293f](a58293f4c9))

---
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-11-09 14:03:18 +00:00
Ben Brandt
30600ba348 fix: render workflow tool cards and progress correctly in ACP v2 (#1550)
## Summary

Fix workflow tool cards that remain pending indefinitely, and surface
workflow progress through standard ACP v2 updates.

### Prevent orphan preview cards

Claude Code invokes Edit/Write preview hooks for workflow agents without
streaming those tools' uses and results through the root query.
Publishing a card from the hook alone leaves a pending call with no
completion path.

Stage early preview patches until the stream or a permission request
publishes the actual tool call. Keep patches pinned against later input
refinements, bound unclaimed previews, and clear them on teardown. A
call's own content takes precedence over a staged preview.

### Show workflow progress in place

Forward the SDK's typed task-progress fields to the existing workflow
card: current activity, summary, last activity, token count, tool-call
count, and elapsed time. Preserve progress arriving before the launch
result, suppress duplicate display updates, and include the latest
progress on session resume. Progress does not change paused/running
state or reopen completed calls.

This reads no workflow transcripts, journals, or metadata files and
introduces no ACP extension. The CLI also emits an untyped
`workflow_progress` snapshot; consuming that richer phase/agent data is
deliberately left out of this change. Existing v1 AIR progress uses the
same field normalization as before.

## Validation

- Regression reproduced before the orphan-preview fix.
- Nine relevant Vitest suites: **1,972 passed, 10 skipped**, including
v1/v2 scenarios, preview ordering, progress-before-launch, idle and
paused updates, duplicate suppression, resume snapshots, and late
progress after completion.
- `npm run check`
- `npm run build`
- `git diff --check`
- Live Claude Code **2.1.319** probes: captured SDK workflow events and
ran a real workflow through the updated ACP v2 adapter. One card
received two progress updates and then reached `completed`. This
verified protocol updates, not the Delta UI.

Rebased onto current `main` and reran the automated checks before
opening the PR.
2026-11-09 14:59:02 +01:00
Mark Tkachenko
dd9eed4e46 test: pin runtime exports of lib.js and acp-agent.js (#1546)
Adds a test that pins the runtime export names of `lib.js` and
`acp-agent.js`.

`acp-agent.ts` is about to be split into smaller modules in a series of
pure-move PRs. Each move keeps the old exports as re-exports, because
`"exports": { "./*": "./*" }` lets consumers deep-import
`dist/acp-agent.js`. tsc only catches a missing export that something in
the repo imports; this test catches the rest. The list should change
only on purpose.
2026-11-09 14:07:08 +01:00
Ben Brandt
4cceb06b31 fix: list a resumed Fable 5.1 session's model as fable (#1548)
## Problem

The Claude Code CLI gives Fable 5.1 a different option value depending
on how the session starts:

- **New session:** listed as `fable` (`resolvedModel:
"claude-fable-5-1"`).
- **Resumed session whose transcript last ran Fable 5.1:** the same row
is listed as `claude-fable-5-1`, and there is no `fable` row.

Reproduced with CLI 2.1.310. Other families don't do this: a session
resumed on `claude-opus-5-5` still lists `opus`.

Clients store the option value as the session's choice. A choice saved
in a new session (`fable`) is not among the options of a resumed one,
and the reverse. A client that checks its saved value against the
options then refuses to apply it, and the model choice keeps flipping
between the two ids.

## Fix

`aliasResumedModelRow` in `session-model.ts` gives the resumed model's
row its family alias back. It changes a row only when:

- the row's value is the model read from the resumed transcript, equals
its own `resolvedModel` and has no context hint,
- no row already has the alias, and
- no newer version of the family is listed, since the alias runs the
family's newest model.

`createSession` applies it before the allowlist and model selection, so
new, resumed and loaded sessions over ACP v1 and v2 all get the same
list.

This is a workaround for the CLI listing the row under a different
value. The CLI also lists rows this way when `ANTHROPIC_MODEL` or
`settings.model` pins `claude-fable-5-1`. Those rows keep the pinned id
on purpose: relabelling them would turn the user's pinned version into
the alias, which moves to a newer Fable when one ships.

## Tests

- `acp-agent-settings.test.ts`: a session resumed on `claude-fable-5-1`
offers `fable` as the current value and option, with no `setModel` call.
Fails without the fix.
- `model-resolution.test.ts`: unit cases for the relabel and for each
case that keeps the list as it is.
- Checked end to end with the real CLI: a Fable turn, then
`session/load`, reports `fable`.
2026-11-09 13:47:11 +01:00
Ben Brandt
ef65ab31b4 fix: update to @agentclientprotocol/sdk 2.5.0 (#1549)
Bumps `@agentclientprotocol/sdk` from 2.4.0 to 2.5.0 (exact pin).

2.5.0 updates the SDK to ACP schema v1.30.0 and v2.0.0-alpha.14
([release
notes](https://github.com/agentclientprotocol/typescript-sdk/releases/tag/v2.5.0)).
No source changes were needed; new schema fields and capabilities are
not adopted here.

## Testing

`npm run build`, `npm run check` and `npm run test:run` pass. `npm run
test:integration` was not run.
2026-11-09 12:43:12 +00:00
21 changed files with 1577 additions and 322 deletions

View File

@@ -24,7 +24,7 @@ jobs:
steps:
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
- uses: amannn/action-semantic-pull-request@2252a14e810aa9b85c2d560bd7070c802206ecf6 # v6.1.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@@ -1,3 +1,3 @@
{
".": "0.101.0"
".": "0.102.0"
}

View File

@@ -1,5 +1,37 @@
# Changelog
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)
### Features
* list workflow phases and agents on the workflow card ([#1552](https://github.com/agentclientprotocol/claude-agent-acp/issues/1552)) ([176ae06](https://github.com/agentclientprotocol/claude-agent-acp/commit/176ae0634c2ef7cb0549339f4b9824884574b2f0))
* send the checklist as a plan operation and remove plans on reset ([#1554](https://github.com/agentclientprotocol/claude-agent-acp/issues/1554)) ([766fd14](https://github.com/agentclientprotocol/claude-agent-acp/commit/766fd142f60bda2f434d55baaa8e72939f215800))
### Bug Fixes
* report v2 background tasks without a tool call as their own tool call ([#1556](https://github.com/agentclientprotocol/claude-agent-acp/issues/1556)) ([d098fb0](https://github.com/agentclientprotocol/claude-agent-acp/commit/d098fb08d54f521d5056d920ebcc5a2656334083))
* update to @anthropic-ai/claude-agent-sdk 0.3.321 ([#1557](https://github.com/agentclientprotocol/claude-agent-acp/issues/1557)) ([855447c](https://github.com/agentclientprotocol/claude-agent-acp/commit/855447c7cd41aa566bdf361d6d59e6a5b038fe6e))
## [0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1) (2026-11-09)
### Bug Fixes
* keep autonomous turn tasks open across owed trailing idles ([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455)) ([1ee2422](https://github.com/agentclientprotocol/claude-agent-acp/commit/1ee242293401372aa71ea5765e8dfd0679146dc9))
* list a resumed Fable 5.1 session's model as `fable` ([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548)) ([4cceb06](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cceb06b31525ccaaecfd999ff9b21d401b2659a))
* list a subagent's background Bash commands ([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540)) ([6597626](https://github.com/agentclientprotocol/claude-agent-acp/commit/6597626d86ddb8c10ea9f1091d921806752d045a))
* list Monitor calls in the session's background work ([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538)) ([ba16234](https://github.com/agentclientprotocol/claude-agent-acp/commit/ba162346c9ccb4be51f13a3271198351d7822e78))
* relay a rate_limit_event that arrives before the turn's first assistant frame ([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534)) ([54392e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/54392e144d307d5dd5b7732ae14634d4b2678bd5))
* render workflow tool cards and progress correctly in ACP v2 ([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550)) ([30600ba](https://github.com/agentclientprotocol/claude-agent-acp/commit/30600ba348e5f5c3755a50f5797fd24645e0ff48))
* replay a compaction summary by its flag, not its framing ([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536)) ([aa0c123](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa0c1234b5c52a63d96c46c9826631bd0f428062))
* report setup-token logins as logged in ([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376)) ([7d1ef76](https://github.com/agentclientprotocol/claude-agent-acp/commit/7d1ef76c1d32e390f8ff8824087df3003825a170))
* start transcript notices on their own line for clients without session.notices ([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362)) ([47b64c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/47b64c04de83145c4d42033c4408c811736c9c40))
* **test:** assert the blockquoted MCP startup transcript line ([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543)) ([3b22586](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b22586cf63cef06f1029a8418a9abb3b2cc3236))
* update to @agentclientprotocol/sdk 2.5.0 ([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549)) ([ef65ab3](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef65ab31b405a5d339133c980eb606a70a2d3624))
* update to @anthropic-ai/claude-agent-sdk 0.3.319 ([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545)) ([a58293f](https://github.com/agentclientprotocol/claude-agent-acp/commit/a58293f4c9aa559a500d61834ef97d2a23b3722b))
## [0.101.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.100.0...v0.101.0) (2026-11-05)

204
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.101.0",
"version": "0.102.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.101.0",
"version": "0.102.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.319",
"@agentclientprotocol/sdk": "2.5.0",
"@anthropic-ai/claude-agent-sdk": "0.3.321",
"diff": "9.0.0",
"zod": "4.6.7"
},
@@ -18,12 +18,12 @@
"claude-agent-acp": "dist/index.js"
},
"devDependencies": {
"@anthropic-ai/sdk": "1.6.0",
"@anthropic-ai/sdk": "1.7.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.6",
"@typescript-eslint/eslint-plugin": "8.72.1",
"@typescript-eslint/parser": "8.72.1",
"@typescript-eslint/eslint-plugin": "8.72.2",
"@typescript-eslint/parser": "8.72.2",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"eslint": "10.14.0",
@@ -38,9 +38,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.4.0.tgz",
"integrity": "sha512-7qJ8nnyj7WesC5ve185ioTpB2/svQoeGV4HA4nLEhgDUrcmBe54/08VcXc/oL4p14tDbxxECZzdYmUYQ+4l+Wg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.5.0.tgz",
"integrity": "sha512-MFlXeIuwN6nZSK45SAsSnVjbLgQ2r4fnthoF1lQoGJP3PdaPRpOZeCF2Dds8S4cN8yZP5QbxB1bkJccGskmcYw==",
"license": "Apache-2.0",
"peerDependencies": {
"@modelcontextprotocol/server": "^2.4.0",
@@ -53,22 +53,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.319.tgz",
"integrity": "sha512-7X+sh4uZv/u4OL4I4cQvMr9N3NM6FDiuyk829hFylgbrnNwVyiGElWEDWGuO26zwMIxmgpKdPoDILoFIHh1uwA==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.321.tgz",
"integrity": "sha512-Smer/h/jQLTtT+l4sxaAUwZq7wif/9RzzuqltS5Gy+kAFo/iP89H7JHa8avhVT0xGkPBvBD/1Bbdg3pcArhQcw==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.14.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.319",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.319",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.319"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.321",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.321",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.321",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.321",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.321",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.321",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.321",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.321"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -77,9 +77,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.319.tgz",
"integrity": "sha512-rNiAbMbRhgOM2CELsDTIq7q++sFVIZukTkgeswcncGbcX8STIgtf0XaZzKdRXtKK+v9ez+mQn8P4pb2Ka17wGA==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.321.tgz",
"integrity": "sha512-r718mW98hvRbmCa0TtSgImHkgyPUjPXSHOsORrehn2a8Z0FU1A2RkX9gCjY7EzZgL0RNbZXsBbkuab//kUjzJw==",
"cpu": [
"arm64"
],
@@ -90,9 +90,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.319.tgz",
"integrity": "sha512-jM730Fl+cFotDnjF9cStIf+Xe57YdfDB3TXaSVVv8uVAXW4xoWFwIj2eDvmsciEEe5UMkKzjVqXu9kR21OrXng==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.321.tgz",
"integrity": "sha512-C08vEnnDylWDaFprx/B1B13RZK3la1HOqR986igkZSwhY//Yxsk5eRI+gz7sxMfhcLZX2NS8PftCOiXr3S74/Q==",
"cpu": [
"x64"
],
@@ -103,9 +103,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.319.tgz",
"integrity": "sha512-g2tpMsHLMQahI4bk5CIcMHAB/RwzxoPGmL5Q5G5MDir5WTrl71vulWbtLQISUPm/yFPRG97tdzZnCa5Uq1v/dw==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.321.tgz",
"integrity": "sha512-vdk9tON09SnsSh/6ULzYTBgsKRat6FyIaqnh4snYl3uy3qX0jpv5UuFUWgS5tvQ1ewI0qYF2k+D78El4gi7/iQ==",
"cpu": [
"arm64"
],
@@ -119,9 +119,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.319.tgz",
"integrity": "sha512-8KDZQJAS2CEb5aGAvuXkpyHMc9TOM/zYTw2iKmbbVi4im0atw93EtJPapYyp0g/lQI/Xk8jMYdWU6Zlv9EWjLw==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.321.tgz",
"integrity": "sha512-+8zFXMpJT2r+0j4E1v9BgRf+Qjtmmp4IG/3mDLT6jB6iA+9UvG9RLex+cqct09d+7xP2mDP8kL4cD1WtKUFX1w==",
"cpu": [
"arm64"
],
@@ -135,9 +135,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.319.tgz",
"integrity": "sha512-uBAZA8x0hd5gVifyL5Lo73dVmi9xZeJ6jro2NO0al6yyWaw/6UL9xbsVh9lOhLFOmYIdSbQ2j4zAfnvG/t5F3Q==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.321.tgz",
"integrity": "sha512-i/Y8Ps4q68SRwWB54yFXyItBwn5YHIOYY4R1gla6p4UwF/pM+xxrHycv+yecbC7oU30ShuBYKWN9mRIZ/BCMqw==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.319.tgz",
"integrity": "sha512-XkKYj3c1jVFjLATrZr4YTsmS5AIb5s2iphF9X670EcVQqDCCGIKKTVNLLg9mg1e7D+2xX8FU0XNlOJatWfpBeA==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.321.tgz",
"integrity": "sha512-ils3IGsNKIDiURuOgSWorLsjMOlKcMdXQCQyaygEycdtPFItPoIBIYDVjoC1I0XV4tlfXXCHfUvNrs23g4HKNw==",
"cpu": [
"x64"
],
@@ -167,9 +167,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.319.tgz",
"integrity": "sha512-YoP0lYHJCziENFCotHQq9qd/gaJrwUNkAcnZyVjXxg1OPmtKuyw31aG60s4cWXRDXXGm/m1siNSQaGGfAYBvrw==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.321.tgz",
"integrity": "sha512-v6/8ikUcYRSYhH5PSGcHq8L6+DKRq/M4I3BzFckis6kMh3degvSuUD1u3+M/oSJ/bcSOA8tXbUgTTwKrlOgvwQ==",
"cpu": [
"arm64"
],
@@ -180,9 +180,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.319",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.319.tgz",
"integrity": "sha512-UC27x+8gfCS2OjNGJWrueP0gLOOKsLEsJ/kg0p6aa8GToZcJYjJ8KeAapHHRYMUAIB6Sarqe49Itm5P1wswCcQ==",
"version": "0.3.321",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.321.tgz",
"integrity": "sha512-9i7ziCR+Wg47cRhYONHnUpTGxUgxfy/+J3N3/4F8YGqz7WLSNH3hpGRj6uJzNK4EMyEhFsca532uD2we0ZMckQ==",
"cpu": [
"x64"
],
@@ -193,9 +193,9 @@
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.6.0.tgz",
"integrity": "sha512-XqiO/46n8S6So8jQ/6/uNCkHntyW6QHHs1gZ+hlwTx1fWDr929g4VmwIXfdMVk7SuBWNdQvAlwyi1G3t6SEIuQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.7.0.tgz",
"integrity": "sha512-Z+0c1iHQozlt4ymMHapPXEXfFnSbPEWGnSKbkavt+qeyaMQ3Sd6QNxfQj7vyhHYqiqYadC9shrXub8ISKf7xjQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.1",
@@ -913,17 +913,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
"integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.2.tgz",
"integrity": "sha512-222PKFXKihmFpi/pS5njoBgdAqsPhj2/GBmFbrNw8Tq8gEG9Sv+kSlKFy4u2q+mJv9ZzJgM2Gkv/bUzRZwZw5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/type-utils": "8.72.1",
"@typescript-eslint/utils": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/scope-manager": "8.72.2",
"@typescript-eslint/type-utils": "8.72.2",
"@typescript-eslint/utils": "8.72.2",
"@typescript-eslint/visitor-keys": "8.72.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -936,22 +936,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.72.1",
"@typescript-eslint/parser": "^8.72.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
"integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.2.tgz",
"integrity": "sha512-llP+T1YuJOLlxEciP6OyQGtz5SMxyn+9LA2BvZw5RmToGVa1y4beEDVQL6Gje7T5WFYh19WREjDCN9Ev4gm5AA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/scope-manager": "8.72.2",
"@typescript-eslint/types": "8.72.2",
"@typescript-eslint/typescript-estree": "8.72.2",
"@typescript-eslint/visitor-keys": "8.72.2",
"debug": "^4.4.3"
},
"engines": {
@@ -967,14 +967,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
"integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.2.tgz",
"integrity": "sha512-6y5dLTZdHhI8Eh3yIQ/bfQhRKwn5e+TpJj64o18Cngc0iW/BCrVvrr6zGYqXPnYH31u6YXAYiFh0+qxX5mybJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.72.1",
"@typescript-eslint/types": "^8.72.1",
"@typescript-eslint/tsconfig-utils": "^8.72.2",
"@typescript-eslint/types": "^8.72.2",
"debug": "^4.4.3"
},
"engines": {
@@ -989,14 +989,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
"integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.2.tgz",
"integrity": "sha512-iIoMJ6/8c1/QpFNyFjU0d1YzPco4MsZjQw0k3V+vfzNWAFmUmvhlyMoYEoYYDQZXc+J6Gi1X5XcxlzW2taIIGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1"
"@typescript-eslint/types": "8.72.2",
"@typescript-eslint/visitor-keys": "8.72.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1007,9 +1007,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
"integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.2.tgz",
"integrity": "sha512-+h3iCgZXuTNtoLXcnnjP12ZUHLMqoUcIHPfQrFEXTUqvb9dF8QK9b5nv5VtSo5fLkdHuks8ehbTzZj3rImAm9w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1024,15 +1024,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
"integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.2.tgz",
"integrity": "sha512-STfkR38qIdhfkLpgtXnUjAzsNy4xouqiBO0lrVc/Wpk9Kem5/aHXUx1J8dGC16zpdYN1+yc2ILQ28er1kXX7ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1",
"@typescript-eslint/utils": "8.72.1",
"@typescript-eslint/types": "8.72.2",
"@typescript-eslint/typescript-estree": "8.72.2",
"@typescript-eslint/utils": "8.72.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -1049,9 +1049,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
"integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.2.tgz",
"integrity": "sha512-Xfn37OLZx03+PCU7qWx9DTv2FCRh1E6fk2+wONeNY15HXIwtcwuveaSwVpGAUptkNmPQegRxW5JyRel9T7wrog==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1063,16 +1063,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
"integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.2.tgz",
"integrity": "sha512-pQEGqIK/cqEvKivVWaFLYpe7XYejFxfuAOi4mu1oAWFvIo36aRHI9esRFTUxcIy/z8N482RJyH+LpZB0ZIU7Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.72.1",
"@typescript-eslint/tsconfig-utils": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/visitor-keys": "8.72.1",
"@typescript-eslint/project-service": "8.72.2",
"@typescript-eslint/tsconfig-utils": "8.72.2",
"@typescript-eslint/types": "8.72.2",
"@typescript-eslint/visitor-keys": "8.72.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -1091,16 +1091,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
"integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.2.tgz",
"integrity": "sha512-hv3eRYbpPbbpezuax1EfGrzpa3C4ySgNZkPUkLOWOAjU8HsOKowEDAr8C7nsNl1C+SBSKDpvno6VKHnlhvp9iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.72.1",
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/typescript-estree": "8.72.1"
"@typescript-eslint/scope-manager": "8.72.2",
"@typescript-eslint/types": "8.72.2",
"@typescript-eslint/typescript-estree": "8.72.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1115,13 +1115,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
"integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
"version": "8.72.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.2.tgz",
"integrity": "sha512-NAGrjn0Pitjqzq/GkgMC4kBYm233PQ8GffyQ9+kTA3AlNQYEzOf25EIM02BAHZv5FRJSpHpJk+OGUxVkOB93GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.72.1",
"@typescript-eslint/types": "8.72.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.101.0",
"version": "0.102.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -68,18 +68,18 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "2.4.0",
"@anthropic-ai/claude-agent-sdk": "0.3.319",
"@agentclientprotocol/sdk": "2.5.0",
"@anthropic-ai/claude-agent-sdk": "0.3.321",
"diff": "9.0.0",
"zod": "4.6.7"
},
"devDependencies": {
"@anthropic-ai/sdk": "1.6.0",
"@anthropic-ai/sdk": "1.7.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.6",
"@types/node": "26.6.6",
"@typescript-eslint/eslint-plugin": "8.72.1",
"@typescript-eslint/parser": "8.72.1",
"@typescript-eslint/eslint-plugin": "8.72.2",
"@typescript-eslint/parser": "8.72.2",
"ajv": "8.20.0",
"ajv-formats": "3.0.1",
"eslint": "10.14.0",

View File

@@ -106,6 +106,7 @@ import {
toGoalSnapshot,
} from "./goal-extension.js";
import { blobResourceToClaude } from "./blob-resources.js";
import { checklistPlanUpdate, resetPlanUpdates } from "./plans.js";
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
import {
@@ -163,7 +164,9 @@ import {
backgroundBashTaskFromToolResult,
backgroundedBashToolCallIds,
clientSupportsAsyncTasks,
taskProgressFields,
} from "./async-tasks.js";
import { workflowSnapshot } from "./workflow-progress.js";
import {
type BackgroundSink,
type BackgroundTaskEnd,
@@ -362,6 +365,7 @@ import {
SessionModeManager,
} from "./session-mode.js";
import {
aliasResumedModelRow,
applyAvailableModelsAllowlist,
buildModelConfigOption,
getAvailableModels,
@@ -4458,7 +4462,7 @@ export class ClaudeAcpAgent {
if (!entries) return;
await this.client.sessionUpdate({
sessionId,
update: { sessionUpdate: "plan", entries },
update: checklistPlanUpdate(entries, this.clientCapabilities?.plan != null),
});
}
@@ -5913,7 +5917,9 @@ export class ClaudeAcpAgent {
const pause = messageLoopYielder.maybeYield();
if (pause) await pause;
// The last message may have changed the background work, and its
// updates are sent, so the list follows them.
// updates are sent, so the list follows them. A task that needs a
// tool call of its own gets it first, so the list can name it.
for (const call of session.background.adoptTasks(session)) await sendUpdate(call);
this.backgroundWork.sync(params.sessionId, session, this.foregroundState(params.sessionId));
pendingNext ??= session.query
.next()
@@ -6754,13 +6760,22 @@ export class ClaudeAcpAgent {
case "hook_response":
case "files_persisted":
break;
case "task_progress":
case "task_progress": {
if (isLateTaskRun(session, message.task_id, message.run_id)) {
this.logger.log(
`Ignoring task_progress for task ${message.task_id} from earlier run ${message.run_id}`,
);
break;
}
// The SDK does not type `workflow_progress`; see workflow-progress.ts.
const workflow = workflowSnapshot(
(message as { workflow_progress?: unknown }).workflow_progress,
);
const progress = session.background.taskProgress(message.task_id, {
...taskProgressFields(message),
...(workflow ? { workflow } : {}),
});
if (progress) await sendUpdate(progress);
await asyncTasks.taskProgress({
task_id: message.task_id,
description: message.description,
@@ -6770,6 +6785,7 @@ export class ClaudeAcpAgent {
tool_use_id: message.tool_use_id,
});
break;
}
case "task_started":
if (isLateTaskRun(session, message.task_id, message.run_id)) {
this.logger.log(
@@ -8633,6 +8649,9 @@ export class ClaudeAcpAgent {
),
);
}
for (const progress of session.background.toolCallProgress(runningToolCalls)) {
await sendUpdate(progress);
}
// After the result's own updates: the command's output replaces
// the placeholder that they gave its terminal.
if (runningTerminalTask) {
@@ -8731,7 +8750,13 @@ export class ClaudeAcpAgent {
session.toolCallFields?.clear();
clearHookCallbacks(params.sessionId);
session.taskState.clear();
await this.publishTaskPlan(params.sessionId, session.taskState);
forgetPublishedTaskPlan(session.taskState);
for (const update of resetPlanUpdates(
this.clientCapabilities?.plan != null,
this.toolCallCapabilities.air.client,
)) {
await this.client.sessionUpdate({ sessionId: params.sessionId, update });
}
// A reset mounts a fresh transcript (`new_conversation_id`), so our
// cached title no longer describes the session: drop it and
// re-evaluate at the next turn-end.
@@ -10779,15 +10804,13 @@ export class ClaudeAcpAgent {
}
/**
* The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
* shows the exact patch of the change before the tool runs.
* Captures pre-execution patches for v2 Edit and Write tool calls.
*
* A v2 diff cannot hold the input's snippet, so without it an auto-approved
* Edit or Write would show its change only once it is done. Claude Code runs
* the tool only after the hook returns, so the file is as it was before the
* change, as for the approval patch of a permission request (which runs
* after this hook, and shows the same patch). The hook makes no permission
* decision, and a failure only leaves the preview out.
* A v2 diff cannot hold the input's snippet, so the patch needs the file as
* it was before the tool runs. Claude Code waits for the hook before running
* the tool. If its streamed tool use has not arrived, the patch waits for
* the tool call's first publication. The hook makes no permission decision,
* and a failure only leaves the preview out.
*/
private fileChangePreviewHook(sessionId: string): HookCallback {
return async (input, toolUseID, { signal }) => {
@@ -10814,34 +10837,25 @@ export class ClaudeAcpAgent {
const toolInput = (input.tool_input ?? {}) as Record<string, unknown>;
const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2");
if (!content) return;
const fields = toolCallFieldsOf(session);
// Workflow tools can run hooks without streaming their use or result.
// Keep the patch until the stream or a permission request publishes the
// tool call, instead of creating a pending call with no completion path.
if (!session.emittedToolCalls.has(toolUseID)) {
fields.stagePreview(toolUseID, content);
return;
}
const { parentToolUseId, toolCallSessionId } = this.toolUseOwner(
session,
sessionId,
input.agent_id,
);
// Hook callbacks bypass the stream, so the hook can come before the
// streamed tool_use, as `canUseTool` can: then the tool call goes out
// with the patch.
if (!session.emittedToolCalls.has(toolUseID)) {
await this.ensureToolCallEmitted(
sessionId,
input.tool_name,
toolUseID,
toolInput,
parentToolUseId,
undefined,
toolCallSessionId,
content,
);
return;
}
const update: SessionNotification["update"] = {
sessionUpdate: "tool_call_update",
toolCallId: toolUseID,
content,
};
if (parentToolUseId) stampParentToolUseId(update, parentToolUseId);
const fields = toolCallFieldsOf(session);
if (!fields.apply(update)) return;
// The streamed input must not replace the patch, as for an approval patch.
fields.pinContent(toolUseID, content);
@@ -12318,9 +12332,9 @@ export class ClaudeAcpAgent {
],
}
: {}),
// On v2, an Edit or Write shows its exact patch before it runs, also
// when no permission request shows it (`fileChangePreviewHook`): a v2
// diff cannot hold the input's snippet.
// v2 needs the patch captured before an Edit or Write runs, also
// without a permission request (`fileChangePreviewHook`): a v2 diff
// cannot hold the input's snippet.
...(this.v2 || subagentConfigHook
? {
PreToolUse: [
@@ -12519,14 +12533,21 @@ export class ClaudeAcpAgent {
// configured. Managed `deniedModels` drops entries the CLI would refuse.
const settingsAvailableModels = settingsManager.getSettings().availableModels;
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
const resumedModel = await resumedModelHint;
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new
// session does (see `aliasResumedModelRow`).
const sdkModels = aliasResumedModelRow(
initializationResult.models,
creationOpts.resume !== undefined ? resumedModel : undefined,
);
const settingsAllowedModels = Array.isArray(settingsAvailableModels)
? applyAvailableModelsAllowlist(
initializationResult.models,
sdkModels,
settingsAvailableModels,
settingsModelOverrides,
settingsManager.getManagedDeniedModels(),
)
: initializationResult.models;
: sdkModels;
// Then keep only the models the `providers/set` gateway listed.
const allowedModels = resolvedProvider?.listedModels
? narrowToGatewayModels(
@@ -12544,7 +12565,7 @@ export class ClaudeAcpAgent {
this.logger,
creationOpts.resume !== undefined,
sessionId,
await resumedModelHint,
resumedModel,
resolvedProvider?.listedModels !== undefined,
);
timing.phase("models");
@@ -12560,7 +12581,7 @@ export class ClaudeAcpAgent {
const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
const fallbackModelInfo = allowlistedModelInfo
? undefined
: (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
: (resolveModelPreference(sdkModels, models.currentModelId) ?? undefined);
const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
// Register the fallback-resolved capabilities under the verbatim live id
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
@@ -14353,10 +14374,10 @@ export function toAcpNotifications(
if (chunk.name === "TodoWrite") {
// @ts-expect-error - sometimes input is empty object or undefined
if (Array.isArray(chunk.input?.todos)) {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
update = checklistPlanUpdate(
planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
options?.clientCapabilities?.plan != null,
);
}
} else if (isTaskTool(chunk.name)) {
// Task* tool_use is suppressed; the plan update is emitted at
@@ -14591,7 +14612,9 @@ export function toAcpNotifications(
const entries = shouldEmitTaskPlan
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
: undefined;
if (entries) update = { sessionUpdate: "plan", entries };
if (entries) {
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
}
} else if (toolUse.name !== "TodoWrite") {
// A command sends its output first, then the exit and the status.
const [finalUpdate, ...rest] = renderer

View File

@@ -105,6 +105,28 @@ type TaskProgress = TaskIdentity & {
output_file?: unknown;
};
/** The display fields of a task progress report, shared by AIR and v2 cards. */
export type TaskProgressFields = {
description?: string;
summary?: string;
lastToolName?: string;
usage?: { totalTokens: number; toolUses: number; durationMs: number };
};
/** Reads the display fields supplied by a task progress report. */
export function taskProgressFields(message: TaskProgress): TaskProgressFields {
const usage = taskUsage(message.usage);
const description = nonBlankString(message.description);
const summary = nonBlankString(message.summary);
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
return {
...(description ? { description } : {}),
...(summary ? { summary } : {}),
...(lastToolName ? { lastToolName } : {}),
...(usage ? { usage } : {}),
};
}
type BackgroundTaskLevel = TaskIdentity & {
taskType?: unknown;
task_type?: unknown;
@@ -267,15 +289,8 @@ export class AsyncTaskRuntime {
return;
}
const usage = taskUsage(message.usage);
const description = nonBlankString(message.description);
const summary = nonBlankString(message.summary);
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
const progress = {
...(description ? { description } : {}),
...(summary ? { summary } : {}),
...(lastToolName ? { lastToolName } : {}),
...(usage ? { usage } : {}),
...taskProgressFields(message),
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
...(task.toolCallId !== previousToolCallId && task.toolCallId
? { toolCallId: task.toolCallId }

View File

@@ -19,26 +19,29 @@
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
* from its tool result until its task ends (`Session.backgroundToolCalls`).
* - Any other task, once its `task_started` came and the tool call that
* started it, if any, returned: a `_claude/task` item with the task's id
* and description. For example, a task whose tool call ended before the
* task joined the level.
* started it, if any, returned: a tool call that the adapter starts for
* it (see `SessionBackgroundWork.adoptTasks`). For example, a task that a
* hook started, or one whose tool call ended before the task joined the
* level.
*
* Only a session whose interrupt spares background tasks reports its lists
* (`Session.reportsBackground`): `session/cancel` must leave listed work
* running.
*/
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import { RequestError, type ToolCallUpdate } from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification } from "./acp-subagents.js";
import type { NativeSubagent } from "./native-subagents.js";
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
import type { TaskProgressFields } from "./async-tasks.js";
import { type WorkflowSnapshot, workflowSnapshotText } from "./workflow-progress.js";
import { textContent } from "./tool-calls/content.js";
/** An item of a `background` list. */
export type BackgroundItem =
| { type: "terminal"; terminalId: string }
| { type: "tool_call"; toolCallId: string }
| { type: "subagent"; sessionId: string }
| { type: "_claude/task"; taskId: string; description: string };
| { type: "subagent"; sessionId: string };
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
export interface BackgroundTerminal {
@@ -55,8 +58,14 @@ export interface BackgroundTerminal {
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
export interface BackgroundToolCall {
toolCallId: string;
/** The tool use of the call, which Claude Code no longer caches after its result. */
toolUse: { name: string; input: unknown };
/**
* The tool use of the call, which Claude Code no longer caches after its
* result. Undefined for a call that the adapter started for a task (see
* `SessionBackgroundWork.adoptTasks`), which has `title` instead.
*/
toolUse?: { name: string; input: unknown };
/** The title of a call that the adapter started for a task: the task's description. */
title?: string;
/** The subagent tool call that made the call, if any. */
parentToolUseId?: string;
paused?: boolean;
@@ -68,9 +77,18 @@ export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
/** The live background task set of `background_tasks_changed`, by task id. */
export type BackgroundLevel = Map<
string,
{ ambient: boolean; taskType: string; description: string; parentTaskId?: string }
{
ambient: boolean;
taskType: string;
description: string;
parentTaskId?: string;
progress?: WorkflowCardProgress;
}
>;
/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */
export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot };
/** The work state of a session, as its `state_update` names it. */
export type WorkStateName = "idle" | "running" | "requires_action" | "unknown";
@@ -264,11 +282,9 @@ export class SessionBackgroundWork {
if (toolCall) {
this.toolCalls.delete(taskId);
await send(
toolCallStatusUpdate(
this.sessionId,
toolCall,
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
),
toolCallUpdate(this.sessionId, toolCall, {
status: end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
}),
);
}
}
@@ -282,7 +298,36 @@ export class SessionBackgroundWork {
const toolCall = this.toolCalls.get(taskId);
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
toolCall.paused = paused;
return toolCallStatusUpdate(this.sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
return toolCallUpdate(this.sessionId, toolCall, { status: paused ? V2_PAUSED : "in_progress" });
}
/**
* Updates an existing workflow card without changing its work state. A
* report without `workflow` keeps the last phase and agent list.
*/
taskProgress(taskId: string, progress: WorkflowCardProgress): AcpSessionNotification | undefined {
const task = this.level.get(taskId);
if (!task || task.taskType !== "local_workflow") return undefined;
const previous = workflowProgress(task.progress);
task.progress = { ...task.progress, ...progress };
const current = workflowProgress(task.progress);
if (!current || JSON.stringify(previous) === JSON.stringify(current)) return undefined;
const toolCall = this.toolCalls.get(taskId);
return toolCall ? toolCallUpdate(this.sessionId, toolCall, current) : undefined;
}
/**
* The latest progress of the workflow calls in `toolCallIds`.
*
* A task can report progress before its launch tool returns. Those fields
* wait in the live task level and are sent after the launch result.
*/
toolCallProgress(toolCallIds: ReadonlySet<string>): AcpSessionNotification[] {
return [...this.toolCalls].flatMap(([taskId, toolCall]) => {
if (!toolCallIds.has(toolCall.toolCallId)) return [];
const progress = workflowProgress(this.level.get(taskId)?.progress);
return progress ? [toolCallUpdate(this.sessionId, toolCall, progress)] : [];
});
}
/**
@@ -303,6 +348,7 @@ export class SessionBackgroundWork {
taskType: task.task_type,
description: task.description,
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
progress: this.level.get(task.task_id)?.progress,
},
]),
);
@@ -331,13 +377,54 @@ export class SessionBackgroundWork {
for (const [taskId, toolCall] of this.toolCalls) {
if (toolCall.parentToolUseId) continue;
this.toolCalls.delete(taskId);
updates.push(toolCallStatusUpdate(this.sessionId, toolCall, V2_CANCELLED));
updates.push(toolCallUpdate(this.sessionId, toolCall, { status: V2_CANCELLED }));
}
this.level = new Map();
this.returned.clear();
return updates;
}
/**
* Starts a tool call for each live task that no terminal or tool call
* stands for yet, and returns the `tool_call`s to send: v2 lists background work only by an entity
* the client knows. Such a task is one that no tool call started, such as
* one a hook started, or one whose tool call returned, with its final
* status, before the task started. The call has the task's description as
* its title, is `in_progress`, and ends with the task (see `taskEnded`). A
* task that an announced subagent session runs gets its call on that
* session's stream. Ambient tasks are housekeeping and get no call.
*
* The agent sends them before each list it publishes, so the call comes
* before the first list that names it, as v2 requires.
*/
adoptTasks(owner: BackgroundOwner): AcpSessionNotification[] {
if (!this.reported) return [];
const started: AcpSessionNotification[] = [];
const children = new Map(
[...(owner.nativeSubagentsByTaskId ?? [])].filter(([, child]) => child.announced),
);
for (const [taskId, task] of this.level) {
if (task.ambient || this.toolCalls.has(taskId) || this.terminals.has(taskId)) continue;
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
// A task joins the level before its `task_started`, and before the tool
// call that starts it returns. Until both, the task is unknown or part
// of that tool call.
const live = owner.liveBackgroundTasks.get(taskId);
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
const parent = task.parentTaskId ? children.get(task.parentTaskId) : undefined;
const toolCall: BackgroundToolCall = {
toolCallId: adoptedToolCallId(taskId),
title: task.description,
...(parent?.parentToolUseId ? { parentToolUseId: parent.parentToolUseId } : {}),
};
this.toolCalls.set(taskId, toolCall);
started.push(adoptedToolCall(this.sessionId, toolCall));
}
return started;
}
/**
* The `background` list of the root session and of each of its announced
* subagent sessions, with what their repeated states need, deeper sessions
@@ -349,7 +436,6 @@ export class SessionBackgroundWork {
const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter(
([, child]) => child.announced,
);
const childByTask = new Map(children);
const childByToolUse = new Map(
children.flatMap(([, child]) =>
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
@@ -378,20 +464,10 @@ export class SessionBackgroundWork {
const session = ownerOf(terminal.parentToolUseId);
if (session)
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
continue;
}
// A command without a terminal yet is one that foreground work waits on.
// A subagent is listed below, as its session.
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
// A task joins the level before its `task_started`, and before the tool
// call that starts it returns. Until both, the task is unknown or part
// of that tool call.
const live = owner.liveBackgroundTasks.get(taskId);
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
lists
.get(parent?.sessionId ?? this.sessionId)!
.push({ type: "_claude/task", taskId, description: task.description });
// Any other task is listed by the tool call that `adoptTasks` starts
// for it, or is not background work. A subagent is listed below, as
// its session.
}
// A child is listed in its parent's list while it works in the background,
// and while it is idle with background work of its own. Deeper children
@@ -434,14 +510,20 @@ export class SessionBackgroundWork {
renderer: AcpToolCallRenderer,
cwd: string,
): AcpSessionNotification[] {
return [...this.toolCalls.values()]
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
.map((toolCall) => {
const update = renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd });
return [...this.toolCalls]
.filter(([, toolCall]) => toolCallIds.includes(toolCall.toolCallId))
.map(([taskId, toolCall]) => {
const update = toolCall.toolUse
? renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd })
: adoptedToolCall(this.sessionId, toolCall).update;
const status = toolCall.paused ? V2_PAUSED : "in_progress";
return {
sessionId: this.sessionId,
update: { ...update, status } as AcpSessionNotification["update"],
update: {
...update,
...workflowProgress(this.level.get(taskId)?.progress),
status,
} as AcpSessionNotification["update"],
};
});
}
@@ -553,19 +635,17 @@ function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessi
};
}
function toolCallStatusUpdate(
function toolCallUpdate(
sessionId: string,
toolCall: BackgroundToolCall,
status: NonNullable<
Extract<AcpSessionNotification["update"], { sessionUpdate: "tool_call_update" }>["status"]
>,
fields: Omit<ToolCallUpdate, "toolCallId">,
): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: toolCall.toolCallId,
status,
...fields,
...(toolCall.parentToolUseId
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
: {}),
@@ -573,6 +653,52 @@ function toolCallStatusUpdate(
};
}
/**
* The id of the tool call that the adapter starts for the task `taskId`.
* Claude Code's tool use ids start with `toolu_`, so it names no other call.
*/
function adoptedToolCallId(taskId: string): string {
return `task_${taskId}`;
}
/** The `tool_call` that the adapter starts for a task (see `SessionBackgroundWork.adoptTasks`). */
function adoptedToolCall(sessionId: string, toolCall: BackgroundToolCall): AcpSessionNotification {
return {
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId: toolCall.toolCallId,
title: toolCall.title ?? "Background task",
kind: "other",
status: toolCall.paused ? V2_PAUSED : "in_progress",
...(toolCall.parentToolUseId
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
: {}),
} as AcpSessionNotification["update"],
};
}
/** The latest workflow activity, rendered as one replaceable tool-card body. */
function workflowProgress(progress: WorkflowCardProgress | undefined) {
if (!progress || Object.keys(progress).length === 0) return undefined;
const activity = progress.description ?? progress.summary;
const usage = progress.usage;
const text = [
progress.summary ?? progress.description,
progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined,
usage
? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s`
: undefined,
progress.workflow ? workflowSnapshotText(progress.workflow) : undefined,
]
.filter((part) => part !== undefined)
.join("\n\n");
return {
title: activity ? `Workflow: ${activity}` : "Workflow",
content: [textContent(text)],
};
}
/**
* Publishes the lists of each session that reports them, each time one
* changes. The sink sends them to the client synchronously, so a list follows

58
src/plans.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* The session updates that show the session's plans. A client with the `plan`
* capability (every v2 client, and a v1 client that asks for it) tracks plans
* by ID and gets `plan_update` and `plan_removed`; any other client gets the
* legacy `plan` update, which holds one ID-less plan per session.
*/
import type {
PlanEntry,
PlanItems,
PlanRemoved,
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { PLAN_MODE_PLAN_ID } from "./exit-plan.js";
type PlanRemovalUpdate = PlanRemoved & { sessionUpdate: "plan_removed" };
/** The ID of the session's checklist, from TodoWrite and the Task tools. Every
* update replaces it whole. An approved ExitPlanMode plan is a separate plan
* ({@link PLAN_MODE_PLAN_ID}). */
export const CHECKLIST_PLAN_ID = "plan";
/** The plan operation that shows the checklist `entries`: `plan_update`, or
* `plan_removed` for an empty checklist. */
export function checklistPlanOperation(
entries: PlanEntry[],
): { sessionUpdate: "plan_update"; plan: PlanItems & { type: "items" } } | PlanRemovalUpdate {
if (entries.length === 0) return { sessionUpdate: "plan_removed", planId: CHECKLIST_PLAN_ID };
return {
sessionUpdate: "plan_update",
plan: { type: "items", planId: CHECKLIST_PLAN_ID, entries },
};
}
/** The update that shows the checklist `entries`: a plan operation (see
* {@link checklistPlanOperation}), else a legacy `plan`, which with no entries
* clears the client's plan. */
export function checklistPlanUpdate(
entries: PlanEntry[],
planOperations: boolean,
): SessionNotification["update"] {
return planOperations ? checklistPlanOperation(entries) : { sessionUpdate: "plan", entries };
}
/** The updates that dismiss the plans of a conversation that a reset (`/clear`)
* replaced: the checklist, and the approved ExitPlanMode plan. AIR keeps its
* approved plans, each joined to its approval card in the transcript (see
* planModeUpdate). Without plan operations, a legacy empty `plan` clears the
* one plan the client holds. */
export function resetPlanUpdates(
planOperations: boolean,
airClient: boolean,
): SessionNotification["update"][] {
const updates = [checklistPlanUpdate([], planOperations)];
if (planOperations && !airClient) {
updates.push({ sessionUpdate: "plan_removed", planId: PLAN_MODE_PLAN_ID });
}
return updates;
}

View File

@@ -281,6 +281,67 @@ function resolveSettingsModel(
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
/** The family and version of a `claude-<family>-<major>[-<minor>]` id:
* `claude-fable-5-1` is fable 5.1, `claude-haiku-4-5-20251001` haiku 4.5. */
function familyVersionOf(id: string): { family: string; major: number; minor: number } | null {
const match = id.toLowerCase().match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d.])/);
if (!match) return null;
return { family: match[1], major: Number(match[2]), minor: Number(match[3] ?? 0) };
}
/**
* Give back the family alias value to the row the CLI lists under a resumed
* session's model id.
*
* The CLI lists Fable 5.2 as `fable`, but when it resumes a session that ran
* Fable 5.2 it lists the same row as `claude-fable-5-2` and has no `fable`
* row. Clients store the option value as the session's choice, so a value
* from a new session would not be offered by a resumed one, and the reverse.
*
* A row gets its family's alias as its value when its `value` is
* `resumedModel`, equals its own `resolvedModel` and has no context hint, no
* row has that alias, and no row of the family has a newer version (an alias
* runs its family's newest model). Every other row is returned as it is.
*
* The CLI lists a row the same way when `ANTHROPIC_MODEL` or `settings.model`
* pins `claude-fable-5-2`. Those rows keep the pinned id, so a client's choice
* stays on that version after a newer Fable becomes `fable`. A resumed Fable
* 5.1 session keeps `claude-fable-5-1` too, because Fable 5.2 is newer.
*/
export function aliasResumedModelRow(
models: ModelInfo[],
resumedModel: string | undefined,
): ModelInfo[] {
if (resumedModel === undefined) return models;
const resumed = canonicalizeModelId(resumedModel);
const values = new Set(models.map((model) => model.value));
const versions = models.flatMap((model) => {
const version = familyVersionOf(model.resolvedModel ?? model.value);
return version ? [version] : [];
});
return models.map((model) => {
const version = familyVersionOf(model.value);
if (
version === null ||
!MODEL_FAMILY_ALIASES.has(version.family) ||
values.has(version.family) ||
model.resolvedModel !== model.value ||
contextHintOf(model.value) !== null ||
canonicalizeModelId(model.value) !== resumed ||
versions.some(
(other) =>
other.family === version.family &&
(other.major > version.major ||
(other.major === version.major && other.minor > version.minor)),
)
) {
return model;
}
values.add(version.family);
return { ...model, value: version.family };
});
}
/** A model id without the spellings that name the same model: provider
* prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
* `-fast` and context hints. `deniedModels` and gateway listings both match on

View File

@@ -30,22 +30,22 @@ describe("ClaudeAcpAgent settings", () => {
} as unknown as AcpClient;
}
function mockQuery() {
function mockQuery(
models: unknown[] = [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.5",
description: "Default",
supportsAutoMode: true,
},
],
) {
let capturedOptions: any;
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.5",
description: "Default",
supportsAutoMode: true,
},
],
}),
initializationResult: async () => ({ models }),
setModel: setModelSpy,
}) as any;
});
@@ -432,7 +432,7 @@ describe("ClaudeAcpAgent settings", () => {
describe("on resume", () => {
const sessionId = "resumed-mode-session";
async function setup(records: unknown[]) {
async function setup(records: unknown[], models?: unknown[]) {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({ permissions: { defaultMode: "plan" } }),
@@ -446,11 +446,11 @@ describe("ClaudeAcpAgent settings", () => {
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
);
const { getCapturedOptions } = mockQuery();
const { getCapturedOptions, setModelSpy } = mockQuery(models);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
return { agent, projectDir, getCapturedOptions };
return { agent, projectDir, getCapturedOptions, setModelSpy };
}
async function resume(records: unknown[], meta: object = {}) {
@@ -479,6 +479,26 @@ describe("ClaudeAcpAgent settings", () => {
previousUuid = uuid;
return record;
};
const assistant = (model: string) => {
const uuid = crypto.randomUUID();
const record = {
type: "assistant",
uuid,
parentUuid: previousUuid,
sessionId,
timestamp: new Date().toISOString(),
isSidechain: false,
message: {
id: `msg_${uuid}`,
type: "message",
role: "assistant",
model,
content: [{ type: "text", text: "ok" }],
},
};
previousUuid = uuid;
return record;
};
it("continues in the last permission mode of the transcript", async () => {
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
@@ -513,6 +533,41 @@ describe("ClaudeAcpAgent settings", () => {
expect(response.modes.currentModeId).toBe("plan");
});
it("offers Fable 5.1 as `fable` when the CLI resumed on it", async () => {
// The CLI names the Fable 5.1 row after its startup model, which on
// resume is the transcript's model; a fresh session lists it as `fable`.
const fable = (value: string, resolvedModel: string, displayName: string) => ({
value,
resolvedModel,
displayName,
description: "",
});
const { agent, projectDir, setModelSpy } = await setup(
[user(), assistant("claude-fable-5-1")],
[
fable("default", "claude-opus-5-5", "Default (recommended)"),
fable("claude-fable-5-1", "claude-fable-5-1", "Fable 5.1"),
fable("claude-fable-5", "claude-fable-5", "Fable 5"),
],
);
const response = await (agent as any).createSession(
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true } },
{ resume: sessionId },
);
const model = response.configOptions.find((option: any) => option.id === "model");
expect(model).toMatchObject({
currentValue: "fable",
options: [
{ value: "default" },
{ value: "fable", name: "Fable 5.1" },
{ value: "claude-fable-5", name: "Fable 5" },
],
});
expect(setModelSpy).not.toHaveBeenCalled();
});
it("continues in the last permission mode on session/load", async () => {
const { agent, projectDir, getCapturedOptions } = await setup([
user("plan"),

View File

@@ -346,49 +346,81 @@ describe("task plan lifecycle", () => {
);
});
it("clears the task plan on a conversation reset", async () => {
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
const { agent, updates } = setup(taskState, async function* (input) {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
yield userEcho(first.value);
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
};
yield successfulResult();
const inProgressPlan = [{ content: "Run tests", status: "in_progress", priority: "medium" }];
const planUpdateKinds = new Set(["plan", "plan_update", "plan_removed"]);
const second = await iter.next();
yield userEcho(second.value);
yield successfulResult();
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/clear" }],
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Start fresh" }],
});
expect(taskState.size).toBe(0);
expect(updates.filter((notification) => notification.update.sessionUpdate === "plan")).toEqual([
{
sessionId: "test-session",
update: {
sessionUpdate: "plan",
entries: [{ content: "Run tests", status: "in_progress", priority: "medium" }],
it.each([
{
client: "a client without plan operations",
clientCapabilities: {},
expected: [
{ sessionUpdate: "plan", entries: inProgressPlan },
{ sessionUpdate: "plan", entries: [] },
],
},
{
client: "a client with plan operations",
clientCapabilities: { plan: {} },
expected: [
{
sessionUpdate: "plan_update",
plan: { type: "items", planId: "plan", entries: inProgressPlan },
},
},
{
{ sessionUpdate: "plan_removed", planId: "plan" },
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
],
},
{
// AIR keeps each approved plan with its approval card.
client: "AIR with plan operations",
clientCapabilities: { ...AIR_CLIENT_CAPABILITIES, plan: {} },
expected: [
{
sessionUpdate: "plan_update",
plan: { type: "items", planId: "plan", entries: inProgressPlan },
},
{ sessionUpdate: "plan_removed", planId: "plan" },
],
},
])(
"clears the plans on a conversation reset for $client",
async ({ clientCapabilities, expected }) => {
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
const { agent, updates } = setup(taskState, async function* (input) {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
yield userEcho(first.value);
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
};
yield successfulResult();
const second = await iter.next();
yield userEcho(second.value);
yield successfulResult();
});
await initializeClient(agent, clientCapabilities);
await agent.prompt({
sessionId: "test-session",
update: { sessionUpdate: "plan", entries: [] },
},
]);
});
prompt: [{ type: "text", text: "/clear" }],
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Start fresh" }],
});
expect(taskState.size).toBe(0);
expect(
updates
.map((notification) => notification.update)
.filter((update) => planUpdateKinds.has(update.sessionUpdate)),
).toEqual(expected);
},
);
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {

View File

@@ -1574,6 +1574,59 @@ describe("ACP v2 prompts", () => {
]);
});
it("removes the checklist and the approved plan on a conversation reset", async () => {
scriptTurns(
async function* (options) {
const todoWrite = assistantText(options, "", "msg_todo");
todoWrite.message.content = [
{
type: "tool_use",
id: "toolu_todo",
name: "TodoWrite",
input: { todos: [{ content: "Do X", status: "pending", activeForm: "Doing X" }] },
},
] as never;
yield todoWrite;
yield result(options);
},
async function* (options) {
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: options.sessionId,
};
yield result(options);
},
);
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("plan") });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("/clear") });
await vi.waitFor(() => expect(client.updates("plan_removed")).toHaveLength(2));
await client.authUpdate(1);
});
expect(
client.sessionUpdates
.map(({ update }) => update)
.filter(({ sessionUpdate }) => sessionUpdate.startsWith("plan_")),
).toEqual([
{
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "Do X", status: "pending", priority: "medium" }],
},
},
{ sessionUpdate: "plan_removed", planId: "plan" },
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
]);
});
it("reports Claude Code's warnings as live notices", async () => {
scriptTurns(async function* (options) {
yield {
@@ -2000,6 +2053,34 @@ describe("ACP v2 prompts", () => {
expect(atClose).toEqual(["user_message", "running", "agent_message_chunk", "idle cancelled"]);
});
it.each([
["Edit", "workflow-agent"],
["Write", undefined],
] as const)("does not publish a preview for a hook-only %s", async (name, agentId) => {
const file = path.join(cwd, "app.ts");
await writeFile(file, "const value = 1;\n");
const input =
name === "Edit"
? { file_path: file, old_string: "const value = 1;", new_string: "const value = 2;" }
: { file_path: file, content: "const value = 2;\n" };
scriptTurns(async function* (options) {
// Workflow agents run these hooks without streaming their tool uses
// or results to the root query.
await previewToolUse(options, { id: "workflow-edit", name, input, agentId });
await writeFile(file, "const value = 2;\n");
yield result(options);
});
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
});
expect(client.updates("tool_call_update")).toEqual([]);
});
// Hook callbacks bypass the SDK's message queue, so the hook can also come
// before the agent has seen the streamed tool_use.
it.each([
@@ -2034,27 +2115,19 @@ describe("ACP v2 prompts", () => {
};
// Claude Code runs the PreToolUse hooks and waits for them; no
// permission request comes for an auto-approved Edit.
const preToolUse = async () => {
for (const matcher of options.hooks?.PreToolUse ?? []) {
for (const hook of matcher.hooks) {
await hook(
{
hook_event_name: "PreToolUse",
session_id: options.sessionId!,
cwd,
tool_name: "Edit",
tool_input: input,
tool_use_id: "toolu_edit",
} as never,
"toolu_edit",
{ signal: new AbortController().signal },
);
}
}
};
const preToolUse = () => previewToolUse(options, { id: "toolu_edit", name: "Edit", input });
if (hookFirst) await preToolUse();
yield toolUse;
yield stream(options, {
type: "message_start",
message: { id: "msg_edit", role: "assistant", content: [], usage: {} },
});
yield stream(options, {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_edit", name: "Edit", input: {} },
});
if (!hookFirst) await preToolUse();
yield toolUse;
// The Edit runs.
await writeFile(file, "line 1\nconst value = 2;\nline 3\n");
yield {
@@ -4018,22 +4091,7 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => {
session_id: options.sessionId,
};
// Claude Code runs the PreToolUse hooks, then asks for the permission.
for (const matcher of options.hooks?.PreToolUse ?? []) {
for (const hook of matcher.hooks) {
await hook(
{
hook_event_name: "PreToolUse",
session_id: options.sessionId!,
cwd,
tool_name: "Write",
tool_input: input,
tool_use_id: "toolu_late",
} as never,
"toolu_late",
{ signal: new AbortController().signal },
);
}
}
await previewToolUse(options, { id: "toolu_late", name: "Write", input });
decision = await options.canUseTool!("Write", input, {
signal: new AbortController().signal,
suggestions: [],
@@ -4466,6 +4524,56 @@ describe("ACP v2 requests that subagents make", () => {
]);
});
it("starts a subagent's task without a tool call on the subagent's stream, and lists it there", async () => {
const workflow = {
task_id: "wf1",
task_type: "local_workflow",
description: "Review",
parent_task_id: "agent-1",
};
scriptTurns(async function* (options) {
yield agentUse(options);
yield level(options, [agent1]);
yield taskStarted(options, "agent-1", { prompt: "Look around", is_backgrounded: true });
yield result(options);
yield idleState(options);
yield level(options, [agent1, workflow]);
yield {
type: "system",
subtype: "task_started",
...workflow,
uuid: randomUUID(),
session_id: options.sessionId,
};
});
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() =>
expect(
client.sessionUpdates
.filter(
({ sessionId, update }) =>
sessionId === "agent-1" && update.sessionUpdate === "state_update",
)
.at(-1)?.update,
).toMatchObject({ background: [{ type: "tool_call", toolCallId: "task_wf1" }] }),
);
});
expect(
client.sessionUpdates.filter(
({ update }) =>
update.sessionUpdate === "tool_call_update" && update.toolCallId === "task_wf1",
)[0],
).toMatchObject({
sessionId: "agent-1",
update: { title: "Review", kind: "other", status: "in_progress" },
});
});
it("keeps an idle subagent listed while its own command runs in the background", async () => {
const childMessage = (options: Options, message: Record<string, unknown>) => ({
...message,
@@ -6049,6 +6157,210 @@ describe("ACP v2 terminal of a command in the background", () => {
).toEqual(["pending", "in_progress"]);
});
it("updates a workflow card from SDK progress before launch and while idle, and keeps it on resume", async () => {
const finish = Promise.withResolvers<void>();
const progress = (options: Options, fields: Record<string, unknown> = {}) =>
taskEvent(options, "task_progress", {
task_id: "wf1",
tool_use_id: "toolu_wf",
description: "Probe: probe",
summary: "Run a probe",
last_tool_name: "probe",
usage: { total_tokens: 0, tool_uses: 0, duration_ms: 29 },
...fields,
});
scriptTurns(async function* (options) {
yield progress(options, { task_id: "unknown" });
yield toolUseMessage(options, "toolu_wf", "Workflow", { script: "probe" });
yield level(options, [workflow]);
yield taskEvent(options, "task_started", { ...workflow, tool_use_id: "toolu_wf" });
// Observed on CLI 2.1.319: workflow progress can precede the launch
// result. last_tool_name is the active agent's label, not a tool.
yield progress(options);
yield {
...toolResultMessage(options, "toolu_wf", { content: "Workflow launched." }),
tool_use_result: {
status: "async_launched",
taskId: "wf1",
taskType: "local_workflow",
},
};
yield result(options);
yield idleState(options);
yield taskEvent(options, "task_updated", { task_id: "wf1", patch: { status: "paused" } });
yield progress(options, {
summary: undefined,
last_tool_name: undefined,
usage: { total_tokens: 4003, tool_uses: 2, duration_ms: 1494 },
});
yield level(options, [workflow]);
yield progress(options, {
usage: { total_tokens: 4003, tool_uses: 2, duration_ms: 1494 },
});
await finish.promise;
yield taskEvent(options, "task_updated", {
task_id: "wf1",
patch: { status: "completed" },
});
yield progress(options, { description: "Late progress" });
yield level(options, []);
});
const body = (tokens: string, tools: number, seconds: number) => [
{
type: "content",
content: {
type: "text",
text: `Run a probe\n\nLast activity: probe\n\n${tokens} tokens · ${tools} tool calls · ${seconds}s`,
},
},
];
let resumed: v2.SessionUpdate[] = [];
let live: v2.SessionUpdate[] = [];
const client = await promptUntil(
"idle end_turn [toolu_wf]",
async (agent, sessionId, client) => {
await vi.waitFor(() =>
expect(client.updates("tool_call_update")).toContainEqual(
expect.objectContaining({ content: body("4,003", 2, 1) }),
),
);
live = client.updates("tool_call_update");
const before = client.sessionUpdates.length;
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
resumed = client.sessionUpdates.slice(before).map(({ update }) => update);
finish.resolve();
await vi.waitFor(() => expect(backgroundTrace(client)).toContain("idle []"));
},
);
expect(
live.filter(
(update) =>
update.sessionUpdate === "tool_call_update" &&
update.title === "Workflow: Probe: probe",
),
).toEqual([
{
sessionUpdate: "tool_call_update",
toolCallId: "toolu_wf",
title: "Workflow: Probe: probe",
content: body("0", 0, 0),
},
{
sessionUpdate: "tool_call_update",
toolCallId: "toolu_wf",
title: "Workflow: Probe: probe",
content: body("4,003", 2, 1),
},
]);
expect(resumed[0]).toMatchObject({
toolCallId: "toolu_wf",
title: "Workflow: Probe: probe",
content: body("4,003", 2, 1),
status: "paused",
});
expect(
client
.updates("tool_call_update")
.flatMap((update) => (update.status ? [update.status] : [])),
).toEqual(["pending", "in_progress", "paused", "paused", "completed"]);
const calls = client.updates("tool_call_update");
const completed = calls.findIndex((update) => update.status === "completed");
expect(
calls.slice(completed + 1).filter((update) => update.title || update.content),
).toEqual([]);
});
it("lists a workflow's phases and agents on its card, and keeps them when a report has none", async () => {
// As CLI 2.1.319 sends it, trimmed of the fields the card ignores.
const snapshot = [
{ type: "workflow_phase", index: 1, title: "Count" },
{ type: "workflow_phase", index: 2, title: "Sum" },
{
type: "workflow_agent",
index: 1,
label: "two",
phaseIndex: 1,
phaseTitle: "Count",
agentId: "af3724ea06eadb5dd",
model: "claude-haiku-5-5",
state: "done",
attempt: 1,
promptPreview: "Reply with only the number 2.",
tokens: 14629,
toolCalls: 0,
durationMs: 1329,
resultPreview: "2",
},
{
type: "workflow_agent",
index: 2,
label: "three",
phaseIndex: 1,
phaseTitle: "Count",
model: "claude-haiku-5-5",
state: "start",
lastToolName: "Bash",
lastToolSummary: "echo 3",
},
];
const progress = (options: Options, fields: Record<string, unknown>) =>
taskEvent(options, "task_progress", {
task_id: "wf1",
tool_use_id: "toolu_wf",
description: "Count: three",
...fields,
});
scriptTurns(async function* (options) {
yield toolUseMessage(options, "toolu_wf", "Workflow", { script: "probe" });
yield level(options, [workflow]);
yield taskEvent(options, "task_started", { ...workflow, tool_use_id: "toolu_wf" });
yield {
...toolResultMessage(options, "toolu_wf", { content: "Workflow launched." }),
tool_use_result: {
status: "async_launched",
taskId: "wf1",
taskType: "local_workflow",
},
};
yield progress(options, {
usage: { total_tokens: 14629, tool_uses: 0, duration_ms: 1343 },
workflow_progress: snapshot,
});
// The CLI leaves the snapshot out of some reports.
yield progress(options, {
usage: { total_tokens: 29282, tool_uses: 1, duration_ms: 2806 },
});
yield result(options);
yield idleState(options);
});
const list = [
"**Count**",
"- two: done · 14,629 tokens · 0 tool calls · 1s",
"- three: running · Bash: echo 3",
"",
"**Sum**",
"- not started",
].join("\n");
const client = await promptUntil("idle end_turn [toolu_wf]");
expect(
client
.updates("tool_call_update")
.flatMap((update) => (update.content ? [update.content] : []))
.slice(-2),
).toEqual(
["14,629 tokens · 0 tool calls · 1s", "29,282 tokens · 1 tool calls · 2s"].map(
(usage) => [
{
type: "content",
content: { type: "text", text: `Count: three\n\n${usage}\n\n${list}` },
},
],
),
);
});
it("sends each listed call's current state before the state snapshot of session/resume", async () => {
scriptTurns(async function* (options) {
yield* monitorStarts(options);
@@ -6087,31 +6399,126 @@ describe("ACP v2 terminal of a command in the background", () => {
]);
});
it("lists a task whose call returned before the task started as a _claude/task item", async () => {
scriptTurns(async function* (options) {
yield toolUseMessage(options, "toolu_mcp", "mcp__ci__watch", { job: "build" });
yield toolResultMessage(options, "toolu_mcp", { content: "Watching the build." });
// The call's task joins the level and starts after the call returned,
// so the call ended and cannot be listed.
const task = { task_id: "mcp1", task_type: "mcp_task", description: "Watch the build" };
yield level(options, [task]);
yield taskEvent(options, "task_started", { ...task, tool_use_id: "toolu_mcp" });
yield result(options);
yield idleState(options);
});
const client = await promptUntil("idle end_turn [_claude/task]");
/** The adapter's own call for the task `mcp1`, which an MCP call started
* but which started only after that call returned with its final status. */
async function* lateTaskStarts(options: Options) {
yield toolUseMessage(options, "toolu_mcp", "mcp__ci__watch", { job: "build" });
yield toolResultMessage(options, "toolu_mcp", { content: "Watching the build." });
const task = { task_id: "mcp1", task_type: "mcp_task", description: "Watch the build" };
yield level(options, [task]);
yield taskEvent(options, "task_started", { ...task, tool_use_id: "toolu_mcp" });
yield result(options);
yield idleState(options);
}
expect(client.updates("state_update").at(-1)).toMatchObject({
background: [{ type: "_claude/task", taskId: "mcp1", description: "Watch the build" }],
it("starts a tool call for a task whose call returned before the task started, and lists it", async () => {
scriptTurns(lateTaskStarts);
const client = await promptUntil("idle end_turn [task_mcp1]");
const updates = client.sessionUpdates.map(({ update }) => update);
const started = updates.findIndex(
(update) =>
update.sessionUpdate === "tool_call_update" && update.toolCallId === "task_mcp1",
);
const listed = updates.findIndex(
(update) =>
update.sessionUpdate === "state_update" &&
JSON.stringify(update).includes('"task_mcp1"'),
);
expect(updates[started]).toMatchObject({
title: "Watch the build",
kind: "other",
status: "in_progress",
});
expect(started).toBeLessThan(listed);
// The call that started the task keeps its final status.
expect(
updates
.filter(
(update) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "toolu_mcp" &&
update.status !== undefined,
)
.map((update) => (update as { status?: unknown }).status)
.at(-1),
).toBe("completed");
});
it("stops such a task on tool_call/cancel, and reports its call cancelled", async () => {
const stopped = Promise.withResolvers<string>();
sdk.stopTask = async (taskId) => stopped.resolve(taskId);
scriptTurns(async function* (options) {
yield* lateTaskStarts(options);
await stopped.promise;
yield taskEvent(options, "task_notification", {
task_id: "mcp1",
tool_use_id: "toolu_mcp",
status: "stopped",
summary: "Stopped",
});
yield level(options, []);
});
const client = await promptUntil(
"idle end_turn [task_mcp1]",
async (agent, sessionId, client) => {
await expect(
agent.request(v2.methods.agent.toolCall.cancel, {
sessionId,
toolCallId: "task_mcp1",
}),
).resolves.toEqual({});
expect(await stopped.promise).toBe("mcp1");
await vi.waitFor(() => expect(backgroundTrace(client)).toContain("idle []"));
},
);
const statuses = client.sessionUpdates.flatMap(({ update }) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "task_mcp1" &&
update.status
? [String(update.status)]
: [],
);
expect(statuses).toEqual(["in_progress", "cancelled"]);
});
it("sends such a task's call before the state snapshot of session/resume", async () => {
scriptTurns(lateTaskStarts);
let resumed: v2.SessionUpdate[] = [];
await promptUntil("idle end_turn [task_mcp1]", async (agent, sessionId, client) => {
const before = client.sessionUpdates.length;
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
resumed = client.sessionUpdates.slice(before).map(({ update }) => update);
});
expect(resumed).toEqual([
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId: "task_mcp1",
title: "Watch the build",
kind: "other",
status: "in_progress",
}),
{
sessionUpdate: "tool_call_update",
toolCallId: "task_mcp1",
capabilities: { cancel: {} },
},
{
sessionUpdate: "state_update",
state: "idle",
background: [{ type: "tool_call", toolCallId: "task_mcp1" }],
},
]);
});
// No tool call started these, such as tasks that a hook started.
it.each([
{
task: workflow,
trace: ["running []", "running [_claude/task]", "idle end_turn [_claude/task]"],
background: [{ type: "_claude/task", taskId: "wf1", description: "Review" }],
trace: ["running []", "running [task_wf1]", "idle end_turn [task_wf1]"],
background: [{ type: "tool_call", toolCallId: "task_wf1" }],
},
// A watcher Claude Code started by itself is housekeeping.
{
@@ -6120,7 +6527,7 @@ describe("ACP v2 terminal of a command in the background", () => {
background: [],
},
])(
"lists a $task.task_type task without such a call unless it is ambient",
"starts and lists a tool call for a $task.task_type task without such a call unless it is ambient",
async ({ task, trace, background }) => {
scriptTurns(async function* (options) {
yield level(options, [task]);
@@ -6353,6 +6760,13 @@ describe("ACP v2 session translation", () => {
});
});
it("reports an empty legacy plan as the removal of the session's v2 plan", () => {
expect(v2SessionUpdate({ sessionUpdate: "plan", entries: [] })).toEqual({
sessionUpdate: "plan_removed",
planId: "plan",
});
});
it("passes the agent's plan operations through, which have v2's shape", () => {
const update = {
sessionUpdate: "plan_update" as const,
@@ -6888,3 +7302,27 @@ describe("ACP v2 auth", () => {
});
});
});
async function previewToolUse(
options: Options,
tool: { id: string; name: string; input: unknown; agentId?: string },
): Promise<void> {
for (const matcher of options.hooks?.PreToolUse ?? []) {
for (const hook of matcher.hooks) {
await hook(
{
hook_event_name: "PreToolUse",
session_id: options.sessionId!,
transcript_path: path.join(cwd, "session.jsonl"),
cwd,
tool_name: tool.name,
tool_input: tool.input,
tool_use_id: tool.id,
...(tool.agentId ? { agent_id: tool.agentId } : {}),
},
tool.id,
{ signal: new AbortController().signal },
);
}
}
}

View File

@@ -151,6 +151,36 @@ describe("ToolCallFieldTracker", () => {
expect(final.content).toEqual(snippet);
});
it("prefers a call's own content to an earlier staged preview", () => {
const tracker = new ToolCallFieldTracker();
tracker.stagePreview("t", [{ type: "diff", path: "/a.ts", oldText: "a", newText: "b" }]);
const content = [{ type: "diff" as const, path: "/a.ts", oldText: "a", newText: "c" }];
const update = { sessionUpdate: "tool_call" as const, toolCallId: "t", title: "Edit", content };
tracker.apply(update);
expect(update.content).toEqual(content);
});
it("bounds unclaimed previews and releases them when a call or session is forgotten", () => {
const tracker = new ToolCallFieldTracker();
const patch = [{ type: "diff" as const, path: "/a.ts", oldText: "a", newText: "b" }];
for (let index = 0; index < 65; index++) tracker.stagePreview(`${index}`, patch);
const start = (id: string) => {
const update = { sessionUpdate: "tool_call" as const, toolCallId: id, title: "Edit" };
tracker.apply(update);
return update;
};
expect(start("0")).not.toHaveProperty("content");
expect(start("1")).toHaveProperty("content", patch);
// A preview belongs only to the initial publication, not a later restart.
expect(start("1")).not.toHaveProperty("content");
tracker.delete("2");
expect(start("2")).not.toHaveProperty("content");
tracker.clear();
expect(start("3")).not.toHaveProperty("content");
});
it("ends an entry after both the tool_result and the hook", () => {
const tracker = new ToolCallFieldTracker();
tracker.apply({ sessionUpdate: "tool_call", toolCallId: "a", title: "A" });

View File

@@ -4,6 +4,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import {
aliasResumedModelRow,
resolveModelPreference,
applyAvailableModelsAllowlist,
isDeniedModel,
@@ -196,6 +197,50 @@ describe("matchResumedModel", () => {
});
});
describe("aliasResumedModelRow", () => {
const row = (value: string, resolvedModel = value): ModelInfo => ({
value,
resolvedModel,
displayName: value,
description: "",
});
// What CLI 2.1.321 lists after resuming a session on `fable` (Fable 5.2).
const STARTED_ON_FABLE = [
row("default", "claude-opus-5-5"),
row("claude-fable-5-2"),
row("claude-fable-5-1"),
row("claude-fable-5"),
];
it("lists the resumed model's row under its family alias", () => {
expect(aliasResumedModelRow(STARTED_ON_FABLE, "claude-fable-5-2").map((m) => m.value)).toEqual([
"default",
"fable",
"claude-fable-5-1",
"claude-fable-5",
]);
});
it.each([
[
"the alias already has a row",
[row("fable", "claude-fable-5-1"), row("claude-fable-5")],
"claude-fable-5",
],
["the row is not the resumed model", STARTED_ON_FABLE, "claude-opus-5-5"],
["the session is not resumed", STARTED_ON_FABLE, undefined],
["the family has a newer row", STARTED_ON_FABLE, "claude-fable-5-1"],
[
"the row carries a context hint",
[row("claude-fable-5-1[1m]", "claude-fable-5-1")],
"claude-fable-5-1[1m]",
],
["the row is not a family model", [row("claude-gpt-9")], "claude-gpt-9"],
])("keeps every value when %s", (_case, models, resumedModel) => {
expect(aliasResumedModelRow(models, resumedModel)).toEqual(models);
});
});
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
describe("module runtime exports", () => {
/**
* The public runtime exports of lib.ts must change only deliberately; consumers import these names.
*/
it("lib.ts exports the expected names", async () => {
expect(Object.keys(await import("../lib.js")).sort()).toEqual([
"AUTH_STATUS_UPDATE_METHOD",
"ClaudeAcpAgent",
"Pushable",
"SettingsManager",
"isLocalCommandMetadata",
"nodeToWebReadable",
"nodeToWebWritable",
"planEntries",
"runAcp",
"streamEventToAcpNotifications",
"stripLocalCommandMetadata",
"toAcpNotifications",
"toDisplayPath",
"toolInfoFromToolUse",
"toolUpdateFromToolResult",
"unreachable",
]);
});
/**
* Moving code out of acp-agent.ts must keep every name re-exported, since consumers deep-import
* dist/acp-agent.js; change this list only deliberately.
*/
it("acp-agent.ts exports the expected names", async () => {
expect(Object.keys(await import("../acp-agent.js")).sort()).toEqual([
"AGENT_CONFIG_ID",
"BUILTIN_AGENT_NAMES",
"ClaudeAcpAgent",
"DEFAULT_AGENT_ID",
"EFFORT_CONFIG_ID",
"FAST_MODE_CONFIG_ID",
"FAST_MODE_OFF",
"FAST_MODE_ON",
"MODEL_CONFIG_ID",
"MODE_CONFIG_ID",
"applyAvailableModelsAllowlist",
"buildConfigOptions",
"clientSupportsBooleanConfigOptions",
"clientSupportsRecommendedConfigValue",
"computeSessionFingerprint",
"createFastModeConfigOption",
"discoverCustomAgents",
"fastModeStateEnabled",
"isLocalCommandMetadata",
"isLoginRequiredText",
"isReplayHiddenMetaMessage",
"isSyntheticLoginMessage",
"matchResumedModel",
"messageIdForGrouping",
"normalizeFastModeDisabledReason",
"promptToClaude",
"resolveFastModeEnabled",
"resolveModelPreference",
"runAcp",
"runPromptWithCancellation",
"settingsEffortForModel",
"streamEventToAcpNotifications",
"stripLocalCommandMetadata",
"toAcpNotifications",
"v1AgentApp",
]);
});
});

View File

@@ -2684,6 +2684,36 @@ describe("toAcpNotifications - TodoWrite with undefined input regression", () =>
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
expect(planUpdates).toHaveLength(1);
});
it.each([
{
todos: [{ content: "Do X", status: "pending", activeForm: "Doing X" }],
expected: {
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "Do X", status: "pending", priority: "medium" }],
},
},
},
{ todos: [], expected: { sessionUpdate: "plan_removed", planId: "plan" } },
])(
"sends $todos.length todos as a plan operation to a client with plan operations",
({ todos, expected }) => {
const notifications = toAcpNotifications(
[{ type: "tool_use" as const, id: "toolu_todo", name: "TodoWrite", input: { todos } }],
"assistant",
"test-session",
{},
mockClient,
mockLogger,
{ clientCapabilities: { plan: {} } },
);
expect(notifications.map(({ update }) => update)).toEqual([expected]);
},
);
});
describe("parseTaskCreateOutput", () => {
@@ -2923,6 +2953,39 @@ describe("toAcpNotifications - Task* tools", () => {
expect(taskState.size).toBe(0);
});
it("sends the task snapshot as a plan operation to a client with plan operations", () => {
const toolUseCache: ToolUseCache = {
"1": { type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "First" } },
};
const created = toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: "1",
content: JSON.stringify({ task: { id: "1", subject: "First" } }),
is_error: false,
},
] as any,
"user",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ taskState: new Map(), clientCapabilities: { plan: {} } },
);
expect(created.map(({ update }) => update)).toEqual([
{
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "First", status: "pending", priority: "medium" }],
},
},
]);
});
it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => {
const toolUseCache: ToolUseCache = {};
const taskState: TaskState = new Map();

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { workflowSnapshot, workflowSnapshotText } from "../workflow-progress.js";
const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title });
const agent = (label: string, fields: Record<string, unknown> = {}) => ({
type: "workflow_agent",
label,
state: "start",
phaseIndex: 1,
...fields,
});
describe("workflowSnapshot", () => {
it.each([
["not an array", { agents: [] }],
["no phase or agent", [{ type: "workflow_note", text: "hi" }]],
["only malformed entries", [phase(1, " "), { type: "workflow_agent", label: "a" }, null]],
])("is undefined for %s", (_, value) => {
expect(workflowSnapshot(value)).toBeUndefined();
});
it("skips malformed entries and fields of the wrong type", () => {
expect(
workflowSnapshot([
phase(1, "Count"),
{ type: "workflow_phase", title: "No index" },
agent("a", { tokens: "12" }),
]),
).toEqual({
phases: [{ index: 1, title: "Count" }],
agents: [{ label: "a", state: "start", phaseIndex: 1 }],
});
});
});
describe("workflowSnapshotText", () => {
it.each([
{
name: "lists agents without a known phase before the phases",
entries: [
phase(1, "Count"),
agent("loose", { phaseIndex: undefined }),
agent("lost", { phaseIndex: 9 }),
],
text: "- loose: running\n- lost: running\n\n**Count**\n- not started",
},
{
name: "shows a retry's attempt and a state the adapter does not know as sent",
entries: [phase(1, "Count"), agent("a", { attempt: 2, state: "error" })],
text: "**Count**\n- a: error (attempt 2)",
},
{
name: "shows the last tool of a running agent only",
entries: [
phase(1, "Count"),
agent("run", { lastToolName: "Read" }),
agent("done", { state: "done", lastToolName: "Bash", lastToolSummary: "echo 3" }),
],
text: "**Count**\n- run: running · Read\n- done: done",
},
])("$name", ({ entries, text }) => {
expect(workflowSnapshotText(workflowSnapshot(entries)!)).toBe(text);
});
});

View File

@@ -19,6 +19,9 @@ const REPLACED_FIELDS = [
type ReplacedField = (typeof REPLACED_FIELDS)[number];
/** Hooks also report workflow tools that never appear in the SDK stream. */
const MAX_STAGED_PREVIEWS = 64;
interface SentToolCall {
/** The JSON of each replaced field that the client holds now. */
fields: Map<ReplacedField, string>;
@@ -46,10 +49,12 @@ interface SentToolCall {
*/
export class ToolCallFieldTracker {
private readonly calls = new Map<string, SentToolCall>();
private readonly previews = new Map<string, ToolCallContent[]>();
/**
* Records a `tool_call`, or removes the unchanged fields of a
* `tool_call_update` and records the rest.
* `tool_call_update` and records the rest. A new call without content takes
* its staged preview, pinned until a final result replaces it.
*
* Returns false when the update carries nothing new: no replaced field
* remains, `_meta` has no key besides `claudeCode` and `jetbrains`, and
@@ -62,10 +67,13 @@ export class ToolCallFieldTracker {
*/
apply(update: SessionUpdate, options?: { replacePinnedContent?: boolean }): boolean {
if (update.sessionUpdate === "tool_call") {
const preview = update.content?.length ? undefined : this.previews.get(update.toolCallId);
this.previews.delete(update.toolCallId);
if (preview) update.content = preview;
const entry: SentToolCall = {
fields: new Map(),
mergedMeta: new Map(),
contentPinned: false,
contentPinned: preview !== undefined,
resultSeen: false,
};
this.calls.set(update.toolCallId, entry);
@@ -106,6 +114,21 @@ export class ToolCallFieldTracker {
return changed;
}
/**
* Keeps a pre-execution patch until the tool call is first published.
*
* A hook can beat the streamed tool use. Workflow tools can also run hooks
* without ever streaming their use or result, so staging a preview must not
* create a client-visible call. At most 64 previews wait; the oldest is
* discarded when the limit is exceeded.
*/
stagePreview(toolCallId: string, content: ToolCallContent[]): void {
this.previews.set(toolCallId, content);
if (this.previews.size > MAX_STAGED_PREVIEWS) {
this.previews.delete(this.previews.keys().next().value!);
}
}
/**
* Records the exact approval patch that the client shows for a tool call.
*
@@ -142,11 +165,13 @@ export class ToolCallFieldTracker {
/** Forgets one tool call. */
delete(toolCallId: string): void {
this.calls.delete(toolCallId);
this.previews.delete(toolCallId);
}
/** Forgets every tool call, when the session is torn down. */
clear(): void {
this.calls.clear();
this.previews.clear();
}
}

View File

@@ -5,15 +5,9 @@
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
import { checklistPlanOperation } from "../plans.js";
import { v2ToolCallUpdate } from "./tool-call.js";
/**
* The id of the plan of a session. v1 has one plan per session, which every
* plan update replaces whole, from TodoWrite and from the Task tools alike.
* v2 tracks plans by id, so that plan gets one.
*/
export const SESSION_PLAN_ID = "plan";
/**
* The v2 form of a v1 session update, or `undefined` when v2 has no
* counterpart and the update tells a v2 client nothing new.
@@ -48,13 +42,12 @@ export function v2SessionUpdate(
case "tool_call":
case "tool_call_update":
return v2ToolCallUpdate(update);
case "plan":
// A v2 plan of items is replaced whole as well.
return {
sessionUpdate: "plan_update",
plan: { type: "items", planId: SESSION_PLAN_ID, entries: update.entries },
...(update._meta != null ? { _meta: update._meta } : {}),
};
case "plan": {
// Every v2 client has the `plan` capability, so the agent sends the
// checklist as `plan_update`; a legacy plan converts the same way.
const converted = checklistPlanOperation(update.entries);
return update._meta != null ? { ...converted, _meta: update._meta } : converted;
}
case "plan_update":
case "plan_removed":
// v1's draft plan operations have v2's shape.

124
src/workflow-progress.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* The phases and agents of a running workflow, from the `workflow_progress`
* snapshot that Claude Code attaches to a workflow's `task_progress`.
*
* The SDK does not type `workflow_progress` (SDK 0.3.319, CLI 2.1.319). Its
* agents do not stream through the root query either, so this snapshot is
* the only per-agent data the adapter gets. The shape below is what the CLI
* sends: a full list of `workflow_phase` and `workflow_agent` entries on
* some progress reports and none on others. Entries that don't match it are
* skipped, so a change in the CLI drops the list instead of the card.
*/
export type WorkflowPhase = { index: number; title: string };
export type WorkflowAgent = {
label: string;
/** `start` while it runs and `done` when it finished, as the CLI names them. */
state: string;
phaseIndex?: number;
attempt?: number;
tokens?: number;
toolCalls?: number;
durationMs?: number;
lastToolName?: string;
lastToolSummary?: string;
};
export type WorkflowSnapshot = { phases: WorkflowPhase[]; agents: WorkflowAgent[] };
/** Reads a `workflow_progress` value. Undefined when it holds no phase or agent. */
export function workflowSnapshot(value: unknown): WorkflowSnapshot | undefined {
if (!Array.isArray(value)) return undefined;
const phases: WorkflowPhase[] = [];
const agents: WorkflowAgent[] = [];
for (const entry of value) {
if (!isRecord(entry)) continue;
if (entry.type === "workflow_phase") {
const title = text(entry.title);
if (typeof entry.index === "number" && title) phases.push({ index: entry.index, title });
} else if (entry.type === "workflow_agent") {
const label = text(entry.label);
const state = text(entry.state);
if (!label || !state) continue;
agents.push({
label,
state,
...numbers(entry, ["phaseIndex", "attempt", "tokens", "toolCalls", "durationMs"]),
...strings(entry, ["lastToolName", "lastToolSummary"]),
});
}
}
return phases.length > 0 || agents.length > 0 ? { phases, agents } : undefined;
}
/**
* The snapshot as Markdown: each phase as a heading with its agents below,
* after the agents that have no known phase. For example:
*
* ```
* **Count**
* - two: done · 14,629 tokens · 0 tool calls · 1s
* - three: running · Bash: echo 3
*
* **Sum**
* - not started
* ```
*/
export function workflowSnapshotText(snapshot: WorkflowSnapshot): string {
const known = new Set(snapshot.phases.map((phase) => phase.index));
const unphased = snapshot.agents.filter(
(agent) => agent.phaseIndex === undefined || !known.has(agent.phaseIndex),
);
const sections = unphased.length > 0 ? [unphased.map(agentLine).join("\n")] : [];
for (const phase of snapshot.phases) {
const agents = snapshot.agents.filter((agent) => agent.phaseIndex === phase.index);
const lines = agents.length > 0 ? agents.map(agentLine) : ["- not started"];
sections.push([`**${phase.title}**`, ...lines].join("\n"));
}
return sections.join("\n\n");
}
function agentLine(agent: WorkflowAgent): string {
const state = agent.state === "start" ? "running" : agent.state;
const attempt =
agent.attempt !== undefined && agent.attempt > 1 ? ` (attempt ${agent.attempt})` : "";
const parts = [`${agent.label}: ${state}${attempt}`];
if (agent.tokens !== undefined) parts.push(`${agent.tokens.toLocaleString("en-US")} tokens`);
if (agent.toolCalls !== undefined) {
parts.push(`${agent.toolCalls.toLocaleString("en-US")} tool calls`);
}
if (agent.durationMs !== undefined) parts.push(`${Math.floor(agent.durationMs / 1000)}s`);
// A finished agent's last tool is history; a running one's is what it does now.
if (agent.state === "start" && agent.lastToolName) {
parts.push(
agent.lastToolSummary
? `${agent.lastToolName}: ${agent.lastToolSummary}`
: agent.lastToolName,
);
}
return `- ${parts.join(" · ")}`;
}
function numbers<K extends string>(record: Record<string, unknown>, keys: K[]) {
return Object.fromEntries(
keys.flatMap((key) => (typeof record[key] === "number" ? [[key, record[key]]] : [])),
) as Partial<Record<K, number>>;
}
function strings<K extends string>(record: Record<string, unknown>, keys: K[]) {
return Object.fromEntries(
keys.flatMap((key) => {
const value = text(record[key]);
return value ? [[key, value]] : [];
}),
) as Partial<Record<K, string>>;
}
function text(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}