diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 280c556af..afe269195 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -337,6 +337,79 @@ impl App { self.chat_widget.prepare_local_op_submission(&op); self.submit_active_thread_op(app_server, op).await?; } + AppEvent::RetrySafetyBufferedTurn { + thread_id, + turn_id, + model, + mut turn, + } => { + if self.active_thread_id != Some(thread_id) + || self.chat_widget.thread_id() != Some(thread_id) + { + return Ok(AppRunControl::Continue); + } + if !self.chat_widget.can_retry_safety_buffered_turn(&turn_id) { + self.app_event_tx.send(AppEvent::UpdateModel(model)); + self.app_event_tx.send(AppEvent::UpdateReasoningEffort(Some( + ReasoningEffortConfig::Low, + ))); + return Ok(AppRunControl::Continue); + } + + let AppCommand::UserTurn { + model: turn_model, + effort, + collaboration_mode, + .. + } = &mut turn + else { + self.chat_widget.add_error_message( + "Failed to retry with a faster model: original turn is unavailable." + .to_string(), + ); + return Ok(AppRunControl::Continue); + }; + *turn_model = model.clone(); + *effort = Some(ReasoningEffortConfig::Low); + *collaboration_mode = collaboration_mode.as_ref().map(|mode| { + mode.with_updates( + Some(model), + Some(Some(ReasoningEffortConfig::Low)), + /*developer_instructions*/ None, + ) + }); + + if let Err(err) = app_server.turn_interrupt(thread_id, turn_id).await { + self.chat_widget + .add_error_message(format!("Failed to retry with a faster model: {err}")); + return Ok(AppRunControl::Continue); + } + let rollback_response = + match app_server.thread_rollback(thread_id, /*num_turns*/ 1).await { + Ok(response) => response, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to retry with a faster model: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + + self.chat_widget.prepare_safety_buffering_retry(); + self.handle_thread_rollback_response_with_origin( + thread_id, + /*num_turns*/ 1, + &rollback_response, + super::thread_routing::ThreadRollbackOrigin::SafetyBufferingRetry, + ) + .await; + + if let Err(err) = self.submit_thread_op(app_server, thread_id, turn).await { + self.chat_widget.fail_safety_buffering_retry(); + self.chat_widget + .add_error_message(format!("Failed to retry with a faster model: {err}")); + } + } AppEvent::RestoreCancelledTurn(prompt) => { self.apply_cancelled_turn_edit(prompt); } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 76cf56ef0..b149fd53f 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -7,6 +7,12 @@ use super::*; use crate::session_resume::read_session_model; +#[derive(Clone, Copy)] +pub(super) enum ThreadRollbackOrigin { + Backtrack, + SafetyBufferingRetry, +} + impl App { pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { if let Some(thread_id) = self.chat_widget.thread_id() { @@ -643,7 +649,7 @@ impl App { .as_ref() .map(|profile| &profile.permission_profile), ); - app_server + let response = app_server .turn_start( thread_id, items.to_vec(), @@ -661,6 +667,12 @@ impl App { final_output_json_schema.clone(), ) .await?; + if self.active_thread_id == Some(thread_id) + && self.chat_widget.thread_id() == Some(thread_id) + { + self.chat_widget + .record_safety_buffering_turn(response.turn.id, op); + } } Ok(true) } @@ -1395,6 +1407,22 @@ impl App { thread_id: ThreadId, num_turns: u32, response: &ThreadRollbackResponse, + ) { + self.handle_thread_rollback_response_with_origin( + thread_id, + num_turns, + response, + ThreadRollbackOrigin::Backtrack, + ) + .await; + } + + pub(super) async fn handle_thread_rollback_response_with_origin( + &mut self, + thread_id: ThreadId, + num_turns: u32, + response: &ThreadRollbackResponse, + origin: ThreadRollbackOrigin, ) { if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; @@ -1421,7 +1449,12 @@ impl App { self.clear_active_thread().await; } } - self.handle_backtrack_rollback_succeeded(num_turns); + match origin { + ThreadRollbackOrigin::Backtrack => self.handle_backtrack_rollback_succeeded(num_turns), + ThreadRollbackOrigin::SafetyBufferingRetry => { + self.apply_non_pending_thread_rollback(num_turns); + } + } } pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1c5c949eb..23e42ce8f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -161,6 +161,14 @@ pub(crate) enum AppEvent { op: AppCommand, }, + /// Interrupt, roll back, and retry a safety-buffered turn with the server-selected model. + RetrySafetyBufferedTurn { + thread_id: ThreadId, + turn_id: String, + model: String, + turn: AppCommand, + }, + /// Deliver a synthetic history lookup response to a specific thread channel. ThreadHistoryEntryResponse { thread_id: ThreadId, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 75d319bae..42903d2e7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -395,10 +395,12 @@ mod review_popups; use self::review::ReviewState; #[cfg(test)] pub(crate) use self::review_popups::show_review_commit_picker_with_entries; +mod safety_buffering; mod service_tiers; mod settings; mod settings_popups; mod side; +use self::safety_buffering::SafetyBufferingState; mod status_state; mod windows_sandbox_prompts; use self::status_state::StatusIndicatorState; @@ -584,6 +586,7 @@ pub(crate) struct ChatWidget { last_unified_wait: Option, unified_exec_wait_streak: Option, turn_lifecycle: TurnLifecycleState, + safety_buffering: SafetyBufferingState, task_complete_pending: bool, unified_exec_processes: Vec, /// Tracks per-server MCP startup state while startup is in progress. diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 414a693bd..7fb6fe54e 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -154,6 +154,7 @@ impl ChatWidget { last_unified_wait: None, unified_exec_wait_streak: None, turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep), + safety_buffering: SafetyBufferingState::default(), task_complete_pending: false, unified_exec_processes: Vec::new(), mcp_startup_status: None, diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 085255a1c..0c5b0ed3e 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -147,6 +147,9 @@ impl ChatWidget { ServerNotification::ModelVerification(notification) => { self.on_app_server_model_verification(¬ification.verifications) } + ServerNotification::ModelSafetyBufferingUpdated(notification) => { + self.on_model_safety_buffering_updated(notification, replay_kind) + } ServerNotification::Warning(notification) => self.on_warning(notification.message), ServerNotification::GuardianWarning(notification) => { self.on_warning(notification.message) @@ -208,7 +211,6 @@ impl ChatWidget { | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) - | ServerNotification::ModelSafetyBufferingUpdated(_) | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index a96ab614c..3ab5b3486 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -24,6 +24,7 @@ impl ChatWidget { duration_ms, } = turn; if matches!(status, TurnStatus::InProgress) { + self.turn_lifecycle.last_turn_id = Some(turn_id.clone()); self.last_non_retry_error = None; self.on_task_started(); } diff --git a/codex-rs/tui/src/chatwidget/safety_buffering.rs b/codex-rs/tui/src/chatwidget/safety_buffering.rs new file mode 100644 index 000000000..389048fb0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/safety_buffering.rs @@ -0,0 +1,196 @@ +//! Safety-buffering status and retry UI for active turns. + +use super::*; +use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; + +const SAFETY_BUFFERING_PROMPT_VIEW_ID: &str = "safety-buffering-prompt"; + +const SAFETY_BUFFERING_MESSAGE_WITH_RETRY: &str = "This request requires additional safety checks, which can take extra time. You can retry with a faster model for a quicker response, though it may be less capable of handling complex requests."; +const SAFETY_BUFFERING_MESSAGE_WITHOUT_RETRY: &str = + "This request requires additional safety checks, which can take extra time."; + +#[derive(Debug)] +struct ActiveSafetyBuffering { + turn_id: String, + retry_prompt_shown: bool, + agent_message_started: bool, +} + +#[derive(Debug, Default)] +pub(super) struct SafetyBufferingState { + submitted_turn: Option<(String, AppCommand)>, + active: Option, +} + +impl ChatWidget { + pub(crate) fn record_safety_buffering_turn(&mut self, turn_id: String, turn: &AppCommand) { + self.safety_buffering.submitted_turn = Some((turn_id, turn.clone())); + } + + pub(super) fn reset_safety_buffering_for_turn_start(&mut self) { + self.bottom_pane + .dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID); + self.safety_buffering.active = None; + } + + pub(crate) fn clear_safety_buffering(&mut self) { + self.bottom_pane + .dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID); + self.safety_buffering = SafetyBufferingState::default(); + } + + pub(super) fn mark_safety_buffering_agent_message_started(&mut self) { + if let Some(active) = self.safety_buffering.active.as_mut() { + active.agent_message_started = true; + } + } + + pub(super) fn safety_buffering_is_waiting(&self) -> bool { + self.safety_buffering + .active + .as_ref() + .is_some_and(|active| !active.agent_message_started) + } + + pub(crate) fn can_retry_safety_buffered_turn(&self, turn_id: &str) -> bool { + self.turn_lifecycle.agent_turn_running + && self + .safety_buffering + .active + .as_ref() + .is_some_and(|active| active.turn_id == turn_id && !active.agent_message_started) + } + + pub(crate) fn prepare_safety_buffering_retry(&mut self) { + let cancel_edit = std::mem::take(&mut self.cancel_edit); + self.last_rendered_user_message_display = None; + self.finalize_turn(); + self.cancel_edit = cancel_edit; + self.input_queue.user_turn_pending_start = true; + } + + pub(crate) fn fail_safety_buffering_retry(&mut self) { + self.input_queue.user_turn_pending_start = false; + self.clear_safety_buffering(); + let prompt = self.cancel_edit.prompt.take(); + self.clear_cancel_edit(); + if let Some(prompt) = prompt { + self.restore_user_message_to_composer(prompt); + } + } + + pub(super) fn on_model_safety_buffering_updated( + &mut self, + notification: ModelSafetyBufferingUpdatedNotification, + replay_kind: Option, + ) { + let ModelSafetyBufferingUpdatedNotification { + turn_id, + show_buffering_ui, + faster_model, + .. + } = notification; + if matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) + || !self.turn_lifecycle.agent_turn_running + || self.turn_lifecycle.last_turn_id.as_deref() != Some(turn_id.as_str()) + { + return; + } + if !show_buffering_ui { + if self + .safety_buffering + .active + .as_ref() + .is_some_and(|active| active.turn_id == turn_id) + { + self.bottom_pane + .dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID); + self.safety_buffering.active = None; + self.restore_reasoning_status_header(); + } + return; + } + + let retry_turn = self + .safety_buffering + .submitted_turn + .as_ref() + .filter(|(submitted_turn_id, _)| replay_kind.is_none() && submitted_turn_id == &turn_id) + .map(|(_, turn)| turn.clone()); + let thread_id = self.thread_id; + let can_offer_retry = faster_model.is_some() && retry_turn.is_some() && thread_id.is_some(); + if !can_offer_retry { + self.bottom_pane + .dismiss_view_by_id(SAFETY_BUFFERING_PROMPT_VIEW_ID); + } + let previous_active = self + .safety_buffering + .active + .as_ref() + .filter(|active| active.turn_id == turn_id); + let retry_prompt_shown = previous_active.is_some_and(|active| active.retry_prompt_shown); + let should_show_retry_prompt = can_offer_retry && !retry_prompt_shown; + let agent_message_started = + previous_active.is_some_and(|active| active.agent_message_started); + self.safety_buffering.active = Some(ActiveSafetyBuffering { + turn_id: turn_id.clone(), + retry_prompt_shown: retry_prompt_shown || should_show_retry_prompt, + agent_message_started, + }); + + let message = if can_offer_retry { + SAFETY_BUFFERING_MESSAGE_WITH_RETRY + } else { + SAFETY_BUFFERING_MESSAGE_WITHOUT_RETRY + }; + self.bottom_pane.ensure_status_indicator(); + self.set_status( + "Working".to_string(), + Some(message.to_string()), + StatusDetailsCapitalization::Preserve, + /*details_max_lines*/ 6, + ); + + let (Some(faster_model), Some(turn), Some(thread_id)) = + (faster_model, retry_turn, thread_id) + else { + return; + }; + if !should_show_retry_prompt { + return; + } + + let header = ColumnRenderable::with(vec![ + Box::new(Line::from("Additional safety checks").bold()) as Box, + Box::new( + Paragraph::new(Line::from(SAFETY_BUFFERING_MESSAGE_WITH_RETRY).dim()) + .wrap(Wrap { trim: false }), + ), + ]); + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(SAFETY_BUFFERING_PROMPT_VIEW_ID), + header: Box::new(header), + items: vec![ + SelectionItem { + name: "Retry with a faster model".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::RetrySafetyBufferedTurn { + thread_id, + turn_id: turn_id.clone(), + model: faster_model.clone(), + turn: turn.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep waiting".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } +} diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 004f64c72..177700d4f 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -27,6 +27,7 @@ impl ChatWidget { } self.refresh_plan_mode_nudge(); self.turn_lifecycle.reset_thread(); + self.clear_safety_buffering(); self.thread_name = session.thread_name.clone(); self.current_goal_status_indicator = None; self.current_goal_status = None; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap new file mode 100644 index 000000000..60fd4d38c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_retry_prompt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: popup +--- + Additional safety checks + This request requires additional safety checks, which can take extra time. + You can retry with a faster model for a quicker response, though it may be + less capable of handling complex requests. + +› 1. Retry with a faster model + 2. Keep waiting + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap new file mode 100644 index 000000000..a719f937c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__safety_buffering_status_without_retry.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: "render_bottom_popup(&chat, 80)" +--- +• Working (0s • esc to interrupt) + └ This request requires additional safety checks, which can take extra time. + + +› Ask Codex to do anything + + gpt-5.5 default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 897094b1f..f8b012420 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -203,6 +203,11 @@ impl ChatWidget { // (between **/**) as the chunk header. Show this header as status. self.reasoning_buffer.push_str(&delta); + if self.safety_buffering_is_waiting() { + self.request_redraw(); + return; + } + if self.unified_exec_wait_streak.is_some() { // Unified exec waiting should take precedence over reasoning-derived status headers. self.request_redraw(); @@ -383,6 +388,7 @@ impl ChatWidget { pub(super) fn handle_streaming_delta(&mut self, delta: String) { if !delta.is_empty() { self.record_visible_turn_activity(); + self.mark_safety_buffering_agent_message_started(); } if self.stream_controller.is_none() { // Before starting an agent stream, flush any active exec cell group. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1af814eb7..24423d373 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -77,6 +77,7 @@ pub(super) use codex_app_server_protocol::MarketplaceUpgradeResponse; pub(super) use codex_app_server_protocol::McpServerStartupState; pub(super) use codex_app_server_protocol::McpServerStatusDetail; pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification; +pub(super) use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification; pub(super) use codex_app_server_protocol::ModelVerificationNotification; pub(super) use codex_app_server_protocol::NonSteerableTurnKind; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index a10c2cca9..2e6d7f8f7 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -61,6 +61,194 @@ fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::Threa } } +fn start_safety_buffering_test_turn( + chat: &mut ChatWidget, + op_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> (ThreadId, &'static str, Op) { + let thread_id = ThreadId::new(); + let turn_id = "turn-safety-buffering"; + chat.thread_id = Some(thread_id); + chat.submit_user_message(UserMessage::from("Explain the request")); + let turn = next_submit_op(op_rx); + assert_matches!(&turn, Op::UserTurn { .. }); + chat.record_safety_buffering_turn(turn_id.to_string(), &turn); + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: thread_id.to_string(), + turn: AppServerTurn { + id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); + (thread_id, turn_id, turn) +} + +fn safety_buffering_notification( + thread_id: ThreadId, + turn_id: &str, + faster_model: Option<&str>, +) -> ModelSafetyBufferingUpdatedNotification { + ModelSafetyBufferingUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + model: "current-model".to_string(), + use_cases: Vec::new(), + reasons: Vec::new(), + show_buffering_ui: true, + faster_model: faster_model.map(str::to_string), + } +} + +#[tokio::test] +async fn safety_buffering_offers_one_retry_with_app_wording() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx); + + let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model")); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(notification.clone()), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(notification), + /*replay_kind*/ None, + ); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("safety_buffering_retry_prompt", popup); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let (event_thread_id, event_turn_id, model, turn) = loop { + match rx.try_recv() { + Ok(AppEvent::RetrySafetyBufferedTurn { + thread_id, + turn_id, + model, + turn, + }) => break (thread_id, turn_id, model, turn), + Ok(_) => continue, + Err(err) => panic!("expected safety-buffering retry event: {err}"), + } + }; + assert_eq!(event_thread_id, thread_id); + assert_eq!(event_turn_id, turn_id); + assert_eq!(model, "faster-model"); + assert_matches!(turn, Op::UserTurn { .. }); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks")); +} + +#[tokio::test] +async fn safety_buffering_stops_retrying_after_agent_message_starts() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification( + thread_id, + turn_id, + Some("faster-model"), + )), + /*replay_kind*/ None, + ); + assert!(chat.can_retry_safety_buffered_turn(turn_id)); + + chat.on_agent_message_delta("Visible response".to_string()); + + assert!(!chat.can_retry_safety_buffered_turn(turn_id)); +} + +#[tokio::test] +async fn safety_buffering_without_retry_shows_short_app_message() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (thread_id, turn_id, turn) = start_safety_buffering_test_turn(&mut chat, &mut op_rx); + + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification( + thread_id, turn_id, /*faster_model*/ None, + )), + /*replay_kind*/ None, + ); + + let render_popup = |chat: &ChatWidget| { + normalize_snapshot_paths(render_bottom_popup(chat, /*width*/ 80)) + }; + let popup = render_popup(&chat); + assert_chatwidget_snapshot!("safety_buffering_status_without_retry", popup,); + + let notification = safety_buffering_notification(thread_id, turn_id, Some("faster-model")); + chat.record_safety_buffering_turn("other-turn".to_string(), &turn); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(notification.clone()), + /*replay_kind*/ None, + ); + assert_eq!(render_popup(&chat), popup); + + chat.record_safety_buffering_turn(turn_id.to_string(), &turn); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(notification), + Some(ReplayKind::ThreadSnapshot), + ); + assert_eq!(render_popup(&chat), popup); +} + +#[tokio::test] +async fn safety_buffering_ignores_hidden_stale_and_historical_updates() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (thread_id, turn_id, _) = start_safety_buffering_test_turn(&mut chat, &mut op_rx); + + let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model")); + hidden.show_buffering_ui = false; + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(hidden), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification( + thread_id, + "stale-turn", + Some("faster-model"), + )), + /*replay_kind*/ None, + ); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(safety_buffering_notification( + thread_id, + turn_id, + Some("faster-model"), + )), + Some(ReplayKind::ResumeInitialMessages), + ); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks")); + + let mut hidden = safety_buffering_notification(thread_id, turn_id, Some("faster-model")); + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(hidden.clone()), + /*replay_kind*/ None, + ); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks")); + hidden.show_buffering_ui = false; + chat.handle_server_notification( + ServerNotification::ModelSafetyBufferingUpdated(hidden), + /*replay_kind*/ None, + ); + + assert_eq!( + chat.bottom_pane + .status_widget() + .expect("status indicator should be visible") + .details(), + None + ); + assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Additional safety checks")); +} + #[tokio::test] async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; @@ -969,6 +1157,24 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn app_server_safety_access_errors_render_dedicated_notice() { + let message = "Invalid prompt: we've limited access to this content for safety reasons."; + for message in [ + message.to_string(), + json!({ "error": { "message": message } }).to_string(), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.handle_non_retry_error(message, /*codex_error_info*/ None); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("This content can't be shown")); + assert!(!rendered.contains("Invalid prompt:")); + } +} + #[tokio::test] async fn live_app_server_model_verification_renders_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index df3c03ed5..8af43550e 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -5,6 +5,9 @@ use super::*; +const SAFETY_ACCESS_BLOCK_PREFIX: &str = + "Invalid prompt: we've limited access to this content for safety reasons."; + impl ChatWidget { /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. /// @@ -48,6 +51,7 @@ impl ChatWidget { pub(super) fn on_task_started(&mut self) { self.input_queue.user_turn_pending_start = false; + self.reset_safety_buffering_for_turn_start(); self.turn_lifecycle.start(Instant::now()); self.transcript.reset_turn_flags(); self.adaptive_chunking.reset(); @@ -297,6 +301,7 @@ impl ChatWidget { /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup /// and should continue to drive the bottom-pane running indicator while it is in progress. pub(super) fn finalize_turn(&mut self) { + self.clear_safety_buffering(); // Drop preview-only stream tail content on any termination path before // failed-cell finalization, so transient tail cells are never persisted. self.clear_active_stream_tail(); @@ -424,6 +429,18 @@ impl ChatWidget { .is_some_and(is_app_server_cyber_policy_error) { self.on_cyber_policy_error(); + } else if message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX) + || serde_json::from_str::(&message).is_ok_and(|response| { + response["error"]["message"] + .as_str() + .is_some_and(|message| message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX)) + }) + { + self.input_queue.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_safety_access_block_event()); + self.request_redraw(); + self.maybe_send_next_queued_input(); } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index d9dff4f5a..c4cc79cf7 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -85,6 +85,37 @@ pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ") } +#[derive(Debug)] +pub(crate) struct SafetyAccessBlockCell; + +const SAFETY_ACCESS_BLOCK_TITLE: &str = "This content can't be shown"; +const SAFETY_ACCESS_BLOCK_BODY: &str = "We've limited access to this content for safety reasons. This type of information may be used to benefit or to harm people."; + +pub(crate) fn new_safety_access_block_event() -> SafetyAccessBlockCell { + SafetyAccessBlockCell +} + +impl HistoryCell for SafetyAccessBlockCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines = vec![vec!["ⓘ ".cyan(), SAFETY_ACCESS_BLOCK_TITLE.bold()].into()]; + let body = Line::from(vec![" ".into(), SAFETY_ACCESS_BLOCK_BODY.dim()]); + let wrap_width = width.saturating_sub(2).max(1) as usize; + let wrapped = adaptive_wrap_line( + &body, + RtOptions::new(wrap_width).subsequent_indent(" ".into()), + ); + push_owned_lines(&wrapped, &mut lines); + lines + } + + fn raw_lines(&self) -> Vec> { + vec![ + Line::from(SAFETY_ACCESS_BLOCK_TITLE), + Line::from(SAFETY_ACCESS_BLOCK_BODY), + ] + } +} + const TRUSTED_ACCESS_FOR_CYBER_URL: &str = "https://chatgpt.com/cyber"; #[derive(Debug)] diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__safety_access_block_event_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__safety_access_block_event_snapshot.snap new file mode 100644 index 000000000..f51269788 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__safety_access_block_event_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +ⓘ This content can't be shown + We've limited access to this content for safety reasons. This type of + information may be used to benefit or to harm people. diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 99f7a3a93..42dadb853 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -699,6 +699,13 @@ fn cyber_policy_error_event_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn safety_access_block_event_snapshot() { + let cell = new_safety_access_block_event(); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); + insta::assert_snapshot!(rendered); +} + #[test] fn cyber_policy_error_event_narrow_snapshot() { let cell = new_cyber_policy_error_event();