Compare commits

..

4 Commits

Author SHA1 Message Date
Ben Brandt
4804917a6f feat: report unknown subagent state at a conversation reset and reference children from tool calls (#1608)
Brings the RFD form of subagent reporting in line with the ACP [Subagent
Sessions RFD](https://agentclientprotocol.com/rfds/subagents), which is
moving to Preview in agentclientprotocol/agent-client-protocol#2800. The
related RFD clarifications merged in
agentclientprotocol/agent-client-protocol#2801. AIR output is unchanged,
and no scenario snapshots changed.

## Changes

- **`unknown` at a conversation reset.** A child that is still working
at `conversation_reset` now reports `unknown` and `capabilities: null`,
not `idle`. The adapter forgets its children at reset, so it can no
longer observe or stop them. v1 reports it on the parent's
`subagent_update`. v2 also sends `state_update` `unknown` on the child.
- Stream end, stream error, `queryEnded`/`retire`, and `worker_restart`
keep `idle`, because the work is known to have stopped.
- In v2, the forgotten child's unfinished tool calls keep their last
status. They are no longer forced to `failed`.
- **`session` tool call content.** Added live and in replay, for the v1
RFD form and v2, only when the child is already announced:
  - `TaskStop` whose `task_id` is a child's agent id.
  - `SendMessage` that stays visible and targets a child's agent id.
- v1 only: an Agent/Task launch that a permission request keeps visible.
- The reference is repeated on every update that carries content,
because `content` replaces the whole array.
- **Docs.** The README's subagent section matches the code: SendMessage
is hidden and reported as linked messages, `unknown` at reset, `session`
content, and no `session_message_chunk`. Three stale comments are fixed.

## Workarounds and limits

- **Post-response ordering still uses `setTimeout(0)`.** SDK 2.6.0 gives
`onRequest` handlers no responder or after-response hook. The comments
in `src/v2/agent.ts` and `src/v1/acp-agent.ts` now state what the
ordering relies on: the response is queued in the microtask after the
handler returns, and `Connection.sendMessage` is first-in, first-out.
The SDK does not promise this.
- **The `unknown` change assumes Claude Code keeps subagents running
across `/clear`.** That comes from the existing v2 comment and test
double. It was not checked against the real CLI.
- No reference is added when a tool names a child by agent name, or for
v2 launch calls that a permission request shows.
- A child that is idle at reset keeps its `cancel` capability. Unchanged
here.
- A stream error with a client-supplied `spawnClaudeCodeProcess` still
reports `idle`, although closing may not stop a remote CLI.

## Testing

- `npm run build`: clean.
- `npm run check`: clean.
- `npm run test:run`: 73 files passed, 5 skipped. 4117 tests passed, 33
skipped.
- Integration tests against the real CLI were not run.
2026-11-12 15:48:47 +00:00
Mark Tkachenko
c331e178a5 fix: explain why Claude Code turns Fast mode off (#1602)
When the user turns Fast mode on, Claude Code can report
`fast_mode_state: "off"` on the next turn's `system`/init. The adapter
mirrored that into the option but explained it only for reasons listed
in `FAST_MODE_UNAVAILABLE_EXPLANATIONS`. `preference`, `unknown` and any
newer code were dropped, so the toggle snapped off with no reason. This
happens on every turn behind an API gateway that authenticates with
`ANTHROPIC_AUTH_TOKEN` only: Claude Code treats Fast mode as disabled by
the organization (`preference`) without asking the server.

Changes:
- `preference` ("not enabled for your organization or API gateway") and
`unknown` ("currently unavailable") are explained. A reason code the SDK
adds later gets a generic `not available (<code>)`.
- Only `sdk_opt_in_required` (every SDK session starts there; the toggle
is the opt-in) and `pending` (eligibility still resolving) stay
unexplained.
- The notice rule is unchanged: Fast mode was on, then off with an
explained reason. Off without a reason stays quiet, because a `result`
can carry the state from the start of the turn.
- One shared function in `session-fast-mode.ts` serves v1 and v2. In
`src/v1/acp-agent.ts` only comments change.

Limitation: the adapter still offers the toggle up front. Until a
session opts in, Claude Code reports `sdk_opt_in_required` and hides the
account or organization blocker, so the adapter can't tell in advance
that Fast mode will be refused.

Tests: unit tests for each reason, plus v1 and v2 flow tests (set `fast`
on, prompt, init reports off/`preference`). The flow tests fail without
the fix.
2026-11-11 17:28:40 +01:00
Mark Tkachenko
830f05a0ac refactor: move the v1 session fingerprint out of acp-agent.ts (#1601)
Moves `computeSessionFingerprint` and its helpers (`normalizeSkills`,
`OPTION_REBUILDS_SESSION`, `canonicalJson`) verbatim from
`src/v1/acp-agent.ts` into `src/v1/session-fingerprint.ts`. No behaviour
change.

- Only `computeSessionFingerprint` is exported, as before; the helpers
stay private to the new module.
- `session-fingerprint.ts` imports nothing from `acp-agent.ts`. The test
that used `computeSessionFingerprint` now imports it from the new
module.
- Emitted JS differs from `main` only in `dist/v1/acp-agent.js` (one
import, the moved code removed) plus the new module.

Review with `git diff --color-moved` to see the move.
2026-11-11 15:31:40 +01:00
Ben Brandt
4df69996ca fix: report a mid-turn API error notice where the response broke off (#1599)
## Problem

When a response stream drops after some blocks arrived, Claude Code
keeps the partial response and writes its own message into the
conversation, e.g. "API Error: Connection lost mid-response. The
response above may be incomplete."

If the partial response contained tool calls, Claude Code runs them and
the turn goes on. Since #1582 the v2 agent sends this text as a
`notice`, but only at the turn's result. In a turn that goes on, the
notice arrived after all the later work, so "the response above" pointed
at the wrong output.

## Change

The v2 agent sends the pending notice as soon as the main conversation
goes on past the failed request: at the next tool result, stream event
or assistant message.

A turn that ends at the failed request behaves as before: the idle's
`error` carries it when the turn fails, and a notice goes out at the
turn's end otherwise.

## Not changed

- v1 keeps its current behaviour, as in #1582.
- `session/load` replay still shows these messages as text.

## Testing

- New v2 case: a kept tool call, the API error message, the tool result,
then more output. The notice comes before the later text. It fails
without the change.
- The test double follows the message shape and order read from the
bundled CLI's code. Not run against a real dropped connection.
- `npm run check`, `npm run build` and `npm run test:run` pass.
2026-11-11 13:45:06 +00:00
18 changed files with 1322 additions and 368 deletions

View File

@@ -53,81 +53,6 @@ npm run build:binary
npm run smoke:binary # add `-- --full` to drive a real session; needs a Claude login
```
### Subagent sessions
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
- `subagent_update` on the parent announces the child before any of its traffic, with the
parent's `title` and `description` for it, and reports its work state: `running`,
`requires_action` while one of its requests is open (and `running` again when the last one ends,
unless a cancel withdrew it: the child is stopping then), and `idle` when its work ends, with
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
stop reason and the SDK's error text as its `error`. The child keeps its session for every
delegation to it.
- A child that runs outside its parent's working directory, such as an Agent call with
`isolation: "worktree"`, gets its `cwd` on `subagent_update` before its first tool call. The
adapter takes it from the `cwd` of the child's own hooks: its SubagentStart hook runs before its
first model request. It sends `cwd: null` when the child is back in its parent's directory. A
child in its parent's directory gets no `cwd`, and no child gets `additionalDirectories`: Claude
Code gives a subagent the session's additional directories, so it inherits them. A replay
announces a child with the `cwd` that its transcript recorded, and clears it after the response
when that directory no longer exists.
- Session messages replace the Agent or Task call that launches the child, which its parent is
not shown:
- The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and
the child's incoming one. Both use the launch's tool use id, and each names the other with the
draft RFD's `recipientMessageId` and `senderMessageId`.
- A foreground child's answer, from the launch's result, is the child's `session_message` to
the parent. In Claude Code's hand-back mode, the child's `SubagentHandback` call is its
message to the parent instead, in both transcripts and linked the same way.
- A launch whose child is never exposed is shown as a tool call after all, with its final
status.
- The message of a SendMessage call that resumes the child is a `session_message` in the child's
transcript. The SendMessage call stays a tool call of the session that made it.
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
the turn waits on. Background children keep running, as the RFD prefers; a client stops one with
its own `cancel`. Each stopped child reports `idle` with `cancelled` from Claude Code's own
events, and its open requests are withdrawn. The adapter ends no child at a cancel itself, so a
child that keeps running keeps its open requests and its open tool calls. To spare background
children, the adapter declares the SDK option `perTaskStopAffordance`, which a client can turn
off in `_meta.claudeCode.options`. That option spares background workflows too, which a client
cannot stop, so the adapter stops them after the interrupt, as Claude Code did without it.
Background commands keep running after a cancel either way. A tool call of a stopped child that Claude Code reports no
result for, such as the command it was running, ends in the child's session before the cancelled
prompt resolves. When Claude Code stops a background child after that, or while no prompt runs,
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
exits is `idle` with no stop reason.
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
guessing its parent.
- A replay restores each child under its live session id, from its own transcript, with the same
session messages as live, and its end is an `idle` before the response.
A launch without Claude Code's metadata file for the child stays an ordinary tool call of the
parent, and a notice, for a client that takes notices, says that subagent history is missing.
- After a load or resume, with or without replay, a SendMessage that resumes an agent from before
it runs that agent in its session from before. Before the agent's traffic, each of its ancestors
that the client was not told about in this connection is announced, parent before child and
with no work state, as the RFD requires. An agent whose launch the session's history does not
record stays unexposed.
- Each running child is announced with the `cancel` capability. `session/cancel` with the child's
session id stops that child's current work, through the SDK's `Query.stopTask`, and not its
parent's: Claude Code also stops the foreground children it waits on, and the parent's turn goes
on. Each stopped child reports `idle` with `cancelled` from Claude Code's own events, and its
requests that the stop withdraws report no `running` first. A replayed child, which no longer
runs, gets no capabilities. A client cannot prompt or otherwise change a child.
An [ACP v2](#experimental-acp-v2) client gets the same without declaring anything, since subagents
are part of v2's baseline. Each child also reports its work state as its own `state_update`, right
after the parent's copy.
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
[AIR extensions](docs/air-extensions.md#native-subagent-sessions).
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
`forwardSubagentText` session option retain the flattened child transcript.
### Experimental ACP v2
The adapter also serves the [draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview):
@@ -143,7 +68,7 @@ with replay, `session/close`, `session/prompt`, `session/cancel`), `session/dele
`session/fork`, `session/status`, `session/options` (with initial `configOptions` on `session/new` and
`session/resume`), `session/set_title`, additional directories, config options (the mode is one), plans, notices,
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
[subagents](#subagent-sessions). Steering is v1 only for now.
[subagents](docs/subagent-sessions.md). Steering is v1 only for now.
v2 is served by the v2 agent in `src/v2/`, which runs straight on Claude
Code instead of translating to v1. A v2 client that declares JetBrains AIR

93
docs/subagent-sessions.md Normal file
View File

@@ -0,0 +1,93 @@
# Subagent sessions
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
- `subagent_update` on the parent announces the child before any of its traffic, with the
parent's `title` and `description` for it, and reports its work state: `running`,
`requires_action` while one of its requests is open (and `running` again when the last one ends,
unless a cancel withdrew it: the child is stopping then), and `idle` when its work ends, with
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
stop reason and the SDK's error text as its `error`. The child keeps its session for every
delegation to it.
- A child that still works when Claude Code starts a fresh conversation, such as for `/clear`, is
`unknown`, and loses its `cancel` capability. Claude Code can keep such a child running, but the
adapter forgets the children of the old conversation, so it reports nothing more for it and
cannot stop it. Its unfinished tool calls keep their status. An idle child keeps its state.
- A child that runs outside its parent's working directory, such as an Agent call with
`isolation: "worktree"`, gets its `cwd` on `subagent_update` before its first tool call. The
adapter takes it from the `cwd` of the child's own hooks: its SubagentStart hook runs before its
first model request. It sends `cwd: null` when the child is back in its parent's directory. A
child in its parent's directory gets no `cwd`, and no child gets `additionalDirectories`: Claude
Code gives a subagent the session's additional directories, so it inherits them. A replay
announces a child with the `cwd` that its transcript recorded, and clears it after the response
when that directory no longer exists.
- Session messages replace the Agent or Task call that launches the child, which its parent is
not shown:
- The launch's prompt is a `session_message` in both transcripts: the parent's outgoing view and
the child's incoming one. Both use the launch's tool use id, and each names the other with the
RFD's `recipientMessageId` and `senderMessageId`.
- A foreground child's answer, from the launch's result, is the child's `session_message` to
the parent. In Claude Code's hand-back mode, the child's `SubagentHandback` call is its
message to the parent instead, in both transcripts and linked the same way.
- A launch whose child is never exposed is shown as a tool call after all, with its final
status.
- A SendMessage call that resumes the child is not shown either. Its message is a
`session_message` in both transcripts, the sender's outgoing view and the child's incoming one,
linked like a launch's prompt. A SendMessage call whose message resumes no announced child,
such as one to the parent, is shown as a tool call, with its final status.
- Every message is one whole `session_message`. The adapter sends no `session_message_chunk`: it
has each message's full text before it reports it, from the input or the result of the tool call
that sent it.
- A tool call that the client is shown and that operates on an announced child of another session
carries the RFD's `session` content, `{ "type": "session", "sessionId": "<child>" }`, after its
other content: a TaskStop call whose `task_id`, or a SendMessage call whose `to`, is the child's
agent id; a SendMessage call that resumed the child and stays shown because a permission request
showed it first; and, on v1, an Agent or Task call that stays shown for the same reason. A
`tool_call_update` without content carries none, since it would replace the content the client
holds. A call that names the child by its name gets no reference: Claude Code resolves the
name, and the adapter cannot tell which agent it picked.
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
the turn waits on. Background children keep running, as the RFD prefers; a client stops one with
its own `cancel`. Each stopped child reports `idle` with `cancelled` from Claude Code's own
events, and its open requests are withdrawn. The adapter ends no child at a cancel itself, so a
child that keeps running keeps its open requests and its open tool calls. To spare background
children, the adapter declares the SDK option `perTaskStopAffordance`, which a client can turn
off in `_meta.claudeCode.options`. That option spares background workflows too, which a client
cannot stop, so the adapter stops them after the interrupt, as Claude Code did without it.
Background commands keep running after a cancel either way. A tool call of a stopped child that Claude Code reports no
result for, such as the command it was running, ends in the child's session before the cancelled
prompt resolves. When Claude Code stops a background child after that, or while no prompt runs,
the tool call ends right after the child reports `idle`. A child that still runs when Claude Code
exits is `idle`, since its work ended with the process: with no stop reason on v1, and on v2
with the `error` stop reason and the error that ends the root's work.
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
guessing its parent.
- A replay restores each child under its live session id, from its own transcript, with the same
session messages and `session` references as live, and its end is an `idle` before the response.
Each replayed child's current state follows the response.
A launch without Claude Code's metadata file for the child stays an ordinary tool call of the
parent, and a notice, for a client that takes notices, says that subagent history is missing.
- After a load or resume, with or without replay, a SendMessage that resumes an agent from before
it runs that agent in its session from before. Before the agent's traffic, each of its ancestors
that the client was not told about in this connection is announced, parent before child and
with no work state, as the RFD requires. An agent whose launch the session's history does not
record stays unexposed.
- Each running child is announced with the `cancel` capability. `session/cancel` with the child's
session id stops that child's current work, through the SDK's `Query.stopTask`, and not its
parent's: Claude Code also stops the foreground children it waits on, and the parent's turn goes
on. Each stopped child reports `idle` with `cancelled` from Claude Code's own events, and its
requests that the stop withdraws report no `running` first. A replayed child, which no longer
runs, gets no capabilities. A client cannot prompt or otherwise change a child.
An [ACP v2](../README.md#experimental-acp-v2) client gets the same without declaring anything, since subagents
are part of v2's baseline. Each child also reports its work state as its own `state_update`, right
after the parent's copy.
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
[AIR extensions](air-extensions.md#native-subagent-sessions).
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
`forwardSubagentText` session option retain the flattened child transcript.

View File

@@ -117,10 +117,15 @@ export type TaskEnd = {
/** The SDK's reason for an unusual end, such as `worker_restart`. */
reason?: string;
/**
* The adapter knows that the work ended, but not why: the stream ended, or
* the conversation was reset, with the task still running.
* The adapter knows that the work ended, but not why: the stream ended
* with the task still running.
*/
unexplained?: boolean;
/**
* The adapter stops following the task, which may still run: the
* conversation was reset, and the runtime forgets its children.
*/
unobservable?: boolean;
};
type TaskStarted = {
@@ -225,6 +230,11 @@ export class NativeSubagentRuntime {
string,
{ initial: AcpSessionNotification; message?: string; delegationId?: string }
>();
/**
* The RFD form: the agent that each TaskStop or SendMessage call names (see
* {@link targetAgentId}), until the call's final update.
*/
private readonly targetByToolCall = new Map<string, string>();
private readonly generationByTaskId = new Map<string, number>();
private readonly pending = new Map<string, AcpSessionNotification[]>();
private pendingCount = 0;
@@ -280,7 +290,10 @@ export class NativeSubagentRuntime {
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
if (!routed) return routed;
this.rememberSendMessage(routed);
return this.form === "rfd" ? this.routeRfdSendMessage(routed, forcedSessionId) : routed;
if (this.form !== "rfd") return routed;
this.rememberTarget(routed);
const shown = this.routeRfdSendMessage(routed, forcedSessionId);
return shown && this.withTarget(shown);
}
/**
@@ -828,7 +841,10 @@ export class NativeSubagentRuntime {
if (child.parentToolUseId) {
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
}
if (this.form === "rfd") {
if (this.form === "rfd" && end?.unobservable) {
// The child's `cancel` goes with it: a forgotten child cannot be stopped.
await this.reportState(child, { state: "unknown" }, { capabilities: null });
} else if (this.form === "rfd") {
await this.reportState(child, rfdEndState(state, end));
child.missingError = state === "failed" && !end?.error && !end?.unexplained;
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
@@ -853,14 +869,33 @@ export class NativeSubagentRuntime {
/**
* Ends every child that still runs, because the stream ended or the
* conversation was reset. AIR's draft reports `state` for each. In the RFD
* form the cause of each child's end is unknown, so it is idle with no stop
* reason: a child that a cancel stopped already reported it from the SDK's
* events, and a background child that outlived the cancel was not cancelled.
* conversation was reset. AIR's draft reports `state` for each.
*
* The RFD form, at the end of the stream: Claude Code's process ran the
* children and is gone, or the caller closes the query next, which
* terminates it, so their work ended. Its cause is unknown, so each is idle with no stop reason: a
* child that a cancel stopped already reported it from the SDK's events,
* and a background child that outlived the cancel was not cancelled.
*
* The RFD form, at a conversation reset (`mayStillRun`): Claude Code can
* keep the old conversation's subagents running, and the caller forgets
* them next (`clear`), so no later event of theirs is reported. Their work
* did not end as far as the adapter knows, so each is `unknown`, as the
* RFD requires when a child can no longer be observed, and loses its
* `cancel` capability. A child that is idle keeps its state.
*/
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
async finishAll(
state: SubagentState,
deliver: Publish,
{ mayStillRun = false }: { mayStillRun?: boolean } = {},
): Promise<void> {
const errors: unknown[] = [];
const end = this.form === "rfd" ? { unexplained: true } : undefined;
const end: TaskEnd | undefined =
this.form !== "rfd"
? undefined
: mayStillRun
? { unobservable: true }
: { unexplained: true };
try {
for (const taskId of [...this.children.keys()].reverse()) {
try {
@@ -1235,13 +1270,25 @@ export class NativeSubagentRuntime {
await this.publishConfiguration(child);
}
/** The RFD form: publishes the work state of `child` on its parent. */
private async reportState(child: NativeSubagent, state: WorkState): Promise<void> {
/**
* The RFD form: publishes the work state of `child` on its parent, with
* `capabilities` when the child's change with it.
*/
private async reportState(
child: NativeSubagent,
state: WorkState,
capabilities?: { capabilities: null },
): Promise<void> {
child.workState = state.state;
child.missingError = false;
await this.publish({
sessionId: child.parentSessionId,
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
...capabilities,
state,
},
});
}
@@ -1439,6 +1486,42 @@ export class NativeSubagentRuntime {
return unexposedControlFallback(sendMessage.initial, notification, sendMessage.sender);
}
/** The RFD form: remembers the agent that a TaskStop or SendMessage call names. */
private rememberTarget({ update }: AcpSessionNotification): void {
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") return;
const claudeMeta = update._meta?.claudeCode as { toolName?: unknown } | undefined;
const agentId = targetAgentId(claudeMeta?.toolName, update.rawInput);
if (agentId !== undefined) this.targetByToolCall.set(update.toolCallId, agentId);
}
/**
* The RFD form: `notification`, an update that the client is shown, with
* the RFD's `session` reference when it is of a tool call that operates on
* an announced child of another session (see {@link withSessionReference}):
*
* - a TaskStop or SendMessage call that names the child by its agent id;
* - a SendMessage call that resumed the child, and stays shown because a
* permission request showed it;
* - an Agent/Task call that launched the child, and stays shown for the
* same reason.
*
* The RFD requires the announcement before the reference, so a call whose
* child is not announced yet gets none.
*/
private withTarget(notification: AcpSessionNotification): AcpSessionNotification {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return notification;
}
const agentId = this.targetByToolCall.get(update.toolCallId);
if (isFinalToolCallStatus(update.status)) this.targetByToolCall.delete(update.toolCallId);
const child =
this.childByParentToolUse.get(update.toolCallId) ??
(agentId !== undefined ? this.children.get(agentId) : undefined);
if (!child?.announced || child.sessionId === notification.sessionId) return notification;
return withSessionReference(notification, child.sessionId);
}
/** Whether `update` is of an Agent/Task call, by its tool name or, in the
* RFD form, by the id of a call the client is not shown. */
private isControlUpdate(
@@ -1504,6 +1587,7 @@ export class NativeSubagentRuntime {
this.hiddenLaunches.clear();
this.hiddenSendMessages.clear();
this.handbacks.clear();
this.targetByToolCall.clear();
this.generationByTaskId.clear();
this.pending.clear();
this.pendingCount = 0;
@@ -1835,7 +1919,7 @@ async function publishPrompt(
* prompt goes from the parent to the child, a SendMessage call, whose message
* goes from its sender to a child, or a child's SubagentHandback call, whose
* report goes from the child to the parent. Both views use the
* call's tool use id, and each names the other with the draft RFD's
* call's tool use id, and each names the other with the RFD's
* `recipientMessageId` and `senderMessageId`.
*/
export function linkedSessionMessages(
@@ -1863,6 +1947,49 @@ export function linkedSessionMessages(
];
}
/**
* The RFD form: the agent that a tool call names as the one it operates on:
* the `task_id` of a TaskStop call, or the `to` of a SendMessage call. It is
* an agent only if it is the id of one: an agent's id is its child session's
* id. Both tools also take a name, which is not resolved here: Claude Code
* resolves it, and the adapter cannot tell which agent it picked. `undefined`
* for any other call.
*/
export function targetAgentId(toolName: unknown, rawInput: unknown): string | undefined {
if (typeof rawInput !== "object" || rawInput === null) return undefined;
const input = rawInput as { task_id?: unknown; to?: unknown };
if (toolName === "TaskStop") return nonBlankString(input.task_id);
if (toolName === "SendMessage") return nonBlankString(input.to);
return undefined;
}
/**
* The RFD form: `notification`, a tool call's report, with a `session`
* reference to `sessionId` after its content: `{ "type": "session",
* "sessionId": "..." }`, which links the call to the session it operates on.
* A `tool_call_update` replaces the content that the client holds, so the
* reference goes on every report that carries content, and a
* `tool_call_update` without content is returned as it is.
*/
export function withSessionReference(
notification: AcpSessionNotification,
sessionId: string,
): AcpSessionNotification {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return notification;
}
if (update.sessionUpdate === "tool_call_update" && update.content == null) return notification;
const content = update.content ?? [];
if (content.some((item) => item.type === "session" && item.sessionId === sessionId)) {
return notification;
}
return {
...notification,
update: { ...update, content: [...content, { type: "session", sessionId }] },
};
}
/**
* The tool with which a child hands its report to its parent, when Claude
* Code runs in hand-back mode. The Agent/Task result then only says that the
@@ -1953,6 +2080,9 @@ function isWorking(child: NativeSubagent): boolean {
* A task that a worker restart orphaned, or that was still running when the
* stream ended, is idle with no stop reason: its work ended, but the adapter
* did not see a cancellation or a failure, which the RFD forbids claiming.
*
* A child that the adapter can no longer observe and that may still run is
* not idle but `unknown` (see `NativeSubagentRuntime.finishAll`).
*/
export function rfdEndState(state: SubagentState, end: TaskEnd | undefined): WorkState {
if (end?.unexplained || end?.reason === "worker_restart") return { state: "idle" };

View File

@@ -28,16 +28,9 @@ export function fastModeStateEnabled(state: FastModeState): boolean {
return state !== "off";
}
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
* that a user can act on (or at least wants to know about). Deliberately
* partial — the omitted reasons are not worth surfacing:
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
* opt-in), so it describes the default, not a problem.
* - `preference`: the user turned Fast mode off themselves.
* - `pending`: eligibility is still resolving; the next report supersedes it.
* - `unknown`: nothing meaningful to say.
* Unknown future reasons fall through the same way (open set — the SDK's docs
* say to ignore values you don't handle). */
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values.
* Every reason is explained except the two in {@link FAST_MODE_UNEXPLAINED_REASONS};
* a code missing here gets the generic text from {@link explainFastModeDisabledReason}. */
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
free: "not available on the free plan",
extra_usage_disabled: "requires extra usage to be enabled for this account",
@@ -45,17 +38,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason,
not_first_party: "not available on this API provider",
disabled_by_env: "disabled by environment configuration",
network_error: "eligibility could not be verified (network error)",
preference: "not enabled for your organization or API gateway",
unknown: "currently unavailable",
};
/** Reasons that are never explained:
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
* opt-in), so it describes the default, not a problem.
* - `pending`: eligibility is still resolving; the next report supersedes it. */
const FAST_MODE_UNEXPLAINED_REASONS: ReadonlySet<FastModeDisabledReason> = new Set([
"sdk_opt_in_required",
"pending",
]);
/** The user-facing explanation of a `fast_mode_disabled_reason`: `undefined`
* for no reason, `sdk_opt_in_required` or `pending`; the table text when there
* is one; else a generic text naming the code (a reason a newer SDK added). */
export function explainFastModeDisabledReason(
reason: FastModeDisabledReason | undefined,
): string | undefined {
if (reason === undefined || FAST_MODE_UNEXPLAINED_REASONS.has(reason)) return undefined;
return FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ?? `not available (${reason})`;
}
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
* a reason we have an explanation for, else `undefined`. Keeping only
* explainable reasons means state comparisons (see `fastModeStateChange`) track
* exactly what the user can see, so routine reports like
* `sdk_opt_in_required` never churn the config option. */
* a reason {@link explainFastModeDisabledReason} explains (including codes a
* newer SDK adds), else `undefined`. State comparisons (see
* `fastModeStateChange`) then track exactly what the user can see, so
* `sdk_opt_in_required` and `pending` never churn the config option. */
export function normalizeFastModeDisabledReason(
reason: FastModeDisabledReason | undefined,
): FastModeDisabledReason | undefined {
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
return explainFastModeDisabledReason(reason) !== undefined ? reason : undefined;
}
/** Build the Fast mode config option. When the Client supports boolean config
@@ -73,9 +87,7 @@ export function createFastModeConfigOption(
useBooleanOption: boolean,
disabledReason?: FastModeDisabledReason,
): SessionConfigOption {
const explanation = enabled
? undefined
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
const explanation = enabled ? undefined : explainFastModeDisabledReason(disabledReason);
const base = {
id: FAST_MODE_CONFIG_ID,
name: "Fast mode",
@@ -141,15 +153,17 @@ export type FastModeOptionState = {
* off), or the same state with the same explainable reason.
*
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
* state. Only explainable reasons are retained (see
* {@link normalizeFastModeDisabledReason}), so a routine
* `sdk_opt_in_required` report on every turn's result can't churn the
* option, while a real blocker updates the description even when the
* state. Every reason except `sdk_opt_in_required` and `pending` is retained
* (see {@link normalizeFastModeDisabledReason}; unknown future codes get the
* generic text), so `sdk_opt_in_required` on every turn's result can't churn
* the option, while a real blocker updates the description even when the
* toggle's own value is unchanged.
*
* `notice` is set when the user had Fast mode on and the SDK turned it off
* for an explainable reason: the description carries the same explanation,
* but a toggle silently snapping back is worth saying out loud once. */
* with a retained reason: the description carries the same explanation, but a
* toggle silently snapping back is worth saying out loud once. Off with no
* reason, `sdk_opt_in_required` or `pending` stays quiet: a `result` can carry
* the turn-start snapshot, so a notice there would falsely say "turned off". */
export function fastModeStateChange(
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
state: FastModeState | undefined,
@@ -163,8 +177,7 @@ export function fastModeStateChange(
// so it can't decorate the option the next time fast mode goes off.
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
const explanation =
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
const explanation = explainFastModeDisabledReason(disabledReason);
if (!current.enabled || enabled || explanation === undefined) {
return { enabled, disabledReason };
}

View File

@@ -20,7 +20,10 @@
* reached the call is that message too: the sender's outgoing view, at the
* call, and the child's incoming one, in the child's history. A child's
* history follows its launch, so a call to a child from its parent is always
* delivered first. Any other SendMessage call stays a tool call.
* delivered first. Any other SendMessage call stays a tool call. As live, a
* TaskStop or SendMessage call that the client is shown and that names a
* replayed child by its agent id carries the RFD's `session` reference to it
* (`ReplayTranscript.withTarget`).
*
* Right after its announcement, each child gets its `model` option: the model
* of its first response, in its transcript. A later response with another
@@ -50,6 +53,8 @@ import {
rfdLabels,
sameDirectory,
type SubagentModels,
targetAgentId,
withSessionReference,
type WorkState,
} from "./native-subagents.js";
import { noticeUpdate } from "./session-notices.js";
@@ -116,9 +121,29 @@ export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
readonly omitted = new Set<string>();
/** The SubagentHandback calls, by tool use id. */
readonly handbacks = new Map<string, ReplayHandback>();
/** The agent that each TaskStop or SendMessage call names (see `targetAgentId`), by tool use id. */
private readonly targets = new Map<string, string>();
constructor(private readonly history: SubagentHistory) {}
/**
* `notification`, a replayed tool call update that the client is shown on
* the session `shownOn`, with the RFD's `session` reference when the call
* names a child of another session by its agent id, as live
* (`NativeSubagentRuntime.withTarget`). Only a child that the replay
* announced before the update is referenced.
*/
withTarget(notification: AcpSessionNotification, shownOn: string): AcpSessionNotification {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return notification;
}
const agentId = this.targets.get(update.toolCallId);
const child = agentId !== undefined ? this.byAgentId.get(agentId) : undefined;
if (!child?.announced || child.sessionId === shownOn) return notification;
return withSessionReference(notification, child.sessionId);
}
/**
* Registers the launches, the launch results, and the SendMessage calls in
* `messages`, the transcript of the session `ownerSessionId`.
@@ -127,6 +152,8 @@ export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
for (const message of messages) {
for (const block of contentBlocks(message)) {
if (block.type === "tool_use" && typeof block.id === "string") {
const agentId = targetAgentId(block.name, block.input);
if (agentId !== undefined) this.targets.set(block.id, agentId);
if (isNativeSubagentControlTool(block.name)) {
this.registerLaunch(block.id, inputOf(block), ownerSessionId);
} else if (block.name === HANDBACK_TOOL) {
@@ -214,6 +241,11 @@ export class RfdSubagentReplay {
}
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
/** See {@link ReplayTranscript.withTarget}. */
withTarget(notification: AcpSessionNotification, shownOn: string): AcpSessionNotification {
return this.transcript.withTarget(notification, shownOn);
}
childOfLaunch(toolUseId: string): ReplayChild | undefined {
return this.transcript.children.get(toolUseId);
}

View File

@@ -2012,6 +2012,164 @@ describe("NativeSubagentRuntime in the RFD form", () => {
expect(states(published).at(-1)).toEqual({ state: "unknown" });
});
it("reports a child that may go on working unobserved as unknown, without its cancel, and keeps an idle one", async () => {
const { runtime, published } = await runningWorker();
await runtime.route(launch("launch-2", { description: "Read the docs" }), async () => {});
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
async () => {},
);
await runtime.finishTask("worker-2", "completed", async () => {}, "launch-2");
published.length = 0;
// A conversation reset: Claude Code can keep the children running.
await runtime.finishAll("failed", async () => {}, { mayStillRun: true });
expect(published.map(({ update }) => update)).toEqual([
{
sessionUpdate: "subagent_update",
sessionId: "worker-1",
capabilities: null,
state: { state: "unknown" },
},
]);
});
describe("a tool call that operates on a child", () => {
const toolCall = (
toolCallId: string,
toolName: string,
rawInput: Record<string, unknown>,
parentToolUseId?: string,
): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call",
toolCallId,
title: toolName,
status: "pending",
rawInput,
content: [{ type: "content", content: { type: "text", text: "input" } }],
_meta: {
claudeCode: { toolName, ...(parentToolUseId ? { parentToolUseId } : {}) },
},
},
}) as AcpSessionNotification;
const toolResult = (
toolCallId: string,
toolName: string,
content?: unknown[],
): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "completed",
...(content ? { content } : {}),
_meta: { claudeCode: { toolName } },
},
}) as AcpSessionNotification;
const text = { type: "content", content: { type: "text", text: "Stopped" } };
const reference = { type: "session", sessionId: "worker-1" };
const contentOf = (notification: AcpSessionNotification | null) =>
(notification?.update as { content?: unknown[] } | undefined)?.content;
it("references the announced child that a TaskStop call names by its agent id", async () => {
const { runtime } = await runningWorker();
expect(
contentOf(
await runtime.route(
toolCall("stop-1", "TaskStop", { task_id: "worker-1" }),
async () => {},
),
),
).toEqual([{ type: "content", content: { type: "text", text: "input" } }, reference]);
// An update without content would replace nothing, so it carries none.
const progress = toolResult("stop-1", "TaskStop");
(progress.update as { status?: string }).status = "in_progress";
expect(contentOf(await runtime.route(progress, async () => {}))).toBeUndefined();
// The result's content replaces the first report's, so it has the reference again.
expect(
contentOf(await runtime.route(toolResult("stop-1", "TaskStop", [text]), async () => {})),
).toEqual([text, reference]);
});
it.each([
["a task that is no child", "TaskStop", { task_id: "bash_1" }],
["a child named by its name, which Claude Code resolves", "TaskStop", { task_id: "builder" }],
["another tool with the same input", "Monitor", { task_id: "worker-1" }],
])("references nothing for %s", async (_, toolName, input) => {
const { runtime } = await runningWorker({ name: "builder" });
expect(
contentOf(await runtime.route(toolCall("call-1", toolName, input), async () => {})),
).toEqual([{ type: "content", content: { type: "text", text: "input" } }]);
});
it("references no child that is not announced yet, as the RFD requires", async () => {
const { runtime } = await runningWorker();
// A nested child whose launch frame, which names its parent, has not come.
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
async () => {},
);
await runtime.route(launch("launch-2", { description: "Dig" }, "launch-9"), async () => {});
expect(
contentOf(
await runtime.route(
toolCall("stop-2", "TaskStop", { task_id: "worker-2" }),
async () => {},
),
),
).toEqual([{ type: "content", content: { type: "text", text: "input" } }]);
});
it("references the child from a SendMessage call to it that stays a tool call", async () => {
const { runtime } = await runningWorker();
// The message resumes no child, so the call is shown at its end.
await expect(
runtime.route(toolCall("send-1", "SendMessage", { to: "worker-1" }), async () => {}),
).resolves.toBeNull();
const shown = await runtime.route(
toolResult("send-1", "SendMessage", [text]),
async () => {},
);
expect(shown).toMatchObject({ sessionId: "root", update: { sessionUpdate: "tool_call" } });
expect(contentOf(shown)).toEqual([text, reference]);
});
it("references the child from its launch call that a permission request showed", async () => {
const { runtime } = await runningWorker();
const shown = await runtime.route(
launchResult("launch-1", "completed", "Done"),
async () => {},
"root",
);
expect(contentOf(shown)).toEqual([
{ type: "content", content: { type: "text", text: "Done" } },
reference,
]);
});
it("references nothing in AIR's draft", async () => {
const runtime = new NativeSubagentRuntime(
"air",
"root",
sessionWith(child({ sessionId: "worker-1", announced: true })),
async () => {},
{ log: () => {} },
);
const call = toolCall("stop-1", "TaskStop", { task_id: "worker-1" });
await expect(runtime.route(call, async () => {})).resolves.toEqual(call);
});
});
it("reports a child as requires_action while its requests are open", async () => {
const { runtime, published } = await runningWorker();
const first = Promise.withResolvers<string>();

View File

@@ -28,11 +28,8 @@ import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
import {
ClaudeAcpAgent,
computeSessionFingerprint,
runPromptWithCancellation,
} from "../../v1/acp-agent.js";
import { ClaudeAcpAgent, runPromptWithCancellation } from "../../v1/acp-agent.js";
import { computeSessionFingerprint } from "../../v1/session-fingerprint.js";
import type { SteerRequest } from "../../v1/steering.js";
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
import { createFastModeConfigOption } from "../../session-fast-mode.js";
@@ -4205,7 +4202,13 @@ describe("subagent transcript replay", () => {
];
}
case "tool_call":
return [`${on} tool_call ${u.toolCallId}`];
return [
`${on} tool_call ${u.toolCallId}` +
(u.content ?? [])
.filter((item: any) => item.type === "session")
.map((item: any) => ` session=${item.sessionId}`)
.join(""),
];
case "config_option_update":
return traceModels
? u.configOptions.map(
@@ -4640,6 +4643,37 @@ describe("subagent transcript replay", () => {
]);
});
it("references a replayed child from a TaskStop call that names it by its agent id", async () => {
const trace = await replayRfd(
[
user("u1", "Go"),
// The child is not announced yet: a call that names it references nothing.
assistant("a1", [toolUse("toolu_early", "TaskStop", { task_id: "abc123" })]),
user("u2", [toolResult("toolu_early", "No such task")]),
assistant("a2", [
toolUse("toolu_agent", "Agent", { subagent_type: "Explore", prompt: "Look around" }),
]),
user("u3", [toolResult("toolu_agent", "Async agent launched")]),
assistant("a3", [toolUse("toolu_stop", "TaskStop", { task_id: "abc123" })]),
user("u4", [toolResult("toolu_stop", "Stopped abc123")]),
],
{
abc123: {
meta: { agentType: "Explore", toolUseId: "toolu_agent", requestShape: "background" },
records: [user("c1", "Look around")],
},
},
);
expect(trace.filter((line) => line.includes("tool_call"))).toEqual([
"root tool_call toolu_early",
"root tool_call toolu_stop session=abc123",
]);
expect(
trace.findIndex((line) => line.startsWith("root subagent_update abc123")),
).toBeLessThan(trace.indexOf("root tool_call toolu_stop session=abc123"));
});
it("replays a child's hand-back as its message to the parent, instead of the launch's result", async () => {
const trace = await replayRfd(
[
@@ -9112,6 +9146,66 @@ describe("subagent permission attribution (issue #851)", () => {
state: { state: "idle" },
});
});
it("reports a child that still works at a conversation reset as unknown, without its cancel", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
{
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
},
// The reset forgot the child: its later end changes nothing.
taskNotification("toolu_parent", "completed"),
]);
expect(subagentTraffic(updates).at(-1)?.update).toEqual({
sessionUpdate: "subagent_update",
sessionId: "agent-42",
capabilities: null,
state: { state: "unknown" },
});
});
it("references an announced child from a TaskStop call that names it by its agent id", async () => {
const toolResult = (toolUseId: string, content: string) => ({
type: "user",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
},
});
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
rootToolUse("toolu_stop", "TaskStop", { task_id: "agent-42" }),
toolResult("toolu_stop", "Stopped agent-42"),
// A task that is no child session.
rootToolUse("toolu_bash", "TaskStop", { task_id: "bash_1" }),
toolResult("toolu_bash", "Stopped bash_1"),
]);
const references = (toolCallId: string) =>
updates.flatMap(({ sessionId, update }) =>
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update.toolCallId === toolCallId &&
update.content != null
? [
`${sessionId} ${JSON.stringify(update.content.filter((item) => item.type === "session"))}`,
]
: [],
);
// The first report and the result both carry content, which replaces
// what the client holds, so both carry the reference.
expect(references("toolu_stop")).toEqual([
'test-session [{"type":"session","sessionId":"agent-42"}]',
'test-session [{"type":"session","sessionId":"agent-42"}]',
]);
expect(references("toolu_bash")).toEqual(["test-session []", "test-session []"]);
});
});
it("ends the root's tool calls at a cancel, and sends nothing to a child that the cancel ended (AIR)", async () => {

View File

@@ -16,6 +16,14 @@ import {
FAST_MODE_OFF,
} from "../../session-fast-mode.js";
import type { AcpClient } from "../../acp-client.js";
import { Pushable } from "../../utils.js";
import { initializeClient } from "../helpers.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
const MODES = {
currentModeId: "default",
@@ -33,6 +41,8 @@ const MODEL_INFOS: ModelInfo[] = [
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
];
const FUTURE_REASON = "some_future_reason" as FastModeDisabledReason;
describe("createFastModeConfigOption", () => {
it("produces a native boolean toggle when the client opted in", () => {
expect(createFastModeConfigOption(true, true)).toEqual({
@@ -45,20 +55,40 @@ describe("createFastModeConfigOption", () => {
});
});
it("explains a blocking disabled reason while the toggle reads off", () => {
const option = createFastModeConfigOption(false, true, "free");
expect(option.description).toBe(
"Faster responses on supported models — not available on the free plan",
);
});
/**
* Scenario:
* 1. Build the option with the toggle off and a disabled reason.
* Expected:
* - The description is the base text plus the reason's explanation.
* - A code the table lacks gets the generic text naming the code.
*/
it.each([
["free", "not available on the free plan"],
["preference", "not enabled for your organization or API gateway"],
["unknown", "currently unavailable"],
[FUTURE_REASON, "not available (some_future_reason)"],
] as [FastModeDisabledReason, string][])(
"explains the disabled reason %s while the toggle reads off",
(reason, explanation) => {
const option = createFastModeConfigOption(false, true, reason);
expect(option.description).toBe(`Faster responses on supported models — ${explanation}`);
},
);
it("ignores routine and on-state reasons", () => {
/**
* Scenario:
* 1. Build the option, toggle off, with `sdk_opt_in_required` or `pending`.
* 2. Build the option, toggle on, with `free`.
* Expected:
* - The description stays the base text in all three cases.
*/
it("ignores unexplained and on-state reasons", () => {
// Every SDK session starts at `sdk_opt_in_required` — the toggle IS the
// opt-in, so it must not read as a blocker.
expect(createFastModeConfigOption(false, true, "sdk_opt_in_required").description).toBe(
"Faster responses on supported models",
);
expect(createFastModeConfigOption(false, true, "preference").description).toBe(
expect(createFastModeConfigOption(false, true, "pending").description).toBe(
"Faster responses on supported models",
);
// A reason riding an `on` state isn't blocking anything right now.
@@ -133,25 +163,38 @@ describe("fastModeStateEnabled", () => {
});
describe("normalizeFastModeDisabledReason", () => {
it("retains only reasons we have an explanation for", () => {
expect(normalizeFastModeDisabledReason("free")).toBe("free");
expect(normalizeFastModeDisabledReason("extra_usage_disabled")).toBe("extra_usage_disabled");
expect(normalizeFastModeDisabledReason("model_not_allowed")).toBe("model_not_allowed");
expect(normalizeFastModeDisabledReason("not_first_party")).toBe("not_first_party");
expect(normalizeFastModeDisabledReason("disabled_by_env")).toBe("disabled_by_env");
expect(normalizeFastModeDisabledReason("network_error")).toBe("network_error");
/**
* Scenario:
* 1. Normalize each explained reason, including `preference`, `unknown` and a future code.
* Expected:
* - Each reason is returned unchanged.
*/
it.each([
"free",
"extra_usage_disabled",
"model_not_allowed",
"not_first_party",
"disabled_by_env",
"network_error",
"preference",
"unknown",
FUTURE_REASON,
] as FastModeDisabledReason[])("retains the explained reason %s", (reason) => {
expect(normalizeFastModeDisabledReason(reason)).toBe(reason);
});
it("drops routine states and unknown values", () => {
expect(normalizeFastModeDisabledReason("sdk_opt_in_required")).toBeUndefined();
expect(normalizeFastModeDisabledReason("preference")).toBeUndefined();
expect(normalizeFastModeDisabledReason("pending")).toBeUndefined();
expect(normalizeFastModeDisabledReason("unknown")).toBeUndefined();
expect(normalizeFastModeDisabledReason(undefined)).toBeUndefined();
expect(
normalizeFastModeDisabledReason("some_future_reason" as FastModeDisabledReason),
).toBeUndefined();
});
/**
* Scenario:
* 1. Normalize `sdk_opt_in_required`, `pending` and `undefined`.
* Expected:
* - Each normalizes to `undefined`.
*/
it.each(["sdk_opt_in_required", "pending", undefined] as (FastModeDisabledReason | undefined)[])(
"drops the unexplained reason %s",
(reason) => {
expect(normalizeFastModeDisabledReason(reason)).toBeUndefined();
},
);
});
describe("buildConfigOptions Fast mode", () => {
@@ -403,29 +446,61 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
expect(sessionUpdates).toHaveLength(0);
});
it("explains a blocking reason once when the SDK refuses a toggle the user turned on", async () => {
/**
* Scenario:
* 1. The toggle is on and the SDK reports `off` with a reason.
* 2. The SDK reports the same `off` and reason again.
* Expected:
* - The toggle is cleared and the reason retained.
* - One transcript line carries the explanation, then one `config_option_update`.
* - The repeat report sends nothing.
*/
it.each([
["extra_usage_disabled", "requires extra usage to be enabled for this account"],
["preference", "not enabled for your organization or API gateway"],
["unknown", "currently unavailable"],
[FUTURE_REASON, "not available (some_future_reason)"],
] as [FastModeDisabledReason, string][])(
"explains the blocking reason %s once when the SDK refuses a toggle the user turned on",
async (reason, explanation) => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
await sync(SESSION_ID, session, "off", reason);
expect(session.fastModeEnabled).toBe(false);
expect(session.fastModeDisabledReason).toBe(reason);
expect(sessionUpdates).toHaveLength(2);
expect(sessionUpdates[0].update).toEqual({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `\n\n> **Fast mode turned off:** ${explanation}.\n\n`,
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true, reason));
// The same report again changes nothing the user can see: no repeat notice.
await sync(SESSION_ID, session, "off", reason);
expect(sessionUpdates).toHaveLength(2);
},
);
/**
* Scenario:
* 1. The toggle is on and the SDK reports `off` with `sdk_opt_in_required`.
* Expected:
* - The toggle is cleared and no reason is retained.
* - Only a `config_option_update` is sent: no notice or transcript line.
*/
it("clears the toggle quietly when the SDK reports off with sdk_opt_in_required", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
await sync(SESSION_ID, session, "off", "sdk_opt_in_required");
expect(session.fastModeEnabled).toBe(false);
expect(session.fastModeDisabledReason).toBe("extra_usage_disabled");
expect(sessionUpdates).toHaveLength(2);
expect(sessionUpdates[0].update).toEqual({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
expect(session.configOptions).toContainEqual(
createFastModeConfigOption(false, true, "extra_usage_disabled"),
);
// The same report again changes nothing the user can see: no repeat notice.
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
expect(sessionUpdates).toHaveLength(2);
expect(session.fastModeDisabledReason).toBeUndefined();
expect(sessionUpdates.map((n) => n.update.sessionUpdate)).toEqual(["config_option_update"]);
});
it("explains the flip as a live notice to a client on the notice contract", async () => {
@@ -493,3 +568,89 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
expect(disabledCase.sessionUpdates).toHaveLength(0);
});
});
describe("Fast mode that Claude Code refuses during a turn", () => {
const SESSION_ID = "test-session";
/** A session whose Claude Code answers the first prompt with a `system`/init
* that reports Fast mode off with `reason`, as it does on every turn. */
async function setupTurn(reason: FastModeDisabledReason) {
const updates: SessionNotification[] = [];
const client = {
sessionUpdate: async (n: SessionNotification) => {
updates.push(n);
},
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
await initializeClient(agent, { session: { configOptions: { boolean: {} } } });
const input = new Pushable<any>();
async function* messages() {
const { value: prompt } = await input[Symbol.asyncIterator]().next();
yield userEcho(prompt);
yield {
type: "system",
subtype: "init",
fast_mode_state: "off",
fast_mode_disabled_reason: reason,
session_id: SESSION_ID,
};
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
const query = Object.assign(wrapQuery(messages()), { applyFlagSettings: async () => {} });
agent.sessions[SESSION_ID] = mockSessionState({
query,
input,
fastModeEnabled: false,
configOptions: [createFastModeConfigOption(false, true)],
});
return { agent, updates };
}
/**
* Scenario:
* 1. The client, without the notices capability, turns Fast mode on.
* 2. It sends a prompt, and Claude Code's init reports Fast mode off, reason `preference`.
* Expected:
* - The transcript gets one line that explains the organization or gateway has not enabled it.
* - Then one `config_option_update` where `fast` reads off, with the explanation in its description.
*/
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
const { agent, updates } = await setupTurn("preference");
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "fast",
type: "boolean",
value: true,
});
updates.length = 0;
await agent.prompt({ sessionId: SESSION_ID, prompt: [{ type: "text", text: "hi" }] });
const explained = updates
.map((n) => n.update)
.filter((u) => u.sessionUpdate !== "usage_update");
expect(explained).toEqual([
{
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "\n\n> **Fast mode turned off:** not enabled for your organization or API gateway.\n\n",
},
},
{
sessionUpdate: "config_option_update",
configOptions: [
expect.objectContaining({
id: "fast",
currentValue: false,
description: expect.stringMatching(
/ — not enabled for your organization or API gateway$/,
),
}),
],
},
]);
});
});

View File

@@ -1445,6 +1445,51 @@ describe("ACP v2 sessions", () => {
});
});
/**
* Scenario:
* 1. The client turns Fast mode on and sends a prompt.
* 2. Claude Code's `system`/init of that turn reports Fast mode off, reason `preference`.
* Expected:
* - One warning notice explains that the organization or gateway has not enabled it.
* - The `fast` option reads off, and its description ends with the explanation.
*/
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
scriptTurns(async function* (options) {
yield fastModeInit(options, "off", "preference");
yield assistantText(options, "Done.");
yield result(options);
yield idleState(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.setConfigOption, {
sessionId,
configId: "fast",
type: "boolean",
value: true,
});
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);
});
expect(client.updates("notice")).toEqual([
{
sessionUpdate: "notice",
severity: "warning",
title: "Fast mode turned off",
description: "Not enabled for your organization or API gateway.",
},
]);
expect(fastOption(client.updates("config_option_update").at(-1))).toMatchObject({
currentValue: false,
description:
"Faster responses on supported models — not enabled for your organization or API gateway",
});
});
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) {
@@ -1691,6 +1736,23 @@ function stream(options: Options, event: Record<string, unknown>) {
};
}
/** Claude Code's per-turn `system`/init, reporting the Fast mode state. */
function fastModeInit(options: Options, state: string, reason: string) {
return {
type: "system",
subtype: "init",
fast_mode_state: state,
fast_mode_disabled_reason: reason,
session_id: options.sessionId,
uuid: randomUUID(),
};
}
/** The `fast` option of a `config_option_update`. */
function fastOption(update: { configOptions: { configId: string }[] } | undefined) {
return update?.configOptions.find((option) => option.configId === "fast");
}
function idleState(options: Options) {
return {
type: "system",
@@ -6167,6 +6229,62 @@ describe("ACP v2 requests that subagents make", () => {
expect(models).toEqual(["claude-sonnet-4-6 Claude Sonnet 4.6", "response"]);
});
it("references a replayed child from a TaskStop call that names it by its agent id", async () => {
scriptTurns();
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.close, { sessionId });
await writeSubagentHistory(
sessionId,
[transcriptRecord(sessionId, "r1", null, { role: "user", content: "hi" })],
"agent-1",
{ agentType: "Explore", toolUseId: "toolu_agent-1" },
[transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" })],
);
const toolUse = (id: string, name: string, input: Record<string, unknown>) =>
transcriptEntry("assistant", randomUUID(), {
id: `msg_${id}`,
role: "assistant",
content: [{ type: "tool_use", id, name, input }],
});
const toolResult = (id: string, content: string) =>
transcriptEntry("user", randomUUID(), {
role: "user",
content: [{ type: "tool_result", tool_use_id: id, content }],
});
vi.mocked(getSessionMessages).mockResolvedValueOnce([
// The child is not announced yet: a call that names it references nothing.
toolUse("toolu_early", "TaskStop", { task_id: "agent-1" }),
toolResult("toolu_early", "No such task"),
toolUse("toolu_agent-1", "Agent", { subagent_type: "Explore", prompt: "Look around" }),
toolResult("toolu_agent-1", "Done."),
toolUse("toolu_stop", "TaskStop", { task_id: "agent-1" }),
toolResult("toolu_stop", "Stopped agent-1"),
] as unknown as Awaited<ReturnType<typeof getSessionMessages>>);
client.sessionUpdates.length = 0;
await agent.request(v2.methods.agent.session.resume, {
sessionId,
cwd,
replayFrom: { type: "start" },
});
await client.authUpdate(1);
});
const references = (toolCallId: string) =>
client.updates("tool_call_update").flatMap((update) => {
const call = update as { toolCallId: string; content?: Array<{ type: string }> | null };
if (call.toolCallId !== toolCallId || call.content == null) return [];
return [call.content.filter((item) => item.type === "session")];
});
expect(references("toolu_stop").length).toBeGreaterThan(0);
for (const reference of references("toolu_stop")) {
expect(reference).toEqual([{ type: "session", sessionId: "agent-1" }]);
}
expect(references("toolu_early").flat()).toEqual([]);
});
it("drops a foreground child's consolidated messages after the root's cancel, except the results of its shown tool calls", async () => {
const interrupted = Promise.withResolvers<void>();
sdk.interrupt = async () => interrupted.resolve();
@@ -6284,7 +6402,7 @@ describe("ACP v2 requests that subagents make", () => {
).toMatchObject({ status: "cancelled" });
});
it("ends the subagents that work when Claude Code starts a fresh conversation", async () => {
it("reports the subagents that work as unknown when Claude Code starts a fresh conversation", async () => {
scriptTurns(
async function* (options) {
yield toolUseMessage(options, "toolu_agent-1", "Agent", {
@@ -6340,7 +6458,10 @@ describe("ACP v2 requests that subagents make", () => {
switch (update.sessionUpdate) {
case "subagent_update":
return update.state
? [`${on} subagent ${update.sessionId} ${state(update.state)}`]
? [
`${on} subagent ${update.sessionId} ${state(update.state)}` +
(update.capabilities === null ? ", no capabilities" : ""),
]
: [];
case "state_update": {
const listed = (
@@ -6358,13 +6479,13 @@ describe("ACP v2 requests that subagents make", () => {
return [];
}
});
// The child's run ends with no stop reason: nothing says how. Its tool
// call ends first, as v2 asks.
// The child may go on working, and the adapter no longer follows it: it
// is `unknown`, not idle, and it cannot be cancelled. Nothing says that
// its tool call ended, so the call keeps its status.
expect(trace).toEqual([
"root running []",
"agent-1 toolu_ls failed",
"root subagent agent-1 idle",
"agent-1 idle []",
"root subagent agent-1 unknown, no capabilities",
"agent-1 unknown []",
"root idle end_turn []",
]);
});
@@ -6506,19 +6627,78 @@ describe("ACP v2 requests that subagents make", () => {
)!.sessionId;
expect(client.permissionRequests).toHaveLength(1);
expect(
client.sessionUpdates.filter(
({ sessionId, update }) =>
sessionId === root &&
update.sessionUpdate === "tool_call_update" &&
(update as { toolCallId: string }).toolCallId === "toolu_send" &&
(update as { status?: string }).status === "completed",
),
).toHaveLength(completedUpdates);
const completed = client.sessionUpdates.filter(
({ sessionId, update }) =>
sessionId === root &&
update.sessionUpdate === "tool_call_update" &&
(update as { toolCallId: string }).toolCallId === "toolu_send" &&
(update as { status?: string }).status === "completed",
);
expect(completed).toHaveLength(completedUpdates);
// The call that stays shown references the child that it resumed.
for (const { update } of completed) {
expect((update as { content?: unknown[] }).content?.at(-1)).toEqual({
type: "session",
sessionId: "agent-1",
});
}
expect(sessionMessages(client, root).slice(3)).toEqual(delivery);
},
);
it("references an announced child from a TaskStop call that names it by its agent id", async () => {
scriptTurns(async function* (options) {
// No child is announced yet: a call that names one references nothing.
yield toolUseMessage(options, "toolu_early", "TaskStop", { task_id: "agent-1" });
yield toolResultMessage(options, "toolu_early", { content: "No such task" });
yield toolUseMessage(options, "toolu_agent-1", "Agent", {
description: "Explore the project",
prompt: "Look around",
run_in_background: true,
});
yield taskStarted(options, "agent-1", { prompt: "Look around", is_backgrounded: true });
yield toolResultMessage(options, "toolu_agent-1", { content: "Launched." });
yield toolUseMessage(options, "toolu_stop", "TaskStop", { task_id: "agent-1" });
yield toolResultMessage(options, "toolu_stop", { content: "Stopped agent-1" });
yield taskNotification(options, "agent-1", { status: "stopped" });
// A task that is no child session.
yield toolUseMessage(options, "toolu_bash", "TaskStop", { task_id: "bash_1" });
yield toolResultMessage(options, "toolu_bash", { content: "Stopped bash_1" });
yield result(options);
yield idleState(options);
});
const client = await promptToIdle(v2Client());
const references = (toolCallId: string) =>
client.updates("tool_call_update").flatMap((update) => {
const call = update as { toolCallId: string; content?: Array<{ type: string }> | null };
if (call.toolCallId !== toolCallId || call.content == null) return [];
return [call.content.filter((item) => item.type === "session")];
});
// Every report of the call that carries content carries the reference:
// a report's content replaces what the client holds.
expect(references("toolu_stop").length).toBeGreaterThan(0);
for (const reference of references("toolu_stop")) {
expect(reference).toEqual([{ type: "session", sessionId: "agent-1" }]);
}
expect(references("toolu_early").flat()).toEqual([]);
expect(references("toolu_bash").flat()).toEqual([]);
// The announcement comes before the first reference, as the RFD requires.
const index = (matches: (update: v2.SessionUpdate) => boolean) =>
client.sessionUpdates.findIndex(({ update }) => matches(update));
expect(
index(
(update) => update.sessionUpdate === "subagent_update" && update.sessionId === "agent-1",
),
).toBeLessThan(
index(
(update) =>
update.sessionUpdate === "tool_call_update" &&
(update as { toolCallId: string }).toolCallId === "toolu_stop",
),
);
});
it.each(["its task_started", "the resumedAgentId of its result alone"])(
"restores a subagent that a SendMessage call resumes from the session's subagent history, at %s",
async (signal) => {

View File

@@ -2505,6 +2505,53 @@ describe("ACP v2 core", () => {
},
);
it("reports a failed API request that the turn goes on past where it failed", async () => {
const text = "API Error: Connection lost mid-response. The response above may be incomplete.";
const client = v2Client();
await client.connect(async (agent) => {
const { claude } = await openSession(agent);
claude.send(cycleStart());
// Claude Code keeps the tool call of the response that broke off, and runs it.
claude.send(
assistant("msg_partial", [
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: { command: "ls" } },
]),
);
claude.send({
...assistant("msg_api_error", [{ type: "text", text }]),
message: {
id: "msg_api_error",
type: "message",
role: "assistant",
model: "<synthetic>",
content: [{ type: "text", text }],
usage: {},
},
error: "server_error",
});
claude.send({
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "src" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
});
claude.send(assistant("msg_after", [{ type: "text", text: "Done." }]));
claude.send(result({ result: "Done." }));
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
});
const notice = { sessionUpdate: "notice", severity: "error", title: text };
const shown = client.sessionUpdates.filter(
(update) =>
update.sessionUpdate === "notice" || update.sessionUpdate === "agent_message_chunk",
);
expect(shown).toMatchObject([notice, { sessionUpdate: "agent_message_chunk" }]);
expect(shown[0]).toEqual(notice);
});
it.each([
[
"an error at the turn limit",

View File

@@ -435,6 +435,7 @@ import {
stripLocalCommandMetadata,
} from "../message-filters.js";
import type { NewSessionMeta, SDKMessageFilter } from "../new-session-meta.js";
import { computeSessionFingerprint } from "./session-fingerprint.js";
import { promptToClaude, SUPPORTED_PROMPT_CONTENT_TYPES } from "../prompt-to-claude.js";
import { AUTONOMOUS_RESULT_ORIGINS, isAbortedResult } from "../result-end.js";
import {
@@ -1065,12 +1066,13 @@ export type Session = {
* independent of user ownership: opted-in clients also apply automatic
* recommendations and settings-derived values. */
appliedEffortLevel?: string;
/** Why the SDK currently can't serve Fast mode, when the reason is one worth
* telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} —
* routine states like the SDK's own opt-in requirement normalize to
* `undefined`). Refreshed from every `fast_mode_disabled_reason` the SDK
* reports on `system`/init and user-turn `result`s; surfaced in the Fast mode
* option's description so a toggle that snaps back off explains itself. */
/** Why the SDK currently can't serve Fast mode: the last
* `fast_mode_disabled_reason` that `explainFastModeDisabledReason` explains.
* {@link normalizeFastModeDisabledReason} drops the two it doesn't,
* `sdk_opt_in_required` and `pending`, to `undefined`. Refreshed from every
* `fast_mode_disabled_reason` the SDK reports on `system`/init and user-turn
* `result`s; surfaced in the Fast mode option's description so a toggle that
* snaps back off explains itself. */
fastModeDisabledReason?: FastModeDisabledReason;
abortController: AbortController;
/** Signal the consumer races `query.next()` against. Aborted by cancel()
@@ -1462,149 +1464,6 @@ function disarmForceCancel(session: Session): void {
}
}
/** Normalize skills without changing their SDK semantics. Array order and
* duplicates do not affect the selected skill set, so neither should rebuild
* the underlying Query process. */
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
}
/** Whether a changed `_meta.claudeCode.options` key on session/load or
* session/resume must rebuild the Query process. Almost every option is read
* only when the process starts, so the default is `true`. Every key of the
* SDK's `Options` must be classified: an SDK bump that adds or removes an
* option fails to compile until it is decided here. */
const OPTION_REBUILDS_SESSION = {
additionalDirectories: true,
agentProgressSummaries: true,
agents: true,
allowDangerouslySkipPermissions: true,
allowedTools: true,
betas: true,
debug: true,
debugFile: true,
disallowedTools: true,
effort: true,
enableFileCheckpointing: true,
env: true,
executableArgs: true,
extraArgs: true,
fallbackModel: true,
forwardSubagentText: true,
includeHookEvents: true,
managedSettings: true,
maxBudgetUsd: true,
maxThinkingTokens: true,
maxTurns: true,
mcpServers: true,
model: true,
outputFormat: true,
pathToClaudeCodeExecutable: true,
permissionPrompts: true,
permissionPromptToolName: true,
persistSession: true,
perTaskStopAffordance: true,
planModeInstructions: true,
pluginDelivery: true,
plugins: true,
projectConfigRoot: true,
promptSuggestions: true,
sandbox: true,
sessionStoreFlush: true,
settings: true,
settingSources: true,
skills: true,
strictMcpConfig: true,
supportedDialogKinds: true,
systemPrompt: true,
taskBudget: true,
thinking: true,
toolAliases: true,
toolConfig: true,
tools: true,
verbatimPrompts: true,
// Managed or ignored by the adapter.
agent: false,
cwd: false,
executable: false,
includePartialMessages: false,
permissionMode: false,
// Per-call controls for attaching to the stored conversation.
continue: false,
forkSession: false,
resume: false,
resumeDropsTurn: false,
resumeSessionAt: false,
sessionId: false,
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
abortController: false,
canUseTool: false,
hooks: false,
onElicitation: false,
onUserDialog: false,
sessionStore: false,
spawnClaudeCodeProcess: false,
stderr: false,
// No effect on an already-created session.
loadTimeoutMs: false,
title: false,
} as const satisfies Record<keyof Options, boolean>;
/** A JSON value with object keys sorted, so key order never changes a
* fingerprint. Functions and class instances (hook callbacks, abort
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
* stable serialized form, so they are left out. */
function canonicalJson(value: unknown): unknown {
if (typeof value === "function") return undefined;
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalJson);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return undefined;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
.filter(([, entry]) => entry !== undefined),
);
}
/** Compute a stable fingerprint of the session-defining params so we can
* detect when a loadSession/resumeSession call requires tearing down and
* recreating the underlying Query process. Every option classified in
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
* keeps stale values. MCP servers are sorted by name, and skills are
* normalized as a set, so ordering differences don't trigger unnecessary
* recreations. */
export function computeSessionFingerprint(params: {
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
additionalDirectories?: NewSessionRequest["additionalDirectories"];
systemPrompt?: NewSessionRequest["systemPrompt"];
_meta?: NewSessionRequest["_meta"];
}): string {
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
const options: Record<string, unknown> = Object.fromEntries(
Object.entries(meta?.claudeCode?.options ?? {}).filter(
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
),
);
options.skills = normalizeSkills(options.skills as Options["skills"]);
return JSON.stringify(
canonicalJson({
cwd: params.cwd,
mcpServers: servers,
additionalDirectories: params.additionalDirectories,
additionalRoots: meta?.additionalRoots,
systemPrompt: meta?.systemPrompt,
clientSystemPrompt: params.systemPrompt ?? undefined,
disableBuiltInTools: meta?.disableBuiltInTools,
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
options,
}),
);
}
/** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it
* cannot drift from the errors this agent throws. */
const AUTH_REQUIRED_CODE = RequestError.authRequired().code;
@@ -3459,11 +3318,13 @@ export class ClaudeAcpAgent {
nativeState: "completed" | "failed" | "cancelled",
asyncState: "failed" | "stopped",
context: string,
// Whether Claude Code can keep the subagents running (see `finishAll`).
{ mayStillRun = false }: { mayStillRun?: boolean } = {},
): Promise<void> => {
await compaction.interrupt();
await Promise.all([
subagents
.finishAll(nativeState, sendUpdate)
.finishAll(nativeState, sendUpdate, { mayStillRun })
.catch((error) =>
this.logger.error(
`Session ${params.sessionId}: failed to publish terminal subagent state ${context}`,
@@ -7118,7 +6979,9 @@ export class ClaudeAcpAgent {
// and task store are independent of the previous transcript.
// Clear both the in-memory snapshot and the client's visible plan
// before any follow-up prompt can republish stale tasks.
await finishLifecycle("failed", "failed", "during conversation reset");
await finishLifecycle("failed", "failed", "during conversation reset", {
mayStillRun: true,
});
subagents.clear();
asyncTasks.clear();
session.resolvedToolNames?.clear();
@@ -7985,7 +7848,8 @@ export class ClaudeAcpAgent {
// A subagent's text replays onto the root only for a client that gets it
// there live: one without native subagent sessions that asked for the
// flattened transcript. With native sessions the text goes to the child
// session, which AIR's replay restores and the RFD form leaves out for now.
// session, which the replay restores in both forms: AIR's draft under a
// replay id, the RFD form under the session id the child had live.
const forwardSubagentText =
!clientSupportsSubagents(this.clientCapabilities) &&
(session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities));
@@ -8405,7 +8269,12 @@ export class ClaudeAcpAgent {
continue;
}
if (rfdReplay && (await rfdReplay.toolCallUpdate(notification.update, child))) continue;
await this.client.sessionUpdate({ ...notification, sessionId: replayTargetSessionId });
const replayed = { ...notification, sessionId: replayTargetSessionId };
await this.client.sessionUpdate(
rfdReplay
? asSdkSessionNotification(rfdReplay.withTarget(replayed, replayTargetSessionId))
: replayed,
);
}
// The history of each subagent is in its own transcript. The replay
@@ -9352,6 +9221,17 @@ export class ClaudeAcpAgent {
* The work that follows a session setup response: the session's commands,
* the current states of the subagents a load replayed, and MCP server
* authentication.
*
* Call it in the same synchronous step as the handler's `return`: an
* `await` between them would let the timer run before the response. The ACP
* SDK (2.6.0) gives a request handler no responder and no after-response
* hook, so the order rests on two things that its source shows and its API
* does not promise. `registerAppRequest` queues the response in the
* microtask that follows the handler's return, with no macrotask in
* between, so the timer runs after it. And `Connection.sendMessage` writes
* in call order, so what the timer sends is written after the response. A
* caller that does not serve the agent through the SDK's `AgentApp` (see
* `v1AgentApp`) has no such order.
*/
private afterSetupResponse(sessionId: string, mcpServers: McpServer[]): void {
setTimeout(() => {
@@ -10452,7 +10332,8 @@ export class ClaudeAcpAgent {
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
// 0.3.219 (the initialize response used to answer from the spawn-time
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
// the opt-in — which normalizes away, so only real blockers are retained.
// the opt-in — which normalizes to `undefined`, as does `pending`; every
// other reason is retained.
const fastModeDisabledReason = fastModeEnabled
? undefined
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);

View File

@@ -0,0 +1,147 @@
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
import type { NewSessionMeta } from "../new-session-meta.js";
/** Normalize skills without changing their SDK semantics. Array order and
* duplicates do not affect the selected skill set, so neither should rebuild
* the underlying Query process. */
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
}
/** Whether a changed `_meta.claudeCode.options` key on session/load or
* session/resume must rebuild the Query process. Almost every option is read
* only when the process starts, so the default is `true`. Every key of the
* SDK's `Options` must be classified: an SDK bump that adds or removes an
* option fails to compile until it is decided here. */
const OPTION_REBUILDS_SESSION = {
additionalDirectories: true,
agentProgressSummaries: true,
agents: true,
allowDangerouslySkipPermissions: true,
allowedTools: true,
betas: true,
debug: true,
debugFile: true,
disallowedTools: true,
effort: true,
enableFileCheckpointing: true,
env: true,
executableArgs: true,
extraArgs: true,
fallbackModel: true,
forwardSubagentText: true,
includeHookEvents: true,
managedSettings: true,
maxBudgetUsd: true,
maxThinkingTokens: true,
maxTurns: true,
mcpServers: true,
model: true,
outputFormat: true,
pathToClaudeCodeExecutable: true,
permissionPrompts: true,
permissionPromptToolName: true,
persistSession: true,
perTaskStopAffordance: true,
planModeInstructions: true,
pluginDelivery: true,
plugins: true,
projectConfigRoot: true,
promptSuggestions: true,
sandbox: true,
sessionStoreFlush: true,
settings: true,
settingSources: true,
skills: true,
strictMcpConfig: true,
supportedDialogKinds: true,
systemPrompt: true,
taskBudget: true,
thinking: true,
toolAliases: true,
toolConfig: true,
tools: true,
verbatimPrompts: true,
// Managed or ignored by the adapter.
agent: false,
cwd: false,
executable: false,
includePartialMessages: false,
permissionMode: false,
// Per-call controls for attaching to the stored conversation.
continue: false,
forkSession: false,
resume: false,
resumeDropsTurn: false,
resumeSessionAt: false,
sessionId: false,
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
abortController: false,
canUseTool: false,
hooks: false,
onElicitation: false,
onUserDialog: false,
sessionStore: false,
spawnClaudeCodeProcess: false,
stderr: false,
// No effect on an already-created session.
loadTimeoutMs: false,
title: false,
} as const satisfies Record<keyof Options, boolean>;
/** A JSON value with object keys sorted, so key order never changes a
* fingerprint. Functions and class instances (hook callbacks, abort
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
* stable serialized form, so they are left out. */
function canonicalJson(value: unknown): unknown {
if (typeof value === "function") return undefined;
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalJson);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return undefined;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
.filter(([, entry]) => entry !== undefined),
);
}
/** Compute a stable fingerprint of the session-defining params so we can
* detect when a loadSession/resumeSession call requires tearing down and
* recreating the underlying Query process. Every option classified in
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
* keeps stale values. MCP servers are sorted by name, and skills are
* normalized as a set, so ordering differences don't trigger unnecessary
* recreations. */
export function computeSessionFingerprint(params: {
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
additionalDirectories?: NewSessionRequest["additionalDirectories"];
systemPrompt?: NewSessionRequest["systemPrompt"];
_meta?: NewSessionRequest["_meta"];
}): string {
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
const options: Record<string, unknown> = Object.fromEntries(
Object.entries(meta?.claudeCode?.options ?? {}).filter(
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
),
);
options.skills = normalizeSkills(options.skills as Options["skills"]);
return JSON.stringify(
canonicalJson({
cwd: params.cwd,
mcpServers: servers,
additionalDirectories: params.additionalDirectories,
additionalRoots: meta?.additionalRoots,
systemPrompt: meta?.systemPrompt,
clientSystemPrompt: params.systemPrompt ?? undefined,
disableBuiltInTools: meta?.disableBuiltInTools,
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
options,
}),
);
}

View File

@@ -448,10 +448,16 @@ class V2Agent implements AgentHandle, SessionConnection {
const response = await this.setupFields(session);
this.afterSetup(session, params);
// The response ends the replay, and the replayed children's states before
// it are history: their current states follow it. Nothing enforces that
// the timer runs after the response goes out; it relies on the response
// being written when this handler returns, as the v1 core's
// `afterSetupResponse` does.
// it are history: their current states follow it. The ACP SDK (2.6.0)
// gives a request handler no responder and no after-response hook, so the
// order rests on two things that its source shows and its API does not
// promise. `registerAppRequest` queues the response in the microtask that
// follows this handler's return, with no macrotask in between, so the
// timer runs after it. And `Connection.sendMessage` writes in call order,
// so what the timer sends is written after the response. The timer is
// set in the same synchronous step as the `return`: an `await` between
// them would let it run first. The v1 core's `afterSetupResponse` relies
// on the same.
if (subagents) setTimeout(() => subagents.afterResponse(), 0);
return response;
}

View File

@@ -136,8 +136,9 @@ export class TurnReport {
* - Claude Code's sign-in message. Its `/login` instruction is for Claude
* Code's terminal, and the turn's idle fails with `authRequired` instead.
* - The message that Claude Code wrote for a failed API request (`error` on
* a `<synthetic>` message). The turn's idle carries it when the turn
* fails, and a notice otherwise (see {@link takeApiError}).
* a `<synthetic>` message). A notice carries it where the turn goes on
* past it. At the turn's end, the idle carries it when the turn fails,
* and a notice otherwise (see {@link takeApiError}).
*/
assistant(message: SDKAssistantMessage): boolean {
if (message.error) this.errorKind = message.error;

View File

@@ -903,6 +903,7 @@ export class V2Session implements RequestTarget {
this.tasks.toolResults(this.output, message);
return;
}
this.wentOnPastApiError();
// The rewrite applies to the updates of this message only.
this.output.rewrite = this.clearContext.toolResults(message);
try {
@@ -935,6 +936,7 @@ export class V2Session implements RequestTarget {
if (this.stopped.drops(message)) return;
if (message.parent_tool_use_id === null) {
this.outputStarts();
this.wentOnPastApiError();
this.reports.streamEvent(message);
}
this.output.streamEvent(message);
@@ -947,6 +949,7 @@ export class V2Session implements RequestTarget {
// request are not shown as Claude's text (see `TurnReport`).
if (this.turnReport.assistant(message)) return;
this.outputStarts();
this.wentOnPastApiError();
this.reports.assistant(message);
}
this.output.assistant(message);
@@ -1208,10 +1211,21 @@ export class V2Session implements RequestTarget {
}
/**
* Sends the text of a failed API request that Claude Code wrote into the
* conversation (see `TurnReport.assistant`) as a notice. A turn whose idle
* fails sends none: its error carries the failure. `end` is the turn's
* result, if it has one.
* The turn went on past a failed API request: Claude Code kept the tool
* calls of a response that broke off ("Connection lost mid-response. The
* response above may be incomplete.") and ran them. Its notice goes out
* here, after the output it is about, and not at the turn's end.
*/
private wentOnPastApiError(): void {
const text = this.turnReport.takeApiError();
if (text) this.reports.apiError(text);
}
/**
* At the turn's end, sends the text of a failed API request that Claude
* Code wrote into the conversation (see `TurnReport.assistant`) as a
* notice. A turn whose idle fails sends none: its error carries the
* failure. `end` is the turn's result, if it has one.
*/
private reportApiError(end: TurnEnd | undefined): void {
const text = this.turnReport.takeApiError();

View File

@@ -259,7 +259,7 @@ export class SubagentReplay {
if (sendMessage) {
if (sendMessage.recipientSessionId === undefined) {
sendMessage.shownAsToolCall = true;
return [notification];
return [this.transcript.withTarget(notification, output.sessionId)];
}
if (sendMessage.outgoingSent) return [];
sendMessage.outgoingSent = true;
@@ -288,7 +288,7 @@ export class SubagentReplay {
return [];
}
const child = this.transcript.children.get(update.toolCallId);
if (!child) return [notification];
if (!child) return [this.transcript.withTarget(notification, output.sessionId)];
if (
update.status === "completed" &&
child.announced &&

View File

@@ -70,7 +70,9 @@ import {
rfdLabels,
sendMessageResume,
subagentIdentity,
targetAgentId,
unexposedControlFallback,
withSessionReference,
type HistoricalSubagent,
type SubagentIdentity,
type SubagentModels,
@@ -133,7 +135,7 @@ export interface RequestTarget {
openRequest<T>(signal: AbortSignal, send: (signal: AbortSignal) => Promise<T>): Promise<T>;
}
type ChildState = "idle" | "running" | "requires_action";
type ChildState = "idle" | "running" | "requires_action" | "unknown";
type IdleOutcome =
| { stopReason: "end_turn" }
@@ -178,6 +180,11 @@ type Child = {
description: string | undefined;
/** Whether a run of it is going on: its foreground work. */
working: boolean;
/**
* Whether the adapter stopped following it while a run of it went on (see
* `conversationReset`): its state is `unknown`.
*/
unobservable: boolean;
/** The state its stream was told last. */
reportedState: ChildState | undefined;
/** How its last run ended. */
@@ -316,6 +323,11 @@ export class SessionTasks {
string,
{ initial: AcpSessionNotification; message?: string; delegationId?: string }
>();
/**
* The agent that each TaskStop or SendMessage call names (`targetAgentId`),
* until the call's final update.
*/
private readonly targetByToolCall = new Map<string, string>();
/** The restore of a subagent from the session's history, while it reads it (see {@link restore}). */
private restoring: Promise<void> | undefined;
@@ -831,18 +843,24 @@ export class SessionTasks {
}
/**
* Claude Code mounted a fresh conversation (`conversation_reset`). Its
* subagents end, deeper ones first, as on v1 (`finishAll`): each
* child that works goes `idle` with no stop reason, since nothing says how
* its run ended, after its unfinished tool calls end `failed`. Then every
* child is forgotten: a later event of one
* changes nothing, and a child not announced yet stays unexposed. Claude
* Code's tasks (the live subagents, background commands and other tasks)
* stay as its task events report them.
* Claude Code mounted a fresh conversation (`conversation_reset`). Every
* child is forgotten: a later event of one changes nothing, and a child not
* announced yet stays unexposed. Claude Code's tasks (the live subagents,
* background commands and other tasks) stay as its task events report them,
* so a child that works may go on working, unobserved. As on v1
* (`finishAll`), each such child, deeper ones first, goes `unknown`, as the
* RFD requires when a child can no longer be observed, on its stream and in
* its parent's mirror, and loses its `cancel` capability: a forgotten child
* cannot be stopped. Nothing says that its run or its tool calls ended, so
* its unfinished tool calls keep their status. A child that is idle keeps
* its state.
*/
conversationReset(): void {
for (const child of [...this.parentsFirst()].reverse()) {
if (child.working) this.endRun(child, undefined);
if (!child.working) continue;
child.working = false;
child.unobservable = true;
this.sendChildState(child, "unknown", { capabilities: null });
}
this.publish();
for (const child of this.children.values()) this.configs.forget(child.sessionId);
@@ -857,6 +875,7 @@ export class SessionTasks {
this.hiddenSendMessages.clear();
this.shownSendMessages.clear();
this.handbacks.clear();
this.targetByToolCall.clear();
}
/**
@@ -1137,6 +1156,7 @@ export class SessionTasks {
title: undefined,
description: undefined,
working: true,
unobservable: false,
reportedState: undefined,
outcome: undefined,
openRequests: 0,
@@ -1373,12 +1393,48 @@ export class SessionTasks {
* Rewrites an update of `output`, the output of the root or of `child`: an
* update of an Agent or Task call goes out as {@link launchUpdate} says,
* and Claude Code's late result of a call that the child's stop ended
* goes nowhere.
* goes nowhere. A tool call that the client is shown and that operates on
* an announced child of another session gets the RFD's `session` reference
* (see {@link withTarget}).
*/
private route(
output: SessionOutput,
notification: AcpSessionNotification,
child: Child | undefined,
): AcpSessionNotification[] {
return this.routed(output, notification, child).map((shown) =>
this.withTarget(shown, output.sessionId),
);
}
/**
* `notification`, an update that the client is shown on the session
* `shownOn`, with a `session` reference when it is of a TaskStop or
* SendMessage call that names an announced child by its agent id, or of a
* SendMessage call that resumed one and stays shown (see
* {@link requestShows}), as on v1 (`NativeSubagentRuntime.withTarget`).
*/
private withTarget(
notification: AcpSessionNotification,
shownOn: string,
): AcpSessionNotification {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return notification;
}
const agentId = this.targetByToolCall.get(update.toolCallId);
if (FINAL_STATUSES.has(update.status)) this.targetByToolCall.delete(update.toolCallId);
const child =
this.childByToolUse.get(update.toolCallId) ??
(agentId !== undefined ? this.children.get(agentId) : undefined);
if (!child?.announced || child.sessionId === shownOn) return notification;
return withSessionReference(notification, child.sessionId);
}
private routed(
output: SessionOutput,
notification: AcpSessionNotification,
child: Child | undefined,
): AcpSessionNotification[] {
const { update } = notification;
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
@@ -1386,6 +1442,8 @@ export class SessionTasks {
}
if (child?.stopped.has(update.toolCallId)) return [];
const toolName = (update._meta?.claudeCode as { toolName?: unknown } | undefined)?.toolName;
const agentId = targetAgentId(toolName, update.rawInput);
if (agentId !== undefined) this.targetByToolCall.set(update.toolCallId, agentId);
if (toolName === "SendMessage") this.senderByToolUse.set(update.toolCallId, output.sessionId);
const message =
this.sendMessageUpdate(output, notification, toolName) ??
@@ -1594,16 +1652,15 @@ export class SessionTasks {
/**
* A run of `child` ended: its unfinished tool calls get their final status,
* except its background work, then its idle goes out, as v2 asks. A stopped
* run's end `cancelled`, and a failed run's or an unexplained end's
* (`undefined`, an idle with no stop reason) `failed`. At a completed end, a
* run's end `cancelled`, and a failed run's `failed`. At a completed end, a
* tool call that Claude dropped before it ran ends `cancelled`, as on the
* root (see `SessionOutput.closeUnfinished`).
*/
private endRun(child: Child, outcome: IdleOutcome | undefined): void {
private endRun(child: Child, outcome: IdleOutcome): void {
if (!child.working) {
// A later report of the same failure can give its error text.
if (
outcome?.stopReason === "error" &&
outcome.stopReason === "error" &&
outcome.error &&
child.outcome?.stopReason === "error" &&
!child.outcome.error
@@ -1614,10 +1671,10 @@ export class SessionTasks {
return;
}
const keep = this.backgroundWork(child.sessionId);
if (outcome?.stopReason === "end_turn") {
if (outcome.stopReason === "end_turn") {
child.output!.closeUnfinished(false, keep);
} else {
const status = outcome?.stopReason === "cancelled" ? "cancelled" : "failed";
const status = outcome.stopReason === "cancelled" ? "cancelled" : "failed";
const ended = child.output!.finishUnfinished(status, keep, { exitTerminals: true });
for (const toolCallId of ended) child.stopped.add(toolCallId);
}
@@ -1659,6 +1716,7 @@ export class SessionTasks {
}
private childState(child: Child): ChildState {
if (child.unobservable) return "unknown";
if (!child.working) return "idle";
return child.openRequests > 0 ? "requires_action" : "running";
}
@@ -1679,12 +1737,21 @@ export class SessionTasks {
this.sendChildState(child, state);
}
private sendChildState(child: Child, state: ChildState): void {
private sendChildState(
child: Child,
state: ChildState,
capabilities?: { capabilities: null },
): void {
child.reportedState = state;
const snapshot = stateObject(state, state === "idle" ? child.outcome : undefined);
this.host.send({
sessionId: child.parentSessionId,
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state: snapshot },
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
...capabilities,
state: snapshot,
},
});
this.host.send({
sessionId: child.sessionId,
@@ -2044,6 +2111,8 @@ function stateObject(state: ChildState, outcome: IdleOutcome | undefined): v2.St
return { state: "running" };
case "requires_action":
return { state: "requires_action" };
case "unknown":
return { state: "unknown" };
default:
return { state: "idle", ...(outcome ?? {}) } as v2.StateUpdate;
}

View File

@@ -85,6 +85,9 @@ function v2ToolCallContent(item: ToolCallContent): v2.ToolCallContent {
return item;
case "terminal":
return item;
case "session":
// A reference to a session, such as a subagent's, is the same in v2.
return item;
default:
if (isV2Diff(item)) return item;
// A v1 diff names one path, with old and new texts that can be a