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
This commit is contained in:
jamy-OAI
2026-11-10 05:09:30 +00:00
committed by copyberry
parent 86a5522ded
commit b6d93b4cba
7 changed files with 120 additions and 10 deletions
+6 -5
View File
@@ -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
}
}
+12 -1
View File
@@ -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);
@@ -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(())
@@ -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.
+5
View File
@@ -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 {
@@ -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
+27
View File
@@ -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();