From b6d93b4cbadd6f449395d326f72465a5823f1492 Mon Sep 17 00:00:00 2001 From: jamy-OAI <332197178+jamy-OAI@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:50:41 +0000 Subject: [PATCH] Disable Daybreak when the selected model lacks access (#60526) ## Why Switching to a model whose access metadata excludes Daybreak can leave Daybreak enabled and block subsequent turns. ## What changed Automatically turn Daybreak off when a model switch or catalog refresh confirms that it is unavailable for the selected model. Persist the disabled preference for the thread and new threads, and warn that prior cybersecurity context may still cause refusals. Switching back to a compatible model leaves Daybreak off until the user re-enables it with `/daybreak`. Require explicit access metadata before disabling Daybreak, and recheck the selected model before applying a queued change. ## Testing Extend session lifecycle coverage for pending model discovery, supported and unknown models, persisted disabling, and subsequent turns using the standard access program. Add a snapshot of the model-switch warning. GitOrigin-RevId: 6c294939d854c7176ed9a346e8ea806ffcf6f715 --- codex-rs/tui/src/app/daybreak.rs | 11 +-- codex-rs/tui/src/app/event_dispatch.rs | 13 +++- .../app/tests/session_lifecycle_requests.rs | 67 +++++++++++++++++-- ...quests__daybreak_model_switch_warning.snap | 6 ++ codex-rs/tui/src/app_event.rs | 5 ++ .../tui/src/chatwidget/model_popup_state.rs | 1 + codex-rs/tui/src/chatwidget/settings.rs | 27 ++++++++ 7 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__daybreak_model_switch_warning.snap diff --git a/codex-rs/tui/src/app/daybreak.rs b/codex-rs/tui/src/app/daybreak.rs index 31a70f2a9d..7ed85c84e3 100644 --- a/codex-rs/tui/src/app/daybreak.rs +++ b/codex-rs/tui/src/app/daybreak.rs @@ -8,7 +8,7 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, enabled: bool, - ) { + ) -> bool { if !self .chat_widget .config_ref() @@ -16,7 +16,7 @@ impl App { .enabled(Feature::CliDaybreak) || self.active_thread_id != Some(thread_id) { - return; + return false; } if enabled && (!self.chat_widget.daybreak_account_eligible() @@ -25,7 +25,7 @@ impl App { self.chat_widget.add_error_message( "Daybreak availability could not be confirmed for this account.".into(), ); - return; + return false; } match app_server .thread_read(thread_id, /*include_turns*/ false) @@ -50,7 +50,7 @@ impl App { self.chat_widget.add_error_message(format!( "Failed to save Daybreak for this thread: {error}" )); - return; + return false; } } Ok(_) => {} @@ -58,7 +58,7 @@ impl App { self.chat_widget.add_error_message(format!( "Failed to read the thread to save Daybreak: {error}" )); - return; + return false; } } self.chat_widget.set_daybreak_enabled(enabled); @@ -119,5 +119,6 @@ impl App { ), /*hint*/ None, ); + true } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7c11ae7145..13c4ed103a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2890,7 +2890,18 @@ impl App { .await; } AppEvent::PersistDaybreakSelection { thread_id, enabled } => { - self.persist_daybreak_selection(app_server, thread_id, enabled).await; + Box::pin(self.persist_daybreak_selection(app_server, thread_id, enabled)).await; + } + AppEvent::DisableUnavailableDaybreak { thread_id, model } => { + if self.chat_widget.current_model() == model + && self.chat_widget.should_disable_daybreak(&model) + && Box::pin(self.persist_daybreak_selection(app_server, thread_id, /*enabled*/ false)).await + { + self.chat_widget.add_info_message( + format!("Daybreak has been turned off for new turns because it is not available for {model} with your current account and server. Prior cybersecurity context in this thread may cause a refusal. You can switch back to a model where you have Daybreak access and use /daybreak to re-enable it."), + /*hint*/ None, + ); + } } AppEvent::SelectSessionModel { model, effort, service_tier } => { self.app_event_tx.send(AppEvent::FollowTranscript); 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 5d13cf3f41..51f217032b 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -914,7 +914,7 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { }; *model = "standard-only".into(); while events.try_recv().is_ok() {} - app.submit_thread_op(&mut server, thread_id, unsupported_turn) + app.submit_thread_op(&mut server, thread_id, unsupported_turn.clone()) .await?; assert!(recorded_params(&requests, "turn/start").is_empty()); assert!(next_history_message(&mut events).contains("Daybreak support for model standard-only")); @@ -953,7 +953,17 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { app.chat_widget.set_daybreak_enabled(/*enabled*/ true); app.chat_widget .set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false); - let models = app.chat_widget.model_catalog().models.clone(); + let mut models = app.chat_widget.model_catalog().models.clone(); + for (name, cyber) in [ + ("red", vec![CyberAccessProgram::DaybreakRed]), + ("empty", vec![]), + ] { + models.push(ModelPreset { + model: name.into(), + available_access_programs: Some(ModelAccessPrograms { cyber }), + ..models[0].clone() + }); + } app.chat_widget.update_account_state( Some(crate::status::StatusAccountDisplay::ApiKey), /*plan_type*/ None, @@ -968,7 +978,26 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { }) .last() .expect("account model refresh"); + let next_disable = |events: &mut tokio::sync::mpsc::UnboundedReceiver<_>| { + std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::DisableUnavailableDaybreak { .. })) + }; + // A switch during discovery must wait for the current account's catalog. + app.chat_widget + .set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ true); + app.chat_widget.set_daybreak_enabled(/*enabled*/ true); + let original_model = app.chat_widget.current_model().to_owned(); + app.chat_widget.set_model("standard-only"); + assert!(next_disable(&mut events).is_none()); assert!(app.chat_widget.on_models_loaded(request_id, Ok(models))); + assert!(matches!(next_disable(&mut events), + Some(AppEvent::DisableUnavailableDaybreak { thread_id: id, model }) + if id == thread_id && model == "standard-only" + )); + for model in ["red", "empty", "unlisted", &original_model] { + app.chat_widget.set_model(model); + } + assert!(next_disable(&mut events).is_none()); for (cli_daybreak, api_key_cyber_access_programs, enabled) in [ (false, false, true), (false, true, true), @@ -998,11 +1027,41 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { let missing_thread_id = ThreadId::new(); app.active_thread_id = Some(missing_thread_id); - app.persist_daybreak_selection(&mut server, missing_thread_id, /*enabled*/ true) - .await; + assert!( + !app.persist_daybreak_selection(&mut server, missing_thread_id, /*enabled*/ true) + .await + ); assert!(next_history_message(&mut events).contains("Failed to read the thread")); assert!(!app.chat_widget.daybreak_enabled); assert!(!app.config.daybreak_enabled); + + app.active_thread_id = Some(thread_id); + app.persist_daybreak_selection(&mut server, thread_id, /*enabled*/ true) + .await; + assert!(next_history_message(&mut events).contains("Daybreak on")); + app.chat_widget.set_model("standard-only"); + let disable = next_disable(&mut events).expect("model switch disables Daybreak"); + assert!(app.chat_widget.daybreak_enabled); + app.handle_event(&mut tui, &mut server, disable).await?; + assert!(next_history_message(&mut events).contains("Daybreak off")); + let warning = next_history_message(&mut events); + insta::assert_snapshot!("daybreak_model_switch_warning", warning); + assert_eq!( + server + .thread_read(thread_id, /*include_turns*/ false) + .await? + .daybreak_enabled, + Some(false) + ); + assert!(!app.config.daybreak_enabled); + app.submit_thread_op(&mut server, thread_id, unsupported_turn) + .await?; + assert_eq!( + recorded_params(&requests, "turn/start")[turns.len()]["cyberAccessProgram"], + "standard" + ); + app.chat_widget.set_model(&original_model); + assert!(!app.chat_widget.daybreak_enabled); server.shutdown().await?; proxy.await??; Ok(()) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__daybreak_model_switch_warning.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__daybreak_model_switch_warning.snap new file mode 100644 index 0000000000..9448abecf6 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__daybreak_model_switch_warning.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/session_lifecycle_requests.rs +assertion_line: 181 +expression: warning +--- +• Daybreak has been turned off for new turns because it is not available for standard-only with your current account and server. Prior cybersecurity context in this thread may cause a refusal. You can switch back to a model where you have Daybreak access and use /daybreak to re-enable it. diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bd2c418c2b..a60bfdafa4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1513,6 +1513,11 @@ pub(crate) enum AppEvent { thread_id: ThreadId, enabled: bool, }, + /// Disable Daybreak after explicit account-scoped metadata excludes it for the selected model. + DisableUnavailableDaybreak { + thread_id: ThreadId, + model: String, + }, /// Fetch the current catalog even when cached models produce no picker. FetchModels { diff --git a/codex-rs/tui/src/chatwidget/model_popup_state.rs b/codex-rs/tui/src/chatwidget/model_popup_state.rs index ae280e79b0..6367e0feb3 100644 --- a/codex-rs/tui/src/chatwidget/model_popup_state.rs +++ b/codex-rs/tui/src/chatwidget/model_popup_state.rs @@ -87,6 +87,7 @@ impl ChatWidget { } Arc::make_mut(&mut self.model_catalog).models = presets; self.set_daybreak_enabled(self.daybreak_enabled); + self.queue_unavailable_daybreak_disable(self.current_model()); self.refresh_effective_service_tier(); self.refresh_model_dependent_surfaces(); true diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index f6dea221f4..18c805904a 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -311,6 +311,7 @@ impl ChatWidget { /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { if model != self.current_model() { + self.queue_unavailable_daybreak_disable(model); if self.current_model() == crate::model_catalog::LUNA_RESERVE_MODEL { self.clear_reserve_return(); } else { @@ -332,6 +333,26 @@ impl ChatWidget { self.refresh_model_dependent_surfaces(); } + pub(super) fn queue_unavailable_daybreak_disable(&self, model: &str) { + if let Some(thread_id) = self.thread_id + && self.should_disable_daybreak(model) + { + self.app_event_tx + .send(AppEvent::DisableUnavailableDaybreak { + thread_id, + model: model.to_owned(), + }); + } + } + + pub(crate) fn should_disable_daybreak(&self, model: &str) -> bool { + self.daybreak_enabled + && self.model_catalog.models.iter().any(|entry| { + entry.model == model + && crate::daybreak::availability(std::slice::from_ref(entry)) == Some(false) + }) + } + pub(crate) fn current_model(&self) -> &str { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.model(); @@ -550,6 +571,9 @@ impl ChatWidget { } } + if self.current_model() != settings.model { + self.queue_unavailable_daybreak_disable(&settings.model); + } settings.collaboration_mode.settings.model = settings.model; settings.collaboration_mode.settings.reasoning_effort = settings.effort; self.set_effective_collaboration_mode(settings.collaboration_mode); @@ -738,6 +762,9 @@ impl ChatWidget { } self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); + if previous_model != self.current_model() { + self.queue_unavailable_daybreak_disable(self.current_model()); + } self.refresh_model_dependent_surfaces(); let next_mode = self.active_mode_kind(); let next_model = self.current_model();