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); }