Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Brandt
7e857d59ec fix: end a v2 turn once when Claude Code folds a prompt into it (#1618)
Fixes #1611.

## Problem

On ACP v2, a prompt sent while the session works gets its reply twice.
The client sees:

1. the reply, streamed under the model's `msg_` id
2. `state_update` `idle` with `stopReason: end_turn` and zero usage
3. `state_update` `running`
4. the same reply again, under a UUID
5. `state_update` `idle` with the real usage

This happens whenever Claude Code folds the prompt into running work.
The work can be a cycle that Claude Code started on its own, as in the
issue, or the turn of an earlier prompt.

## Cause

Claude Code sends one result, not two. The order of its frames differs
for a folded prompt (CLI 2.1.323):

| Prompt | Order |
| --- | --- |
| started the work | `result`, then `command_lifecycle` `completed` |
| folded into the work | `command_lifecycle` `completed`, then `result`
|

`V2Session.lifecycle` took every `completed` frame of the last started
prompt as the end of a turn that had no result, and closed the turn. The
result then found no open turn. It opened a new one, saw no streamed
text in it, and sent `result.result` as the reply.

## Fix

The session remembers which prompt started Claude Code's current cycle
(`cycleCommand`). Only that prompt's `completed` frame closes a turn
that had no result. A folded prompt's `completed` frame leaves the turn
open, and the result ends it.

A result ends the cycle, so the next prompt of a turn that stays open
(the `/clear` and the plan prompt of a fresh context) starts its own.

The `cancelled` frame is unchanged: it still closes the turn for the
prompt that started last.

## Tests

- `v2.test.ts`: one `it.each` with the frames in the CLI's order, for a
prompt folded into another prompt's turn and into a cycle that Claude
Code started on its own. Both fail without the fix with `running, idle,
running, idle`.
- `v2-integration.test.ts`: one case against the bundled CLI. It passes
with the fix.
- Live, with the issue's steps: one reply and one `idle` with the real
usage.

## Not covered

- I did not capture what Claude Code sends when a cancel arrives during
work with a folded prompt.
- I observed the frame order on CLI 2.1.323 only.

ACP v1 is not affected: it does not end a turn at a `completed` frame.
2026-11-13 13:48:52 +00:00
acp-release-bot[bot]
c5c120dba9 chore(main): release 0.107.0 (#1595)
🤖 I have created a release *beep* *boop*
---


##
[0.107.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.106.0...v0.107.0)
(2026-11-13)


### Features

* report the turn's rate limit in rate-limit failure errors
([#1610](https://github.com/agentclientprotocol/claude-agent-acp/issues/1610))
([4f474d6](4f474d655b))
* report unknown subagent state at a conversation reset and reference
children from tool calls
([#1608](https://github.com/agentclientprotocol/claude-agent-acp/issues/1608))
([4804917](4804917a6f))
* say the cause of an api retry in the AIR warning details
([#1594](https://github.com/agentclientprotocol/claude-agent-acp/issues/1594))
([4f690e1](4f690e129c))


### Bug Fixes

* assume a 1M context window until Claude Code reports it
([#1613](https://github.com/agentclientprotocol/claude-agent-acp/issues/1613))
([9619805](9619805306))
* end the tool calls of a response that Claude Code took back
([#1616](https://github.com/agentclientprotocol/claude-agent-acp/issues/1616))
([21f3e04](21f3e0430b))
* explain why Claude Code turns Fast mode off
([#1602](https://github.com/agentclientprotocol/claude-agent-acp/issues/1602))
([c331e17](c331e178a5))
* follow Claude Code's rule for managed deniedModels in the model picker
([#1615](https://github.com/agentclientprotocol/claude-agent-acp/issues/1615))
([c78bcc7](c78bcc7f41))
* log a managed policy source that did not load
([#1614](https://github.com/agentclientprotocol/claude-agent-acp/issues/1614))
([ca46b41](ca46b412f5))
* report a mid-turn API error notice where the response broke off
([#1599](https://github.com/agentclientprotocol/claude-agent-acp/issues/1599))
([4df6999](4df69996ca))
* say why the login failed in the v2 authRequired error
([#1598](https://github.com/agentclientprotocol/claude-agent-acp/issues/1598))
([ecd066c](ecd066c870))
* update to @anthropic-ai/claude-agent-sdk 0.3.323
([#1612](https://github.com/agentclientprotocol/claude-agent-acp/issues/1612))
([ddee24e](ddee24ef8f))

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

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-13 13:34:19 +00:00
Eric Holk
4f474d655b feat: report the turn's rate limit in rate-limit failure errors (#1610)
When a turn fails with `errorKind: "rate_limit"`, the error's `data` now
also carries the turn's latest `rate_limit_event` info as `rateLimit`:

```json
"data": {
  "errorKind": "rate_limit",
  "rateLimit": { "status": "rejected", "rateLimitType": "five_hour", "resetsAt": 1763002200 }
}
```

## Why

ACP clients may want to handle a rate-limit failure better than showing
its message, for example by showing when the limit resets or offering to
continue the turn then. That needs the reset time as data. Today the
adapter forwards `rate_limit_event` info only on a `usage_update`
(`_meta["_claude/rateLimit"]`). A message sent while the account is
already over its limit is refused before its first usage, so no
`usage_update` goes out and the client gets only:

```json
"error": { "code": -32603, "message": "Internal error: You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)", "data": { "errorKind": "rate_limit" } }
```

Without this change, the only way to get the reset time is parsing that
text.

## What changed

- `turnErrorData` in `src/result-end.ts` builds the `data` of the error
that fails a turn. It adds `rateLimit` only for a `rate_limit` failure
in a turn that saw a `rate_limit_event`. Other failures keep their
current data.
- v2: `TurnReport` records the turn's latest `rate_limit_event` info and
clears it on `reset()`. `idleOutcome` passes it to `turnErrorData`.
- v1: the prompt loop tracks `turnRateLimit` alongside
`lastAssistantError`, resets it with the other per-turn scratch state,
and passes it at the three result-failure sites that used
`errorKindData(lastAssistantError)`.

## Testing

- New v1 and v2 tests: a `rate_limit_event` followed by a `rate_limit`
failure puts `rateLimit` in the error data.
- `npm run build`, `npm run check` and `npm run test:run` pass.
- Manually tested with a locally built binary: a client received
`rateLimit` in the error for a message refused at the 5-hour limit.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 13:31:11 +00:00
Ben Brandt
21f3e0430b fix: end the tool calls of a response that Claude Code took back (#1616)
## Summary

Follow-up to #1612. When a model refuses, Claude Code takes back the
response it already sent and retries on the fallback model. A tool call
of that response which has no result yet is stopped, and no result ever
comes for it. The adapter never read which messages were taken back, so
that tool call stayed open:

- **v1:** until the turn's end, where the prompt then failed with
`incomplete_tool_call`, although the retry had answered.
- **v2:** past the turn's idle, since a call that ran is left open for
its result.

Both cores now end the call as soon as it is named: `failed` on v1,
`cancelled` on v2, with a text that says why.

## What Claude Code sends

Read from the code of CLI 2.1.323. A refusal fallback cannot be produced
on demand, so none of this was observed live.

- The refused response reaches the host as usual: its stream and its
`assistant` frames, which can hold `tool_use` blocks, and the results of
tools that had finished.
- Claude Code then stops the tools that had not finished and sends no
result for them.
- It names the messages it took back, by the `uuid` their frames were
sent with, in two places: `retracted_message_uuids` of the
`model_refusal_fallback` notice, and `supersedes` of the first
`assistant` frame of the retry.

Since 2.1.323 `supersedes` also names a response whose tool call could
not be parsed. Such a response has stop reason `tool_use` and no
`tool_use` block, so it leaves no tool call open, and this change does
nothing for it.

## What changes

- `RetractedToolCalls` in `src/tool-calls/retracted.ts` (new) keeps the
tool uses of each assistant frame by `uuid`, for the last 1,000 frames,
and gives the tool calls of the messages that a frame takes back. It
reads both signals and gives each tool call once.
- `AcpToolCallRenderer.unfinished` gains a `retracted` case with its own
text: "Claude Code replaced the response that made this tool call, and
stopped the call. If it had started, some of its effects may have
happened."
- v1 closes through the existing `closeOpenToolCalls`, which now also
takes the tool call id. The change in `src/v1/acp-agent.ts` is wiring.
- v2 adds `SessionOutput.closeRetracted`, which shares one private
`close` with `closeUnfinished`.

Only a call that is still open is closed. A call that already has its
result is left alone, and a result that arrives after the close changes
nothing.

## Not in this PR

- **Superseded text stays in the transcript.** ACP v1 has no way to take
a message back. On v2 the `agent_message` upsert could clear one
(`content: []`), but that leaves an empty message whose rendering is up
to the client, and v1 and v2 would then differ. This needs a decision,
so it is left as it is.
- A tool use that streamed but whose `assistant` frame never arrived has
no `uuid` to match, so it is not closed by this.
- No scenario-harness case: its plain and Zed recordings must match the
origin/main baseline, so a deliberate difference there needs a
documented exception first.

## Testing

- `npm run build`, `npm run check`, `npm run test:run` (4,128 tests
pass)
- `src/tests/retracted-tool-calls.test.ts` for the shared class
- One `it.each` in `src/tests/v1/acp-agent.test.ts` and one in
`src/tests/v2/acp-v2.test.ts`, with one row per signal. The v1 rows fail
on main with `incomplete_tool_call`.
- The doubles follow the frames as read from the CLI's code, since the
case cannot be reproduced against the real CLI.
2026-11-13 14:27:16 +01:00
Ben Brandt
c78bcc7f41 fix: follow Claude Code's rule for managed deniedModels in the model picker (#1615)
## Summary

Follow-up to #1612. With an `availableModels` allowlist, the model
picker rebuilds its rows from the allowlist entries and drops the ones a
managed `deniedModels` list blocks. It decided that with its own rule
(`isDeniedModel`), which differed from Claude Code's in both directions.
The rule is now the SDK's `matchDeniedModels()` (alpha, 0.3.323).

This only affects a session that has both an `availableModels` allowlist
and a managed `deniedModels` list. Without an allowlist the picker shows
Claude Code's own list, which Claude Code has filtered already.

## What Claude Code really does

Measured with CLI 2.1.323: sessions started with `managedSettings: {
deniedModels }`, then `setModel(id)` for each id. No prompt was sent.
The deny check runs before the "not found" check, so a refusal that says
"is restricted by your organization's settings" means the list blocks
the id.

| Entry | Model id | `setModel` | SDK matcher | Old adapter rule |
| --- | --- | --- | --- | --- |
| `claude-opus-5-5` | `vertex_ai/claude-opus-5-5` | refused | denied |
offered |
| `claude-opus-5-5` | `anthropic/claude-opus-5.5`,
`claude-opus-5-5:free` | refused | not denied | hidden |
| `claude-opus-5-5` | `openrouter/anthropic/claude-opus-5.5` | refused |
not denied | offered |
| `claude-opus-5` | `claude-opus-5-5` | refused | denied | hidden |
| `claude-opus-4.5` | `claude-opus-4-5` | allowed | not denied | hidden
|
| `claude-opus-4.5` | `anthropic/claude-opus-4.5` | refused | not denied
| hidden |
| `claude-haiku` | `claude-haiku-5-5` | allowed | not denied | hidden |
| `my-model` | `gateway/my-model` | refused | not denied | offered |
| `my-model` | `my-model:free` | not blocked | not denied | hidden |
| `default`, `opusplan` | the same name | allowed | not denied | hidden
|

Over 169 ids in 30 sessions:

- the old rule offered 14 ids that `setModel` refuses, and hid 27 that
the list does not block;
- `matchDeniedModels` alone missed 33 refused ids. Its doc says so: it
compares an id with the entries, and the CLI also judges path tails and
a model's other names;
- the new rule agrees on all 169.

## The rule

`deniedModelIds` in `src/session-model.ts` asks `matchDeniedModels`
about three spellings of each id, so the entry rules stay the SDK's:

- the id itself;
- its last path segment, because an exact-name entry also blocks
`gateway/my-model`;
- its version spelling: everything from the first `:` dropped, and `5.5`
written `5-5`.

**Workaround to note.** The version spelling is asked behind a fixed
path, `version-of/`. The SDK ignores a path for a versioned id, and the
path keeps the spelling from equalling an exact-name entry. Without it,
the entry `my-model` would block `my-model:free`, which Claude Code
allows. This leans on how the SDK function treats paths, which nothing
documents. The tests hold recorded CLI answers and fail if the path is
removed or if only the id itself is asked.

`isDeniedModel` and its own rule are removed.
`applyAvailableModelsAllowlist` is async now, because the SDK function
is; v1 (`src/v1/acp-agent.ts`) and v2 (`src/v2/setup.ts`) each gain one
`await`. The SDK call takes under 1 ms.

## Not verified

- A custom name such as `gpt-5` answers "not found" on a first-party
account. That is read as "not denied" from the order of the checks; no
gateway was available to confirm it.
- `modelOverrides` targets were checked as plain ids given to
`setModel`.
- `matchDeniedModels().warnings` are not logged: `/status` and `claude
doctor` already list them.

## Testing

- `npm run build`, `npm run check`, `npm run test:run` (4,125 tests
pass)
- The `isDeniedModel` tests became one `it.each` in
`src/tests/model-resolution.test.ts`. Every id in it is a recorded CLI
answer.
- A second, independent run against the real CLI reproduced nine of the
recorded answers.
2026-11-13 14:26:13 +01:00
Ben Brandt
ca46b412f5 fix: log a managed policy source that did not load (#1614)
## Summary

Follow-up to #1612. Logs when a machine policy source may be there and
did not load, using `machinePolicy.didNotLoad` (alpha) that
`resolveSettings()` reports since SDK 0.3.323.

## Why

`resolveSettings()` shows a policy source that it could not read (a
macOS MDM profile, the Windows registry policy, the managed-settings
file) as absent. The adapter then applies nothing from it, and nothing
said so:

- at start-up, `applyManagedPolicyEnv` copies no env vars from it into
the process;
- in `SettingsManager`, the session's settings and model list (managed
`deniedModels`, `availableModels`, `modelOverrides`) do not reflect it.

Claude Code reads the policy itself, so its own read is not affected.
The log line only explains why the adapter's side looks as if no policy
were set.

## What changes

- `unloadedPolicySources` in `src/managed-policy.ts` names the sources
for a log line. It is undefined when every source loaded, and for an SDK
that does not report the member.
- `applyManagedPolicyEnv` writes one line to stderr and still applies
the env vars of the sources that did load.
- `SettingsManager` logs once per change, because it resolves the
settings again after every settings file edit.

Not in this PR: reading the policy again later. The env vars are applied
once at start-up, so a source that loads later is picked up for env vars
only by a restart, which the message says.

## Testing

- `npm run build`, `npm run check`, `npm run test:run` (4,126 tests
pass)
- The real SDK reports `machinePolicy: { didNotLoad: false }` on this
machine; the doubles use that shape.
2026-11-13 14:16:33 +01:00
24 changed files with 851 additions and 130 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.106.0"
".": "0.107.0"
}

View File

@@ -1,5 +1,26 @@
# Changelog
## [0.107.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.106.0...v0.107.0) (2026-11-13)
### Features
* report the turn's rate limit in rate-limit failure errors ([#1610](https://github.com/agentclientprotocol/claude-agent-acp/issues/1610)) ([4f474d6](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f474d655b88a075ec7d0124d45cafa0f5565af7))
* report unknown subagent state at a conversation reset and reference children from tool calls ([#1608](https://github.com/agentclientprotocol/claude-agent-acp/issues/1608)) ([4804917](https://github.com/agentclientprotocol/claude-agent-acp/commit/4804917a6fa7050490d1d62c6689e78c231c752a))
* say the cause of an api retry in the AIR warning details ([#1594](https://github.com/agentclientprotocol/claude-agent-acp/issues/1594)) ([4f690e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f690e129c01f22e2c2a87e0e0a3c5b503241f15))
### Bug Fixes
* assume a 1M context window until Claude Code reports it ([#1613](https://github.com/agentclientprotocol/claude-agent-acp/issues/1613)) ([9619805](https://github.com/agentclientprotocol/claude-agent-acp/commit/96198053067cf4d7b7440c52036174ad4f54183e))
* end the tool calls of a response that Claude Code took back ([#1616](https://github.com/agentclientprotocol/claude-agent-acp/issues/1616)) ([21f3e04](https://github.com/agentclientprotocol/claude-agent-acp/commit/21f3e0430b693e6b04750ab3018e1eaf726af173))
* explain why Claude Code turns Fast mode off ([#1602](https://github.com/agentclientprotocol/claude-agent-acp/issues/1602)) ([c331e17](https://github.com/agentclientprotocol/claude-agent-acp/commit/c331e178a5df47448be1a3ed2f71270c3aad2433))
* follow Claude Code's rule for managed deniedModels in the model picker ([#1615](https://github.com/agentclientprotocol/claude-agent-acp/issues/1615)) ([c78bcc7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c78bcc7f417fc8d3da66f51021b43f4528826ec0))
* log a managed policy source that did not load ([#1614](https://github.com/agentclientprotocol/claude-agent-acp/issues/1614)) ([ca46b41](https://github.com/agentclientprotocol/claude-agent-acp/commit/ca46b412f51cdb7b5a303c751f5d48c216700887))
* report a mid-turn API error notice where the response broke off ([#1599](https://github.com/agentclientprotocol/claude-agent-acp/issues/1599)) ([4df6999](https://github.com/agentclientprotocol/claude-agent-acp/commit/4df69996ca74adff6c42fc60e4b9b742f2d3fdf5))
* say why the login failed in the v2 authRequired error ([#1598](https://github.com/agentclientprotocol/claude-agent-acp/issues/1598)) ([ecd066c](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecd066c8700955e3456dd9387e6b8317f024e26c))
* update to @anthropic-ai/claude-agent-sdk 0.3.323 ([#1612](https://github.com/agentclientprotocol/claude-agent-acp/issues/1612)) ([ddee24e](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddee24ef8fd0abc0401fb32d567914030fbf6944))
## [0.106.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.105.0...v0.106.0) (2026-11-10)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.106.0",
"version": "0.107.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.106.0",
"version": "0.107.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.6.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.106.0",
"version": "0.107.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

@@ -1,5 +1,28 @@
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
/** The machine policy sources that `machinePolicy.didNotLoad` names. */
const POLICY_SOURCES: Record<string, string> = {
profile: "the macOS MDM profile",
registry: "the Windows registry policy",
file: "the managed-settings file",
};
/**
* The machine policy sources that may be there and did not load
* (`machinePolicy.didNotLoad`, SDK 0.3.323), named for a log line: "the macOS
* MDM profile, the managed-settings file". The resolved settings show such a
* source as absent, so the adapter applies nothing from it, and Claude Code
* reads the policy itself. Undefined when every source loaded, and when the
* SDK does not report it.
*/
export function unloadedPolicySources(
resolved: Pick<Awaited<ReturnType<typeof resolveSettings>>, "machinePolicy">,
): string | undefined {
const unloaded = resolved.machinePolicy?.didNotLoad;
if (!unloaded || unloaded.length === 0) return undefined;
return unloaded.map((kind) => POLICY_SOURCES[kind] ?? kind).join(", ");
}
/**
* Applies the managed-policy tier's env vars to `process.env` before any SDK
* call, so the SDK subprocess inherits them. Going through `resolveSettings`
@@ -28,6 +51,13 @@ export async function applyManagedPolicyEnv(
console.error("Failed to resolve managed policy settings:", error);
return;
}
const unloaded = unloadedPolicySources(policy);
if (unloaded) {
console.error(
`Managed settings did not load from ${unloaded}: its env vars are not applied. ` +
"Restart the agent to read it again.",
);
}
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
env[key] = value;
}

View File

@@ -8,9 +8,26 @@
import type {
SDKMessage,
SDKMessageOrigin,
SDKRateLimitInfo,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
/**
* The `data` of the error that fails a turn, for clients to dispatch on
* without matching its text: the assistant message's `errorKind`, and for a
* `rate_limit` failure the turn's latest `rate_limit_event` info as
* `rateLimit`, whose `resetsAt` says when the account can run again. A turn
* refused before its first usage sends no `usage_update` to carry that info.
* `undefined` without an error kind.
*/
export function turnErrorData<K extends string>(
errorKind: K | undefined,
rateLimit: SDKRateLimitInfo | undefined,
): { errorKind: K; rateLimit?: SDKRateLimitInfo } | undefined {
if (!errorKind) return undefined;
return errorKind === "rate_limit" && rateLimit ? { errorKind, rateLimit } : { errorKind };
}
export type ResultEnd =
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
/**

View File

@@ -1,5 +1,11 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
// matchDeniedModels is marked @alpha in the SDK; API may shift in a future release.
import {
matchDeniedModels,
type ModelInfo,
type Query,
type Settings,
} from "@anthropic-ai/claude-agent-sdk";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
import { bedrockRegionPrefix } from "./gateway-models.js";
@@ -105,7 +111,7 @@ function containsWords(haystack: string, needle: string): boolean {
}
/** `id` without the gateway spellings that `normalizeModelId` keeps for
* `deniedModels` and gateway listings: path segments before `claude-`
* gateway listings: path segments before `claude-`
* (`vertex_ai/`, `openrouter/anthropic/`, `publishers/anthropic/models/`)
* and the Anthropic API's `-latest` suffix (`claude-3-5-haiku-latest`). */
function withoutGatewaySpelling(id: string): string {
@@ -376,8 +382,8 @@ export function aliasResumedModelRow(
/** A model id without the spellings that name the same model: provider
* prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
* `-fast` and context hints. `deniedModels` and gateway listings both match on
* it. OpenRouter lists `anthropic/claude-opus-5.5:batch[1m]`, which normalizes
* `-fast` and context hints. Gateway listings match on it. OpenRouter lists
* `anthropic/claude-opus-5.5:batch[1m]`, which normalizes
* to `claude-opus-5-5`; `anthropic/openai/gpt-5` keeps its `openai/` prefix and
* matches no Claude id. */
function normalizeModelId(value: string): string {
@@ -401,24 +407,56 @@ function normalizeModelId(value: string): string {
);
}
/** A path that no model id starts with. Nothing enforces that no
* `deniedModels` entry names it (see {@link deniedModelSpellings}). */
const VERSION_SPELLING_PATH = "version-of/";
/**
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
* closely enough for the picker: a family alias blocks the whole family, and a
* model id blocks that version in every spelling plus the later minor versions
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). Entries share
* `normalizeModelId`, so `claude-opus-4.5` or `anthropic/claude-opus-4.5` also
* blocks `claude-opus-4-5`, which the CLI may still allow. The CLI stays the
* enforcer; this only keeps the picker from offering a refused row.
* The spellings of `id` to ask the SDK's `matchDeniedModels` about. That
* function compares an id with the entries alone. The CLI blocks more
* (`setModel` against CLI 2.1.323):
* - a last path segment that an exact-name entry names: `my-model` blocks
* `gateway/my-model`;
* - a version behind a `:variant` suffix or written with a dot:
* `claude-opus-5-5` blocks `claude-opus-5-5:free` and
* `anthropic/claude-opus-5.5`.
*
* An exact-name entry does not block a `:variant`: `my-model` leaves
* `my-model:free` allowed. So the version spelling goes behind a path, which
* the SDK ignores for a versioned id and which keeps the spelling from
* equalling an exact name.
*/
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
const id = normalizeModelId(modelId);
if (!id) return false;
return deniedModels.some((entry) => {
const denied = normalizeModelId(entry);
if (!denied) return false;
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
return id === denied || id.startsWith(`${denied}-`);
});
function deniedModelSpellings(id: string): string[] {
const lastSegment = id.slice(id.lastIndexOf("/") + 1);
const version = lastSegment.replace(/:.*$/, "").replace(/(?<=\d)\.(?=\d)/g, "-");
return [id, lastSegment, `${VERSION_SPELLING_PATH}${version}`];
}
/**
* Which of `ids` a managed `deniedModels` list blocks, by the CLI's own entry
* rules: the SDK's `matchDeniedModels`, asked about each of the
* {@link deniedModelSpellings} of an id.
* - A family alias (`opus`) blocks the family.
* - A model id blocks that version in every spelling, and the later minor
* versions when it names none (`claude-opus-5` also blocks `claude-opus-5-5`).
* - Any other entry (`claude-opus-4.5`, `my-model`) blocks only that name,
* alone or as the last path segment (`gateway/my-model`).
* - `default`, `best` and `opusplan` block nothing.
*
* The CLI stays the enforcer; this only keeps the picker from offering a row
* that `setModel` would refuse.
*/
export async function deniedModelIds(
ids: readonly string[],
deniedModels: readonly string[],
): Promise<Set<string>> {
if (ids.length === 0 || deniedModels.length === 0) return new Set();
const asked = ids.flatMap((id) => deniedModelSpellings(id).map((spelling) => ({ id, spelling })));
const { denied } = await matchDeniedModels(
asked.map(({ spelling }) => spelling),
deniedModels,
);
return new Set(asked.filter((_, index) => denied[index]).map(({ id }) => id));
}
/**
@@ -436,15 +474,16 @@ export function isDeniedModel(modelId: string, deniedModels: readonly string[]):
* - `undefined` is handled by the caller (no allowlist applied).
* - The Default option is unaffected by `availableModels` — it always remains
* available, even when the allowlist is `[]`.
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
* dropped even though the CLI would otherwise leave it unmatched.
* - Managed `deniedModels` wins over the allowlist: an entry is dropped when
* the list blocks its name, its `modelOverrides` target, or the model of the
* row it resolves to (see {@link deniedModelIds}).
*/
export function applyAvailableModelsAllowlist(
export async function applyAvailableModelsAllowlist(
sdkModels: ModelInfo[],
allowlist: string[],
settingsModelOverrides?: Record<string, string>,
deniedModels: readonly string[] = [],
): ModelInfo[] {
): Promise<ModelInfo[]> {
// Default is always preserved per the docs. Synthesize one if the SDK
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
// `models[0]` as a fallback) still has something to work with.
@@ -465,22 +504,21 @@ export function applyAvailableModelsAllowlist(
// opaque provider IDs) often won't textually resemble anything in
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
// override target, so it's what `setModel` ends up passing to the API.
for (const entry of allowlist) {
const entries = allowlist.flatMap((entry) => {
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) continue;
const overridden = settingsModelOverrides?.[trimmed];
const effective = overridden ?? trimmed;
if (seen.has(effective)) continue;
if (!trimmed) return [];
const effective = settingsModelOverrides?.[trimmed] ?? trimmed;
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
if (
[trimmed, effective, sdkMatch?.resolvedModel].some(
(id) => id != null && isDeniedModel(id, deniedModels),
)
) {
continue;
}
const ids = [trimmed, effective, ...(sdkMatch?.resolvedModel ? [sdkMatch.resolvedModel] : [])];
return [{ trimmed, effective, sdkMatch, ids }];
});
const denied = await deniedModelIds(
entries.flatMap(({ ids }) => ids),
deniedModels,
);
for (const { trimmed, effective, sdkMatch, ids } of entries) {
if (seen.has(trimmed) || seen.has(effective)) continue;
if (ids.some((id) => denied.has(id))) continue;
if (sdkMatch) {
result.push({ ...sdkMatch, value: effective });
} else {

View File

@@ -7,6 +7,7 @@ import {
resolveSettings,
type Settings,
} from "@anthropic-ai/claude-agent-sdk";
import { unloadedPolicySources } from "./managed-policy.js";
import { claudeConfigDir } from "./paths.js";
/**
@@ -62,6 +63,8 @@ export class SettingsManager {
private managedModelSettings: ModelSelectionSettings = {};
private managedApiKeyHelper?: string;
private managedCompactionSettings: CompactionSettings = {};
/** The policy sources last logged as not loaded (see {@link unloadedPolicySources}). */
private unloadedPolicy?: string;
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
@@ -118,6 +121,17 @@ export class SettingsManager {
private async loadAllSettings(): Promise<void> {
try {
const resolved = await resolveSettings({ cwd: this.cwd });
// Once per change: a reload after each settings file edit would repeat it.
const unloaded = unloadedPolicySources(resolved);
if (unloaded !== this.unloadedPolicy) {
this.unloadedPolicy = unloaded;
if (unloaded) {
this.logger.error(
`Managed settings did not load from ${unloaded}: the session's settings ` +
"and model list do not reflect it. Claude Code reads the policy itself.",
);
}
}
this.effective = filterEscalatingDefaultMode(resolved);
// The CLI reads `deniedModels` from managed settings only.
this.managedDeniedModels = resolved.sources

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { applyManagedPolicyEnv } from "../managed-policy.js";
import { applyManagedPolicyEnv, unloadedPolicySources } from "../managed-policy.js";
type ResolveSettings = typeof resolveSettings;
@@ -57,6 +57,25 @@ describe("applyManagedPolicyEnv", () => {
);
});
it("reports a policy source that did not load on stderr, and still applies the rest", async () => {
const env: Record<string, string | undefined> = {};
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
await applyManagedPolicyEnv(
resolverReturning({
effective: { env: { FROM_FILE: "1" } },
machinePolicy: { didNotLoad: ["profile"] },
}),
env,
);
expect(env).toEqual({ FROM_FILE: "1" });
expect(stderr).toHaveBeenCalledWith(
"Managed settings did not load from the macOS MDM profile: its env vars are not applied. " +
"Restart the agent to read it again.",
);
});
it("asks the SDK for the managed tier only", async () => {
const seen: unknown[] = [];
const resolve = (async (options: unknown) => {
@@ -69,3 +88,18 @@ describe("applyManagedPolicyEnv", () => {
expect(seen).toEqual([{ settingSources: [] }]);
});
});
describe("unloadedPolicySources", () => {
it.each([
// What the SDK reports when every source loaded.
[{ machinePolicy: { didNotLoad: false as const } }, undefined],
// An SDK before 0.3.323 leaves the member out.
[{}, undefined],
[
{ machinePolicy: { didNotLoad: ["registry" as const, "file" as const] } },
"the Windows registry policy, the managed-settings file",
],
])("names the sources of %j", (resolved, expected) => {
expect(unloadedPolicySources(resolved)).toBe(expected);
});
});

View File

@@ -168,7 +168,7 @@ describe("versioned model display names", () => {
{ 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 models = await applyAvailableModelsAllowlist(sdkModels, ids);
const setModel = vi.fn();
const state = await getAvailableModels(
{ setModel } as unknown as Query,

View File

@@ -7,7 +7,7 @@ import {
aliasResumedModelRow,
resolveModelPreference,
applyAvailableModelsAllowlist,
isDeniedModel,
deniedModelIds,
matchResumedModel,
modelEntryFor,
} from "../session-model.js";
@@ -304,8 +304,8 @@ describe("aliasResumedModelRow", () => {
});
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
it("resolves an allowlist entry pinned to a canonical resolvedModel id", async () => {
const result = await applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
const entry = result.find((m) => m.value === "claude-sonnet-5");
expect(entry).toBeDefined();
expect(entry?.displayName).toBe("Sonnet");
@@ -324,14 +324,14 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
},
];
it("surfaces the override target as the value while keeping the alias's display metadata", () => {
it("surfaces the override target as the value while keeping the alias's display metadata", async () => {
// The override target is an opaque Bedrock ARN with no "opus" substring,
// so matching against it (instead of the "claude-opus-4-6" alias) would
// find nothing and silently drop displayName/description/supportsEffort.
const overrides = {
"claude-opus-4-6": "arn:aws:bedrock:us-west-2:111122223333:inference-profile/custom-7f3a",
};
const result = applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides);
const result = await applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides);
const entry = result.find((m) => m.value === overrides["claude-opus-4-6"]);
expect(entry).toEqual({
@@ -345,8 +345,8 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
});
describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
it("drops an allowlisted model that deniedModels blocks, keeping Default", () => {
const result = applyAvailableModelsAllowlist(
it("drops an allowlisted model that deniedModels blocks, keeping Default", async () => {
const result = await applyAvailableModelsAllowlist(
LIVE_SHAPED_MODELS,
["claude-sonnet-5", "claude-opus-5-5"],
undefined,
@@ -355,43 +355,87 @@ describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
expect(result.map((m) => m.value)).toEqual(["default", "claude-sonnet-5"]);
});
it("checks an alias entry against the row it resolves to", () => {
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
it("checks an alias entry against the row it resolves to", async () => {
const result = await applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
"claude-sonnet-5",
]);
expect(result.map((m) => m.value)).toEqual(["default"]);
});
it("checks an entry against its modelOverrides target", async () => {
const result = await applyAvailableModelsAllowlist(
LIVE_SHAPED_MODELS,
["sonnet", "haiku"],
{
haiku:
"arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-opus-5-5-v1:0",
},
["claude-opus-5-5"],
);
expect(result.map((m) => m.value)).toEqual(["default", "sonnet"]);
});
});
describe("isDeniedModel", () => {
it("blocks every spelling of a denied version and its later minors", () => {
const denied = ["claude-opus-5"];
expect(isDeniedModel("claude-opus-5", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5-20260101", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5[1m]", denied)).toBe(true);
expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true);
expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false);
expect(isDeniedModel("anthropic/claude-opus-5.5:batch[1m]", denied)).toBe(true);
expect(isDeniedModel("anthropic/us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
});
it("reads an entry in OpenRouter or dotted spelling as the dashed SDK id", () => {
expect(isDeniedModel("claude-opus-4-5", ["claude-opus-4.5"])).toBe(true);
expect(isDeniedModel("claude-opus-5-5", ["anthropic/claude-opus-5.5"])).toBe(true);
expect(isDeniedModel("claude-opus-5", ["anthropic/claude-opus-5.5"])).toBe(false);
});
it("blocks only that version when the entry names a minor", () => {
expect(isDeniedModel("claude-opus-5", ["claude-opus-5-5"])).toBe(false);
expect(isDeniedModel("claude-opus-5-5-fast", ["claude-opus-5-5"])).toBe(true);
});
it("blocks a whole family for a family alias and ignores settings-dependent aliases", () => {
expect(isDeniedModel("claude-haiku-4-5-20251001", ["haiku"])).toBe(true);
expect(isDeniedModel("haiku", ["haiku"])).toBe(true);
expect(isDeniedModel("claude-sonnet-5", ["haiku"])).toBe(false);
expect(isDeniedModel("claude-opus-5", ["default", "best", "opusplan"])).toBe(false);
describe("deniedModelIds", () => {
// What CLI 2.1.323 answered `setModel(id)` under each managed `deniedModels`
// list: it refused the `blocked` ids as restricted by the organization, and
// not the `allowed` ones.
it.each<{ name: string; denied: string[]; blocked: string[]; allowed: string[] }>([
{
name: "a family alias blocks the family",
denied: ["opus"],
blocked: ["opus[1m]", "vertex_ai/claude-opus-5-5", "claude-opus-5.5", "my-opus-proxy"],
allowed: ["opusplan", "anthropic/openai/gpt-5", "claude-sonnet-5.5"],
},
{
name: "a version without a minor blocks its later minors",
denied: ["claude-opus-5"],
blocked: ["claude-opus-5", "claude-opus-5-5", "us.anthropic.claude-opus-5-5-v1:0"],
allowed: ["opus", "claude-opus-4-5"],
},
{
name: "a version with a minor blocks that version in every spelling",
denied: ["claude-opus-5-5"],
blocked: [
"claude-opus-5-5-fast",
"claude-opus-5-5[1m]",
"claude-opus-5-5@20260101",
"openrouter/anthropic/claude-opus-5-5",
],
allowed: ["claude-opus-5", "opus", "claude-opus-5-50"],
},
{
name: "a version is blocked behind a :variant suffix and written with a dot",
denied: ["claude-opus-5-5"],
blocked: [
"claude-opus-5-5:free",
"claude-opus-5-5:batch[1m]",
"anthropic/claude-opus-5.5",
"x/claude-opus-5.5:free",
"claude-opus-5.5-20260101",
],
allowed: ["claude-opus-5.50", "anthropic/claude-opus-5"],
},
{
name: "an entry that is no version blocks that exact name, also as the last path segment",
denied: ["my-model"],
blocked: ["My-Model", "gateway/my-model", "a/b/my-model", "gateway/my-model[1m]"],
allowed: ["my-model:free", "gateway/my-model:free", "my-model-2", "my-model/x"],
},
{
name: "a dotted entry is an exact name, not a version",
denied: ["claude-opus-4.5"],
blocked: ["claude-opus-4.5", "anthropic/claude-opus-4.5"],
allowed: ["claude-opus-4-5", "claude-opus-4-5-20251101", "claude-opus-4.5:batch"],
},
{
name: "an alias whose model depends on the release blocks nothing",
denied: ["default", "best", "opusplan"],
blocked: [],
allowed: ["default", "best", "opusplan", "opus"],
},
])("$name", async ({ denied, blocked, allowed }) => {
expect([...(await deniedModelIds([...allowed, ...blocked], denied))]).toEqual(blocked);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import * as os from "node:os";
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { SettingsManager } from "../settings.js";
@@ -90,3 +91,30 @@ describe("model selection settings policy", () => {
});
});
});
describe("a managed policy source that did not load", () => {
let manager: SettingsManager;
afterEach(() => manager?.dispose());
it("is logged once while the same source stays unloaded", async () => {
vi.mocked(resolveSettings).mockResolvedValue({
effective: {},
provenance: {},
sources: [],
machinePolicy: { didNotLoad: ["profile"] },
});
const error = vi.fn();
manager = new SettingsManager(process.cwd(), { logger: { log: () => {}, error } });
await manager.initialize();
// Another cwd resolves the settings again.
await manager.setCwd(os.tmpdir());
expect(resolveSettings).toHaveBeenCalledTimes(2);
expect(error.mock.calls).toEqual([
[
"Managed settings did not load from the macOS MDM profile: the session's settings " +
"and model list do not reflect it. Claude Code reads the policy itself.",
],
]);
});
});

View File

@@ -0,0 +1,63 @@
import { randomUUID } from "node:crypto";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, it } from "vitest";
import { RetractedToolCalls } from "../tool-calls/retracted.js";
/** An assistant message that calls the tools `toolUseIds`, as the SDK sends it. */
function assistant(toolUseIds: string[], supersedes?: string[]) {
return {
type: "assistant",
message: {
id: "msg_1",
type: "message",
role: "assistant",
model: "claude-fable-5",
content: toolUseIds.map((id) => ({ type: "tool_use", id, name: "Read", input: {} })),
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "session",
...(supersedes && { supersedes }),
} as unknown as SDKMessage & { uuid: string };
}
/** The notice of a refusal fallback that took back the messages `retracted`. */
const notice = (retracted: string[]) =>
({
type: "system",
subtype: "model_refusal_fallback",
retracted_message_uuids: retracted,
}) as unknown as SDKMessage;
describe("RetractedToolCalls", () => {
it("gives the tool calls of a message once, though the notice and the retry both name it", () => {
const calls = new RetractedToolCalls();
const refused = assistant(["toolu_a", "toolu_b"]);
calls.sent(refused);
expect(calls.retractedBy(notice([refused.uuid]))).toEqual(["toolu_a", "toolu_b"]);
expect(calls.retractedBy(assistant([], [refused.uuid]))).toEqual([]);
});
it("leaves out a tool call that the replacing message makes itself", () => {
const calls = new RetractedToolCalls();
const refused = assistant(["toolu_gone", "toolu_again"]);
calls.sent(refused);
expect(calls.retractedBy(assistant(["toolu_again"], [refused.uuid]))).toEqual(["toolu_gone"]);
});
it("forgets the oldest of more than 1000 messages", () => {
const calls = new RetractedToolCalls();
const oldest = assistant(["toolu_oldest"]);
calls.sent(oldest);
const others = Array.from({ length: 1000 }, (_, i) => assistant([`toolu_${i}`]));
for (const message of others) calls.sent(message);
expect(calls.retractedBy(notice([oldest.uuid]))).toEqual([]);
expect(calls.retractedBy(notice([others[0]!.uuid]))).toEqual(["toolu_0"]);
});
});

View File

@@ -13406,6 +13406,30 @@ describe("stop reason propagation", () => {
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
});
it("adds the turn's rate limit to a rate-limit failure's data", async () => {
const agent = createMockAgent();
const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" };
injectSession(agent, [
{
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: randomUUID(),
session_id: "test-session",
},
createAssistantError("rate_limit"),
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)",
}),
]);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toMatchObject({ data: { errorKind: "rate_limit", rateLimit } });
});
it.each(
[false, true].flatMap((typedFailures) =>
[undefined, "authentication_failed" as const].map((staleError) => ({
@@ -14033,6 +14057,74 @@ describe("model refusal fallback handling", () => {
),
).toBe(false);
});
// Claude Code takes the response of a model that refuses back, stops its
// tool call without a result, and retries on the fallback model. Its notice,
// or the first message of the retry, names the message that it took back.
// Nothing else ends the call: the prompt used to fail with
// `incomplete_tool_call` at the turn's end, although the retry answered.
it.each<[string, { notice: boolean; retry: boolean }]>([
["the fallback notice", { notice: true, retry: false }],
["the first message of the retry", { notice: false, retry: true }],
])(
"ends the tool call of a response that Claude Code took back, named by %s",
async (_name, names) => {
const { agent, sessionUpdate } = createCapturingAgent();
const assistant = (id: string, block: Record<string, unknown>) => ({
type: "assistant",
message: {
id,
type: "message",
role: "assistant",
model: "claude-fable-5",
content: [block],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
});
const refused = assistant("msg_refused", {
type: "tool_use",
id: "toolu_refused",
name: "Read",
input: { file_path: "/a.log" },
});
injectGeneratorSession(
agent,
makeGenerator([
refused,
refusalFallbackMessage(names.notice ? { retracted_message_uuids: [refused.uuid] } : {}),
{
...assistant("msg_retry", { type: "text", text: "The logs are clean." }),
...(names.retry && { supersedes: [refused.uuid] }),
},
successResult(),
]),
modelStateOverrides,
);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
const closed = updates.findIndex(
(u) => u.toolCallId === "toolu_refused" && u.status === "failed",
);
expect(JSON.stringify(updates[closed])).toContain(
"Claude Code replaced the response that made this tool call",
);
// Before the retry's answer.
expect(closed).toBeLessThan(
updates.findIndex((u) => u.content?.text === "The logs are clean."),
);
},
);
});
describe("MCP server startup failures", () => {

View File

@@ -1493,20 +1493,7 @@ describe("ACP v2 sessions", () => {
it("reports a refusal fallback with a notice and follows the model it swapped to", async () => {
sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] });
scriptTurns(async function* (options) {
yield {
type: "system",
subtype: "model_refusal_fallback",
trigger: "refusal",
direction: "retry",
scope: "session",
original_model: "claude-opus-4-8",
fallback_model: "claude-haiku-4-5",
request_id: "req_1",
api_refusal_category: "cyber",
content: "banner",
uuid: randomUUID(),
session_id: options.sessionId,
};
yield refusalFallback(options);
yield assistantText(options, "Done.");
yield result(options);
yield idleState(options);
@@ -1763,6 +1750,28 @@ function idleState(options: Options) {
};
}
/**
* Claude Code's notice that it retried a refused response on the fallback
* model. `retracted` names the messages of that response, which it took back.
*/
function refusalFallback(options: Options, retracted?: string[]) {
return {
type: "system",
subtype: "model_refusal_fallback",
trigger: "refusal",
direction: "retry",
scope: "session",
original_model: "claude-opus-4-8",
fallback_model: "claude-haiku-4-5",
request_id: "req_1",
api_refusal_category: "cyber",
...(retracted && { retracted_message_uuids: retracted }),
content: "banner",
uuid: randomUUID(),
session_id: options.sessionId,
};
}
function assistantText(options: Options, text: string, id = "msg_answer") {
return {
type: "assistant",
@@ -3831,6 +3840,57 @@ describe("ACP v2 prompts", () => {
expect(turnTrace(client.sessionUpdates).at(-1)).toBe("idle end_turn");
});
/**
* Scenario:
* 1. Claude's response calls Read, and the model then refuses.
* 2. Claude Code takes the response back, stops the call without a result,
* and retries on the fallback model. Its notice, or the first message of
* the retry, names the message that it took back.
* Expected:
* - The call ends `cancelled` when it is named, before the retry's answer.
* Nothing else ends a call that ran: it would stay open past the idle.
*/
it.each<[string, { notice: boolean; retry: boolean }]>([
["the fallback notice", { notice: true, retry: false }],
["the first message of the retry", { notice: false, retry: true }],
])(
"ends the tool call of a response that Claude Code took back, named by %s",
async (_name, names) => {
scriptTurns(async function* (options) {
const refused = toolUseMessage(options, "toolu_refused", "Read", { file_path: "/a.log" });
yield refused;
yield refusalFallback(options, names.notice ? [refused.uuid] : undefined);
yield {
...assistantText(options, "The logs are clean."),
...(names.retry && { supersedes: [refused.uuid] }),
};
yield result(options);
});
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
await client.authUpdate(1);
});
const updates = client.sessionUpdates.map(({ update }) => update);
const closed = updates.findIndex(
(update) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "toolu_refused" &&
update.status === "cancelled",
);
expect(JSON.stringify(updates[closed])).toContain(
"Claude Code replaced the response that made this tool call",
);
expect(closed).toBeLessThan(
updates.findIndex((update) => update.sessionUpdate === "agent_message_chunk"),
);
},
);
it("reports a turn that fails after it was taken in as idle with the error stop reason", async () => {
scriptTurns(async function* (options) {
yield result(options, { is_error: true, result: "API Error: 529 Overloaded" });

View File

@@ -143,6 +143,32 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("v2 core with Claude Code",
]);
}, 180_000);
it("ends the work once when Claude Code folds a prompt into it", async () => {
const sessionId = await newSession("bypassPermissions");
await prompt(
sessionId,
"Use the Bash tool in the foreground (not run_in_background) to run exactly: ping -c 8 127.0.0.1 > /dev/null . Then reply with just: woke",
);
await until(() =>
updates.some(
({ update }) =>
update.sessionUpdate === "terminal_update" &&
/ping/.test((update as v2.TerminalUpdate).command ?? ""),
),
);
// Claude Code takes this prompt in after the command, into the same work.
// It sends the prompt's `completed` frame before the work's result.
await prompt(sessionId, "Reply with exactly the single word: pong");
await until(() => states(sessionId).some((update) => update.state === "idle"));
// A turn that the result opened would follow the idle at once.
await new Promise((resolve) => setTimeout(resolve, 2000));
expect(states(sessionId).map((update) => update.state)).toEqual(["running", "idle"]);
// The idle is the result's: it carries what the work spent.
const idle = states(sessionId).at(-1) as { stopReason?: string; usage?: v2.Usage };
expect(idle.stopReason).toBe("end_turn");
expect(idle.usage?.totalTokens).toBeGreaterThan(0);
}, 240_000);
it("fails a queued prompt with -32800 at a cancel and goes idle with cancelled", async () => {
const sessionId = await newSession("bypassPermissions");
await prompt(

View File

@@ -1706,6 +1706,62 @@ describe("ACP v2 core", () => {
});
});
it.each([
["the turn of another prompt", true],
["a cycle that Claude Code started on its own", false],
])("ends the work once, at its result, when a prompt is folded into %s", async (_, prompted) => {
const client = v2Client();
await client.connect(async (agent) => {
const { response, claude } = await openSession(agent);
const ask = () =>
agent.request(v2.methods.agent.session.prompt, {
sessionId: response.sessionId,
prompt: PROMPT,
});
let opener: SDKUserMessage | undefined;
if (prompted) {
const first = ask();
opener = await claude.prompt(0);
claude.send(lifecycle(opener, "started"));
await first;
}
claude.send(cycleStart());
await vi.waitFor(() => expect(client.states()).toEqual(["running"]));
// As the bundled CLI sends it: a folded prompt's echo comes before its
// `started`, and its `completed` before the result of the work.
const second = ask();
const folded = await claude.prompt(prompted ? 1 : 0);
claude.send(echo(folded));
claude.send(lifecycle(folded, "started"));
await second;
for (const event of textDelta("msg_pong", "pong")) claude.send(event);
claude.send(assistant("msg_pong", [{ type: "text", text: "pong" }]));
claude.send(lifecycle(folded, "completed"));
claude.send(
result({
result: "pong",
origin: { kind: prompted ? "human" : "task-notification" },
user_message_uuids: [...(opener ? [opener.uuid] : []), folded.uuid],
}),
);
if (opener) claude.send(lifecycle(opener, "completed"));
claude.send(sessionState("idle"));
await vi.waitFor(() => expect(client.states()).toContain("idle"));
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(client.states()).toEqual(["running", "idle"]);
expect(
client.sessionUpdates.filter((update) => update.sessionUpdate === "agent_message_chunk"),
).toMatchObject([{ messageId: "msg_pong" }]);
// The idle carries the result's usage: the result ended the work.
expect(client.sessionUpdates.at(-1)).toMatchObject({
state: "idle",
stopReason: "end_turn",
usage: { totalTokens: 6 },
});
});
it("reports a tool call that Claude dropped before it ran as cancelled at the turn's end", async () => {
const client = v2Client();
await client.connect(async (agent) => {
@@ -2467,6 +2523,29 @@ describe("ACP v2 core", () => {
});
});
it("adds the turn's rate limit to a rate-limit failure's error data", async () => {
const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" };
const client = v2Client();
await client.connect(async (agent) => {
const { claude } = await openSession(agent);
claude.send(cycleStart());
claude.send({
type: "rate_limit_event",
rate_limit_info: rateLimit,
uuid: "00000000-0000-4000-8000-00000000e002",
session_id: "session",
});
claude.send({ ...assistant("msg_limited", []), error: "rate_limit" });
claude.send(result({ is_error: true, result: "You've hit your 5-hour limit" }));
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
});
expect(client.sessionUpdates.at(-1)).toMatchObject({
stopReason: "error",
error: { data: { errorKind: "rate_limit", rateLimit } },
});
});
it.each([
["a notice when the turn goes on to end", { result: "" }, "end_turn", 1],
["a notice once when the result repeats its text", {}, "end_turn", 1],

View File

@@ -686,21 +686,31 @@ export class AcpToolCallRenderer {
* client `failed`, since v1 has no `cancelled`. If Claude Code ran it but
* returned no result, it failed, unless a `session/cancel` ended the turn
* (`cancelled`): the cancel stopped the run, so a v2 client gets `cancelled`
* for it too. The report does not end the call's terminal: see {@link terminalExit}.
* for it too. The same goes for a call that Claude Code stopped when it took
* back the message that made it (`retracted`, see `RetractedToolCalls`),
* which closes when that is known, before the turn ends. The report does not
* end the call's terminal: see {@link terminalExit}.
*/
unfinished(call: { toolCallId: string; ran: boolean; cancelled?: boolean }): ToolCallUpdate {
const stopped = !call.ran || call.cancelled === true;
unfinished(call: {
toolCallId: string;
ran: boolean;
cancelled?: boolean;
retracted?: boolean;
}): ToolCallUpdate {
const stopped = !call.ran || call.cancelled === true || call.retracted === true;
return {
sessionUpdate: "tool_call_update",
toolCallId: call.toolCallId,
status: stopped && this.capabilities.v2 ? V2_CANCELLED : "failed",
content: [
textContent(
!call.ran
? "Claude stopped this tool call before it ran."
: call.cancelled
? "The turn was cancelled before this tool call returned a result."
: "Claude ended the turn without returning a result for this tool.",
call.retracted
? "Claude Code replaced the response that made this tool call, and stopped the call. If it had started, some of its effects may have happened."
: !call.ran
? "Claude stopped this tool call before it ran."
: call.cancelled
? "The turn was cancelled before this tool call returned a result."
: "Claude ended the turn without returning a result for this tool.",
),
],
};

View File

@@ -0,0 +1,68 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
/** The most assistant messages whose tool uses are kept. The oldest are dropped. */
const MAX_MESSAGES = 1000;
/**
* The tool uses of the assistant messages that Claude Code sent, by message
* `uuid`, to find the tool calls of a message that Claude Code takes back.
*
* Claude Code takes back the response of a model that refuses, and retries on
* the fallback model. It stops the tool calls of that response that have no
* result yet, and sends no result for them. It names the messages it took
* back, by the `uuid` they were sent with:
* - in `retracted_message_uuids` of the `model_refusal_fallback` notice;
* - in `supersedes` of the first assistant message of the retry.
*
* Both name the same messages, so a tool call is given once. Since Claude
* Code 2.1.323 `supersedes` also names a response whose tool call could not
* be parsed, which Claude Code retries too. Such a response holds no tool
* use: its stop reason is `tool_use` and it has no `tool_use` block.
*/
export class RetractedToolCalls {
private readonly toolUses = new Map<string, string[]>();
/** Records the tool uses of a complete assistant message. */
sent(message: SDKMessage): void {
if (message.type !== "assistant" || typeof message.uuid !== "string") return;
const ids = toolUseIds(message);
if (ids.length === 0) return;
this.toolUses.delete(message.uuid);
this.toolUses.set(message.uuid, ids);
if (this.toolUses.size > MAX_MESSAGES) {
this.toolUses.delete(this.toolUses.keys().next().value!);
}
}
/**
* The tool calls of the messages that `message` takes back, each given
* once. A tool call that `message` makes itself is not among them: the
* message that replaces another can hold the same tool use.
*/
retractedBy(message: SDKMessage): string[] {
const own = new Set(toolUseIds(message));
const retracted: string[] = [];
for (const uuid of retractedMessageUuids(message)) {
for (const id of this.toolUses.get(uuid) ?? []) {
if (!own.has(id)) retracted.push(id);
}
this.toolUses.delete(uuid);
}
return retracted;
}
}
/** The `uuid`s of the messages that `message` says Claude Code took back. */
function retractedMessageUuids(message: SDKMessage): readonly string[] {
if (message.type === "assistant") return message.supersedes ?? [];
if (message.type === "system" && message.subtype === "model_refusal_fallback") {
return message.retracted_message_uuids ?? [];
}
return [];
}
/** The ids of the tool uses of an assistant message. */
function toolUseIds(message: SDKMessage): string[] {
if (message.type !== "assistant" || !Array.isArray(message.message.content)) return [];
return message.message.content.flatMap((block) => (block.type === "tool_use" ? [block.id] : []));
}

View File

@@ -76,6 +76,7 @@ import {
Query,
query,
SDKAssistantMessageError,
SDKRateLimitInfo,
SDKActiveGoalMessage,
SDKMessage,
SDKMessageOrigin,
@@ -330,6 +331,7 @@ import { previewPatchContent } from "../diff.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaClient, ClientConnection } from "./client-connection.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { RetractedToolCalls } from "../tool-calls/retracted.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "../tool-calls/client-capabilities.js";
import {
AcpToolCallRenderer,
@@ -420,7 +422,7 @@ import {
providerCacheKeyFor,
readContextWindow,
} from "../context-window.js";
import { isLoginRequiredText, resultEnd } from "../result-end.js";
import { isLoginRequiredText, resultEnd, turnErrorData } from "../result-end.js";
import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js";
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
import { subagentHookOptions } from "../subagent-configuration.js";
@@ -1172,6 +1174,10 @@ export type Session = {
* `tool_call_update` resends only the fields that changed. Created lazily
* by {@link toolCallFieldsOf}. */
toolCallFields?: ToolCallFieldTracker;
/** The tool uses of the assistant messages, to end the tool calls of a
* message that Claude Code takes back. Created lazily by
* {@link retractedToolCallsOf}. */
retractedToolCalls?: RetractedToolCalls;
/** ACP session affinity for calls emitted eagerly by permission handling. */
eagerToolCallSessions?: Map<string, string>;
/** ExitPlanMode denial that intentionally interrupts the current Claude
@@ -3089,6 +3095,8 @@ export class ClaudeAcpAgent {
let lastAssistantModel: string | null = null;
// A rate_limit_event that came before the turn had any usage to report.
let pendingRateLimit: unknown = null;
// The turn's latest rate_limit_event info, for its failure (see `turnErrorData`).
let turnRateLimit: SDKRateLimitInfo | undefined;
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
// problem, billing), it sets a categorical `error` field on the
// `SDKAssistantMessage` that precedes the final `result` message. We capture
@@ -3456,6 +3464,7 @@ export class ClaudeAcpAgent {
lastAssistantUsage = null;
lastAssistantModel = null;
lastAssistantError = undefined;
turnRateLimit = undefined;
lastAssistantWasUsageLimit = false;
lastAssistantFailureTitle = undefined;
lastRefusalExplanation = null;
@@ -3939,19 +3948,23 @@ export class ClaudeAcpAgent {
parentToolUseId !== null && runsInBackground(parentToolUseId);
/** Ends the open tool calls that `include` accepts, with the report of a call
* that a cancel stopped and sent no result for. `include` gets the Agent/Task
* call of the subagent that made the tool call, if a subagent did. */
* that was stopped and sent no result: by a cancel, or (`stoppedBy`
* `retracted`) by Claude Code when it took back the message that made the
* call. `include` gets the Agent/Task call of the subagent that made the
* tool call, if a subagent did, and the tool call. */
const closeOpenToolCalls = async (
include: (parentToolUseId: string | undefined) => boolean,
include: (parentToolUseId: string | undefined, toolCallId: string) => boolean,
stoppedBy: "cancelled" | "retracted" = "cancelled",
): Promise<void> => {
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
for (const toolCallId of [...session.emittedToolCalls]) {
const parentToolUseId = session.toolCallParents?.get(toolCallId);
if (!include(parentToolUseId)) continue;
if (!include(parentToolUseId, toolCallId)) continue;
const report = renderer.unfinished({
toolCallId,
ran: session.dispatchedToolCalls?.has(toolCallId) === true,
cancelled: true,
cancelled: stoppedBy === "cancelled",
retracted: stoppedBy === "retracted",
});
// Named as the call's other updates are: the subagent runtime routes the
// report with them, to the child session, or to none that has ended.
@@ -3960,6 +3973,14 @@ export class ClaudeAcpAgent {
}
};
/** Ends the open tool calls of the messages that `message` says Claude
* Code took back (see `RetractedToolCalls`). No result comes for them. */
const closeRetractedToolCalls = async (message: SDKMessage): Promise<void> => {
const retracted = new Set(retractedToolCallsOf(session).retractedBy(message));
if (retracted.size === 0) return;
await closeOpenToolCalls((_parent, toolCallId) => retracted.has(toolCallId), "retracted");
};
/** Whether the closing at the end of the work a cancel stopped takes a tool
* call that the subagent started by the Agent call `parentToolUseId` made
* (undefined for a call of the main conversation): not one of a running
@@ -5384,6 +5405,7 @@ export class ClaudeAcpAgent {
// state sync the client's model picker (and the model-dependent
// options rebuilt from it) keeps advertising a model the
// session is no longer running (see `modelRefusalFallback`).
await closeRetractedToolCalls(message);
const fallback = modelRefusalFallback(message);
const { summary: fallbackSummary, explanation } = fallback;
// A silent model swap is a session-level advisory, not something the model said.
@@ -6061,7 +6083,10 @@ export class ClaudeAcpAgent {
}
await failActiveWithSessionFailure(
providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
internalErrorForClient(
turnErrorData(lastAssistantError, turnRateLimit),
ended.error,
),
lastAssistantFailureTitle ?? ended.error,
);
break;
@@ -6121,7 +6146,10 @@ export class ClaudeAcpAgent {
if ("error" in ended) {
await failActiveWithSessionFailure(
providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
internalErrorForClient(
turnErrorData(lastAssistantError, turnRateLimit),
ended.error,
),
lastAssistantFailureTitle ?? ended.error,
);
break;
@@ -6134,7 +6162,10 @@ export class ClaudeAcpAgent {
if ("error" in ended) {
await failActiveWithSessionFailure(
LIMIT_FAILURE_CATEGORY[message.subtype],
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
internalErrorForClient(
turnErrorData(lastAssistantError, turnRateLimit),
ended.error,
),
ended.error,
);
break;
@@ -6377,6 +6408,8 @@ export class ClaudeAcpAgent {
case "user":
case "assistant": {
if (message.type === "assistant") {
await closeRetractedToolCalls(message);
retractedToolCallsOf(session).sent(message);
recordDispatchedToolUses(session, message.message.content);
// A tool use that did not stream is reached here first.
const content = message.message.content;
@@ -6921,6 +6954,7 @@ export class ClaudeAcpAgent {
}
case "rate_limit_event": {
session.idleCompaction?.noteRateLimit(message.rate_limit_info);
turnRateLimit = message.rate_limit_info;
// With partial messages on, the SDK sends this before the turn's
// first assistant frame, while there is no usage yet to send it with.
if (lastAssistantTotalUsage === null) {
@@ -10221,7 +10255,7 @@ export class ClaudeAcpAgent {
creationOpts.resume !== undefined ? resumedModel : undefined,
);
const settingsAllowedModels = Array.isArray(settingsAvailableModels)
? applyAvailableModelsAllowlist(
? await applyAvailableModelsAllowlist(
sdkModels,
settingsAvailableModels,
settingsModelOverrides,
@@ -10943,6 +10977,13 @@ function toolCallFieldsOf(session: {
return (session.toolCallFields ??= new ToolCallFieldTracker());
}
/** The record of a session's assistant tool uses, created on first use. */
function retractedToolCallsOf(session: {
retractedToolCalls?: RetractedToolCalls;
}): RetractedToolCalls {
return (session.retractedToolCalls ??= new RetractedToolCalls());
}
const MAX_RESOLVED_TOOL_NAMES = 1000;
/** Keeps the tool names of the tool results in `content` before the tool use cache drops them. */

View File

@@ -12,11 +12,12 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type {
SDKAssistantMessage,
SDKAssistantMessageError,
SDKRateLimitInfo,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js";
import { isSyntheticLoginMessage } from "../message-filters.js";
import { resultEnd } from "../result-end.js";
import { resultEnd, turnErrorData } from "../result-end.js";
import { assistantMessageText, providerFailureCategory } from "../session-failure-extension.js";
import {
getMatchingModelUsage,
@@ -96,7 +97,7 @@ export function idleOutcome(
// authenticate: this login reached its time limit and cannot be
// renewed."
RequestError.authRequired(undefined, ended.loginRequired ? undefined : ended.error)
: RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error),
: RequestError.internalError(turnErrorData(kind, report?.rateLimit), ended.error),
);
}
@@ -118,6 +119,8 @@ function failed(error: RequestError): IdleOutcome {
*/
export class TurnReport {
errorKind: SDKAssistantMessageError | undefined;
/** The turn's latest `rate_limit_event` info (see `turnErrorData`). */
rateLimit: SDKRateLimitInfo | undefined;
/** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */
loginRequired = false;
/** The explanation of the turn's refusal. */
@@ -181,6 +184,7 @@ export class TurnReport {
reset(): void {
this.errorKind = undefined;
this.rateLimit = undefined;
this.loginRequired = false;
this.refusalExplanation = undefined;
this.apiError = undefined;

View File

@@ -141,6 +141,14 @@ export class V2Session implements RequestTarget {
private foreground = false;
/** The prompt whose `started` frame came last in the open turn. */
private turnCommand: string | undefined;
/**
* Whether a cycle of Claude Code runs in the open turn: one started (see
* {@link openTurn}) and its result has not come. Claude Code folds a prompt
* that starts meanwhile into that cycle.
*/
private cycleOpen = false;
/** The prompt that started the running cycle, if one did. */
private cycleCommand: string | undefined;
/** The state the client was told last. */
private reportedState: ForegroundState = "idle";
/** The open permission requests and elicitations of the session. */
@@ -1008,6 +1016,7 @@ export class V2Session implements RequestTarget {
return;
case "model_refusal_fallback": {
// Claude Code retried a refused turn on the fallback model.
this.output.closeRetracted(message);
const fallback = modelRefusalFallback(message);
this.output.emitV1([
{ sessionId: this.sessionId, update: noticeUpdate(fallback.notice) },
@@ -1043,6 +1052,7 @@ export class V2Session implements RequestTarget {
* output (see {@link SessionReports}).
*/
private report(message: SDKMessage): void {
if (message.type === "rate_limit_event") this.turnReport.rateLimit = message.rate_limit_info;
if (message.type === "tool_progress") {
this.output.toolProgress(message);
return;
@@ -1086,9 +1096,13 @@ export class V2Session implements RequestTarget {
);
return;
case "completed":
// Comes after the turn's result. A turn that ended without one ends here.
// Claude Code sends it after the result for the prompt that started
// the cycle, so a cycle that ended without a result ends here. For a
// prompt folded into the cycle it sends it before the result, which
// then ends the turn. Nothing but Claude Code's order tells the two
// apart.
this.stopped.promptEnded(uuid);
if (uuid === this.turnCommand) this.closeTurn();
if (uuid === this.cycleCommand) this.closeTurn();
return;
default:
// `queued`: the prompt waits in Claude Code's queue.
@@ -1151,6 +1165,10 @@ export class V2Session implements RequestTarget {
if (interrupted) this.cancelledWork = true;
// A result with no open turn: a turn that sent nothing before it.
this.openTurn(undefined);
// The result ends the cycle. A prompt that starts next in the open turn,
// such as the `/clear` of a fresh context, starts its own.
this.cycleOpen = false;
this.cycleCommand = undefined;
// A cycle that Claude Code started on its own leaves its Fast mode state
// to the next result of a prompt.
if (!(message.origin && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind))) {
@@ -1307,8 +1325,8 @@ export class V2Session implements RequestTarget {
// --- State ---------------------------------------------------------------
/**
* Opens a turn, if none is open. `command` is the prompt that started it,
* if one did.
* Opens a turn, if none is open, and starts a cycle in it, if none runs
* there. `command` is the prompt that started it, if one did.
*/
private openTurn(command: string | undefined): void {
if (command !== undefined) {
@@ -1317,6 +1335,10 @@ export class V2Session implements RequestTarget {
this.localCommands.delete(command);
if (local) this.turnLocalCommand = { command: local, abort: new AbortController() };
}
if (!this.cycleOpen) {
this.cycleOpen = true;
this.cycleCommand = command;
}
if (this.foreground) return;
this.foreground = true;
this.lastEnd = undefined;
@@ -1354,6 +1376,8 @@ export class V2Session implements RequestTarget {
this.output.cancelled = false;
this.lastEnd = undefined;
this.turnCommand = undefined;
this.cycleOpen = false;
this.cycleCommand = undefined;
this.turnReport.reset();
const ownCommandDone = this.ownCommandDone;
this.ownCommandDone = undefined;

View File

@@ -80,7 +80,7 @@ export async function sessionModels(
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new session does.
const listed = aliasResumedModelRow(sdkModels, resumed?.model);
const allowed = Array.isArray(availableModels)
? applyAvailableModelsAllowlist(
? await applyAvailableModelsAllowlist(
listed,
availableModels,
modelOverrides,

View File

@@ -16,6 +16,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk/unstable";
import type {
SDKAssistantMessage,
SDKMemoryRecallMessage,
SDKMessage,
SDKPartialAssistantMessage,
SDKPermissionDeniedMessage,
SDKToolProgressMessage,
@@ -40,6 +41,7 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
import { isCompactSummaryMessage, replayCompactSummary } from "../context-compaction.js";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { RetractedToolCalls } from "../tool-calls/retracted.js";
import { AcpToolCallRenderer, progressToolCallId, V2_CANCELLED } from "../tool-calls/renderer.js";
import { changedTaskPlanEntries, unregisterHookCallback, type TaskState } from "../tools.js";
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
@@ -100,6 +102,8 @@ export class SessionOutput {
private replayCleared: Set<string> | undefined;
/** The open tool calls whose tool use reached a complete assistant message: Claude Code runs them. */
private readonly dispatched = new Set<string>();
/** The tool uses of the assistant messages, for {@link closeRetracted}. */
private readonly retracted = new RetractedToolCalls();
/**
* Whether the output comes from work that a `session/cancel` stopped, until
* its idle. The session's streamed deltas still go out,
@@ -185,6 +189,8 @@ export class SessionOutput {
* cancel, the stopped work's message is dropped (see {@link cancelled}).
*/
assistant(message: SDKAssistantMessage): void {
this.closeRetracted(message);
this.retracted.sent(message);
const messageId = messageIdForGrouping(message);
for (const block of message.message.content) {
if (block.type === "tool_use") this.dispatched.add(block.id);
@@ -564,22 +570,44 @@ export class SessionOutput {
* The calls in `keep` stay open, such as one whose task runs in the background.
*/
closeUnfinished(stopped: boolean, keep: ReadonlySet<string> = new Set()): void {
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
for (const toolCallId of [...this.unfinished]) {
if (keep.has(toolCallId)) continue;
const ran = this.dispatched.has(toolCallId);
if (ran && !stopped) continue;
const report = renderer.unfinished({ toolCallId, ran, cancelled: stopped });
const toolUse = this.toolUseCache[toolCallId];
const terminalExit = toolUse && renderer.terminalExit(toolUse);
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
this.emittedToolCalls.delete(toolCallId);
delete this.toolUseCache[toolCallId];
this.toolCallFields.delete(toolCallId);
this.emit([{ sessionId: this.sessionId, update: report }]);
this.close({ toolCallId, ran, cancelled: stopped });
}
}
/**
* Ends the open tool calls of the messages that `message` says Claude Code
* took back (see `RetractedToolCalls`), `cancelled`: no result comes for
* them, and a call that ran stays open at the end of the work otherwise.
*/
closeRetracted(message: SDKMessage): void {
for (const toolCallId of this.retracted.retractedBy(message)) {
if (this.unfinished.has(toolCallId)) {
this.close({ toolCallId, ran: this.dispatched.has(toolCallId), retracted: true });
}
}
}
/**
* Ends the open tool call of `call` without its result, with the report of
* `AcpToolCallRenderer.unfinished`. A command's report also ends its
* terminal. The call is forgotten, so a late result changes nothing.
*/
private close(call: Parameters<AcpToolCallRenderer["unfinished"]>[0]): void {
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
const report = renderer.unfinished(call);
const toolUse = this.toolUseCache[call.toolCallId];
const terminalExit = toolUse && renderer.terminalExit(toolUse);
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
this.emittedToolCalls.delete(call.toolCallId);
delete this.toolUseCache[call.toolCallId];
this.toolCallFields.delete(call.toolCallId);
this.emit([{ sessionId: this.sessionId, update: report }]);
}
/** The tool calls that the client holds without a final status. */
private readonly unfinished = new Set<string>();