mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: end a turn at its result for AIR clients with backgroundSubagents (#1262)
## What This PR adds the AIR client capability `backgroundSubagents`. A client declares it in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`. With the capability, an ACP v1 prompt turn ends at its result. This is also true while background subagents of the turn still run, including a subagent that SendMessage resumed. Subagent updates, permission requests and elicitations then go out after the prompt response. Their routing does not change. The agent advertises the capability only to ACP v1 AIR clients. ## Why The hold of #864/#866 keeps `session/prompt` open until the subagents finish. The Claude CLI ends the model turn at `result`, and only `idle` waits for the background agents. In AIR the hold hides the end of the answer, and the next user message steers into an old turn. One SendMessage-resumed subagent kept a turn open for about two hours. ## Unchanged - A client without the capability keeps the hold exactly. So #866 stays fixed for clients that stop listening at the prompt response. - ACP v2 keeps the hold too. ## Related changes - Out of a turn, the ExitPlanMode request of such a client does not offer the clear-context option. The fresh query must continue an ACP turn, and there is none. - An ExitPlanMode interruption of an autonomous cycle with no held turn now clears its markers. ## Docs `docs/air-extensions.md` has a new "Background subagents" section. ## Tests New capability cases: - a fresh subagent; - a SendMessage-resumed subagent; - a permission request after the prompt response, with the `parentToolUseId` attribution; - an autonomous followup that does not end the queued next prompt; - ExitPlanMode out of a turn, with and without a reset; - the exclusion of a running subagent from the incomplete-tool check. The existing tests cover the path without the capability. When the gate is disabled, 7 of the 8 new cases fail. The eighth is the in-turn control case and passes in both configurations. `npm run build`, `npm run check` and `npm run test:run` pass. The exception is 4 `providers.test.ts` cases. They depend on the environment (a local proxy `baseUrl`) and fail on `main` too.
This commit is contained in:
@@ -27,6 +27,7 @@ It describes only this adapter.
|
|||||||
- [Session failure](#session-failure)
|
- [Session failure](#session-failure)
|
||||||
- [Native subagent sessions](#native-subagent-sessions)
|
- [Native subagent sessions](#native-subagent-sessions)
|
||||||
- [Session index](#session-index)
|
- [Session index](#session-index)
|
||||||
|
- [Background subagents](#background-subagents)
|
||||||
- [Context compaction](#context-compaction)
|
- [Context compaction](#context-compaction)
|
||||||
- [Question custom answers](#question-custom-answers)
|
- [Question custom answers](#question-custom-answers)
|
||||||
- [Load replay end](#load-replay-end)
|
- [Load replay end](#load-replay-end)
|
||||||
@@ -125,7 +126,8 @@ The `initialize` response of an AIR client carries the agent side of the extensi
|
|||||||
"asyncTasks",
|
"asyncTasks",
|
||||||
"recommendedValue",
|
"recommendedValue",
|
||||||
"diffPatch",
|
"diffPatch",
|
||||||
"planFile"
|
"planFile",
|
||||||
|
"backgroundSubagents"
|
||||||
],
|
],
|
||||||
"goal": {
|
"goal": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -154,6 +156,7 @@ The response to a client that is not AIR has no `_meta.jetbrains` key.
|
|||||||
| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) |
|
| `agentFileChangeReport` | yes | Accepts a report request on `session/prompt` and sends the changed file list. | [Agent file-change report](#agent-file-change-report) |
|
||||||
| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) |
|
| `nativeSubagentSessions` | yes | Reports an Agent or Task subagent as a native ACP child session. | [Native subagent sessions](#native-subagent-sessions) |
|
||||||
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
|
| `planFile` | yes | Sends the path of the plan file in place of the plan text of an ExitPlanMode. | [Plan file](#plan-file) |
|
||||||
|
| `backgroundSubagents` | yes | Ends a prompt turn at its result while background subagents still run. | [Background subagents](#background-subagents) |
|
||||||
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
|
| `sessionIndex` | on request | Pages, orders and annotates `session/list`; adds rename, archive and a list subscription. | [Session index](#session-index) |
|
||||||
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
|
| `sessionArchive` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/archive` and `_session/unarchive`. | [Archive](#archive) |
|
||||||
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
|
| `sessionRename` | with `sessionIndex` | Nothing on its own: the agent lists it to tell that it supports `_session/rename`. | [Rename](#rename) |
|
||||||
@@ -1539,6 +1542,45 @@ It differs in transport, until the RFD lands in the SDK, in the list filter, and
|
|||||||
- the list parameter is `_meta.jetbrains.air.list.archived`, and the state is `_meta.jetbrains.air.archived` on rows and in `session_info_update`, not the `archived` fields;
|
- the list parameter is `_meta.jetbrains.air.list.archived`, and the state is `_meta.jetbrains.air.archived` on rows and in `session_info_update`, not the `archived` fields;
|
||||||
- an AIR client without `sessionIndex` still archives with `session/delete` (see [Delete and close](#delete-and-close)), which the RFD asks clients not to do.
|
- an AIR client without `sessionIndex` still archives with `session/delete` (see [Delete and close](#delete-and-close)), which the RFD asks clients not to do.
|
||||||
|
|
||||||
|
## Background subagents
|
||||||
|
|
||||||
|
A prompt turn can start a background Agent or Task subagent.
|
||||||
|
A SendMessage tool call can also resume a subagent that ended.
|
||||||
|
Claude Code sends the result of the turn when the main agent stops, also while such a subagent still runs.
|
||||||
|
|
||||||
|
### Hold without the capability
|
||||||
|
|
||||||
|
A client that does not declare `backgroundSubagents` gets the hold of issues #864 and #866.
|
||||||
|
The adapter keeps the `session/prompt` request open while a subagent that the turn started or resumed runs.
|
||||||
|
The subagent output, its permission requests and the followup summary of the model then go out inside the turn.
|
||||||
|
The turn ends at the followup result, at an `idle` with no live subagent, at `session/cancel`, or when the next prompt starts.
|
||||||
|
The hold protects a client that stops listening at the prompt response.
|
||||||
|
|
||||||
|
### Behavior with the capability
|
||||||
|
|
||||||
|
AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`.
|
||||||
|
The capability applies to ACP v1 only. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2.
|
||||||
|
|
||||||
|
- The turn ends at its result with the stop reason of the result, usually `end_turn`.
|
||||||
|
- The subagent updates, permission requests and elicitations go out after the prompt response.
|
||||||
|
- The routing does not change: the native child session, else the root session with the parent tool call in `_meta.claudeCode.parentToolUseId`.
|
||||||
|
- The followup cycle after a subagent ends is an autonomous cycle. Its updates go out of a turn. Its result does not end a turn.
|
||||||
|
- A steer with no turn in flight starts a new turn, or returns `promptRequired` when the client asks for it.
|
||||||
|
- `session/cancel` with no turn works as before: the adapter interrupts the SDK query and reports each native subagent session as cancelled.
|
||||||
|
- The tool call of a running subagent does not fail the incomplete tool call check of the turn.
|
||||||
|
- The [agent file-change report](#agent-file-change-report) of the turn does not include an edit that a subagent makes after the result.
|
||||||
|
- An open permission request of a subagent counts as an open request for the next turn and for the steer priority.
|
||||||
|
|
||||||
|
### Plan approval out of a turn
|
||||||
|
|
||||||
|
The followup cycle can call ExitPlanMode. AIR then gets the permission request out of a turn.
|
||||||
|
The request has no clear-context option, because the fresh query must continue an ACP turn.
|
||||||
|
"No, keep planning" stops only the followup cycle. There is no turn to end.
|
||||||
|
|
||||||
|
A prompt turn owns the request when the turn is active, or when Claude Code asks before the echo of its prompt.
|
||||||
|
Then the request has the clear-context option.
|
||||||
|
A turn that took over a followup cycle also owns the request of that cycle, and the accepted plan continues in that turn.
|
||||||
|
|
||||||
## Context compaction
|
## Context compaction
|
||||||
|
|
||||||
The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`.
|
The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`.
|
||||||
|
|||||||
148
src/acp-agent.ts
148
src/acp-agent.ts
@@ -127,6 +127,7 @@ import {
|
|||||||
} from "./native-subagents.js";
|
} from "./native-subagents.js";
|
||||||
import {
|
import {
|
||||||
AIR_ASYNC_TASKS_CAPABILITY,
|
AIR_ASYNC_TASKS_CAPABILITY,
|
||||||
|
AIR_BACKGROUND_SUBAGENTS_CAPABILITY,
|
||||||
AIR_CUSTOM_INSTRUCTIONS_CAPABILITY,
|
AIR_CUSTOM_INSTRUCTIONS_CAPABILITY,
|
||||||
airCustomInstructions,
|
airCustomInstructions,
|
||||||
AIR_DIFF_PATCH_CAPABILITY,
|
AIR_DIFF_PATCH_CAPABILITY,
|
||||||
@@ -858,7 +859,9 @@ type Turn = {
|
|||||||
* names only a launching subagent), so a spawn made by a
|
* names only a launching subagent), so a spawn made by a
|
||||||
* PREVIOUS turn's followup chain while a later turn happens to be held
|
* PREVIOUS turn's followup chain while a later turn happens to be held
|
||||||
* is attributed to the holder — extending that hold behind a foreign
|
* is attributed to the holder — extending that hold behind a foreign
|
||||||
* chain. Bounded: the hold still ends at drain, hand-off, or cancel. */
|
* chain. Bounded: the hold still ends at drain, hand-off, or cancel.
|
||||||
|
* The set is also kept for a client with `backgroundSubagents`, but no
|
||||||
|
* hold reads it there (see `deferredSettle`). */
|
||||||
spawnedTaskIds?: Set<string>;
|
spawnedTaskIds?: Set<string>;
|
||||||
/** Set instead of settling when the turn's terminal result arrives while
|
/** Set instead of settling when the turn's terminal result arrives while
|
||||||
* subagents it spawned are still live (`spawnedTaskIds` ∩
|
* subagents it spawned are still live (`spawnedTaskIds` ∩
|
||||||
@@ -869,6 +872,17 @@ type Turn = {
|
|||||||
* issue #866), and the model's task-notification followup summary all
|
* issue #866), and the model's task-notification followup summary all
|
||||||
* land inside the turn.
|
* land inside the turn.
|
||||||
*
|
*
|
||||||
|
* An ACP v1 AIR client that declares `backgroundSubagents` gets no hold:
|
||||||
|
* this field stays unset, and the turn settles at its result. AIR keeps
|
||||||
|
* listening after the prompt response, so the subagent updates, their
|
||||||
|
* permission requests and elicitations, and the followup summary go out
|
||||||
|
* after it, with the same routing. A held turn hides the end of the
|
||||||
|
* answer, and the next user message would steer into it. Consequences:
|
||||||
|
* a followup result is an out-of-turn autonomous result; a steer with no
|
||||||
|
* turn in flight starts a new turn; the file-change report of the turn
|
||||||
|
* leaves out subagent edits after the result. Every other client keeps
|
||||||
|
* the hold, so issue #866 stays fixed for it.
|
||||||
|
*
|
||||||
* Idle cadence depends on the CLI. Through 2.1.269 the trailing idle is
|
* Idle cadence depends on the CLI. Through 2.1.269 the trailing idle is
|
||||||
* NOT held for background agents (observed on 2.1.206: `idle` follows
|
* NOT held for background agents (observed on 2.1.206: `idle` follows
|
||||||
* the result immediately while the subagent still runs), so the hold
|
* the result immediately while the subagent still runs), so the hold
|
||||||
@@ -1324,7 +1338,9 @@ export type Session = {
|
|||||||
* `turnAwaitingSubagents` (with `spawnedTaskIds`) to decide whether a
|
* `turnAwaitingSubagents` (with `spawnedTaskIds`) to decide whether a
|
||||||
* turn's settlement is deferred (see `Turn.deferredSettle`), so the
|
* turn's settlement is deferred (see `Turn.deferredSettle`), so the
|
||||||
* subagents' post-result output and permission requests stay inside the
|
* subagents' post-result output and permission requests stay inside the
|
||||||
* turn (issues #864/#866). Deliberately false for non-subagent background
|
* turn (issues #864/#866). A client with `backgroundSubagents` gets no
|
||||||
|
* such hold. Its subagent requests keep this attribution after the
|
||||||
|
* prompt response. Deliberately false for non-subagent background
|
||||||
* tasks (e.g. a `run_in_background` dev server): those can outlive every
|
* tasks (e.g. a `run_in_background` dev server): those can outlive every
|
||||||
* turn, and the model's contract with them is a wake-on-exit
|
* turn, and the model's contract with them is a wake-on-exit
|
||||||
* notification, not a turn-scoped drain — a hold must NEVER wait on a
|
* notification, not a turn-scoped drain — a hold must NEVER wait on a
|
||||||
@@ -1392,7 +1408,9 @@ export type Session = {
|
|||||||
* agent id without a new `task_started` (a running `task_updated` patch or
|
* agent id without a new `task_started` (a running `task_updated` patch or
|
||||||
* a SendMessage `resumedAgentId`). The resume signal puts the subagent back
|
* a SendMessage `resumedAgentId`). The resume signal puts the subagent back
|
||||||
* into `liveBackgroundTasks` from this record, so the resuming turn holds
|
* into `liveBackgroundTasks` from this record, so the resuming turn holds
|
||||||
* and its permission requests keep their attribution. Bounded by
|
* and its permission requests keep their attribution. With
|
||||||
|
* `backgroundSubagents` the resuming turn does not hold, and the
|
||||||
|
* attribution stays the same. Bounded by
|
||||||
* `MAX_RESUMABLE_SUBAGENTS`, oldest first. */
|
* `MAX_RESUMABLE_SUBAGENTS`, oldest first. */
|
||||||
resumableSubagents?: Map<string, { parentToolUseId?: string }>;
|
resumableSubagents?: Map<string, { parentToolUseId?: string }>;
|
||||||
/** The latest `run_id` seen on each task's events, so a late frame of an
|
/** The latest `run_id` seen on each task's events, so a late frame of an
|
||||||
@@ -1563,7 +1581,8 @@ const UNREPORTED_FOREGROUND: ForegroundEvents = {
|
|||||||
/** Whether this turn's terminal result arrived but its settlement is being
|
/** Whether this turn's terminal result arrived but its settlement is being
|
||||||
* held for background subagents it spawned (see Turn.deferredSettle). The
|
* held for background subagents it spawned (see Turn.deferredSettle). The
|
||||||
* single spelling of the hold predicate, shared by the consumer's settle
|
* single spelling of the hold predicate, shared by the consumer's settle
|
||||||
* lanes and cancel(). */
|
* lanes and cancel(). Always false for a client with `backgroundSubagents`,
|
||||||
|
* because no turn of that client is held. */
|
||||||
function isHeldOpen(turn: Turn | null | undefined): turn is Turn & { deferredSettle: TurnOutcome } {
|
function isHeldOpen(turn: Turn | null | undefined): turn is Turn & { deferredSettle: TurnOutcome } {
|
||||||
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
|
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
|
||||||
}
|
}
|
||||||
@@ -2716,6 +2735,19 @@ export class ClaudeAcpAgent {
|
|||||||
teardownSession: (sessionId) => this.teardownSession(sessionId),
|
teardownSession: (sessionId) => this.teardownSession(sessionId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a turn ends at its result while background subagents that it
|
||||||
|
* spawned or resumed still run. Only an ACP v1 AIR client that declares
|
||||||
|
* `backgroundSubagents` gets this. Every other client keeps the hold of
|
||||||
|
* `Turn.deferredSettle`.
|
||||||
|
*/
|
||||||
|
private endsTurnsWithLiveSubagents(): boolean {
|
||||||
|
return (
|
||||||
|
!this.v2 &&
|
||||||
|
clientSupportsAirCapability(this.clientCapabilities, AIR_BACKGROUND_SUBAGENTS_CAPABILITY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
client: AcpClient,
|
client: AcpClient,
|
||||||
logger?: Logger,
|
logger?: Logger,
|
||||||
@@ -2978,6 +3010,8 @@ export class ClaudeAcpAgent {
|
|||||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||||
AIR_DIFF_PATCH_CAPABILITY,
|
AIR_DIFF_PATCH_CAPABILITY,
|
||||||
AIR_PLAN_FILE_CAPABILITY,
|
AIR_PLAN_FILE_CAPABILITY,
|
||||||
|
// ACP v2 keeps the hold (see `src/v2/prompt.ts`).
|
||||||
|
...(this.v2 ? [] : [AIR_BACKGROUND_SUBAGENTS_CAPABILITY]),
|
||||||
...this.sessionIndex.capabilities(),
|
...this.sessionIndex.capabilities(),
|
||||||
),
|
),
|
||||||
AIR_GOAL_KEY,
|
AIR_GOAL_KEY,
|
||||||
@@ -4468,10 +4502,27 @@ export class ClaudeAcpAgent {
|
|||||||
// Tool-use blocks start streaming before their JSON input. Keep the
|
// Tool-use blocks start streaming before their JSON input. Keep the
|
||||||
// partial input per parent message and block index so completed top-level
|
// partial input per parent message and block index so completed top-level
|
||||||
// fields can refine the pending tool call while it streams. Entries are
|
// fields can refine the pending tool call while it streams. Entries are
|
||||||
// dropped at block/message boundaries; the whole map is swept when a turn
|
// dropped at block/message boundaries; the map is swept when a turn
|
||||||
// settles, since an interrupted subagent stream (keyed by a
|
// settles, since an interrupted subagent stream (keyed by a
|
||||||
// parent_tool_use_id that never recurs) has no boundary event of its own.
|
// parent_tool_use_id that never recurs) has no boundary event of its own.
|
||||||
const streamedToolInputs: StreamedToolInputCache = new Map();
|
const streamedToolInputs: StreamedToolInputCache = new Map();
|
||||||
|
/** Sweeps `streamedToolInputs` when a turn ends. A turn of a client with
|
||||||
|
* `backgroundSubagents` can end while its subagents still stream tool
|
||||||
|
* input, so the sweep keeps the lanes of live subagents, unless the turn
|
||||||
|
* ends cancelled. Their own message boundaries drop them later. */
|
||||||
|
const sweepStreamedToolInputs = (turnStopReason?: StopReason) => {
|
||||||
|
if (turnStopReason === "cancelled" || !this.endsTurnsWithLiveSubagents()) {
|
||||||
|
streamedToolInputs.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const liveLanes = new Set<string>();
|
||||||
|
for (const task of session.liveBackgroundTasks.values()) {
|
||||||
|
if (task.isSubagent && task.parentToolUseId) liveLanes.add(task.parentToolUseId);
|
||||||
|
}
|
||||||
|
for (const lane of streamedToolInputs.keys()) {
|
||||||
|
if (!liveLanes.has(lane)) streamedToolInputs.delete(lane);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Stop reason accumulated for the active turn (result subtype, refusal,
|
// Stop reason accumulated for the active turn (result subtype, refusal,
|
||||||
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
||||||
let stopReason: StopReason = "end_turn";
|
let stopReason: StopReason = "end_turn";
|
||||||
@@ -5068,9 +5119,10 @@ export class ClaudeAcpAgent {
|
|||||||
|
|
||||||
/** Registers a live background task (see `liveBackgroundTasks`). A
|
/** Registers a live background task (see `liveBackgroundTasks`). A
|
||||||
* subagent is also recorded on the active turn, so that turn holds until
|
* subagent is also recorded on the active turn, so that turn holds until
|
||||||
* the subagent settles (see `Turn.spawnedTaskIds`). Its requests may
|
* the subagent settles (see `Turn.spawnedTaskIds`). A client with
|
||||||
* already be open, and where it runs decides whether they block the
|
* `backgroundSubagents` gets no hold (see `turnAwaitingSubagents`).
|
||||||
* foreground work, so that is checked again. */
|
* Its requests may already be open, and where it runs decides
|
||||||
|
* whether they block the foreground work, so that is checked again. */
|
||||||
const registerLiveTask = (
|
const registerLiveTask = (
|
||||||
taskId: string,
|
taskId: string,
|
||||||
parentToolUseId: string | undefined,
|
parentToolUseId: string | undefined,
|
||||||
@@ -5124,8 +5176,9 @@ export class ClaudeAcpAgent {
|
|||||||
|
|
||||||
/** Registers a settled subagent again when the SDK resumes it without a
|
/** Registers a settled subagent again when the SDK resumes it without a
|
||||||
* new `task_started`. The active turn is the one that resumed it, so it
|
* new `task_started`. The active turn is the one that resumed it, so it
|
||||||
* holds until the subagent settles again. A repeated resume signal finds
|
* holds until the subagent settles again, unless the client declared
|
||||||
* the subagent live and changes nothing. */
|
* `backgroundSubagents`. A repeated resume signal finds the subagent live
|
||||||
|
* and changes nothing. */
|
||||||
const resumeLiveTask = (taskId: string) => {
|
const resumeLiveTask = (taskId: string) => {
|
||||||
if (session.liveBackgroundTasks.has(taskId)) return;
|
if (session.liveBackgroundTasks.has(taskId)) return;
|
||||||
const settled = session.resumableSubagents?.get(taskId);
|
const settled = session.resumableSubagents?.get(taskId);
|
||||||
@@ -5136,9 +5189,12 @@ export class ClaudeAcpAgent {
|
|||||||
/** Whether any background subagent this turn spawned is still live —
|
/** Whether any background subagent this turn spawned is still live —
|
||||||
* while true, the turn's settlement stays deferred so the subagent's
|
* while true, the turn's settlement stays deferred so the subagent's
|
||||||
* output and permission requests land inside it (see
|
* output and permission requests land inside it (see
|
||||||
* Turn.deferredSettle). */
|
* Turn.deferredSettle). Always false for a client that declared
|
||||||
|
* `backgroundSubagents`: its turns end at their result, and the subagent
|
||||||
|
* traffic goes out after the prompt response. This is the single gate of
|
||||||
|
* the hold, so no turn of such a client is ever held open. */
|
||||||
const turnAwaitingSubagents = (turn: Turn) => {
|
const turnAwaitingSubagents = (turn: Turn) => {
|
||||||
if (!turn.spawnedTaskIds?.size) {
|
if (this.endsTurnsWithLiveSubagents() || !turn.spawnedTaskIds?.size) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const taskId of turn.spawnedTaskIds) {
|
for (const taskId of turn.spawnedTaskIds) {
|
||||||
@@ -5171,7 +5227,8 @@ export class ClaudeAcpAgent {
|
|||||||
* turn open (see Turn.deferredSettle). Every result-time settle of a
|
* turn open (see Turn.deferredSettle). Every result-time settle of a
|
||||||
* turn that can have spawned subagents must route through here: a site
|
* turn that can have spawned subagents must route through here: a site
|
||||||
* calling settleActive directly bypasses the hold and re-opens the
|
* calling settleActive directly bypasses the hold and re-opens the
|
||||||
* out-of-turn permission deadlock (issue #866) through its lane. */
|
* out-of-turn permission deadlock (issue #866) through its lane. For a
|
||||||
|
* client with `backgroundSubagents` this always settles now. */
|
||||||
const settleOrDefer = async (outcome: TurnOutcome) => {
|
const settleOrDefer = async (outcome: TurnOutcome) => {
|
||||||
// No result ends a steered turn: the steer aborted the cycle this result
|
// No result ends a steered turn: the steer aborted the cycle this result
|
||||||
// may belong to, and the steered one is still to come. Record the outcome
|
// may belong to, and the steered one is still to come. Record the outcome
|
||||||
@@ -5480,7 +5537,7 @@ export class ClaudeAcpAgent {
|
|||||||
disarmForceCancel(session);
|
disarmForceCancel(session);
|
||||||
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
||||||
session.activeTurn = null;
|
session.activeTurn = null;
|
||||||
streamedToolInputs.clear();
|
sweepStreamedToolInputs(result.stopReason);
|
||||||
if (wasHeld) {
|
if (wasHeld) {
|
||||||
// Settling a held turn is its delivery-stretch boundary: the turn's
|
// Settling a held turn is its delivery-stretch boundary: the turn's
|
||||||
// answer finished long ago, so text streamed since the last boundary
|
// answer finished long ago, so text streamed since the last boundary
|
||||||
@@ -5514,7 +5571,7 @@ export class ClaudeAcpAgent {
|
|||||||
turn.settled = true;
|
turn.settled = true;
|
||||||
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
||||||
session.activeTurn = null;
|
session.activeTurn = null;
|
||||||
streamedToolInputs.clear();
|
sweepStreamedToolInputs();
|
||||||
// A failed turn's stretch is over, and some failure lanes (the issue
|
// A failed turn's stretch is over, and some failure lanes (the issue
|
||||||
// #825 idle-fail) never see the result whose `finally` would close it —
|
// #825 idle-fail) never see the result whose `finally` would close it —
|
||||||
// start the next stretch clean, or its stale delivery record would
|
// start the next stretch clean, or its stale delivery record would
|
||||||
@@ -7156,6 +7213,43 @@ export class ClaudeAcpAgent {
|
|||||||
await settleOrDefer({ ...heldTurn.deferredSettle, stopReason: "cancelled" });
|
await settleOrDefer({ ...heldTurn.deferredSettle, stopReason: "cancelled" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// The same followup with no held turn: the client declared
|
||||||
|
// `backgroundSubagents`, or the hold already ended. A user
|
||||||
|
// turn that took the cycle over owns the request (see
|
||||||
|
// `requestOwnerTurn`), so it continues the accepted plan in a
|
||||||
|
// fresh context, as the user lane does. With no such turn
|
||||||
|
// the user answered out of a turn, and the interrupt only
|
||||||
|
// stops this autonomous cycle. Drop the markers, so that a
|
||||||
|
// later user-turn result is not read as this interruption.
|
||||||
|
// `canUseTool` offers no clear-context option to a request
|
||||||
|
// without an owner turn, so a reset here is only a race with
|
||||||
|
// the end of a turn.
|
||||||
|
if (
|
||||||
|
pendingExitPlanModeInterruption &&
|
||||||
|
isExitPlanInterruptionResult(message, pendingExitPlanModeInterruption)
|
||||||
|
) {
|
||||||
|
session.pendingExitPlanModeInterruption = undefined;
|
||||||
|
const ownerTurn = this.requestOwnerTurn(params.sessionId, session);
|
||||||
|
if (
|
||||||
|
pendingExitPlanContextReset?.toolUseId ===
|
||||||
|
pendingExitPlanModeInterruption.toolUseId &&
|
||||||
|
ownerTurn !== undefined &&
|
||||||
|
ownerTurn === session.activeTurn
|
||||||
|
) {
|
||||||
|
await this.exitPlan.restart(
|
||||||
|
params.sessionId,
|
||||||
|
session,
|
||||||
|
pendingExitPlanContextReset,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingExitPlanContextReset) {
|
||||||
|
session.pendingExitPlanContextReset = undefined;
|
||||||
|
this.logger.error(
|
||||||
|
`Session ${params.sessionId}: no turn to continue the accepted plan in a fresh context`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// CLI 2.1.274+ answers background-task completions that were
|
// CLI 2.1.274+ answers background-task completions that were
|
||||||
// already queued with ONE model call: every queued
|
// already queued with ONE model call: every queued
|
||||||
// notification still gets its own result, but all except the
|
// notification still gets its own result, but all except the
|
||||||
@@ -7478,7 +7572,9 @@ export class ClaudeAcpAgent {
|
|||||||
// permission requests, and the model's promised summary all land
|
// permission requests, and the model's promised summary all land
|
||||||
// inside the turn. `session/cancel` and the next prompt's echo
|
// inside the turn. `session/cancel` and the next prompt's echo
|
||||||
// hand-off still settle a deferred turn early, so a long-running
|
// hand-off still settle a deferred turn early, so a long-running
|
||||||
// subagent never holds the prompt hostage.
|
// subagent never holds the prompt hostage. A client with
|
||||||
|
// `backgroundSubagents` gets no hold: the turn settles here, and
|
||||||
|
// the subagent traffic goes out after the prompt response.
|
||||||
//
|
//
|
||||||
// is_error/auth already settled via failActive (activeTurn is null
|
// is_error/auth already settled via failActive (activeTurn is null
|
||||||
// then, so both branches no-op); a cancelled turn settles at
|
// then, so both branches no-op); a cancelled turn settles at
|
||||||
@@ -9834,6 +9930,20 @@ export class ClaudeAcpAgent {
|
|||||||
return this.foregroundWork.get(sessionId)?.owner === AUTONOMOUS_CYCLE;
|
return this.foregroundWork.get(sessionId)?.owner === AUTONOMOUS_CYCLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ACP turn that owns a root request of the session now, if any. A
|
||||||
|
* model cycle that Claude Code started on its own owns no turn. A turn that
|
||||||
|
* took a cycle over owns it. A queued turn owns the request too, because
|
||||||
|
* Claude Code can ask for a queued prompt before the consumer reaches its
|
||||||
|
* echo (see `recordForegroundToolCall`). The ExitPlanMode request and the
|
||||||
|
* result of its interruption use this one rule, so an offered
|
||||||
|
* clear-context option always has a turn to continue. */
|
||||||
|
private requestOwnerTurn(sessionId: string, session: Session): Turn | undefined {
|
||||||
|
if (this.runsAutonomousCycle(sessionId)) return undefined;
|
||||||
|
const active = session.activeTurn;
|
||||||
|
if (active && !active.settled) return active;
|
||||||
|
return session.turnQueue?.find((queued) => !queued.settled);
|
||||||
|
}
|
||||||
|
|
||||||
/** Forward a permission request to the client, wiring the tool call's
|
/** Forward a permission request to the client, wiring the tool call's
|
||||||
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
||||||
* while the client's prompt is still open the signal aborts, the SDK sends
|
* while the client's prompt is still open the signal aborts, the SDK sends
|
||||||
@@ -10233,6 +10343,12 @@ export class ClaudeAcpAgent {
|
|||||||
defaultToNo,
|
defaultToNo,
|
||||||
availableModes: this.sessionModes.availableModeIds(session.modes),
|
availableModes: this.sessionModes.availableModeIds(session.modes),
|
||||||
prePlanMode: session.prePlanMode,
|
prePlanMode: session.prePlanMode,
|
||||||
|
// A fresh context continues the ACP turn that owns the request. With
|
||||||
|
// `backgroundSubagents` a followup cycle can ask out of a turn, and
|
||||||
|
// then there is no turn to continue.
|
||||||
|
allowContextReset:
|
||||||
|
!this.endsTurnsWithLiveSubagents() ||
|
||||||
|
this.requestOwnerTurn(sessionId, session) !== undefined,
|
||||||
contextUsedPercent:
|
contextUsedPercent:
|
||||||
session.contextUsedTokens === undefined || session.contextWindowSize <= 0
|
session.contextUsedTokens === undefined || session.contextWindowSize <= 0
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
||||||
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
||||||
|
/**
|
||||||
|
* A prompt turn ends at its result also while its background subagents run.
|
||||||
|
* The subagent updates and requests then go out after the prompt response.
|
||||||
|
*/
|
||||||
|
export const AIR_BACKGROUND_SUBAGENTS_CAPABILITY = "backgroundSubagents";
|
||||||
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
||||||
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
||||||
export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions";
|
export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions";
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export interface PermissionOptionContext {
|
|||||||
/** The mode the session left when it entered plan mode. */
|
/** The mode the session left when it entered plan mode. */
|
||||||
prePlanMode?: string;
|
prePlanMode?: string;
|
||||||
contextUsedPercent?: number;
|
contextUsedPercent?: number;
|
||||||
|
/** False when no ACP turn can continue an accepted plan in a fresh
|
||||||
|
* context. Then an ExitPlanMode request offers no clear-context option. */
|
||||||
|
allowContextReset?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allowOnce(name = "Yes"): PermissionOption {
|
export function allowOnce(name = "Yes"): PermissionOption {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function buildExitPlanModePermissionOptions(
|
|||||||
: (["auto", "bypassPermissions"] as const)
|
: (["auto", "bypassPermissions"] as const)
|
||||||
).filter((mode) => modes.has(mode));
|
).filter((mode) => modes.has(mode));
|
||||||
const preferredMode = elevatedModes[0] ?? "acceptEdits";
|
const preferredMode = elevatedModes[0] ?? "acceptEdits";
|
||||||
if (plainString(context.input.plan)) {
|
if (context.allowContextReset !== false && plainString(context.input.plan)) {
|
||||||
const usage =
|
const usage =
|
||||||
context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`;
|
context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`;
|
||||||
options.push(
|
options.push(
|
||||||
|
|||||||
@@ -12994,6 +12994,7 @@ describe("logout", () => {
|
|||||||
"recommendedValue",
|
"recommendedValue",
|
||||||
"diffPatch",
|
"diffPatch",
|
||||||
"planFile",
|
"planFile",
|
||||||
|
"backgroundSubagents",
|
||||||
],
|
],
|
||||||
goal: { version: 1, controlMethod: GOAL_CONTROL_METHOD, actions: ["set", "clear"] },
|
goal: { version: 1, controlMethod: GOAL_CONTROL_METHOD, actions: ["set", "clear"] },
|
||||||
});
|
});
|
||||||
@@ -13017,6 +13018,7 @@ describe("logout", () => {
|
|||||||
"recommendedValue",
|
"recommendedValue",
|
||||||
"diffPatch",
|
"diffPatch",
|
||||||
"planFile",
|
"planFile",
|
||||||
|
"backgroundSubagents",
|
||||||
],
|
],
|
||||||
goal: { version: 1, controlMethod: GOAL_CONTROL_METHOD, actions: ["set", "clear"] },
|
goal: { version: 1, controlMethod: GOAL_CONTROL_METHOD, actions: ["set", "clear"] },
|
||||||
});
|
});
|
||||||
@@ -20977,6 +20979,19 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
|||||||
// 2.1.270: no idle until the subagent drains) and settles once its
|
// 2.1.270: no idle until the subagent drains) and settles once its
|
||||||
// subagents are done — at the followup's terminal result, or at an idle
|
// subagents are done — at the followup's terminal result, or at an idle
|
||||||
// with none of them left.
|
// with none of them left.
|
||||||
|
//
|
||||||
|
// The tests without `declareBackgroundSubagents` are the cases of a client
|
||||||
|
// that does not declare the AIR `backgroundSubagents` capability. Such a
|
||||||
|
// client keeps the hold.
|
||||||
|
|
||||||
|
/** Declares the AIR `backgroundSubagents` capability: no turn is held. */
|
||||||
|
const declareBackgroundSubagents = (agent: ClaudeAcpAgent) =>
|
||||||
|
agent.initialize({
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {
|
||||||
|
_meta: { jetbrains: { air: { version: 1, capabilities: ["backgroundSubagents"] } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function createMockAgent() {
|
function createMockAgent() {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
@@ -21569,6 +21584,421 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
|||||||
// cycle, which the error-shaped diagnostic of the answer ends cancelled.
|
// cycle, which the error-shaped diagnostic of the answer ends cancelled.
|
||||||
expect(foreground).toEqual(["running", "ended end_turn", "running", "ended cancelled"]);
|
expect(foreground).toEqual(["running", "ended end_turn", "running", "ended cancelled"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("with backgroundSubagents", () => {
|
||||||
|
it("asks out of the turn without a clear-context option and drops the interrupt", async () => {
|
||||||
|
let request: RequestPermissionRequest | undefined;
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async (params: RequestPermissionRequest) => {
|
||||||
|
request = params;
|
||||||
|
return { outcome: { outcome: "selected", optionId: "reject" } };
|
||||||
|
},
|
||||||
|
} as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
const createSession = vi.spyOn(agent as any, "createSession");
|
||||||
|
let answer!: () => void;
|
||||||
|
const answered = new Promise<void>((resolve) => (answer = resolve));
|
||||||
|
let planToolSeen = false;
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const iter = input[Symbol.asyncIterator]();
|
||||||
|
const { value: first } = await iter.next();
|
||||||
|
// The followup asks before its tool result, as Claude Code does.
|
||||||
|
const followup = heldTurnPlanningFollowup(first);
|
||||||
|
for (let step = followup.next(); !step.done; step = followup.next()) {
|
||||||
|
const message: any = step.value;
|
||||||
|
if (message.type === "user" && message.tool_result_meta) {
|
||||||
|
planToolSeen = true;
|
||||||
|
await answered;
|
||||||
|
}
|
||||||
|
yield message;
|
||||||
|
}
|
||||||
|
yield idle();
|
||||||
|
const { value: second } = await iter.next();
|
||||||
|
yield userEcho(second);
|
||||||
|
yield running();
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const first = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "plan" }],
|
||||||
|
});
|
||||||
|
// The turn ends at its own result; the plan comes after it.
|
||||||
|
expect(first.stopReason).toBe("end_turn");
|
||||||
|
await waitFor(() => planToolSeen);
|
||||||
|
expect(session.activeTurn).toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.canUseTool("test-session")("ExitPlanMode", { plan: "Implement it" }, {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "tool-plan",
|
||||||
|
} as any),
|
||||||
|
).resolves.toMatchObject({ behavior: "deny", interrupt: true });
|
||||||
|
// No ACP turn can continue a fresh context out of a turn.
|
||||||
|
expect(request?.options.map((o) => o.optionId)).not.toEqual(
|
||||||
|
expect.arrayContaining([expect.stringMatching(/^exit-plan-clear-/)]),
|
||||||
|
);
|
||||||
|
expect(request?.options.map((o) => o.optionId)).toContain("reject");
|
||||||
|
|
||||||
|
answer();
|
||||||
|
await waitFor(() => session.pendingExitPlanModeInterruption === undefined);
|
||||||
|
const second = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "next" }],
|
||||||
|
});
|
||||||
|
expect(second.stopReason).toBe("end_turn");
|
||||||
|
expect(createSession).not.toHaveBeenCalled();
|
||||||
|
await session.consumer;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the clear-context option inside a turn", async () => {
|
||||||
|
let request: RequestPermissionRequest | undefined;
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async (params: RequestPermissionRequest) => {
|
||||||
|
request = params;
|
||||||
|
return { outcome: { outcome: "selected", optionId: "reject" } };
|
||||||
|
},
|
||||||
|
} as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
let asked!: () => void;
|
||||||
|
const askedGate = new Promise<void>((resolve) => (asked = resolve));
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
await askedGate;
|
||||||
|
yield {
|
||||||
|
type: "user",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "tool-plan", content: "no" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
|
||||||
|
const prompt = agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "plan" }],
|
||||||
|
});
|
||||||
|
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||||
|
await agent.canUseTool("test-session")("ExitPlanMode", { plan: "Implement it" }, {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "tool-plan",
|
||||||
|
} as any);
|
||||||
|
expect(request?.options.map((o) => o.optionId)).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringMatching(/^exit-plan-clear-/)]),
|
||||||
|
);
|
||||||
|
asked();
|
||||||
|
await prompt;
|
||||||
|
await agent.sessions["test-session"]?.consumer;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a clear-context reset that has no turn to continue", async () => {
|
||||||
|
const error = vi.fn();
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{ sessionUpdate: async () => {} } as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error },
|
||||||
|
);
|
||||||
|
const createSession = vi.spyOn(agent as any, "createSession");
|
||||||
|
injectGeneratorSession(
|
||||||
|
agent,
|
||||||
|
(input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||||
|
yield* heldTurnPlanningFollowup(first);
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
creationParams: { cwd: "/test", mcpServers: [] },
|
||||||
|
pendingExitPlanContextReset: {
|
||||||
|
toolUseId: "tool-plan",
|
||||||
|
plan: "Implement it",
|
||||||
|
mode: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const response = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "plan" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.stopReason).toBe("end_turn");
|
||||||
|
await session.consumer;
|
||||||
|
expect(createSession).not.toHaveBeenCalled();
|
||||||
|
expect(session.pendingExitPlanModeInterruption).toBeUndefined();
|
||||||
|
expect(session.pendingExitPlanContextReset).toBeUndefined();
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("no turn to continue the accepted plan"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Agent whose client keeps the last permission request and rejects it. */
|
||||||
|
const rejectingAgent = () => {
|
||||||
|
const requests: RequestPermissionRequest[] = [];
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async (params: RequestPermissionRequest) => {
|
||||||
|
requests.push(params);
|
||||||
|
return { outcome: { outcome: "selected", optionId: "reject" } };
|
||||||
|
},
|
||||||
|
} as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
return { agent, requests };
|
||||||
|
};
|
||||||
|
const askExitPlan = (agent: ClaudeAcpAgent) =>
|
||||||
|
agent.canUseTool("test-session")("ExitPlanMode", { plan: "Implement it" }, {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "tool-plan",
|
||||||
|
} as any);
|
||||||
|
const offersClearContext = (request: RequestPermissionRequest | undefined) =>
|
||||||
|
(request?.options ?? []).some((o) => o.optionId.startsWith("exit-plan-clear-"));
|
||||||
|
|
||||||
|
it("offers the clear-context option to a prompt before its echo", async () => {
|
||||||
|
const { agent, requests } = rejectingAgent();
|
||||||
|
let echo!: () => void;
|
||||||
|
const echoGate = new Promise<void>((resolve) => (echo = resolve));
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||||
|
// Claude Code asks for the prompt before the consumer reaches its echo.
|
||||||
|
await echoGate;
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
yield {
|
||||||
|
type: "user",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "tool-plan", content: "no" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const prompt = agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "plan" }],
|
||||||
|
});
|
||||||
|
await waitFor(() => (session.turnQueue ?? []).some((turn) => !turn.settled));
|
||||||
|
expect(session.activeTurn).toBeFalsy();
|
||||||
|
|
||||||
|
await askExitPlan(agent);
|
||||||
|
expect(offersClearContext(requests[0])).toBe(true);
|
||||||
|
echo();
|
||||||
|
await prompt;
|
||||||
|
await session.consumer;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no clear-context option to an autonomous cycle while a prompt waits for its echo", async () => {
|
||||||
|
const { agent, requests } = rejectingAgent();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const iter = input[Symbol.asyncIterator]();
|
||||||
|
const { value: first } = await iter.next();
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
// A followup cycle that Claude Code starts on its own.
|
||||||
|
yield assistantText("followup");
|
||||||
|
const { value: second } = await iter.next();
|
||||||
|
await gate;
|
||||||
|
yield {
|
||||||
|
type: "user",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "tool-plan", content: "no" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield resultMessage({ origin: { kind: "task-notification" } });
|
||||||
|
yield idle();
|
||||||
|
yield userEcho(second);
|
||||||
|
yield running();
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const first = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "plan" }],
|
||||||
|
});
|
||||||
|
expect(first.stopReason).toBe("end_turn");
|
||||||
|
await waitFor(() => (agent as any).runsAutonomousCycle("test-session"));
|
||||||
|
const second = agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "next" }],
|
||||||
|
});
|
||||||
|
await waitFor(() => (session.turnQueue ?? []).some((turn) => !turn.settled));
|
||||||
|
|
||||||
|
// The queued prompt does not own the request of the running cycle.
|
||||||
|
await askExitPlan(agent);
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(offersClearContext(requests[0])).toBe(false);
|
||||||
|
release();
|
||||||
|
await expect(second).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||||
|
await session.consumer;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues an accepted plan of an interleaved followup in the turn that took it over", async () => {
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{ sessionUpdate: async () => {} } as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
let continuation: unknown;
|
||||||
|
const createSession = vi
|
||||||
|
.spyOn(agent as any, "createSession")
|
||||||
|
.mockImplementation(async (_params: any, options: any) => {
|
||||||
|
const input = new Pushable<any>();
|
||||||
|
async function* freshGenerator() {
|
||||||
|
const next = await input[Symbol.asyncIterator]().next();
|
||||||
|
continuation = next.value;
|
||||||
|
yield userEcho(next.value);
|
||||||
|
yield running();
|
||||||
|
yield assistantText("Implemented");
|
||||||
|
yield resultMessage({ stop_reason: "max_tokens" });
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
agent.sessions["test-session"] = mockSessionState({
|
||||||
|
query: wrapQuery(freshGenerator()),
|
||||||
|
input,
|
||||||
|
modes: { currentModeId: options.permissionMode, availableModes: [] },
|
||||||
|
fastModeEnabled: false,
|
||||||
|
});
|
||||||
|
return { sessionId: "test-session" };
|
||||||
|
});
|
||||||
|
injectGeneratorSession(
|
||||||
|
agent,
|
||||||
|
(input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||||
|
// The echo activates the turn, and a task-notification followup
|
||||||
|
// plans inside it.
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
yield {
|
||||||
|
type: "assistant",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool-plan",
|
||||||
|
name: "ExitPlanMode",
|
||||||
|
input: { plan: "Implement it" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: "user",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "tool-plan",
|
||||||
|
content: "User accepted the plan and requested a fresh context",
|
||||||
|
is_error: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tool_result_meta: [{ id: "tool-plan", non_execution_kind: "user-rejected" }],
|
||||||
|
};
|
||||||
|
// The result names no prompt, so it takes the autonomous lane.
|
||||||
|
yield resultMessage({
|
||||||
|
subtype: "error_during_execution",
|
||||||
|
is_error: true,
|
||||||
|
stop_reason: null,
|
||||||
|
errors: [
|
||||||
|
"[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use",
|
||||||
|
],
|
||||||
|
origin: { kind: "task-notification" },
|
||||||
|
});
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
creationParams: { cwd: "/test", mcpServers: [] },
|
||||||
|
pendingExitPlanContextReset: {
|
||||||
|
toolUseId: "tool-plan",
|
||||||
|
plan: "Implement it",
|
||||||
|
mode: "auto",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
|
||||||
|
const response = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "plan" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/test" }),
|
||||||
|
expect.objectContaining({ publicSessionId: "test-session", permissionMode: "auto" }),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(continuation)).toContain(
|
||||||
|
"Implement the following plan:\\n\\nImplement it",
|
||||||
|
);
|
||||||
|
expect(response.stopReason).toBe("max_tokens");
|
||||||
|
await agent.sessions["test-session"]?.consumer;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// CLI 2.1.270+ stays `running` while background agents live (verified
|
// CLI 2.1.270+ stays `running` while background agents live (verified
|
||||||
@@ -21856,7 +22286,10 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
|||||||
/** Turn 1 spawns agent-1, which fails inside the turn. Turn 2 resumes it
|
/** Turn 1 spawns agent-1, which fails inside the turn. Turn 2 resumes it
|
||||||
* with `resume` and holds at `gate` until the test releases it; then
|
* with `resume` and holds at `gate` until the test releases it; then
|
||||||
* agent-1 completes and the model sends its followup. */
|
* agent-1 completes and the model sends its followup. */
|
||||||
function run(resume: unknown[], options: { subagents?: boolean } = {}) {
|
function run(
|
||||||
|
resume: unknown[],
|
||||||
|
options: { subagents?: boolean; backgroundSubagents?: boolean } = {},
|
||||||
|
) {
|
||||||
const updates: AcpSessionNotification[] = [];
|
const updates: AcpSessionNotification[] = [];
|
||||||
const requests: RequestPermissionRequest[] = [];
|
const requests: RequestPermissionRequest[] = [];
|
||||||
const agent = new ClaudeAcpAgent(
|
const agent = new ClaudeAcpAgent(
|
||||||
@@ -21906,6 +22339,7 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
|||||||
clientCapabilities: AIR_NATIVE_SUBAGENTS,
|
clientCapabilities: AIR_NATIVE_SUBAGENTS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.backgroundSubagents) await declareBackgroundSubagents(agent);
|
||||||
const first = await agent.prompt({
|
const first = await agent.prompt({
|
||||||
sessionId: "test-session",
|
sessionId: "test-session",
|
||||||
prompt: [{ type: "text", text: "explore" }],
|
prompt: [{ type: "text", text: "explore" }],
|
||||||
@@ -21915,7 +22349,10 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
|||||||
sessionId: "test-session",
|
sessionId: "test-session",
|
||||||
prompt: [{ type: "text", text: "continue" }],
|
prompt: [{ type: "text", text: "continue" }],
|
||||||
});
|
});
|
||||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn?.deferredSettle);
|
// With `backgroundSubagents` the turn is never held.
|
||||||
|
if (!options.backgroundSubagents) {
|
||||||
|
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn?.deferredSettle);
|
||||||
|
}
|
||||||
// Wrapped: an async function would unwrap and wait for the held prompt.
|
// Wrapped: an async function would unwrap and wait for the held prompt.
|
||||||
return { second };
|
return { second };
|
||||||
};
|
};
|
||||||
@@ -22063,6 +22500,275 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
|||||||
release();
|
release();
|
||||||
await agent.sessions["test-session"]?.consumer;
|
await agent.sessions["test-session"]?.consumer;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("with backgroundSubagents", () => {
|
||||||
|
const summaryChunks = (updates: AcpSessionNotification[]) =>
|
||||||
|
updates.filter(
|
||||||
|
(n) =>
|
||||||
|
n.update.sessionUpdate === "agent_message_chunk" &&
|
||||||
|
(n.update.content as { text?: string }).text === "resumed summary",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("settles the SendMessage turn at its result while the resumed subagent runs", async () => {
|
||||||
|
const { agent, updates, release, notified, start } = run([sendMessageResult()], {
|
||||||
|
backgroundSubagents: true,
|
||||||
|
});
|
||||||
|
const { second } = await start();
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
await expect(second).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||||
|
expect(notified()).toBe(false);
|
||||||
|
expect(session.liveBackgroundTasks.has("agent-1")).toBe(true);
|
||||||
|
expect(session.activeTurn).toBeNull();
|
||||||
|
|
||||||
|
// The followup summary goes out after the prompt response.
|
||||||
|
release();
|
||||||
|
await waitFor(() => summaryChunks(updates).length === 1);
|
||||||
|
await waitFor(() => !session.liveBackgroundTasks.has("agent-1"));
|
||||||
|
await session.consumer;
|
||||||
|
expect(session.owedTrailingIdles).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a permission request of the resumed subagent after the prompt response", async () => {
|
||||||
|
const { agent, updates, requests, release, start } = run([sendMessageResult()], {
|
||||||
|
backgroundSubagents: true,
|
||||||
|
});
|
||||||
|
const { second } = await start();
|
||||||
|
await expect(second).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||||
|
|
||||||
|
const result = await agent.canUseTool("test-session")("Bash", { command: "ls" }, {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "toolu_sub",
|
||||||
|
agentID: "agent-1",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ behavior: "allow" });
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0].toolCall.toolCallId).toBe("toolu_sub");
|
||||||
|
expect(
|
||||||
|
updates.find(
|
||||||
|
(n) => n.update.sessionUpdate === "tool_call" && n.update.toolCallId === "toolu_sub",
|
||||||
|
)?.update,
|
||||||
|
).toMatchObject({ _meta: { claudeCode: { parentToolUseId: "toolu_agent-1" } } });
|
||||||
|
release();
|
||||||
|
await agent.sessions["test-session"]?.consumer;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with backgroundSubagents", () => {
|
||||||
|
/** Agent whose client records the chunk texts as `chunk:<text>`, keeps
|
||||||
|
* every update, and allows every permission request. */
|
||||||
|
const recordingAgent = () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const updates: AcpSessionNotification[] = [];
|
||||||
|
const requests: RequestPermissionRequest[] = [];
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{
|
||||||
|
sessionUpdate: async (n: AcpSessionNotification) => {
|
||||||
|
updates.push(n);
|
||||||
|
if (n.update.sessionUpdate === "agent_message_chunk") {
|
||||||
|
events.push(`chunk:${(n.update.content as { text?: string }).text}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestPermission: async (params: RequestPermissionRequest) => {
|
||||||
|
requests.push(params);
|
||||||
|
return { outcome: { outcome: "selected", optionId: "allow-once" } };
|
||||||
|
},
|
||||||
|
} as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
return { agent, events, updates, requests };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("settles at the result while a fresh subagent runs and sends its traffic after the response", async () => {
|
||||||
|
const { agent, events, updates, requests } = recordingAgent();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
yield subagentStarted("agent-1");
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
await gate;
|
||||||
|
yield taskNotification("agent-1");
|
||||||
|
yield assistantText("late summary");
|
||||||
|
yield resultMessage({ origin: { kind: "task-notification" } });
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const response = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "explore" }],
|
||||||
|
});
|
||||||
|
events.push("resolved");
|
||||||
|
|
||||||
|
expect(response.stopReason).toBe("end_turn");
|
||||||
|
expect(response.usage?.totalTokens).toBe(15);
|
||||||
|
expect(session.activeTurn).toBeNull();
|
||||||
|
expect(session.liveBackgroundTasks.get("agent-1")).toMatchObject({
|
||||||
|
parentToolUseId: "toolu_agent-1",
|
||||||
|
isSubagent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The subagent asks after the prompt response. The request keeps the
|
||||||
|
// parent tool call of the subagent, and the client answers it.
|
||||||
|
const result = await agent.canUseTool("test-session")("Bash", { command: "ls" }, {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "toolu_sub",
|
||||||
|
agentID: "agent-1",
|
||||||
|
} as any);
|
||||||
|
expect(result).toMatchObject({ behavior: "allow" });
|
||||||
|
expect(requests.map((r) => r.toolCall.toolCallId)).toEqual(["toolu_sub"]);
|
||||||
|
expect(
|
||||||
|
updates.find(
|
||||||
|
(n) => n.update.sessionUpdate === "tool_call" && n.update.toolCallId === "toolu_sub",
|
||||||
|
)?.update,
|
||||||
|
).toMatchObject({ _meta: { claudeCode: { parentToolUseId: "toolu_agent-1" } } });
|
||||||
|
|
||||||
|
// The followup summary is an out-of-turn session/update.
|
||||||
|
release();
|
||||||
|
await waitFor(() => events.includes("chunk:late summary"));
|
||||||
|
expect(events.indexOf("chunk:late summary")).toBeGreaterThan(events.indexOf("resolved"));
|
||||||
|
await session.consumer;
|
||||||
|
expect(session.liveBackgroundTasks.has("agent-1")).toBe(false);
|
||||||
|
expect(session.owedTrailingIdles).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the streamed tool input of a running subagent when the turn ends at its result", async () => {
|
||||||
|
const { agent, updates } = recordingAgent();
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||||
|
const childStream = (event: unknown) => ({
|
||||||
|
type: "stream_event",
|
||||||
|
parent_tool_use_id: "toolu_agent-1",
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "test-session",
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
yield subagentStarted("agent-1");
|
||||||
|
// The subagent starts to stream a tool use before the parent result.
|
||||||
|
yield childStream({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "tool_use", id: "toolu_child_read", name: "Read", input: {} },
|
||||||
|
});
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
await gate;
|
||||||
|
yield childStream({
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
type: "input_json_delta",
|
||||||
|
partial_json: '{"file_path":"/test/src/late.ts","offset":',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
yield taskNotification("agent-1");
|
||||||
|
yield resultMessage({ origin: { kind: "task-notification" } });
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const response = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "explore" }],
|
||||||
|
});
|
||||||
|
expect(response.stopReason).toBe("end_turn");
|
||||||
|
|
||||||
|
// The input delta after the prompt response still refines the tool call.
|
||||||
|
release();
|
||||||
|
await session.consumer;
|
||||||
|
expect(
|
||||||
|
updates.find(
|
||||||
|
(n) =>
|
||||||
|
n.update.sessionUpdate === "tool_call_update" &&
|
||||||
|
n.update.toolCallId === "toolu_child_read",
|
||||||
|
)?.update,
|
||||||
|
).toMatchObject({ locations: [{ path: "/test/src/late.ts" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not settle the next prompt at the followup result after the turn ended", async () => {
|
||||||
|
const { agent, events } = recordingAgent();
|
||||||
|
injectGeneratorSession(agent, (input) => {
|
||||||
|
async function* messageGenerator() {
|
||||||
|
const iter = input[Symbol.asyncIterator]();
|
||||||
|
const { value: first } = await iter.next();
|
||||||
|
yield userEcho(first);
|
||||||
|
yield running();
|
||||||
|
yield subagentStarted("agent-1");
|
||||||
|
yield resultMessage();
|
||||||
|
yield idle();
|
||||||
|
// The next prompt is queued before the subagent ends.
|
||||||
|
const { value: second } = await iter.next();
|
||||||
|
yield taskNotification("agent-1");
|
||||||
|
yield assistantText("late summary");
|
||||||
|
yield resultMessage({
|
||||||
|
origin: { kind: "task-notification" },
|
||||||
|
stop_reason: "max_tokens",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 100,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
yield idle();
|
||||||
|
yield userEcho(second);
|
||||||
|
yield running();
|
||||||
|
yield assistantText("second answer");
|
||||||
|
yield resultMessage({
|
||||||
|
usage: {
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 7,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
yield idle();
|
||||||
|
}
|
||||||
|
return messageGenerator();
|
||||||
|
});
|
||||||
|
await declareBackgroundSubagents(agent);
|
||||||
|
const session = agent.sessions["test-session"]!;
|
||||||
|
|
||||||
|
const first = await agent.prompt({
|
||||||
|
sessionId: "test-session",
|
||||||
|
prompt: [{ type: "text", text: "explore" }],
|
||||||
|
});
|
||||||
|
expect(first.stopReason).toBe("end_turn");
|
||||||
|
|
||||||
|
const second = await agent
|
||||||
|
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "next" }] })
|
||||||
|
.then((r) => {
|
||||||
|
events.push("resolved:second");
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The second prompt settles at its own result, with its own usage.
|
||||||
|
expect(second.stopReason).toBe("end_turn");
|
||||||
|
expect(second.usage?.totalTokens).toBe(27);
|
||||||
|
expect(events).toEqual(["chunk:late summary", "chunk:second answer", "resolved:second"]);
|
||||||
|
await session.consumer;
|
||||||
|
expect(session.owedTrailingIdles).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not fail a held turn when a followup errors while another subagent is live", async () => {
|
it("does not fail a held turn when a followup errors while another subagent is live", async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const AIR_CAPABILITY_NAMES = [
|
|||||||
"rawInputRendering",
|
"rawInputRendering",
|
||||||
"planFile",
|
"planFile",
|
||||||
"agentFileChangeReport",
|
"agentFileChangeReport",
|
||||||
|
"backgroundSubagents",
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseCapabilities: ClientCapabilities = {
|
const baseCapabilities: ClientCapabilities = {
|
||||||
|
|||||||
@@ -123,6 +123,29 @@ describe("incomplete foreground tools", () => {
|
|||||||
expect(logError).not.toHaveBeenCalled();
|
expect(logError).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("with backgroundSubagents, settles at the result without failing the running subagent's tool", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||||
|
const { agent, prompt, updates, logError } = createTestSession((input) =>
|
||||||
|
gatedBackgroundSubagentMessages(input, gate),
|
||||||
|
);
|
||||||
|
await agent.initialize({
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: {
|
||||||
|
_meta: { jetbrains: { air: { version: 1, capabilities: ["backgroundSubagents"] } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The turn is not held, so the check runs while the subagent still runs.
|
||||||
|
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||||
|
expect(agent.sessions[sessionId].liveBackgroundTasks.has("child")).toBe(true);
|
||||||
|
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
|
||||||
|
expect(logError).not.toHaveBeenCalled();
|
||||||
|
release();
|
||||||
|
await agent.sessions[sessionId].consumer;
|
||||||
|
expect(agent.sessions[sessionId].liveBackgroundTasks.has("child")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
|
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
|
||||||
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
|
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
|
||||||
|
|
||||||
@@ -1275,6 +1298,36 @@ async function* deferredSettlementMessages(input: Pushable<any>) {
|
|||||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tool call of a subagent that still runs in the background when the
|
||||||
|
* result arrives. Its tool result comes after the result. */
|
||||||
|
async function* gatedBackgroundSubagentMessages(input: Pushable<any>, gate: Promise<void>) {
|
||||||
|
yield* echoNextPrompt(input);
|
||||||
|
yield toolStart();
|
||||||
|
yield toolUse();
|
||||||
|
yield {
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_started",
|
||||||
|
session_id: sessionId,
|
||||||
|
task_id: "child",
|
||||||
|
tool_use_id: toolCallId,
|
||||||
|
subagent_type: "Explore",
|
||||||
|
description: "investigate",
|
||||||
|
};
|
||||||
|
yield successfulResultMessage();
|
||||||
|
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||||
|
await gate;
|
||||||
|
yield toolResult();
|
||||||
|
yield {
|
||||||
|
type: "system",
|
||||||
|
subtype: "task_notification",
|
||||||
|
session_id: sessionId,
|
||||||
|
task_id: "child",
|
||||||
|
tool_use_id: toolCallId,
|
||||||
|
status: "completed",
|
||||||
|
summary: "done",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
|
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
|
||||||
const messages = input[Symbol.asyncIterator]();
|
const messages = input[Symbol.asyncIterator]();
|
||||||
yield userEcho((await messages.next()).value);
|
yield userEcho((await messages.next()).value);
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ describe("sessionIndex negotiation", () => {
|
|||||||
"recommendedValue",
|
"recommendedValue",
|
||||||
"diffPatch",
|
"diffPatch",
|
||||||
"planFile",
|
"planFile",
|
||||||
|
"backgroundSubagents",
|
||||||
];
|
];
|
||||||
|
|
||||||
it("is advertised with sessionArchive and sessionRename only to an AIR client that declares it", async () => {
|
it("is advertised with sessionArchive and sessionRename only to an AIR client that declares it", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user