diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index fcd201a8eb..5da0fb956c 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -168,6 +168,109 @@ async fn wait_for_turn_completed( } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_recourse_handles_a_policy_block() -> Result<()> { + let (mut app, mut events, _) = make_test_app_with_channels().await; + let model = responses::start_mock_server().await; + app_test_support::MockResponsesConfig::new(&model.uri()) + .write(app.config.codex_home.as_path())?; + app.config = app + .rebuild_config_for_cwd(app.config.cwd.to_path_buf()) + .await?; + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + for (domain, error_domain) in [("cyber", "cyber"), ("bio", "bio"), ("cyber", "bio")] { + let topic = if domain == "cyber" { + "unauthorized_account_or_system_access" + } else { + "risky_biological_activities" + }; + let bio_message = "This request has been flagged for possible biological risk."; + let before_block = if domain == "bio" { + ev_assistant_message("partial", "Checking the request.") + } else { + serde_json::json!({"type": "response.metadata", "metadata": { + "type": "safety_buffering", "use_cases": [domain], "reasons": ["policy-check"] + }}) + }; + let _model_requests = responses::mount_sse_once( + &model, + responses::sse(vec![ + ev_response_created("blocked"), + before_block, + serde_json::json!({"type": "response.metadata", "metadata": { + "openai_chatgpt_moderation_metadata": {"safety_block": { + "schema_version": 1, "domain": domain, "status": "loading" + }} + }}), + serde_json::json!({"type": "response.metadata", "metadata": { + "openai_chatgpt_moderation_metadata": {"safety_recourse": { + "schema_version": 1, "domain": domain, "source": "user_request", + "source_message": {"id": format!("{}🔒", "a".repeat(255)), "kind": "ordinary_message", "role": "user"}, + "topic": topic, + "suggestions": [ + {"label": "Recover an account", "prompt_text": "Help me recover my account safely."}, + {"label": "Protect a system", "prompt_text": "Explain system protection."}, + {"label": "Understand risks", "prompt_text": "Explain account security risks."} + ] + }} + }}), + serde_json::json!({"type": "response.failed", "response": { + "id": "blocked", "status": "failed", + "error": {"code": format!("{error_domain}_policy"), "message": (error_domain == "bio").then_some(bio_message)} + }}), + ]), + ) + .await; + let started = server.start_thread(&app.config).await?; + let source_id = started.session.thread_id; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started, + ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + submit_prompt(&mut app, "Blocked original prompt"); + app.submit_thread_op(&mut server, source_id, next_user_turn_event(&mut events)) + .await?; + loop { + let event = server.next_event().await.expect("moderation metadata"); + let is_metadata = matches!(&event.event, AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::TurnModerationMetadata(_))); + app.handle_backend_event(&server, event).await; + drain_active_thread_events(&mut app); + if is_metadata { + break; + } + } + let loading = render_bottom_popup(&app.chat_widget, /*width*/ 80); + let loading = loading.split(" (").next().unwrap_or_default(); + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + assert!(!cell.as_any().is::()); + } + } + wait_for_turn_completed(&mut app, &mut server, source_id).await; + let notice = next_history_message(&mut events); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("This content can")); + if domain != error_domain { + let generic = + history_cell::new_safety_access_block_event().display_lines(/*width*/ 80); + assert_eq!(notice, lines_to_single_string(&generic)); + continue; + } + if domain == "bio" { + assert!(loading.contains("Finding an explanation and safer ways to continue…")); + assert!(notice.contains("research and safety considerations.")); + continue; + } + insta::assert_snapshot!("safety_recourse", format!("{loading}\n\n{notice}")); + } + server.shutdown().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn active_turn_interrupt_is_nonblocking_and_coalesces_repeated_requests() -> Result<()> { Box::pin(interrupt_after_inactive_steer(SteerSwitch::WithinTask)).await diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_recourse.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_recourse.snap new file mode 100644 index 0000000000..1cbc45ef1d --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_recourse.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/tests/safety_buffering.rs +expression: "format!(\"{loading}\\n\\n{notice}\")" +--- +ⓘ This content can’t be shown + We take extra care with some cybersecurity requests. If you’re doing + authorized security work, apply for Daybreak to get broader access. + Learn more: https://help.openai.com/en/articles/20001326 + Apply for Daybreak: https://openai.com/form/enterprise-trusted-access-for- + cyber/ +• Finding an explanation and safer ways to continue… + +ⓘ This content can’t be shown + We take extra caution with requests that could enable access to accounts or + systems beyond permitted scope. We can still help with an overview of + account security and recovery. If you’re doing authorized security work, + apply for Daybreak to get broader access. + Learn more: https://help.openai.com/en/articles/20001326 + Apply for Daybreak: https://openai.com/form/enterprise-trusted-access-for- + cyber/ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index da2d0536ff..68868be0c7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -379,6 +379,7 @@ mod review; mod review_popups; use self::review::ReviewState; mod safety_buffering; +mod safety_recourse; mod service_tiers; mod settings; mod settings_popups; @@ -620,6 +621,7 @@ pub(crate) struct ChatWidget { last_unified_wait: Option, unified_exec_wait_streak: Option, turn_lifecycle: TurnLifecycleState, + safety_recourse: Option, realtime_conversation: RealtimeConversationUiState, realtime_conversation_available_for_thread: bool, safety_buffering: SafetyBufferingState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 8d4bbbd182..7b2d08c691 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -193,6 +193,7 @@ impl ChatWidget { last_unified_wait: None, unified_exec_wait_streak: None, turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep), + safety_recourse: None, realtime_conversation: RealtimeConversationUiState::default(), realtime_conversation_available_for_thread: false, safety_buffering: SafetyBufferingState::default(), diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index ce5da8c93b..fd7fb8f86c 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -213,6 +213,7 @@ impl ChatWidget { .queued_user_messages .push_back(QueuedUserMessage { user_message, + prepared_images: None, action, delivery: MessageDelivery::Unsent, pending_pastes, @@ -279,6 +280,7 @@ impl ChatWidget { QueuedInputAction::Literal => { let QueuedUserMessage { user_message, + prepared_images, pending_pastes, source, .. @@ -307,11 +309,12 @@ impl ChatWidget { ); } submitted_follow_up = self - .submit_user_message_with_history_and_shell_escape_policy( + .submit_user_message_with_prepared_images( user_message, history_record, ShellEscapePolicy::Disallow, source, + prepared_images, ) .0; if !submitted_follow_up { diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 9998e77583..dd2fb80f35 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -140,7 +140,10 @@ impl ChatWidget { return (false, None); } self.empty_state_animation.borrow_mut().dismiss(); - if self.input_queue.rate_limit_recovery_pending || self.pending_image_submission.is_some() { + if self.input_queue.rate_limit_recovery_pending + || self.pending_image_submission.is_some() + || self.safety_recourse.is_some() + { let model_prompt = source == UserMessageSource::Prompt && (shell_escape_policy == ShellEscapePolicy::Disallow || !user_message.text.starts_with('!')); @@ -154,6 +157,7 @@ impl ChatWidget { queue_index, QueuedUserMessage { source, + prepared_images, ..QueuedUserMessage::new( user_message, match shell_escape_policy { diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 0cb7a7a984..e1cd4adeb4 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -87,6 +87,7 @@ impl ChatWidget { self.on_thread_settings_updated(notification); } ServerNotification::TurnStarted(notification) => { + self.safety_recourse = None; if from_replay { self.restore_realtime_transcripts_before_turn(¬ification.turn.id); } else { @@ -211,7 +212,7 @@ impl ChatWidget { notification.error.additional_details, ); } - } else { + } else if from_replay || !safety_recourse::defer_error(self, ¬ification) { self.last_non_retry_error = Some(( notification.turn_id.clone(), notification.error.message.clone(), @@ -242,6 +243,11 @@ impl ChatWidget { ServerNotification::ModelSafetyBufferingUpdated(notification) => { self.on_model_safety_buffering_updated(notification, replay_kind) } + ServerNotification::TurnModerationMetadata(notification) => { + if !from_replay { + safety_recourse::on_metadata(self, notification); + } + } ServerNotification::AuthRecoveryStarted(notification) => { self.add_info_message(notification.message, /*hint*/ None) } @@ -388,7 +394,6 @@ impl ChatWidget { | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) - | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeItemAdded(_) @@ -430,6 +435,9 @@ impl ChatWidget { // client can submit the same text and it still needs its own user cell. self.last_rendered_user_message_display = None; let mut question_drafts = None; + let live_turn = + replay_kind.is_none() && self.last_turn_id() == Some(notification.turn.id.as_str()); + let mut recourse = self.safety_recourse.take_if(|_| live_turn); let was_replaying_turn_completion = self.thread_usage.replaying_turn_completion; self.thread_usage.replaying_turn_completion = replay_kind.is_some(); match notification.turn.status { @@ -504,7 +512,11 @@ impl ChatWidget { if replay_kind.is_none() { question_drafts = self.take_question_drafts(); } - if let Some(error) = notification.turn.error { + let error = notification + .turn + .error + .or_else(|| recourse.as_mut()?.deferred_error.take()); + if let Some(error) = error { if replay_kind.is_none() && error.codex_error_info == Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation) @@ -514,6 +526,8 @@ impl ChatWidget { error.misalignment, ); self.last_non_retry_error = None; + } else if safety_recourse::render_failure(self, recourse, &error) { + self.last_non_retry_error = None; } else if self.last_non_retry_error.as_ref() == Some(&(notification.turn.id.clone(), error.message.clone())) { diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index d32142a67a..4f31674e5b 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -258,14 +258,17 @@ impl ChatWidget { options.separate_status_line = options.command_popup_placement != crate::bottom_pane::CommandPopupPlacement::AboveComposer; let bottom_pane = self.bottom_pane.backdrop_with_options(options); - if !self.quick_dial_menu { + let above_composer = if self.quick_dial_menu { + RenderableItem::Owned(Box::new(self.quick_dial_hud())) + } else if let Some(pending) = &self.safety_recourse + && !self.bottom_pane.has_active_view() + { + RenderableItem::Borrowed(&pending.notice) + } else { return bottom_pane; - } + }; let mut column = FlexRenderable::new(); - column.push( - /*flex*/ 1, - RenderableItem::Owned(Box::new(self.quick_dial_hud())), - ); + column.push(/*flex*/ 1, above_composer); column.push(/*flex*/ 0, bottom_pane); RenderableItem::Owned(Box::new(column)) } diff --git a/codex-rs/tui/src/chatwidget/safety_buffering.rs b/codex-rs/tui/src/chatwidget/safety_buffering.rs index dc3fb0b805..4b7d512f49 100644 --- a/codex-rs/tui/src/chatwidget/safety_buffering.rs +++ b/codex-rs/tui/src/chatwidget/safety_buffering.rs @@ -114,6 +114,7 @@ impl ChatWidget { } = notification; if matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) || !self.turn_lifecycle.agent_turn_running + || self.safety_recourse.is_some() || self.turn_lifecycle.last_turn_id.as_deref() != Some(turn_id.as_str()) || self .safety_buffering diff --git a/codex-rs/tui/src/chatwidget/safety_recourse.rs b/codex-rs/tui/src/chatwidget/safety_recourse.rs new file mode 100644 index 0000000000..25f163a141 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/safety_recourse.rs @@ -0,0 +1,102 @@ +//! Turn-scoped safety recourse: match the terminal policy error and explain the block. + +mod metadata; + +use super::*; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnModerationMetadataNotification; +use metadata::Domain; + +pub(super) struct PendingRecourse { + domain: Domain, + pub(super) notice: Box, + result: Option, + pub(super) deferred_error: Option, +} + +pub(super) fn on_metadata(chat: &mut ChatWidget, notification: TurnModerationMetadataNotification) { + if !chat.turn_lifecycle.agent_turn_running + || !matches_turn(chat, ¬ification.thread_id, ¬ification.turn_id) + || chat.side_conversation_active() + || chat.config_ref().ephemeral + || chat.blocks_direct_input + || (chat.safety_recourse.is_some() && chat.has_queued_follow_up_messages()) + { + return; + } + let Some((domain, result)) = metadata::parse(notification.metadata) else { + return; + }; + let pending = chat.safety_recourse.get_or_insert(PendingRecourse { + domain, + notice: Box::new(match domain { + Domain::Bio => history_cell::new_safety_access_block_event(), + Domain::Cyber => history_cell::new_cyber_policy_error_event(chat.cyber_policy_notice()), + }), + result: None, + deferred_error: None, + }); + if pending.domain == domain && pending.result.is_none() { + pending.result = result; + chat.clear_safety_buffering(); + chat.bottom_pane.ensure_status_indicator(); + chat.set_status_header("Finding an explanation and safer ways to continue…".into()); + } +} + +fn matches_turn(chat: &ChatWidget, thread_id: &str, turn_id: &str) -> bool { + chat.last_turn_id() == Some(turn_id) + && chat + .thread_id() + .is_some_and(|id| id.to_string() == thread_id) +} + +fn matches_error(domain: Domain, error: &TurnError) -> bool { + match domain { + Domain::Bio => super::turn_runtime::is_bio_policy_error(&error.message), + Domain::Cyber => error.codex_error_info == Some(AppServerCodexErrorInfo::CyberPolicy), + } +} + +pub(super) fn defer_error(chat: &mut ChatWidget, notification: &ErrorNotification) -> bool { + if matches_turn(chat, ¬ification.thread_id, ¬ification.turn_id) + && let Some(pending) = &mut chat.safety_recourse + && matches_error(pending.domain, ¬ification.error) + { + pending.deferred_error = Some(notification.error.clone()); + return true; + } + false +} + +pub(super) fn render_failure( + chat: &mut ChatWidget, + pending: Option, + error: &TurnError, +) -> bool { + let Some(pending) = pending else { + return false; + }; + let Some(result) = pending.result else { + return false; + }; + if chat.has_queued_follow_up_messages() || !matches_error(pending.domain, error) { + return false; + } + let mut cell = match pending.domain { + Domain::Bio => history_cell::new_safety_access_block_event(), + Domain::Cyber => { + let notice = chat.cyber_policy_notice(); + if notice == crate::daybreak::Notice::Disabled { + return false; + } + history_cell::new_cyber_policy_error_event(notice) + } + }; + cell.explanation = Some(result); + chat.input_queue.submit_pending_steers_after_interrupt = false; + chat.finalize_turn(); + chat.add_to_history(cell); + chat.request_redraw(); + true +} diff --git a/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs b/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs new file mode 100644 index 0000000000..77b87be6b3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs @@ -0,0 +1,158 @@ +//! Validates and renders the desktop safety-recourse metadata contract. + +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashSet; + +#[derive(Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(super) enum Domain { + Bio, + Cyber, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Block { + schema_version: u8, + domain: Domain, + status: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WireResult { + schema_version: u8, + domain: Domain, + source: String, + source_message: SourceMessage, + topic: String, + suggestions: [Suggestion; 3], +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceMessage { + id: String, + kind: String, + role: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Suggestion { + label: String, + prompt_text: String, +} + +pub(super) fn parse(mut value: Value) -> Option<(Domain, Option)> { + let object = value.as_object_mut()?; + let recourse = object.remove("safety_recourse").filter(|v| !v.is_null()); + let Some(value) = recourse else { + let block: Block = serde_json::from_value(object.remove("safety_block")?).ok()?; + return (block.schema_version == 1 && block.status == "loading") + .then_some((block.domain, None)); + }; + let wire: WireResult = serde_json::from_value(value).ok()?; + let message = &wire.source_message; + if wire.schema_version != 1 || !valid_text(&message.id, /*max_chars*/ 256) { + return None; + } + let (bio_lead, cyber_lead, bio_offer) = match ( + wire.source.as_str(), + message.kind.as_str(), + message.role.as_str(), + ) { + ("user_request", "ordinary_message", "user") => ( + "with requests involving ", + "with requests that could enable ", + None, + ), + ("assistant_response", "ordinary_message", "assistant") => ( + "when a response would provide information about ", + "when a response could enable ", + None, + ), + ("file_content", "file_tool_call", "assistant") + | ("file_content", "file_tool_result", "tool") => ( + "when content from a file involves ", + "when content could enable ", + Some("You can continue without this file or get an overview of "), + ), + ("web_search_result", "web_tool_call", "assistant") + | ("web_search_result", "web_tool_result", "tool") => ( + "when a web search result contains information about ", + "when content could enable ", + Some("You can retry without web search or get an overview of "), + ), + ("other_tool", "other_tool_call", "assistant") + | ("other_tool", "other_tool_result", "tool") => { + ("when content involves ", "when content could enable ", None) + } + _ => return None, + }; + let (concern, help) = match (wire.domain, wire.topic.as_str()) { + (Domain::Bio, "biological_agent_access_and_safeguards") => ( + "access to biological materials and related safety controls", + "access processes and safeguards", + ), + (Domain::Bio, "biological_agent_growth_or_production") => ( + "growing or producing biological agents", + "the science and safety considerations", + ), + (Domain::Bio, "risky_biological_experimentation_or_modification") => ( + "biological experimentation or modification that could pose safety risks", + "the research goals and safety considerations", + ), + (Domain::Bio, "harmful_biological_exposure_or_release") => ( + "biological exposure or release that could cause harm", + "the risks, prevention, and response", + ), + (Domain::Bio, "risky_biological_activities") => ( + "biological activities that could pose safety risks", + "biological research and safety considerations", + ), + (Domain::Cyber, "unauthorized_account_or_system_access") => ( + "access to accounts or systems beyond permitted scope", + "account security and recovery", + ), + (Domain::Cyber, "high_risk_cybersecurity_tools") => ( + "system disruption or the development of high-risk cyber tools", + "system protection and incident recovery", + ), + (Domain::Cyber, "sensitive_data_or_private_communications") => ( + "sensitive data collection or private communications monitoring", + "data protection and privacy safeguards", + ), + (Domain::Cyber, "software_vulnerabilities_or_security_weaknesses") => ( + "capability or technique development for bypassing security controls", + "safe assessment and remediation", + ), + (Domain::Cyber, "risky_cybersecurity_activities") => ( + "cyber activities that pose security or safety risks", + "cybersecurity fundamentals and defensive best practices", + ), + _ => return None, + }; + let mut prompts = HashSet::new(); + for suggestion in &wire.suggestions { + if !valid_text(&suggestion.label, /*max_chars*/ 80) + || !valid_text(&suggestion.prompt_text, /*max_chars*/ 2_000) + || !prompts.insert(suggestion.prompt_text.trim()) + { + return None; + } + } + let overview = "We can still help with an overview of "; + let (lead, offer) = match wire.domain { + Domain::Bio => (bio_lead, bio_offer.unwrap_or(overview)), + Domain::Cyber => (cyber_lead, overview), + }; + let explanation = format!("We take extra caution {lead}{concern}. {offer}{help}."); + Some((wire.domain, Some(explanation))) +} + +fn valid_text(value: &str, max_chars: usize) -> bool { + let value = value.trim(); + !value.is_empty() && value.chars().count() <= max_chars +} diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 90c06a91d2..a1ff412d4d 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -125,6 +125,7 @@ impl ChatWidget { self.clear_thread_usage_state(); } self.turn_lifecycle.reset_thread(); + self.safety_recourse = None; self.clear_safety_buffering(); self.thread_name = session.thread_name.clone(); self.current_goal_status_indicator = None; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index f1e0b40973..5ec00ac55b 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -389,6 +389,7 @@ async fn parent_owned_thread_preserves_queued_input_before_draining() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await; let queued_message = QueuedUserMessage { user_message: UserMessage::from("keep this queued prompt"), + prepared_images: None, action: QueuedInputAction::Plain, delivery: MessageDelivery::Unsent, pending_pastes: vec![("[Image 1]".to_string(), "pasted contents".to_string())], @@ -2826,6 +2827,7 @@ fn image_preparation_keeps_input_responsive_and_preserves_pending_input() { if scenario == "rate_limit_recovery" { chat.input_queue.rate_limit_recovery_pending = true; chat.on_images_prepared(id); + std::fs::remove_file(&message.local_images[0].path).unwrap(); assert_eq!( chat.input_queue .queued_user_messages @@ -2834,6 +2836,10 @@ fn image_preparation_keeps_input_responsive_and_preserves_pending_input() { .collect::>(), vec![message, UserMessage::from("x")], ); + chat.input_queue.rate_limit_recovery_pending = false; + assert!(chat.maybe_send_next_queued_input()); + assert_matches!(next_submit_op(&mut ops), Op::UserTurn { items, .. } + if matches!(items.first(), Some(UserInput::Image { .. }))); } else if scenario == "model_change" { chat.handle_paste("new draft".into()); let mut models = chat.model_catalog.try_list_models().unwrap(); diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index d9726d2fc4..ef5954a853 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -11,9 +11,20 @@ const LEGACY_SAFETY_ACCESS_BLOCK_PREFIX: &str = const BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX: &str = "This content was flagged for possible biological risk."; +pub(super) fn is_bio_policy_error(message: &str) -> bool { + is_safety_access_block_message(message) + || serde_json::from_str::(message).is_ok_and(|response| { + response["error"]["code"].as_str() == Some("bio_policy") + || response["error"]["message"] + .as_str() + .is_some_and(is_safety_access_block_message) + }) +} + fn is_safety_access_block_message(message: &str) -> bool { message.starts_with(LEGACY_SAFETY_ACCESS_BLOCK_PREFIX) || message.starts_with(BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX) + || message.starts_with("This request has been flagged for possible biological risk.") } impl ChatWidget { @@ -341,6 +352,7 @@ impl ChatWidget { self.reasoning_header = None; self.clear_context_compaction(); self.clear_safety_buffering(); + self.safety_recourse = None; // 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(); @@ -414,15 +426,19 @@ impl ChatWidget { true } - pub(super) fn on_cyber_policy_error(&mut self) { + pub(super) fn cyber_policy_notice(&self) -> crate::daybreak::Notice { let can_enable_daybreak = self.daybreak_account_eligible() && !self.side_conversation_active(); - let notice = crate::daybreak::notice_for_setting( + crate::daybreak::notice_for_setting( &self.model_catalog.models, self.current_model(), self.daybreak_enabled && can_enable_daybreak, can_enable_daybreak, - ); + ) + } + + pub(super) fn on_cyber_policy_error(&mut self) { + let notice = self.cyber_policy_notice(); self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_cyber_policy_error_event(notice)); @@ -539,14 +555,7 @@ impl ChatWidget { .is_some_and(is_app_server_cyber_policy_error) { self.on_cyber_policy_error(); - } else if is_safety_access_block_message(&message) - || serde_json::from_str::(&message).is_ok_and(|response| { - response["error"]["code"].as_str() == Some("bio_policy") - || response["error"]["message"] - .as_str() - .is_some_and(is_safety_access_block_message) - }) - { + } else if is_bio_policy_error(&message) { self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_safety_access_block_event()); diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index 729d7982eb..0159284e0c 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -77,6 +77,7 @@ pub(super) enum MessageDelivery { #[derive(Debug, Clone, PartialEq)] pub(super) struct QueuedUserMessage { pub(super) user_message: UserMessage, + pub(super) prepared_images: Option>, pub(super) action: QueuedInputAction, pub(super) delivery: MessageDelivery, pub(super) pending_pastes: Vec<(String, String)>, @@ -87,6 +88,7 @@ impl QueuedUserMessage { pub(super) fn new(user_message: UserMessage, action: QueuedInputAction) -> Self { Self { user_message, + prepared_images: None, action, delivery: MessageDelivery::Unsent, pending_pastes: Vec::new(), diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 06d7b2f13a..9f5e2137f0 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -145,6 +145,7 @@ pub(crate) fn new_server_version_warning( #[derive(Debug)] pub(crate) struct SafetyAccessBlockCell { title: &'static str, + pub(crate) explanation: Option, body: &'static str, actions: &'static [(&'static str, &'static str)], } @@ -154,7 +155,8 @@ const SAFETY_ACCESS_BLOCK_LEARN_MORE_URL: &str = "https://help.openai.com/en/art pub(crate) fn new_safety_access_block_event() -> SafetyAccessBlockCell { SafetyAccessBlockCell { title: "This content can't be shown", - body: "We take extra caution with requests involving biological research and applications that could pose safety risks. Eligible researchers can apply for Trusted Access.", + explanation: Some("We take extra caution with requests involving biological research and applications that could pose safety risks.".into()), + body: "Eligible researchers can apply for Trusted Access.", actions: &[ ( "Trusted Access", @@ -171,7 +173,7 @@ pub(crate) fn new_cyber_policy_error_event( use crate::daybreak::Notice; let (body, actions): (_, &'static [(&str, &str)]) = match notice { Notice::Apply => ( - "We take extra care with some cybersecurity requests. If you’re doing authorized security work, apply for Daybreak to get broader access.", + "If you’re doing authorized security work, apply for Daybreak to get broader access.", &[ ("Learn more", SAFETY_ACCESS_BLOCK_LEARN_MORE_URL), ( @@ -195,11 +197,22 @@ pub(crate) fn new_cyber_policy_error_event( }; SafetyAccessBlockCell { title: "This content can’t be shown", + explanation: (notice == Notice::Apply) + .then(|| "We take extra care with some cybersecurity requests.".into()), body, actions, } } +impl SafetyAccessBlockCell { + fn message(&self) -> String { + match &self.explanation { + Some(explanation) => format!("{explanation} {}", self.body), + None => self.body.to_owned(), + } + } +} + impl HistoryCell for SafetyAccessBlockCell { fn display_lines(&self, width: u16) -> Vec> { visible_lines(self.display_hyperlink_lines(width)) @@ -209,7 +222,7 @@ impl HistoryCell for SafetyAccessBlockCell { let mut lines = vec![HyperlinkLine::new( vec!["ⓘ ".fg(accent_color()), self.title.bold()].into(), )]; - let body = Line::from(vec![" ".into(), self.body.dim()]); + let body = Line::from(vec![" ".into(), self.message().dim()]); let wrap_width = width.saturating_sub(2).max(1) as usize; let wrapped = adaptive_wrap_line( &body, @@ -242,7 +255,7 @@ impl HistoryCell for SafetyAccessBlockCell { } fn raw_lines(&self) -> Vec> { - let mut lines = vec![Line::from(self.title), Line::from(self.body)]; + let mut lines = vec![Line::from(self.title), Line::from(self.message())]; lines.extend( self.actions .iter() diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index caf701e25e..cc7f2f668c 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -932,6 +932,8 @@ 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); + let body = cell.display_lines(u16::MAX)[1].to_string(); + assert_eq!(cell.raw_lines()[1].to_string(), body.trim_start()); } #[test]