mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Explain safety policy blocks with turn-scoped recourse in the TUI (#57363)
## What changed - Handle `TurnModerationMetadata` for the active turn, showing a temporary block notice while an explanation is pending. - Validate safety recourse metadata and render topic-specific explanations with safer ways to continue for biological and cybersecurity policy blocks. Apply the explanation only when the terminal policy error matches its domain; otherwise retain the existing error handling. - Queue input while recourse is pending and preserve prepared images when queued messages are submitted. ## Testing Add an integration test covering biological and cybersecurity blocks, the loading notice, and fallback for a mismatched policy error, plus a snapshot of the cybersecurity explanation. Extend queued-image coverage to verify submission succeeds after the original image file is removed. GitOrigin-RevId: dd9ca57cf3b2d842e3deb8aff5d7c3c73a92e14d
This commit is contained in:
@@ -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::<history_cell::SafetyAccessBlockCell>());
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
+20
@@ -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/
|
||||
@@ -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<UnifiedExecWaitState>,
|
||||
unified_exec_wait_streak: Option<UnifiedExecWaitStreak>,
|
||||
turn_lifecycle: TurnLifecycleState,
|
||||
safety_recourse: Option<safety_recourse::PendingRecourse>,
|
||||
realtime_conversation: RealtimeConversationUiState,
|
||||
realtime_conversation_available_for_thread: bool,
|
||||
safety_buffering: SafetyBufferingState,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn HistoryCell>,
|
||||
result: Option<String>,
|
||||
pub(super) deferred_error: Option<TurnError>,
|
||||
}
|
||||
|
||||
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<PendingRecourse>,
|
||||
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
|
||||
}
|
||||
@@ -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<String>)> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<_>>(),
|
||||
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();
|
||||
|
||||
@@ -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::<serde_json::Value>(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::<serde_json::Value>(&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());
|
||||
|
||||
@@ -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<Vec<UserInput>>,
|
||||
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(),
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(crate) fn new_server_version_warning(
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SafetyAccessBlockCell {
|
||||
title: &'static str,
|
||||
pub(crate) explanation: Option<String>,
|
||||
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<Line<'static>> {
|
||||
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<Line<'static>> {
|
||||
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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user