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
This commit is contained in:
Jay Wang
2026-11-10 15:54:09 +00:00
committed by copyberry
parent 37103d7f01
commit 039d0f27d5
7 changed files with 145 additions and 31 deletions
+11 -2
View File
@@ -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;
}
@@ -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/
-4
View File
@@ -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();
+17 -10
View File
@@ -12,7 +12,6 @@ pub(super) const VIEW_ID: &str = "safety-recourse";
pub(super) struct PendingRecourse {
domain: Domain,
pub(super) notice: Box<dyn HistoryCell>,
result: Option<Recourse>,
pub(super) deferred_error: Option<TurnError>,
}
@@ -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()
});
@@ -82,13 +82,13 @@ pub(super) fn parse(mut value: Value) -> Option<(Domain, Option<Recourse>)> {
| ("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<Recourse>)> {
}
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),
@@ -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
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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,