Plan mode: stream proposed plans, emit plan items, and render in TUI (#9786)

## Summary
- Stream proposed plans in Plan Mode using `<proposed_plan>` tags parsed
in core, emitting plan deltas plus a plan `ThreadItem`, while stripping
tags from normal assistant output.
- Persist plan items and rebuild them on resume so proposed plans show
in thread history.
- Wire plan items/deltas through app-server protocol v2 and render a
dedicated proposed-plan view in the TUI, including the “Implement this
plan?” prompt only when a plan item is present.

## Changes

### Core (`codex-rs/core`)
- Added a generic, line-based tag parser that buffers each line until it
can disprove a tag prefix; implements auto-close on `finish()` for
unterminated tags. `codex-rs/core/src/tagged_block_parser.rs`
- Refactored proposed plan parsing to wrap the generic parser.
`codex-rs/core/src/proposed_plan_parser.rs`
- In plan mode, stream assistant deltas as:
  - **Normal text** → `AgentMessageContentDelta`
  - **Plan text** → `PlanDelta` + `TurnItem::Plan` start/completion  
  (`codex-rs/core/src/codex.rs`)
- Final plan item content is derived from the completed assistant
message (authoritative), not necessarily the concatenated deltas.
- Strips `<proposed_plan>` blocks from assistant text in plan mode so
tags don’t appear in normal messages.
(`codex-rs/core/src/stream_events_utils.rs`)
- Persist `ItemCompleted` events only for plan items for rollout replay.
(`codex-rs/core/src/rollout/policy.rs`)
- Guard `update_plan` tool in Plan Mode with a clear error message.
(`codex-rs/core/src/tools/handlers/plan.rs`)
- Updated Plan Mode prompt to:  
  - keep `<proposed_plan>` out of non-final reasoning/preambles  
  - require exact tag formatting  
  - allow only one `<proposed_plan>` block per turn  
  (`codex-rs/core/templates/collaboration_mode/plan.md`)

### Protocol / App-server protocol
- Added `TurnItem::Plan` and `PlanDeltaEvent` to core protocol items.
(`codex-rs/protocol/src/items.rs`, `codex-rs/protocol/src/protocol.rs`)
- Added v2 `ThreadItem::Plan` and `PlanDeltaNotification` with
EXPERIMENTAL markers and note that deltas may not match the final plan
item. (`codex-rs/app-server-protocol/src/protocol/v2.rs`)
- Added plan delta route in app-server protocol common mapping.
(`codex-rs/app-server-protocol/src/protocol/common.rs`)
- Rebuild plan items from persisted `ItemCompleted` events on resume.
(`codex-rs/app-server-protocol/src/protocol/thread_history.rs`)

### App-server
- Forward plan deltas to v2 clients and map core plan items to v2 plan
items. (`codex-rs/app-server/src/bespoke_event_handling.rs`,
`codex-rs/app-server/src/codex_message_processor.rs`)
- Added v2 plan item tests.
(`codex-rs/app-server/tests/suite/v2/plan_item.rs`)

### TUI
- Added a dedicated proposed plan history cell with special background
and padding, and moved “• Proposed Plan” outside the highlighted block.
(`codex-rs/tui/src/history_cell.rs`, `codex-rs/tui/src/style.rs`)
- Only show “Implement this plan?” when a plan item exists.
(`codex-rs/tui/src/chatwidget.rs`,
`codex-rs/tui/src/chatwidget/tests.rs`)

<img width="831" height="847" alt="Screenshot 2026-01-29 at 7 06 24 PM"
src="https://github.com/user-attachments/assets/69794c8c-f96b-4d36-92ef-c1f5c3a8f286"
/>

### Docs / Misc
- Updated protocol docs to mention plan deltas.
(`codex-rs/docs/protocol_v1.md`)
- Minor plumbing updates in exec/debug clients to tolerate plan deltas.
(`codex-rs/debug-client/src/reader.rs`, `codex-rs/exec/...`)

## Tests
- Added core integration tests:
  - Plan mode strips plan from agent messages.
  - Missing `</proposed_plan>` closes at end-of-message.  
  (`codex-rs/core/tests/suite/items.rs`)
- Added unit tests for generic tag parser (prefix buffering, non-tag
lines, auto-close). (`codex-rs/core/src/tagged_block_parser.rs`)
- Existing app-server plan item tests in v2.
(`codex-rs/app-server/tests/suite/v2/plan_item.rs`)

## Notes / Behavior
- Plan output no longer appears in standard assistant text in Plan Mode;
it streams via `PlanDelta` and completes as a `TurnItem::Plan`.
- The final plan item content is authoritative and may diverge from
streamed deltas (documented as experimental).
- Reasoning summaries are not filtered; prompt instructs the model not
to include `<proposed_plan>` outside the final plan message.

## Codex Author
`codex fork 019bec2d-b09d-7450-b292-d7bcdddcdbfb`
This commit is contained in:
Charley Cunningham
2026-01-30 10:59:30 -08:00
committed by GitHub
parent 40bf11bd52
commit ec4a2d07e4
36 changed files with 2021 additions and 42 deletions

View File

@@ -20,6 +20,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::ItemCompletedEvent;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
@@ -33,6 +34,7 @@ use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::WarningEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_core::web_search::web_search_detail;
use codex_protocol::items::TurnItem;
use codex_protocol::num_format::format_with_separators;
use owo_colors::OwoColorize;
use owo_colors::Style;
@@ -73,6 +75,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
last_message_path: Option<PathBuf>,
last_total_token_usage: Option<codex_core::protocol::TokenUsageInfo>,
final_message: Option<String>,
last_proposed_plan: Option<String>,
}
impl EventProcessorWithHumanOutput {
@@ -99,6 +102,7 @@ impl EventProcessorWithHumanOutput {
last_message_path,
last_total_token_usage: None,
final_message: None,
last_proposed_plan: None,
}
} else {
Self {
@@ -116,6 +120,7 @@ impl EventProcessorWithHumanOutput {
last_message_path,
last_total_token_usage: None,
final_message: None,
last_proposed_plan: None,
}
}
}
@@ -260,12 +265,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
);
}
EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => {
let last_message = last_agent_message.as_deref();
let last_message = last_agent_message
.as_deref()
.or(self.last_proposed_plan.as_deref());
if let Some(output_file) = self.last_message_path.as_deref() {
handle_last_message(last_message, output_file);
}
self.final_message = last_agent_message;
self.final_message = last_agent_message.or_else(|| self.last_proposed_plan.clone());
return CodexStatus::InitiateShutdown;
}
@@ -297,6 +304,12 @@ impl EventProcessor for EventProcessorWithHumanOutput {
message,
);
}
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::Plan(item),
..
}) => {
self.last_proposed_plan = Some(item.text);
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
eprint!(
"{}\n{} in {}",
@@ -769,6 +782,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::SkillsUpdateAvailable

View File

@@ -58,6 +58,7 @@ use tracing::warn;
pub struct EventProcessorWithJsonOutput {
last_message_path: Option<PathBuf>,
last_proposed_plan: Option<String>,
next_event_id: AtomicU64,
// Tracks running commands by call_id, including the associated item id.
running_commands: HashMap<String, RunningCommand>,
@@ -102,6 +103,7 @@ impl EventProcessorWithJsonOutput {
pub fn new(last_message_path: Option<PathBuf>) -> Self {
Self {
last_message_path,
last_proposed_plan: None,
next_event_id: AtomicU64::new(0),
running_commands: HashMap::new(),
running_patch_applies: HashMap::new(),
@@ -119,6 +121,13 @@ impl EventProcessorWithJsonOutput {
protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev),
protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(),
protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev),
protocol::EventMsg::ItemCompleted(protocol::ItemCompletedEvent {
item: codex_protocol::items::TurnItem::Plan(item),
..
}) => {
self.last_proposed_plan = Some(item.text.clone());
Vec::new()
}
protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev),
protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev),
protocol::EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev),
@@ -855,7 +864,10 @@ impl EventProcessor for EventProcessorWithJsonOutput {
last_agent_message,
}) => {
if let Some(output_file) = self.last_message_path.as_deref() {
handle_last_message(last_agent_message.as_deref(), output_file);
let last_message = last_agent_message
.as_deref()
.or(self.last_proposed_plan.as_deref());
handle_last_message(last_message, output_file);
}
CodexStatus::InitiateShutdown
}