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 () => {