From 854db8ccdf08c540489df352300ad519860d9b11 Mon Sep 17 00:00:00 2001 From: Kirill Alekhnovich Date: Thu, 8 Oct 2026 14:25:01 +0200 Subject: [PATCH] fix: report plan readiness in file mode (IJAI-3109) AIR receives only a file path when it supports planFile. Report planReady when ExitPlanMode has complete input so AIR can capture the final plan before review. Repeat the signal on the permission subject when the complete report arrives later. Partial input and history replay keep the signal absent. Preserve the existing file-only payload and document compatibility with older clients and adapters. --- docs/air-extensions.md | 22 ++++++++++++++++++++++ src/air-extension.ts | 2 ++ src/tests/tool-call-contract.test.ts | 27 +++++++++++++++++++++++++++ src/tool-calls/renderer.ts | 21 ++++++++++++++++++--- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 63ea2934..1825f0b9 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -484,6 +484,28 @@ While the adapter does not know the path, a report carries no `planFilePath` key The adapter never sends a blank path, because AIR reads a present non-file value as a retraction. Without `rawInputRendering`, a `planFile` client gets no copy of the plan in `content` either. +### Ready for review + +The existing `planFile` capability also supports `_meta.jetbrains.air.planReady: true`. +The complete ExitPlanMode tool call or refinement sends this boolean with the file path. +The permission subject repeats it when the permission callback precedes the complete streamed report. +The field tracker can omit a repeated signal. +Partial input and history replay never send it. + +```json +{ + "rawInput": { "planFilePath": "/Users/me/.claude/plans/tidy-plan.md" }, + "_meta": { "jetbrains": { "air": { "version": 1, "planReady": true } } } +} +``` + +The file now holds the complete plan submitted for review. +AIR reads it once, saves the text, and shows the full plan without simulated streaming. +A revised plan uses a new tool call id, even when it uses the same file. +The adapter keeps the file unchanged while this review waits for a decision. +Older clients ignore the new metadata. +Older adapters can omit it; clients can read the file at the approval request instead. + ### Fallback The plan text goes out as before in these cases: diff --git a/src/air-extension.ts b/src/air-extension.ts index c4bc7418..1b20e3a3 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -11,6 +11,8 @@ export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering"; * file, and AIR reads the plan from it. */ export const AIR_PLAN_FILE_CAPABILITY = "planFile"; +/** The plan file holds the complete plan submitted for review. */ +export const AIR_PLAN_READY_KEY = "planReady"; /** * The session index: a bounded, ordered `session/list` with row metadata, and * `_session/rename`, `_session/archive`, `_session/unarchive` and the list diff --git a/src/tests/tool-call-contract.test.ts b/src/tests/tool-call-contract.test.ts index 4ee713e6..ab59d7dd 100644 --- a/src/tests/tool-call-contract.test.ts +++ b/src/tests/tool-call-contract.test.ts @@ -399,6 +399,7 @@ describe("the ACP tool call contract", () => { { content: "User has approved your plan.\n\n## Approved Plan:\n1. Do it" }, ); expect(call.rawInput).toEqual({ planFilePath }); + expect(call._meta).toHaveProperty("jetbrains.air.planReady", true); expect(call.content).toEqual([]); expect(updates[0]).toMatchObject({ status: "completed", @@ -408,6 +409,32 @@ describe("the ACP tool call contract", () => { expect(JSON.stringify([call, updates])).not.toContain("Do it"); }); + it("marks a complete plan ready, but not its partial input or replay", () => { + const toolUse = { + id: "review", + name: "ExitPlanMode", + input: { plan: "1. Do it", planFilePath }, + }; + const renderer = AcpToolCallRenderer.for(planFileAir); + expect(renderer.toolCall(toolUse, { inputComplete: false })._meta).not.toHaveProperty( + "jetbrains.air.planReady", + ); + expect(renderer.partialRefinement(toolUse, toolUse.input)._meta).not.toHaveProperty( + "jetbrains.air.planReady", + ); + expect(renderer.refinement(toolUse)._meta).toHaveProperty("jetbrains.air.planReady", true); + expect(renderer.permissionToolCall(toolUse)._meta).toHaveProperty( + "jetbrains.air.planReady", + true, + ); + expect( + AcpToolCallRenderer.for(planFileAir, true).refinement(toolUse)._meta, + ).not.toHaveProperty("jetbrains.air.planReady"); + expect(AcpToolCallRenderer.for(terminalAir).refinement(toolUse)._meta).not.toHaveProperty( + "jetbrains.air.planReady", + ); + }); + it("clears the plan file for AIR in a call of its own when the plan is approved", () => { const { updates } = report( planFileAir, diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index 24eee410..375c0df0 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -8,6 +8,7 @@ import type { } from "@agentclientprotocol/sdk"; import { AIR_COMMAND_TITLE_KEY, + AIR_PLAN_READY_KEY, AIR_SKILL_KEY, AIR_SUBAGENT_KEY, withAirMeta, @@ -201,7 +202,11 @@ export class AcpToolCallRenderer { const isMcp = toolUse.name.startsWith("mcp__"); return { _meta: { - ...this.toolUseMeta(toolUse, options.cwd), + ...this.planReadyMeta( + this.toolUseMeta(toolUse, options.cwd), + facts, + options.inputComplete !== false, + ), ...(facts.command && this.capabilities.terminalOutput ? { terminal_info: { terminal_id: toolUse.id } } : {}), @@ -228,7 +233,7 @@ export class AcpToolCallRenderer { refinement(toolUse: ToolUse, cwd?: string): ToolCallUpdate { const facts = this.facts(toolUse, cwd); return { - _meta: this.toolUseMeta(toolUse, cwd), + _meta: this.planReadyMeta(this.toolUseMeta(toolUse, cwd), facts), toolCallId: toolUse.id, sessionUpdate: "tool_call_update", rawInput: this.rawInput(facts, toolUse.input), @@ -304,7 +309,7 @@ export class AcpToolCallRenderer { ...(options.extraLocations?.length ? { locations: [...(facts.locations ?? []), ...options.extraLocations] } : {}), - ...(options.meta ? { _meta: options.meta } : {}), + _meta: this.planReadyMeta(options.meta, facts), }; } @@ -681,6 +686,16 @@ export class AcpToolCallRenderer { ]; } + /** Marks a complete file plan before the client presents its approval. */ + private planReadyMeta( + meta: ToolUpdateMeta | undefined, + facts: ToolUseFacts, + inputComplete = true, + ): ToolUpdateMeta | undefined { + if (!inputComplete || this.replay || !facts.planFilePath) return meta; + return withAirMeta(meta, AIR_PLAN_READY_KEY, true) as ToolUpdateMeta; + } + /** * The `_meta` of a tool use report: the tool name, and for AIR the tool * flags under `_meta.jetbrains.air`. A shell description stays out of the