mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
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 `<current_date status="unavailable" />`. - 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
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
("<current_time_unavailable>", "</current_time_unavailable>")
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
Self::MESSAGE.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
shell_version_removed: bool,
|
||||
current_date: Option<String>,
|
||||
current_date_removed: bool,
|
||||
timezone: Option<String>,
|
||||
network: Option<NetworkContext>,
|
||||
filesystem: Option<FileSystemContext>,
|
||||
@@ -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(" <current_date status=\"unavailable\" />\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(" ");
|
||||
|
||||
@@ -396,3 +396,31 @@ fn shell_version_diff_clears_previously_visible_version() {
|
||||
"<environment_context>\n <shell_version status=\"unavailable\" />\n</environment_context>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
"<environment_context>\n <current_date status=\"unavailable\" />\n</environment_context>"
|
||||
);
|
||||
assert!(
|
||||
unavailable
|
||||
.render_diff(PreviousSectionState::Known(&unavailable.snapshot()))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
available
|
||||
.render_diff(PreviousSectionState::Known(&unavailable.snapshot()))
|
||||
.expect("restored current date")
|
||||
.render(),
|
||||
"<environment_context>\n <current_date>2026-06-17</current_date>\n</environment_context>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
last_window_id: Option<String>,
|
||||
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<Option<DateTime<Utc>>> {
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ToolInvocation> 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),
|
||||
|
||||
@@ -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<ToolInvocation> for SleepHandler {
|
||||
.input_queue
|
||||
.subscribe_activity(turn_state.as_deref())
|
||||
.await;
|
||||
let sleep_result: Result<bool, FunctionCallError> = 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<ToolInvocation> 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."
|
||||
|
||||
@@ -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 =
|
||||
"<current_time_reminder>It is 2026-06-17 17:35:15 UTC.</current_time_reminder>";
|
||||
const THIRD_REMINDER: &str =
|
||||
"<current_time_reminder>It is 2026-06-17 17:36:15 UTC.</current_time_reminder>";
|
||||
const CLOCK_UNAVAILABLE: &str =
|
||||
"<current_time_unavailable>failed to read current time</current_time_unavailable>";
|
||||
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::<usize>(),
|
||||
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("<current_date>"))
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn current_time_tool_returns_the_latest_time() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -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("<current_time_reminder>"))
|
||||
.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, "<current_time_unavailable>"),
|
||||
1
|
||||
);
|
||||
assert_eq!(notice_count(&parent_request, "<current_time_reminder>"), 1);
|
||||
assert_eq!(
|
||||
notice_count(&child_request, "<current_time_unavailable>"),
|
||||
0
|
||||
);
|
||||
assert_eq!(notice_count(&child_request, "<current_time_reminder>"), 1);
|
||||
}
|
||||
let child_body = child_request.body_json();
|
||||
if matches!(selection, FullHistoryV2ModelSelection::WorldStateIdentity) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user