From 45374e9dfec6348ce4fdd473bbb2df4e1698d73b Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 15:55:08 +0100 Subject: [PATCH] feat: send structured plans as items plan_update when the client supports it v1 clients that advertise clientCapabilities.plan now get the update_plan checklist as an items plan_update with the fixed planId codex-structured-plan, as v2 clients already do. Clients without the capability keep the legacy plan update. Adds docs/plans.md. --- README.md | 2 +- docs/air-extensions.md | 2 +- docs/plans.md | 61 +++++++++++++++++++ src/AcpV2SessionUpdate.ts | 9 +-- src/CodexEventHandler.ts | 7 +-- src/CodexPlanStream.ts | 18 ++++++ .../data/plan-checklist-items-update.json | 27 ++++++++ .../CodexACPAgent/plan-events.test.ts | 14 +++-- .../data/air/plan-deltas-and-turn-plan.jsonl | 2 +- 9 files changed, 123 insertions(+), 19 deletions(-) create mode 100644 docs/plans.md create mode 100644 src/__tests__/CodexACPAgent/data/plan-checklist-items-update.json diff --git a/README.md b/README.md index e9b6cb596..916215500 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - Model, reasoning effort, fast mode, approval, sandbox mode, and configured [Codex permission profile](docs/permission-profiles.md) selection. - Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/air-extensions.md#recommended-config-values) capability. - Text prompts, embedded context, images, resource links, and additional workspace directories. -- Shell command, file change, [permission request](docs/air-extensions.md#permission-presentation), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. +- Shell command, file change, [permission request](docs/air-extensions.md#permission-presentation), MCP tool call, terminal output, reasoning, [plan](docs/plans.md), web search, image generation, image view, token usage, and review events. - Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch). - For AIR, one tool call shape with each fact in one field, as the [tool call contract](docs/air-extensions.md#tool-call-contract) defines. Other clients keep the earlier fields. - [Native ACP subagent sessions](docs/subagent-sessions.md) (after capability negotiation) with separate child histories and root-routed permissions; a legacy tool-call fallback otherwise. diff --git a/docs/air-extensions.md b/docs/air-extensions.md index c22a0d1b1..b849bd95c 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -357,7 +357,7 @@ The other clients get the same fields as before the AIR extensions. | `webSearch` | `kind: search`, a title that names the query or the page, `rawInput = {query, action}`. | A live report has `rawInput = {type, id, query, action}`. | | `imageGeneration` | `kind: other`, `title: "Image generation"`. The revised prompt and the image go to `content`. A saved image without data goes to `content` as a resource link. | The start has `rawInput = {id}`. The end has `rawOutput = {status, revisedPrompt, result, savedPath}`, and no resource link. | | `plan` item (the Markdown plan of plan mode) | With `planContentDelta`, appended text in `plan_update`. | `plan_update` snapshots when the client shows plans. Otherwise the whole plan in one `agent_message_chunk` when the plan item completes. | -| Turn plan (`turn/plan/updated`) | standard `plan` with entries | The same. | +| Turn plan (`turn/plan/updated`) | `plan_update` with an `items` plan when the client shows plans, otherwise standard `plan` with entries. See [Plans](plans.md). | The same. | | `contextCompaction` | `compaction_update` when the client declares `session.compaction`. Otherwise a synthetic tool call with `_meta.jetbrains.air.contextCompaction`. | The same, without `_meta`. | | `agentMessage` | `agent_message_chunk` with `_meta.jetbrains.air.phase` when Codex reports a phase. | No `_meta`. | | Command, file change, or sandbox permission request | See [Tool call of the request](#tool-call-of-the-request). | `kind`, `status: pending`, and a title, also for a started tool call. Command approvals keep the started title or show the command; other requests use a generic title or a network title. No `_meta`. | diff --git a/docs/plans.md b/docs/plans.md new file mode 100644 index 000000000..4d311c9a9 --- /dev/null +++ b/docs/plans.md @@ -0,0 +1,61 @@ +# Plans + +Codex produces two kinds of plans: + +- **Checklist plans** from the `update_plan` tool (`turn/plan/updated`). Each update + replaces the whole list. A session has at most one, so it always uses the + `planId` `codex-structured-plan`. +- **Markdown plans** that Codex writes in plan mode (the `plan` item). The + `planId` is the Codex item id. + +The adapter follows the [plan operations RFD](https://agentclientprotocol.com/rfds/plan-operations). +On v1, a client opts in to `plan_update` with `clientCapabilities.plan: {}`. On v2, +every client gets `plan_update`, and no capability is needed. + +## With plan updates + +A checklist update is an `items` plan: + +```json +{ + "sessionUpdate": "plan_update", + "plan": { + "type": "items", + "planId": "codex-structured-plan", + "entries": [ + {"content": "Read code", "priority": "medium", "status": "completed"}, + {"content": "Change code", "priority": "medium", "status": "in_progress"} + ] + } +} +``` + +Codex has no step priority, so every entry has `priority: "medium"`. + +A Markdown plan is sent as `markdown` snapshots while Codex writes it, at most +one every 150 ms. The last snapshot has the text of the completed item. +Session replay sends one snapshot for each saved plan. + +```json +{ + "sessionUpdate": "plan_update", + "plan": {"type": "markdown", "planId": "item-id", "content": "# Plan\n\n1. Read."} +} +``` + +AIR clients with `planContentDelta` get the appended text instead of whole +snapshots; see [AIR extensions](air-extensions.md#plan-content-delta-and-plan-review). + +## Without plan updates + +A v1 client without `clientCapabilities.plan` gets: + +- checklist plans as the legacy `plan` update with the same `entries`; +- each Markdown plan as one `agent_message_chunk` with the item id as + `messageId`, when the plan item completes. Replay sends the same chunk. + +## Not sent + +The adapter does not send `plan_removed`, because Codex has no event that ends +a plan. It does not send `file` plans, because Codex streams plan text rather +than writing a plan file. Codex's `explanation` of a checklist update is not sent. diff --git a/src/AcpV2SessionUpdate.ts b/src/AcpV2SessionUpdate.ts index 4153a3e86..7ea56de5b 100644 --- a/src/AcpV2SessionUpdate.ts +++ b/src/AcpV2SessionUpdate.ts @@ -4,14 +4,9 @@ import {randomUUID} from "node:crypto"; import * as path from "node:path"; import type {AcpSessionUpdate} from "./AcpSessionExtensions"; import {toV2ConfigOptions} from "./AcpV2ConfigOptions"; +import {STRUCTURED_PLAN_ID} from "./CodexPlanStream"; import {stripShellPrefix} from "./CommandUtils"; -/** - * `planId` of the structured (tool-driven) plan on v2. A session has at most one such plan and - * every update replaces it, so a constant id is stable for the whole session. - */ -export const STRUCTURED_PLAN_ID = "codex-structured-plan"; - export function toV2AvailableCommands(commands: acp.AvailableCommand[]): acpV2.AvailableCommand[] { return commands.map((command) => { const {input, ...withoutInput} = command; @@ -71,6 +66,8 @@ export function toV2SessionUpdate(update: AcpSessionUpdate): acpV2.SessionUpdate return update; case "config_option_update": return {...update, configOptions: toV2ConfigOptions(update.configOptions)}; + // v2 clients always get plan updates, so the event handler already sends `plan_update`. + // The legacy shape is still mapped so that it never reaches a v2 client. case "plan": { const {entries, _meta} = update; return { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 7814edc87..837e7123c 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -47,7 +47,7 @@ import {ImageGenerationReporter} from "./tool-calls/reporters/ImageGenerationRep import {ImageViewReporter} from "./tool-calls/reporters/ImageViewReporter"; import {McpToolReporter} from "./tool-calls/reporters/McpToolReporter"; import {WebSearchReporter} from "./tool-calls/reporters/WebSearchReporter"; -import {CodexPlanStream} from "./CodexPlanStream"; +import {CodexPlanStream, structuredPlanUpdate} from "./CodexPlanStream"; import {isWorkspaceRoutingUnavailableMessage} from "./CodexThreadErrors"; import { createMessagePhaseMeta, @@ -1087,10 +1087,7 @@ export class CodexEventHandler { priority: "medium" }) ); - return { - sessionUpdate: "plan", - entries: plan, - } + return structuredPlanUpdate(plan, this.sessionState.clientCapabilities); } private async createErrorEvent(params: ErrorNotification): Promise { diff --git a/src/CodexPlanStream.ts b/src/CodexPlanStream.ts index e666a59aa..c113cac4c 100644 --- a/src/CodexPlanStream.ts +++ b/src/CodexPlanStream.ts @@ -1,9 +1,27 @@ +import type {PlanEntry} from "@agentclientprotocol/sdk/unstable"; import type {ACPSessionConnection, UpdateSessionEvent} from "./ACPSessionConnection"; import {AIR_CONTENT_DELTA_KEY, withAirMeta} from "./AirExtension"; import {createAgentTextMessageChunk, createMessagePhaseMeta} from "./ContentChunks"; import {logger} from "./Logger"; import type {ClientCapabilities} from "./tool-calls/ClientCapabilities"; +/** + * `planId` of the structured (`update_plan` tool) plan. A session has at most one such plan and + * every update replaces it, so a constant id is stable for the whole session. + */ +export const STRUCTURED_PLAN_ID = "codex-structured-plan"; + +/** + * Reports the structured plan. A client with plan updates gets an `items` `plan_update`, + * so all of its plans come as `plan_update`. Other clients get the legacy `plan` update. + */ +export function structuredPlanUpdate(entries: PlanEntry[], capabilities: ClientCapabilities): UpdateSessionEvent { + if (capabilities.planUpdates) { + return {sessionUpdate: "plan_update", plan: {type: "items", planId: STRUCTURED_PLAN_ID, entries}}; + } + return {sessionUpdate: "plan", entries}; +} + /** * Streams the Markdown plan that Codex writes in plan mode. * diff --git a/src/__tests__/CodexACPAgent/data/plan-checklist-items-update.json b/src/__tests__/CodexACPAgent/data/plan-checklist-items-update.json new file mode 100644 index 000000000..d3771b323 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/plan-checklist-items-update.json @@ -0,0 +1,27 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "plan_update", + "plan": { + "type": "items", + "planId": "codex-structured-plan", + "entries": [ + { + "status": "completed", + "content": "Add the event mapping", + "priority": "medium" + }, + { + "status": "in_progress", + "content": "Verify it in Zed", + "priority": "medium" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/plan-events.test.ts b/src/__tests__/CodexACPAgent/plan-events.test.ts index 1ac252b40..1d90dec47 100644 --- a/src/__tests__/CodexACPAgent/plan-events.test.ts +++ b/src/__tests__/CodexACPAgent/plan-events.test.ts @@ -152,7 +152,10 @@ describe("CodexEventHandler - plan events", () => { ); }); - it("keeps turn plan updates as ACP checklist updates", async () => { + it.each([ + {name: "a legacy plan update without the plan capability", capabilities: undefined, snapshot: "plan-checklist-update.json"}, + {name: "an items plan_update with the plan capability", capabilities: {plan: {}}, snapshot: "plan-checklist-items-update.json"}, + ])("sends turn plan updates as $name", async ({capabilities, snapshot}) => { const notifications: ServerNotification[] = [ { method: "turn/plan/updated", @@ -174,11 +177,12 @@ describe("CodexEventHandler - plan events", () => { }, ]; - await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + await setupPromptAndSendNotifications(mockFixture, sessionId, { + ...sessionState, + clientCapabilities: ClientCapabilities.from(capabilities), + }, notifications); - await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( - "data/plan-checklist-update.json", - ); + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot(`data/${snapshot}`); }); describe("plan update coalescing", () => { diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 84cb96c7d..d3b4ecf3f 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -3,6 +3,6 @@ {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n1. Do the thing.","planId":"plan-1","type":"markdown"},"sessionUpdate":"plan_update"}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"entries":[{"content":"Read code","priority":"medium","status":"completed"},{"content":"Change code","priority":"medium","status":"in_progress"}],"sessionUpdate":"plan"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"entries":[{"content":"Read code","priority":"medium","status":"completed"},{"content":"Change code","priority":"medium","status":"in_progress"}],"planId":"codex-structured-plan","type":"items"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} {"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}}