diff --git a/codex-rs/core/src/session/time_reminder.rs b/codex-rs/core/src/session/time_reminder.rs index 398ae58114..963c8c4017 100644 --- a/codex-rs/core/src/session/time_reminder.rs +++ b/codex-rs/core/src/session/time_reminder.rs @@ -1,15 +1,41 @@ use chrono::DateTime; use chrono::Utc; use codex_features::CurrentTimeReminderDeliveryMode; +use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ResponseItem; use super::session::Session; use super::turn_context::TurnContext; +use crate::config::Config; +use crate::config::CurrentTimeReminderConfig; use crate::context::ContextualUserFragment; use crate::context_manager::is_user_turn_boundary; +pub(super) fn apply_persistent_defaults(config: &mut Config) { + if config.current_time_reminder.is_some() + || config + .config_layer_stack + .effective_config() + .get("features") + .and_then(|features| features.get("current_time_reminder")) + .is_some() + { + return; + } + + // Apply defaults only to this turn; explicit settings and managed policy win. + if config.features.enable(Feature::CurrentTimeReminder).is_ok() + && config.features.enabled(Feature::CurrentTimeReminder) + { + config.current_time_reminder = Some(CurrentTimeReminderConfig { + sleep_tool: true, + ..CurrentTimeReminderConfig::default() + }); + } +} + #[derive(Default)] pub(crate) struct CurrentTimeReminderState { last_delivery_time: Option>, @@ -73,6 +99,13 @@ pub(super) async fn maybe_record_current_time_reminder( turn_context: &TurnContext, window_id: &str, ) -> CodexResult<()> { + if !turn_context + .config + .features + .enabled(Feature::CurrentTimeReminder) + { + return Ok(()); + } let Some(config) = turn_context.config.current_time_reminder else { return Ok(()); }; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 617e8c22a1..9d175ec840 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -18,6 +18,7 @@ use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::RawFileSystemSandboxPolicy; use codex_protocol::protocol::EnvironmentConfig; use codex_protocol::protocol::EnvironmentConfigState; @@ -725,6 +726,9 @@ impl Session { let mut per_turn_config = per_turn_config; super::token_budget::apply_model_defaults(&mut per_turn_config, model_info); + if step_settings.reasoning_effort() == Some(&ReasoningEffort::Persistent) { + super::time_reminder::apply_persistent_defaults(&mut per_turn_config); + } per_turn_config.service_tier = step_settings.service_tier.clone(); let permission_profile = environments.permission_profile_or_else(|| { per_turn_config.permissions.effective_permission_profile() diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs index 040c2515bb..fa06d98192 100644 --- a/codex-rs/core/tests/suite/current_time_reminder.rs +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -9,6 +9,7 @@ use anyhow::anyhow; use chrono::DateTime; use chrono::Local; use chrono::Utc; +use codex_config::test_support::CloudConfigBundleFixture; use codex_core::SleepFuture; use codex_core::TimeFuture; use codex_core::TimeProvider; @@ -20,6 +21,7 @@ use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; @@ -40,6 +42,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::json; +use test_case::test_case; const FIRST_REMINDER: &str = "It is 2026-06-17 17:34:15 UTC."; @@ -51,6 +54,15 @@ const THIRD_REMINDER: &str = "It is 2026-06-17 17:36:15 UTC."; const FIRST_TIME_UNIX_SECONDS: i64 = 1_781_717_655; +#[derive(Clone, Copy)] +enum ClockSetup { + Configured, + Persistent, + OrdinaryEffort, + ExplicitlyDisabled, + RequiredOff, +} + struct TestTimeProvider { current_time: AtomicI64, sleep_seconds: AtomicU64, @@ -323,8 +335,13 @@ async fn current_time_reminders_can_follow_only_user_or_tool_outputs() -> Result Ok(()) } +#[test_case(ClockSetup::Configured; "configured")] +#[test_case(ClockSetup::Persistent; "persistent")] +#[test_case(ClockSetup::OrdinaryEffort; "ordinary_effort")] +#[test_case(ClockSetup::ExplicitlyDisabled; "explicitly_disabled")] +#[test_case(ClockSetup::RequiredOff; "required_off")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn system_time_source_adds_current_time_reminder() -> Result<()> { +async fn system_time_source_adds_current_time_reminder(clock_setup: ClockSetup) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -333,16 +350,61 @@ async fn system_time_source_adds_current_time_reminder() -> Result<()> { sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), ) .await; - let test = test_codex() - .with_config(|config| { - enable_current_time_reminder(config, /*interval*/ 1, CurrentTimeSource::System) + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + let config = match clock_setup { + ClockSetup::Configured => Some("[features]\ncurrent_time_reminder = true\n"), + ClockSetup::ExplicitlyDisabled => { + Some("[features]\ncurrent_time_reminder = false\n") + } + ClockSetup::Persistent | ClockSetup::OrdinaryEffort | ClockSetup::RequiredOff => { + None + } + }; + if let Some(config) = config { + std::fs::write(home.join("config.toml"), config) + .expect("clock configuration should be written"); + } }) - .build(&server) - .await?; + .with_config(move |config| { + config.include_environment_context = false; + config.model_reasoning_effort = Some(match clock_setup { + ClockSetup::OrdinaryEffort => ReasoningEffort::High, + ClockSetup::Configured + | ClockSetup::Persistent + | ClockSetup::ExplicitlyDisabled + | ClockSetup::RequiredOff => ReasoningEffort::Persistent, + }); + }); + if matches!(clock_setup, ClockSetup::RequiredOff) { + builder = builder.with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[features]\ncurrent_time_reminder = false\n", + ), + ); + } + let test = builder.build_with_auto_env(&server).await?; - test.submit_turn("what time is it?").await?; + test.submit_text_turn("what time is it?").await?; let request = responses.single_request(); + assert_eq!( + ["curr_time", "sleep"].map(|name| request.tool_by_name("clock", name).is_some()), + match clock_setup { + ClockSetup::Configured => [true, false], + ClockSetup::Persistent => [true, true], + ClockSetup::OrdinaryEffort + | ClockSetup::ExplicitlyDisabled + | ClockSetup::RequiredOff => [false, false], + } + ); + if matches!( + clock_setup, + ClockSetup::OrdinaryEffort | ClockSetup::ExplicitlyDisabled | ClockSetup::RequiredOff + ) { + assert!(current_time_reminders(&request).is_empty()); + return Ok(()); + } assert!(request.has_content_kinds(&["current_time.reminder"])); let reminders = current_time_reminders(&request); assert_eq!(reminders.len(), 1); diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index d926e9dc2f..b4c9064880 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -1012,7 +1012,7 @@ async fn review_uses_custom_review_model_from_config() { } /// Ensure that when `review_model` is not set in the config, the review request -/// uses the session model. +/// uses the session model without exposing disabled clock tools or reminders. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn review_uses_session_model_when_review_model_unset() { skip_if_no_network!(); @@ -1025,7 +1025,7 @@ async fn review_uses_session_model_when_review_model_unset() { .with_config(|config| { config.model = Some("gpt-5.4".to_string()); config.review_model = None; - config.model_reasoning_effort = Some(ReasoningEffort::Max); + config.model_reasoning_effort = Some(ReasoningEffort::Persistent); }) .build_with_auto_env(&server) .await @@ -1061,7 +1061,12 @@ async fn review_uses_session_model_when_review_model_unset() { assert_eq!(request.path(), "/v1/responses"); let body = request.body_json(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.4"); - assert_eq!(body["reasoning"]["effort"].as_str(), Some("max")); + assert_eq!(body["reasoning"]["effort"].as_str(), Some("disabled")); + assert_eq!( + ["curr_time", "sleep"].map(|name| request.tool_by_name("clock", name).is_some()), + [false, false] + ); + assert!(!request.has_content_kinds(&["current_time.reminder"])); let _codex_home_guard = codex_home; server.verify().await;