## 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`
245 lines
7 KiB
Rust
245 lines
7 KiB
Rust
use crate::models::WebSearchAction;
|
|
use crate::protocol::AgentMessageEvent;
|
|
use crate::protocol::AgentReasoningEvent;
|
|
use crate::protocol::AgentReasoningRawContentEvent;
|
|
use crate::protocol::ContextCompactedEvent;
|
|
use crate::protocol::EventMsg;
|
|
use crate::protocol::UserMessageEvent;
|
|
use crate::protocol::WebSearchEndEvent;
|
|
use crate::user_input::ByteRange;
|
|
use crate::user_input::TextElement;
|
|
use crate::user_input::UserInput;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use ts_rs::TS;
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
#[serde(tag = "type")]
|
|
#[ts(tag = "type")]
|
|
pub enum TurnItem {
|
|
UserMessage(UserMessageItem),
|
|
AgentMessage(AgentMessageItem),
|
|
Plan(PlanItem),
|
|
Reasoning(ReasoningItem),
|
|
WebSearch(WebSearchItem),
|
|
ContextCompaction(ContextCompactionItem),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct UserMessageItem {
|
|
pub id: String,
|
|
pub content: Vec<UserInput>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
#[serde(tag = "type")]
|
|
#[ts(tag = "type")]
|
|
pub enum AgentMessageContent {
|
|
Text { text: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct AgentMessageItem {
|
|
pub id: String,
|
|
pub content: Vec<AgentMessageContent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct PlanItem {
|
|
pub id: String,
|
|
pub text: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct ReasoningItem {
|
|
pub id: String,
|
|
pub summary_text: Vec<String>,
|
|
#[serde(default)]
|
|
pub raw_content: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
|
|
pub struct WebSearchItem {
|
|
pub id: String,
|
|
pub query: String,
|
|
pub action: WebSearchAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
|
pub struct ContextCompactionItem {
|
|
pub id: String,
|
|
}
|
|
|
|
impl ContextCompactionItem {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
EventMsg::ContextCompacted(ContextCompactedEvent {})
|
|
}
|
|
}
|
|
|
|
impl Default for ContextCompactionItem {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl UserMessageItem {
|
|
pub fn new(content: &[UserInput]) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
content: content.to_vec(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
// Legacy user-message events flatten only text inputs into `message` and
|
|
// rebase text element ranges onto that concatenated text.
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
message: self.message(),
|
|
images: Some(self.image_urls()),
|
|
local_images: self.local_image_paths(),
|
|
text_elements: self.text_elements(),
|
|
})
|
|
}
|
|
|
|
pub fn message(&self) -> String {
|
|
self.content
|
|
.iter()
|
|
.map(|c| match c {
|
|
UserInput::Text { text, .. } => text.clone(),
|
|
_ => String::new(),
|
|
})
|
|
.collect::<Vec<String>>()
|
|
.join("")
|
|
}
|
|
|
|
pub fn text_elements(&self) -> Vec<TextElement> {
|
|
let mut out = Vec::new();
|
|
let mut offset = 0usize;
|
|
for input in &self.content {
|
|
if let UserInput::Text {
|
|
text,
|
|
text_elements,
|
|
} = input
|
|
{
|
|
// Text element ranges are relative to each text chunk; offset them so they align
|
|
// with the concatenated message returned by `message()`.
|
|
for elem in text_elements {
|
|
let byte_range = ByteRange {
|
|
start: offset + elem.byte_range.start,
|
|
end: offset + elem.byte_range.end,
|
|
};
|
|
out.push(TextElement::new(
|
|
byte_range,
|
|
elem.placeholder(text).map(str::to_string),
|
|
));
|
|
}
|
|
offset += text.len();
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn image_urls(&self) -> Vec<String> {
|
|
self.content
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
UserInput::Image { image_url } => Some(image_url.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
|
|
self.content
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
UserInput::LocalImage { path } => Some(path.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl AgentMessageItem {
|
|
pub fn new(content: &[AgentMessageContent]) -> Self {
|
|
Self {
|
|
id: uuid::Uuid::new_v4().to_string(),
|
|
content: content.to_vec(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_events(&self) -> Vec<EventMsg> {
|
|
self.content
|
|
.iter()
|
|
.map(|c| match c {
|
|
AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent {
|
|
message: text.clone(),
|
|
}),
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl ReasoningItem {
|
|
pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
|
|
let mut events = Vec::new();
|
|
for summary in &self.summary_text {
|
|
events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
|
|
text: summary.clone(),
|
|
}));
|
|
}
|
|
|
|
if show_raw_agent_reasoning {
|
|
for entry in &self.raw_content {
|
|
events.push(EventMsg::AgentReasoningRawContent(
|
|
AgentReasoningRawContentEvent {
|
|
text: entry.clone(),
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
events
|
|
}
|
|
}
|
|
|
|
impl WebSearchItem {
|
|
pub fn as_legacy_event(&self) -> EventMsg {
|
|
EventMsg::WebSearchEnd(WebSearchEndEvent {
|
|
call_id: self.id.clone(),
|
|
query: self.query.clone(),
|
|
action: self.action.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TurnItem {
|
|
pub fn id(&self) -> String {
|
|
match self {
|
|
TurnItem::UserMessage(item) => item.id.clone(),
|
|
TurnItem::AgentMessage(item) => item.id.clone(),
|
|
TurnItem::Plan(item) => item.id.clone(),
|
|
TurnItem::Reasoning(item) => item.id.clone(),
|
|
TurnItem::WebSearch(item) => item.id.clone(),
|
|
TurnItem::ContextCompaction(item) => item.id.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec<EventMsg> {
|
|
match self {
|
|
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
|
|
TurnItem::AgentMessage(item) => item.as_legacy_events(),
|
|
TurnItem::Plan(_) => Vec::new(),
|
|
TurnItem::WebSearch(item) => vec![item.as_legacy_event()],
|
|
TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning),
|
|
TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()],
|
|
}
|
|
}
|
|
}
|