From 9b3a5c891c75e78b985adb31f4e4b59a16ad2ebc Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Thu, 22 Oct 2026 15:31:05 -0700 Subject: [PATCH] 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. --- docs/air-extensions.md | 44 +- src/acp-agent.ts | 148 +++- src/air-extension.ts | 5 + src/permissions/options/shared.ts | 3 + src/permissions/options/tools.ts | 2 +- src/tests/acp-agent.test.ts | 710 +++++++++++++++++- .../air/session-load-replay.jsonl | 2 +- .../__snapshots__/air/session-setup.jsonl | 2 +- src/tests/acp-scenarios/harness.ts | 1 + src/tests/incomplete-tools.test.ts | 53 ++ src/tests/session-index.test.ts | 1 + 11 files changed, 949 insertions(+), 22 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 4bbd6956..3797c742 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -27,6 +27,7 @@ It describes only this adapter. - [Session failure](#session-failure) - [Native subagent sessions](#native-subagent-sessions) - [Session index](#session-index) +- [Background subagents](#background-subagents) - [Context compaction](#context-compaction) - [Question custom answers](#question-custom-answers) - [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", "recommendedValue", "diffPatch", - "planFile" + "planFile", + "backgroundSubagents" ], "goal": { "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) | | `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) | +| `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) | | `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) | @@ -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; - 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 The adapter implements the ACP session compaction RFD for a client that declares `clientCapabilities.session.compaction`. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 8eee9892..955aab42 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -127,6 +127,7 @@ import { } from "./native-subagents.js"; import { AIR_ASYNC_TASKS_CAPABILITY, + AIR_BACKGROUND_SUBAGENTS_CAPABILITY, AIR_CUSTOM_INSTRUCTIONS_CAPABILITY, airCustomInstructions, AIR_DIFF_PATCH_CAPABILITY, @@ -858,7 +859,9 @@ type Turn = { * names only a launching subagent), so a spawn made by a * PREVIOUS turn's followup chain while a later turn happens to be held * 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; /** Set instead of settling when the turn's terminal result arrives while * subagents it spawned are still live (`spawnedTaskIds` ∩ @@ -869,6 +872,17 @@ type Turn = { * issue #866), and the model's task-notification followup summary all * 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 * NOT held for background agents (observed on 2.1.206: `idle` follows * 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 * turn's settlement is deferred (see `Turn.deferredSettle`), so 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 * 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 @@ -1392,7 +1408,9 @@ export type Session = { * agent id without a new `task_started` (a running `task_updated` patch or * a SendMessage `resumedAgentId`). The resume signal puts the subagent back * 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. */ resumableSubagents?: Map; /** 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 * held for background subagents it spawned (see Turn.deferredSettle). The * 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 } { return turn != null && turn.deferredSettle !== undefined && !turn.settled; } @@ -2716,6 +2735,19 @@ export class ClaudeAcpAgent { 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( client: AcpClient, logger?: Logger, @@ -2978,6 +3010,8 @@ export class ClaudeAcpAgent { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, + // ACP v2 keeps the hold (see `src/v2/prompt.ts`). + ...(this.v2 ? [] : [AIR_BACKGROUND_SUBAGENTS_CAPABILITY]), ...this.sessionIndex.capabilities(), ), AIR_GOAL_KEY, @@ -4468,10 +4502,27 @@ export class ClaudeAcpAgent { // Tool-use blocks start streaming before their JSON input. Keep the // partial input per parent message and block index so completed top-level // 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 // parent_tool_use_id that never recurs) has no boundary event of its own. 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(); + 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, // max_tokens, …). Reset per turn; read when the turn settles at idle. let stopReason: StopReason = "end_turn"; @@ -5068,9 +5119,10 @@ export class ClaudeAcpAgent { /** Registers a live background task (see `liveBackgroundTasks`). A * subagent is also recorded on the active turn, so that turn holds until - * the subagent settles (see `Turn.spawnedTaskIds`). Its requests may - * already be open, and where it runs decides whether they block the - * foreground work, so that is checked again. */ + * the subagent settles (see `Turn.spawnedTaskIds`). A client with + * `backgroundSubagents` gets no hold (see `turnAwaitingSubagents`). + * Its requests may already be open, and where it runs decides + * whether they block the foreground work, so that is checked again. */ const registerLiveTask = ( taskId: string, parentToolUseId: string | undefined, @@ -5124,8 +5176,9 @@ export class ClaudeAcpAgent { /** 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 - * holds until the subagent settles again. A repeated resume signal finds - * the subagent live and changes nothing. */ + * holds until the subagent settles again, unless the client declared + * `backgroundSubagents`. A repeated resume signal finds the subagent live + * and changes nothing. */ const resumeLiveTask = (taskId: string) => { if (session.liveBackgroundTasks.has(taskId)) return; const settled = session.resumableSubagents?.get(taskId); @@ -5136,9 +5189,12 @@ export class ClaudeAcpAgent { /** Whether any background subagent this turn spawned is still live — * while true, the turn's settlement stays deferred so the subagent's * 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) => { - if (!turn.spawnedTaskIds?.size) { + if (this.endsTurnsWithLiveSubagents() || !turn.spawnedTaskIds?.size) { return false; } 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 that can have spawned subagents must route through here: a site * 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) => { // 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 @@ -5480,7 +5537,7 @@ export class ClaudeAcpAgent { disarmForceCancel(session); session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn); session.activeTurn = null; - streamedToolInputs.clear(); + sweepStreamedToolInputs(result.stopReason); if (wasHeld) { // Settling a held turn is its delivery-stretch boundary: the turn's // answer finished long ago, so text streamed since the last boundary @@ -5514,7 +5571,7 @@ export class ClaudeAcpAgent { turn.settled = true; session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn); session.activeTurn = null; - streamedToolInputs.clear(); + sweepStreamedToolInputs(); // 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 — // start the next stretch clean, or its stale delivery record would @@ -7156,6 +7213,43 @@ export class ClaudeAcpAgent { await settleOrDefer({ ...heldTurn.deferredSettle, stopReason: "cancelled" }); 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 // already queued with ONE model call: every queued // 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 // inside the turn. `session/cancel` and the next prompt's echo // 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 // 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; } + /** 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 * `signal` through as a `cancellationSignal`. When the turn is cancelled * while the client's prompt is still open the signal aborts, the SDK sends @@ -10233,6 +10343,12 @@ export class ClaudeAcpAgent { defaultToNo, availableModes: this.sessionModes.availableModeIds(session.modes), 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: session.contextUsedTokens === undefined || session.contextWindowSize <= 0 ? undefined diff --git a/src/air-extension.ts b/src/air-extension.ts index d8c86aa4..553ddd55 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -1,5 +1,10 @@ export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions"; 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_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue"; export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions"; diff --git a/src/permissions/options/shared.ts b/src/permissions/options/shared.ts index 17ad76f8..1829670e 100644 --- a/src/permissions/options/shared.ts +++ b/src/permissions/options/shared.ts @@ -30,6 +30,9 @@ export interface PermissionOptionContext { /** The mode the session left when it entered plan mode. */ prePlanMode?: string; 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 { diff --git a/src/permissions/options/tools.ts b/src/permissions/options/tools.ts index 7a1d596f..ce735607 100644 --- a/src/permissions/options/tools.ts +++ b/src/permissions/options/tools.ts @@ -70,7 +70,7 @@ export function buildExitPlanModePermissionOptions( : (["auto", "bypassPermissions"] as const) ).filter((mode) => modes.has(mode)); const preferredMode = elevatedModes[0] ?? "acceptEdits"; - if (plainString(context.input.plan)) { + if (context.allowContextReset !== false && plainString(context.input.plan)) { const usage = context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`; options.push( diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 8f142820..66b0b986 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -12994,6 +12994,7 @@ describe("logout", () => { "recommendedValue", "diffPatch", "planFile", + "backgroundSubagents", ], goal: { version: 1, controlMethod: GOAL_CONTROL_METHOD, actions: ["set", "clear"] }, }); @@ -13017,6 +13018,7 @@ describe("logout", () => { "recommendedValue", "diffPatch", "planFile", + "backgroundSubagents", ], 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 // subagents are done — at the followup's terminal result, or at an idle // 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() { 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. 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((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((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((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((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(); + 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 @@ -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 * with `resume` and holds at `gate` until the test releases it; then * 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 requests: RequestPermissionRequest[] = []; const agent = new ClaudeAcpAgent( @@ -21906,6 +22339,7 @@ describe("deferred settlement for live background subagents (issues #864/#866)", clientCapabilities: AIR_NATIVE_SUBAGENTS, }); } + if (options.backgroundSubagents) await declareBackgroundSubagents(agent); const first = await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "explore" }], @@ -21915,7 +22349,10 @@ describe("deferred settlement for live background subagents (issues #864/#866)", sessionId: "test-session", 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. return { second }; }; @@ -22063,6 +22500,275 @@ describe("deferred settlement for live background subagents (issues #864/#866)", release(); 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:`, 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((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((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 () => { diff --git a/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl b/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl index ce330589..7143b8b3 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl @@ -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":""},"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":""},"authMethods":[],"protocolVersion":1}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl b/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl index 572597ec..ada64ce5 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl @@ -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":""},"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":""},"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":""}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/tests/acp-scenarios/harness.ts b/src/tests/acp-scenarios/harness.ts index 0ad28ce6..a21499f8 100644 --- a/src/tests/acp-scenarios/harness.ts +++ b/src/tests/acp-scenarios/harness.ts @@ -58,6 +58,7 @@ export const AIR_CAPABILITY_NAMES = [ "rawInputRendering", "planFile", "agentFileChangeReport", + "backgroundSubagents", ]; const baseCapabilities: ClientCapabilities = { diff --git a/src/tests/incomplete-tools.test.ts b/src/tests/incomplete-tools.test.ts index 177d551c..2d630f4c 100644 --- a/src/tests/incomplete-tools.test.ts +++ b/src/tests/incomplete-tools.test.ts @@ -123,6 +123,29 @@ describe("incomplete foreground tools", () => { 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((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 () => { const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages); @@ -1275,6 +1298,36 @@ async function* deferredSettlementMessages(input: Pushable) { 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, gate: Promise) { + 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) { const messages = input[Symbol.asyncIterator](); yield userEcho((await messages.next()).value); diff --git a/src/tests/session-index.test.ts b/src/tests/session-index.test.ts index bba489cd..087a955a 100644 --- a/src/tests/session-index.test.ts +++ b/src/tests/session-index.test.ts @@ -271,6 +271,7 @@ describe("sessionIndex negotiation", () => { "recommendedValue", "diffPatch", "planFile", + "backgroundSubagents", ]; it("is advertised with sessionArchive and sessionRename only to an AIR client that declares it", async () => {