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.
This commit is contained in:
Kirill Alekhnovich
2026-10-08 14:25:01 +02:00
parent 77910dea74
commit 854db8ccdf
4 changed files with 69 additions and 3 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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