From 5bf132cd527311eb61bbec46562e3890eb49df80 Mon Sep 17 00:00:00 2001 From: Vivian Fang Date: Wed, 16 Sep 2026 00:56:32 +0000 Subject: [PATCH] Add opt-in nonfatal handling for clock read failures (#45825) ## Why Clock provider failures can abort a turn while preparing time context or running clock tools. Allow turns to continue with an explicit indication that the current time is unavailable. ## What changed - Add `features.nonfatal_clock_read_errors`, disabled by default, to report clock failures to the model without failing the turn. - Emit a generic `failed to read current time` notice for context reads and tool errors, without exposing provider error details. Deduplicate context notices per turn and compaction window, and remove inherited notices from forked subagent context. - Omit unavailable environment dates and explicitly clear previously visible dates with ``. - Return external sleep clock failures to the model when the feature is enabled, preserving sleep item completion notifications. ## Testing Add coverage for continued inference after clock failures, notice deduplication across compaction, subagent notice filtering, environment date removal and recovery, and sleep failures during initial and polling reads. GitOrigin-RevId: a39c3723c06a6f786d8ad59d667e8b9f626c973e --- codex-rs/app-server/tests/suite/v2/sleep.rs | 68 ++++++++++++--- codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/agent/control/spawn.rs | 2 + .../core/src/context/current_time_reminder.rs | 28 +++++++ codex-rs/core/src/context/mod.rs | 1 + .../src/context/world_state/environment.rs | 10 ++- .../world_state/environment_render_tests.rs | 28 +++++++ codex-rs/core/src/session/time_reminder.rs | 67 +++++++++++++-- codex-rs/core/src/session/world_state.rs | 19 ++--- .../core/src/tools/handlers/current_time.rs | 18 +++- codex-rs/core/src/tools/handlers/sleep.rs | 30 ++++--- .../core/tests/suite/current_time_reminder.rs | 83 +++++++++++++++++++ .../tests/suite/subagent_notifications.rs | 47 ++++++++++- codex-rs/features/src/lib.rs | 8 ++ 14 files changed, 367 insertions(+), 48 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/sleep.rs b/codex-rs/app-server/tests/suite/v2/sleep.rs index 93542f4388..2b60c6a783 100644 --- a/codex-rs/app-server/tests/suite/v2/sleep.rs +++ b/codex-rs/app-server/tests/suite/v2/sleep.rs @@ -5,19 +5,23 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CurrentTimeReadResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SleepItem; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::json; use std::time::Duration; use tempfile::TempDir; +use test_case::test_case; use tokio::time::timeout; use super::analytics::captured_analytics_events; @@ -159,13 +163,19 @@ async fn clock_tools_emit_control_tool_analytics() -> Result<()> { Ok(()) } +#[test_case(0; "initial read fails")] +#[test_case(1; "polling read fails")] +#[test_case(3; "successful reads")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> { +async fn external_sleep_polls_current_time_and_emits_items( + successful_sleep_reads: usize, +) -> Result<()> { const CALL_ID: &str = "sleep-1"; const DURATION_MS: u64 = 2_000; + let read_fails = successful_sleep_reads < 3; let server = responses::start_mock_server().await; - responses::mount_sse_sequence( + let model = responses::mount_sse_sequence( &server, vec![ responses::sse(vec![ @@ -189,13 +199,15 @@ async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> { let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()) .with_root_config("include_environment_context = false") - .with_extra_config( - r#"[features.current_time_reminder] + .with_extra_config(&format!( + r#"[features] +nonfatal_clock_read_errors = {read_fails} +[features.current_time_reminder] enabled = true sleep_tool = true clock_source = "external" "#, - ) + )) .write(codex_home.path())?; let mut mcp = TestAppServer::builder() @@ -228,21 +240,51 @@ clock_source = "external" // Read once for the initial reminder, then once to establish the sleep deadline. respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT).await?; let started = wait_for_sleep_started(&mut mcp, CALL_ID).await?; - respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT).await?; - - // The first poll remains below the deadline, so the provider must request time again. - respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 1).await?; - respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 2).await?; + for current_time_at in [CURRENT_TIME_AT, CURRENT_TIME_AT + 1, CURRENT_TIME_AT + 2] + .into_iter() + .take(successful_sleep_reads) + { + respond_to_current_time_read(&mut mcp, &thread.id, current_time_at).await?; + } + if read_fails { + let request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CurrentTimeRead { request_id, params } = request else { + panic!("expected clock read, got {request:?}"); + }; + assert_eq!(params.thread_id, thread.id); + mcp.send_error( + request_id, + JSONRPCErrorError { + code: -32_000, + message: "PRIVATE_CLOCK_ERROR".to_string(), + data: None, + }, + ) + .await?; + } let completed = wait_for_sleep_completed(&mut mcp, CALL_ID).await?; - // The next inference boundary reads the same external clock after the sleep completes. + // The next inference boundary reads the same external clock after the tool returns. respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 2).await?; - timeout( + let turn_completed: TurnCompletedNotification = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + mcp.read_notification("turn/completed"), ) .await??; + assert_eq!(turn_completed.turn.status, TurnStatus::Completed); + if read_fails { + let requests = model.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1].function_call_output_text(CALL_ID).as_deref(), + Some("failed to read current time") + ); + } let expected_item = ThreadItem::Sleep(SleepItem { id: CALL_ID.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5058ed4875..1928af9db0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -894,6 +894,9 @@ "non_prefixed_mcp_tool_names": { "$ref": "#/definitions/FeatureToml_for_NonPrefixedMcpToolNamesConfigToml" }, + "nonfatal_clock_read_errors": { + "type": "boolean" + }, "omit_app_server_notification_media": { "type": "boolean" }, @@ -6531,6 +6534,9 @@ "non_prefixed_mcp_tool_names": { "$ref": "#/definitions/FeatureToml_for_NonPrefixedMcpToolNamesConfigToml" }, + "nonfatal_clock_read_errors": { + "type": "boolean" + }, "omit_app_server_notification_media": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 77d4726de6..95d148c2e4 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -6,6 +6,7 @@ use crate::codex_thread::CodexThread; use crate::config::PermissionProfileSnapshot; use crate::context::ContextualUserFragment; use crate::context::CurrentTimeReminder; +use crate::context::CurrentTimeUnavailable; use crate::context::DeveloperInstructions; use crate::context::GuardianContextMode; use crate::context::ManagedDeveloperInstructions; @@ -135,6 +136,7 @@ fn retain_forked_developer_message( )) || MultiAgentModeInstructions::matches_text(text) || CurrentTimeReminder::matches_text(text) + || CurrentTimeUnavailable::matches_text(text) || usage_hint_texts .iter() .any(|usage_hint_text| usage_hint_text == text)) diff --git a/codex-rs/core/src/context/current_time_reminder.rs b/codex-rs/core/src/context/current_time_reminder.rs index e70bd0c9d7..2273cbc04c 100644 --- a/codex-rs/core/src/context/current_time_reminder.rs +++ b/codex-rs/core/src/context/current_time_reminder.rs @@ -41,3 +41,31 @@ impl ContextualUserFragment for CurrentTimeReminder { format!("It is {}.", self.formatted_time()) } } + +pub(crate) struct CurrentTimeUnavailable; + +impl CurrentTimeUnavailable { + pub(crate) const MESSAGE: &str = "failed to read current time"; +} + +impl ContextualUserFragment for CurrentTimeUnavailable { + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("current_time.unavailable".to_string()) + } + + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + Self::MESSAGE.to_string() + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 35bd92794a..7c61965105 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -66,6 +66,7 @@ pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::is_user_authorization_message; pub(crate) use contextual_user_message::parse_visible_hook_prompt_message; pub(crate) use current_time_reminder::CurrentTimeReminder; +pub(crate) use current_time_reminder::CurrentTimeUnavailable; pub(crate) use developer_instructions::DeveloperInstructions; pub(crate) use environments_instructions::EnvironmentsInstructions; pub(crate) use guardian_approved_action::GuardianApprovedAction; diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs index 8f38946ede..c1656865cb 100644 --- a/codex-rs/core/src/context/world_state/environment.rs +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -92,6 +92,7 @@ impl EnvironmentsState { shell_version: self.shell_version.clone(), shell_version_removed: false, current_date: self.current_date.clone(), + current_date_removed: false, timezone: self.timezone.clone(), network: self.network.clone(), filesystem: self.filesystem.clone(), @@ -183,6 +184,8 @@ impl WorldStateSection for EnvironmentsState { shell_version_removed: self.shell_version.is_none() && previous.shell_version.is_some(), current_date: self.current_date.clone(), + current_date_removed: self.current_date.is_none() + && previous.current_date.is_some(), timezone: self.timezone.clone(), network: self.network.clone(), filesystem: self.filesystem.clone(), @@ -221,6 +224,7 @@ struct RenderedEnvironments { shell_version: Option, shell_version_removed: bool, current_date: Option, + current_date_removed: bool, timezone: Option, network: Option, filesystem: Option, @@ -289,7 +293,11 @@ impl ContextualUserFragment for RenderedEnvironments { let shell_version = self.shell_version.as_deref(); push_optional_element(&mut rendered, "shell_version", shell_version); } - push_optional_element(&mut rendered, "current_date", self.current_date.as_deref()); + if self.current_date_removed { + rendered.push_str(" \n"); + } else { + push_optional_element(&mut rendered, "current_date", self.current_date.as_deref()); + } push_optional_element(&mut rendered, "timezone", self.timezone.as_deref()); if let Some(network) = &self.network { rendered.push_str(" "); diff --git a/codex-rs/core/src/context/world_state/environment_render_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs index 0d1198d052..1f24d55476 100644 --- a/codex-rs/core/src/context/world_state/environment_render_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -396,3 +396,31 @@ fn shell_version_diff_clears_previously_visible_version() { "\n \n" ); } + +#[test] +fn current_date_diff_clears_once_and_recovers() { + let available = EnvironmentsState { + current_date: Some("2026-06-17".to_string()), + ..Default::default() + }; + let unavailable = EnvironmentsState::default(); + assert_eq!( + unavailable + .render_diff(PreviousSectionState::Known(&available.snapshot())) + .expect("removed current date") + .render(), + "\n \n" + ); + assert!( + unavailable + .render_diff(PreviousSectionState::Known(&unavailable.snapshot())) + .is_none() + ); + assert_eq!( + available + .render_diff(PreviousSectionState::Known(&unavailable.snapshot())) + .expect("restored current date") + .render(), + "\n 2026-06-17\n" + ); +} diff --git a/codex-rs/core/src/session/time_reminder.rs b/codex-rs/core/src/session/time_reminder.rs index 6f42343a94..b4a2650f8f 100644 --- a/codex-rs/core/src/session/time_reminder.rs +++ b/codex-rs/core/src/session/time_reminder.rs @@ -11,6 +11,7 @@ use super::turn_context::TurnContext; use crate::config::Config; use crate::config::CurrentTimeReminderConfig; use crate::context::ContextualUserFragment; +use crate::context::CurrentTimeUnavailable; use crate::context_manager::is_user_turn_boundary; pub(super) fn apply_persistent_defaults(config: &mut Config) { @@ -40,6 +41,7 @@ pub(super) fn apply_persistent_defaults(config: &mut Config) { pub(crate) struct CurrentTimeReminderState { last_delivery_time: Option>, last_window_id: Option, + last_clock_failure: Option<(String, uuid::Uuid)>, pending_user_or_tool_output_boundary: bool, } @@ -94,6 +96,59 @@ impl CurrentTimeReminderState { } } +impl Session { + pub(super) async fn read_clock_for_context( + &self, + turn_context: &TurnContext, + clock_read: &'static str, + ) -> CodexResult>> { + let error = match self + .services + .time_provider + .current_time(self.thread_id) + .await + { + Ok(time) => return Ok(Some(time)), + Err(error) => error, + }; + if !turn_context + .config + .features + .enabled(Feature::NonfatalClockReadErrors) + { + return Err(CodexErr::Fatal(format!( + "failed to read current time: {error:#}" + ))); + } + tracing::error!( + clock_read, + thread_id = %self.thread_id, + turn_id = %turn_context.sub_id, + "failed to read current time; the clock provider may be stalled" + ); + { + let mut state = self.state.lock().await; + let failure = ( + turn_context.sub_id.clone(), + state.auto_compact_window_ids().window_id, + ); + // A compacted window may no longer contain the earlier notice. + if state.current_time_reminder.last_clock_failure.as_ref() == Some(&failure) { + return Ok(None); + } + state.current_time_reminder.last_clock_failure = Some(failure); + } + let response_item = ContextualUserFragment::into(CurrentTimeUnavailable); + self.record_conversation_items( + turn_context, + turn_context.model_info(), + std::slice::from_ref(&response_item), + ) + .await; + Ok(None) + } +} + pub(super) async fn maybe_record_current_time_reminder( sess: &Session, turn_context: &TurnContext, @@ -110,12 +165,12 @@ pub(super) async fn maybe_record_current_time_reminder( return Ok(()); }; - let current_time = sess - .services - .time_provider - .current_time(sess.thread_id) - .await - .map_err(|err| CodexErr::Fatal(format!("failed to read current time: {err:#}")))?; + let Some(current_time) = sess + .read_clock_for_context(turn_context, "reminder") + .await? + else { + return Ok(()); + }; let reminder_is_due = { let mut state = sess.state.lock().await; diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 74b1defa86..eae14ba44c 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -25,7 +25,6 @@ use crate::context::world_state::WorldState; use codex_connectors::AppToolPolicyEvaluator; use codex_extension_api::WorldStateContributionInput; use codex_features::Feature; -use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::BaseInstructionsProvenance; @@ -199,19 +198,19 @@ impl Session { } if turn_context.config.include_environment_context { let current_date = self - .services - .time_provider - .current_time(self.thread_id()) - .await - .map_err(|err| CodexErr::Fatal(format!("failed to read current time: {err:#}")))? - .with_timezone(&chrono::Local) - .format("%Y-%m-%d") - .to_string(); + .read_clock_for_context(turn_context, "environment_date") + .await? + .map(|current_time| { + current_time + .with_timezone(&chrono::Local) + .format("%Y-%m-%d") + .to_string() + }); world_state.add_section( EnvironmentsState::from_turn_context_with_environments( turn_context, &step_context.environments, - Some(current_date), + current_date, ) .await .with_subagents(environment_subagents), diff --git a/codex-rs/core/src/tools/handlers/current_time.rs b/codex-rs/core/src/tools/handlers/current_time.rs index 5d48896011..527701b963 100644 --- a/codex-rs/core/src/tools/handlers/current_time.rs +++ b/codex-rs/core/src/tools/handlers/current_time.rs @@ -1,5 +1,6 @@ use crate::context::ContextualUserFragment; use crate::context::CurrentTimeReminder; +use crate::context::CurrentTimeUnavailable; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -8,6 +9,7 @@ use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; +use codex_features::Feature; use codex_protocol::models::ResponseInputItem; use codex_tools::JsonSchema; use codex_tools::ResponsesApiNamespace; @@ -98,7 +100,21 @@ impl ToolExecutor for CurrentTimeHandler { .current_time(invocation.session.thread_id) .await .map_err(|err| { - FunctionCallError::Fatal(format!("failed to read current time: {err:#}")) + if invocation + .turn + .config + .features + .enabled(Feature::NonfatalClockReadErrors) + { + tracing::error!( + thread_id = %invocation.session.thread_id, + turn_id = %invocation.turn.sub_id, + "failed to read current time for the clock tool; the clock provider may be stalled" + ); + FunctionCallError::RespondToModel(CurrentTimeUnavailable::MESSAGE.to_string()) + } else { + FunctionCallError::Fatal(format!("failed to read current time: {err:#}")) + } })?; Ok(boxed_tool_output(CurrentTimeOutput( CurrentTimeReminder::new(current_time), diff --git a/codex-rs/core/src/tools/handlers/sleep.rs b/codex-rs/core/src/tools/handlers/sleep.rs index 7defc8314b..45db1a3b57 100644 --- a/codex-rs/core/src/tools/handlers/sleep.rs +++ b/codex-rs/core/src/tools/handlers/sleep.rs @@ -1,3 +1,4 @@ +use crate::context::CurrentTimeUnavailable; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; @@ -8,6 +9,7 @@ use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use codex_extension_items::ExtensionItem; use codex_extension_items::sleep::SleepItem; +use codex_features::Feature; use codex_protocol::items::TurnItem; use codex_tools::JsonSchema; use codex_tools::ResponsesApiNamespace; @@ -111,7 +113,7 @@ impl ToolExecutor for SleepHandler { .input_queue .subscribe_activity(turn_state.as_deref()) .await; - let sleep_result: Result = if pending_activity.is_some() { + let sleep_result = if pending_activity.is_some() { Ok(true) } else { let sleep = session @@ -120,27 +122,29 @@ impl ToolExecutor for SleepHandler { .sleep(session.thread_id, Duration::from_millis(args.duration_ms)); tokio::pin!(sleep); tokio::select! { - result = &mut sleep => result - .map(|()| false) - .map_err(|err| { - FunctionCallError::Fatal(format!("failed to sleep: {err:#}")) - }), + result = &mut sleep => result.map(|()| false), result = activity_rx.changed() => { if result.is_ok() { Ok(true) } else { - sleep - .await - .map(|()| false) - .map_err(|err| { - FunctionCallError::Fatal(format!("failed to sleep: {err:#}")) - }) + sleep.await.map(|()| false) } } } }; session.emit_turn_item_completed(turn.as_ref(), item).await; - let interrupted = sleep_result?; + let interrupted = sleep_result.map_err(|err| { + if turn.config.features.enabled(Feature::NonfatalClockReadErrors) { + tracing::error!( + thread_id = %session.thread_id, + turn_id = %turn.sub_id, + "failed to read current time for the sleep tool; the clock provider may be stalled" + ); + FunctionCallError::RespondToModel(CurrentTimeUnavailable::MESSAGE.to_string()) + } else { + FunctionCallError::Fatal(format!("failed to sleep: {err:#}")) + } + })?; let message = if interrupted { "Sleep interrupted by new input." diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs index e6ada23200..7e66fcaf40 100644 --- a/codex-rs/core/tests/suite/current_time_reminder.rs +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -31,6 +31,7 @@ use core_test_support::assert_regex_match; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; @@ -53,6 +54,8 @@ const SECOND_REMINDER: &str = "It is 2026-06-17 17:35:15 UTC."; const THIRD_REMINDER: &str = "It is 2026-06-17 17:36:15 UTC."; +const CLOCK_UNAVAILABLE: &str = + "failed to read current time"; const FIRST_TIME_UNIX_SECONDS: i64 = 1_781_717_655; #[derive(Clone, Copy)] @@ -648,6 +651,86 @@ async fn time_provider_failure_stops_before_inference() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opted_in_clock_failures_reach_the_model_without_aborting() -> Result<()> { + skip_if_no_network!(Ok(())); + + const CALL_ID: &str = "failed-current-time"; + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace(CALL_ID, "clock", "curr_time", "{}"), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 80), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call_with_namespace("clock-before-compact", "clock", "curr_time", "{}"), + ev_completed_with_tokens("resp-2", /*total_tokens*/ 500), + ]), + sse(vec![ + ev_response_created("resp-compact"), + ev_assistant_message("msg-compact", "compact summary"), + ev_completed("resp-compact"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_completed_with_tokens("resp-3", /*total_tokens*/ 80), + ]), + ], + ) + .await; + let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); + model_provider.name = "OpenAI-compatible test provider".to_string(); + model_provider.base_url = Some(format!("{}/v1", server.uri())); + model_provider.supports_websockets = false; + let test = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + config.model_auto_compact_token_limit = Some(200); + enable_current_time_reminder(config, /*interval*/ 0, CurrentTimeSource::External); + config.include_environment_context = true; + config + .features + .enable(Feature::NonfatalClockReadErrors) + .unwrap(); + }) + .with_external_time_provider(Arc::new(FailingTimeProvider)) + .build_with_auto_env(&server) + .await?; + + test.submit_turn("continue in a new context window").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 4); + assert_eq!( + requests[1].function_call_output_text(CALL_ID), + Some("failed to read current time".to_string()), + ); + for index in [0, 1, 3] { + let request = &requests[index]; + let developer_messages = request.message_input_texts("developer"); + assert_eq!( + developer_messages + .iter() + .map(|text| text.matches(CLOCK_UNAVAILABLE).count()) + .sum::(), + 1, + "request {index} developer messages: {developer_messages:?}", + ); + assert!(current_time_reminders(request).is_empty()); + assert!( + !request + .message_input_texts("user") + .iter() + .any(|text| text.contains("")) + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn current_time_tool_returns_the_latest_time() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index ac5e47f16c..bbd7113cf0 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1623,8 +1623,14 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co .features .enable(Feature::CurrentTimeReminder) .expect("test config should allow feature update"); + config + .features + .enable(Feature::NonfatalClockReadErrors) + .expect("test config should allow feature update"); + config.include_environment_context = false; config.current_time_reminder = Some(CurrentTimeReminderConfig { reminder_interval_seconds: 0, + clock_source: codex_features::CurrentTimeSource::External, ..CurrentTimeReminderConfig::default() }); } @@ -1652,6 +1658,31 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) { builder = builder.with_history_mode(ThreadHistoryMode::Paginated); } + if matches!(selection, FullHistoryV2ModelSelection::CurrentTimeReminders) { + #[derive(Default)] + struct FailFirstClockRead(std::sync::atomic::AtomicBool); + + impl codex_core::TimeProvider for FailFirstClockRead { + fn current_time(&self, _thread_id: ThreadId) -> codex_core::TimeFuture<'_> { + let already_read = self.0.swap(true, std::sync::atomic::Ordering::Relaxed); + Box::pin(async move { + anyhow::ensure!(already_read, "parent clock unavailable"); + Ok(chrono::Utc::now()) + }) + } + + fn sleep( + &self, + _thread_id: ThreadId, + _duration: Duration, + ) -> codex_core::SleepFuture<'_> { + Box::pin(async { Ok(()) }) + } + } + + builder = + builder.with_external_time_provider(std::sync::Arc::new(FailFirstClockRead::default())); + } let test = builder.build(&server).await?; if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) { test.codex.submit(Op::Compact).await?; @@ -1806,15 +1837,23 @@ async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_co ); } if matches!(selection, FullHistoryV2ModelSelection::CurrentTimeReminders) { - let reminder_count = |request: &ResponsesRequest| { + let notice_count = |request: &ResponsesRequest, marker: &str| { request .message_input_texts("developer") .into_iter() - .filter(|text| text.starts_with("")) + .filter(|text| text.starts_with(marker)) .count() }; - assert_eq!(reminder_count(&parent_request), 2); - assert_eq!(reminder_count(&child_request), 1); + assert_eq!( + notice_count(&parent_request, ""), + 1 + ); + assert_eq!(notice_count(&parent_request, ""), 1); + assert_eq!( + notice_count(&child_request, ""), + 0 + ); + assert_eq!(notice_count(&child_request, ""), 1); } let child_body = child_request.body_json(); if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b6355c2d8f..557a9dfb17 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -338,6 +338,8 @@ pub enum Feature { ReasoningEffortOverride, /// Add current-time reminders to model-visible context. CurrentTimeReminder, + /// Report failed clock reads to the model without failing the turn. + NonfatalClockReadErrors, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, /// Prompt Codex Apps connector auth failures through MCP URL elicitations. @@ -1661,6 +1663,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::NonfatalClockReadErrors, + key: "nonfatal_clock_read_errors", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::CollaborationModes, key: "collaboration_modes",