Honor API-key feature gates in TUI Daybreak selection (#52228)

## Why

API-key accounts could retain a saved Daybreak preference without `ApiKeyCyberAccessPrograms` enabled. Eligible API-key turns also omitted the `standard` access program when Daybreak was off.

## What changed

- Require both `CliDaybreak` and `ApiKeyCyberAccessPrograms` for known API-key accounts, and refresh Daybreak state when either flag changes.
- Use account eligibility consistently for normal turns, continuations, and background tasks so eligible API-key accounts can select the catalog's `standard` program when Daybreak is off.
- Preserve resumed preferences while account state is unknown and update `/daybreak` help to reflect API-key eligibility.

## Testing

Extend regression coverage for feature-flag combinations, API-key continuations, resumed preferences, and `/daybreak` help.

GitOrigin-RevId: 966f4789c9d685ff122da196a13da50c60a4b393
This commit is contained in:
Brian Lee
2026-10-08 19:02:53 +00:00
committed by copyberry
parent 96d238d0b9
commit 5505a80f38
10 changed files with 104 additions and 32 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ impl App {
return;
}
if enabled
&& (!self.chat_widget.daybreak_turn_eligible(enabled)
&& (!self.chat_widget.daybreak_account_eligible()
|| !crate::daybreak::available(&self.chat_widget.model_catalog().models))
{
self.chat_widget.add_error_message(
+1 -1
View File
@@ -117,7 +117,7 @@ impl App {
let enabled = self.chat_widget.daybreak_enabled
&& !self.chat_widget.side_conversation_active()
&& !self.side_threads.contains_key(&review.thread_id);
let eligible_account = self.chat_widget.daybreak_turn_eligible(enabled);
let eligible_account = self.chat_widget.daybreak_account_eligible();
let cyber_access_program = match crate::daybreak::program_for_turn(
&self.chat_widget.model_catalog().models,
self.chat_widget.current_model(),
+1 -1
View File
@@ -88,7 +88,7 @@ impl App {
let daybreak_enabled = self.chat_widget.daybreak_enabled
&& !self.chat_widget.side_conversation_active()
&& !self.side_threads.contains_key(&thread_id);
let eligible_account = self.chat_widget.daybreak_turn_eligible(daybreak_enabled);
let eligible_account = self.chat_widget.daybreak_account_eligible();
if let Err(message) = crate::daybreak::program_for_turn(
&self.chat_widget.model_catalog().models,
&model,
@@ -40,16 +40,21 @@ fn error_notification(
#[tokio::test]
async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> {
for (reject, preserve, daybreak, rollout_enabled) in [
(false, false, false, true),
(false, true, true, true),
(true, false, false, true),
(false, false, false, false),
(false, true, true, false),
for (reject, preserve, daybreak, rollout_enabled, has_chatgpt_account) in [
(false, false, false, true, true),
(false, true, true, true, true),
(true, false, false, true, true),
(false, false, false, false, true),
(false, true, true, false, true),
// Continuations submit directly rather than via the normal turn-start path.
(false, false, false, true, false),
(false, true, true, true, false),
] {
let (mut app, mut rx, _) = make_test_app_with_channels().await;
app.chat_widget
.set_feature_enabled(Feature::CliDaybreak, rollout_enabled);
app.chat_widget
.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, !has_chatgpt_account);
let (mut server, requests, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
@@ -70,8 +75,10 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() ->
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,
(!has_chatgpt_account).then_some(crate::status::StatusAccountDisplay::ApiKey),
/*plan_type*/ None,
has_chatgpt_account,
/*has_codex_backend_auth*/ has_chatgpt_account,
);
app.chat_widget.open_model_popup();
let request_id = std::iter::from_fn(|| rx.try_recv().ok())
@@ -196,17 +196,32 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> {
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ false,
);
assert!(
!app.chat_widget
.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false,)
);
app.submit_thread_op(&mut server, thread_id, turn.clone())
.await?;
app.chat_widget.set_daybreak_enabled(/*enabled*/ false);
app.submit_thread_op(&mut server, thread_id, turn).await?;
assert!(!app.chat_widget.daybreak_enabled);
for (cli_daybreak, api_key_cyber_access_programs, enabled) in [
(false, false, true),
(false, true, true),
(true, false, true),
(true, true, true),
(true, true, false),
] {
app.chat_widget
.set_feature_enabled(Feature::CliDaybreak, cli_daybreak);
app.chat_widget.set_feature_enabled(
Feature::ApiKeyCyberAccessPrograms,
api_key_cyber_access_programs,
);
app.chat_widget.set_daybreak_enabled(enabled);
app.submit_thread_op(&mut server, thread_id, turn.clone())
.await?;
}
let turns = recorded_params(&requests, "turn/start");
assert_eq!(turns[5]["cyberAccessProgram"], "daybreakBlue");
assert!(turns[6]["cyberAccessProgram"].is_null());
assert_eq!(
turns[5..]
.iter()
.map(|turn| turn["cyberAccessProgram"].as_str())
.collect::<Vec<_>>(),
vec![None, None, None, Some("daybreakBlue"), Some("standard")]
);
while events.try_recv().is_ok() {}
let missing_thread_id = ThreadId::new();
+1 -1
View File
@@ -828,7 +828,7 @@ impl App {
let enabled = self.chat_widget.daybreak_enabled
&& !self.chat_widget.side_conversation_active()
&& !self.side_threads.contains_key(&thread_id);
let eligible_account = self.chat_widget.daybreak_turn_eligible(enabled);
let eligible_account = self.chat_widget.daybreak_account_eligible();
let cyber_access_program = match crate::daybreak::program_for_turn(
&self.chat_widget.model_catalog().models,
model,
+22 -9
View File
@@ -6,14 +6,29 @@ use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID;
impl ChatWidget {
pub(crate) fn set_daybreak_enabled(&mut self, enabled: bool) {
self.daybreak_enabled = enabled && self.config.features.enabled(Feature::CliDaybreak);
self.daybreak_enabled = enabled && self.daybreak_feature_enabled();
self.bottom_pane
.set_daybreak_command_description(self.daybreak_command_description());
}
/// UI eligibility only; the catalog and server still determine model/program access.
pub(crate) fn daybreak_account_eligible(&self) -> bool {
fn daybreak_feature_enabled(&self) -> bool {
// Preserve a resumed preference while account state is still unknown. Once an API-key
// account is known, both flags must be enabled before using that preference.
self.config.features.enabled(Feature::CliDaybreak)
&& (self.has_chatgpt_account
|| !matches!(
self.status_account_display,
Some(StatusAccountDisplay::ApiKey)
)
|| self
.config
.features
.enabled(Feature::ApiKeyCyberAccessPrograms))
}
/// UI and turn-selection eligibility; the catalog and server still determine program access.
pub(crate) fn daybreak_account_eligible(&self) -> bool {
self.daybreak_feature_enabled()
&& self.config.model_provider_id == "openai"
&& (self.has_chatgpt_account
|| matches!(
@@ -22,11 +37,6 @@ impl ChatWidget {
))
}
/// API-key turns need no explicit program while Daybreak is off.
pub(crate) fn daybreak_turn_eligible(&self, enabled: bool) -> bool {
self.daybreak_account_eligible() && (self.has_chatgpt_account || enabled)
}
pub(super) fn daybreak_command_description(&self) -> Option<&'static str> {
if self.daybreak_enabled {
Some("Disable broader access for cybersecurity work")
@@ -112,7 +122,10 @@ impl ChatWidget {
);
}
let enabled = self.config.features.enabled(feature);
if feature == Feature::CliDaybreak {
if matches!(
feature,
Feature::CliDaybreak | Feature::ApiKeyCyberAccessPrograms
) {
self.set_daybreak_enabled(self.daybreak_enabled);
self.refresh_status_surfaces();
}
@@ -2,4 +2,4 @@
source: tui/src/chatwidget/tests/slash_commands.rs
expression: "render_bottom_popup(&chat, 80).lines().next().unwrap()"
---
› /daybreak Learn about broader access for cybersecurity work
› /daybreak Enable broader access for cybersecurity work
@@ -53,10 +53,30 @@ async fn resumed_initial_messages_render_history() {
@""
);
// A saved On preference cannot bypass the API-key gate.
chat.status_account_display = Some(StatusAccountDisplay::ApiKey);
chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true);
configured.thread_id = ThreadId::new();
chat.handle_thread_session(configured.clone());
assert!(!chat.daybreak_enabled);
insta::assert_snapshot!(
drain_insert_history(&mut rx).into_iter().flatten()
.map(|line| line.to_string())
.filter(|line| line.contains("Daybreak"))
.collect::<Vec<_>>().join("\n"),
@""
);
// Session hydration can precede the account update on ChatGPT sign-in.
chat.status_account_display = None;
configured.thread_id = ThreadId::new();
chat.handle_thread_session(configured);
assert!(chat.daybreak_enabled);
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
);
assert!(chat.daybreak_enabled);
replay_user_message_text(
&mut chat,
"user-1",
@@ -2272,7 +2272,10 @@ async fn slash_daybreak_offers_an_application_when_unavailable() {
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![model]));
chat.has_chatgpt_account = false;
chat.status_account_display = Some(StatusAccountDisplay::ApiKey);
chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ true);
chat.set_daybreak_enabled(/*enabled*/ false);
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
chat.bottom_pane
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
assert_chatwidget_snapshot!(
@@ -2282,6 +2285,20 @@ async fn slash_daybreak_offers_an_application_when_unavailable() {
.next()
.unwrap()
);
chat.set_daybreak_enabled(/*enabled*/ true);
chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false);
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
chat.bottom_pane
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
insta::assert_snapshot!(
render_bottom_popup(&chat, /*width*/ 80)
.lines()
.filter(|line| line.contains("broader access"))
.collect::<Vec<_>>()
.join("\n"),
@""
);
chat.has_chatgpt_account = true;
chat.status_account_display = None;
for (enabled, name) in [