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