From f5da38291b3bd93c757b343dbd22e249bd3f7c21 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Jun 2026 12:29:53 +0100 Subject: [PATCH 1/3] fix: new goal and plan --- codex-rs/core/src/codex_thread.rs | 14 +++++++- codex-rs/core/src/session/inject.rs | 16 ++++++++- codex-rs/core/src/session/tests.rs | 52 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cd90829c0cb2..febe25f22fbc 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -279,7 +279,19 @@ impl CodexThread { self.codex.session.inject_if_running(items).await } - /// Starts a regular turn with model-visible items only if the thread is idle. + /// Starts an automatic regular turn with model-visible items only when idle + /// work is allowed for this thread. + /// + /// This is the required entry point for extensions that want to launch + /// model-visible work from `ThreadLifecycleContributor::on_thread_idle`. + /// The call succeeds only if no user/client-triggered turn is queued, no + /// task is currently active, and the thread is not in Plan mode. Active + /// Review tasks are rejected by the active-task check because Review turns + /// are not steerable. + /// + /// On rejection, the original `items` are returned unchanged so the caller + /// can decide whether to drop them, retry later, or log why no automatic + /// turn was started. pub async fn try_start_turn_if_idle( &self, items: Vec, diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 64d4aaade17a..bd5b8f74ab7d 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -4,6 +4,7 @@ use super::turn_context::TurnContext; use crate::state::ActiveTurn; use crate::state::TurnState; use crate::tasks::RegularTask; +use codex_protocol::config_types::ModeKind; use codex_protocol::models::ResponseItem; use std::sync::Arc; @@ -32,7 +33,13 @@ impl Session { } } - /// Starts a regular turn with the provided items only if the session is idle. + /// Starts a regular turn with the provided items only if automatic idle work + /// is allowed for the current session state. + /// + /// This is the shared gate for extension-initiated idle work. It refuses to + /// start a turn when user/client-triggered work is queued, any task is still + /// active, or the session is currently in Plan mode. Active Review tasks are + /// covered by the active-task check because Review turns are not steerable. pub(crate) async fn try_start_turn_if_idle( self: &Arc, input: Vec, @@ -43,6 +50,9 @@ impl Session { if self.input_queue.has_trigger_turn_mailbox_items().await { return Err(input); } + if self.collaboration_mode().await.mode == ModeKind::Plan { + return Err(input); + } let turn_state = { let mut active_turn = self.active_turn.lock().await; @@ -62,6 +72,10 @@ impl Session { let turn_context = self .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) .await; + if turn_context.collaboration_mode.mode == ModeKind::Plan { + self.clear_reserved_idle_turn(&turn_state).await; + return Err(input); + } self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) .await; if self.input_queue.has_trigger_turn_mailbox_items().await { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a3e5558971d9..b8747a5aa303 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8779,6 +8779,58 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } +#[tokio::test] +async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let mut collaboration_mode = sess.collaboration_mode().await; + collaboration_mode.mode = ModeKind::Plan; + { + let mut state = sess.state.lock().await; + state.session_configuration.collaboration_mode = collaboration_mode; + } + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("plan mode should reject automatic idle input"); + + assert_eq!(vec![item], err); + assert!(sess.active_turn.lock().await.is_none()); + assert_eq!( + Vec::::new(), + sess.input_queue.get_pending_input(&sess.active_turn).await + ); +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Review, + listen_to_cancellation_token: true, + }, + ) + .await; + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("active review turn should reject automatic idle input"); + + assert_eq!(vec![item], err); + assert_eq!( + Vec::::new(), + sess.input_queue.get_pending_input(&sess.active_turn).await + ); + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; +} + #[tokio::test] async fn steer_input_requires_active_turn() { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; From fc0492a9a75cab47a635590ee51092040b605ccf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Jun 2026 13:55:50 +0100 Subject: [PATCH 2/3] Handle plan-mode idle turn rejection --- codex-rs/core/src/codex_thread.rs | 48 ++++++++++++++++++++++++++--- codex-rs/core/src/lib.rs | 2 ++ codex-rs/core/src/session/inject.rs | 40 +++++++++++++++++++----- codex-rs/core/src/session/tests.rs | 38 +++++++++++++++++++++-- codex-rs/ext/goal/src/runtime.rs | 12 ++++++-- 5 files changed, 123 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index febe25f22fbc..4a049a6e2082 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -74,6 +74,46 @@ pub struct ThreadConfigSnapshot { pub thread_source: Option, } +/// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic +/// idle turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TryStartTurnIfIdleRejectionReason { + /// User/client-triggered mailbox work is already queued and must take + /// priority over extension-initiated idle work. + PendingTriggerTurn, + /// The thread is in Plan mode, where automatic idle work must not start a + /// new model turn. + PlanMode, + /// Another turn or task is active, or the idle reservation was lost before + /// the automatic turn could start. + Busy, +} + +/// Rejection returned when an extension asks to start automatic idle work but +/// the thread is not eligible to run it. +#[derive(Debug)] +pub struct TryStartTurnIfIdleError { + reason: TryStartTurnIfIdleRejectionReason, + input: Vec, +} + +impl TryStartTurnIfIdleError { + pub(crate) fn new(reason: TryStartTurnIfIdleRejectionReason, input: Vec) -> Self { + Self { reason, input } + } + + /// Returns the stable reason the automatic idle turn was rejected. + pub fn reason(&self) -> TryStartTurnIfIdleRejectionReason { + self.reason + } + + /// Consumes the rejection and returns the original model-visible input + /// unchanged, so callers can retry, drop, or log it explicitly. + pub fn into_input(self) -> Vec { + self.input + } +} + impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { let file_system_sandbox_policy = self.permission_profile.file_system_sandbox_policy(); @@ -289,13 +329,13 @@ impl CodexThread { /// Review tasks are rejected by the active-task check because Review turns /// are not steerable. /// - /// On rejection, the original `items` are returned unchanged so the caller - /// can decide whether to drop them, retry later, or log why no automatic - /// turn was started. + /// On rejection, the returned error includes a stable reason and carries + /// the original `items` unchanged so the caller can decide whether to drop + /// them, retry later, or log why no automatic turn was started. pub async fn try_start_turn_if_idle( &self, items: Vec, - ) -> Result<(), Vec> { + ) -> Result<(), TryStartTurnIfIdleError> { self.codex.session.try_start_turn_if_idle(items).await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 31ac529556f4..d3031ad22276 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -22,6 +22,8 @@ mod config_lock; pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; +pub use codex_thread::TryStartTurnIfIdleError; +pub use codex_thread::TryStartTurnIfIdleRejectionReason; pub use session::turn_context::TurnContext; mod agent; mod attestation; diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index bd5b8f74ab7d..3f73cd96d086 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -1,6 +1,8 @@ use super::input_queue::TurnInput; use super::session::Session; use super::turn_context::TurnContext; +use crate::codex_thread::TryStartTurnIfIdleError; +use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::state::ActiveTurn; use crate::state::TurnState; use crate::tasks::RegularTask; @@ -43,21 +45,30 @@ impl Session { pub(crate) async fn try_start_turn_if_idle( self: &Arc, input: Vec, - ) -> Result<(), Vec> { + ) -> Result<(), TryStartTurnIfIdleError> { if input.is_empty() { return Ok(()); } if self.input_queue.has_trigger_turn_mailbox_items().await { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } if self.collaboration_mode().await.mode == ModeKind::Plan { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PlanMode, + input, + )); } let turn_state = { let mut active_turn = self.active_turn.lock().await; if active_turn.is_some() { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::Busy, + input, + )); } let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); Arc::clone(&active_turn.turn_state) @@ -66,7 +77,10 @@ impl Session { if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; self.maybe_start_turn_for_pending_work().await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } let turn_context = self @@ -74,14 +88,21 @@ impl Session { .await; if turn_context.collaboration_mode.mode == ModeKind::Plan { self.clear_reserved_idle_turn(&turn_state).await; - return Err(input); + self.maybe_start_turn_for_pending_work().await; + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PlanMode, + input, + )); } self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) .await; if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; self.maybe_start_turn_for_pending_work().await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } let still_reserved = { let active_turn = self.active_turn.lock().await; @@ -91,7 +112,10 @@ impl Session { }; if !still_reserved { self.clear_reserved_idle_turn(&turn_state).await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::Busy, + input, + )); } self.input_queue diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b8747a5aa303..6502de6d57eb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1,5 +1,6 @@ use super::turn_context::TurnEnvironment; use super::*; +use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::test_config; @@ -8770,7 +8771,8 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() { .await .expect_err("active turn should reject idle-only input"); - assert_eq!(vec![item], err); + assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); + assert_eq!(vec![item], err.into_input()); assert_eq!( Vec::::new(), sess.input_queue.get_pending_input(&sess.active_turn).await @@ -8795,7 +8797,8 @@ async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() { .await .expect_err("plan mode should reject automatic idle input"); - assert_eq!(vec![item], err); + assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, err.reason()); + assert_eq!(vec![item], err.into_input()); assert!(sess.active_turn.lock().await.is_none()); assert_eq!( Vec::::new(), @@ -8803,6 +8806,34 @@ async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() { ); } +#[tokio::test] +async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root(), + Vec::new(), + "pending trigger".to_string(), + /*trigger_turn*/ true, + )) + .await; + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("pending trigger-turn mail should reject automatic idle input"); + + assert_eq!( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + err.reason() + ); + assert_eq!(vec![item], err.into_input()); + assert!(sess.active_turn.lock().await.is_none()); + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); +} + #[tokio::test] async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; @@ -8822,7 +8853,8 @@ async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() { .await .expect_err("active review turn should reject automatic idle input"); - assert_eq!(vec![item], err); + assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); + assert_eq!(vec![item], err.into_input()); assert_eq!( Vec::::new(), sess.input_queue.get_pending_input(&sess.active_turn).await diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 8d79391c95ae..63af4e8c248e 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -4,6 +4,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use codex_core::ThreadManager; +use codex_core::TryStartTurnIfIdleRejectionReason; use codex_protocol::ThreadId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; @@ -307,8 +308,15 @@ impl GoalRuntimeHandle { return Ok(()); }; - if thread.try_start_turn_if_idle(vec![item]).await.is_err() { - tracing::debug!("skipping goal continuation because the thread is no longer idle"); + if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await { + let reason = err.reason(); + tracing::debug!( + ?reason, + "skipping goal continuation because automatic idle work was rejected" + ); + if reason == TryStartTurnIfIdleRejectionReason::PlanMode { + return Ok(()); + } } let current_turn_is_goal_active = self From da6a6f58b42e4c33086521ef52669ebcc95d481b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 4 Jun 2026 13:28:46 +0100 Subject: [PATCH 3/3] nit fix --- codex-rs/ext/goal/src/runtime.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 63af4e8c248e..a2400e49b8d8 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -4,7 +4,6 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use codex_core::ThreadManager; -use codex_core::TryStartTurnIfIdleRejectionReason; use codex_protocol::ThreadId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; @@ -314,9 +313,6 @@ impl GoalRuntimeHandle { ?reason, "skipping goal continuation because automatic idle work was rejected" ); - if reason == TryStartTurnIfIdleRejectionReason::PlanMode { - return Ok(()); - } } let current_turn_is_goal_active = self