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