From 039d0f27d56c31913ffc0f2055a3432b1bcb0b06 Mon Sep 17 00:00:00 2001 From: Jay Wang Date: Tue, 10 Nov 2026 15:39:38 +0000 Subject: [PATCH] Clarify safety recourse messaging and add a reporting option (#60613) ## What changed - Show a refusal status while preparing alternative suggestions and remove the pending notice above the composer. - Clarify that choosing a rewrite starts a new chat with earlier context, and update overview suggestions to explicitly say users can ask Codex. - Add an option to report safety-check concerns through the consent flow when reporting is enabled. ## Testing Update safety recourse snapshots and extend coverage for reporting consent, returning to the picker, and Daybreak notices when enabled or unavailable for the selected model. GitOrigin-RevId: 99f565ebec7064ec55d388e73acede2dfe639474 --- .../tui/src/app/tests/safety_buffering.rs | 13 ++- ...ts__safety_buffering__safety_recourse.snap | 15 +-- codex-rs/tui/src/chatwidget/rendering.rs | 4 - .../tui/src/chatwidget/safety_recourse.rs | 27 +++-- .../chatwidget/safety_recourse/metadata.rs | 6 +- ...dget__tests__daybreak_recourse_picker.snap | 5 +- .../src/chatwidget/tests/status_and_layout.rs | 106 ++++++++++++++++++ 7 files changed, 145 insertions(+), 31 deletions(-) diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index 15d4059907..945307db67 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -242,7 +242,13 @@ async fn safety_recourse_handles_a_policy_block() -> Result<()> { app.chat_widget .apply_external_edit(UNSENT_DRAFT.to_string()); loop { - let event = server.next_event().await.expect("moderation metadata"); + let event = + tokio::time::timeout(std::time::Duration::from_secs(5), server.next_event()) + .await + .unwrap_or_else(|_| { + panic!("timed out waiting for {domain} moderation metadata") + }) + .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; @@ -272,7 +278,10 @@ async fn safety_recourse_handles_a_policy_block() -> Result<()> { continue; } if domain == "bio" { - assert!(loading.contains("Finding an explanation and safer ways to continue…")); + assert!( + loading + .contains("I can’t help with this request. Preparing alternative suggestions…") + ); assert!(notice.contains("research and safety considerations.")); continue; } 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 index 1cbc45ef1d..b5df6a6be5 100644 --- 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 @@ -1,20 +1,15 @@ --- source: tui/src/app/tests/safety_buffering.rs +assertion_line: 279 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… +• I can’t help with this request. Preparing alternative suggestions… ⓘ 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. + systems beyond permitted scope. You can ask Codex for 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/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 1a78db7a24..eb7e9d3e67 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -293,10 +293,6 @@ impl ChatWidget { let bottom_pane = self.bottom_pane.backdrop_with_options(options); let above_composer = if self.quick_dial_menu && !options.floating_shortcuts { 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 if let Some(backend) = self.visible_new_backend_control() { let mut spans = vec![" ".into(), backend.into()]; let keymap = self.bottom_pane.keymap(); diff --git a/codex-rs/tui/src/chatwidget/safety_recourse.rs b/codex-rs/tui/src/chatwidget/safety_recourse.rs index 602ddf46e5..8126459a26 100644 --- a/codex-rs/tui/src/chatwidget/safety_recourse.rs +++ b/codex-rs/tui/src/chatwidget/safety_recourse.rs @@ -12,7 +12,6 @@ pub(super) const VIEW_ID: &str = "safety-recourse"; pub(super) struct PendingRecourse { domain: Domain, - pub(super) notice: Box, result: Option, pub(super) deferred_error: Option, } @@ -32,10 +31,6 @@ pub(super) fn on_metadata(chat: &mut ChatWidget, notification: TurnModerationMet }; 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, }); @@ -43,7 +38,9 @@ pub(super) fn on_metadata(chat: &mut ChatWidget, notification: TurnModerationMet 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()); + chat.set_status_header( + "I can’t help with this request. Preparing alternative suggestions…".into(), + ); } } @@ -90,8 +87,6 @@ pub(super) fn render_failure( return false; }; let mut items = Vec::new(); - let subtitle = (!result.suggestions.is_empty()) - .then(|| "Choosing a rewrite sends it in a new chat.".into()); for (label, prompt) in result.suggestions { let turn_id = turn_id.to_string(); items.push(SelectionItem { @@ -126,6 +121,17 @@ pub(super) fn render_failure( history_cell::new_cyber_policy_error_event(notice) } }; + if chat.config.feedback_enabled { + items.push(SelectionItem { + name: "Give feedback".into(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFeedbackConsent { + category: crate::app_event::FeedbackCategory::SafetyCheck, + }) + })], + ..Default::default() + }); + } cell.explanation = Some(result.explanation); chat.input_queue.submit_pending_steers_after_interrupt = false; chat.finalize_turn(); @@ -133,8 +139,9 @@ pub(super) fn render_failure( if !items.is_empty() { chat.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(VIEW_ID), - title: Some("Here are a few ways I can help:".into()), - subtitle, + title: Some( + "You can choose a rewrite to start a new chat with your earlier context:".into(), + ), items, ..SelectionViewParams::picker() }); diff --git a/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs b/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs index 1f8dfaa686..6f85047669 100644 --- a/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs +++ b/codex-rs/tui/src/chatwidget/safety_recourse/metadata.rs @@ -82,13 +82,13 @@ pub(super) fn parse(mut value: Value) -> Option<(Domain, Option)> { | ("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 "), + Some("You can continue without this file or ask Codex for 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 "), + Some("You can retry without web search or ask Codex for an overview of "), ), ("other_tool", "other_tool_call", "assistant") | ("other_tool", "other_tool_result", "tool") => { @@ -169,7 +169,7 @@ pub(super) fn parse(mut value: Value) -> Option<(Domain, Option)> { } suggestions.push((label.into(), prompt.into())); } - let overview = "We can still help with an overview of "; + let overview = "You can ask Codex for an overview of "; let (lead, offer) = match wire.domain { Domain::Bio => (bio_lead, bio_offer.unwrap_or(overview)), Domain::Cyber => (cyber_lead, overview), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_recourse_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_recourse_picker.snap index e1009f3b4f..edb4057afe 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_recourse_picker.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_recourse_picker.snap @@ -1,14 +1,15 @@ --- source: tui/src/chatwidget/tests/status_and_layout.rs +assertion_line: 233 expression: "render_bottom_popup(&chat, 80)" --- - Here are a few ways I can help: - Choosing a rewrite sends it in a new chat. + You can choose a rewrite to start a new chat with your earlier context: › 1. Overview 2. Recovery 3. Prevention 4. Enable Daybreak + 5. Give feedback enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index f22f70eec8..9cc90c4c12 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -234,11 +234,117 @@ async fn daybreak_refusal_offers_enable_for_the_next_turn() { "daybreak_recourse_picker", render_bottom_popup(&chat, /*width*/ 80) ); + chat.handle_key_event(KeyCode::Char('5').into()); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, + AppEvent::OpenFeedbackConsent { + category: crate::app_event::FeedbackCategory::SafetyCheck + } + )) + ); + chat.open_feedback_consent(crate::app_event::FeedbackCategory::SafetyCheck); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Upload logs?")); + chat.handle_key_event(KeyCode::Esc.into()); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("1. Overview")); chat.handle_key_event(KeyCode::Char('4').into()); assert!(std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( event, AppEvent::PersistDaybreakSelection { thread_id: id, enabled: true } if id == thread_id ))); + let mut daybreak = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + daybreak.model = "gpt-5.6-sol".into(); + daybreak.available_access_programs = Some(codex_protocol::openai_models::ModelAccessPrograms { + cyber: vec![codex_protocol::turn_input::CyberAccessProgram::DaybreakBlue], + }); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![daybreak.clone()])); + chat.set_model("gpt-5.6-sol"); + chat.daybreak_enabled = true; + handle_turn_started(&mut chat, "daybreak-recourse"); + safety_recourse::on_metadata( + &mut chat, + codex_app_server_protocol::TurnModerationMetadataNotification { + thread_id: thread_id.to_string(), + turn_id: "daybreak-recourse".into(), + metadata: json!({"safety_recourse": { + "schema_version": 1, "domain": "cyber", "source": "user_request", + "source_message": {"id":"1", "kind":"ordinary_message", "role":"user"}, + "topic": "unauthorized_account_or_system_access", + "suggestions": [ + {"label": "Overview", "prompt_text": "Explain access"}, + {"label": "Recovery", "prompt_text": "Explain account recovery"}, + {"label": "Prevention", "prompt_text": "Explain account protection"} + ] + }}), + }, + ); + let pending = chat.safety_recourse.take(); + assert!(chat.daybreak_account_eligible()); + assert!(chat.daybreak_enabled); + assert_eq!(chat.cyber_policy_notice(), crate::daybreak::Notice::Enabled); + assert!(safety_recourse::render_failure(&mut chat, pending, &error)); + let cells = drain_insert_history(&mut events); + let notices = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>(); + let notice = notices + .iter() + .find(|notice| notice.contains("Daybreak is on")) + .unwrap_or_else(|| panic!("missing Daybreak-on notice: {notices:#?}")); + assert!(notice.contains("You can ask Codex for an overview")); + let picker = render_bottom_popup(&chat, /*width*/ 80); + assert!(picker.contains("Overview")); + assert!(!picker.contains("Enable Daybreak")); + chat.handle_key_event(KeyCode::Esc.into()); + chat.daybreak_enabled = false; + + let mut astra = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + astra.model = "gpt-6-astra".into(); + astra.available_access_programs = Some(codex_protocol::openai_models::ModelAccessPrograms { + cyber: vec![codex_protocol::turn_input::CyberAccessProgram::Standard], + }); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![astra])); + chat.set_model("gpt-6-astra"); + handle_turn_started(&mut chat, "astra-recourse"); + safety_recourse::on_metadata( + &mut chat, + codex_app_server_protocol::TurnModerationMetadataNotification { + thread_id: thread_id.to_string(), + turn_id: "astra-recourse".into(), + metadata: json!({"safety_recourse": { + "schema_version": 1, "domain": "cyber", "source": "user_request", + "source_message": {"id":"1", "kind":"ordinary_message", "role":"user"}, + "topic": "unauthorized_account_or_system_access", + "suggestions": [ + {"label": "Overview", "prompt_text": "Explain access"}, + {"label": "Recovery", "prompt_text": "Explain account recovery"}, + {"label": "Prevention", "prompt_text": "Explain account protection"} + ] + }}), + }, + ); + let pending = chat.safety_recourse.take(); + assert!(safety_recourse::render_failure(&mut chat, pending, &error)); + let cells = drain_insert_history(&mut events); + let notices = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>(); + assert!( + notices + .iter() + .any(|notice| notice.contains("Daybreak isn’t available for Astra")), + "missing Astra notice: {notices:#?}" + ); + let picker = render_bottom_popup(&chat, /*width*/ 80); + assert!(picker.contains("Overview")); + assert!(!picker.contains("Enable Daybreak")); + chat.handle_key_event(KeyCode::Esc.into()); + + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![daybreak])); + chat.set_model("gpt-5.6-sol"); + chat.daybreak_enabled = false; chat.set_parent_owned_thread(); handle_error( &mut chat,