From cd4a9cbd254ade36de9fea0cdac3b6a9d40d55b1 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 1 Oct 2026 04:02:39 +0000 Subject: [PATCH] Honor Daybreak settings in TUI continuations and background tasks (#49859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Cyber-policy refusal guidance did not distinguish whether Daybreak was on or off, and policy continuations and background task turns omitted `cyberAccessProgram`. ## What changed - Show refusal guidance for the current Daybreak setting and offer eligible users an “Enable Daybreak” picker for their next request. Persist the selection without automatically retrying the refused request, and suppress the picker during history replay and when viewing parent-owned threads. - Select and validate the cyber access program for policy continuations and background task turns using account eligibility, model support, and the thread's Daybreak setting. - Resolve new background threads' Daybreak defaults from effective configuration unless launch flags or a selected profile explicitly override them. Use the target thread's saved setting for follow-up messages. ## Testing Add unit, snapshot, and request-recording coverage for refusal guidance, picker behavior, continuation access programs, and background tasks retaining Daybreak after configuration changes. GitOrigin-RevId: 94dee39c067e8eea5791cb31d5fe65fc600e5a18 --- codex-rs/tui/src/app/misalignment_policy.rs | 18 +++++ .../app/tests/misalignment_policy_tests.rs | 39 +++++++++- .../app/tests/session_lifecycle_requests.rs | 48 +++++++++++- codex-rs/tui/src/app_server_session.rs | 20 ++++- ...tests__daybreak_refusal_enable_picker.snap | 11 +++ ..._tests__daybreak_refusal_parent_owned.snap | 13 ++++ .../src/chatwidget/tests/status_and_layout.rs | 70 +++++++++++++++-- codex-rs/tui/src/chatwidget/turn_runtime.rs | 43 ++++++++-- codex-rs/tui/src/daybreak.rs | 46 ++++++----- codex-rs/tui/src/daybreak_tests.rs | 66 ++++++++++++---- codex-rs/tui/src/dynamic_tools.rs | 78 +++++++++++++++++++ codex-rs/tui/src/history_cell/notices.rs | 8 +- ...policy_error_event_available_snapshot.snap | 12 +++ ...r_policy_error_event_limited_snapshot.snap | 7 -- codex-rs/tui/src/history_cell/tests.rs | 14 +++- 15 files changed, 431 insertions(+), 62 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_enable_picker.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_parent_owned.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_available_snapshot.snap delete mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap diff --git a/codex-rs/tui/src/app/misalignment_policy.rs b/codex-rs/tui/src/app/misalignment_policy.rs index 97554a030d..c4cb4aff42 100644 --- a/codex-rs/tui/src/app/misalignment_policy.rs +++ b/codex-rs/tui/src/app/misalignment_policy.rs @@ -114,6 +114,23 @@ impl App { let Some(message) = review.continuation_message() else { return; }; + let eligible_account = self.chat_widget.has_chatgpt_account() + && self.chat_widget.config_ref().model_provider_id == "openai"; + let enabled = self.chat_widget.daybreak_enabled + && !self.chat_widget.side_conversation_active() + && !self.side_threads.contains_key(&review.thread_id); + let cyber_access_program = match crate::daybreak::program_for_turn( + &self.chat_widget.model_catalog().models, + self.chat_widget.current_model(), + eligible_account, + enabled, + ) { + Ok(program) => program, + Err(message) => { + self.chat_widget.add_error_message(message); + return; + } + }; let config = self.chat_widget.config_ref(); let explicit_profile = self.runtime_permission_profile_override @@ -149,6 +166,7 @@ impl App { approvals_reviewer: Some(config.approvals_reviewer.into()), sandbox_policy, permissions, + cyber_access_program: cyber_access_program.map(Into::into), input: vec![UserInput::Text { text: message.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs index c1f8ab79e2..6d4af54fa3 100644 --- a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -3,6 +3,8 @@ use super::session_lifecycle_requests::start_recording_app_server; use super::*; use codex_app_server_protocol::MisalignmentErrorDetails; use codex_app_server_protocol::MisalignmentSteer; +use codex_protocol::openai_models::ModelAccessPrograms; +use codex_protocol::turn_input::CyberAccessProgram; use pretty_assertions::assert_eq; fn policy_error() -> AppServerTurnError { @@ -37,7 +39,11 @@ fn error_notification( #[tokio::test] async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { - for (reject, preserve) in [(false, false), (false, true), (true, false)] { + for (reject, preserve, daybreak) in [ + (false, false, false), + (false, true, true), + (true, false, false), + ] { let (mut app, mut rx, _) = make_test_app_with_channels().await; let (mut server, requests, proxy) = start_recording_app_server( &app.config, @@ -57,6 +63,32 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> session.active_permission_profile = None; app.active_thread_id = Some(thread_id); app.chat_widget.handle_thread_session(session); + if !reject { + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + app.chat_widget.open_model_popup(); + let request_id = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::FetchModels { request_id } => Some(request_id), + _ => None, + }) + .expect("model catalog request"); + let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + model.model = app.chat_widget.current_model().to_string(); + model.available_access_programs = Some(ModelAccessPrograms { + cyber: vec![ + CyberAccessProgram::Standard, + CyberAccessProgram::DaybreakBlue, + ], + }); + app.chat_widget + .on_models_loaded(request_id, Ok(vec![model])); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Esc)); + app.chat_widget.set_daybreak_enabled(daybreak); + } app.runtime_permission_profile_override = Some(if preserve { RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()) } else { @@ -194,6 +226,11 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(config.approvals_reviewer.into()), sandbox_policy: (!preserve).then(|| config.legacy_sandbox_policy().into()), + cyber_access_program: Some(if daybreak { + CyberAccessProgram::DaybreakBlue.into() + } else { + CyberAccessProgram::Standard.into() + }), input: vec![codex_app_server_protocol::UserInput::Text { text: "Continue **only** within the requested scope.\nDo not edit files." .to_string(), diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 480c661cad..debe018b2a 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1746,8 +1746,37 @@ async fn embedded_server_rejects_unowned_dynamic_tool_calls() -> Result<()> { async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespace() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; let codex_home = tempdir()?; + let backend = wiremock::MockServer::start().await; + let backend_url = format!("{}/backend-api", backend.uri()); app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + let mut catalog = codex_models_manager::bundled_models_response()?; + for model in &mut catalog.models { + model.available_access_programs = + Some(codex_protocol::openai_models::ModelAccessPrograms { + cyber: vec![codex_protocol::turn_input::CyberAccessProgram::DaybreakBlue], + }); + } + let catalog_path = codex_home.path().join("models.json"); + std::fs::write(&catalog_path, serde_json::to_vec(&catalog)?)?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "daybreak = true\nmodel_catalog_json = {:?}\nchatgpt_base_url = {backend_url:?}\ncli_auth_credentials_store = \"file\"\n", + catalog_path.display().to_string() + ), + )?; + app.config.chatgpt_base_url = backend_url; + app.config.model_catalog = Some(catalog); + app.config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File; + app_test_support::write_chatgpt_auth( + codex_home.path(), + app_test_support::ChatGptAuthFixture::new("test-token") + .chatgpt_user_id("test-user") + .plan_type("plus"), + codex_login::AuthCredentialsStoreMode::File, + ) + .expect("write fixture auth"); app.config .permissions .set_permission_profile(PermissionProfile::workspace_write_with( @@ -1941,7 +1970,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac params: codex_app_server_protocol::ThreadMetadataUpdateParams { thread_id: creation_source.to_string(), project_id: Some(project.project.id.clone()), - daybreak_enabled: None, + daybreak_enabled: Some(false), git_info: None, }, }) @@ -2026,6 +2055,10 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac .expect("background task creation")["projectId"], project.project.id ); + assert_eq!( + recorded_params(&requests, "thread/start").last().unwrap()["daybreakEnabled"], + true + ); assert_eq!( recorded_params(&requests, "thread/start") .last() @@ -2039,6 +2072,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac let turn = recorded_params(&requests, "turn/start") .pop() .expect("background task turn request"); + assert_eq!(turn["cyberAccessProgram"], "daybreakBlue"); assert_eq!(turn["input"], serde_json::json!([])); assert_eq!( turn["toolOutput"], @@ -2066,6 +2100,13 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac 1 ); + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("daybreak = true", "daybreak = false"), + )?; + spawn_approved_task_tool_call( &app, &app_server, @@ -2077,7 +2118,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac namespace: Some("codex_tui".to_string()), tool: "send_message_to_thread".to_string(), arguments: serde_json::json!({ - "threadId": creation_source, + "threadId": created_thread_id, "prompt": "Follow & report" }), }, @@ -2092,7 +2133,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac else { panic!("expected follow-up task registration before its next turn") }; - assert_eq!(ThreadId::from_string(&thread.id)?, creation_source); + assert_eq!(ThreadId::from_string(&thread.id)?, created_thread_id); assert_eq!(recorded_params(&requests, "turn/start").len(), 1); Box::pin(app.handle_event( &mut tui, @@ -2113,6 +2154,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac }; assert!(response.success, "{response:?}"); let turn = &recorded_params(&requests, "turn/start")[1]; + assert_eq!(turn["cyberAccessProgram"], "daybreakBlue"); assert_eq!(turn["input"], serde_json::json!([])); assert_eq!( turn["toolOutput"], diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index cf0e1b683d..a7459c4f96 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -475,12 +475,28 @@ impl AppServerSession { }) }) .transpose()?; - let thread_start_params = thread_start_params_from_config( + let mut thread_start_params = thread_start_params_from_config( &config, self.thread_params_mode(), self.remote_cwd_override(), /*session_start_source*/ None, ); + let daybreak_launch_override = config + .config_layer_stack + .layers_high_to_low() + .find(|layer| layer.config.get("daybreak").is_some()) + .is_some_and(|layer| { + matches!( + layer.name, + codex_config::ConfigLayerSource::SessionFlags + | codex_config::ConfigLayerSource::User { + profile: Some(_), + .. + } + ) + }); + thread_start_params.daybreak_enabled = + daybreak_launch_override.then_some(config.daybreak_enabled); self.dynamic_tool_mcp = Some(Arc::new( DynamicToolMcpServer::start( self.request_handle(), @@ -1761,7 +1777,7 @@ pub(crate) fn status_account_display_from_auth_mode( } } -fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { +pub(crate) fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { let upgrade = model.upgrade.map(|upgrade_id| { let upgrade_info = model.upgrade_info.clone(); ModelUpgrade { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_enable_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_enable_picker.snap new file mode 100644 index 0000000000..af63beb6dd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_enable_picker.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Turn on Daybreak for your next request? + + +› 1. Enable Daybreak + 2. Not now + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_parent_owned.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_parent_owned.snap new file mode 100644 index 0000000000..77622343e0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__daybreak_refusal_parent_owned.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +assertion_line: 195 +expression: "format!(\"{}\\n{}\", lines_to_single_string(cells.last().unwrap()),\nrender_bottom_popup(&chat, 80))" +--- +ⓘ This content can’t be shown + Daybreak is currently off. If you’re doing authorized security work, turn it + on and try your request again. + Learn more: https://help.openai.com/en/articles/20001326 + +› Viewing sub-agent — direct input is disabled + + GPT-6.1-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 003e631969..cbd7070e35 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -123,13 +123,6 @@ async fn resumed_session_hides_unknown_token_usage_until_an_update_arrives() { #[tokio::test] async fn app_server_cyber_policy_error_renders_dedicated_notice() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(Some("gpt-5.6-sol")).await; - chat.has_chatgpt_account = true; - let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); - model.model = "gpt-5.6-sol".into(); - model.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![model])); handle_error( &mut chat, @@ -146,6 +139,69 @@ async fn app_server_cyber_policy_error_renders_dedicated_notice() { assert!(!rendered.contains("server fallback message")); } +#[tokio::test] +async fn daybreak_refusal_offers_enable_for_the_next_turn() { + let (mut chat, mut events, mut ops) = make_chatwidget_manual_with_auth( + Some("gpt-5.6-sol"), + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + FrameRequester::test_dummy(), + ) + .await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); + model.model = "gpt-5.6-sol".into(); + model.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![model])); + + chat.thread_usage.replaying_turn_completion = true; + chat.on_cyber_policy_error(); + let cells = drain_insert_history(&mut events); + assert!(lines_to_single_string(&cells[0]).contains("Daybreak is currently off")); + assert!(chat.bottom_pane.no_modal_or_popup_active()); + chat.thread_usage.replaying_turn_completion = false; + + chat.daybreak_enabled = true; + chat.on_cyber_policy_error(); + let cells = drain_insert_history(&mut events); + assert!(lines_to_single_string(&cells[0]).contains("even when Daybreak is on")); + assert!(chat.bottom_pane.no_modal_or_popup_active()); + chat.daybreak_enabled = false; + + chat.on_cyber_policy_error(); + assert_chatwidget_snapshot!( + "daybreak_refusal_enable_picker", + render_bottom_popup(&chat, /*width*/ 80) + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!(event, + AppEvent::PersistDaybreakSelection { thread_id: id, enabled: true } if id == thread_id + )) + ); + assert!(ops.try_recv().is_err()); + + chat.set_parent_owned_thread(); + handle_error( + &mut chat, + "server fallback message", + Some(CodexErrorInfo::CyberPolicy), + ); + let cells = drain_insert_history(&mut events); + assert!(chat.bottom_pane.no_modal_or_popup_active()); + assert_chatwidget_snapshot!( + "daybreak_refusal_parent_owned", + normalize_snapshot_paths(format!( + "{}\n{}", + lines_to_single_string(cells.last().unwrap()), + render_bottom_popup(&chat, /*width*/ 80) + )) + ); +} + #[tokio::test] async fn app_server_model_verification_renders_warning() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 778588ea37..84aeef22ea 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -392,14 +392,47 @@ impl ChatWidget { } pub(super) fn on_cyber_policy_error(&mut self) { + let can_enable_daybreak = self.has_chatgpt_account + && self.config.model_provider_id == "openai" + && !self.side_conversation_active(); + let notice = crate::daybreak::notice_for_setting( + &self.model_catalog.models, + self.current_model(), + self.daybreak_enabled && can_enable_daybreak, + can_enable_daybreak, + ); self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - let notice = if self.has_chatgpt_account && self.config.model_provider_id == "openai" { - crate::daybreak::notice_for_model(&self.model_catalog.models, self.current_model()) - } else { - crate::daybreak::Notice::Apply - }; self.add_to_history(history_cell::new_cyber_policy_error_event(notice)); + if notice == crate::daybreak::Notice::Disabled + && !self.thread_usage.replaying_turn_completion + && !self.blocks_direct_input + && let Some(thread_id) = self.thread_id + { + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Turn on Daybreak for your next request?".into()), + items: vec![ + SelectionItem { + name: "Enable Daybreak".into(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PersistDaybreakSelection { + thread_id, + enabled: true, + }) + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Not now".into(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..SelectionViewParams::picker() + }); + self.defer_input_until_settings_applied(); + } self.request_redraw(); // After an error ends the turn, try sending the next queued input. diff --git a/codex-rs/tui/src/daybreak.rs b/codex-rs/tui/src/daybreak.rs index 93b8ea0673..947bd4861b 100644 --- a/codex-rs/tui/src/daybreak.rs +++ b/codex-rs/tui/src/daybreak.rs @@ -32,31 +32,41 @@ pub(crate) fn program_for_turn( } } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Notice { Apply, Astra, - #[default] - Limited, + Disabled, + Enabled, } -pub(crate) fn notice_for_model(models: &[ModelPreset], model: &str) -> Notice { - let programs = models - .iter() - .find(|entry| entry.model == model) - .and_then(|entry| entry.available_access_programs.as_ref()); - if matches!(model, "gpt-6-astra" | "gpt-6-astra-wm") - && programs.is_some_and(|programs| { - programs.cyber.contains(&CyberAccessProgram::Standard) && programs.daybreak().is_none() - }) - { - return Notice::Astra; +pub(crate) fn notice_for_setting( + models: &[ModelPreset], + model: &str, + enabled: bool, + can_enable_daybreak: bool, +) -> Notice { + if enabled { + return Notice::Enabled; } - if programs.and_then(ModelAccessPrograms::daybreak).is_some() { - Notice::Limited - } else { - Notice::Apply + if can_enable_daybreak { + let programs = models + .iter() + .find(|entry| entry.model == model) + .and_then(|entry| entry.available_access_programs.as_ref()); + if matches!(model, "gpt-6-astra" | "gpt-6-astra-wm") + && programs.is_some_and(|programs| { + programs.cyber.contains(&CyberAccessProgram::Standard) + && programs.daybreak().is_none() + }) + { + return Notice::Astra; + } + if programs.and_then(ModelAccessPrograms::daybreak).is_some() { + return Notice::Disabled; + } } + Notice::Apply } pub(crate) fn available(models: &[ModelPreset]) -> bool { diff --git a/codex-rs/tui/src/daybreak_tests.rs b/codex-rs/tui/src/daybreak_tests.rs index fbfe577450..ee86b68d8c 100644 --- a/codex-rs/tui/src/daybreak_tests.rs +++ b/codex-rs/tui/src/daybreak_tests.rs @@ -48,7 +48,12 @@ fn refusal_guidance_uses_the_model_catalog() { ] { model.model = name.into(); assert_eq!( - notice_for_model(std::slice::from_ref(&model), name), + notice_for_setting( + std::slice::from_ref(&model), + name, + /*enabled*/ false, + /*can_enable_daybreak*/ true, + ), expected ); } @@ -59,20 +64,53 @@ fn refusal_guidance_uses_the_model_catalog() { .cyber .push(CyberAccessProgram::DaybreakBlue); assert_eq!( - notice_for_model(std::slice::from_ref(&model), &model.model), - Notice::Limited + notice_for_setting( + std::slice::from_ref(&model), + &model.model, + /*enabled*/ false, + /*can_enable_daybreak*/ true, + ), + Notice::Disabled ); - - let mut other = model.clone(); + assert_eq!( + notice_for_setting( + std::slice::from_ref(&model), + &model.model, + /*enabled*/ true, + /*can_enable_daybreak*/ true, + ), + Notice::Enabled + ); + model.available_access_programs.as_mut().unwrap().cyber = + vec![CyberAccessProgram::DaybreakBlue]; + assert_eq!( + notice_for_setting( + std::slice::from_ref(&model), + &model.model, + /*enabled*/ false, + /*can_enable_daybreak*/ true + ), + Notice::Disabled + ); + let other = model.clone(); model.model = "gpt-5.6-sol".into(); - other.model = "another-model".into(); model.available_access_programs.as_mut().unwrap().cyber = vec![CyberAccessProgram::Standard]; - for programs in [None, other.available_access_programs.clone()] { - other.available_access_programs = programs; - assert_eq!( - notice_for_model(&[model.clone(), other.clone()], &model.model), - Notice::Apply - ); - } - assert_eq!(notice_for_model(&[], "gpt-6-astra"), Notice::Apply); + assert_eq!( + notice_for_setting( + &[model.clone(), other], + &model.model, + /*enabled*/ false, + /*can_enable_daybreak*/ true + ), + Notice::Apply + ); + assert_eq!( + notice_for_setting( + &[], + "gpt-6-astra", + /*enabled*/ false, + /*can_enable_daybreak*/ true + ), + Notice::Apply + ); } diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index 10c568e1ec..c2a3fa8d15 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -4,6 +4,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::Account; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::DynamicToolCallOutputContentItem; use codex_app_server_protocol::DynamicToolCallParams; @@ -12,7 +13,11 @@ use codex_app_server_protocol::DynamicToolFunctionSpec; use codex_app_server_protocol::DynamicToolNamespaceSpec; use codex_app_server_protocol::DynamicToolNamespaceTool; use codex_app_server_protocol::DynamicToolSpec; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::ImageReference; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::SandboxPolicy; @@ -480,6 +485,19 @@ async fn execute_inner( thread_start_params.cwd = Some(source_thread.cwd.to_string_lossy().into_owned()); thread_start_params.project_id = source_thread.project_id.clone(); thread_start_params.ephemeral = Some(source_thread.ephemeral); + if thread_start_params.daybreak_enabled.is_none() { + let defaults = crate::config_update::read_effective_config( + handle.clone(), + source_thread.cwd.to_string_lossy().into_owned(), + ) + .await + .map_err(|error| error.to_string())?; + thread_start_params.daybreak_enabled = defaults + .config + .additional + .get("daybreak") + .and_then(Value::as_bool); + } thread_start_params.history_mode = (source_thread.history_mode == ThreadHistoryMode::Paginated) .then_some(ThreadHistoryMode::Paginated); @@ -533,7 +551,15 @@ async fn execute_inner( .await .map_err(|error| error.to_string())?; let thread_id = started.thread.id.clone(); + let daybreak_enabled = started.thread.daybreak_enabled.unwrap_or(false); register_background_thread(app_event_tx, started.thread, task_tools_available).await?; + let cyber_access_program = background_turn_program( + &handle, + &started.model, + &started.model_provider, + daybreak_enabled, + ) + .await?; if let Some(title) = arguments.title && let Err(error) = request::(&handle, |request_id| { ClientRequest::ThreadSetName { @@ -555,6 +581,7 @@ async fn execute_inner( prompt, /*model*/ None, sandbox_policy, + cyber_access_program, ) .await?; Ok(json!({"threadId": thread_id})) @@ -669,12 +696,20 @@ async fn execute_inner( }, ) .await?; + let daybreak_enabled = thread.daybreak_enabled.unwrap_or(false); register_background_thread( app_event_tx, resumed.thread, /*task_tools_available*/ false, ) .await?; + let cyber_access_program = background_turn_program( + &handle, + arguments.model.as_deref().unwrap_or(&resumed.model), + &resumed.model_provider, + daybreak_enabled, + ) + .await?; start_turn( &handle, &arguments.thread_id, @@ -682,6 +717,7 @@ async fn execute_inner( prompt, arguments.model, /*sandbox_policy*/ None, + cyber_access_program, ) .await?; Ok(json!({"threadId": arguments.thread_id})) @@ -1187,6 +1223,7 @@ async fn start_turn( prompt: String, model: Option, sandbox_policy: Option, + cyber_access_program: Option, ) -> Result { request(handle, |request_id| ClientRequest::TurnStart { request_id, @@ -1202,12 +1239,53 @@ async fn start_turn( })), model, sandbox_policy, + cyber_access_program, ..TurnStartParams::default() }, }) .await } +async fn background_turn_program( + handle: &AppServerRequestHandle, + model: &str, + provider: &str, + enabled: bool, +) -> Result, String> { + let (account, models) = tokio::join!( + request::(handle, |request_id| ClientRequest::GetAccount { + request_id, + params: GetAccountParams { + refresh_token: false + }, + }), + request::(handle, |request_id| ClientRequest::ModelList { + request_id, + params: ModelListParams { + cursor: None, + limit: None, + include_hidden: Some(true) + }, + }), + ); + let eligible = provider == "openai" + && matches!( + account.ok().and_then(|response| response.account), + Some(Account::Chatgpt { .. }) + ); + let models = models + .map(|response| { + response + .data + .into_iter() + .map(crate::app_server_session::model_preset_from_api_model) + .collect::>() + }) + .unwrap_or_default(); + crate::daybreak::program_for_turn(&models, model, eligible, enabled) + .map(|program| program.map(Into::into)) +} + fn thread_summary(thread: &Thread) -> Value { json!({ "id": thread.id, diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index c5fa224172..06d7b2f13a 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -180,12 +180,16 @@ pub(crate) fn new_cyber_policy_error_event( ), ], ), + Notice::Disabled => ( + "Daybreak is currently off. If you’re doing authorized security work, turn it on and try your request again.", + &[("Learn more", SAFETY_ACCESS_BLOCK_LEARN_MORE_URL)], + ), Notice::Astra => ( "Daybreak isn’t available for Astra. Some cybersecurity requests may still be limited.", &[("Learn more", SAFETY_ACCESS_BLOCK_LEARN_MORE_URL)], ), - Notice::Limited => ( - "We take extra care with some cybersecurity requests.", + Notice::Enabled => ( + "Some cybersecurity requests are still limited, even when Daybreak is on.", &[("Learn more", SAFETY_ACCESS_BLOCK_LEARN_MORE_URL)], ), }; diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_available_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_available_snapshot.snap new file mode 100644 index 0000000000..6c9272f98d --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_available_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +ⓘ This content can’t be shown + Daybreak is currently off. If you’re doing authorized security work, turn it + on and try your request again. + Learn more: https://help.openai.com/en/articles/20001326 + +ⓘ This content can’t be shown + Some cybersecurity requests are still limited, even when Daybreak is on. + Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap deleted file mode 100644 index 2b946b26e3..0000000000 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/history_cell/tests.rs -expression: rendered ---- -ⓘ This content can’t be shown - We take extra care with some cybersecurity requests. - Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index d2105eb0af..08e1f778b7 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -916,9 +916,17 @@ fn cyber_policy_error_event_astra_snapshot() { } #[test] -fn cyber_policy_error_event_limited_snapshot() { - let cell = new_cyber_policy_error_event(crate::daybreak::Notice::Limited); - let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); +fn cyber_policy_error_event_available_snapshot() { + let rendered = [ + crate::daybreak::Notice::Disabled, + crate::daybreak::Notice::Enabled, + ] + .into_iter() + .map(|notice| { + render_lines(&new_cyber_policy_error_event(notice).display_lines(/*width*/ 80)).join("\n") + }) + .collect::>() + .join("\n\n"); insta::assert_snapshot!(rendered); }