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:
Eric Traut
2026-10-27 05:15:31 +00:00
committed by copyberry
parent ff2346f33f
commit 118aae2a5f
17 changed files with 470 additions and 26 deletions
@@ -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
@@ -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/
+2
View File
@@ -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(),
+4 -1
View File
@@ -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 {
+17 -3
View File
@@ -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(&notification.turn.id);
} else {
@@ -211,7 +212,7 @@ impl ChatWidget {
notification.error.additional_details,
);
}
} else {
} else if from_replay || !safety_recourse::defer_error(self, &notification) {
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()))
{
+9 -6
View File
@@ -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, &notification.thread_id, &notification.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, &notification.thread_id, &notification.turn_id)
&& let Some(pending) = &mut chat.safety_recourse
&& matches_error(pending.domain, &notification.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();
+20 -11
View File
@@ -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(),
+17 -4
View File
@@ -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()
+2
View File
@@ -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]