From 78df1237d1a7a3b5d9d383acffbf7d3664b5acc7 Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Tue, 7 Jul 2026 10:33:42 -0700 Subject: [PATCH] Handle bio policy errors in Codex (#31439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Treat streamed Responses `bio_policy` failures as terminal invalid requests instead of retryable stream errors. - Recognize the new biology policy code and message in the TUI while preserving the legacy `invalid_prompt` contract. - Keep the existing dedicated biology safety notice and add regression/snapshot coverage for all supported error shapes. ## Why [openai/openai#1068559](https://github.com/openai/openai/pull/1068559) gates a Responses API contract change from `invalid_prompt` and the legacy message to `bio_policy` and new biology copy. Without this compatibility change, streamed blocks are retried as transient failures and the OSS TUI falls back to a generic/raw error instead of the dedicated safety notice. ## Validation - `just test -p codex-api` — 137 passed - `just test -p codex-tui app_server_safety_access_errors_render_dedicated_notice` — passed - `just fix -p codex-api` - `just fix -p codex-tui` - `just fmt` - `just test -p codex-tui` — 2,957 passed; two reproducible failures remain in untouched Guardian feature-flag persistence tests: - `update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` --- codex-rs/codex-api/src/sse/responses.rs | 54 ++++++++++++------- .../tui/src/chatwidget/tests/app_server.rs | 36 ++++++++++--- ...policy_error_renders_dedicated_notice.snap | 11 ++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 18 +++++-- 4 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 265d3ced5c..70f96cb855 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -398,7 +398,8 @@ pub fn process_responses_event( } else if is_cyber_policy_error(&error) { let message = cyber_policy_message(error.message); response_error = ApiError::CyberPolicy { message }; - } else if is_invalid_prompt_error(&error) { + } else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy")) + { let message = error .message .unwrap_or_else(|| "Invalid request.".to_string()); @@ -632,10 +633,6 @@ fn is_usage_not_included(error: &Error) -> bool { error.code.as_deref() == Some("usage_not_included") } -fn is_invalid_prompt_error(error: &Error) -> bool { - error.code.as_deref() == Some("invalid_prompt") -} - fn is_cyber_policy_error(error: &Error) -> bool { error.code.as_deref() == Some("cyber_policy") } @@ -1076,23 +1073,42 @@ mod tests { } #[tokio::test] - async fn invalid_prompt_without_type_is_invalid_request() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + async fn content_policy_errors_without_type_are_invalid_requests() { + for (code, expected_message) in [ + ( + "invalid_prompt", + "Invalid prompt: we've limited access to this content for safety reasons.", + ), + ( + "bio_policy", + "This content was flagged for possible biological risk.", + ), + ] { + let raw_error = json!({ + "type": "response.failed", + "sequence_number": 3, + "response": { + "id": "resp_content_policy_no_type", + "object": "response", + "created_at": 1759771628, + "status": "failed", + "background": false, + "error": { "code": code, "message": expected_message }, + "incomplete_details": null, + }, + }) + .to_string(); + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let events = collect_events(&[sse1.as_bytes()]).await; - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Err(ApiError::InvalidRequest { message }) => { - assert_eq!( - message, - "Invalid prompt: we've limited access to this content for safety reasons." - ); + assert_eq!(events.len(), 1); + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!(message, expected_message); + } + other => panic!("unexpected event for {code}: {other:?}"), } - other => panic!("unexpected event: {other:?}"), } } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 952aff9f95..28e3cbae3e 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1182,11 +1182,26 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { #[tokio::test] async fn app_server_safety_access_errors_render_dedicated_notice() { - let message = "Invalid prompt: we've limited access to this content for safety reasons."; - for message in [ - message.to_string(), - json!({ "error": { "message": message } }).to_string(), - ] { + let legacy_message = "Invalid prompt: we've limited access to this content for safety reasons."; + let bio_policy_message = "This content was flagged for possible biological risk."; + let cases = [ + ("legacy plain message", legacy_message.to_string()), + ( + "legacy JSON message", + json!({ "error": { "message": legacy_message } }).to_string(), + ), + ("bio policy plain message", bio_policy_message.to_string()), + ( + "bio policy JSON message", + json!({ "error": { "message": bio_policy_message } }).to_string(), + ), + ( + "bio policy code", + json!({ "error": { "code": "bio_policy", "message": "copy may change" } }).to_string(), + ), + ]; + let mut rendered_cases = Vec::new(); + for (case, message) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_non_retry_error(message, /*codex_error_info*/ None); @@ -1195,8 +1210,17 @@ async fn app_server_safety_access_errors_render_dedicated_notice() { let rendered = lines_to_single_string(&cells[0]); assert!(rendered.contains("This content can't be shown")); assert!(rendered.contains("biological research")); - assert!(!rendered.contains("Invalid prompt:")); + rendered_cases.push((case, rendered)); } + + let canonical = &rendered_cases[0].1; + for (case, rendered) in &rendered_cases[1..] { + assert_eq!(rendered, canonical, "unexpected rendering for {case}"); + } + insta::assert_snapshot!( + "app_server_bio_policy_error_renders_dedicated_notice", + rendered_cases.last().unwrap().1.as_str() + ); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap new file mode 100644 index 0000000000..3149943b64 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: rendered_cases.last().unwrap().1.as_str() +--- +ⓘ This content can't be shown + We take extra caution with requests involving biological research and + applications that could pose safety risks. Eligible researchers can apply + for Trusted Access. + Trusted Access: https://www.openai.com/form/trusted-access-for-biology- + research/ + Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index fdfad7f6d8..d71a60820e 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -5,8 +5,15 @@ use super::*; -const SAFETY_ACCESS_BLOCK_PREFIX: &str = +const LEGACY_SAFETY_ACCESS_BLOCK_PREFIX: &str = "Invalid prompt: we've limited access to this content for safety reasons."; +const BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX: &str = + "This content was flagged for possible biological risk."; + +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) +} impl ChatWidget { /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. @@ -430,11 +437,12 @@ impl ChatWidget { .is_some_and(is_app_server_cyber_policy_error) { self.on_cyber_policy_error(); - } else if message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX) + } else if is_safety_access_block_message(&message) || serde_json::from_str::(&message).is_ok_and(|response| { - response["error"]["message"] - .as_str() - .is_some_and(|message| message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX)) + response["error"]["code"].as_str() == Some("bio_policy") + || response["error"]["message"] + .as_str() + .is_some_and(is_safety_access_block_message) }) { self.input_queue.submit_pending_steers_after_interrupt = false;