Compare commits

...

12 Commits

Author SHA1 Message Date
Ben Brandt
7184fd3cfe fix: keep a long Agent call's heartbeat on the session that made the call
A heartbeat of a running Agent call reports under a synthetic id, so the
adapter falls back to parent_tool_use_id, which is the Agent call itself, and
also stamped that id as the beat's parentToolUseId. For a client on the
subagents RFD, which sees the Agent call, the beat and every later update of
the call then went to the child session. Found in a live run with a 40-second
subagent.

The same run showed that Claude Code's interrupt currently stops background
subagents too, so the README and comments no longer say they keep running.
2026-10-13 14:59:44 +02:00
Ben Brandt
9382e10f2c fix: keep the cancel's tool-call cleanup for AIR, whose draft ends every child at the cancel
The previous commit stopped a cancel from clearing each tool call's state, so
that a raced tool call of an RFD client's background child stays on the root.
For AIR that let a later update of an ended child's permission tool call reach
the child's session after subagent_state_update cancelled. Only the RFD form
keeps the state now.
2026-10-13 09:52:54 +02:00
Ben Brandt
4b67682ff9 fix: never announce a nested subagent early or under the root, and keep a raced tool call where it was emitted after a cancel
Two MUSTs of the subagents RFD that rare event orders broke:

- A child is announced before any traffic that names it, and never under a
  guessed parent. An Agent call made inside a child that was known but not
  yet announced registered that child as the parent, so the nested child
  was announced under it before the child itself was; and one made inside a
  child not known yet fell back to the root. In the RFD form, such a call
  now establishes no parent: its update is buffered under the child's tool
  use, as before, and routed again once the child is announced, which
  registers the parent and announces the nested child after it.
