Rename plan tool types to todo and dual-emit todo/plan

This commit is contained in:
Charles Cunningham
2026-01-29 11:09:15 -08:00
parent b0f1b9f622
commit 525e0d7f3c
15 changed files with 313 additions and 176 deletions
+13 -10
View File
@@ -17,8 +17,6 @@ use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg;
use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus;
use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
@@ -36,6 +34,8 @@ use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
use codex_protocol::todo_tool::TodoItemArg as CoreTodoItemArg;
use codex_protocol::todo_tool::TodoStatus as CoreTodoStatus;
use codex_protocol::user_input::ByteRange as CoreByteRange;
use codex_protocol::user_input::TextElement as CoreTextElement;
use codex_protocol::user_input::UserInput as CoreUserInput;
@@ -2256,11 +2256,14 @@ pub struct TurnDiffUpdatedNotification {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
/// Todo list update from the todo_write tool. The plan name is legacy.
/// Todo list update from the todo_write tool. `todo` is preferred; `plan` is a legacy alias.
pub struct TurnPlanUpdatedNotification {
pub thread_id: String,
pub turn_id: String,
pub explanation: Option<String>,
#[serde(default)]
pub todo: Vec<TurnPlanStep>,
#[serde(default)]
pub plan: Vec<TurnPlanStep>,
}
@@ -2281,8 +2284,8 @@ pub enum TurnPlanStepStatus {
Completed,
}
impl From<CorePlanItemArg> for TurnPlanStep {
fn from(value: CorePlanItemArg) -> Self {
impl From<CoreTodoItemArg> for TurnPlanStep {
fn from(value: CoreTodoItemArg) -> Self {
Self {
step: value.step,
status: value.status.into(),
@@ -2290,12 +2293,12 @@ impl From<CorePlanItemArg> for TurnPlanStep {
}
}
impl From<CorePlanStepStatus> for TurnPlanStepStatus {
fn from(value: CorePlanStepStatus) -> Self {
impl From<CoreTodoStatus> for TurnPlanStepStatus {
fn from(value: CoreTodoStatus) -> Self {
match value {
CorePlanStepStatus::Pending => Self::Pending,
CorePlanStepStatus::InProgress => Self::InProgress,
CorePlanStepStatus::Completed => Self::Completed,
CoreTodoStatus::Pending => Self::Pending,
CoreTodoStatus::InProgress => Self::InProgress,
CoreTodoStatus::Completed => Self::Completed,
}
}
}
+1 -1
View File
@@ -433,7 +433,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
- `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`.
- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`.
- `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items.
- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its todo list; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. (The plan-named event and field are legacy and are unrelated to Plan mode.)
- `turn/plan/updated` — `{ turnId, explanation?, todo, plan }` whenever the agent shares or changes its todo list; each entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. `todo` is preferred and `plan` is a deprecated legacy alias; both are emitted for compatibility. (The plan-named event and field are legacy and are unrelated to Plan mode.)
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
@@ -87,10 +87,10 @@ use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse;
use codex_protocol::todo_tool::UpdateTodoArgs;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::path::PathBuf;
@@ -1143,21 +1143,20 @@ async fn handle_turn_diff(
async fn handle_turn_todo_update(
conversation_id: ThreadId,
event_turn_id: &str,
todo_update_event: UpdatePlanArgs,
todo_update_event: UpdateTodoArgs,
api_version: ApiVersion,
outgoing: &OutgoingMessageSender,
) {
if let ApiVersion::V2 = api_version {
// NOTE: The protocol keeps plan-named fields/events for compatibility; this is the todo list update.
let (explanation, todo_items) = todo_update_event.into_parts();
let steps: Vec<TurnPlanStep> = todo_items.into_iter().map(TurnPlanStep::from).collect();
let notification = TurnPlanUpdatedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.to_string(),
explanation: todo_update_event.explanation,
plan: todo_update_event
.plan
.into_iter()
.map(TurnPlanStep::from)
.collect(),
explanation,
todo: steps.clone(),
plan: steps,
};
outgoing
.send_server_notification(ServerNotification::TurnPlanUpdated(notification))
@@ -1813,8 +1812,8 @@ mod tests {
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::todo_tool::TodoItemArg;
use codex_protocol::todo_tool::TodoStatus;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
@@ -1992,19 +1991,19 @@ mod tests {
async fn test_handle_turn_todo_update_emits_notification_for_v2() -> Result<()> {
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = OutgoingMessageSender::new(tx);
let update = UpdatePlanArgs {
explanation: Some("need todo list".to_string()),
plan: vec![
PlanItemArg {
let update = UpdateTodoArgs::new(
Some("need todo list".to_string()),
vec![
TodoItemArg {
step: "first".to_string(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
},
PlanItemArg {
TodoItemArg {
step: "second".to_string(),
status: StepStatus::Completed,
status: TodoStatus::Completed,
},
],
};
);
let conversation_id = ThreadId::new();
@@ -2026,6 +2025,7 @@ mod tests {
assert_eq!(n.thread_id, conversation_id.to_string());
assert_eq!(n.turn_id, "turn-123");
assert_eq!(n.explanation.as_deref(), Some("need todo list"));
assert_eq!(n.todo, n.plan);
assert_eq!(n.plan.len(), 2);
assert_eq!(n.plan[0].step, "first");
assert_eq!(n.plan[0].status, TurnPlanStepStatus::Pending);
+8 -6
View File
@@ -10,8 +10,8 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::spec::JsonSchema;
use async_trait::async_trait;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::EventMsg;
use codex_protocol::todo_tool::UpdateTodoArgs;
use std::collections::BTreeMap;
use std::sync::LazyLock;
@@ -41,20 +41,22 @@ pub static TODO_WRITE_TOOL: LazyLock<ToolSpec> = LazyLock::new(|| {
"explanation".to_string(),
JsonSchema::String { description: None },
);
// NOTE: The "plan" field name is legacy tool payload shape, unrelated to Plan mode.
properties.insert("todo".to_string(), todo_items_schema.clone());
// NOTE: The "plan" field name is a deprecated legacy alias, unrelated to Plan mode.
properties.insert("plan".to_string(), todo_items_schema);
ToolSpec::Function(ResponsesApiTool {
name: "todo_write".to_string(),
description: r#"Updates the task list.
Provide an optional explanation and a list of todo items, each with a step and status.
Provide an optional explanation and a list of todo items in `todo`, each with a step and status.
The legacy `plan` field is still accepted.
At most one step can be in_progress at a time.
"#
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),
required: Some(vec!["todo".to_string()]),
additional_properties: Some(false.into()),
},
})
@@ -112,8 +114,8 @@ pub(crate) async fn handle_todo_write(
Ok("Todo list updated".to_string())
}
fn parse_todo_write_arguments(arguments: &str) -> Result<UpdatePlanArgs, FunctionCallError> {
serde_json::from_str::<UpdatePlanArgs>(arguments).map_err(|e| {
fn parse_todo_write_arguments(arguments: &str) -> Result<UpdateTodoArgs, FunctionCallError> {
serde_json::from_str::<UpdateTodoArgs>(arguments).map_err(|e| {
FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}"))
})
}
+8 -7
View File
@@ -9,7 +9,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::todo_tool::TodoStatus;
use codex_protocol::user_input::UserInput;
use core_test_support::assert_regex_match;
use core_test_support::responses;
@@ -124,7 +124,7 @@ async fn todo_write_tool_emits_todo_update_event() -> anyhow::Result<()> {
let call_id = "todo-tool-call";
let todo_args = json!({
"explanation": "Tool harness check",
"plan": [
"todo": [
{"step": "Inspect workspace", "status": "in_progress"},
{"step": "Report results", "status": "pending"},
],
@@ -169,11 +169,12 @@ async fn todo_write_tool_emits_todo_update_event() -> anyhow::Result<()> {
EventMsg::PlanUpdate(update) => {
saw_todo_update = true;
assert_eq!(update.explanation.as_deref(), Some("Tool harness check"));
assert_eq!(update.plan.len(), 2);
assert_eq!(update.plan[0].step, "Inspect workspace");
assert_matches!(update.plan[0].status, StepStatus::InProgress);
assert_eq!(update.plan[1].step, "Report results");
assert_matches!(update.plan[1].status, StepStatus::Pending);
let todo_items = update.todo_items();
assert_eq!(todo_items.len(), 2);
assert_eq!(todo_items[0].step, "Inspect workspace");
assert_matches!(todo_items[0].status, TodoStatus::InProgress);
assert_eq!(todo_items[1].step, "Report results");
assert_matches!(todo_items[1].status, TodoStatus::Pending);
false
}
EventMsg::TurnComplete(_) => true,
@@ -45,8 +45,7 @@ use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::handle_last_message;
use codex_common::create_config_summary_entries;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::todo_tool::TodoStatus;
/// This should be configurable. When used in CI, users may not want to impose
/// a limit so they can see the full transcript.
@@ -539,10 +538,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
eprintln!();
}
EventMsg::PlanUpdate(todo_update_event) => {
let UpdatePlanArgs {
explanation,
plan: todo_items,
} = todo_update_event;
let (explanation, todo_items) = todo_update_event.into_parts();
// Header
ts_msg!(self, "{}", "Todo list update".style(self.magenta));
@@ -557,13 +553,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
// Pretty-print the todo items with simple status markers.
for todo_item in todo_items {
match todo_item.status {
StepStatus::Completed => {
TodoStatus::Completed => {
ts_msg!(self, " {} {}", "✓".style(self.green), todo_item.step);
}
StepStatus::InProgress => {
TodoStatus::InProgress => {
ts_msg!(self, " {} {}", "→".style(self.cyan), todo_item.step);
}
StepStatus::Pending => {
TodoStatus::Pending => {
ts_msg!(
self,
" {} {}",
@@ -50,8 +50,8 @@ use codex_core::protocol::CollabCloseEndEvent;
use codex_core::protocol::CollabWaitingBeginEvent;
use codex_core::protocol::CollabWaitingEndEvent;
use codex_protocol::models::WebSearchAction;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::todo_tool::TodoStatus;
use codex_protocol::todo_tool::UpdateTodoArgs;
use serde_json::Value as JsonValue;
use tracing::error;
use tracing::warn;
@@ -700,17 +700,17 @@ impl EventProcessorWithJsonOutput {
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
}
fn todo_items_from_update(&self, args: &UpdatePlanArgs) -> Vec<TodoItem> {
args.plan
fn todo_items_from_update(&self, args: &UpdateTodoArgs) -> Vec<TodoItem> {
args.todo_items()
.iter()
.map(|p| TodoItem {
text: p.step.clone(),
completed: matches!(p.status, StepStatus::Completed),
completed: matches!(p.status, TodoStatus::Completed),
})
.collect()
}
fn handle_todo_update(&mut self, args: &UpdatePlanArgs) -> Vec<ThreadEvent> {
fn handle_todo_update(&mut self, args: &UpdateTodoArgs) -> Vec<ThreadEvent> {
let items = self.todo_items_from_update(args);
if let Some(running) = &mut self.running_todo_list {
@@ -56,12 +56,12 @@ use codex_exec::exec_events::Usage;
use codex_exec::exec_events::WebSearchItem;
use codex_protocol::ThreadId;
use codex_protocol::models::WebSearchAction;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::todo_tool::TodoItemArg;
use codex_protocol::todo_tool::TodoStatus;
use codex_protocol::todo_tool::UpdateTodoArgs;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
@@ -226,19 +226,19 @@ fn todo_update_emits_todo_list_started_updated_and_completed() {
// First todo_write update (PlanUpdate event) => item.started (todo_list)
let first = event(
"p1",
EventMsg::PlanUpdate(UpdatePlanArgs {
explanation: None,
plan: vec![
PlanItemArg {
EventMsg::PlanUpdate(UpdateTodoArgs::new(
None,
vec![
TodoItemArg {
step: "step one".to_string(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
},
PlanItemArg {
TodoItemArg {
step: "step two".to_string(),
status: StepStatus::InProgress,
status: TodoStatus::InProgress,
},
],
}),
)),
);
let out_first = ep.collect_thread_events(&first);
assert_eq!(
@@ -265,19 +265,19 @@ fn todo_update_emits_todo_list_started_updated_and_completed() {
// Second todo_write update (PlanUpdate event) => item.updated (same id)
let second = event(
"p2",
EventMsg::PlanUpdate(UpdatePlanArgs {
explanation: None,
plan: vec![
PlanItemArg {
EventMsg::PlanUpdate(UpdateTodoArgs::new(
None,
vec![
TodoItemArg {
step: "step one".to_string(),
status: StepStatus::Completed,
status: TodoStatus::Completed,
},
PlanItemArg {
TodoItemArg {
step: "step two".to_string(),
status: StepStatus::InProgress,
status: TodoStatus::InProgress,
},
],
}),
)),
);
let out_second = ep.collect_thread_events(&second);
assert_eq!(
@@ -666,13 +666,13 @@ fn todo_update_after_complete_starts_new_todo_list_with_new_id() {
// First turn: start + complete
let start = event(
"t1",
EventMsg::PlanUpdate(UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
EventMsg::PlanUpdate(UpdateTodoArgs::new(
None,
vec![TodoItemArg {
step: "only".to_string(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
}],
}),
)),
);
let _ = ep.collect_thread_events(&start);
let complete = event(
@@ -686,13 +686,13 @@ fn todo_update_after_complete_starts_new_todo_list_with_new_id() {
// Second turn: a new todo list should have a new id
let start_again = event(
"t3",
EventMsg::PlanUpdate(UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
EventMsg::PlanUpdate(UpdateTodoArgs::new(
None,
vec![TodoItemArg {
step: "again".to_string(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
}],
}),
)),
);
let out = ep.collect_thread_events(&start_again);
+1
View File
@@ -14,4 +14,5 @@ pub mod parse_command;
pub mod plan_tool;
pub mod protocol;
pub mod request_user_input;
pub mod todo_tool;
pub mod user_input;
+8 -30
View File
@@ -1,30 +1,8 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
// Types for the todo_write tool arguments matching codex-vscode/todo-mcp/src/main.rs.
// The plan-named types and fields are legacy payload shape and are not related to Plan mode.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
pub struct PlanItemArg {
pub step: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
pub struct UpdatePlanArgs {
#[serde(default)]
pub explanation: Option<String>,
/// Legacy field name for todo items; kept for compatibility.
pub plan: Vec<PlanItemArg>,
}
// Deprecated compatibility layer for the old plan_* names. These are todo list
// types and not related to Plan mode.
#[deprecated(note = "use codex_protocol::todo_tool::TodoItemArg")]
pub use crate::todo_tool::TodoItemArg as PlanItemArg;
#[deprecated(note = "use codex_protocol::todo_tool::TodoStatus")]
pub use crate::todo_tool::TodoStatus as StepStatus;
#[deprecated(note = "use codex_protocol::todo_tool::UpdateTodoArgs")]
pub use crate::todo_tool::UpdateTodoArgs as UpdatePlanArgs;
+4 -3
View File
@@ -29,8 +29,8 @@ use crate::models::WebSearchAction;
use crate::num_format::format_with_separators;
use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
use crate::request_user_input::RequestUserInputResponse;
use crate::todo_tool::UpdateTodoArgs;
use crate::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use mcp_types::CallToolResult;
@@ -810,8 +810,9 @@ pub enum EventMsg {
/// Notification that skill data may have been updated and clients may want to reload.
SkillsUpdateAvailable,
/// Todo list update from the todo_write tool. The plan name is legacy.
PlanUpdate(UpdatePlanArgs),
/// Todo list update from the todo_write tool. Payload uses `todo` (preferred)
/// and also emits `plan` as a legacy alias.
PlanUpdate(UpdateTodoArgs),
TurnAborted(TurnAbortedEvent),
+158
View File
@@ -0,0 +1,158 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use serde::de;
use serde::ser::SerializeStruct;
use ts_rs::TS;
// Types for the todo_write tool arguments matching codex-vscode/todo-mcp/src/main.rs.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TodoItemArg {
pub step: String,
pub status: TodoStatus,
}
/// Arguments for the todo_write tool.
///
/// The `plan` field is a deprecated legacy alias for `todo`, but is still emitted for
/// backward compatibility.
#[derive(Debug, Clone, JsonSchema, TS, PartialEq, Eq, Default)]
pub struct UpdateTodoArgs {
pub explanation: Option<String>,
pub todo: Vec<TodoItemArg>,
pub plan: Vec<TodoItemArg>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct UpdateTodoArgsRaw {
#[serde(default)]
explanation: Option<String>,
#[serde(default)]
todo: Option<Vec<TodoItemArg>>,
#[serde(default)]
plan: Option<Vec<TodoItemArg>>,
}
impl UpdateTodoArgs {
pub fn new(explanation: Option<String>, todo: Vec<TodoItemArg>) -> Self {
let plan = todo.clone();
Self {
explanation,
todo,
plan,
}
}
pub fn todo_items(&self) -> &[TodoItemArg] {
if self.todo.is_empty() {
&self.plan
} else {
&self.todo
}
}
pub fn into_todo_items(self) -> Vec<TodoItemArg> {
if self.todo.is_empty() {
self.plan
} else {
self.todo
}
}
pub fn into_parts(self) -> (Option<String>, Vec<TodoItemArg>) {
let UpdateTodoArgs {
explanation,
todo,
plan,
} = self;
let items = if todo.is_empty() { plan } else { todo };
(explanation, items)
}
}
impl Serialize for UpdateTodoArgs {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let items = self.todo_items();
let mut state = serializer.serialize_struct("UpdateTodoArgs", 3)?;
if let Some(explanation) = &self.explanation {
state.serialize_field("explanation", explanation)?;
}
state.serialize_field("todo", items)?;
state.serialize_field("plan", items)?;
state.end()
}
}
impl<'de> Deserialize<'de> for UpdateTodoArgs {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = UpdateTodoArgsRaw::deserialize(deserializer)?;
let items = match (raw.todo, raw.plan) {
(Some(todo), Some(plan)) => {
if todo.is_empty() {
plan
} else {
todo
}
}
(Some(todo), None) => todo,
(None, Some(plan)) => plan,
(None, None) => return Err(de::Error::missing_field("todo")),
};
Ok(UpdateTodoArgs::new(raw.explanation, items))
}
}
#[cfg(test)]
mod tests {
use super::TodoItemArg;
use super::TodoStatus;
use super::UpdateTodoArgs;
use pretty_assertions::assert_eq;
#[test]
fn deserializes_legacy_plan_field() {
let args: UpdateTodoArgs =
serde_json::from_str(r#"{"explanation":"x","plan":[{"step":"a","status":"pending"}]}"#)
.expect("legacy plan field should parse");
let expected = UpdateTodoArgs::new(
Some("x".to_string()),
vec![TodoItemArg {
step: "a".to_string(),
status: TodoStatus::Pending,
}],
);
assert_eq!(args, expected);
}
#[test]
fn serializes_todo_and_plan_fields() {
let args = UpdateTodoArgs::new(
None,
vec![TodoItemArg {
step: "a".to_string(),
status: TodoStatus::Completed,
}],
);
let value = serde_json::to_value(&args).expect("args should serialize");
assert_eq!(value.get("todo"), value.get("plan"));
}
}
+2 -2
View File
@@ -208,7 +208,7 @@ use codex_core::protocol::SandboxPolicy;
use codex_file_search::FileMatch;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::todo_tool::UpdateTodoArgs;
use strum::IntoEnumIterator;
const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally";
@@ -1324,7 +1324,7 @@ impl ChatWidget {
Some(combined)
}
fn on_todo_update(&mut self, update: UpdatePlanArgs) {
fn on_todo_update(&mut self, update: UpdateTodoArgs) {
self.saw_todo_update_this_turn = true;
self.add_to_history(history_cell::new_todo_update(update));
}
+23 -23
View File
@@ -70,10 +70,10 @@ use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::todo_tool::TodoItemArg;
use codex_protocol::todo_tool::TodoStatus;
use codex_protocol::todo_tool::UpdateTodoArgs;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -1279,13 +1279,13 @@ async fn plan_implementation_popup_shows_on_todo_update_without_message() {
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_todo_update(UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
chat.on_todo_update(UpdateTodoArgs::new(
None,
vec![TodoItemArg {
step: "First".to_string(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
}],
});
));
chat.on_task_complete(None, false);
let popup = render_bottom_popup(&chat, 80);
@@ -1307,13 +1307,13 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() {
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_todo_update(UpdatePlanArgs {
explanation: None,
plan: vec![PlanItemArg {
chat.on_todo_update(UpdateTodoArgs::new(
None,
vec![TodoItemArg {
step: "First".to_string(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
}],
});
));
chat.on_rate_limit_snapshot(Some(snapshot(92.0)));
chat.on_task_complete(None, false);
@@ -4406,23 +4406,23 @@ async fn apply_patch_request_shows_diff_summary() -> anyhow::Result<()> {
#[tokio::test]
async fn todo_update_renders_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let update = UpdatePlanArgs {
explanation: Some("Adapting plan".to_string()),
plan: vec![
PlanItemArg {
let update = UpdateTodoArgs::new(
Some("Adapting plan".to_string()),
vec![
TodoItemArg {
step: "Explore codebase".into(),
status: StepStatus::Completed,
status: TodoStatus::Completed,
},
PlanItemArg {
TodoItemArg {
step: "Implement feature".into(),
status: StepStatus::InProgress,
status: TodoStatus::InProgress,
},
PlanItemArg {
TodoItemArg {
step: "Write tests".into(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
},
],
};
);
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::PlanUpdate(update),
+29 -32
View File
@@ -46,9 +46,9 @@ use codex_core::protocol::SessionConfiguredEvent;
use codex_core::web_search::web_search_detail;
use codex_protocol::models::WebSearchAction;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::todo_tool::TodoItemArg;
use codex_protocol::todo_tool::TodoStatus;
use codex_protocol::todo_tool::UpdateTodoArgs;
use codex_protocol::user_input::TextElement;
use image::DynamicImage;
use image::ImageReader;
@@ -1728,11 +1728,8 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
}
/// Render a user-friendly todo list update styled like a checkbox list.
pub(crate) fn new_todo_update(update: UpdatePlanArgs) -> TodoUpdateCell {
let UpdatePlanArgs {
explanation,
plan: todo_items,
} = update;
pub(crate) fn new_todo_update(update: UpdateTodoArgs) -> TodoUpdateCell {
let (explanation, todo_items) = update.into_parts();
TodoUpdateCell {
explanation,
todo_items,
@@ -1742,7 +1739,7 @@ pub(crate) fn new_todo_update(update: UpdatePlanArgs) -> TodoUpdateCell {
#[derive(Debug)]
pub(crate) struct TodoUpdateCell {
explanation: Option<String>,
todo_items: Vec<PlanItemArg>,
todo_items: Vec<TodoItemArg>,
}
impl HistoryCell for TodoUpdateCell {
@@ -1755,11 +1752,11 @@ impl HistoryCell for TodoUpdateCell {
.collect()
};
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
let render_step = |status: &TodoStatus, text: &str| -> Vec<Line<'static>> {
let (box_str, step_style) = match status {
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
StepStatus::Pending => ("□ ", Style::default().dim()),
TodoStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
TodoStatus::InProgress => ("□ ", Style::default().cyan().bold()),
TodoStatus::Pending => ("□ ", Style::default().dim()),
};
let wrap_width = (width as usize)
.saturating_sub(4)
@@ -1789,7 +1786,7 @@ impl HistoryCell for TodoUpdateCell {
if self.todo_items.is_empty() {
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
} else {
for PlanItemArg { step, status } in self.todo_items.iter() {
for TodoItemArg { step, status } in self.todo_items.iter() {
indented_lines.extend(render_step(status, step));
}
}
@@ -2895,26 +2892,26 @@ mod tests {
#[test]
fn plan_update_with_note_and_wrapping_snapshot() {
// Long explanation forces wrapping; include long step text to verify step wrapping and alignment.
let update = UpdatePlanArgs {
explanation: Some(
let update = UpdateTodoArgs::new(
Some(
"I’ll update Grafana call error handling by adding retries and clearer messages when the backend is unreachable."
.to_string(),
),
plan: vec![
PlanItemArg {
vec![
TodoItemArg {
step: "Investigate existing error paths and logging around HTTP timeouts".into(),
status: StepStatus::Completed,
status: TodoStatus::Completed,
},
PlanItemArg {
TodoItemArg {
step: "Harden Grafana client error handling with retry/backoff and user‑friendly messages".into(),
status: StepStatus::InProgress,
status: TodoStatus::InProgress,
},
PlanItemArg {
TodoItemArg {
step: "Add tests for transient failure scenarios and surfacing to the UI".into(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
},
],
};
);
let cell = new_todo_update(update);
// Narrow width to force wrapping for both the note and steps
@@ -2925,19 +2922,19 @@ mod tests {
#[test]
fn plan_update_without_note_snapshot() {
let update = UpdatePlanArgs {
explanation: None,
plan: vec![
PlanItemArg {
let update = UpdateTodoArgs::new(
None,
vec![
TodoItemArg {
step: "Define error taxonomy".into(),
status: StepStatus::InProgress,
status: TodoStatus::InProgress,
},
PlanItemArg {
TodoItemArg {
step: "Implement mapping to user messages".into(),
status: StepStatus::Pending,
status: TodoStatus::Pending,
},
],
};
);
let cell = new_todo_update(update);
let lines = cell.display_lines(40);