Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
61 changes: 61 additions & 0 deletions docs/plans.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 3 additions & 6 deletions src/AcpV2SessionUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 2 additions & 5 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<UpdateSessionEvent | null> {
Expand Down
18 changes: 18 additions & 0 deletions src/CodexPlanStream.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down
27 changes: 27 additions & 0 deletions src/__tests__/CodexACPAgent/data/plan-checklist-items-update.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
}
]
}
14 changes: 9 additions & 5 deletions src/__tests__/CodexACPAgent/plan-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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", () => {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading