diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index defdbeb38976..b8bdcf22bbf7 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -5,6 +5,7 @@ use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; use crate::event_mapping::is_contextual_user_message_content; +use crate::rollout_history::is_user_turn_boundary; use crate::session::turn_context::TurnContext; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; @@ -16,7 +17,6 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnContextItem; @@ -747,22 +747,6 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { } } -pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { - if matches!(item, ResponseItem::AgentMessage { .. }) { - return true; - } - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - - (role == "user" && !is_contextual_user_message_content(content)) - || (role == "assistant" && is_inter_agent_instruction_content(content)) -} - -fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { - InterAgentCommunication::is_message_content(content) -} - fn user_message_positions(items: &[ResponseItem]) -> Vec { let mut positions = Vec::new(); for (idx, item) in items.iter().enumerate() { diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index f811c8b373c9..052bb875e138 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -3,5 +3,4 @@ mod normalize; pub(crate) mod updates; pub(crate) use history::ContextManager; -pub(crate) use history::is_user_turn_boundary; pub(crate) use history::truncate_function_output_payload; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ad5f54123fa3..5fd50e304b60 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -136,6 +136,7 @@ pub use agents_md::LOCAL_AGENTS_MD_FILENAME; pub use agents_md::LoadedAgentsMd; mod rollout; mod rollout_budget; +pub mod rollout_history; pub(crate) mod safety; mod session_rollout_init_error; pub mod shell; diff --git a/codex-rs/core/src/rollout_history.rs b/codex-rs/core/src/rollout_history.rs new file mode 100644 index 000000000000..93f8a2f474fb --- /dev/null +++ b/codex-rs/core/src/rollout_history.rs @@ -0,0 +1,23 @@ +use crate::event_mapping::is_contextual_user_message_content; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; + +/// Returns whether a response item starts a user-directed turn in stored history. +/// +/// Context-only user messages do not start turns. Agent messages and structured +/// assistant instructions do, since they represent input that expects a response. +pub fn is_user_turn_boundary(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return true; + } + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + + (role == "user" && !is_contextual_user_message_content(content)) + || (role == "assistant" && InterAgentCommunication::is_message_content(content)) +} + +#[cfg(test)] +#[path = "rollout_history_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/rollout_history_tests.rs b/codex-rs/core/src/rollout_history_tests.rs new file mode 100644 index 000000000000..5031001dfc82 --- /dev/null +++ b/codex-rs/core/src/rollout_history_tests.rs @@ -0,0 +1,68 @@ +use super::is_user_turn_boundary; +use codex_protocol::AgentPath; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; + +fn message(role: &str, content: ContentItem) -> ResponseItem { + serde_json::from_value(serde_json::json!({ + "type": "message", + "role": role, + "content": [content], + })) + .expect("message response item should deserialize") +} + +#[test] +fn classifies_user_directed_turn_boundaries() { + let user_message = message( + "user", + ContentItem::InputText { + text: "hello".to_string(), + }, + ); + let agent_message = serde_json::from_value(serde_json::json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [], + })) + .expect("agent message response item should deserialize"); + let instruction = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").expect("valid agent path"), + Vec::new(), + "continue".to_string(), + /*trigger_turn*/ true, + ); + let assistant_instruction = message( + "assistant", + ContentItem::OutputText { + text: serde_json::to_string(&instruction).expect("instruction should serialize"), + }, + ); + + assert!(is_user_turn_boundary(&user_message)); + assert!(is_user_turn_boundary(&agent_message)); + assert!(is_user_turn_boundary(&assistant_instruction)); +} + +#[test] +fn excludes_context_and_non_input_items() { + let context_message = message( + "user", + ContentItem::InputText { + text: "/tmp".to_string(), + }, + ); + let assistant_message = message( + "assistant", + ContentItem::OutputText { + text: "done".to_string(), + }, + ); + + assert!(!is_user_turn_boundary(&context_message)); + assert!(!is_user_turn_boundary(&assistant_message)); + assert!(!is_user_turn_boundary(&ResponseItem::Other)); +} diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5c68893e7ff9..dbcb238ac744 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -49,7 +49,7 @@ use codex_protocol::protocol::WarningEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; -use crate::context_manager::is_user_turn_boundary; +use crate::rollout_history::is_user_turn_boundary; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::mcp::RequestId as ProtocolRequestId; use codex_rmcp_client::ElicitationAction; diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 0b89caaf0575..8cb17536c8e8 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -1,6 +1,6 @@ use super::*; use crate::context::world_state::WorldStateSnapshot; -use crate::context_manager::is_user_turn_boundary; +use crate::rollout_history::is_user_turn_boundary; use codex_protocol::protocol::SessionContextWindow; use uuid::Uuid; diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index 25263b14b12a..47bcf15bf044 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -3,8 +3,8 @@ //! In core, "user turns" are detected by scanning `ResponseItem::Message` items and //! interpreting them via `event_mapping::parse_turn_item(...)`. -use crate::context_manager::is_user_turn_boundary; use crate::event_mapping; +use crate::rollout_history::is_user_turn_boundary; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::EventMsg;