From 96f94b02be9bef6447ee94acdf1f785d01becab4 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 1 Feb 2026 20:39:47 -0800 Subject: [PATCH] Count only real user turns for turn-context sync --- codex-rs/core/src/codex.rs | 110 +++++++++++++++++++++++++++-- codex-rs/core/src/state/session.rs | 3 +- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bf13e154b6..b93bac2287 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -111,6 +111,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; +use crate::context_manager::is_user_turn_boundary; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -1858,8 +1859,7 @@ impl Session { match item { RolloutItem::ResponseItem(response_item) => { history_items.push(response_item.clone()); - if matches!(response_item, ResponseItem::Message { role, .. } if role == "user") - { + if is_user_turn_boundary(response_item) { turn_context_history.push(None); awaiting_turn_context = true; } @@ -1943,9 +1943,7 @@ impl Session { let user_positions: Vec = items .iter() .enumerate() - .filter_map(|(idx, item)| { - matches!(item, ResponseItem::Message { role, .. } if role == "user").then_some(idx) - }) + .filter_map(|(idx, item)| is_user_turn_boundary(item).then_some(idx)) .collect(); let Some(&first_user_idx) = user_positions.first() else { return; @@ -1963,7 +1961,7 @@ impl Session { fn user_turn_count(items: &[ResponseItem]) -> usize { items .iter() - .filter(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user")) + .filter(|item| is_user_turn_boundary(item)) .count() } @@ -5377,6 +5375,106 @@ mod tests { assert_eq!(modes, vec![Some(collaboration_mode), None]); } + #[test] + fn user_turn_count_ignores_non_turn_user_messages() { + let items = vec![ + user_message("do the thing"), + user_message( + "# AGENTS.md instructions for project\n\n\nrule\n", + ), + user_message( + "\ndemo\nskills/demo/SKILL.md\nbody\n", + ), + user_message("echo 42"), + user_message("real user turn"), + ]; + + assert_eq!(Session::user_turn_count(&items), 1); + } + + #[test] + fn reconstruct_turn_context_history_ignores_non_turn_user_messages() { + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-test".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + let turn_context = TurnContextItem { + cwd: PathBuf::from("/tmp"), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: collaboration_mode.settings.model.clone(), + personality: None, + collaboration_mode: Some(collaboration_mode.clone()), + effort: None, + summary: ReasoningSummaryConfig::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }; + let rollout_items = vec![ + RolloutItem::ResponseItem(user_message( + "do the thing", + )), + RolloutItem::ResponseItem(user_message( + "# AGENTS.md instructions for project\n\n\nrule\n", + )), + RolloutItem::ResponseItem(user_message("real user turn")), + RolloutItem::TurnContext(turn_context), + ]; + + let history = Session::reconstruct_turn_context_history_from_rollout(&rollout_items); + let modes: Vec> = history + .iter() + .map(|item| { + item.as_ref() + .and_then(|ctx| ctx.collaboration_mode.as_ref().map(|mode| mode.mode)) + }) + .collect(); + + assert_eq!(modes, vec![Some(ModeKind::Plan)]); + } + + #[test] + fn drop_last_n_user_turns_from_items_ignores_non_turn_user_messages() { + let mut items = vec![ + user_message("ctx"), + user_message("do the thing"), + user_message("turn 1 user"), + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "turn 1 assistant".to_string(), + }], + end_turn: None, + }, + user_message("turn 2 user"), + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "turn 2 assistant".to_string(), + }], + end_turn: None, + }, + ]; + + Session::drop_last_n_user_turns_from_items(&mut items, 2); + + assert_eq!( + items, + vec![ + user_message("ctx"), + user_message("do the thing"), + ] + ); + } + #[tokio::test] async fn thread_rollback_ignores_stale_turn_context() { let (sess, tc, rx) = make_session_and_context_with_rx().await; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 0492d12999..c6952bde4a 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -8,6 +8,7 @@ use std::collections::HashSet; use crate::codex::SessionConfiguration; use crate::context_manager::ContextManager; +use crate::context_manager::is_user_turn_boundary; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; @@ -75,7 +76,7 @@ impl SessionState { /// user turn and optionally fill the slot with a TurnContextItem. pub(crate) fn record_user_turn_placeholders(&mut self, items: &[ResponseItem]) { for item in items { - if matches!(item, ResponseItem::Message { role, .. } if role == "user") { + if is_user_turn_boundary(item) { self.turn_context_history.push(None); } }