From 598bbcdb5842452a4cce5dfec972e5ad4c66b0d9 Mon Sep 17 00:00:00 2001 From: friel-openai Date: Tue, 28 Apr 2026 08:46:13 -0700 Subject: [PATCH] Preserve assistant phase for replayed messages (#19832) --- codex-rs/core/src/codex_thread.rs | 8 +++++- codex-rs/core/src/goals.rs | 2 ++ codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mod.rs | 2 ++ codex-rs/core/src/session/tests.rs | 9 ++++++- codex-rs/core/src/tasks/user_shell.rs | 11 ++++++++- codex-rs/protocol/src/models.rs | 35 +++++++++++++++++++++++++-- codex-rs/protocol/src/protocol.rs | 1 + 8 files changed, 64 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 47f1b99ce..a2f64e86f 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -456,9 +456,15 @@ impl CodexThread { fn pending_message_input_item(message: &ResponseItem) -> CodexResult { match message { - ResponseItem::Message { role, content, .. } => Ok(ResponseInputItem::Message { + ResponseItem::Message { + role, + content, + phase, + .. + } => Ok(ResponseInputItem::Message { role: role.clone(), content: content.clone(), + phase: phase.clone(), }), _ => Err(CodexErr::InvalidRequest( "append_message only supports ResponseItem::Message".to_string(), diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index f3c64f1b3..f570ebfda 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -1307,6 +1307,7 @@ impl Session { content: vec![ContentItem::InputText { text: continuation_prompt(&goal), }], + phase: None, }], }) } @@ -1452,6 +1453,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { content: vec![ContentItem::InputText { text: budget_limit_prompt(goal), }], + phase: None, } } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 70900b446..8f7255ec4 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -1205,6 +1205,7 @@ Approved action: let items = vec![ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], + phase: None, }]; if let Err(items) = sess.inject_response_items(items).await { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 34107e2f5..d58e065f2 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1699,6 +1699,7 @@ impl Session { .inject_response_items(vec![ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], + phase: None, }]) .await .is_err() @@ -1795,6 +1796,7 @@ impl Session { .inject_response_items(vec![ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }], + phase: None, }]) .await .is_err() diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 731987faa..8854744b7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6432,6 +6432,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() content: vec![ContentItem::InputText { text: "late pending input".to_string(), }], + phase: None, }]) .await .expect("inject pending input into active turn"); @@ -6677,18 +6678,21 @@ async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() { content: vec![ContentItem::InputText { text: "blocked queued prompt".to_string(), }], + phase: None, }; let later = ResponseInputItem::Message { role: "user".to_string(), content: vec![ContentItem::InputText { text: "later queued prompt".to_string(), }], + phase: None, }; let newer = ResponseInputItem::Message { role: "user".to_string(), content: vec![ContentItem::InputText { text: "newer queued prompt".to_string(), }], + phase: None, }; sess.inject_response_items(vec![blocked.clone(), later.clone()]) @@ -6719,6 +6723,7 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() { content: vec![ContentItem::InputText { text: "queued before wake".to_string(), }], + phase: None, }; sess.queue_response_items_for_next_turn(vec![queued_item.clone()]) @@ -6745,6 +6750,7 @@ async fn idle_interrupt_does_not_wake_queued_next_turn_items() { content: vec![ContentItem::InputText { text: "queued before interrupt".to_string(), }], + phase: None, }; sess.queue_response_items_for_next_turn(vec![queued_item]) @@ -6764,6 +6770,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { content: vec![ContentItem::InputText { text: "late pending input".to_string(), }], + phase: None, }; let turn_state = { let mut active = sess.active_turn.lock().await; @@ -6969,7 +6976,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh .await?; let pending_input = sess.get_pending_input().await; - let [ResponseInputItem::Message { role, content }] = pending_input.as_slice() else { + let [ResponseInputItem::Message { role, content, .. }] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); }; assert_eq!("developer", role); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 444b0c3ec..23cd07640 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -354,7 +354,16 @@ async fn persist_user_shell_output( } let response_input_item = match output_item { - ResponseItem::Message { role, content, .. } => ResponseInputItem::Message { role, content }, + ResponseItem::Message { + role, + content, + phase, + .. + } => ResponseInputItem::Message { + role, + content, + phase, + }, _ => unreachable!("user shell command output record should always be a message"), }; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0431974d4..2d7dba7d5 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -609,6 +609,9 @@ pub enum ResponseInputItem { Message { role: String, content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + phase: Option, }, FunctionCallOutput { call_id: String, @@ -1034,11 +1037,15 @@ pub fn local_image_content_items_with_label_number( impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { - ResponseInputItem::Message { role, content } => Self::Message { + ResponseInputItem::Message { + role, + content, + phase, + } => Self::Message { role, content, id: None, - phase: None, + phase, }, ResponseInputItem::FunctionCallOutput { call_id, output } => { Self::FunctionCallOutput { call_id, output } @@ -1176,6 +1183,7 @@ impl From> for ResponseInputItem { UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core }) .collect::>(), + phase: None, } } } @@ -1581,6 +1589,29 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn response_input_message_conversion_preserves_phase() { + let item = ResponseItem::from(ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "still working".to_string(), + }], + phase: Some(MessagePhase::Commentary), + }); + + assert_eq!( + item, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "still working".to_string(), + }], + phase: Some(MessagePhase::Commentary), + } + ); + } + #[test] fn sandbox_permissions_helpers_match_documented_semantics() { let cases = [ diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c39c017fe..287662702 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -855,6 +855,7 @@ impl InterAgentCommunication { content: vec![ContentItem::OutputText { text: serde_json::to_string(self).unwrap_or_default(), }], + phase: Some(MessagePhase::Commentary), } }