mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
+5
-10
@@ -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/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user