diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 04de4f1274..5cbf0aac40 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -809,6 +809,16 @@ "Notice": { "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", "properties": { + "availability_nux_display_counts": { + "additionalProperties": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "default": {}, + "description": "Tracks how many times the TUI actually displayed each availability NUX.", + "type": "object" + }, "hide_full_access_warning": { "description": "Tracks whether the user has acknowledged the full access warning prompt.", "type": "boolean" @@ -836,16 +846,6 @@ "default": {}, "description": "Tracks acknowledged model migrations as old->new model slug mappings.", "type": "object" - }, - "model_new_nux_display_counts": { - "additionalProperties": { - "format": "uint32", - "minimum": 0.0, - "type": "integer" - }, - "default": {}, - "description": "Tracks how many times the TUI actually displayed the model-new startup tip.", - "type": "object" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index dc55bd8942..d9b376106b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -39,8 +39,8 @@ pub enum ConfigEdit { SetNoticeHideModelMigrationPrompt(String, bool), /// Record that a migration prompt was shown for an old->new model mapping. RecordModelMigrationSeen { from: String, to: String }, - /// Record that the model-new startup tip was displayed for a model. - RecordModelNewNuxDisplay { model: String }, + /// Record that an availability NUX was displayed. + RecordAvailabilityNuxDisplay { id: String }, /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), /// Set or clear a skill config entry under `[[skills.config]]`. @@ -333,19 +333,19 @@ impl ConfigDocument { &[Notice::TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), - ConfigEdit::RecordModelNewNuxDisplay { model } => { + ConfigEdit::RecordAvailabilityNuxDisplay { id } => { let resolved = self.scoped_segments( Scope::Global, - &[Notice::TABLE_KEY, "model_new_nux_display_counts"], + &[Notice::TABLE_KEY, "availability_nux_display_counts"], ); let Some(table) = self.descend(&resolved, TraversalMode::Create) else { return Ok(false); }; let current = table - .get(model.as_str()) + .get(id.as_str()) .and_then(toml_edit::Item::as_integer) .unwrap_or(0); - table[model.as_str()] = value(current + 1); + table[id.as_str()] = value(current + 1); Ok(true) } ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( @@ -805,10 +805,9 @@ impl ConfigEditsBuilder { self } - pub fn record_model_new_nux_display(mut self, model: &str) -> Self { - self.edits.push(ConfigEdit::RecordModelNewNuxDisplay { - model: model.to_string(), - }); + pub fn record_availability_nux_display(mut self, id: &str) -> Self { + self.edits + .push(ConfigEdit::RecordAvailabilityNuxDisplay { id: id.to_string() }); self } @@ -1459,7 +1458,7 @@ gpt-5 = "gpt-5.1" } #[test] - fn blocking_record_model_new_nux_display_increments_count() { + fn blocking_record_availability_nux_display_increments_count() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( @@ -1467,16 +1466,16 @@ gpt-5 = "gpt-5.1" r#"[notice] existing = "value" -[notice.model_new_nux_display_counts] -gpt-5 = 1 +[notice.availability_nux_display_counts] +try_spark = 1 "#, ) .expect("seed"); apply_blocking( codex_home, None, - &[ConfigEdit::RecordModelNewNuxDisplay { - model: "gpt-5".to_string(), + &[ConfigEdit::RecordAvailabilityNuxDisplay { + id: "try_spark".to_string(), }], ) .expect("persist"); @@ -1486,8 +1485,8 @@ gpt-5 = 1 let expected = r#"[notice] existing = "value" -[notice.model_new_nux_display_counts] -gpt-5 = 2 +[notice.availability_nux_display_counts] +try_spark = 2 "#; assert_eq!(contents, expected); } diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index c4161bb9bb..d482faa2c0 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -722,9 +722,9 @@ pub struct Notice { /// Tracks acknowledged model migrations as old->new model slug mappings. #[serde(default)] pub model_migrations: BTreeMap, - /// Tracks how many times the TUI actually displayed the model-new startup tip. + /// Tracks how many times the TUI actually displayed each availability NUX. #[serde(default)] - pub model_new_nux_display_counts: BTreeMap, + pub availability_nux_display_counts: BTreeMap, } impl Notice { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 591c997211..2d643c2667 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2689,16 +2689,16 @@ impl App { )); } } - AppEvent::PersistModelNewNuxDisplayed { model } => { + AppEvent::PersistAvailabilityNuxDisplayed { id } => { let count = self .config .notices - .model_new_nux_display_counts - .entry(model.clone()) + .availability_nux_display_counts + .entry(id.clone()) .or_default(); *count += 1; if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .record_model_new_nux_display(model.as_str()) + .record_availability_nux_display(id.as_str()) .apply() .await { @@ -3828,7 +3828,7 @@ mod tests { app.chat_widget.current_model(), event, is_first, - None, + Vec::new(), )) as Arc }; @@ -3874,7 +3874,7 @@ mod tests { requested_model: &str, actual_model: &str, is_first_event: bool, - selected_startup_tip: Option<&str>, + startup_tips: &[&str], ) -> String { let event = SessionConfiguredEvent { session_id: ThreadId::new(), @@ -3896,7 +3896,7 @@ mod tests { requested_model, event, is_first_event, - selected_startup_tip.map(str::to_string), + startup_tips.iter().map(|tip| (*tip).to_string()).collect(), ); cell.display_lines(80) @@ -3929,20 +3929,32 @@ mod tests { "gpt-test", "gpt-test", true, - Some( - "New You're using GPT Test. Fast, high-reliability coding model. Use /model to compare or switch anytime.", - ), + &["New Spark is now available to you."], ); assert_snapshot!("first_session_header_with_model_nux", rendered); } + #[test] + fn first_session_header_with_multiple_model_nux_matches_snapshot() { + let rendered = render_session_info_snapshot( + "gpt-test", + "gpt-test", + true, + &[ + "New Spark is now available to you.", + "New Canvas is now available to you.", + ], + ); + assert_snapshot!("first_session_header_with_multiple_model_nux", rendered); + } + #[test] fn later_session_header_with_model_nux_matches_snapshot() { let rendered = render_session_info_snapshot( "gpt-test", "gpt-test", false, - Some("New You're using GPT Test. Use /model to compare or switch anytime."), + &["New Spark is now available to you."], ); assert_snapshot!("later_session_header_with_model_nux", rendered); } @@ -3950,7 +3962,7 @@ mod tests { #[test] fn later_session_header_after_model_nux_exhaustion_matches_snapshot() { let rendered = - render_session_info_snapshot("requested-model", "resolved-model", false, None); + render_session_info_snapshot("requested-model", "resolved-model", false, &[]); assert_snapshot!("later_session_header_after_model_nux_exhaustion", rendered); } @@ -4396,7 +4408,7 @@ mod tests { app.chat_widget.current_model(), event, is_first, - None, + Vec::new(), )) as Arc }; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d60eea371..31b6823dc3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -293,9 +293,9 @@ pub(crate) enum AppEvent { to_model: String, }, - /// Persist that the model-new startup tip was displayed for a model. - PersistModelNewNuxDisplayed { - model: String, + /// Persist that an availability NUX was displayed. + PersistAvailabilityNuxDisplayed { + id: String, }, /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 34c1bc63b4..2327b556b2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1131,26 +1131,18 @@ impl ChatWidget { self.last_copyable_output = None; let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); - let startup_tip = if self.config.show_tooltips { - let active_model = self - .models_manager - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model_for_header) - }); - crate::tooltips::get_startup_tip( - active_model.as_ref(), - &self.config.notices.model_new_nux_display_counts, + let startup_tips = if self.config.show_tooltips { + let models = self.models_manager.try_list_models().unwrap_or_default(); + crate::tooltips::get_startup_tips( + &models, + &self.config.notices.availability_nux_display_counts, self.auth_manager .auth_cached() .and_then(|auth| auth.account_plan_type()), self.show_welcome_banner, ) } else { - None + Default::default() }; self.session_header.set_model(&model_for_header); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( @@ -1160,24 +1152,51 @@ impl ChatWidget { ); self.refresh_model_display(); self.sync_personality_command_enabled(); - let selected_startup_tip = startup_tip.clone().map(StartupTip::message); + let displayed_startup_tips = if self.show_welcome_banner { + startup_tips + .first_session_tips + .iter() + .map(StartupTip::message) + .collect() + } else { + startup_tips + .selected_tip + .iter() + .map(StartupTip::message) + .collect() + }; let session_info_cell = history_cell::new_session_info( &requested_model, event, self.show_welcome_banner, - selected_startup_tip, + displayed_startup_tips, ); self.apply_session_info_cell(session_info_cell); - if let Some(StartupTip::ModelNew { model, .. }) = startup_tip { + let displayed_availability_nux_ids: Vec = if self.show_welcome_banner { + startup_tips + .first_session_tips + .iter() + .filter_map(StartupTip::availability_nux_id) + .map(str::to_string) + .collect() + } else { + startup_tips + .selected_tip + .iter() + .filter_map(StartupTip::availability_nux_id) + .map(str::to_string) + .collect() + }; + for id in displayed_availability_nux_ids { let count = self .config .notices - .model_new_nux_display_counts - .entry(model.clone()) + .availability_nux_display_counts + .entry(id.clone()) .or_default(); *count += 1; self.app_event_tx - .send(AppEvent::PersistModelNewNuxDisplayed { model }); + .send(AppEvent::PersistAvailabilityNuxDisplayed { id }); } if let Some(messages) = initial_messages { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index afa8ebf1e6..02b288dcc1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -43,6 +43,7 @@ use codex_protocol::items::AgentMessageItem; use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::models::MessagePhase; +use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffortPreset; @@ -1845,7 +1846,12 @@ async fn session_configured_first_session_shows_model_nux_and_increments_display let mut model_info = construct_model_info_offline("gpt-5.1", &chat.config); model_info.display_name = "GPT Test".to_string(); model_info.description = Some("Fast, high-reliability coding model.".to_string()); - model_info.show_nux = true; + model_info.availability_nux = Some(ModelAvailabilityNux { + id: "try_spark".to_string(), + message: + "*New* You're using **GPT Test**. Fast, high-reliability coding model. Use /model to compare or switch anytime." + .to_string(), + }); chat.models_manager = Arc::new(ModelsManager::new( chat.config.codex_home.clone(), chat.auth_manager.clone(), @@ -1884,8 +1890,8 @@ async fn session_configured_first_session_shows_model_nux_and_increments_display Some(&1), chat.config .notices - .model_new_nux_display_counts - .get("gpt-5.1") + .availability_nux_display_counts + .get("try_spark") ); } @@ -1893,7 +1899,7 @@ async fn session_configured_first_session_shows_model_nux_and_increments_display async fn session_configured_ineligible_model_does_not_increment_display_count() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; let mut model_info = construct_model_info_offline("gpt-5.1", &chat.config); - model_info.show_nux = false; + model_info.availability_nux = None; chat.models_manager = Arc::new(ModelsManager::new( chat.config.codex_home.clone(), chat.auth_manager.clone(), @@ -1928,8 +1934,8 @@ async fn session_configured_ineligible_model_does_not_increment_display_count() None, chat.config .notices - .model_new_nux_display_counts - .get("gpt-5.1") + .availability_nux_display_counts + .get("try_spark") ); } @@ -6116,7 +6122,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { is_default: false, upgrade: None, show_in_picker, - show_nux: false, + availability_nux: None, supported_in_api: true, input_modalities: default_input_modalities(), }; @@ -6385,7 +6391,7 @@ async fn single_reasoning_option_skips_selection() { is_default: false, upgrade: None, show_in_picker: true, - show_nux: false, + availability_nux: None, supported_in_api: true, input_modalities: default_input_modalities(), }; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index aa1dff8150..5fb6686202 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1038,7 +1038,7 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, - selected_startup_tip: Option, + startup_tips: Vec, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -1086,12 +1086,12 @@ pub(crate) fn new_session_info( ]; parts.push(Box::new(PlainHistoryCell { lines: help_lines })); - if let Some(selected_startup_tip) = selected_startup_tip { - parts.push(Box::new(TooltipHistoryCell::new(selected_startup_tip))); + for startup_tip in startup_tips { + parts.push(Box::new(TooltipHistoryCell::new(startup_tip))); } } else { - if let Some(selected_startup_tip) = selected_startup_tip { - parts.push(Box::new(TooltipHistoryCell::new(selected_startup_tip))); + for startup_tip in startup_tips { + parts.push(Box::new(TooltipHistoryCell::new(startup_tip))); } if requested_model != model { let lines = vec![ diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_model_nux.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_model_nux.snap index 8561910272..e1c277fae3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_model_nux.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_model_nux.snap @@ -17,5 +17,4 @@ expression: rendered /model - choose what model and reasoning effort to use /review - review any changes and find issues - Tip: New You're using GPT Test. Fast, high-reliability coding model. Use / - model to compare or switch anytime. + Tip: New Spark is now available to you. diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_multiple_model_nux.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_multiple_model_nux.snap new file mode 100644 index 0000000000..4d31ce2c46 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__first_session_header_with_multiple_model_nux.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/app.rs +expression: rendered +--- +╭─────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-test high /model to change │ +│ directory: /tmp/project │ +╰─────────────────────────────────────────────╯ + + To get started, describe a task or try one of these commands: + + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration + /permissions - choose what Codex is allowed to do + /model - choose what model and reasoning effort to use + /review - review any changes and find issues + + Tip: New Spark is now available to you. + + Tip: New Canvas is now available to you. diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__later_session_header_with_model_nux.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__later_session_header_with_model_nux.snap index 3d366c3043..d7816646d3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__later_session_header_with_model_nux.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__later_session_header_with_model_nux.snap @@ -9,4 +9,4 @@ expression: rendered │ directory: /tmp/project │ ╰─────────────────────────────────────────────╯ - Tip: New You're using GPT Test. Use /model to compare or switch anytime. + Tip: New Spark is now available to you. diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 5317b7051d..f1a963a48f 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -1,9 +1,11 @@ use codex_core::features::FEATURES; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use lazy_static::lazy_static; use rand::Rng; use std::collections::BTreeMap; +use std::collections::HashSet; const ANNOUNCEMENT_TIP_URL: &str = "https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml"; @@ -51,27 +53,40 @@ fn experimental_tooltips() -> Vec<&'static str> { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum StartupTip { Generic(String), - ModelNew { model: String, message: String }, + AvailabilityNux { id: String, message: String }, } impl StartupTip { - pub(crate) fn message(self) -> String { + pub(crate) fn message(&self) -> String { match self { - Self::Generic(message) | Self::ModelNew { message, .. } => message, + Self::Generic(message) | Self::AvailabilityNux { message, .. } => message.clone(), + } + } + + pub(crate) fn availability_nux_id(&self) -> Option<&str> { + match self { + Self::Generic(_) => None, + Self::AvailabilityNux { id, .. } => Some(id.as_str()), } } } -pub(crate) fn get_startup_tip( - model: Option<&ModelPreset>, - model_new_nux_display_counts: &BTreeMap, +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct StartupTips { + pub(crate) first_session_tips: Vec, + pub(crate) selected_tip: Option, +} + +pub(crate) fn get_startup_tips( + models: &[ModelPreset], + availability_nux_display_counts: &BTreeMap, plan: Option, is_first_session: bool, -) -> Option { +) -> StartupTips { let mut rng = rand::rng(); - get_startup_tip_with_rng( - model, - model_new_nux_display_counts, + get_startup_tips_with_rng( + models, + availability_nux_display_counts, plan, is_first_session, announcement::fetch_announcement_tip(), @@ -79,39 +94,38 @@ pub(crate) fn get_startup_tip( ) } -fn get_startup_tip_with_rng( - model: Option<&ModelPreset>, - model_new_nux_display_counts: &BTreeMap, +fn get_startup_tips_with_rng( + models: &[ModelPreset], + availability_nux_display_counts: &BTreeMap, plan: Option, is_first_session: bool, announcement_tip: Option, rng: &mut R, -) -> Option { - let model_new_tip = model.and_then(|preset| { - let display_count = model_new_nux_display_counts - .get(&preset.model) - .copied() - .unwrap_or(0); - (preset.show_nux && display_count < 4).then(|| StartupTip::ModelNew { - model: preset.model.clone(), - message: model_new_tooltip(preset), - }) - }); +) -> StartupTips { + let availability_nux_tips = availability_nux_tips(models, availability_nux_display_counts); if is_first_session { - return model_new_tip; + return StartupTips { + first_session_tips: availability_nux_tips, + selected_tip: None, + }; } - let Some(model_new_tip) = model_new_tip else { - return get_generic_tooltip_with_rng(plan, announcement_tip, rng).map(StartupTip::Generic); - }; + if availability_nux_tips.is_empty() { + return StartupTips { + first_session_tips: Vec::new(), + selected_tip: get_generic_tooltip_with_rng(plan, announcement_tip, rng) + .map(StartupTip::Generic), + }; + } - let mut weighted_candidates = vec![ - model_new_tip.clone(), - model_new_tip.clone(), - model_new_tip.clone(), - model_new_tip, - ]; + let mut weighted_candidates = Vec::new(); + for availability_nux_tip in &availability_nux_tips { + weighted_candidates.push(availability_nux_tip.clone()); + weighted_candidates.push(availability_nux_tip.clone()); + weighted_candidates.push(availability_nux_tip.clone()); + weighted_candidates.push(availability_nux_tip.clone()); + } if let Some(announcement_tip) = announcement_tip { weighted_candidates.push(StartupTip::Generic(announcement_tip)); } @@ -122,9 +136,12 @@ fn get_startup_tip_with_rng( weighted_candidates.push(StartupTip::Generic(random_tip.to_string())); } - weighted_candidates - .get(rng.random_range(0..weighted_candidates.len())) - .cloned() + StartupTips { + first_session_tips: Vec::new(), + selected_tip: weighted_candidates + .get(rng.random_range(0..weighted_candidates.len())) + .cloned(), + } } fn get_generic_tooltip_with_rng( @@ -167,18 +184,31 @@ fn plan_tooltip(plan: Option) -> Option<&'static str> { } } -fn model_new_tooltip(model: &ModelPreset) -> String { - let description = model.description.trim(); - if description.is_empty() { - format!( - "*New* You're using **{}**. Use /model to compare or switch anytime.", - model.display_name - ) - } else { - format!( - "*New* You're using **{}**. {} Use /model to compare or switch anytime.", - model.display_name, description - ) +fn availability_nux_tips( + models: &[ModelPreset], + availability_nux_display_counts: &BTreeMap, +) -> Vec { + let mut seen_ids = HashSet::new(); + + models + .iter() + .filter_map(|preset| preset.availability_nux.as_ref()) + .filter(|availability_nux| { + availability_nux_display_counts + .get(&availability_nux.id) + .copied() + .unwrap_or(0) + < 4 + }) + .filter(|availability_nux| seen_ids.insert(availability_nux.id.clone())) + .map(startup_tip_from_availability_nux) + .collect() +} + +fn startup_tip_from_availability_nux(availability_nux: &ModelAvailabilityNux) -> StartupTip { + StartupTip::AvailabilityNux { + id: availability_nux.id.clone(), + message: availability_nux.message.clone(), } } @@ -343,7 +373,7 @@ mod tests { use rand::SeedableRng; use rand::rngs::StdRng; - fn model_preset(show_nux: bool, description: &str) -> ModelPreset { + fn model_preset(availability_nux: Option<(&str, &str)>, description: &str) -> ModelPreset { ModelPreset { id: "gpt-test".to_string(), model: "gpt-test".to_string(), @@ -355,7 +385,10 @@ mod tests { is_default: false, upgrade: None, show_in_picker: true, - show_nux, + availability_nux: availability_nux.map(|(id, message)| ModelAvailabilityNux { + id: id.to_string(), + message: message.to_string(), + }), supported_in_api: true, input_modalities: codex_protocol::openai_models::default_input_modalities(), } @@ -479,12 +512,15 @@ content = "This is a test announcement" } #[test] - fn first_session_eligible_model_returns_model_tip() { - let preset = model_preset(true, "Fast, high-reliability coding model."); + fn first_session_eligible_model_returns_all_availability_nux_tips() { + let preset = model_preset( + Some(("try_spark", "*New* Spark is now available to you.")), + "Fast, high-reliability coding model.", + ); let mut rng = StdRng::seed_from_u64(1); - let tip = get_startup_tip_with_rng( - Some(&preset), + let tips = get_startup_tips_with_rng( + &[preset], &BTreeMap::new(), Some(PlanType::Plus), true, @@ -493,21 +529,24 @@ content = "This is a test announcement" ); assert_eq!( - Some(StartupTip::ModelNew { - model: "gpt-test".to_string(), - message: "*New* You're using **GPT Test**. Fast, high-reliability coding model. Use /model to compare or switch anytime.".to_string(), - }), - tip + StartupTips { + first_session_tips: vec![StartupTip::AvailabilityNux { + id: "try_spark".to_string(), + message: "*New* Spark is now available to you.".to_string(), + }], + selected_tip: None, + }, + tips ); } #[test] fn first_session_ineligible_model_skips_tip() { - let preset = model_preset(false, ""); + let preset = model_preset(None, ""); let mut rng = StdRng::seed_from_u64(1); - let tip = get_startup_tip_with_rng( - Some(&preset), + let tips = get_startup_tips_with_rng( + &[preset], &BTreeMap::new(), Some(PlanType::Plus), true, @@ -515,16 +554,68 @@ content = "This is a test announcement" &mut rng, ); - assert_eq!(None, tip); + assert_eq!(StartupTips::default(), tips); } #[test] - fn later_session_can_select_model_tip_from_weighted_pool() { - let preset = model_preset(true, ""); + fn first_session_dedupes_multiple_availability_nuxes_by_id() { + let mut rng = StdRng::seed_from_u64(1); + let models = vec![ + model_preset( + Some(("try_spark", "*New* Spark is now available to you.")), + "", + ), + model_preset( + Some(("try_spark", "*New* Spark is now available to you.")), + "", + ), + ModelPreset { + model: "gpt-other".to_string(), + availability_nux: Some(ModelAvailabilityNux { + id: "try_canvas".to_string(), + message: "*New* Canvas is now available to you.".to_string(), + }), + ..model_preset(None, "") + }, + ]; + + let tips = get_startup_tips_with_rng( + &models, + &BTreeMap::new(), + Some(PlanType::Plus), + true, + Some("announcement".to_string()), + &mut rng, + ); + + assert_eq!( + StartupTips { + first_session_tips: vec![ + StartupTip::AvailabilityNux { + id: "try_spark".to_string(), + message: "*New* Spark is now available to you.".to_string(), + }, + StartupTip::AvailabilityNux { + id: "try_canvas".to_string(), + message: "*New* Canvas is now available to you.".to_string(), + }, + ], + selected_tip: None, + }, + tips + ); + } + + #[test] + fn later_session_can_select_availability_nux_from_weighted_pool() { + let preset = model_preset( + Some(("try_spark", "*New* Spark is now available to you.")), + "", + ); let mut rng = StdRng::seed_from_u64(5); - let tip = get_startup_tip_with_rng( - Some(&preset), + let tips = get_startup_tips_with_rng( + &[preset], &BTreeMap::new(), Some(PlanType::Plus), false, @@ -533,24 +624,28 @@ content = "This is a test announcement" ); assert_eq!( - Some(StartupTip::ModelNew { - model: "gpt-test".to_string(), - message: - "*New* You're using **GPT Test**. Use /model to compare or switch anytime." - .to_string(), - }), - tip + StartupTips { + first_session_tips: Vec::new(), + selected_tip: Some(StartupTip::AvailabilityNux { + id: "try_spark".to_string(), + message: "*New* Spark is now available to you.".to_string(), + }), + }, + tips ); } #[test] - fn later_session_count_limit_disables_model_tip() { - let preset = model_preset(true, ""); - let counts = BTreeMap::from([("gpt-test".to_string(), 4)]); + fn later_session_count_limit_disables_availability_nux() { + let preset = model_preset( + Some(("try_spark", "*New* Spark is now available to you.")), + "", + ); + let counts = BTreeMap::from([("try_spark".to_string(), 4)]); let mut rng = StdRng::seed_from_u64(5); - let tip = get_startup_tip_with_rng( - Some(&preset), + let tips = get_startup_tips_with_rng( + &[preset], &counts, Some(PlanType::Plus), false, @@ -558,27 +653,37 @@ content = "This is a test announcement" &mut rng, ); - assert_eq!(Some(StartupTip::Generic("announcement".to_string())), tip); + assert_eq!( + StartupTips { + first_session_tips: Vec::new(), + selected_tip: Some(StartupTip::Generic("announcement".to_string())), + }, + tips + ); } #[test] fn later_session_eligible_model_includes_announcement_and_generic_candidates() { - let preset = model_preset(true, ""); + let preset = model_preset( + Some(("try_spark", "*New* Spark is now available to you.")), + "", + ); let mut saw_announcement = false; let mut saw_plan_tip = false; let mut saw_random_tip = false; - let mut saw_model_tip = false; + let mut saw_availability_nux_tip = false; for seed in 0..64 { let mut rng = StdRng::seed_from_u64(seed); - let tip = get_startup_tip_with_rng( - Some(&preset), + let tip = get_startup_tips_with_rng( + std::slice::from_ref(&preset), &BTreeMap::new(), Some(PlanType::Plus), false, Some("announcement".to_string()), &mut rng, ) + .selected_tip .expect("tip"); match tip { @@ -593,8 +698,8 @@ content = "This is a test announcement" StartupTip::Generic(_) => { saw_random_tip = true; } - StartupTip::ModelNew { .. } => { - saw_model_tip = true; + StartupTip::AvailabilityNux { .. } => { + saw_availability_nux_tip = true; } } } @@ -602,6 +707,6 @@ content = "This is a test announcement" assert!(saw_announcement); assert!(saw_plan_tip); assert!(saw_random_tip); - assert!(saw_model_tip); + assert!(saw_availability_nux_tip); } }