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:
Vivian Fang
2026-09-16 00:56:32 +00:00
committed by copyberry
parent fac58c1153
commit 5bf132cd52
14 changed files with 367 additions and 48 deletions

View File

@@ -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(),

View File

@@ -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"
},

View File

@@ -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))

View File

@@ -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()
}
}

View File

@@ -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;

View File

@@ -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(" ");

View File

@@ -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>"
);
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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),

View File

@@ -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."

View File

@@ -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(()));

View File

@@ -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) {

View File

@@ -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",