Compare commits

..

11 Commits

Author SHA1 Message Date
acp-release-bot[bot]
d52c7c64d8 chore(main): release 0.97.0 (#1437)
🤖 I have created a release *beep* *boop*
---


##
[0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0)
(2026-10-26)


### Features

* add ultracode session config option
([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438))
([de96cef](de96cef382))


### Bug Fixes

* bump body-parser from 2.3.0 to 2.4.0
([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436))
([c26e640](c26e640915))
* distinguish model options with duplicate labels
([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286))
([434be13](434be13a33))
* keep the usage-limit error when it arrives after compaction
([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377))
([2a1525e](2a1525e57c))
* pass --settings before the auth status subcommand
([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392))
([6a33ded](6a33dedcbb))
* preserve foreground fork reports until launch completes
([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431))
([e369479](e369479f93))
* settle advisor tool results before completing turns
([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247))
([bed795d](bed795d261))
* settle live steers folded into autonomous cycles
([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185))
([a4957d1](a4957d17c1))

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

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-26 13:46:13 +01:00
Ben Brandt
de96cef382 feat: add ultracode session config option (#1438)
Adds an **Ultracode** session config option. It turns on Claude Code's
dynamic workflows for every task, at any effort level. Clients that
support boolean config options get a native switch; the rest get an
On/Off select. This follows the Fast mode pattern.

## How the CLI handles ultracode (2.1.310, checked against the bundled
binary and a live session)

- **Which models:** ultracode works on exactly the models that support
`xhigh` effort. The CLI uses the same check for both. So Opus 4.7+,
Sonnet 5+, Haiku 5.5 and Fable qualify; Opus 4.6 and Haiku 4.5 don't.
Effort level no longer matters.
- **Workflows:** dynamic workflows must be enabled. They are off by
default on Pro plans, and org or managed policy can turn them off.
- **State reporting:** `query.getSettings()` returns
`applied.ultracodeAvailable` and `applied.ultracodeRequested`. The CLI's
schema says hosts should "offer an Ultracode control only while this is
true". This method exists at runtime but is missing from the SDK's
`.d.ts`, so the adapter reads it with a cast and falls back to settings
and model data when it is absent.
- **Unsupported requests:** `applyFlagSettings({ultracode})` is never
validated. The request is stored and stays in place across model
switches; it takes effect whenever the model supports it.
- **Effort changes:** an `effortLevel` change sent without an
`ultracode` key turns ultracode off.

## Behavior

- **Visibility:** at session start, one `getSettings()` call decides
availability and the starting value. Model switches combine the
workflows result learned at start with the new model's
`supportedEffortLevels`, so the switch path makes no extra control
request.
- **Value:** the session tracks the user's choice, like
`fastModeEnabled`. The option hides on unsupported models and comes back
with the same value.
- **Effort applies:** `effortFlagSettings` now carries the session's
toggle instead of `settings.ultracode`. This applies at the initial
apply, on model switch, and from the effort picker, so effort changes no
longer turn ultracode off.
- **Clear context:** the clear-context restart restores the toggle, as
it does Fast mode.

## Known limitation

If a user types `/effort ultracode on` as a prompt, the toggle does not
update. The adapter already doesn't pick up effort changes typed as
`/effort` commands.

## Testing

- New `src/tests/ultracode-config.test.ts`. It covers the helpers and
session behavior: seeding from the CLI's report, hiding while workflows
are off, following the model's support, keeping the value across effort
and model switches, and rejected applies.
- New clear-context restore test. `effortFlagSettings` tests updated.
- Live probe against CLI 2.1.310 confirmed the `getSettings` fields, the
`xhigh` proxy (`claude-opus-4-6` reports unavailable while the request
stays in place), and the effort/key behavior.
- `npm run check`, `npm run build`, `npm run test:run` all pass.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:34:13 +00:00
Sean McCullough
a4957d17c1 fix: settle live steers folded into autonomous cycles (#1185)
#1233 fixed queued prompts folded into task-notification cycles. One
case remains: a steer sent to the active prompt can be folded into an
autonomous cycle, leaving `session/prompt` pending when the result names
only the steer UUID.

Keep queued prompt matching and also match the active turn's steer UUID.
Exclude settled turns and turns held open for background tasks. Preserve
plural UUID precedence.

The six new settlement cases fail on main and pass with the fix; all 11
new tests pass. Build, lint and format checks pass. The full default
suite reports 2,497 passed and 30 skipped with an identical Node binary
at a separate path. At `/usr/bin/node`, it reports 2,496 passed, one
failed and 30 skipped: an unchanged path-normalization test also fails
on main. No test was weakened. No live-provider tests were run.

Co-authored-by: Sean McCullough <sean@mcculloughs.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:35:31 +01:00
Nacho Lopez
bed795d261 fix: settle advisor tool results before completing turns (#1247)
When Claude uses the advisor server tool, a completed turn can fail with
`ACP error -32603: Internal error: Claude ended the turn without
returning results for tool calls: srvtoolu_...`. `server_tool_use` is
tracked as a foreground call, but the matching `advisor_tool_result` is
discarded, leaving that call unfinished at settlement.

Route advisor results through the existing tool-result handler so they
clear the outstanding call and publish a terminal update. Plaintext
advice is displayed, redacted advice gets a readable completion message
without exposing ciphertext, and advisor error results publish a failed
tool status with the error code. Release the advisor's hook callback
immediately because the server tool has no later client-side
`PostToolUse` hook.

Addresses the result-lifecycle part of #914.

Validation:

- Regression coverage through the real prompt consumer for plaintext,
redacted, and error results, including the complete assistant message
that dispatches the advisor call. All three cases fail without the fix.
A dispatched advisor call without a result still fails with
`incomplete_tool_call`.
- `npm run test:run -- src/tests/incomplete-tools.test.ts`: 59 passed.
- `npm run test:run`: 3,055 passed, 30 skipped.
- `npm run build` and `npm run check`: passed.
- Exact-head Linux CI passed in the fork:
https://github.com/mrmans0n/claude-agent-acp/actions/runs/38595756003.

The upstream PR workflows are waiting for maintainer approval to run.
The required upstream `Build` check remains pending; the fork CI result
does not satisfy that merge gate.
2026-10-26 11:28:57 +01:00
Jasper
434be13a33 fix: distinguish model options with duplicate labels (#1286)
Fixes #1246.

When `availableModels` includes aliases and pinned IDs that inherit the
same SDK display name, name-only model pickers show indistinguishable
rows. Append the selectable model ID to names that remain duplicated
after the existing version-label normalization. For example, the four
`Opus 5.5` rows become `Opus 5.5 (opus)`, `Opus 5.5 (opus[1m])`, and
their explicit-ID equivalents.

Keep every option and its ID, description, selection, and recommendation
behavior. Existing distinct labels stay unchanged. Regression tests
exercise the real allowlist transformation and cover duplicate custom
labels too.

Validation:

- Linux, locked dependencies and the repository's `lts/*` Node
configuration: `npm run check`, `npm run build`, and the full test suite
passed at ba37dd8: **2,491 passed, 30 skipped** ([fix
job](https://github.com/jipeng6036-del/claude-agent-acp/actions/runs/37923655565/job/113797353655)).
- Exact baseline 908c994: **2,489 passed, 30 skipped**; both new
regression tests fail against the original implementation. All existing
test outcomes match ([baseline
verification](https://github.com/jipeng6036-del/claude-agent-acp/actions/runs/37923859076)).
- Windows/Node 24: build and 105 focused tests passed. The full suite
has the same 48 failures on baseline and fix; only the two new tests add
passes. Authenticated SDK and JetBrains UI integration were not run.

Signed-off-by: Jasper <241105014+jipeng6036-del@users.noreply.github.com>
Co-authored-by: Jasper <241105014+jipeng6036-del@users.noreply.github.com>
2026-10-26 10:27:07 +00:00
Kerekes Dániel
2a1525e57c fix: keep the usage-limit error when it arrives after compaction (#1377)
Fixes #1370

After a compaction in the same turn, the synthetic usage-limit frame is
dropped by the stale-frame suppression before its failure details are
read. The prompt then ends with a generic internal error instead of the
quota error, so clients can't show the limit UI.

The suppression branch now records the usage-limit title and error
before it skips the frame. Only recognized usage-limit frames are read
there, so a stale replayed frame still can't overwrite the failure of
the current turn.

Added a test for a usage-limit frame after a completed compaction, for
both the legacy `errorKind` and the typed session failure, plus a stale
frame arriving after it. It fails without the change. `npm run check`,
`npm run build` and `npm run test:run` pass.
2026-10-26 10:26:18 +00:00
PerryLink
54f1e21bc4 test: retry the acp-v2 teardown rm on Windows (#1373)
## What breaks

On Windows, `npm run test:run` fails inside `src/tests/acp-v2.test.ts`,
and every failure is raised by the teardown rather than by the behaviour
the test checks. Measured on Windows 11 build 26200, Node 22.22.3,
vitest 5.0.4, two runs:

```
$ node node_modules/vitest/vitest.mjs run src/tests/acp-v2.test.ts
 Test Files  1 failed (1)
      Tests  45 failed | 91 passed (136)
   Duration  11.75s (tests 91%, transform 7%, import 2%)
```

```
 Test Files  1 failed (1)
      Tests  53 failed | 83 passed (136)
   Duration  11.79s (tests 96%, import 2%, transform 2%)
```

Exit code 1 both times. Every failure is the same error, and it comes
from `afterEach`, after the test body has finished:

```
 FAIL  src/tests/acp-v2.test.ts > ACP v2 prompts > does not report the next turn as waiting for a question that its client never settled
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\...\Temp\acp-v2-TNkkd2\project-J9PwxJ'

 FAIL  src/tests/acp-v2.test.ts > ACP v2 prompts > the tool calls that a cancel stops > ends a stopped tool call and its subagent's as cancelled, before it reports idle cancelled
Error: EBUSY: resource busy or locked, rmdir 'C:\Users\...\Temp\acp-v2-O3wrrr\project-NgBUMz'
```

The string `EBUSY` occurs 45 and 53 times in those two logs, matching
the two failure counts. The failing set moves between runs; which test
loses the race depends on when Windows releases each directory handle.

CI runs the suite on `ubuntu-latest` only
(`.github/workflows/ci.yml:20`, `:45`), so CI cannot show this. Anyone
who runs the documented `npm run test:run` on Windows gets a red file
and a non-zero exit.

## Why

The per-test teardown at `src/tests/acp-v2.test.ts:156` is:

```ts
await rm(tempDir, { recursive: true, force: true });
```

`force: true` suppresses ENOENT only; it does not retry EBUSY. Each
test's cwd is a child of `tempDir` (`cwd = await
mkdtemp(path.join(tempDir, "project-"))`, line 147), and Windows keeps a
directory busy briefly after the process holding it exits, so `rmdir`
returns EBUSY, `afterEach` rejects, and vitest marks the test failed.

## The fix

```diff
-  await rm(tempDir, { recursive: true, force: true });
+  await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
```

`maxRetries` is Node's own retry loop for `fs.rm`, covering EBUSY,
EMFILE, ENFILE, ENOTEMPTY and EPERM with a backoff between attempts. The
repository already uses it for the same class of problem in
`src/tests/session-hooks.test.ts:150`:

```ts
// The CLI may still write the transcript of the last session after dispose.
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
```

## Measured after

```
$ node node_modules/vitest/vitest.mjs run src/tests/acp-v2.test.ts
 Test Files  1 passed (1)
      Tests  136 passed (136)
   Duration  16.16s (tests 96%, transform 2%, import 2%)
```

A second run after restoring the patched file: `Tests 136 passed (136)`,
`Duration 16.56s`, exit code 0, `EBUSY` count 0. `npm run check` (eslint
plus `prettier --check .`) also passes, and `npm run build` (`tsc`,
whose `include` covers `src/**/*.ts` so the test file is typechecked
too) exits 0.

## Scope

1 file changed, 1 insertion, 1 deletion: `src/tests/acp-v2.test.ts`.

Deliberately not changed:

- No other test file, and no teardown other than this one call.
`session-hooks.test.ts` is untouched.
- No assertion, fixture, mock or production code, so the 136 tests still
check exactly what they checked before.
- No CI change. Adding a Windows job is a separate call about CI time
and runner budget, and this fix can be verified on Windows without one.

## Unproven

- I have one Windows 11 machine. On `ubuntu-latest` the teardown did not
fail here before or after the patch, and I did not run the job on Linux;
the Linux side is "no behaviour change" by inspection, not something I
measured.
- `maxRetries: 10` covers a handle that closes shortly after the process
exits. A handle held for longer than the whole retry window would still
surface EBUSY; I did not observe one.
- I ran only this file, not the full suite, so I cannot say whether
other files fail on Windows for other reasons.
2026-10-26 10:25:48 +00:00
dependabot[bot]
01b686713d ci(deps): Bump actions/create-github-app-token from 3.2.0 to 3.3.0 (#1383)
Bumps
[actions/create-github-app-token](https://github.com/actions/create-github-app-token)
from 3.2.0 to 3.3.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/create-github-app-token/releases">actions/create-github-app-token's
releases</a>.</em></p>
<blockquote>
<h2>v3.3.0</h2>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.2.0...v3.3.0">3.3.0</a>
(2026-10-12)</h2>
<h3>Features</h3>
<ul>
<li>update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)
(<a
href="a47236f79f">a47236f</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)
(<a
href="12461a3a79">12461a3</a>)</li>
<li><strong>deps:</strong> bump the production-dependencies group across
1 directory with 3 updates (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/395">#395</a>)
(<a
href="a69c19ddfd">a69c19d</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md">actions/create-github-app-token's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.2.0...v3.3.0">3.3.0</a>
(2026-10-12)</h2>
<h3>Features</h3>
<ul>
<li>update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)
(<a
href="a47236f79f">a47236f</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)
(<a
href="12461a3a79">12461a3</a>)</li>
<li><strong>deps:</strong> bump the production-dependencies group across
1 directory with 3 updates (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/395">#395</a>)
(<a
href="a69c19ddfd">a69c19d</a>)</li>
</ul>
<h2><a
href="https://github.com/actions/create-github-app-token/compare/v3.1.1...v3.2.0">3.2.0</a>
(2026-05-12)</h2>
<h3>Features</h3>
<ul>
<li>add support for enterprise-level GitHub Apps (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/263">#263</a>)
(<a
href="952a2a7073">952a2a7</a>)</li>
<li>support full repository names in <code>repositories</code> input (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/372">#372</a>)
(<a
href="85eb8dd414">85eb8dd</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> bump <code>@​actions/core</code> from 3.0.0
to 3.0.1 in the production-dependencies group (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/364">#364</a>)
(<a
href="43e5c345bf">43e5c34</a>)</li>
<li>validate private-key input (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/376">#376</a>)
(<a
href="f24bbd8964">f24bbd8</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="64ab729a62"><code>64ab729</code></a>
chore(main): release 3.3.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/402">#402</a>)</li>
<li><a
href="f3a828e4b9"><code>f3a828e</code></a>
ci: create signed dist commits in release workflow (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/404">#404</a>)</li>
<li><a
href="655017ffa7"><code>655017f</code></a>
docs: clarify when owner grants access to all repositories (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/378">#378</a>)</li>
<li><a
href="12461a3a79"><code>12461a3</code></a>
fix(deps): bump nested undici from 6.24.0 to 6.29.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/403">#403</a>)</li>
<li><a
href="a47236f79f"><code>a47236f</code></a>
feat: update permission inputs (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/399">#399</a>)</li>
<li><a
href="a69c19ddfd"><code>a69c19d</code></a>
fix(deps): bump the production-dependencies group across 1 directory
with 3 u...</li>
<li><a
href="d5cb7ae41d"><code>d5cb7ae</code></a>
build(deps): bump actions/checkout from 6 to 7 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/384">#384</a>)</li>
<li><a
href="575da866bd"><code>575da86</code></a>
build(deps): bump stefanzweifel/git-auto-commit-action from 7.1.0 to
7.2.0 in...</li>
<li><a
href="e85f677b44"><code>e85f677</code></a>
test: omit stack traces from snapshots (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/401">#401</a>)</li>
<li><a
href="9419396df4"><code>9419396</code></a>
build(deps-dev): bump c8 from 11.0.0 to 12.0.0 (<a
href="https://redirect.github.com/actions/create-github-app-token/issues/400">#400</a>)</li>
<li>Additional commits viewable in <a
href="bcd2ba4921...64ab729a62">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/create-github-app-token&package-manager=github_actions&previous-version=3.2.0&new-version=3.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

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

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

---

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

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


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-26 10:24:25 +00:00
KlausUllrich
6a33dedcbb fix: pass --settings before the auth status subcommand (#1392)
`runCliAuthProbe` builds `["auth", "status", "--json"]` and, when the
session has a settings
override, appends `--settings <json>`. `--settings` is a global option
of the Claude Code CLI;
the `auth status` subcommand only accepts `--json` and `--text` (see
`claude auth status --help`).
So every probe with an override fails with `error: unknown option
'--settings'` and an empty
stdout. The catch branch logs `claude auth status failed: …` on every
call and returns
`undefined`. For sessions that pass `_meta.claudeCode.options.settings`,
the session-creation and
per-prompt refreshes therefore never update the auth status. Only the
initialize probe, which has
no override, still works.

This moves `--settings <json>` in front of the subcommand. Without an
override the argv is
unchanged.

**Reproduce** (no credentials needed; an empty config dir answers "not
logged in"):

```sh
d=$(mktemp -d)
env -i HOME="$d" PATH="$PATH" CLAUDE_CONFIG_DIR="$d/.claude" \
  claude auth status --json --settings '{}'
# error: unknown option '--settings'
env -i HOME="$d" PATH="$PATH" CLAUDE_CONFIG_DIR="$d/.claude" \
  claude --settings '{}' auth status --json
# {"loggedIn": false, "authMethod": "none", ...}
```

The option is applied in the global position, not just parsed:
`claude --settings '{"apiKeyHelper":"/bin/echo rt-fake-key"}' auth
status --json` reports
`"loggedIn": true, "authMethod": "api_key_helper"`.

Tested with 10 native Claude Code binaries (linux-x64), each in an empty
`HOME`:

| Claude Code | `auth status --json --settings '{}'` | `--settings '{}'
auth status --json` |
|---|---|---|
| 2.1.286 | unknown option | JSON |
| 2.1.294 | unknown option | JSON |
| 2.1.295, 2.1.296, 2.1.299 | unknown option | JSON |
| 2.1.300, 2.1.301, 2.1.302 | unknown option | JSON |
| 2.1.303 (the SDK's `claudeCodeVersion`) | unknown option | JSON |
| 2.1.304 | unknown option | JSON |

The existing test in `auth-status.test.ts` pinned the old order; it now
expects `--settings`
first. Its mock and `statusCalls()` matched probes by `args[1] ===
"status"`, which a leading
global option shifts, so both now locate the subcommand instead.

`npm run build`, `format:check`, `lint` and `test:run` pass, apart from
one test that fails
identically on `main` without this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:11 +00:00
Slapoguzov Alexander
e369479f93 fix: preserve foreground fork reports until launch completes (#1431)
A foreground fork can stream a copied `tool_result` for the Agent call
that launched it. That placeholder completed the call and removed its
tracked state, so the real report arriving later was dropped. Ignore
only results whose tool ID equals the message's parent tool ID, keeping
the launching call open for its actual report.

Keep other results in the same message and discard the message-level
`tool_use_result` sidecar when its launch result was removed, so it
cannot be attributed to a remaining tool result. Regression coverage
exercises plain and Air clients, root and nested launch results, and a
mixed message containing a genuine nested Agent result.

Fixes #1389.

Validation:

- [Linux
CI](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/40217593917)
passes the full suite: 3,304 passed, 32 skipped (57 test files passed, 4
skipped). All PR checks are green.
- `npm run check` and `npm run build` pass.
- `vitest run src/tests/acp-agent.test.ts --maxWorkers=1`: 712 passed,
10 skipped. The four new regression cases fail on the original code and
pass with the fix.
- Eight actual-SDK runs against localhost fake gateways complete
successfully without model calls: plain and Air foreground forks, native
Air child sessions, nested forks with the background option omitted or
false, the SDK's fork-inside-fork rejection, a background launch
receipt, and history replay. Foreground reports arrive exactly once and
no launch-result tracking warning remains.

The local full-suite run encountered 19 failures in
`session-index.test.ts` and then stopped progressing, so it was
interrupted. A standalone recursive-file-watcher probe also emits
`EMFILE` on this machine. The successful Linux CI run above provides the
complete-suite verification.

Review dependencies (updated 26 October):

| Related proposal | Relationship to this PR |
| --- | --- |
|
[#1408](https://github.com/agentclientprotocol/claude-agent-acp/pull/1408)
— targeted session lookup | Independent. Both orders merge cleanly onto
the checked main and produce the same tree. |
|
[#1422](https://github.com/agentclientprotocol/claude-agent-acp/pull/1422)
— resumed subagents | Complementary, with no dependency on this PR. It
now conflicts with upstream main in `src/acp-agent.ts`; its author needs
to resolve that conflict, integrate the [separately shared AIR routing
patch](https://github.com/agentclientprotocol/claude-agent-acp/pull/1422#issuecomment-6401008862)
and complete its validation. |

<details>
<summary>Additional local integration validation — 25 October</summary>

A local combination of the recorded main, #1408, #1431, #1422 and the
already-reviewed routing patch passed 199 focused
foreground/resume/native-subagent checks (647 other cases filtered or
skipped), TypeScript build, and two replays of previously captured SDK
frames for AIR and standard subagents. The two captured-frame replays
were independently repeated. No new model calls were made.

The combination is a private test tree, not a published change. It does
not replace full CI for #1422 or live Air acceptance. The routing patch
needed three-way application against that recorded main because its
surrounding source had moved; it applied cleanly that way. The
current-main conflict in #1422 means this historical result does not
establish today’s combined compatibility.

Checked main `739dde3`, #1408 `e3b3aa4`, #1431 `8f96f46`, #1422
`dd3bf2c`. The two orders of #1408 and #1431 produce tree `1716d7e`; the
full local test tree is `61c0447`. Refresh this evidence if these inputs
change.

</details>

Co-authored-by: Codex <codex@openai.com>
2026-10-26 11:20:15 +01:00
dependabot[bot]
c26e640915 fix: bump body-parser from 2.3.0 to 2.4.0 (#1436)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.3.0
to 2.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/body-parser/releases">body-parser's
releases</a>.</em></p>
<blockquote>
<h2>v2.4.0</h2>
<h2>Important: Security</h2>
<ul>
<li>Security fix for <a
href="https://www.cve.org/CVERecord?id=CVE-2026-111181">CVE-2026-111181</a>
(<a
href="https://github.com/expressjs/body-parser/security/advisories/GHSA-27r3-jxmh-p828">GHSA-27r3-jxmh-p828</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump actions/checkout from 6.0.3 to 7.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/737">expressjs/body-parser#737</a></li>
<li>chore: group github actions updates by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/743">expressjs/body-parser#743</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/745">expressjs/body-parser#745</a></li>
<li>test: fix 'presist' typo in test descriptions by <a
href="https://github.com/maxtaran2010"><code>@​maxtaran2010</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/749">expressjs/body-parser#749</a></li>
<li>docs: include security fix in 2.3.0 changes by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/748">expressjs/body-parser#748</a></li>
<li>fix: avoid mutating read options when reading body by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/742">expressjs/body-parser#742</a></li>
<li>refactor: move getCharset helper to only usage by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/707">expressjs/body-parser#707</a></li>
<li>build(deps): bump the github-actions group with 7 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/753">expressjs/body-parser#753</a></li>
<li>refactor: switch to neostandard by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/751">expressjs/body-parser#751</a></li>
<li>perf(json): avoid first-char regex for direct JSON bodies by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/744">expressjs/body-parser#744</a></li>
<li>docs: fix entity verify error description by <a
href="https://github.com/Rawal27"><code>@​Rawal27</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/755">expressjs/body-parser#755</a></li>
<li>feat: zstandard support by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/586">expressjs/body-parser#586</a></li>
<li>test: remove unused experimentalZstd option by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/757">expressjs/body-parser#757</a></li>
<li>ci: run tests on windows and macos matrix by <a
href="https://github.com/kilisamemarisaaa"><code>@​kilisamemarisaaa</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/759">expressjs/body-parser#759</a></li>
<li>deps: qs@^6.16.0 by <a
href="https://github.com/krzysdz"><code>@​krzysdz</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/762">expressjs/body-parser#762</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/763">expressjs/body-parser#763</a></li>
<li>ci: trigger npm publish on release published and set dist-tag by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/768">expressjs/body-parser#768</a></li>
<li>build(deps): bump the github-actions group with 3 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/771">expressjs/body-parser#771</a></li>
<li>fix(test): pin supertest to 7.3.0 to avoid 7.3.1 regression by <a
href="https://github.com/saadkhantareen"><code>@​saadkhantareen</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/773">expressjs/body-parser#773</a></li>
<li>perf: inline content stream setup by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/750">expressjs/body-parser#750</a></li>
<li>docs: note that req.body may be undefined in v2 by <a
href="https://github.com/Hashim1999164"><code>@​Hashim1999164</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/752">expressjs/body-parser#752</a></li>
<li>docs: fix strict option description by <a
href="https://github.com/Rawal27"><code>@​Rawal27</code></a> in <a
href="https://redirect.github.com/expressjs/body-parser/pull/756">expressjs/body-parser#756</a></li>
<li>docs: fix depth error description by <a
href="https://github.com/johnnyhuirilef"><code>@​johnnyhuirilef</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/776">expressjs/body-parser#776</a></li>
<li>chore: update dependencies by <a
href="https://github.com/Phillip9587"><code>@​Phillip9587</code></a> in
<a
href="https://redirect.github.com/expressjs/body-parser/pull/778">expressjs/body-parser#778</a></li>
<li>Release: 2.4.0 by <a
href="https://github.com/UlisesGascon"><code>@​UlisesGascon</code></a>
in <a
href="https://redirect.github.com/expressjs/body-parser/pull/777">expressjs/body-parser#777</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/maxtaran2010"><code>@​maxtaran2010</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/749">expressjs/body-parser#749</a></li>
<li><a href="https://github.com/Rawal27"><code>@​Rawal27</code></a> made
their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/755">expressjs/body-parser#755</a></li>
<li><a
href="https://github.com/kilisamemarisaaa"><code>@​kilisamemarisaaa</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/759">expressjs/body-parser#759</a></li>
<li><a
href="https://github.com/saadkhantareen"><code>@​saadkhantareen</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/773">expressjs/body-parser#773</a></li>
<li><a
href="https://github.com/Hashim1999164"><code>@​Hashim1999164</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/752">expressjs/body-parser#752</a></li>
<li><a
href="https://github.com/johnnyhuirilef"><code>@​johnnyhuirilef</code></a>
made their first contribution in <a
href="https://redirect.github.com/expressjs/body-parser/pull/776">expressjs/body-parser#776</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0">https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/expressjs/body-parser/blob/master/HISTORY.md">body-parser's
changelog</a>.</em></p>
<blockquote>
<h1>2.4.0</h1>
<ul>
<li>Security fix for <a
href="https://github.com/expressjs/body-parser/security/advisories/GHSA-27r3-jxmh-p828">GHSA-27r3-jxmh-p828</a>
(CVE-2026-111181)</li>
<li>feat: add <code>zstd</code> content-encoding support (<a
href="https://redirect.github.com/expressjs/body-parser/issues/586">#586</a>)
<ul>
<li>Requires Node.js <code>^22.15.0</code> or <code>&gt;=23.8.0</code>;
older versions keep returning <code>415</code> for <code>zstd</code>
encoded bodies</li>
</ul>
</li>
<li>fix: avoid mutating the parser options and the request object while
reading the body (<a
href="https://redirect.github.com/expressjs/body-parser/issues/742">#742</a>,
<a
href="https://redirect.github.com/expressjs/body-parser/issues/750">#750</a>)</li>
<li>perf(json): avoid first-char regex for direct JSON bodies (<a
href="https://redirect.github.com/expressjs/body-parser/issues/744">#744</a>)</li>
<li>deps:
<ul>
<li>content-type@^2.1.0</li>
<li>iconv-lite^0.7.3</li>
<li>qs@^6.16.1</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="b4744d739e"><code>b4744d7</code></a>
2.4.0 (<a
href="https://redirect.github.com/expressjs/body-parser/issues/777">#777</a>)</li>
<li><a
href="f7075eae3a"><code>f7075ea</code></a>
fix: prevent process crash from trailing bytes after a compressed
body</li>
<li><a
href="185ff1d555"><code>185ff1d</code></a>
chore: update dependencies (<a
href="https://redirect.github.com/expressjs/body-parser/issues/778">#778</a>)</li>
<li><a
href="73713f76c4"><code>73713f7</code></a>
docs: fix depth error description (<a
href="https://redirect.github.com/expressjs/body-parser/issues/776">#776</a>)</li>
<li><a
href="2dcc826c6d"><code>2dcc826</code></a>
docs: improve readability (<a
href="https://redirect.github.com/expressjs/body-parser/issues/756">#756</a>)</li>
<li><a
href="402dbdc6f1"><code>402dbdc</code></a>
docs: note that req.body may be undefined in v2 (<a
href="https://redirect.github.com/expressjs/body-parser/issues/752">#752</a>)</li>
<li><a
href="9883a1eeb4"><code>9883a1e</code></a>
perf: inline content stream setup (<a
href="https://redirect.github.com/expressjs/body-parser/issues/750">#750</a>)</li>
<li><a
href="02101da415"><code>02101da</code></a>
fix(test): pin supertest to 7.3.0 to avoid 7.3.1 regression (<a
href="https://redirect.github.com/expressjs/body-parser/issues/773">#773</a>)</li>
<li><a
href="822a0fd462"><code>822a0fd</code></a>
build(deps): bump the github-actions group with 3 updates (<a
href="https://redirect.github.com/expressjs/body-parser/issues/771">#771</a>)</li>
<li><a
href="aa9889bc35"><code>aa9889b</code></a>
ci: trigger npm publish on release published and set dist-tag (<a
href="https://redirect.github.com/expressjs/body-parser/issues/768">#768</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for body-parser since your current version.</p>
</details>
<br />


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

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

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

---

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

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/agentclientprotocol/claude-agent-acp/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 11:19:09 +01:00
23 changed files with 1329 additions and 52 deletions

View File

@@ -36,7 +36,7 @@ jobs:
# Generating a GitHub token, so that PRs and tags created by
# the release-please-action can trigger actions workflows.
- name: Generate GitHub token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: generate-token
with:
# GitHub App ID secret name
@@ -256,7 +256,7 @@ jobs:
exit 1
- name: Generate token scoped to the registry repo
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
id: registry-token
with:
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}

View File

@@ -1,3 +1,3 @@
{
".": "0.96.0"
".": "0.97.0"
}

View File

@@ -1,5 +1,23 @@
# Changelog
## [0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0) (2026-10-26)
### Features
* add ultracode session config option ([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438)) ([de96cef](https://github.com/agentclientprotocol/claude-agent-acp/commit/de96cef382d8daf27301f1f90ae4b5e7f665f73a))
### Bug Fixes
* bump body-parser from 2.3.0 to 2.4.0 ([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436)) ([c26e640](https://github.com/agentclientprotocol/claude-agent-acp/commit/c26e640915739dc7a3cd0664f8f5879098e0fd83))
* distinguish model options with duplicate labels ([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286)) ([434be13](https://github.com/agentclientprotocol/claude-agent-acp/commit/434be13a33141af8655c6f7c30f574841bf2279c))
* keep the usage-limit error when it arrives after compaction ([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377)) ([2a1525e](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a1525e57cd89246d1a50993fd6ba335c4e634ca))
* pass --settings before the auth status subcommand ([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392)) ([6a33ded](https://github.com/agentclientprotocol/claude-agent-acp/commit/6a33dedcbb90e7bf9db91207f0edbf19ca6d705e))
* preserve foreground fork reports until launch completes ([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431)) ([e369479](https://github.com/agentclientprotocol/claude-agent-acp/commit/e369479f93b34f84a11103323d097c44df42e02e))
* settle advisor tool results before completing turns ([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247)) ([bed795d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bed795d261620e6d5113e427a7196d94e66e7034))
* settle live steers folded into autonomous cycles ([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185)) ([a4957d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4957d17c1e1a925ba3b72b29c5dcde4faabbe17))
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)

View File

@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
- Interactive (and background) terminals
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
- Client MCP servers
- An Ultracode session config option, which turns on Claude Code's dynamic workflows for every task. It appears when dynamic workflows are enabled and the model supports ultracode (any model with `xhigh` effort)
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)

View File

@@ -707,7 +707,7 @@ A non-empty plan also gets one clear-context choice with the same priority.
After that choice, the adapter stops the old private Claude query and hides that internal rejection.
It starts a new private query under the same ACP session.
It continues the turn with `Implement the following plan:` and the complete plan.
The mode, model, agent, effort, Fast mode, and turn usage stay the same.
The mode, model, agent, effort, Fast mode, ultracode, and turn usage stay the same.
The SDK cannot apply `allowedPrompts` again, so those hints do not survive the handoff.
The adapter does not allow a callback only because the session mode is `bypassPermissions`.

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.96.0",
"version": "0.97.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.96.0",
"version": "0.97.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.0.2",
@@ -1250,19 +1250,19 @@
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"iconv-lite": "^0.7.3",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.96.0",
"version": "0.97.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -365,6 +365,15 @@ import {
mergeEffortSettings,
settingsEffortForModel,
} from "./session-effort.js";
import {
createUltracodeConfigOption,
readUltracodeSettings,
resolveUltracodeEnabled,
ULTRACODE_CONFIG_ID,
ultracodeSupported,
ultracodeWorkflowsEnabled,
type UltracodeOptionState,
} from "./session-ultracode.js";
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
export {
@@ -1191,6 +1200,15 @@ export type Session = {
* user's intent so it persists across model switches; the Fast mode config
* option is only surfaced while the selected model supports it. */
fastModeEnabled: boolean;
/** Whether ultracode is turned on for this session. Tracked as the user's
* intent, like `fastModeEnabled`: the CLI keeps the request across model
* switches and applies it whenever the model supports ultracode, while the
* config option is only surfaced on supporting models. */
ultracodeEnabled: boolean;
/** Whether dynamic workflows are enabled, learned from the CLI's
* `get_settings` at session start; undefined when unknown. Combined with
* the model's support to decide whether to show the ultracode option. */
ultracodeWorkflowsEnabled?: boolean;
/** The non-default effort the user picked through the ACP picker this
* session. A pin lives at the SDK's flag layer, which overrides the CLI's
* persisted effort (including the per-model `modelSettings` entries), so it
@@ -2801,6 +2819,7 @@ export class ClaudeAcpAgent {
return session;
},
applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
applyUltracode: (session, enabled) => this.applyUltracode(session, enabled),
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
supportsPlanOperations: () => this.clientCapabilities?.plan != null,
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
@@ -3430,7 +3449,9 @@ export class ClaudeAcpAgent {
try {
const cliPath = await claudeCliPath();
const args = ["auth", "status", "--json"];
if (context?.override) args.push("--settings", JSON.stringify(context.override));
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
// it with "unknown option '--settings'", so it has to come first.
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
({ stdout } = await execFileAsync(cliPath, args, {
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
cwd: context?.manager.getCwd() ?? process.cwd(),
@@ -5087,6 +5108,8 @@ export class ClaudeAcpAgent {
* marked as a background result, but it lists the uuids of the prompts it
* answered.
*
* A steer belongs to the live active turn even though it has no queued
* Turn of its own. Its stamped result can answer that turn too.
* A turn held open for its background subagents already has its result,
* so a result that names it does not answer it. */
const answersPendingPrompt = (message: {
@@ -5102,8 +5125,15 @@ export class ClaudeAcpAgent {
answeredPromptUuids = [message.user_message_uuid];
}
const activeTurn = session.activeTurn;
const liveActiveTurn =
activeTurn && !activeTurn.settled && !isHeldOpen(activeTurn) ? activeTurn : undefined;
for (const promptUuid of answeredPromptUuids) {
const turn = findUnsettledTurn(promptUuid);
// Keep queued prompt matching, including folded sends with no echo.
// Steers qualify only through their live active owner.
const turn =
findUnsettledTurn(promptUuid) ??
(liveActiveTurn?.steeredUuids?.has(promptUuid) ? liveActiveTurn : undefined);
if (turn === undefined) {
// Not a prompt of this session, or it was answered already.
continue;
@@ -8045,6 +8075,13 @@ export class ClaudeAcpAgent {
message.message.model === "<synthetic>" &&
compaction.hasDeliveredOutput
) {
if (isSyntheticUsageLimitMessage(message.message)) {
lastAssistantWasUsageLimit = true;
lastAssistantFailureTitle = assistantMessageText(message.message);
if (message.error) {
lastAssistantError = message.error;
}
}
break;
}
@@ -8200,6 +8237,7 @@ export class ClaudeAcpAgent {
}
let content: typeof message.message.content;
let toolUseResult = message.type === "user" ? message.tool_use_result : undefined;
if (
message.type === "assistant" &&
(message.parent_tool_use_id === null || forwardsSubagentText())
@@ -8220,6 +8258,20 @@ export class ClaudeAcpAgent {
(item) => item.type !== "text" && item.type !== "thinking",
);
streamedBlocksOf(message.parent_tool_use_id).length = 0;
} else if (
message.parent_tool_use_id !== null &&
Array.isArray(message.message.content)
) {
// A foreground fork inherits a placeholder result for its own
// launch. Only the launching side can complete that Agent call
// with the fork's actual report.
const launchId = message.parent_tool_use_id;
content = message.message.content.filter(
(block) => block.type !== "tool_result" || block.tool_use_id !== launchId,
);
// The message-level sidecar cannot describe a remaining result
// after its launch result was removed.
if (content.length !== message.message.content.length) toolUseResult = undefined;
} else {
content = message.message.content;
}
@@ -8237,7 +8289,7 @@ export class ClaudeAcpAgent {
);
const backgroundBashTask = backgroundBashTaskFromToolResult(
content,
message.tool_use_result,
toolUseResult,
session.toolUseCache,
);
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
@@ -8266,7 +8318,7 @@ export class ClaudeAcpAgent {
asyncTasks,
backgroundBashTask,
);
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
const resumedAgentId = resumedNativeSubagentId(toolUseResult);
if (resumedAgentId) {
const resume = sendMessageResume(
session.toolUseCache,
@@ -8307,7 +8359,7 @@ export class ClaudeAcpAgent {
emittedToolCalls: session.emittedToolCalls,
toolCallFields: toolCallFieldsOf(session),
messageId: messageIdForGrouping(message),
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
toolUseResult,
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
// type, hence the cast. Validated by parseToolResultMeta.
toolResultMeta:
@@ -9170,13 +9222,18 @@ export class ClaudeAcpAgent {
throw new Error(`Unknown config option: ${params.configId}`);
}
// Fast mode carries a boolean value (for Clients that opted into boolean
// config options) or the "on"/"off" select fallback, so it bypasses the
// Fast mode and ultracode carry a boolean value (for Clients that opted
// into boolean config options) or the "on"/"off" select fallback, so they
// bypass the
// string-only validation the select-style options below rely on.
if (params.configId === FAST_MODE_CONFIG_ID) {
await this.applyFastMode(session, resolveFastModeEnabled(params));
return { configOptions: session.configOptions };
}
if (params.configId === ULTRACODE_CONFIG_ID) {
await this.applyUltracode(session, resolveUltracodeEnabled(params));
return { configOptions: session.configOptions };
}
if (typeof params.value !== "string") {
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
@@ -11005,6 +11062,13 @@ export class ClaudeAcpAgent {
{
useRecommendedValue,
},
{
// Like Fast mode, the toggle hides on models without ultracode
// support and comes back with the retained intent on one that has it.
supported: ultracodeSupported(session.ultracodeWorkflowsEnabled, newModelInfo),
enabled: session.ultracodeEnabled,
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
},
);
// Opted-in clients apply the concrete displayed effort on every switch,
@@ -11031,10 +11095,7 @@ export class ClaudeAcpAgent {
// deliberately apply their concrete displayed effort.
effortFlagSettings(
useRecommendedValue ? newEffort : undefined,
mergeEffortSettings(
session.settingsManager.getSettings(),
session.effortSettingsOverride,
),
session.ultracodeEnabled,
),
);
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
@@ -11103,15 +11164,7 @@ export class ClaudeAcpAgent {
} else if (configId === EFFORT_CONFIG_ID) {
// Apply first so a rejected control request cannot leave the displayed
// value ahead of the SDK flag layer.
await session.query.applyFlagSettings(
effortFlagSettings(
value,
mergeEffortSettings(
session.settingsManager.getSettings(),
session.effortSettingsOverride,
),
),
);
await session.query.applyFlagSettings(effortFlagSettings(value, session.ultracodeEnabled));
session.configOptions = session.configOptions.map((o) =>
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
);
@@ -11193,6 +11246,22 @@ export class ClaudeAcpAgent {
this.refreshFastModeOption(session, enabled);
}
/** Turn ultracode on or off for a session. Sends an explicit boolean, which
* leaves the effort level alone (`null` would reset the flag layer instead).
* The SDK flag is applied first so a rejected request leaves the session
* state and option untouched. */
private async applyUltracode(session: Session, enabled: boolean): Promise<void> {
await session.query.applyFlagSettings({ ultracode: enabled });
session.ultracodeEnabled = enabled;
const refreshed = createUltracodeConfigOption(
enabled,
clientSupportsBooleanConfigOptions(this.clientCapabilities),
);
session.configOptions = session.configOptions.map((o) =>
o.id === ULTRACODE_CONFIG_ID ? refreshed : o,
);
}
/** Reconcile the session's Fast mode toggle with an SDK-reported
* `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
* The SDK can flip fast mode independently of the user — e.g. back to `on`
@@ -12031,6 +12100,21 @@ export class ClaudeAcpAgent {
settingsManager.getSettings(),
configuredSettingsObject,
);
// The CLI reports whether ultracode can run (workflows enabled and a
// supporting model) and whether the session already requests it (an
// `ultracode: true` setting). Without a report, fall back to settings.
const ultracodeReport = await readUltracodeSettings(q);
timing.phase("ultracode");
const ultracodeEnabled = ultracodeReport?.requested ?? effortSettings.ultracode === true;
const workflowsEnabled = ultracodeWorkflowsEnabled(ultracodeReport, currentModelInfo);
const ultracode: UltracodeOptionState = {
supported:
ultracodeReport?.available ?? ultracodeSupported(workflowsEnabled, currentModelInfo),
enabled: ultracodeEnabled,
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
};
const configOptions = buildConfigOptions(
modes,
models,
@@ -12042,10 +12126,11 @@ export class ClaudeAcpAgent {
{
useRecommendedValue,
},
ultracode,
);
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, effortSettings));
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, ultracodeEnabled));
}
// Seed the context window without awaited IPC. The cached authoritative
// window from a prior turn wins (`result.modelUsage`, cross-session),
@@ -12113,6 +12198,8 @@ export class ClaudeAcpAgent {
currentAgent,
fastModeEnabled,
fastModeDisabledReason,
ultracodeEnabled,
ultracodeWorkflowsEnabled: workflowsEnabled,
abortController,
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
forwardSubagentText,
@@ -12942,6 +13029,7 @@ export function buildConfigOptions(
currentAgent: string = DEFAULT_AGENT_ID,
fastMode?: FastModeOptionState,
presentation?: ConfigOptionPresentation,
ultracode?: UltracodeOptionState,
): SessionConfigOption[] {
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
// A gateway that lists none of the SDK's models leaves no model to offer, so
@@ -12972,6 +13060,12 @@ export function buildConfigOptions(
);
}
// Surface the ultracode toggle only while the CLI can run it: dynamic
// workflows are enabled and the current model supports it.
if (ultracode?.supported) {
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
}
// Only surface the Agent picker when there's a real choice — i.e. the user
// has configured at least one custom agent (built-ins are filtered out in
// discoverCustomAgents). With none configured, "Default" would be the only
@@ -13614,6 +13708,38 @@ export function toAcpNotifications(
break;
}
case "advisor_tool_result": {
// Advisor results arrive in assistant content, including encrypted advice.
// Resolve the server tool through the normal result path without rendering
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
const result = chunk.content;
unregisterHookCallback(chunk.tool_use_id);
output.push(
...toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: chunk.tool_use_id,
is_error: result.type === "advisor_tool_result_error",
content:
result.type === "advisor_result"
? result.text
: result.type === "advisor_redacted_result"
? "Advisor guidance applied server-side."
: `Advisor error: ${result.error_code}`,
},
],
role,
sessionId,
toolUseCache,
client,
logger,
options,
),
);
break;
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
@@ -13783,7 +13909,6 @@ export function toAcpNotifications(
case "container_upload":
case "compaction":
case "compaction_delta":
case "advisor_tool_result":
case "fallback":
case "mcp_tool_listing":
break;

View File

@@ -39,6 +39,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
configOptions: SessionConfigOption[];
currentAgent: string;
fastModeEnabled: boolean;
ultracodeEnabled: boolean;
effortPinnedLevel?: string;
activeTurn?: Turn | null;
turnQueue?: Turn[];
@@ -59,6 +60,7 @@ export type ClearContextCoordinatorHost<
options: { publicSessionId: string; permissionMode: PermissionMode },
): Promise<Session>;
applyFastMode(session: Session, enabled: boolean): Promise<void>;
applyUltracode(session: Session, enabled: boolean): Promise<void>;
publishSessionState(
sessionId: string,
mode: PermissionMode,
@@ -146,6 +148,16 @@ export async function continuePlanInFreshContext<
}
assertRestartActive(freshSession);
}
// The fresh query seeds ultracode from settings only, so a toggle made
// during the old session would otherwise be lost.
if (oldSession.ultracodeEnabled !== freshSession.ultracodeEnabled) {
try {
await host.applyUltracode(freshSession, oldSession.ultracodeEnabled);
} catch (error) {
host.logError("Failed to restore ultracode after clearing context:", error);
}
assertRestartActive(freshSession);
}
oldSession.activeTurn = null;
oldSession.turnQueue = [];

View File

@@ -2,3 +2,4 @@
export const DEFAULT_MODEL_ID = "default";
export const DEFAULT_AGENT_ID = "default";
export const EFFORT_CONFIG_ID = "effort";
export const ULTRACODE_CONFIG_ID = "ultracode";

View File

@@ -14,14 +14,15 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
// ultracode on at any level, so carry a settings-requested ultracode along
// with every effort apply.
// ultracode on at any level, so send `ultracode: true` with every effort apply
// while the session has ultracode on. When it is off, the key is omitted: an
// effort change then leaves ultracode off, as it already was.
export function effortFlagSettings(
value: string | undefined,
settings: Settings,
ultracode: boolean,
): { effortLevel: EffortLevel | null; ultracode?: true } {
const effortLevel = toSdkEffortLevel(value);
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
}
function canonicalizeModelSettingsKey(value: string): string {

View File

@@ -595,12 +595,20 @@ export async function getAvailableModels(
for (const name of displayNames) {
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
}
const names = models.map((model, index) =>
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
);
const nameCounts = new Map<string, number>();
for (const name of names) {
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
}
return {
availableModels: models.map((model, index) => ({
modelId: model.value,
name:
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
// Allowlisted aliases can inherit the same SDK label. Keep every ID
// selectable, but distinguish the rows for clients that show only names.
name: nameCounts.get(names[index]) === 1 ? names[index] : `${names[index]} (${model.value})`,
description: model.description,
})),
currentModelId: currentModel.value,

136
src/session-ultracode.ts Normal file
View File

@@ -0,0 +1,136 @@
import type {
SessionConfigOption,
SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
export { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
/** Select-fallback values for clients without boolean config options. */
export const ULTRACODE_ON = "on";
export const ULTRACODE_OFF = "off";
const ULTRACODE_DESCRIPTION = "Dynamic workflows on every task, at any effort level";
/** Ultracode state from the CLI's `get_settings` control response
* (`applied.ultracodeAvailable` / `applied.ultracodeRequested`). Each field is
* undefined when the CLI does not report it. */
export type UltracodeSettingsReport = {
/** Workflows are enabled and the current model supports ultracode. */
available?: boolean;
/** Ultracode is turned on for the session, whether or not it can take
* effect on the current model. */
requested?: boolean;
};
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
// SDK's type declarations as of 0.3.310.
type GetSettingsQuery = Query & {
getSettings?: () => Promise<{
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
}>;
};
/** Ask the CLI whether ultracode is available and requested for the session.
* Returns undefined when the SDK has no `getSettings` or the request fails;
* callers then fall back to {@link modelSupportsUltracode}. */
export async function readUltracodeSettings(
query: Query,
): Promise<UltracodeSettingsReport | undefined> {
const getSettings = (query as GetSettingsQuery).getSettings;
if (typeof getSettings !== "function") return undefined;
try {
const applied = (await getSettings.call(query))?.applied;
return {
available:
typeof applied?.ultracodeAvailable === "boolean" ? applied.ultracodeAvailable : undefined,
requested:
typeof applied?.ultracodeRequested === "boolean" ? applied.ultracodeRequested : undefined,
};
} catch {
return undefined;
}
}
/** The CLI supports ultracode on exactly the models that support `xhigh`
* effort (both use the same capability check), and `supportedEffortLevels`
* lists `xhigh` only for those models. E.g. Opus 4.7+, Sonnet 5+ and Haiku
* 5.5 qualify; Opus 4.6 and Haiku 4.5 do not. */
export function modelSupportsUltracode(modelInfo: ModelInfo | undefined): boolean {
return (
modelInfo?.supportsEffort === true &&
modelInfo.supportedEffortLevels?.includes("xhigh") === true
);
}
/** Whether dynamic workflows are enabled for the session, inferred from the
* CLI's `ultracodeAvailable` for the model the session started on. This is
* the part of availability that does not change with the model: the plan
* default (off on Pro), `enableWorkflows`/`disableWorkflows` and org policy.
* Undefined when the report can't tell, e.g. `ultracodeAvailable: false` on a
* model that lacks ultracode support anyway. */
export function ultracodeWorkflowsEnabled(
report: UltracodeSettingsReport | undefined,
modelInfo: ModelInfo | undefined,
): boolean | undefined {
if (report?.available === true) return true;
if (report?.available === false && modelSupportsUltracode(modelInfo)) return false;
return undefined;
}
/** Whether to offer the ultracode toggle for `modelInfo`. An unknown workflows
* state shows the toggle: the CLI keeps an unavailable request without
* error, so the toggle is merely inert there. */
export function ultracodeSupported(
workflowsEnabled: boolean | undefined,
modelInfo: ModelInfo | undefined,
): boolean {
return workflowsEnabled !== false && modelSupportsUltracode(modelInfo);
}
/** Ultracode state threaded into `buildConfigOptions`. The option is only
* surfaced while `supported`. */
export type UltracodeOptionState = {
supported: boolean;
enabled: boolean;
/** Whether the client opted into boolean config options. */
useBooleanOption: boolean;
};
/** Build the ultracode toggle: a native boolean for clients that opted into
* boolean config options, otherwise an "on"/"off" select. */
export function createUltracodeConfigOption(
enabled: boolean,
useBooleanOption: boolean,
): SessionConfigOption {
const base = {
id: ULTRACODE_CONFIG_ID,
name: "Ultracode",
description: ULTRACODE_DESCRIPTION,
category: "model_config",
} as const;
if (useBooleanOption) {
return { ...base, type: "boolean", currentValue: enabled };
}
return {
...base,
type: "select",
currentValue: enabled ? ULTRACODE_ON : ULTRACODE_OFF,
options: [
{ value: ULTRACODE_ON, name: "On" },
{ value: ULTRACODE_OFF, name: "Off" },
],
};
}
/** Read the requested ultracode value from a `session/set_config_option`
* request: a boolean, or the "on"/"off" select-fallback strings. */
export function resolveUltracodeEnabled(params: SetSessionConfigOptionRequest): boolean {
const value = params.value;
if (typeof value === "boolean") return value;
if (value === ULTRACODE_ON) return true;
if (value === ULTRACODE_OFF) return false;
throw new Error(`Invalid value for config option ${ULTRACODE_CONFIG_ID}: ${value}`);
}

View File

@@ -12631,6 +12631,56 @@ describe("stop reason propagation", () => {
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
});
it.each(
[false, true].flatMap((typedFailures) =>
[undefined, "authentication_failed" as const].map((staleError) => ({
typedFailures,
staleError,
})),
),
)(
"preserves a usage-limit failure after compaction (typed: $typedFailures, stale error: $staleError)",
async ({ typedFailures, staleError }) => {
const agent = createMockAgent();
if (typedFailures) await initializeClient(agent, airSessionFailureCapabilities);
const staleFrame = createAssistantError(staleError, "stale compaction output");
staleFrame.message.model = "<synthetic>";
injectSession(agent, [
{
type: "system",
subtype: "status",
status: null,
compact_result: "success",
uuid: "compact-completed",
session_id: "test-session",
},
createUsageLimitAssistantError(),
staleFrame,
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "private provider detail",
}),
]);
const prompt = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
if (typedFailures) {
expect(sessionFailureFromResponse(await prompt)).toMatchObject({
category: "limit",
actions: [],
title:
"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
});
} else {
await expect(prompt).rejects.toMatchObject({ data: { errorKind: "rate_limit" } });
}
},
);
it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => {
const agent = createMockAgent();
injectSession(agent, [
@@ -13928,6 +13978,7 @@ describe("session/close", () => {
agents: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
abortController: new AbortController(),
emitRawSDKMessages: false,
forwardSubagentText: false,
@@ -14049,6 +14100,7 @@ describe("session/delete", () => {
agents: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
abortController: new AbortController(),
emitRawSDKMessages: false,
forwardSubagentText: false,
@@ -14208,6 +14260,7 @@ describe("getOrCreateSession param change detection", () => {
agents: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
abortController: new AbortController(),
emitRawSDKMessages: false,
forwardSubagentText: false,
@@ -15964,6 +16017,132 @@ describe("usage_update computation", () => {
});
});
describe("foreground fork tool results", () => {
it.each([
["plain", {}, null],
["AIR", AIR_CLIENT_CAPABILITIES, null],
["plain nested", {}, "toolu_outer"],
["AIR nested", AIR_CLIENT_CAPABILITIES, "toolu_outer"],
] as const)(
"waits for the launching side's result for a %s client",
async (_, capabilities, parent) => {
const updates: SessionNotification[] = [];
const logger = { log: vi.fn(), error: vi.fn() };
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
logger,
);
await initializeClient(agent, capabilities);
const input = new Pushable<any>();
const assistant = (content: unknown[], parentToolUseId: string | null) => ({
type: "assistant",
parent_tool_use_id: parentToolUseId,
uuid: randomUUID(),
session_id: "test-session",
message: {
id: randomUUID(),
role: "assistant",
model: "claude-sonnet-4-6",
content,
usage: successfulResultMessage().usage,
},
});
const user = (content: unknown[], parentToolUseId: string | null) => ({
type: "user",
parent_tool_use_id: parentToolUseId,
uuid: randomUUID(),
session_id: "test-session",
message: { role: "user", content },
});
const toolResult = (id: string, text: string) => ({
type: "tool_result",
tool_use_id: id,
content: [{ type: "text", text }],
});
async function* messages() {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield assistant(
[
{
type: "tool_use",
id: "toolu_fork",
name: "Agent",
input: {
subagent_type: "fork",
run_in_background: false,
prompt: "Inspect the parser",
},
},
],
parent,
);
// The fork inherits a result for its own launch. It cannot complete
// that call: the launching side sends the actual report later.
yield user([toolResult("toolu_fork", "Fork started — that fork is you.")], "toolu_fork");
yield assistant(
[
{
type: "tool_use",
id: "toolu_child",
name: "Agent",
input: { subagent_type: "general-purpose", prompt: "Read the parser" },
},
],
"toolu_fork",
);
// Keep results of tools the fork itself called, even in a message
// that also carries the copied launch result.
yield {
...user(
[
toolResult("toolu_fork", "Fork started — processing in background"),
toolResult("toolu_child", "child report"),
],
"toolu_fork",
),
// Filtering must not attribute the inherited launch sidecar to
// the one result that remains in this message.
tool_use_result: {
status: "completed",
content: [{ type: "text", text: "inherited launch report" }],
},
};
yield user([toolResult("toolu_fork", "fork report")], parent);
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(messages()), input });
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Inspect it" }] }),
).resolves.toMatchObject({ stopReason: "end_turn" });
const completed = updates.flatMap(({ update }) =>
update.sessionUpdate === "tool_call_update" && update.status === "completed"
? [{ id: update.toolCallId, content: update.content, rawOutput: update.rawOutput }]
: [],
);
expect(completed.map(({ id }) => id)).toEqual(["toolu_child", "toolu_fork"]);
expect(completed.map(({ content }) => content)).toEqual([
[{ type: "content", content: { type: "text", text: "child report" } }],
[{ type: "content", content: { type: "text", text: "fork report" } }],
]);
if (!("_meta" in capabilities)) {
expect(completed.map(({ rawOutput }) => rawOutput)).toEqual([
[{ type: "text", text: "child report" }],
[{ type: "text", text: "fork report" }],
]);
}
expect(logger.error.mock.calls.flat().join("\n")).not.toContain("wasn't tracked");
},
);
});
describe("assembled assistant text fallback", () => {
const ZERO_USAGE = {
input_tokens: 0,
@@ -18536,6 +18715,7 @@ describe("post-error recovery", () => {
agents: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
abortController: new AbortController(),
emitRawSDKMessages: false,
forwardSubagentText: false,
@@ -26892,6 +27072,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
agents: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
abortController: new AbortController(),
emitRawSDKMessages: false,
forwardSubagentText: false,
@@ -28799,6 +28980,7 @@ describe("agent selection config option", () => {
agents,
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
abortController: new AbortController(),
emitRawSDKMessages: false,
forwardSubagentText: false,

View File

@@ -153,7 +153,7 @@ afterEach(async () => {
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
execFileSpy.mockReset();
vi.unstubAllEnvs();
await rm(tempDir, { recursive: true, force: true });
await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
});
/** Connects a fresh router to a client, and returns the client end of the stream. */

View File

@@ -22,6 +22,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
* Tests set `execFileResults` per invoked subcommand. */
const execFileSpy = vi.hoisted(() => vi.fn());
/** `auth status` probes, wherever global options such as `--settings` put the subcommand. */
function isStatusProbe(args: string[]): boolean {
const auth = args.indexOf("auth");
return auth >= 0 && args[auth + 1] === "status";
}
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
@@ -409,7 +414,7 @@ describe("auth status over ACP", () => {
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
if (args[1] === "status") {
if (isStatusProbe(args)) {
const stdout = statusStdout;
const fails = statusFails;
const answer = () => {
@@ -451,7 +456,7 @@ describe("auth status over ACP", () => {
});
function statusCalls() {
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
return execFileSpy.mock.calls.filter((call) => isStatusProbe(call[1] as string[]));
}
function updates() {
@@ -542,11 +547,11 @@ describe("auth status over ACP", () => {
env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" },
});
expect(statusCalls().at(-1)?.[1]).toEqual([
"--settings",
JSON.stringify({ apiKeyHelper: "central proxy start" }),
"auth",
"status",
"--json",
"--settings",
JSON.stringify({ apiKeyHelper: "central proxy start" }),
]);
await promptOnce(sessionId);
await flushStatus();

View File

@@ -31,6 +31,7 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
configOptions: [],
currentAgent: "default",
fastModeEnabled: false,
ultracodeEnabled: false,
input: { push: vi.fn() },
...overrides,
};
@@ -49,6 +50,7 @@ function testHost(
return freshSession;
}),
applyFastMode: vi.fn(async () => {}),
applyUltracode: vi.fn(async () => {}),
publishSessionState: vi.fn(async () => {}),
continuationMessage: vi.fn((sessionId, plan, promptUuid) => ({
type: "user" as const,
@@ -252,6 +254,25 @@ describe("continuePlanInFreshContext", () => {
expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, false);
});
it("restores ultracode turned on before clearing context", async () => {
const turn: ClearContextTurn = {
settled: false,
promptUuid: "00000000-0000-4000-8000-000000000000",
};
const oldSession = testSession({ activeTurn: turn, ultracodeEnabled: true });
const freshSession = testSession({ ultracodeEnabled: false });
const host = testHost(oldSession, freshSession);
await continuePlanInFreshContext(
"public-session",
oldSession,
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
host,
);
expect(host.applyUltracode).toHaveBeenCalledWith(freshSession, true);
});
it("does not publish or continue a restart cancelled while creating the fresh session", async () => {
const turn: ClearContextTurn = {
settled: false,

View File

@@ -0,0 +1,298 @@
import { describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
const refused = () => {
throw new Error("Unexpected real SDK call");
};
return {
...actual,
query: vi.fn(refused),
getSessionMessages: vi.fn(async () => []),
getSubagentMessages: vi.fn(async () => []),
listSessions: vi.fn(async () => []),
getSessionInfo: vi.fn(async () => undefined),
deleteSession: vi.fn(refused),
forkSession: vi.fn(refused),
importSessionToStore: vi.fn(refused),
};
});
async function untilDone<T>(promise: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Prompt did not settle")), 3000);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
function createAgent() {
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
}
function usage(inputTokens: number, outputTokens: number) {
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
}
function modelUsage(inputTokens: number, outputTokens: number) {
return {
"claude-sonnet-4-6": {
inputTokens,
outputTokens,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
webSearchRequests: 0,
costUSD: 0,
contextWindow: 200000,
},
};
}
function stamp(uuid: string, fields: string) {
return {
...(fields !== "plural" ? { user_message_uuid: uuid } : {}),
...(fields !== "singular" ? { user_message_uuids: [uuid] } : {}),
};
}
const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
describe("a steer folded into an autonomous cycle", () => {
it.each([
["singular", true],
["plural", true],
["both", true],
["singular", false],
["plural", false],
["both", false],
] as const)("settles from its own %s UUID stamp (echo: %s)", async (fields, echo) => {
const agent = createAgent();
const input = new Pushable<any>();
const echoed = Promise.withResolvers<void>();
const interrupted = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const processed = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
async function* messages() {
const iter = input[Symbol.asyncIterator]();
const { value: original } = await iter.next();
yield userEcho(original);
echoed.resolve();
const { value: steered } = await iter.next();
yield successfulResultMessage({
origin: { kind: "human" },
...stamp(original.uuid, "both"),
queued_turn_count: 1,
stop_reason: "tool_use",
usage: usage(10, 5),
modelUsage: modelUsage(10, 5),
});
interrupted.resolve();
await resume.promise;
if (echo) yield userEcho(steered);
// Only the steer's UUID is named; it has no queued Turn of its own.
yield successfulResultMessage({
origin: { kind: "task-notification" },
...stamp(steered.uuid, fields),
queued_turn_count: 0,
stop_reason: "max_tokens",
usage: usage(20, 7),
modelUsage: modelUsage(30, 12),
});
yield idle;
processed.resolve();
// Stream shutdown must not supply the prompt's response.
await finish.promise;
}
const session = mockSessionState({ input, query: wrapQuery(messages()) });
agent.sessions["test-session"] = session;
let response: unknown;
const prompt = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
.then((value) => (response = value));
try {
await untilDone(echoed.promise);
await expect(
agent.steer({
sessionId: "test-session",
prompt: [{ type: "text", text: "also handle this" }],
}),
).resolves.toEqual({ outcome: "injected" });
await untilDone(interrupted.promise);
expect(response).toBeUndefined();
resume.resolve();
await untilDone(processed.promise);
await untilDone(prompt);
const tokenCount = {
totalTokens: 42,
inputTokens: 30,
cachedInputTokens: 0,
cachedWriteTokens: 0,
outputTokens: 12,
reasoningOutputTokens: 0,
};
expect(response).toEqual({
stopReason: "max_tokens",
usage: {
inputTokens: 30,
outputTokens: 12,
cachedReadTokens: 0,
cachedWriteTokens: 0,
totalTokens: 42,
},
_meta: {
quota: {
token_count: tokenCount,
model_usage: [{ model: "claude-sonnet-4-6", token_count: tokenCount }],
},
},
});
expect(session.activeTurn).toBeNull();
expect(session.turnQueue).toHaveLength(0);
expect(session.owedTrailingIdles).toBe(0);
} finally {
resume.resolve();
finish.resolve();
await untilDone(session.consumer);
await untilDone(prompt);
}
});
it.each(["unrelated", "held", "settled", "plural-unrelated", "plural-empty"] as const)(
"does not attribute a result to a steer in the %s state",
async (state) => {
const agent = createAgent();
const input = new Pushable<any>();
const echoed = Promise.withResolvers<void>();
const processed = Promise.withResolvers<void>();
const resume = Promise.withResolvers<void>();
const finish = Promise.withResolvers<void>();
let response: unknown;
let owner: any;
let steerUuid = "";
const heldOutcome = { stopReason: "end_turn" };
async function* messages() {
const iter = input[Symbol.asyncIterator]();
const { value: original } = await iter.next();
yield userEcho(original);
echoed.resolve();
const { value: steered } = await iter.next();
steerUuid = steered.uuid;
const session = agent.sessions["test-session"];
owner = session.activeTurn;
// Constructed defensive states: these do not model normal settlement.
if (state === "held") {
owner.deferredSettle = heldOutcome;
owner.spawnedTaskIds = new Set(["still-running"]);
session.liveBackgroundTasks.set("still-running", { isSubagent: true });
} else if (state === "settled") {
owner.settled = true;
}
const unrelated = randomUUID();
yield successfulResultMessage({
origin: { kind: "task-notification" },
user_message_uuid: state === "unrelated" ? unrelated : steerUuid,
user_message_uuids:
state === "plural-empty" ? [] : [state.includes("unrelated") ? unrelated : steerUuid],
stop_reason: "refusal",
usage: usage(100, 50),
modelUsage: modelUsage(100, 50),
});
processed.resolve();
await resume.promise;
owner.settled = false;
owner.deferredSettle = undefined;
owner.spawnedTaskIds = undefined;
session.liveBackgroundTasks.clear();
yield successfulResultMessage({
origin: { kind: "human" },
...stamp(steerUuid, "both"),
usage: usage(10, 5),
});
yield idle; // the excluded autonomous result's trailer
yield idle; // the human result's trailer
await finish.promise;
}
const session = mockSessionState({ input, query: wrapQuery(messages()) });
agent.sessions["test-session"] = session;
const prompt = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
.then((value) => (response = value));
try {
await untilDone(echoed.promise);
await expect(
agent.steer({
sessionId: "test-session",
prompt: [{ type: "text", text: "also handle this" }],
}),
).resolves.toEqual({ outcome: "injected" });
await untilDone(processed.promise);
expect(response).toBeUndefined();
expect(session.activeTurn).toBe(owner);
expect(session.accumulatedUsage).toEqual({
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
});
expect(session.accumulatedModelUsage).toEqual({});
expect(owner.steeredUuids).toEqual(new Set([steerUuid]));
expect(owner.steeredEchoes).toEqual(new Set([steerUuid]));
expect(owner.steeredSettle).toBeUndefined();
if (state === "held") expect(owner.deferredSettle).toBe(heldOutcome);
if (state === "settled") expect(owner.settled).toBe(true);
resume.resolve();
await untilDone(prompt);
expect(response).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 10,
outputTokens: 5,
cachedReadTokens: 0,
cachedWriteTokens: 0,
totalTokens: 15,
},
_meta: {
quota: {
token_count: {
totalTokens: 15,
inputTokens: 10,
cachedInputTokens: 0,
cachedWriteTokens: 0,
outputTokens: 5,
reasoningOutputTokens: 0,
},
model_usage: [],
},
},
});
} finally {
resume.resolve();
finish.resolve();
await untilDone(session.consumer);
await untilDone(prompt);
}
},
);
});

View File

@@ -31,6 +31,9 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
supportedCommands: async () => [],
mcpServerStatus: async () => [],
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
// No ultracode fields: session creation falls back to settings and the
// model's effort levels, as with a CLI that does not report them.
getSettings: async () => ({ applied: {} }),
close: () => {},
interrupt: async () => undefined,
[Symbol.asyncIterator]: async function* () {},

View File

@@ -97,6 +97,76 @@ describe("incomplete foreground tools", () => {
expect(agent.sessions[sessionId].toolCallParents?.size ?? 0).toBe(0);
});
it.each([
{
content: { type: "advisor_result", text: "Check the queue ordering." },
status: "completed",
text: "Check the queue ordering.",
},
{
content: { type: "advisor_redacted_result", encrypted_content: "opaque-advice" },
status: "completed",
text: "Advisor guidance applied server-side.",
},
{
content: { type: "advisor_tool_result_error", error_code: "overloaded" },
status: "failed",
text: "Advisor error: overloaded",
},
] as const)(
"settles an advisor call with a $content.type result",
async ({ content, status, text }) => {
const advisorId = "srvtoolu_advisor";
const { prompt, updates, logError, agent } = createTestSession(async function* (input) {
yield* echoNextPrompt(input);
yield advisorStart(advisorId);
yield advisorToolUse(advisorId);
yield advisorResult(advisorId, content);
expect(hasHookCallback(advisorId)).toBe(false);
yield successfulResultMessage();
});
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
const terminal = updates.filter(
(update) =>
update.toolCallId === advisorId &&
(update.status === "completed" || update.status === "failed"),
);
expect(terminal).toHaveLength(1);
expect(terminal[0]).toMatchObject({
sessionUpdate: "tool_call_update",
toolCallId: advisorId,
status,
});
expect(JSON.stringify(terminal[0])).toContain(text);
expect(JSON.stringify(updates)).not.toContain("opaque-advice");
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
expect(logError).not.toHaveBeenCalled();
},
);
it("fails a dispatched advisor call without a result", async () => {
const advisorId = "srvtoolu_advisor";
const { prompt, updates, agent } = createTestSession(async function* (input) {
yield* echoNextPrompt(input);
yield advisorStart(advisorId);
yield advisorToolUse(advisorId);
yield successfulResultMessage();
});
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
const terminal = updates.filter(
(update) =>
update.toolCallId === advisorId &&
(update.status === "completed" || update.status === "failed"),
);
expect(terminal).toHaveLength(1);
expect(terminal[0]).toMatchObject({ status: "failed" });
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
});
it("allows a tool explicitly handed off to a background task", async () => {
const { prompt, updates } = createTestSession(backgroundToolMessages);
@@ -1123,6 +1193,44 @@ function toolUse(
};
}
function advisorStart(id: string) {
return {
...toolStart(id),
event: {
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id, name: "advisor", input: {} },
},
};
}
/** The complete assistant message that dispatches the advisor server tool. */
function advisorToolUse(id: string) {
return {
type: "assistant",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [{ type: "server_tool_use", id, name: "advisor", input: {} }],
},
};
}
function advisorResult(id: string, content: unknown) {
return {
type: "assistant",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [{ type: "advisor_tool_result", tool_use_id: id, content }],
},
};
}
function assistantText(text: string) {
return {
type: "assistant",

View File

@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { query, type ModelInfo, type Query } from "@anthropic-ai/claude-agent-sdk";
import { buildModelConfigOption, getAvailableModels } from "../session-model.js";
import {
applyAvailableModelsAllowlist,
buildModelConfigOption,
getAvailableModels,
} from "../session-model.js";
const OPUS: ModelInfo = {
value: "opus[1m]",
@@ -151,6 +155,68 @@ describe("versioned model display names", () => {
);
expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]);
});
it("distinguishes allowlisted aliases and pins that inherit the same SDK label", async () => {
const sdkModels: ModelInfo[] = [
{ value: "default", displayName: "Default (recommended)", description: "" },
{
value: "opus",
resolvedModel: "claude-opus-5-5",
displayName: "Opus 5.5",
description: "For complex work and everyday tasks",
},
{ value: "haiku", displayName: "Haiku", description: "Fast" },
];
const ids = ["claude-opus-5-5[1m]", "claude-opus-5-5", "opus[1m]", "opus", "haiku"];
const models = applyAvailableModelsAllowlist(sdkModels, ids);
const setModel = vi.fn();
const state = await getAvailableModels(
{ setModel } as unknown as Query,
models,
sdkModels,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
const expected = [
"Default (recommended)",
...ids.slice(0, 4).map((id) => `Opus 5.5 (${id})`),
"Haiku",
];
expect(state.availableModels.map((model) => model.name)).toEqual(expected);
expect(state.availableModels.map((model) => model.modelId)).toEqual(["default", ...ids]);
expect(state.currentModelId).toBe("default");
expect(state.availableModels.slice(1, 5).map((model) => model.description)).toEqual(
Array(4).fill(sdkModels[1].description),
);
const option = buildModelConfigOption(state, models, false);
expect(option).toMatchObject({ currentValue: "default" });
expect(option.type === "select" && option.options.map((model) => model.name)).toEqual(expected);
expect(sdkModels[1].displayName).toBe("Opus 5.5");
expect(setModel).not.toHaveBeenCalled();
});
it("distinguishes identical custom labels without changing their model IDs", async () => {
const models: ModelInfo[] = [
{ value: "provider-a", displayName: "My model", description: "First endpoint" },
{ value: "provider-b", displayName: "My model", description: "Second endpoint" },
];
const state = await getAvailableModels(
{} as Query,
models,
models,
{ getSettings: () => ({}) },
{ log: vi.fn(), error: vi.fn() },
false,
"model-presentation-test",
);
expect(state.availableModels).toEqual([
{ modelId: "provider-a", name: "My model (provider-a)", description: "First endpoint" },
{ modelId: "provider-b", name: "My model (provider-b)", description: "Second endpoint" },
]);
});
});
// Account-dependent model availability requires the same authenticated environment

View File

@@ -381,17 +381,17 @@ describe("settingsEffortForModel", () => {
});
describe("effortFlagSettings", () => {
it("carries ultracode only when settings request it", () => {
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
it("carries ultracode only while the session has it on", () => {
expect(effortFlagSettings("high", true)).toEqual({
effortLevel: "high",
ultracode: true,
});
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
expect(effortFlagSettings("default", true)).toEqual({
effortLevel: null,
ultracode: true,
});
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
expect(effortFlagSettings("high", false)).toEqual({ effortLevel: "high" });
expect(effortFlagSettings(undefined, false)).toEqual({ effortLevel: null });
});
});

View File

@@ -0,0 +1,292 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import {
createUltracodeConfigOption,
modelSupportsUltracode,
readUltracodeSettings,
resolveUltracodeEnabled,
ULTRACODE_CONFIG_ID,
ULTRACODE_OFF,
ULTRACODE_ON,
ultracodeSupported,
ultracodeWorkflowsEnabled,
} from "../session-ultracode.js";
import { makeMockQuery } from "./helpers.js";
const { querySpy } = vi.hoisted(() => ({ querySpy: vi.fn() }));
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
return { ...actual, query: querySpy };
});
const OPUS: ModelInfo = {
value: "opus",
displayName: "Opus",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
const FABLE: ModelInfo = {
value: "fable",
displayName: "Fable",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
};
// Opus 4.6 and Haiku 4.5 have no `xhigh` effort, so the CLI has no ultracode
// for them either.
const OLD_OPUS: ModelInfo = {
value: "claude-opus-4-6",
displayName: "Opus 4.6",
description: "",
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high", "max"],
};
function getSettingsQuery(getSettings: unknown): Query {
return { getSettings } as unknown as Query;
}
describe("ultracode helpers", () => {
it("builds a boolean option or an on/off select", () => {
expect(createUltracodeConfigOption(true, true)).toEqual({
id: ULTRACODE_CONFIG_ID,
name: "Ultracode",
description: "Dynamic workflows on every task, at any effort level",
category: "model_config",
type: "boolean",
currentValue: true,
});
expect(createUltracodeConfigOption(false, false)).toMatchObject({
type: "select",
currentValue: ULTRACODE_OFF,
options: [
{ value: ULTRACODE_ON, name: "On" },
{ value: ULTRACODE_OFF, name: "Off" },
],
});
});
it("accepts booleans and the select strings", () => {
const request = (value: unknown) =>
({ sessionId: "s", configId: ULTRACODE_CONFIG_ID, value }) as never;
expect(resolveUltracodeEnabled(request(true))).toBe(true);
expect(resolveUltracodeEnabled(request(ULTRACODE_OFF))).toBe(false);
expect(() => resolveUltracodeEnabled(request("maybe"))).toThrow(
"Invalid value for config option ultracode: maybe",
);
});
it("treats a model as supporting ultracode when it supports xhigh effort", () => {
expect(modelSupportsUltracode(OPUS)).toBe(true);
expect(modelSupportsUltracode(OLD_OPUS)).toBe(false);
expect(modelSupportsUltracode({ ...OPUS, supportsEffort: false })).toBe(false);
expect(modelSupportsUltracode(undefined)).toBe(false);
});
it("infers the workflows gate only when the start model can tell", () => {
expect(ultracodeWorkflowsEnabled({ available: true }, OLD_OPUS)).toBe(true);
expect(ultracodeWorkflowsEnabled({ available: false }, OPUS)).toBe(false);
// Opus 4.6 is unavailable regardless of workflows.
expect(ultracodeWorkflowsEnabled({ available: false }, OLD_OPUS)).toBeUndefined();
expect(ultracodeWorkflowsEnabled(undefined, OPUS)).toBeUndefined();
expect(ultracodeSupported(undefined, OPUS)).toBe(true);
expect(ultracodeSupported(false, OPUS)).toBe(false);
expect(ultracodeSupported(true, OLD_OPUS)).toBe(false);
});
it("reads the CLI's ultracode report and tolerates CLIs without one", async () => {
await expect(
readUltracodeSettings(
getSettingsQuery(async () => ({
applied: { ultracodeAvailable: true, ultracodeRequested: false },
})),
),
).resolves.toEqual({ available: true, requested: false });
await expect(
readUltracodeSettings(getSettingsQuery(async () => ({ applied: {} }))),
).resolves.toEqual({ available: undefined, requested: undefined });
await expect(readUltracodeSettings(getSettingsQuery(undefined))).resolves.toBeUndefined();
await expect(
readUltracodeSettings(
getSettingsQuery(async () => {
throw new Error("unsupported control request");
}),
),
).resolves.toBeUndefined();
});
});
describe("ultracode session config option", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-ultracode-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
querySpy.mockReset();
vi.resetModules();
});
afterEach(async () => {
if (originalClaudeConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
async function createSession(opts: {
models?: ModelInfo[];
getSettings?: () => Promise<unknown>;
settings?: Record<string, unknown>;
clientCapabilities?: ClientCapabilities;
}) {
if (opts.settings) {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify(opts.settings),
);
}
const applyFlagSettings = vi.fn();
querySpy.mockReturnValue(
makeMockQuery({
initializationResult: async () => ({ models: opts.models ?? [OPUS, FABLE, OLD_OPUS] }),
applyFlagSettings,
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient);
(agent as any).clientCapabilities = opts.clientCapabilities ?? {
session: { configOptions: { boolean: {} } },
};
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const sessionId: string = response.sessionId;
const ultracodeOption = () =>
agent.sessions[sessionId].configOptions.find((o) => o.id === ULTRACODE_CONFIG_ID);
return { agent, sessionId, applyFlagSettings, ultracodeOption };
}
it("seeds the option from the CLI's report", async () => {
const { ultracodeOption, agent, sessionId } = await createSession({
getSettings: async () => ({
applied: { ultracodeAvailable: true, ultracodeRequested: true },
}),
});
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, true));
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(true);
});
it("hides the option while dynamic workflows are off, across model switches", async () => {
const { agent, sessionId, ultracodeOption } = await createSession({
getSettings: async () => ({
applied: { ultracodeAvailable: false, ultracodeRequested: false },
}),
});
expect(ultracodeOption()).toBeUndefined();
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
expect(ultracodeOption()).toBeUndefined();
});
it("follows the model's support and keeps the toggle across switches", async () => {
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({
clientCapabilities: {},
});
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, false));
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
value: ULTRACODE_ON,
});
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: true });
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "claude-opus-4-6" });
expect(ultracodeOption()).toBeUndefined();
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, false));
});
it("keeps a toggled-on ultracode when effort changes, and not a toggled-off one", async () => {
// An effortLevel apply without an ultracode key turns ultracode off, so
// the effort picker must carry the session's toggle.
const { agent, sessionId, applyFlagSettings } = await createSession({
settings: { ultracode: true },
});
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: false,
});
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: false });
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "high" });
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high" });
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: true,
});
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "low" });
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "low", ultracode: true });
});
it("keeps ultracode on through the effort apply that follows a model switch", async () => {
// recommendedValue clients apply the new model's effort on every switch.
const { agent, sessionId, applyFlagSettings } = await createSession({
clientCapabilities: {
session: { configOptions: { boolean: {} } },
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
},
});
await agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: true,
});
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
});
it("does not change session state when the SDK rejects the flag", async () => {
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({});
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
await expect(
agent.setSessionConfigOption({
sessionId,
configId: ULTRACODE_CONFIG_ID,
type: "boolean",
value: true,
}),
).rejects.toThrow("nope");
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(false);
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, true));
});
});