- An interaction already issued never moves to another session. A cancel
  cleared the session of every eagerly emitted tool call, a relic of when a
  cancel ended every subagent. A background child keeps running after a
  cancel (agent-client-protocol#2308), so a tool call of its that a
  permission request emitted on the root before the child was announced
  then moved into the child, for example with its next progress beat. A
  cancel now keeps the per-tool-call state (the session, the fields the
  client holds, the hook callbacks); each tool call drops it when it ends,
  and the cancelled turn's own tool calls end through their results or
  closeUnfinished.
2026-10-13 08:28:34 +02:00
Ben Brandt
529718af76 fix: report a failed subagent with the error stop reason
The subagents RFD reports a failure that ends a child's foreground work as
`idle` with `stopReason: "error"` and, when available, the failure as a
JSON-RPC `error` (agent-client-protocol#2281). SDK 1.8.0's v1
`IdleStateUpdate` has that variant, so the stand-in
`_meta.claudeCode.error` goes: a failed child is now idle with the `error`
stop reason, and with the SDK's error text as an internal error, the code a
failed turn uses on the v2 surface.
2026-10-13 08:11:40 +02:00
Ben Brandt
69cd49afeb Merge remote-tracking branch 'origin/main' into feat/subagent-update
# Conflicts:
#	src/acp-agent.ts
2026-10-13 08:02:17 +02:00
Ben Brandt
764c5abdf1 fix: report no cancellation for a subagent that only outlived a cancel
From a third review of the RFD form, against the subagents RFD as
revised by agentclientprotocol/agent-client-protocol#2308 (a cancel stops
the descendant work the cancelled work waits on; the rest is up to the
Agent; the Client cleans up a descendant's requests once it reports a
cancelled idle):

- At the end of the stream, or a conversation reset, a child that still
  runs is idle with no stop reason. After a cancel it is a background
  child that outlived it, so reporting `cancelled` invented a
  cancellation, which under #2308 also tells the client to clean up its
  requests. Foreground children report their cancellation from the SDK's
  task events, before the cancelled prompt resolves.
- A SendMessage result whose call has no message text still names the
  call, so a late result does not resume a run that already ended.
- An agent-level test pins #2308's validation scenario: at a root
  cancel, the foreground child's request is withdrawn and it reports a
  cancelled idle before the prompt response; the background child keeps
  running with its request open, and ends idle with no reason.
- README says what `session/cancel` does to children.

AIR's path and golden files are unchanged.
2026-10-05 23:06:55 +02:00
Ben Brandt
5588538fe2 docs: note why a subagent's agent id is unique enough as its session id
The RFD needs a child session id unique within the connection and never
under two parents. Agent ids are random and an agent keeps its parent, so
only a fork could repeat one under another root, which the SDK's
forkSession cannot do today because it copies no subagents/ history.
2026-10-05 19:29:55 +02:00
Ben Brandt
fd41b69e05 Merge remote-tracking branch 'origin/main' into feat/subagent-update
# Conflicts:
#	src/native-subagents.ts
2026-10-05 17:02:56 +02:00
Ben Brandt
bce759a258 fix: order a resumed subagent's delegations by the call that made each one
Fixes from a second review of the RFD form, for resume and end events
that arrive out of their usual order:

- Each delegation is the call the SDK runs it under. A run's end records
  its call; a SendMessage result for a call whose run already ended
  resumes nothing (`isEndedDelegation`), so a late result no longer sets
  an idle child running for good or re-arms the turn's hold.
- The end of an earlier delegation ends nothing (`isStaleEnd`), now also
  in the agent: a stale task_notification no longer settles the resumed
  run's live task, which permission attribution and the turn hold use.
- A run under an id the child used before becomes the current delegation
  again: the check compares with the current call, not the map.
- A child that was never announced does not take a resume's call, so a
  late frame cannot announce a withdrawn child.
- A running task_updated patch reports only that the child runs: its
  prompt is a guess, and the task_started or SendMessage result carries
  the delegation (`sendMessageResumed`). `taskResumed` keeps its
  signature from main.
- A resume while a request of the child is open reports requires_action.
- The end-to-end stale-end test now checks that the child stays running
  until the real end.

AIR's path and golden files are unchanged.
2026-10-05 17:00:24 +02:00
Ben Brandt
f826b6025e Merge remote-tracking branch 'origin/main' into feat/subagent-update 2026-10-05 16:29:22 +02:00
Ben Brandt
020dab8dce fix: follow a resumed subagent under the call that resumed it, and other RFD review fixes
Fixes from a close review of the RFD form, against the subagents RFD and
the bundled Claude Code CLI:

- The CLI runs a resumed agent under the id of the call that resumed it
  (`toolUseContext.toolUseId`, the SendMessage call). The child now takes
  over that id, so the resumed run's output and its task_notification
  reach the child instead of being buffered and dropped. A task_started
  of a known child resumes it even without a subagent type.
- A prompt message's id is the id of the call that delegated, and a
  prompt whose id is already in the child's transcript is not sent
  again, so a repeated resume signal does not overwrite or repeat it.
- The end of an earlier delegation no longer ends the current one.
- A resume's `senderSessionId` is the session that made the SendMessage
  call, and is left out when unknown, instead of always the parent.
- A failure reported first without the SDK's error text gets it when a
  later event of the same failure carries it.
- A task that a worker restart orphaned is idle with no stop reason, not
  cancelled: the RFD forbids inventing a cancellation.
- A child that ends before its parent is known stays unexposed, rather
  than being announced under the root: the RFD forbids guessing parentage.
- A late frame that names a child keeps its role in the title.
- `announceNativeSubagent` takes its form explicitly.
- README, docs/air-extensions.md and doc comments say what the code does.

AIR's path and golden files are unchanged.
2026-10-05 16:29:21 +02:00
Ben Brandt
e34c447e96 feat: report subagents as the subagents RFD defines, for clients that are not AIR
A client that declares `clientCapabilities.subagents` and is not AIR now
gets native subagent sessions in the form of the revised subagents RFD
(agentclientprotocol/agent-client-protocol#1992, docs/rfds/subagents.mdx),
which SDK 1.7.0 types. AIR keeps the earlier draft it implements
(`subagent_spawned`, `subagent_state_update`), whichever signal it
declares; its golden files do not change.

In the RFD form (`SubagentForm`, chosen by `subagentForm`):

- The child's session id is the SDK agent id, for every delegation to
  it: a SendMessage resume no longer opens a new generation.
- `subagent_update` announces the child on its immediate parent before
  any child traffic, with `title` (the agent's name, with its role after
  it, else the role), `description` (the Agent input's short
  description, never the prompt), and the `running` state.
- The prompt of each delegation is a `session_message` in the child's
  transcript, from the parent, with the id of the tool use that sent it.
  The Agent, Task, or SendMessage tool call stays a tool call of the
  session that made it.
- The work state follows the SDK: `requires_action` while a permission
  or question request of the child is open, `idle` with `end_turn`,
  `cancelled` when the SDK stopped or killed the task, and a failure as
  `idle` with the SDK's error in `_meta.claudeCode.error`. (The RFD's
  `error` stop reason is newer than SDK 1.7.0's closed v1 StopReason.)
  `session/cancel` no longer ends the children: a background subagent
  outlives the interrupt, and the RFD reports a cancellation only from
  evidence. At the end of the stream a running child gets `idle`,
  `cancelled` after a cancel.
- An idle child is reusable, so its later updates still reach it.
- No per-child capabilities, so a client may not change a child.
- Replay does not expose children yet, until it keeps their live ids.

The agent-side `sessionCapabilities.subagents` goes to AIR alone: the RFD
has only the client capability. compare.ts allows it against the
origin/main baseline.

Tests: the end-to-end tests of the earlier draft now run with an AIR
client; new runtime and end-to-end tests cover the RFD form, and check
every update against the v1 schema.

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-05 13:27:21 +02:00
8 changed files with 2020 additions and 151 deletions

View File

@@ -37,14 +37,35 @@ npm install @agentclientprotocol/claude-agent-acp@preview
### Subagent sessions
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
in its initialize response. The canonical field remains supported and takes precedence once it is
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
tool-call representation and child interactions stay on the root session. Clients that use the
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-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 `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.
- The prompt of the Agent or Task call that launches the child, and the message of each
SendMessage call that resumes it, is a `session_message` in the child's transcript. The tool
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 and, currently, background children too. Each stopped child reports `idle`
with `cancelled` from Claude Code's own events, and the open requests of the foreground ones are
withdrawn. The adapter ends no child at a cancel itself, so a child that keeps running keeps its
open requests. 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 client cannot prompt, cancel, or otherwise change a child yet, and a replay does not restore
children yet.
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.
## Contribution Policy

View File

@@ -1186,8 +1186,9 @@ This section covers only the AIR bridge.
- The canonical client field is `clientCapabilities.subagents: {}`.
- Released ACP SDKs can strip that draft field.
AIR can instead declare `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`.
- Either signal enables native subagent sessions. The canonical field takes precedence when it is available.
- The agent advertises `nativeSubagentSessions` to AIR, and `agentCapabilities.sessionCapabilities.subagents` to every client.
- Either signal enables native subagent sessions, and AIR gets this earlier draft of the RFD whichever it declares.
- The agent advertises `nativeSubagentSessions` and `agentCapabilities.sessionCapabilities.subagents` to AIR.
- A client that is not AIR and declares `clientCapabilities.subagents` gets the revised RFD instead (`subagent_update`, `session_message`), see the README.
- With native sessions, the adapter sends `subagent_spawned` and `subagent_state_update`.
The child output goes to the child session. The Agent or Task tool call is not the subagent card.
- `subagent_spawned` can carry an optional `prompt`: the exact prompt of the child session.

View File

@@ -109,6 +109,7 @@ import {
asSdkSessionNotification,
clientSupportsSubagents,
SubagentAwareSessionCapabilities,
subagentForm,
type SubagentState,
} from "./acp-subagents.js";
import {
@@ -118,6 +119,7 @@ import {
NativeSubagentRuntime,
nativeSubagentState,
resumedNativeSubagentId,
sendMessageResume,
sendMessageResumePrompt,
} from "./native-subagents.js";
import {
@@ -2796,6 +2798,8 @@ export class ClaudeAcpAgent {
}
}
// The subagents RFD has only the client capability. AIR's draft also had
// this agent capability, which AIR gets (`docs/air-extensions.md`).
const sessionCapabilities: SubagentAwareSessionCapabilities = {
additionalDirectories: {},
close: {},
@@ -2803,7 +2807,7 @@ export class ClaudeAcpAgent {
fork: {},
list: {},
resume: {},
subagents: {},
...(this.toolCallCapabilities.air.client ? { subagents: {} } : {}),
};
return {
@@ -4092,7 +4096,7 @@ export class ClaudeAcpAgent {
* stamps from `parent_tool_use_id`, and never reach the top-level feed
* as the turn's answer. */
const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(
clientSupportsSubagents(this.clientCapabilities),
subagentForm(this.clientCapabilities),
params.sessionId,
session,
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
@@ -5849,6 +5853,10 @@ export class ClaudeAcpAgent {
);
break;
}
// In the RFD form, a resumed child runs under the call that
// resumed it, and the end of an earlier delegation ends
// nothing: the child and its live task belong to the new run.
if (subagents.isStaleEnd(message.task_id, message.tool_use_id)) break;
// The task settled — no further tool calls can originate
// from it, so its registry entry can be dropped.
await subagents.finishTask(
@@ -5856,6 +5864,7 @@ export class ClaudeAcpAgent {
message.status,
sendUpdate,
message.tool_use_id,
{ reason: message.reason },
);
await asyncTasks.taskNotification({
task_id: message.task_id,
@@ -5898,7 +5907,13 @@ export class ClaudeAcpAgent {
message.patch.status === "failed" ||
message.patch.status === "killed"
) {
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
await subagents.finishTask(
message.task_id,
message.patch.status,
sendUpdate,
undefined,
{ error: message.patch.status === "failed" ? message.patch.error : undefined },
);
const parentToolUseId = session.liveBackgroundTasks.get(
message.task_id,
)?.parentToolUseId;
@@ -7361,20 +7376,26 @@ export class ClaudeAcpAgent {
);
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
if (resumedAgentId) {
resumeLiveTask(resumedAgentId);
await subagents.taskResumed(
const resume = sendMessageResume(
session.toolUseCache,
resumedAgentId,
sendUpdate,
sendMessageResumePrompt(
session.toolUseCache,
resumedAgentId,
Array.isArray(content)
? content.flatMap((block) =>
block.type === "tool_result" ? [block.tool_use_id] : [],
)
: [],
),
Array.isArray(content)
? content.flatMap((block) =>
block.type === "tool_result" ? [block.tool_use_id] : [],
)
: [],
);
// In the RFD form the result can arrive after the run it
// started ended, and then resumes nothing.
if (!subagents.isEndedDelegation(resumedAgentId, resume?.toolUseId)) {
resumeLiveTask(resumedAgentId);
await subagents.sendMessageResumed(
resumedAgentId,
sendUpdate,
resume?.text,
resume?.toolUseId,
);
}
}
}
@@ -7440,13 +7461,18 @@ export class ClaudeAcpAgent {
if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
break;
}
const subagentParentToolUseId = message.parent_tool_use_id
? [...session.liveBackgroundTasks.values()].some(
(task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id,
)
// The beat is in the subagent whose Agent call is
// `parent_tool_use_id`, unless it reports on that Agent call
// itself: the Agent call's own heartbeat falls back to its id, and
// a tool call is never its own parent.
const subagentParentToolUseId =
message.parent_tool_use_id &&
message.parent_tool_use_id !== toolCallId &&
[...session.liveBackgroundTasks.values()].some(
(task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id,
)
? message.parent_tool_use_id
: undefined
: undefined;
: undefined;
// `tool_name` names the tool that reports the beat. When the beat
// falls back to the parent call, AIR gets the name of that call,
// or no name. Every other client gets `tool_name`, like upstream.
@@ -7691,9 +7717,18 @@ export class ClaudeAcpAgent {
// their compaction is running. Close it before ANY cancelled prompt
// resolves, not just the active-turn idle/backstop paths.
await session.contextCompaction?.interrupt();
// A cancel drops the state of each tool call (the session that a
// permission request emitted it in, the fields the client holds, the hook
// callbacks), even when publishing fails. AIR's draft ends every child
// here, so a later update of an ended child's tool call is then dropped
// instead of reaching that child's session. The RFD form keeps the state:
// a background subagent can keep running after a cancel
// (agentclientprotocol/agent-client-protocol#2308), and its tool call that
// a permission request emitted on the root before the child was announced
// must stay on the root, which the RFD requires. Each tool call drops its
// own state when it ends.
try {
await session.nativeSubagentRuntime?.finishAll(
"cancelled",
await session.nativeSubagentRuntime?.parentCancelled(
session.nativeSubagentDeliver ??
(async (notification) =>
this.client.sessionUpdate(asSdkSessionNotification(notification))),
@@ -7704,9 +7739,11 @@ export class ClaudeAcpAgent {
error,
);
} finally {
session.eagerToolCallSessions?.clear();
session.toolCallFields?.clear();
clearHookCallbacks(params.sessionId);
if (session.nativeSubagentRuntime?.form !== "rfd") {
session.eagerToolCallSessions?.clear();
session.toolCallFields?.clear();
clearHookCallbacks(params.sessionId);
}
}
// The user messages this cancel abandons while Claude Code may still have
// them queued: they are dropped from its queue before the interrupt.
@@ -8246,7 +8283,10 @@ export class ClaudeAcpAgent {
})
: undefined;
let replayTurnId: string | undefined;
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
// Only AIR's draft replays child sessions so far. A client on the RFD gets
// no children on replay, which the RFD allows (it SHOULD replay them), until
// the replay keeps the children's live session ids.
const nativeReplayEnabled = subagentForm(this.clientCapabilities) === "air";
const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
const replayTerminalStates = new Map<string, "completed" | "failed" | "cancelled">();
const replayChildren = new Map<
@@ -8664,6 +8704,21 @@ export class ClaudeAcpAgent {
return response;
}
/** Runs `request`, a request that `requestSessionId` sends for the session
* `ownerSessionId`. When that is a subagent's child session, the child
* reports that it awaits the user while the request is open (the RFD form,
* see `NativeSubagentRuntime.awaitingUser`). */
private withChildAwaitingUser<T>(
ownerSessionId: string,
requestSessionId: string,
request: () => Promise<T>,
): Promise<T> {
const runtime = this.sessions[ownerSessionId]?.nativeSubagentRuntime;
return runtime && requestSessionId !== ownerSessionId
? runtime.awaitingUser(requestSessionId, request)
: request();
}
/** Mark a client request of the subagent `agentID` (absent for the main
* conversation) as awaiting user input for exactly the lifetime of its
* promise (see `Session.openUserInputs`). Steering consults the open
@@ -8818,10 +8873,12 @@ export class ClaudeAcpAgent {
// The request goes to a subagent's child session for a tool call of that
// subagent, but it blocks the turn of the owner session.
try {
return await this.withPendingUserInput(
ownerSessionId,
() => raceWithAbort(this.client.requestPermission(params, signal), signal),
agentID,
return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, () =>
this.withPendingUserInput(
ownerSessionId,
() => raceWithAbort(this.client.requestPermission(params, signal), signal),
agentID,
),
);
} catch (error) {
if (signal.aborted) {
@@ -9289,10 +9346,12 @@ export class ClaudeAcpAgent {
);
let response;
try {
response = await this.withPendingUserInput(
sessionId,
() => this.client.createElicitation(createRequest, signal),
agentID,
response = await this.withChildAwaitingUser(sessionId, requestSessionId, () =>
this.withPendingUserInput(
sessionId,
() => this.client.createElicitation(createRequest, signal),
agentID,
),
);
} catch (error) {
// A cancellation we requested (signal aborted) settles as an aborted tool

View File

@@ -6,17 +6,33 @@ import type {
import {
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
clientSupportsAirCapability,
isAirClient,
} from "./air-extension.js";
export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
/**
* Temporary typed surface for agentclientprotocol/agent-client-protocol#1992.
* How a client gets native subagent sessions.
*
* The wire contract is already defined by the ACP draft, but the published
* TypeScript SDK does not contain it yet. Keep the compatibility boundary in
* this file so it can be replaced by SDK exports without changing lifecycle
* code when the draft ships.
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
* as revised): `subagent_update` and `session_message`, which the SDK types.
* A client that declares `clientCapabilities.subagents` and is not AIR gets
* it. (ACP v2 clients do not get subagents yet.)
* - `air`: the earlier draft of that RFD, which AIR implements:
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
* whichever capability it declares (`docs/air-extensions.md`).
*/
export type SubagentForm = "rfd" | "air";
/** The form of native subagent sessions that the client gets, or none. */
export function subagentForm(capabilities?: ClientCapabilities | null): SubagentForm | undefined {
if (!clientSupportsSubagents(capabilities)) return undefined;
return isAirClient(capabilities) ? "air" : "rfd";
}
/**
* The earlier draft of the subagents RFD, which AIR implements and the SDK
* does not type.
*/
export type SubagentSessionCapabilities = {
cancel?: boolean;
@@ -116,7 +132,7 @@ export function clientSupportsSubagents(capabilities?: ClientCapabilities | null
return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY);
}
/** The only cast needed until the TypeScript SDK publishes PR #1992. */
/** The cast that AIR's draft updates and the async task updates need. */
export function asSdkSessionNotification(
notification: AcpSessionNotification,
): SessionNotification {

View File

@@ -1,6 +1,12 @@
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk";
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
type WorkState = NonNullable<
Extract<SessionNotification["update"], { sessionUpdate: "subagent_update" }>["state"]
>;
export type NativeSubagent = {
sessionId: string;
parentSessionId: string;
@@ -9,9 +15,32 @@ export type NativeSubagent = {
task: string;
/**
* The exact prompt of this generation, sent as `prompt` in
* `subagent_spawned`. It is absent when the adapter has no prompt.
* `subagent_spawned`, or as the child's first `session_message` in the RFD
* form. It is absent when the adapter has no prompt.
*/
prompt?: string;
/** The RFD form: the parent's label for the child, `subagent_update.title`. */
title?: string;
/** The RFD form: the child's role, its `subagent_type`, for the title. */
role?: string;
/** The RFD form: the child's purpose, `subagent_update.description`. */
description?: string;
/**
* The RFD form: the work state the client holds. The child is reusable, so
* `idle` is not terminal. AIR's draft uses {@link terminalState} instead.
*/
workState?: WorkState["state"];
/**
* The RFD form: the child's idle snapshot reports a failure without the
* SDK's error text, which a later event of the same failure may carry.
*/
missingError?: boolean;
/** The RFD form: the open permission and question requests of the child. */
openRequests?: number;
/** The RFD form: the ids of the prompt messages in the child's transcript. */
promptIds?: Set<string>;
/** The RFD form: the calls that delegated to the child, whose runs ended. */
endedRuns?: Set<string>;
announced?: boolean;
terminalState?: SubagentState;
/** Connection-local single-flight state; never serialized on the wire. */
@@ -29,6 +58,19 @@ export type NativeSubagentSession = {
type Publish = (notification: AcpSessionNotification) => Promise<void>;
type Logger = { log(message: string): void };
/** What the SDK says about the end of a task, beyond its status. */
export type TaskEnd = {
/** The SDK's text for a failure. */
error?: string;
/** 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.
*/
unexplained?: boolean;
};
type TaskStarted = {
taskId: string;
toolUseId?: string | null;
@@ -56,6 +98,8 @@ const MAX_CHILD_TOOL_CALLS = 2048;
*/
export class NativeSubagentRuntime {
readonly enabled: boolean;
/** The form of the child sessions; see {@link SubagentForm}. */
readonly form: SubagentForm | undefined;
private readonly children: Map<string, NativeSubagent>;
private readonly taskByToolUse: Map<string, string>;
@@ -70,18 +114,22 @@ export class NativeSubagentRuntime {
*/
private readonly childByToolCall = new Map<string, NativeSubagent>();
private readonly taskFinishPromises = new Map<string, Promise<void>>();
/** The RFD form: the session that made each SendMessage call. */
private readonly senderByToolUse = new Map<string, string>();
private readonly generationByTaskId = new Map<string, number>();
private readonly pending = new Map<string, AcpSessionNotification[]>();
private pendingCount = 0;
/** `form` is undefined for a client without native subagent sessions. */
constructor(
enabled: boolean,
form: SubagentForm | undefined,
private readonly rootSessionId: string,
private readonly session: NativeSubagentSession,
private readonly publish: Publish,
private readonly logger: Logger,
) {
this.enabled = enabled;
this.form = form;
this.enabled = form !== undefined;
this.children = session.nativeSubagentsByTaskId ??= new Map();
this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
@@ -96,6 +144,16 @@ export class NativeSubagentRuntime {
notification: AcpSessionNotification,
deliver: Publish,
forcedSessionId?: string,
): Promise<AcpSessionNotification | null> {
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
if (routed && this.form === "rfd") this.rememberSendMessage(routed);
return routed;
}
private async routeUpdate(
notification: AcpSessionNotification,
deliver: Publish,
forcedSessionId?: string,
): Promise<AcpSessionNotification | null> {
const { update } = notification;
const claudeMeta = update._meta?.claudeCode as
@@ -116,28 +174,47 @@ export class NativeSubagentRuntime {
mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity),
);
}
const parentChild = claudeMeta?.parentToolUseId
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
: undefined;
const parentSessionId = claudeMeta?.parentToolUseId
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
? parentChild?.sessionId
: this.rootSessionId;
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
// The RFD form: a call made inside a child that is not announced yet
// establishes no parent, because the RFD forbids announcing its child
// under the root or before that child. The update is buffered below
// under the child's tool use, and routed again, with the child as the
// parent, once the child is announced.
const parentPending =
this.form === "rfd" && !!claudeMeta?.parentToolUseId && !parentChild?.announced;
const child = this.childByParentToolUse.get(toolCallId);
if (child && !child.announced) {
child.parentSessionId = parentSessionId ?? this.rootSessionId;
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
await announceNativeSubagent(child, this.publish);
for (const pending of this.takePending(toolCallId)) await deliver(pending);
}
if (!child && isFailedToolCallUpdate(update)) {
this.takePending(toolCallId);
const initial = this.controlByToolUse.get(toolCallId);
this.cleanupControl(toolCallId);
if (forcedSessionId) {
return { ...notification, sessionId: forcedSessionId };
if (!parentPending) {
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
if (child && !child.announced) {
child.parentSessionId = parentSessionId ?? this.rootSessionId;
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
await this.announce(child);
for (const pending of this.takePending(toolCallId)) await deliver(pending);
}
return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
}
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
if (this.form === "air") {
if (!child && isFailedToolCallUpdate(update)) {
this.takePending(toolCallId);
const initial = this.controlByToolUse.get(toolCallId);
this.cleanupControl(toolCallId);
if (forcedSessionId) {
return { ...notification, sessionId: forcedSessionId };
}
return failedControlFallback(
initial,
notification,
parentSessionId ?? this.rootSessionId,
);
}
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
}
// The RFD form: the Agent/Task tool call is the parent's record of the
// delegation, so it stays a tool call of the session that made it.
}
const toolCallId =
@@ -172,6 +249,19 @@ export class NativeSubagentRuntime {
async taskStarted(task: TaskStarted, deliver: Publish): Promise<void> {
if (!this.enabled) return;
const known = this.form === "rfd" ? this.children.get(task.taskId) : undefined;
if (known) {
// A child keeps its session for every delegation to it. The SDK starts
// a resumed agent again under the id of the call that resumed it.
await this.resumeChild(
known,
task.taskId,
promptText(task.prompt),
task.toolUseId ?? undefined,
deliver,
);
return;
}
if (!task.subagentType) {
if (task.toolUseId) {
this.takePending(task.toolUseId);
@@ -207,6 +297,11 @@ export class NativeSubagentRuntime {
identity?.description ?? task.description,
),
...promptField(promptText(task.prompt) ?? identity?.prompt),
...rfdLabels(
identity?.name,
identity?.subagentType ?? task.subagentType,
identity?.description ?? task.description,
),
},
!!knownParentSessionId || !task.toolUseId,
deliver,
@@ -219,6 +314,10 @@ export class NativeSubagentRuntime {
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
* signal. A child that did not finish is not changed. The `prompt` is the
* SendMessage text that resumed the child, when the adapter knows it.
*
* In the RFD form, the running patch only reports that the child runs: its
* prompt is a guess (the latest SendMessage to the agent), and the
* `task_started` or the SendMessage result carries the delegation.
*/
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
if (!this.enabled) return;
@@ -226,6 +325,10 @@ export class NativeSubagentRuntime {
if (!previous) return;
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
if (finishing) await finishing.catch(() => {});
if (this.form === "rfd") {
await this.resumeChild(previous, taskId, undefined, undefined, deliver);
return;
}
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
await this.openGeneration(
taskId,
@@ -242,27 +345,99 @@ export class NativeSubagentRuntime {
);
}
/**
* The SDK reports in a SendMessage result that the call `toolUseId`, with
* the message `prompt`, resumed the agent `taskId`. AIR's draft treats it as
* any resume signal. In the RFD form, the call is the delegation, which the
* child takes unless its run already ended: the result can arrive after it.
*/
async sendMessageResumed(
taskId: string,
deliver: Publish,
prompt?: string,
toolUseId?: string,
): Promise<void> {
if (this.form !== "rfd") return this.taskResumed(taskId, deliver, prompt);
const child = this.children.get(taskId);
if (!child) return;
const finishing = this.taskFinishPromises.get(taskId);
if (finishing) await finishing.catch(() => {});
if (this.isEndedDelegation(taskId, toolUseId)) return;
await this.resumeChild(child, taskId, promptText(prompt), toolUseId, deliver);
}
/**
* The RFD form: whether the run of the call `toolUseId` that delegated to
* the child `taskId` already ended. AIR's draft has no such runs.
*/
isEndedDelegation(taskId: string, toolUseId: string | null | undefined): boolean {
if (this.form !== "rfd" || !toolUseId) return false;
return this.children.get(taskId)?.endedRuns?.has(toolUseId) ?? false;
}
/**
* The RFD form: whether `toolUseId` is an earlier delegation of the child
* `taskId`, whose end does not end the current one. AIR's draft has no
* such delegations.
*/
isStaleEnd(taskId: string, toolUseId: string | null | undefined): boolean {
if (this.form !== "rfd" || !toolUseId) return false;
const child = this.children.get(taskId);
return (
!!child &&
child.parentToolUseId !== undefined &&
toolUseId !== child.parentToolUseId &&
this.childByParentToolUse.get(toolUseId) === child
);
}
/**
* Reports that the SDK ended the task's work. `end` has what the SDK says
* about it: its text for a failure, and its reason for an unusual end.
*/
async finishTask(
taskId: string,
status: unknown,
deliver: Publish,
toolUseId?: string | null,
end?: TaskEnd,
): Promise<void> {
if (!this.enabled) return;
const state = nativeSubagentState(status);
const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId) return;
if (!state || !child || child.terminalState !== undefined) return;
if (!state || !child) return;
if (this.form === "rfd") {
if (this.isStaleEnd(taskId, toolUseId)) return;
if (!child.announced && !child.announcePromise) {
this.withdraw(taskId, child);
return;
}
}
const existing = this.taskFinishPromises.get(taskId);
if (existing) return existing;
if (existing) {
await existing;
if (this.form === "rfd") await this.supplyError(child, state, end?.error);
return;
}
if (this.form === "air" ? child.terminalState !== undefined : child.workState === "idle") {
if (this.form === "rfd") await this.supplyError(child, state, end?.error);
return;
}
const finish = Promise.resolve().then(async () => {
try {
await announceNativeSubagent(child, this.publish);
await this.announce(child);
if (child.parentToolUseId) {
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
}
await finishNativeSubagent(this.session, taskId, state, this.publish);
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);
} else {
await finishNativeSubagent(this.session, taskId, state, this.publish);
}
} finally {
if (child.parentToolUseId) {
this.cleanupControl(child.parentToolUseId);
@@ -277,12 +452,20 @@ 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.
*/
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
const errors: unknown[] = [];
const end = this.form === "rfd" ? { unexplained: true } : undefined;
try {
for (const taskId of [...this.children.keys()].reverse()) {
try {
await this.finishTask(taskId, state, deliver);
await this.finishTask(taskId, state, deliver, undefined, end);
} catch (error) {
errors.push(error);
}
@@ -298,20 +481,145 @@ export class NativeSubagentRuntime {
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish native subagents");
}
/**
* The parent session got `session/cancel`. AIR's draft ends every child as
* cancelled here. The RFD form reports nothing: acknowledging a cancel is no
* evidence that a child stopped. Claude Code's interrupt stops the
* foreground children that the turn waits on and, currently, background
* children too, and the SDK's task events report each as cancelled. A child
* that keeps running keeps its open requests
* (agentclientprotocol/agent-client-protocol#2308).
*/
async parentCancelled(deliver: Publish): Promise<void> {
if (this.form === "air") await this.finishAll("cancelled", deliver);
}
/**
* Runs `request`, a permission or question request of the session
* `sessionId`. In the RFD form, a child waiting on it is `requires_action`
* until its last open request ends.
*/
async awaitingUser<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined;
if (!child?.announced) return request();
child.openRequests = (child.openRequests ?? 0) + 1;
if (child.openRequests === 1 && child.workState === "running") {
await this.reportState(child, { state: "requires_action" }).catch((error) =>
this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`),
);
}
try {
return await request();
} finally {
child.openRequests = (child.openRequests ?? 1) - 1;
if (child.openRequests === 0 && child.workState === "requires_action") {
await this.reportState(child, { state: "running" }).catch((error) =>
this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`),
);
}
}
}
discardPending(parentToolUseId: string): void {
this.takePending(parentToolUseId);
}
/**
* Routes an update to the child session. An update of a finished child is
* dropped: it never goes to the root session.
* The RFD form: the child runs again, and keeps its session. `toolUseId` is
* the call that delegated, usually a SendMessage: the SDK runs the resumed
* work under its id, so it becomes the child's current delegation, whose
* end ends the run, and its prompt is the message with that id. A child
* that is already working only takes the delegation; a prompt already in
* its transcript is not sent again. A child that was never announced is
* not changed.
*/
private async resumeChild(
child: NativeSubagent,
taskId: string,
prompt: string | undefined,
toolUseId: string | undefined,
deliver: Publish,
): Promise<void> {
if (!child.announced) return;
if (toolUseId && toolUseId !== child.parentToolUseId) {
this.taskByToolUse.set(toolUseId, taskId);
this.childByParentToolUse.set(toolUseId, child);
child.parentToolUseId = toolUseId;
}
if (!isWorking(child)) {
await this.reportState(child, {
state: child.openRequests ? "requires_action" : "running",
});
}
if (prompt && toolUseId) {
await publishPrompt(
child,
prompt,
toolUseId,
this.senderByToolUse.get(toolUseId),
this.publish,
);
}
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
}
/**
* The RFD form: a child that ends before its parent is known stays
* unexposed, since the RFD forbids guessing its parent. Its buffered updates
* are dropped, and it is forgotten, so a late frame does not announce it.
* (Buffering until that frame would also meet the RFD, but the frame comes
* before the task in the SDK's order, so a child ending without it is rare.)
*/
private withdraw(taskId: string, child: NativeSubagent): void {
this.children.delete(taskId);
if (child.parentToolUseId) {
this.takePending(child.parentToolUseId);
this.cleanupControl(child.parentToolUseId);
this.childByParentToolUse.delete(child.parentToolUseId);
this.taskByToolUse.delete(child.parentToolUseId);
}
}
/**
* The RFD form: a later event of a failure can carry the SDK's error text
* that the reported idle snapshot lacks.
*/
private async supplyError(
child: NativeSubagent,
state: SubagentState,
error: string | undefined,
): Promise<void> {
if (state !== "failed" || !error || !child.missingError) return;
await this.reportState(child, rfdEndState("failed", { error }));
}
/** Announces `child` in the client's form. A runtime without one never does. */
private async announce(child: NativeSubagent): Promise<void> {
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
}
/** The RFD form: publishes the work state of `child` on its parent. */
private async reportState(child: NativeSubagent, state: WorkState): Promise<void> {
child.workState = state.state;
child.missingError = false;
await this.publish({
sessionId: child.parentSessionId,
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
});
}
/**
* Routes an update to the child session. It never goes to the root session:
* in AIR's draft, an update of a finished child is dropped.
*/
private toChild(
child: NativeSubagent,
notification: AcpSessionNotification,
toolCallId: string | undefined,
): AcpSessionNotification | null {
if (child.terminalState !== undefined || child.terminalPromise) {
// AIR's draft ends a child for good. In the RFD form an idle child is
// reusable, and its background work can still report.
if (this.form === "air" && (child.terminalState !== undefined || child.terminalPromise)) {
this.logger.log(
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
);
@@ -327,10 +635,11 @@ export class NativeSubagentRuntime {
* tool call when the work started. An async task can outlive that child: a
* subagent can start a background command and end its turn. So the route
* still sends `async_task_spawned`, `async_task_progress` and
* `async_task_state_update` to the child generation after it finished. The route drops every other update
* after the child finished. `undefined` means that the root session owns the
* tool call. `eagerSessionId` is the session where a permission request
* created the tool call before the stream routed it.
* `async_task_state_update` to the child generation after it finished. In
* AIR's draft, the route drops every other update after the child finished;
* in the RFD form, an idle child gets them all. `undefined` means that the
* root session owns the tool call. `eagerSessionId` is the session where a
* permission request created the tool call before the stream routed it.
*/
routeOfToolCall(
toolCallId: string,
@@ -349,6 +658,23 @@ export class NativeSubagentRuntime {
);
}
/**
* The RFD form: remembers the session that made a SendMessage call, the
* sender of the prompt when the call resumes a child.
*/
private rememberSendMessage(notification: AcpSessionNotification): void {
const { update } = notification;
if (update.sessionUpdate !== "tool_call") return;
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
if (claudeMeta?.toolName !== "SendMessage") return;
this.senderByToolUse.delete(update.toolCallId);
this.senderByToolUse.set(update.toolCallId, notification.sessionId);
if (this.senderByToolUse.size > MAX_CHILD_TOOL_CALLS) {
const oldest = this.senderByToolUse.keys().next().value;
if (oldest !== undefined) this.senderByToolUse.delete(oldest);
}
}
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
this.childByToolCall.delete(toolCallId);
this.childByToolCall.set(toolCallId, child);
@@ -376,6 +702,7 @@ export class NativeSubagentRuntime {
this.controlByToolUse.clear();
this.childByParentToolUse.clear();
this.taskFinishPromises.clear();
this.senderByToolUse.clear();
this.generationByTaskId.clear();
this.pending.clear();
this.pendingCount = 0;
@@ -438,7 +765,14 @@ export class NativeSubagentRuntime {
previous: NativeSubagent | undefined,
fields: Pick<
NativeSubagent,
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
| "parentSessionId"
| "parentToolUseId"
| "name"
| "task"
| "prompt"
| "title"
| "role"
| "description"
>,
announce: boolean,
deliver: Publish,
@@ -455,10 +789,20 @@ export class NativeSubagentRuntime {
this.controlByToolUse.delete(toolUseId);
}
if (!announce) return;
await announceNativeSubagent(child, this.publish);
await this.announce(child);
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
}
/**
* The ACP session id of a child: the SDK agent id, and in AIR's draft a
* generation suffix for each resume. The RFD needs it unique within the
* connection and never under two parents. Agent ids are random, and an
* agent keeps its parent, so only a fork could repeat one under another
* root. That holds today because the SDK's `forkSession` copies no
* `subagents/` history, so a fork cannot resume an agent of the original.
* If a fork ever can, the RFD requires remapping the copied ids, for
* example by deriving the child id from the root session id.
*/
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
if (!previous) {
this.generationByTaskId.set(taskId, 1);
@@ -470,13 +814,45 @@ export class NativeSubagentRuntime {
}
}
/**
* Announces the child on its parent, once, before any update of the child.
* The RFD form announces it as running and then sends the prompt as the
* first message of the child. The child gets no capabilities, so the client
* may not cancel or otherwise change it.
*/
export async function announceNativeSubagent(
child: NativeSubagent,
publish: Publish,
form: SubagentForm,
): Promise<void> {
if (child.announced) return;
if (child.announcePromise) return child.announcePromise;
const announce = Promise.resolve().then(async () => {
if (form === "rfd") {
child.workState = "running";
await publish({
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
...(child.title ? { title: child.title } : {}),
...(child.description ? { description: child.description } : {}),
state: { state: "running" },
},
});
child.announced = true;
// The immediate parent made the Agent/Task call that launched the child.
if (child.prompt) {
await publishPrompt(
child,
child.prompt,
child.parentToolUseId,
child.parentSessionId,
publish,
);
}
return;
}
await publish({
sessionId: child.parentSessionId,
update: {
@@ -508,7 +884,7 @@ export async function finishNativeSubagent(
if (!child || child.terminalState !== undefined) return;
if (child.terminalPromise) return child.terminalPromise;
const finish = Promise.resolve().then(async () => {
await announceNativeSubagent(child, publish);
await announceNativeSubagent(child, publish, "air");
await publish({
sessionId: child.parentSessionId,
update: {
@@ -527,6 +903,98 @@ export async function finishNativeSubagent(
}
}
/**
* The RFD form: a message to the child, in the child's transcript (its
* incoming view). Its id is the id of the tool use that sent it, the
* Agent/Task call or a SendMessage, so a prompt whose id is already in the
* transcript is the same delegation and is not sent again. `sender` is the
* session that made that call, when known.
*/
async function publishPrompt(
child: NativeSubagent,
prompt: string,
toolUseId: string | undefined,
sender: string | undefined,
publish: Publish,
): Promise<void> {
child.promptIds ??= new Set();
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
if (child.promptIds.has(messageId)) return;
child.promptIds.add(messageId);
await publish({
sessionId: child.sessionId,
update: {
sessionUpdate: "session_message",
messageId,
...(sender ? { senderSessionId: sender } : {}),
recipientSessionId: child.sessionId,
content: [{ type: "text", text: prompt }],
},
});
}
/** The RFD form: whether the child's foreground work is in progress. */
function isWorking(child: NativeSubagent): boolean {
return child.workState === "running" || child.workState === "requires_action";
}
/**
* The RFD form: the work state at the end of a child's work.
*
* A failure is idle with the `error` stop reason and, when the SDK gives its
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
* response, so this is how a client learns that its work failed. The error is
* an internal error, as for a failed turn on the v2 surface.
*
* 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.
*/
function rfdEndState(state: SubagentState, end: TaskEnd | undefined): WorkState {
if (end?.unexplained || end?.reason === "worker_restart") return { state: "idle" };
switch (state) {
case "completed":
return { state: "idle", stopReason: "end_turn" };
case "cancelled":
return { state: "idle", stopReason: "cancelled" };
case "failed":
return {
state: "idle",
stopReason: "error",
...(end?.error
? { error: { code: RequestError.internalError().code, message: end.error } }
: {}),
};
default:
// "disconnected": the child can no longer be observed.
return { state: "unknown" };
}
}
/**
* The RFD form: the parent's labels for a child. The title is the agent's
* name, with its role after it when both are known, else the role. The
* description is the task's short description, never the prompt.
*/
function rfdLabels(
name: unknown,
role: unknown,
description: unknown,
): { title?: string; role?: string; description?: string } {
const agentName = nonBlankString(name);
const agentRole = nonBlankString(role);
const title =
agentName && agentRole && agentName !== agentRole
? `${agentName} (${agentRole})`
: (agentName ?? agentRole);
const text = nonBlankString(description);
return {
...(title ? { title } : {}),
...(agentRole ? { role: agentRole } : {}),
...(text ? { description: text } : {}),
};
}
/**
* The agent id that a successful SendMessage result resumed. The SDK puts it
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
@@ -547,17 +1015,34 @@ export function sendMessageResumePrompt(
agentId: string,
resultToolUseIds: readonly string[] = [],
): string | undefined {
return sendMessageResume(toolUses, agentId, resultToolUseIds)?.text;
}
/**
* The SendMessage call that resumed the agent `agentId`, and its text when it
* has one; see {@link sendMessageResumePrompt}. The call is known even
* without text, so a late result still names the delegation it reports.
*/
export function sendMessageResume(
toolUses: Record<string, { name: string; input: unknown } | undefined>,
agentId: string,
resultToolUseIds: readonly string[] = [],
): { toolUseId: string; text?: string } | undefined {
let withoutText: string | undefined;
for (const toolUseId of resultToolUseIds) {
const toolUse = toolUses[toolUseId];
if (toolUse?.name !== "SendMessage") continue;
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
if (text) return text;
if (text) return { toolUseId, text };
withoutText ??= toolUseId;
}
if (resultToolUseIds.length > 0) return undefined;
for (const toolUse of Object.values(toolUses).reverse()) {
if (resultToolUseIds.length > 0) return withoutText ? { toolUseId: withoutText } : undefined;
for (const [toolUseId, toolUse] of Object.entries(toolUses).reverse()) {
if (toolUse?.name !== "SendMessage") continue;
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
if (input?.to === agentId) return promptText(input.message);
if (input?.to !== agentId) continue;
const text = promptText(input.message);
return text ? { toolUseId, text } : { toolUseId };
}
return undefined;
}
@@ -746,6 +1231,14 @@ function applySubagentIdentity(
child.task = subagentDescription(identity.prompt, identity.description);
}
child.prompt ??= identity.prompt;
const labels = rfdLabels(
identity.name,
identity.subagentType ?? child.role,
identity.description,
);
child.title = labels.title ?? child.title;
child.role = labels.role ?? child.role;
child.description = labels.description ?? child.description;
}
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */

View File

@@ -109,6 +109,7 @@ import type {
BetaWebFetchToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { validateRecorded } from "./acp-scenarios/schema.js";
/** The capabilities of an AIR client. Only AIR gets the AIR extensions of
* `docs/air-extensions.md`. */
@@ -116,6 +117,13 @@ const AIR_CLIENT_CAPABILITIES = {
_meta: { jetbrains: { air: { version: 1, capabilities: [] as string[] } } },
};
/** AIR with native subagent sessions. AIR gets the earlier draft of the
* subagents RFD (`subagent_spawned`, `subagent_state_update`, a session per
* generation), which the tests that use this pin. */
const AIR_NATIVE_SUBAGENTS = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
} as ClientCapabilities;
/** A `system`/init frame advertising the msg_lifecycle_v1 capability, so the
* consumer latches `session.msgLifecycleV1` and cancel() routes orphan
* accounting through `orphanCommands` (CLIs 2.1.206+). */
@@ -3400,7 +3408,7 @@ describe("subagent transcript replay", () => {
await initializeClient(
agent,
capability === "native"
? ({ subagents: {} } as ClientCapabilities)
? AIR_NATIVE_SUBAGENTS
: capability === "legacy"
? { _meta: { "subagent-transcript": true } }
: {},
@@ -3536,7 +3544,7 @@ describe("subagent transcript replay", () => {
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, { subagents: {} } as ClientCapabilities);
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
vi.mocked(getSessionInfo).mockResolvedValueOnce({ cwd: "/tmp/proj" } as any);
vi.mocked(getSubagentMessages).mockClear();
await (
@@ -5414,6 +5422,54 @@ describe("subagent permission attribution (issue #851)", () => {
expect(requests[0].sessionId).toBe("child-session");
});
it("reports a child as requires_action while its permission request is open (the subagents RFD)", async () => {
const { agent, updates, requests, session } = setup();
await initializeClient(agent, { subagents: {} } as ClientCapabilities);
session.liveBackgroundTasks.set("agent-42", {
parentToolUseId: "toolu_parent",
isSubagent: true,
});
session.nativeSubagentsByTaskId = new Map([
[
"agent-42",
{
sessionId: "agent-42",
parentSessionId: "session-1",
parentToolUseId: "toolu_parent",
name: "Explore",
task: "Investigate",
announced: true,
workState: "running",
},
],
]);
session.nativeSubagentRuntime = new NativeSubagentRuntime(
"rfd",
"session-1",
session,
async (notification) => {
updates.push(notification as SessionNotification);
},
{ log: () => {} },
);
await agent.canUseTool("session-1")("Bash", { command: "ls" }, {
signal: new AbortController().signal,
suggestions: [],
toolUseID: "toolu_sub",
agentID: "agent-42",
} as any);
const states = updates.flatMap(({ sessionId, update }) =>
update.sessionUpdate === "subagent_update" ? [[sessionId, update.state]] : [],
);
expect(states).toEqual([
["session-1", { state: "requires_action" }],
["session-1", { state: "running" }],
]);
expect(requests[0].sessionId).toBe("agent-42");
});
it("keeps a raced permission on the root until the child is announced", async () => {
const { agent, updates, requests, session } = setup();
await agent.initialize({
@@ -5450,6 +5506,228 @@ describe("subagent permission attribution (issue #851)", () => {
expect(requests[0].sessionId).toBe("session-1");
});
it("keeps a raced permission's tool call on the root after a cancel, while its background child runs on", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: AcpSessionNotification) => {
updates.push(notification);
},
requestPermission: async () => ({
outcome: { outcome: "selected", optionId: "allow-once" },
}),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities,
});
const permitted = Promise.withResolvers<void>();
const cancelled = Promise.withResolvers<void>();
injectGeneratorSession(agent, (input) =>
(async function* () {
const { value: prompt } = await input[Symbol.asyncIterator]().next();
yield userEcho(prompt);
// The child's task starts before the root's Agent frame, so the child
// is known but not announced when it asks for permission.
yield {
...taskStarted("agent-42", "toolu_parent"),
subagent_type: "Explore",
is_backgrounded: true,
};
await permitted.promise;
yield rootToolUse("toolu_parent", "Agent");
await cancelled.promise;
// The child runs in the background, so it outlives the cancel.
yield {
type: "tool_progress",
tool_use_id: "toolu_sub",
tool_name: "Bash",
parent_tool_use_id: "toolu_parent",
elapsed_time_seconds: 3,
uuid: randomUUID(),
session_id: "test-session",
};
yield successResult();
})(),
);
const turn = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
await vi.waitFor(() =>
expect(agent.sessions["test-session"]?.nativeSubagentsByTaskId?.has("agent-42")).toBe(true),
);
await agent.canUseTool("test-session")("Bash", { command: "ls" }, {
signal: new AbortController().signal,
suggestions: [],
toolUseID: "toolu_sub",
agentID: "agent-42",
} as any);
permitted.resolve();
await vi.waitFor(() =>
expect(updates.map(({ update }) => update.sessionUpdate)).toContain("subagent_update"),
);
await agent.cancel({ sessionId: "test-session" });
cancelled.resolve();
await expect(turn).resolves.toMatchObject({ stopReason: "cancelled" });
const sessionsOfToolCall = updates.flatMap(({ sessionId, update }) =>
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update.toolCallId === "toolu_sub"
? [`${update.sessionUpdate} ${update.status ?? ""} ${sessionId}`]
: [],
);
expect(sessionsOfToolCall).toEqual([
"tool_call pending test-session",
"tool_call_update in_progress test-session",
]);
});
it("sends AIR no update of a child's permission tool call after the cancel ended the child", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: AcpSessionNotification) => {
updates.push(notification);
},
requestPermission: async () => ({
outcome: { outcome: "selected", optionId: "allow-once" },
}),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await agent.initialize({ protocolVersion: 1, clientCapabilities: AIR_NATIVE_SUBAGENTS });
const cancelled = Promise.withResolvers<void>();
injectGeneratorSession(agent, (input) =>
(async function* () {
const { value: prompt } = await input[Symbol.asyncIterator]().next();
yield userEcho(prompt);
yield rootToolUse("toolu_parent", "Agent");
yield {
...taskStarted("agent-42", "toolu_parent"),
subagent_type: "Explore",
is_backgrounded: true,
};
await cancelled.promise;
// The child runs in the background, so it outlives the cancel.
yield {
type: "tool_progress",
tool_use_id: "toolu_sub",
tool_name: "Bash",
parent_tool_use_id: "toolu_parent",
elapsed_time_seconds: 3,
uuid: randomUUID(),
session_id: "test-session",
};
yield successResult();
})(),
);
const turn = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
await vi.waitFor(() =>
expect(updates.map(({ update }) => update.sessionUpdate)).toContain("subagent_spawned"),
);
await agent.canUseTool("test-session")("Bash", { command: "ls" }, {
signal: new AbortController().signal,
suggestions: [],
toolUseID: "toolu_sub",
agentID: "agent-42",
} as any);
await agent.cancel({ sessionId: "test-session" });
cancelled.resolve();
await expect(turn).resolves.toMatchObject({ stopReason: "cancelled" });
// AIR's draft ends the child at the cancel, and an ended child gets no
// more updates.
const childEvents = updates.flatMap(({ sessionId, update }) =>
update.sessionUpdate === "subagent_state_update"
? [`subagent ${update.state}`]
: (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update.toolCallId === "toolu_sub"
? [`${update.sessionUpdate} ${update.status ?? ""} ${sessionId}`]
: [],
);
expect(childEvents).toEqual(["tool_call pending agent-42", "subagent cancelled"]);
});
it("keeps the Agent call's own heartbeat and result on the session that made the call (the subagents RFD)", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: AcpSessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, { subagents: {} } as ClientCapabilities);
injectGeneratorSession(
agent,
makeGenerator([
rootToolUse("toolu_parent", "Agent", {
subagent_type: "Explore",
description: "Investigate",
prompt: "Find the parser",
}),
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
// A heartbeat of the running Agent call: its own id is synthetic, and
// the SDK names the Agent call as `parent_tool_use_id`.
{
type: "tool_progress",
tool_use_id: "toolu_parent-heartbeat-1",
tool_name: "Agent",
parent_tool_use_id: "toolu_parent",
elapsed_time_seconds: 30,
uuid: randomUUID(),
session_id: "test-session",
},
{
type: "system",
subtype: "task_notification",
task_id: "agent-42",
tool_use_id: "toolu_parent",
status: "completed",
output_file: "",
summary: "done",
uuid: randomUUID(),
session_id: "test-session",
},
{
type: "user",
uuid: randomUUID(),
session_id: "test-session",
parent_tool_use_id: null,
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_parent", content: "It is in x.ts." },
],
},
},
successResult(),
]),
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
const agentCall = updates.flatMap(({ sessionId, update }) =>
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update.toolCallId === "toolu_parent"
? [{ sessionId, status: update.status }]
: [],
);
expect(agentCall.map(({ status }) => status)).toEqual(
expect.arrayContaining(["in_progress", "completed"]),
);
expect(new Set(agentCall.map(({ sessionId }) => sessionId))).toEqual(new Set(["test-session"]));
});
it("routes a native child AskUserQuestion and its tool call to the child session", async () => {
const { agent, updates, elicitations, session } = setup();
await agent.initialize({
@@ -5513,6 +5791,25 @@ describe("subagent permission attribution (issue #851)", () => {
};
}
/** The call `toolUseId` of the tool `name` that the root session makes. */
function rootToolUse(toolUseId: string, name: string, input: Record<string, unknown> = {}) {
return {
type: "assistant" as const,
uuid: randomUUID(),
session_id: "test-session",
parent_tool_use_id: null,
message: {
id: `msg-${toolUseId}`,
model: "claude-sonnet-4-5",
role: "assistant" as const,
type: "message",
stop_reason: "tool_use",
content: [{ type: "tool_use" as const, id: toolUseId, name, input }],
usage: { input_tokens: 0, output_tokens: 0 },
},
};
}
function successResult() {
return {
type: "result" as const,
@@ -5882,9 +6179,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
injectGeneratorSession(
agent,
@@ -5928,6 +6223,462 @@ describe("subagent permission attribution (issue #851)", () => {
);
});
describe("the subagents RFD, for a client that declares subagents and is not AIR", () => {
// Each task_started names a subagent type: a task without one is not a subagent.
/** The updates of `frames`, for a client that declares `subagents`. */
async function run(frames: unknown[]): Promise<AcpSessionNotification[]> {
return (await runAgent(frames)).updates;
}
/** {@link run}, and the agent that ran the frames. */
async function runAgent(frames: unknown[]) {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: AcpSessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities,
});
// The root's Agent call comes first: the child's parent is the root.
injectGeneratorSession(
agent,
makeGenerator([rootToolUse("toolu_parent", "Agent"), ...frames, successResult()] as never),
);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
// Every update is a valid v1 session update, without an extension.
for (const payload of updates) {
expect(validateRecorded({ kind: "sessionUpdate", payload }, new Set())).toEqual([]);
}
return { agent, updates };
}
const subagentTraffic = (updates: AcpSessionNotification[]) =>
updates.filter(({ update }) =>
[
"subagent_update",
"session_message",
"subagent_spawned",
"subagent_state_update",
].includes(update.sessionUpdate),
);
const taskUpdated = (patch: Record<string, unknown>) => ({
type: "system",
subtype: "task_updated",
task_id: "agent-42",
patch,
uuid: randomUUID(),
session_id: "test-session",
});
it("announces the child as running, then sends it the prompt as a message", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore", prompt: "Go" },
taskUpdated({ status: "completed" }),
]);
expect(subagentTraffic(updates)).toEqual([
{
sessionId: "test-session",
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
title: "Explore",
description: "Investigate",
state: { state: "running" },
},
},
{
sessionId: "agent-42",
update: {
sessionUpdate: "session_message",
messageId: "toolu_parent",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
content: [{ type: "text", text: "Go" }],
},
},
{
sessionId: "test-session",
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "idle", stopReason: "end_turn" },
},
},
]);
});
it("reports a failed child as idle, with the SDK's error", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
taskUpdated({ status: "failed", error: "Context too small" }),
]);
expect(subagentTraffic(updates).at(-1)?.update).toEqual({
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: {
state: "idle",
stopReason: "error",
error: { code: -32603, message: "Context too small" },
},
});
});
it("reports a child that the SDK stopped as cancelled", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
taskUpdated({ status: "killed" }),
]);
expect(subagentTraffic(updates).at(-1)?.update).toMatchObject({
state: { state: "idle", stopReason: "cancelled" },
});
});
it("sends no update of the earlier draft that AIR implements", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
taskUpdated({ status: "completed" }),
]);
expect(subagentTraffic(updates).length).toBeGreaterThan(0);
expect(
updates.filter(({ update }) =>
["subagent_spawned", "subagent_state_update"].includes(update.sessionUpdate),
),
).toEqual([]);
});
const childText = (parentToolUseId: string, text: string) => ({
type: "stream_event",
parent_tool_use_id: parentToolUseId,
uuid: randomUUID(),
session_id: "test-session",
event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
});
const taskNotification = (
toolUseId: string,
status: string,
extra: Record<string, unknown> = {},
) => ({
type: "system",
subtype: "task_notification",
task_id: "agent-42",
tool_use_id: toolUseId,
status,
output_file: "",
summary: "done",
uuid: randomUUID(),
session_id: "test-session",
...extra,
});
it("keeps the child's session for a SendMessage resume, which the SDK runs under the SendMessage id", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore", prompt: "Go" },
taskNotification("toolu_parent", "completed"),
rootToolUse("toolu_send", "SendMessage", {
to: "agent-42",
message: "Also check Windows",
}),
// The CLI starts the resumed agent again, under the SendMessage call.
{
...taskStarted("agent-42", "toolu_send"),
subagent_type: "Explore",
prompt: "Also check Windows",
},
childText("toolu_send", "Checking Windows"),
// A late copy of the first delegation's end does not end the second.
taskNotification("toolu_parent", "completed"),
childText("toolu_send", "Still checking"),
taskNotification("toolu_send", "completed"),
]);
const traffic = subagentTraffic(updates).map(({ sessionId, update }) => ({
sessionId,
update:
update.sessionUpdate === "subagent_update"
? {
sessionUpdate: update.sessionUpdate,
sessionId: update.sessionId,
state: update.state,
}
: update,
}));
expect(traffic).toEqual([
{
sessionId: "test-session",
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "running" },
},
},
{
sessionId: "agent-42",
update: {
sessionUpdate: "session_message",
messageId: "toolu_parent",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
content: [{ type: "text", text: "Go" }],
},
},
{
sessionId: "test-session",
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "idle", stopReason: "end_turn" },
},
},
{
sessionId: "test-session",
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "running" },
},
},
{
sessionId: "agent-42",
update: {
sessionUpdate: "session_message",
messageId: "toolu_send",
senderSessionId: "test-session",
recipientSessionId: "agent-42",
content: [{ type: "text", text: "Also check Windows" }],
},
},
{
sessionId: "test-session",
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "idle", stopReason: "end_turn" },
},
},
]);
// The resumed run's output reaches the same child session, and the
// child is idle only after all of it: the late end did not end it.
const order = updates.flatMap(({ sessionId, update }) =>
sessionId === "agent-42" && update.sessionUpdate === "agent_message_chunk"
? ["output"]
: update.sessionUpdate === "subagent_update" && update.state?.state === "idle"
? ["idle"]
: [],
);
expect(order).toEqual(["idle", "output", "output", "idle"]);
});
it("keeps the resumed run's live task when an earlier delegation's end arrives late", async () => {
const { agent } = await runAgent([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore", prompt: "Go" },
taskNotification("toolu_parent", "completed"),
rootToolUse("toolu_send", "SendMessage", { to: "agent-42", message: "Again" }),
{ ...taskStarted("agent-42", "toolu_send"), subagent_type: "Explore", prompt: "Again" },
taskNotification("toolu_parent", "completed"),
]);
// The child's permission requests are still attributed to its run.
expect(agent.sessions["test-session"]?.liveBackgroundTasks.get("agent-42")).toMatchObject({
parentToolUseId: "toolu_send",
});
});
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
const { agent, updates } = await runAgent([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore", prompt: "Go" },
taskNotification("toolu_parent", "completed"),
rootToolUse("toolu_send", "SendMessage", { to: "agent-42", message: "Again" }),
{ ...taskStarted("agent-42", "toolu_send"), subagent_type: "Explore", prompt: "Again" },
taskNotification("toolu_send", "failed"),
{
type: "user",
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "test-session",
tool_use_result: { success: true, resumedAgentId: "agent-42" },
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_send", content: "Resumed" }],
},
},
]);
expect(subagentTraffic(updates).at(-1)?.update).toMatchObject({
sessionUpdate: "subagent_update",
state: { state: "idle" },
});
expect(agent.sessions["test-session"]?.liveBackgroundTasks.has("agent-42")).toBe(false);
});
// agentclientprotocol/agent-client-protocol#2308: a cancel stops the
// descendant work that the cancelled work waits on, and leaves the rest
// to the Agent; the Client cleans up a descendant's requests only once it
// reports a cancelled idle.
it("at a root cancel, stops the foreground child and leaves the background child running", async () => {
const log: string[] = [];
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: AcpSessionNotification) => {
updates.push(update);
if (update.update.sessionUpdate === "subagent_update") {
log.push(`${update.update.sessionId} ${JSON.stringify(update.update.state)}`);
}
},
requestPermission: (params: { sessionId: string }, signal?: AbortSignal) => {
log.push(`request @${params.sessionId}`);
signal?.addEventListener("abort", () => log.push(`withdrawn @${params.sessionId}`));
return new Promise(() => {});
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities,
});
const later = new Pushable<unknown>();
const input = new Pushable<any>();
let started!: () => void;
const ready = new Promise<void>((resolve) => (started = resolve));
const launch = (id: string, input: Record<string, unknown>) =>
rootToolUse(id, "Agent", { description: id, prompt: "Work", ...input });
async function* stream() {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
yield launch("toolu_fg", { subagent_type: "Explore" });
yield { ...taskStarted("agent-fg", "toolu_fg"), subagent_type: "Explore" };
yield launch("toolu_bg", { subagent_type: "Explore", run_in_background: true });
yield {
...taskStarted("agent-bg", "toolu_bg"),
subagent_type: "Explore",
is_backgrounded: true,
};
started();
yield* later;
}
const query = wrapQuery(stream());
// The CLI's interrupt (read in the bundled CLI): the foreground child's
// abort controller is linked to the turn's, so its request is withdrawn
// and its run reports killed, then stopped, then the turn goes idle. The
// background child's is not linked, so nothing happens to it.
const foreground = new AbortController();
query.interrupt = vi.fn(async () => {
foreground.abort();
setTimeout(() => {
later.push({ ...taskUpdated({ status: "killed" }), task_id: "agent-fg" });
later.push({ ...taskNotification("toolu_fg", "stopped"), task_id: "agent-fg" });
later.push({
type: "system",
subtype: "session_state_changed",
state: "idle",
uuid: randomUUID(),
session_id: "test-session",
});
}, 5);
});
agent.sessions["test-session"] = mockSessionState({ query, input });
const prompt = agent
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] })
.then((response) => {
log.push(`prompt ${response.stopReason}`);
return response;
});
await ready;
await new Promise((resolve) => setTimeout(resolve, 20));
const canUseTool = agent.canUseTool("test-session");
const ask = (agentID: string, signal: AbortSignal) =>
canUseTool("Bash", { command: "make" }, {
signal,
suggestions: [],
toolUseID: `${agentID}-bash`,
agentID,
} as never).catch(() => {});
void ask("agent-fg", foreground.signal);
void ask("agent-bg", new AbortController().signal);
await new Promise((resolve) => setTimeout(resolve, 20));
await agent.cancel({ sessionId: "test-session" });
await prompt;
const atResponse = [...log];
// The CLI process exits while the background child still runs.
later.end();
await new Promise((resolve) => setTimeout(resolve, 20));
const before = (prefix: string) => atResponse.filter((line) => line.startsWith(prefix));
// The foreground child: withdrawn, then reported cancelled before the
// cancelled prompt resolves.
expect(atResponse).toContain("withdrawn @agent-fg");
expect(before("agent-fg ").at(-1)).toBe('agent-fg {"state":"idle","stopReason":"cancelled"}');
expect(atResponse.at(-1)).toBe("prompt cancelled");
// The background child keeps running, with its request still open.
expect(atResponse).not.toContain("withdrawn @agent-bg");
expect(before("agent-bg ")).toEqual([
'agent-bg {"state":"running"}',
'agent-bg {"state":"requires_action"}',
]);
// When the stream ends, its end has no reason the adapter saw.
expect(log.filter((line) => line.startsWith("agent-bg ")).at(-1)).toBe(
'agent-bg {"state":"idle"}',
);
for (const payload of updates) {
expect(validateRecorded({ kind: "sessionUpdate", payload }, new Set())).toEqual([]);
}
});
it("adds the SDK's error to a failure that a notification reported first", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
taskNotification("toolu_parent", "failed"),
taskUpdated({ status: "failed", error: "Context too small" }),
]);
expect(
subagentTraffic(updates)
.slice(-2)
.map(({ update }) => update),
).toEqual([
{
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "idle", stopReason: "error" },
},
{
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: {
state: "idle",
stopReason: "error",
error: { code: -32603, message: "Context too small" },
},
},
]);
});
it("reports a child that a worker restart orphaned as idle, not cancelled", async () => {
const updates = await run([
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
taskNotification("toolu_parent", "stopped", { reason: "worker_restart" }),
]);
expect(subagentTraffic(updates).at(-1)?.update).toEqual({
sessionUpdate: "subagent_update",
sessionId: "agent-42",
state: { state: "idle" },
});
});
});
it("buffers child output until spawn and drops duplicates and late updates", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
@@ -5940,9 +6691,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
const childMessage = (text: string) => ({
type: "stream_event" as const,
@@ -6062,9 +6811,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
injectGeneratorSession(
agent,
@@ -6137,9 +6884,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
const agentTool = (id: string, parent_tool_use_id: string | null) => ({
type: "assistant" as const,
@@ -6236,9 +6981,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
injectGeneratorSession(
agent,
@@ -6270,9 +7013,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
injectGeneratorSession(
agent,
@@ -6312,9 +7053,7 @@ describe("subagent permission attribution (issue #851)", () => {
);
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
let childStarted!: () => void;
const started = new Promise<void>((resolve) => (childStarted = resolve));
@@ -6388,6 +7127,8 @@ describe("subagent permission attribution (issue #851)", () => {
agent,
makeGenerator([
...childMessages,
// The spawn arrives after the child's output, which waits for it.
rootToolUse("toolu_parent", "Agent"),
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
{
type: "system",
@@ -7290,7 +8031,7 @@ describe("native subagent eager tool ownership", () => {
nativeSubagentParentByToolUseId: new Map(),
};
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
session,
async (notification) => {
@@ -7350,7 +8091,7 @@ describe("native subagent eager tool ownership", () => {
nativeSubagentTaskIdByToolUseId: new Map([["toolu_agent", "agent-1"]]),
nativeSubagentParentByToolUseId: new Map(),
};
const runtime = new NativeSubagentRuntime(true, "root", session, async () => {}, {
const runtime = new NativeSubagentRuntime("air", "root", session, async () => {}, {
log: () => {},
});
const terminal = {
@@ -10444,22 +11185,19 @@ describe("logout", () => {
expect(response.agentCapabilities?.auth?.logout).toEqual({});
});
it("advertises the agent subagent capability independently of client negotiation", async () => {
it("advertises the agent subagent capability of AIR's draft to AIR alone", async () => {
const agent = createMockAgent();
const unsupported = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
const supported = await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
});
const advertised = async (clientCapabilities: ClientCapabilities) =>
(
(await agent.initialize({ protocolVersion: 1, clientCapabilities })).agentCapabilities
?.sessionCapabilities as { subagents?: unknown }
).subagents;
expect(
(unsupported.agentCapabilities?.sessionCapabilities as { subagents?: unknown }).subagents,
).toEqual({});
expect(
(supported.agentCapabilities?.sessionCapabilities as { subagents?: unknown }).subagents,
).toEqual({});
// The subagents RFD has only the client capability.
expect(await advertised({})).toBeUndefined();
expect(await advertised({ subagents: {} } as ClientCapabilities)).toBeUndefined();
expect(await advertised(AIR_CLIENT_CAPABILITIES)).toEqual({});
expect(await advertised(AIR_NATIVE_SUBAGENTS)).toEqual({});
});
it("negotiates subagents through AIR metadata when the SDK strips the draft field", async () => {
@@ -15680,19 +16418,19 @@ describe("post-error recovery", () => {
error: (...args) => errors.push(args),
});
async function* emptyQuery() {}
const parentCancelled = vi.fn(async () => {
throw new Error("client disconnected");
});
const session = mockSessionState({
query: wrapQuery(emptyQuery()),
nativeSubagentRuntime: {
finishAll: vi.fn(async () => {
throw new Error("client disconnected");
}),
} as unknown as NativeSubagentRuntime,
nativeSubagentRuntime: { form: "air", parentCancelled } as unknown as NativeSubagentRuntime,
eagerToolCallSessions: new Map([["tool", "test-session"]]),
});
agent.sessions["test-session"] = session;
await expect(agent.cancel({ sessionId: "test-session" })).resolves.toBeUndefined();
expect(parentCancelled).toHaveBeenCalledOnce();
expect(session.query.interrupt).toHaveBeenCalledOnce();
expect(session.eagerToolCallSessions).toEqual(new Map());
expect(errors.flat().join(" ")).toContain("failed to publish cancelled subagent state");
@@ -19025,9 +19763,7 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
if (options.subagents) {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { subagents: {} } as ClientCapabilities & {
subagents: Record<string, never>;
},
clientCapabilities: AIR_NATIVE_SUBAGENTS,
});
}
const first = await agent.prompt({

View File

@@ -19,6 +19,9 @@
* - An `available_commands_update` also lists the `mcp` command of the
* adapter (see {@link ADAPTER_COMMANDS}), when origin/main did not list
* `mcp`. The adapter replaces the text of `/mcp` for every client.
* - The `initialize` response does not advertise
* `agentCapabilities.sessionCapabilities.subagents`: the subagents RFD has
* only the client capability, and only AIR's draft keeps the agent one.
* - A `terminal_exit` has a `null` exit code where origin/main sent a code
* that the tool result does not say (see `CommandOutput.exitCode`): 1 for a
* failure that names no code or an interrupted command, 0 for a
@@ -198,13 +201,29 @@ function isMeta(key: string): boolean {
return key.startsWith("_meta.");
}
/** `record` without the agent subagent capability, when it is an `initialize` response. */
function withoutAgentSubagentCapability(record: Recorded): Recorded {
if (record.kind !== "initialize") return record;
const payload = record.payload as Json;
const agent = payload.agentCapabilities as Json | undefined;
const session = agent?.sessionCapabilities as Json | undefined;
if (!session || !("subagents" in session)) return record;
return {
...record,
payload: {
...payload,
agentCapabilities: { ...agent, sessionCapabilities: without(session, "subagents") },
},
} as Recorded;
}
/**
* Returns the differences between the baseline and the current traffic that
* the compatibility rule does not allow. An empty list means compatible.
*/
export function compareWithBaseline(baseline: Recorded[], current: Recorded[]): string[] {
const expected = baseline
.map((record) => withoutAirOnlyKeys(record) as Recorded)
.map((record) => withoutAgentSubagentCapability(withoutAirOnlyKeys(record) as Recorded))
.filter((record) => {
const update = updateOf(record);
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);

View File

@@ -7,6 +7,7 @@ import {
NativeSubagentRuntime,
NativeSubagentSession,
resumedNativeSubagentId,
sendMessageResume,
sendMessageResumePrompt,
} from "../native-subagents.js";
@@ -68,8 +69,8 @@ describe("NativeSubagentRuntime lifecycle", () => {
await release.promise;
});
const first = announceNativeSubagent(value, publish);
const second = announceNativeSubagent(value, publish);
const first = announceNativeSubagent(value, publish, "air");
const second = announceNativeSubagent(value, publish, "air");
await Promise.resolve();
expect(publish).toHaveBeenCalledTimes(1);
@@ -101,7 +102,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
});
it("falls back to one ordinary failed tool call when no child ever starts", async () => {
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
const runtime = new NativeSubagentRuntime("air", "root", {}, async () => {}, { log: () => {} });
await expect(
runtime.route(control("tool_call", "pending"), async () => {}),
@@ -124,7 +125,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
});
it("creates a schema-valid non-native failed tool call without a cached initial call", async () => {
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
const runtime = new NativeSubagentRuntime("air", "root", {}, async () => {}, { log: () => {} });
const fallback = await runtime.route(
{
@@ -166,7 +167,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
const session = sessionWith(outer);
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
session,
async (notification) => {
@@ -211,7 +212,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
const published: AcpSessionNotification[] = [];
const delivered: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -258,7 +259,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
it("creates a distinct ACP lifecycle when the SDK restarts the same task id", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -332,7 +333,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
it("announces resumed subagent generation immediately when SendMessage restarts the task (#1158)", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -393,7 +394,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
it("announces a resumed nested subagent under a live ancestor when its parent has finished", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -439,7 +440,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
): Promise<Record<string, unknown>> {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -507,7 +508,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
const published: AcpSessionNotification[] = [];
const logged: string[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -622,10 +623,22 @@ describe("NativeSubagentRuntime lifecycle", () => {
expect(sendMessageResumePrompt(toolUses, "worker-3")).toBeUndefined();
});
it("names the SendMessage call that resumed the agent even without a text", () => {
const toolUses = {
blank: { name: "SendMessage", input: { to: "worker-1", message: " " } },
bash: { name: "Bash", input: { command: "ls" } },
};
expect(sendMessageResume(toolUses, "worker-1", ["blank"])).toEqual({ toolUseId: "blank" });
expect(sendMessageResume(toolUses, "worker-1")).toEqual({ toolUseId: "blank" });
expect(sendMessageResume(toolUses, "worker-1", ["bash"])).toBeUndefined();
expect(sendMessageResumePrompt(toolUses, "worker-1", ["blank"])).toBeUndefined();
});
it("keeps the work of a child tool call in the generation that started it", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
true,
"air",
"root",
{},
async (notification) => {
@@ -729,3 +742,514 @@ describe("NativeSubagentRuntime lifecycle", () => {
});
});
});
/**
* The subagents RFD (agentclientprotocol/agent-client-protocol#1992, as
* revised), for a client that declares `subagents` and is not AIR.
*/
describe("NativeSubagentRuntime in the RFD form", () => {
const launch = (
toolCallId: string,
input: Record<string, unknown>,
parentToolUseId?: string,
): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Agent",
status: "pending",
rawInput: input,
_meta: {
claudeCode: { toolName: "Agent", ...(parentToolUseId ? { parentToolUseId } : {}) },
},
},
}) as AcpSessionNotification;
const output = (parentToolUseId: string): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "working" },
_meta: { claudeCode: { parentToolUseId } },
},
}) as AcpSessionNotification;
/** A SendMessage call, of the root or of the child that `parentToolUseId` launched. */
const sendMessage = (toolCallId: string, parentToolUseId?: string): AcpSessionNotification =>
({
sessionId: "root",
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "SendMessage",
status: "pending",
_meta: {
claudeCode: {
toolName: "SendMessage",
...(parentToolUseId ? { parentToolUseId } : {}),
},
},
},
}) as AcpSessionNotification;
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
async function runningWorker(input: Record<string, unknown> = {}) {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
const control = await runtime.route(
launch("launch-1", { description: "Fix the build", prompt: "Make it pass", ...input }),
async () => {},
);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
async () => {},
);
return { runtime, published, control };
}
const states = (published: AcpSessionNotification[]) =>
published.flatMap(({ update }) =>
update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [],
);
it("keeps the Agent tool call as a tool call of the session that made it", async () => {
const { runtime, control } = await runningWorker();
expect(control).toMatchObject({ sessionId: "root", update: { toolCallId: "launch-1" } });
// A nested launch is a tool call of the child that made it.
await expect(
runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}),
).resolves.toMatchObject({ sessionId: "worker-1", update: { toolCallId: "launch-2" } });
});
it("announces the child with the parent's labels, then sends it the prompt", async () => {
const { published } = await runningWorker({ name: "builder" });
expect(published).toEqual([
{
sessionId: "root",
update: {
sessionUpdate: "subagent_update",
sessionId: "worker-1",
title: "builder (Explore)",
description: "Fix the build",
state: { state: "running" },
},
},
{
sessionId: "worker-1",
update: {
sessionUpdate: "session_message",
messageId: "launch-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Make it pass" }],
},
},
]);
});
it("titles a child without a name by its role", async () => {
const { published } = await runningWorker();
expect(published[0]?.update).toMatchObject({ title: "Explore", description: "Fix the build" });
});
it("keeps the session of an idle child, which a resume runs again", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {});
// An idle child is reusable, so its updates still go to it.
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
sessionId: "worker-1",
});
await runtime.route(sendMessage("send-1"), async () => {});
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// A repeated resume signal of the running child changes nothing.
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
await runtime.finishTask("worker-1", "completed", async () => {});
expect(states(published)).toEqual([
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
]);
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
expect.objectContaining({ sessionId: "worker-1" }),
{
sessionId: "worker-1",
update: {
sessionUpdate: "session_message",
messageId: "send-1",
senderSessionId: "root",
recipientSessionId: "worker-1",
content: [{ type: "text", text: "Check the tests too" }],
},
},
]);
expect(
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
).toBe(false);
});
it("moves the work of a resume to the child, under the id of the call that resumed it", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.route(sendMessage("send-1"), async () => {});
// The resumed run's output can arrive before the SDK's task_started.
await expect(runtime.route(output("send-1"), async () => {})).resolves.toBeNull();
const delivered: AcpSessionNotification[] = [];
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "send-1", prompt: "Check the tests too" },
async (notification) => {
delivered.push(notification);
},
);
// The resume signal of the SendMessage result repeats it, and changes nothing.
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
// The waiting output is delivered for routing again, which now finds the child.
expect(delivered).toEqual([output("send-1")]);
await expect(runtime.route(output("send-1"), async () => {})).resolves.toMatchObject({
sessionId: "worker-1",
});
// The end of the first delegation, again, does not end the second.
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
expect(states(published).at(-1)).toEqual({ state: "running" });
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
expect(states(published)).toEqual([
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
]);
expect(
published
.filter(({ update }) => update.sessionUpdate === "session_message")
.map(({ update }) => (update as { messageId: string }).messageId),
).toEqual(["launch-1", "send-1"]);
});
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "send-1", prompt: "Check the tests too" },
async () => {},
);
// The terminal patch carries no tool id; the notification repeats it.
await runtime.finishTask("worker-1", "failed", async () => {});
await runtime.finishTask("worker-1", "failed", async () => {}, "send-1");
expect(runtime.isEndedDelegation("worker-1", "send-1")).toBe(true);
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
expect(states(published)).toEqual([
{ state: "running" },
{ state: "idle", stopReason: "end_turn" },
{ state: "running" },
{ state: "idle", stopReason: "error" },
]);
});
it("ends a run under an id the child used before, which the SDK falls back to", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
// A resume that no call made runs under the launch's id again.
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "launch-1" }, async () => {});
expect(runtime.isStaleEnd("worker-1", "send-1")).toBe(true);
expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false);
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
expect(states(published)).toHaveLength(6);
});
it("reports a running patch as running, without its guessed prompt or call", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskResumed("worker-1", async () => {}, "An old message");
expect(states(published).at(-1)).toEqual({ state: "running" });
expect(
published.filter(({ update }) => update.sessionUpdate === "session_message"),
).toHaveLength(1);
expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false);
});
it("resumes a child with an open request as requires_action", async () => {
const { runtime, published } = await runningWorker();
let answer!: () => void;
const request = runtime.awaitingUser(
"worker-1",
() => new Promise<void>((resolve) => (answer = resolve)),
);
await Promise.resolve();
// The work ends while the request is open; then a resume runs it again.
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
answer();
await request;
expect(states(published)).toEqual([
{ state: "running" },
{ state: "requires_action" },
{ state: "idle", stopReason: "end_turn" },
{ state: "requires_action" },
{ state: "running" },
]);
});
it("names the session that sent a resume's prompt, and no sender when it is unknown", async () => {
const { runtime, published } = await runningWorker();
// worker-1 launches worker-2, which the root then resumes.
await runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {});
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
async () => {},
);
await runtime.finishTask("worker-2", "completed", async () => {}, "launch-2");
await runtime.route(sendMessage("send-root"), async () => {});
await runtime.sendMessageResumed("worker-2", async () => {}, "From the root", "send-root");
await runtime.finishTask("worker-2", "completed", async () => {}, "send-root");
// worker-1 resumes it too.
await runtime.route(sendMessage("send-worker", "launch-1"), async () => {});
await runtime.sendMessageResumed("worker-2", async () => {}, "From worker-1", "send-worker");
await runtime.finishTask("worker-2", "completed", async () => {}, "send-worker");
// A call the runtime never routed has no known sender.
await runtime.sendMessageResumed("worker-2", async () => {}, "From someone", "send-unknown");
const senders = published.flatMap(({ sessionId, update }) =>
sessionId === "worker-2" && update.sessionUpdate === "session_message"
? [[update.messageId, update.senderSessionId]]
: [],
);
expect(senders).toEqual([
["send-root", "root"],
["send-worker", "worker-1"],
["send-unknown", undefined],
]);
});
it("keeps a nested child that ends before its parent is known unexposed", async () => {
const { runtime, published } = await runningWorker();
// worker-2's spawning frame has not arrived, so its parent is unknown.
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
async () => {},
);
await expect(runtime.route(output("launch-2"), async () => {})).resolves.toBeNull();
// A resume signal does not give the unexposed child another call.
await runtime.taskStarted({ taskId: "worker-2", toolUseId: "send-9" }, async () => {});
await runtime.finishTask("worker-2", "completed", async () => {});
// The frame arrives late: it is the tool call of worker-1 that made it.
await expect(
runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}),
).resolves.toMatchObject({ sessionId: "worker-1" });
expect(
published.some(
({ update }) =>
(update.sessionUpdate === "subagent_update" && update.sessionId === "worker-2") ||
(update.sessionUpdate === "session_message" && update.recipientSessionId === "worker-2"),
),
).toBe(false);
expect(published.some(({ sessionId }) => sessionId === "worker-2")).toBe(false);
});
describe("a nested child whose own parent is not announced yet", () => {
/** A runtime, the updates it sends in order, and the deliver function of
* the agent, which routes a buffered update again (as `sendUpdate` does). */
function setup() {
const sent: AcpSessionNotification[] = [];
const runtime: NativeSubagentRuntime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
sent.push(notification);
},
{ log: () => {} },
);
const deliver = async (notification: AcpSessionNotification): Promise<void> => {
const routed = await runtime.route(notification, deliver);
if (routed) sent.push(routed);
};
return { runtime, sent, deliver };
}
/** The announcements, as [parent, child], and the sessions that got
* traffic before each was announced. */
function check(sent: AcpSessionNotification[]) {
const announced = new Set(["root"]);
const early: string[] = [];
const announcements: [string, string][] = [];
for (const { sessionId, update } of sent) {
if (!announced.has(sessionId)) early.push(sessionId);
if (update.sessionUpdate === "subagent_update" && !announced.has(update.sessionId)) {
announced.add(update.sessionId);
announcements.push([sessionId, update.sessionId]);
}
}
return { announcements, early };
}
it("announces it after that parent, when the parent's task started before its launch frame", async () => {
const { runtime, sent, deliver } = setup();
// worker-1 is known, but its launch frame has not come: not announced.
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
await deliver(launch("launch-2", { description: "Dig" }, "launch-1"));
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
deliver,
);
await deliver(launch("launch-1", { description: "Fix the build" }));
expect(check(sent)).toEqual({
announcements: [
["root", "worker-1"],
["worker-1", "worker-2"],
],
early: [],
});
});
it("never announces it under the root, when its parent is not known yet", async () => {
const { runtime, sent, deliver } = setup();
await deliver(launch("launch-2", { description: "Dig" }, "launch-1"));
await runtime.taskStarted(
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
deliver,
);
await deliver(launch("launch-1", { description: "Fix the build" }));
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
deliver,
);
expect(check(sent)).toEqual({
announcements: [
["root", "worker-1"],
["worker-1", "worker-2"],
],
early: [],
});
});
});
it("keeps the role in the title when a late frame names the child", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
"root",
{},
async (notification) => {
published.push(notification);
},
{ log: () => {} },
);
await runtime.taskStarted(
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
async () => {},
);
await runtime.route(launch("launch-1", { name: "builder" }), async () => {});
expect(published[0]?.update).toMatchObject({ title: "builder (Explore)" });
});
it("reports nothing when the parent is cancelled, until the SDK ends the child", async () => {
const { runtime, published } = await runningWorker();
await runtime.parentCancelled(async () => {});
expect(states(published)).toEqual([{ state: "running" }]);
await runtime.finishTask("worker-1", "killed", async () => {});
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "cancelled" });
});
it("ends the children that still run with the stream as idle, with no reason it did not see", async () => {
// After a cancel, a child that still runs is a background child that
// outlived it: the stream's end is no evidence that it was cancelled.
const cancelled = await runningWorker();
await cancelled.runtime.finishAll("cancelled", async () => {});
expect(states(cancelled.published).at(-1)).toEqual({ state: "idle" });
const failed = await runningWorker();
await failed.runtime.finishAll("failed", async () => {});
expect(states(failed.published).at(-1)).toEqual({ state: "idle" });
// A later error event does not turn that end into a failure.
await failed.runtime.finishTask("worker-1", "failed", async () => {}, undefined, {
error: "boom",
});
expect(states(failed.published)).toHaveLength(2);
});
it("keeps a SendMessage result without a text from resuming a run that ended", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
expect(runtime.isEndedDelegation("worker-1", "send-1")).toBe(true);
await runtime.sendMessageResumed("worker-1", async () => {}, undefined, "send-1");
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
});
it("reports a child that can no longer be observed as unknown", async () => {
const { runtime, published } = await runningWorker();
await runtime.finishTask("worker-1", "disconnected", async () => {});
expect(states(published).at(-1)).toEqual({ state: "unknown" });
});
it("reports a child as requires_action while its requests are open", async () => {
const { runtime, published } = await runningWorker();
const first = Promise.withResolvers<string>();
const second = Promise.withResolvers<string>();
const answers = Promise.all([
runtime.awaitingUser("worker-1", () => first.promise),
runtime.awaitingUser("worker-1", () => second.promise),
]);
await vi.waitFor(() => expect(states(published)).toHaveLength(2));
first.resolve("allow");
await first.promise;
expect(states(published).at(-1)).toEqual({ state: "requires_action" });
second.resolve("deny");
await expect(answers).resolves.toEqual(["allow", "deny"]);
expect(states(published)).toEqual([
{ state: "running" },
{ state: "requires_action" },
{ state: "running" },
]);
// The root session's own requests change no child.
await runtime.awaitingUser("root", async () => "allow");
expect(states(published)).toHaveLength(3);
});
it("does not report running again for a child that ended while it waited", async () => {
const { runtime, published } = await runningWorker();
const answer = Promise.withResolvers<string>();
const request = runtime.awaitingUser("worker-1", () => answer.promise);
await vi.waitFor(() => expect(states(published)).toHaveLength(2));
await runtime.finishTask("worker-1", "completed", async () => {});
answer.resolve("allow");
await request;
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
});
});