mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
Refresh the TUI model picker from the app server (#41467)
## Why The model picker can open from a cached startup catalog that no longer reflects the models available to the current account. ## What changed - Fetch the current model list asynchronously whenever the picker opens, while showing cached choices immediately. - Refresh an open model picker in place, preserving its highlighted model, dismissal behavior, and reasoning submenu. - Apply accepted catalog updates to model-dependent UI, service-tier controls, and new-thread defaults. Ignore stale, failed, empty, unchanged, or account-invalidated responses. ## Testing - Added coverage for catalog refreshes, stale and unusable responses, popup state preservation, empty choices, service tiers, and new-thread defaults. GitOrigin-RevId: 45abe1d309ff7f90701fffe29c6544ca4157f836
This commit is contained in:
committed by
copyberry
parent
0b45b171ca
commit
5fc7840cf6
@@ -1518,6 +1518,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::FetchModels { request_id } => {
|
||||
if self.chat_widget.model_popup_request_is_current(request_id) {
|
||||
app_server.fetch_models(request_id, self.app_event_tx.clone());
|
||||
}
|
||||
}
|
||||
AppEvent::ModelsLoaded { request_id, result } => {
|
||||
if self.chat_widget.on_models_loaded(request_id, result) {
|
||||
self.model_catalog = self.chat_widget.model_catalog();
|
||||
app_server.set_available_models(self.model_catalog.try_list_models()?);
|
||||
self.sync_active_thread_service_tier_to_cached_session().await;
|
||||
}
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
self.chat_widget.open_reasoning_popup(model);
|
||||
}
|
||||
@@ -1571,8 +1583,8 @@ impl App {
|
||||
self.chat_widget
|
||||
.open_plan_reasoning_scope_prompt(model, effort);
|
||||
}
|
||||
AppEvent::OpenAllModelsPopup { models } => {
|
||||
self.chat_widget.open_all_models_popup(models);
|
||||
AppEvent::OpenAllModelsPopup => {
|
||||
self.chat_widget.open_all_models_popup();
|
||||
}
|
||||
AppEvent::OpenFullAccessConfirmation {
|
||||
preset,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_config::types::ModelAvailabilityNuxConfig;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::openai_models::ModelAvailabilityNux;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -9,6 +10,116 @@ fn all_model_presets() -> Vec<ModelPreset> {
|
||||
crate::test_support::TEST_MODEL_PRESETS.clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refresh_updates_app_catalog_from_app_server() -> Result<()> {
|
||||
let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let mut app_server = start_config_write_test_app_server(&app).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let fast = Some(ServiceTier::Fast.request_value());
|
||||
let thread_id = ThreadId::new();
|
||||
let mut session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
|
||||
session.model = "gpt-5.4".to_string();
|
||||
app.primary_thread_id = Some(thread_id);
|
||||
app.active_thread_id = Some(thread_id);
|
||||
app.primary_session_configured = Some(session.clone());
|
||||
app.thread_event_channels.insert(
|
||||
thread_id,
|
||||
ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()),
|
||||
);
|
||||
app.chat_widget.handle_thread_session(session.clone());
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::FastMode, /*enabled*/ 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("initial model request");
|
||||
let mut hidden = all_model_presets();
|
||||
for preset in &mut hidden {
|
||||
preset.show_in_picker = false;
|
||||
}
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::ModelsLoaded {
|
||||
request_id,
|
||||
result: Ok(hidden),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
app.chat_widget.open_model_popup();
|
||||
assert!(app.chat_widget.no_modal_or_popup_active());
|
||||
let request = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.find(|event| matches!(event, AppEvent::FetchModels { .. }))
|
||||
.expect("catalog request");
|
||||
|
||||
Box::pin(app.handle_event(&mut tui, &mut app_server, request)).await?;
|
||||
let mut loaded = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async {
|
||||
loop {
|
||||
if let Some(event @ AppEvent::ModelsLoaded { .. }) = rx.recv().await {
|
||||
break event;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let AppEvent::ModelsLoaded {
|
||||
result: Ok(models), ..
|
||||
} = &mut loaded
|
||||
else {
|
||||
panic!("model/list should return models");
|
||||
};
|
||||
assert!(!models.is_empty());
|
||||
for model in models.iter_mut() {
|
||||
model.is_default = model.model == "gpt-5.4";
|
||||
model.default_service_tier = model
|
||||
.is_default
|
||||
.then(|| ServiceTier::Fast.request_value().to_string());
|
||||
}
|
||||
let expected = models.clone();
|
||||
Box::pin(app.handle_event(&mut tui, &mut app_server, loaded)).await?;
|
||||
assert!(app.chat_widget.no_modal_or_popup_active());
|
||||
app.chat_widget.open_model_popup();
|
||||
assert!(!app.chat_widget.no_modal_or_popup_active());
|
||||
|
||||
session.service_tier = fast.map(str::to_string);
|
||||
let channel = &app.thread_event_channels[&thread_id];
|
||||
let cached = channel.store.lock().await.session.clone();
|
||||
assert_eq!(
|
||||
(&app.primary_session_configured, &cached),
|
||||
(&Some(session.clone()), &Some(session.clone()))
|
||||
);
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
ThreadId::new(),
|
||||
test_path_buf("/tmp/other"),
|
||||
));
|
||||
app.chat_widget.handle_thread_session(cached.unwrap());
|
||||
assert_eq!(app.chat_widget.current_service_tier(), fast);
|
||||
let stale = AppEvent::ModelsLoaded {
|
||||
request_id: uuid::Uuid::new_v4(),
|
||||
result: Ok(all_model_presets()),
|
||||
};
|
||||
Box::pin(app.handle_event(&mut tui, &mut app_server, stale)).await?;
|
||||
assert_eq!(
|
||||
(
|
||||
app.model_catalog.try_list_models().unwrap(),
|
||||
app.chat_widget.model_catalog().try_list_models().unwrap(),
|
||||
),
|
||||
(expected.clone(), expected)
|
||||
);
|
||||
let mut config = app.config.clone();
|
||||
config.service_tier = None;
|
||||
config.features.enable(Feature::FastMode)?;
|
||||
for (model, expected_tier) in [(None, fast), (Some("gpt-5.2"), Some("default"))] {
|
||||
config.model = model.map(str::to_string);
|
||||
let started = app_server.start_thread(&config).await?;
|
||||
assert_eq!(started.session.service_tier.as_deref(), expected_tier);
|
||||
}
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn model_presets_with_test_upgrades() -> Vec<ModelPreset> {
|
||||
let mut presets = all_model_presets();
|
||||
for model in ["gpt-5.2", "gpt-5.4"] {
|
||||
|
||||
@@ -982,6 +982,15 @@ pub(crate) enum AppEvent {
|
||||
service_tier: Option<String>,
|
||||
},
|
||||
|
||||
/// Fetch the current catalog even when cached models produce no picker.
|
||||
FetchModels {
|
||||
request_id: uuid::Uuid,
|
||||
},
|
||||
ModelsLoaded {
|
||||
request_id: uuid::Uuid,
|
||||
result: Result<Vec<ModelPreset>, String>,
|
||||
},
|
||||
|
||||
/// Open the reasoning selection popup after picking a model.
|
||||
OpenReasoningPopup {
|
||||
model: ModelPreset,
|
||||
@@ -1005,9 +1014,7 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
/// Open the full model picker (non-auto models).
|
||||
OpenAllModelsPopup {
|
||||
models: Vec<ModelPreset>,
|
||||
},
|
||||
OpenAllModelsPopup,
|
||||
|
||||
/// Open the confirmation prompt before enabling full access mode.
|
||||
OpenFullAccessConfirmation {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod fs;
|
||||
mod history;
|
||||
mod models;
|
||||
mod rollout_history;
|
||||
|
||||
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Fetch picker models without blocking the event loop and keep new-thread defaults in sync.
|
||||
|
||||
use super::AppServerSession;
|
||||
use super::model_preset_from_api_model;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl AppServerSession {
|
||||
pub(crate) fn set_available_models(&mut self, models: Vec<ModelPreset>) {
|
||||
if let Some(default) = models
|
||||
.iter()
|
||||
.find(|model| model.is_default)
|
||||
.or(models.first())
|
||||
{
|
||||
self.default_model = Some(default.model.clone());
|
||||
}
|
||||
self.available_models = models;
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_models(&self, request_id: Uuid, app_event_tx: AppEventSender) {
|
||||
let request_handle = self.request_handle();
|
||||
tokio::spawn(async move {
|
||||
let result = request_handle
|
||||
.request_typed::<ModelListResponse>(ClientRequest::ModelList {
|
||||
request_id: RequestId::String(format!("model-list-{request_id}")),
|
||||
params: ModelListParams {
|
||||
cursor: None,
|
||||
limit: None,
|
||||
include_hidden: Some(true),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map(|response| {
|
||||
response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(model_preset_from_api_model)
|
||||
.collect()
|
||||
})
|
||||
.map_err(|err| err.to_string());
|
||||
app_event_tx.send(AppEvent::ModelsLoaded { request_id, result });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,7 @@ pub(crate) struct ListSelectionView {
|
||||
active_tab_idx: Option<usize>,
|
||||
state: ScrollState,
|
||||
completion: Option<ViewCompletion>,
|
||||
dismiss_after_child_accept: bool,
|
||||
pub(super) dismiss_after_child_accept: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
is_searchable: bool,
|
||||
search_query: String,
|
||||
|
||||
@@ -1198,6 +1198,7 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Replace the newest matching selection view without disturbing views stacked above it.
|
||||
/// Preserve pending parent cleanup when an already-open child is accepted.
|
||||
pub(crate) fn replace_selection_view_if_present(
|
||||
&mut self,
|
||||
view_id: &'static str,
|
||||
@@ -1213,11 +1214,13 @@ impl BottomPane {
|
||||
|
||||
let replaces_active_view = index + 1 == self.view_stack.len();
|
||||
self.apply_standard_popup_hint(&mut params);
|
||||
self.view_stack[index] = Box::new(list_selection_view::ListSelectionView::new(
|
||||
let mut view = list_selection_view::ListSelectionView::new(
|
||||
params,
|
||||
self.app_event_tx.clone(),
|
||||
self.keymap.list.clone(),
|
||||
));
|
||||
);
|
||||
view.dismiss_after_child_accept = self.view_stack[index].dismiss_after_child_accept();
|
||||
self.view_stack[index] = Box::new(view);
|
||||
if replaces_active_view {
|
||||
self.schedule_active_view_frame();
|
||||
}
|
||||
|
||||
@@ -384,6 +384,7 @@ use self::plugins::PluginListFetchState;
|
||||
use self::plugins::PluginsCacheState;
|
||||
mod plan_implementation;
|
||||
use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
|
||||
mod model_popup_state;
|
||||
mod model_popups;
|
||||
mod notifications;
|
||||
use self::notifications::Notification;
|
||||
@@ -565,6 +566,8 @@ pub(crate) struct ChatWidget {
|
||||
has_chatgpt_account: bool,
|
||||
has_codex_backend_auth: bool,
|
||||
model_catalog: Arc<ModelCatalog>,
|
||||
model_popup_request_id: Option<uuid::Uuid>,
|
||||
model_popup_model_ids: Vec<String>,
|
||||
session_telemetry: SessionTelemetry,
|
||||
session_header: SessionHeader,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
|
||||
@@ -119,6 +119,8 @@ impl ChatWidget {
|
||||
has_chatgpt_account,
|
||||
has_codex_backend_auth,
|
||||
model_catalog,
|
||||
model_popup_request_id: None,
|
||||
model_popup_model_ids: Vec::new(),
|
||||
session_telemetry,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Accept current account-scoped model replies and refresh present pickers without reopening them.
|
||||
|
||||
use super::model_popups::ALL_MODELS_SELECTION_VIEW_ID;
|
||||
use super::model_popups::MODEL_SELECTION_VIEW_ID;
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
fn model_popup_view_id(&self) -> Option<&'static str> {
|
||||
[MODEL_SELECTION_VIEW_ID, ALL_MODELS_SELECTION_VIEW_ID]
|
||||
.into_iter()
|
||||
.find(|view_id| {
|
||||
self.bottom_pane
|
||||
.selected_index_for_present_view(view_id)
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn model_popup_request_is_current(&self, request_id: uuid::Uuid) -> bool {
|
||||
self.model_popup_request_id == Some(request_id)
|
||||
}
|
||||
|
||||
pub(crate) fn on_models_loaded(
|
||||
&mut self,
|
||||
request_id: uuid::Uuid,
|
||||
result: Result<Vec<ModelPreset>, String>,
|
||||
) -> bool {
|
||||
if !self.model_popup_request_is_current(request_id) {
|
||||
return false;
|
||||
}
|
||||
self.model_popup_request_id = None;
|
||||
let Ok(presets) = result else {
|
||||
return false;
|
||||
};
|
||||
if presets.is_empty()
|
||||
|| self.model_catalog.try_list_models().ok().as_ref() == Some(&presets)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.model_catalog = Arc::new(ModelCatalog::new(presets.clone()));
|
||||
self.refresh_effective_service_tier();
|
||||
self.refresh_model_dependent_surfaces();
|
||||
// Refresh an existing parent without reopening it or interrupting its reasoning child.
|
||||
match self.model_popup_view_id() {
|
||||
Some(MODEL_SELECTION_VIEW_ID) => self.open_model_popup_with_presets(presets),
|
||||
Some(ALL_MODELS_SELECTION_VIEW_ID) => self.open_all_models_popup(),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn show_model_selection_view(&mut self, mut params: SelectionViewParams) {
|
||||
let selected_index = params
|
||||
.view_id
|
||||
.and_then(|view_id| self.bottom_pane.selected_index_for_present_view(view_id));
|
||||
let selected_model = selected_index.and_then(|index| self.model_popup_model_ids.get(index));
|
||||
params.initial_selected_idx = params
|
||||
.items
|
||||
.iter()
|
||||
.position(|item| Some(&item.name) == selected_model);
|
||||
self.model_popup_model_ids = params.items.iter().map(|item| item.name.clone()).collect();
|
||||
if let Some(view_id) = params.view_id.filter(|_| selected_index.is_some()) {
|
||||
self.bottom_pane
|
||||
.replace_selection_view_if_present(view_id, params);
|
||||
} else {
|
||||
self.bottom_pane.show_selection_view(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
use super::*;
|
||||
|
||||
const ULTRA_REASONING_CONCURRENCY_WARNING_THRESHOLD: usize = 8;
|
||||
pub(super) const MODEL_SELECTION_VIEW_ID: &str = "model-selection";
|
||||
pub(super) const ALL_MODELS_SELECTION_VIEW_ID: &str = "all-models-selection";
|
||||
|
||||
impl ChatWidget {
|
||||
/// Open a popup to choose a quick auto model. Selecting "All models"
|
||||
@@ -29,7 +31,11 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let request_id = uuid::Uuid::new_v4();
|
||||
self.model_popup_request_id = Some(request_id);
|
||||
self.open_model_popup_with_presets(presets);
|
||||
// Show cached choices immediately and update any still-present picker when the reply arrives.
|
||||
self.app_event_tx.send(AppEvent::FetchModels { request_id });
|
||||
}
|
||||
|
||||
fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
|
||||
@@ -89,7 +95,7 @@ impl ChatWidget {
|
||||
.partition(|preset| Self::is_auto_model(&preset.model));
|
||||
|
||||
if auto_presets.is_empty() {
|
||||
self.open_all_models_popup(other_presets);
|
||||
self.open_all_models_popup_with_view_id(other_presets, MODEL_SELECTION_VIEW_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,11 +145,8 @@ impl ChatWidget {
|
||||
.collect();
|
||||
|
||||
if !other_presets.is_empty() {
|
||||
let all_models = other_presets;
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenAllModelsPopup {
|
||||
models: all_models.clone(),
|
||||
});
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenAllModelsPopup);
|
||||
})];
|
||||
|
||||
let is_current = !items.iter().any(|item| item.is_current);
|
||||
@@ -165,7 +168,8 @@ impl ChatWidget {
|
||||
"Select Model",
|
||||
"Pick a quick auto mode or browse all models.",
|
||||
);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
self.show_model_selection_view(SelectionViewParams {
|
||||
view_id: Some(MODEL_SELECTION_VIEW_ID),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
header,
|
||||
@@ -173,7 +177,7 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
fn is_auto_model(model: &str) -> bool {
|
||||
pub(super) fn is_auto_model(model: &str) -> bool {
|
||||
model.starts_with("codex-auto-")
|
||||
}
|
||||
|
||||
@@ -186,8 +190,24 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_all_models_popup(&mut self, presets: Vec<ModelPreset>) {
|
||||
pub(crate) fn open_all_models_popup(&mut self) {
|
||||
let presets = self
|
||||
.model_catalog
|
||||
.try_list_models()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|preset| preset.show_in_picker && !Self::is_auto_model(&preset.model))
|
||||
.collect();
|
||||
self.open_all_models_popup_with_view_id(presets, ALL_MODELS_SELECTION_VIEW_ID);
|
||||
}
|
||||
|
||||
fn open_all_models_popup_with_view_id(
|
||||
&mut self,
|
||||
presets: Vec<ModelPreset>,
|
||||
view_id: &'static str,
|
||||
) {
|
||||
if presets.is_empty() {
|
||||
self.bottom_pane.dismiss_view_by_id(view_id);
|
||||
self.add_info_message(
|
||||
"No additional models are available right now.".to_string(),
|
||||
/*hint*/ None,
|
||||
@@ -224,7 +244,8 @@ impl ChatWidget {
|
||||
"Select Model and Effort",
|
||||
"Access legacy models by running codex -m <model_name> or in your config.toml",
|
||||
);
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
self.show_model_selection_view(SelectionViewParams {
|
||||
view_id: Some(view_id),
|
||||
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
|
||||
items,
|
||||
header,
|
||||
|
||||
@@ -216,6 +216,7 @@ impl ChatWidget {
|
||||
) {
|
||||
// Account-update notifications are the identity boundary. The visible account fields can
|
||||
// be identical across two accounts, so always invalidate account-scoped requests and data.
|
||||
self.model_popup_request_id = None;
|
||||
self.invalidate_connector_scope();
|
||||
self.clear_pending_token_activity_refreshes();
|
||||
self.clear_pending_rate_limit_reset_requests();
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Select Model and Effort
|
||||
Access legacy models by running codex -m <model_name> or in your config.toml
|
||||
|
||||
› 1. gpt-5.5 Frontier model for complex coding, research, and real-
|
||||
world work.
|
||||
2. gpt-5.2 (current) Optimized for professional work and long-running
|
||||
agents.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Select Model
|
||||
Pick a quick auto mode or browse all models.
|
||||
|
||||
1. codex-auto-test Auto model
|
||||
› 2. All models (current) Choose a specific model and reasoning level
|
||||
(current: gpt-5.2)
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
› /fast
|
||||
|
||||
gpt-5.4 default · /tmp/project
|
||||
@@ -3236,6 +3236,286 @@ async fn model_selection_popup_snapshot() {
|
||||
assert_chatwidget_snapshot!("model_selection_popup", popup);
|
||||
}
|
||||
|
||||
fn apply_model_list_response(chat: &mut ChatWidget, presets: Vec<ModelPreset>) {
|
||||
let request_id = chat.model_popup_request_id.expect("pending model request");
|
||||
assert!(chat.on_models_loaded(request_id, Ok(presets)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refresh_rejects_obsolete_and_unusable_replies() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let initial = chat.model_catalog.try_list_models().unwrap();
|
||||
let mut refreshed = initial.clone();
|
||||
refreshed[0].description = "Updated model details".to_string();
|
||||
chat.open_model_popup();
|
||||
let old_request = chat.model_popup_request_id.unwrap();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.open_model_popup();
|
||||
let current_request = chat.model_popup_request_id.unwrap();
|
||||
assert!(!chat.on_models_loaded(old_request, Ok(refreshed.clone())));
|
||||
assert!(chat.model_popup_request_is_current(current_request));
|
||||
// Identical visible account fields still mark an account boundary.
|
||||
chat.update_account_state(
|
||||
chat.status_account_display.clone(),
|
||||
chat.plan_type,
|
||||
chat.has_chatgpt_account,
|
||||
chat.has_codex_backend_auth,
|
||||
);
|
||||
assert!(!chat.on_models_loaded(current_request, Ok(refreshed.clone())));
|
||||
assert_eq!(chat.model_catalog.try_list_models().unwrap(), initial);
|
||||
|
||||
for result in [
|
||||
Err("unavailable".to_string()),
|
||||
Ok(Vec::new()),
|
||||
Ok(initial.clone()),
|
||||
] {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.open_model_popup();
|
||||
let request_id = chat.model_popup_request_id.unwrap();
|
||||
let before = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(!chat.on_models_loaded(request_id, result));
|
||||
assert!(!chat.model_popup_request_is_current(request_id));
|
||||
assert_eq!(chat.model_catalog.try_list_models().unwrap(), initial);
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before);
|
||||
}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.open_model_popup();
|
||||
apply_model_list_response(&mut chat, refreshed.clone());
|
||||
assert_eq!(chat.model_catalog.try_list_models().unwrap(), refreshed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refreshes_startup_catalog() {
|
||||
for (explicit_all_models, hidden_startup) in [(false, false), (true, false), (false, true)] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let mut startup = vec![get_available_model(&chat, "gpt-5.2")];
|
||||
let mut refreshed = chat.model_catalog.try_list_models().unwrap();
|
||||
let mut auto = startup[0].clone();
|
||||
auto.model = "codex-auto-test".to_string();
|
||||
auto.id = auto.model.clone();
|
||||
auto.description = "Auto model".to_string();
|
||||
refreshed.push(auto);
|
||||
startup[0].show_in_picker = !hidden_startup;
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(startup.clone()));
|
||||
chat.open_model_popup();
|
||||
assert!(
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::FetchModels { .. }))
|
||||
);
|
||||
if explicit_all_models {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.open_all_models_popup();
|
||||
}
|
||||
|
||||
apply_model_list_response(&mut chat, refreshed.clone());
|
||||
if hidden_startup {
|
||||
assert!(chat.no_modal_or_popup_active());
|
||||
chat.open_model_popup();
|
||||
}
|
||||
|
||||
assert_eq!(chat.model_catalog.try_list_models().unwrap(), refreshed);
|
||||
insta::allow_duplicates! {
|
||||
assert_chatwidget_snapshot!(
|
||||
if explicit_all_models {
|
||||
"model_selection_popup"
|
||||
} else {
|
||||
"model_picker_refreshes_auto_models"
|
||||
},
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_queued_all_models_uses_refreshed_catalog() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let refreshed = chat.model_catalog.try_list_models().unwrap();
|
||||
let preset = get_available_model(&chat, "gpt-5.2");
|
||||
let mut auto = preset.clone();
|
||||
auto.model = "codex-auto-test".to_string();
|
||||
auto.id = auto.model.clone();
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(vec![auto, preset]));
|
||||
chat.open_model_popup();
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::FetchModels { .. }));
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_eq!(chat.bottom_pane.active_view_id(), None);
|
||||
// Apply the model/list reply before the queued All models intent is handled.
|
||||
apply_model_list_response(&mut chat, refreshed);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenAllModelsPopup));
|
||||
chat.open_all_models_popup();
|
||||
assert_chatwidget_snapshot!(
|
||||
"model_selection_popup",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refresh_preserves_highlight() {
|
||||
for (remove_selected, reasoning_submenu, expected) in [
|
||||
(false, false, "gpt-5.5"),
|
||||
(true, false, "gpt-5.2"),
|
||||
(false, true, "gpt-5.5"),
|
||||
(true, true, "gpt-5.2"),
|
||||
] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let mut presets = vec![
|
||||
get_available_model(&chat, "gpt-5.2"),
|
||||
get_available_model(&chat, "gpt-5.5"),
|
||||
];
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(presets.clone()));
|
||||
chat.open_model_popup();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
if reasoning_submenu {
|
||||
chat.open_reasoning_popup(presets[1].clone());
|
||||
}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 80);
|
||||
presets.reverse();
|
||||
if remove_selected {
|
||||
presets.remove(/*index*/ 0);
|
||||
}
|
||||
apply_model_list_response(&mut chat, presets);
|
||||
if reasoning_submenu {
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
}
|
||||
if !remove_selected {
|
||||
insta::allow_duplicates! {
|
||||
assert_chatwidget_snapshot!(
|
||||
"model_picker_refresh_preserves_highlight",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
}
|
||||
}
|
||||
while rx.try_recv().is_ok() {}
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let selected = assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReasoningPopup { model }) => model.model);
|
||||
assert_eq!(selected, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refreshes_service_tier_controls() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
set_fast_mode_test_catalog(&mut chat);
|
||||
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
|
||||
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
|
||||
let mut refreshed = chat.model_catalog.try_list_models().unwrap();
|
||||
refreshed
|
||||
.iter_mut()
|
||||
.for_each(|model| model.service_tiers.clear());
|
||||
chat.open_model_popup();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
|
||||
apply_model_list_response(&mut chat, refreshed);
|
||||
|
||||
assert_eq!(chat.current_service_tier(), None);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/fast".to_string(), Vec::new(), Vec::new());
|
||||
assert_chatwidget_snapshot!(
|
||||
"model_picker_refreshes_service_tier_controls",
|
||||
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refresh_preserves_dismissal_and_reasoning_submenu() {
|
||||
for (dismiss, explicit_all_models, accept_reasoning) in [
|
||||
(true, false, false),
|
||||
(false, false, false),
|
||||
(false, true, false),
|
||||
(false, false, true),
|
||||
(false, true, true),
|
||||
] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let preset = get_available_model(&chat, "gpt-5.2");
|
||||
let refreshed = chat.model_catalog.try_list_models().unwrap();
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(vec![preset.clone()]));
|
||||
chat.open_model_popup();
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::FetchModels { .. }));
|
||||
if explicit_all_models {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.open_all_models_popup();
|
||||
}
|
||||
if dismiss {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
} else {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let model =
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenReasoningPopup { model }) => model);
|
||||
chat.open_reasoning_popup(model);
|
||||
}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 80);
|
||||
|
||||
apply_model_list_response(&mut chat, refreshed.clone());
|
||||
|
||||
assert_eq!(chat.model_catalog.try_list_models().unwrap(), refreshed);
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before);
|
||||
if !dismiss {
|
||||
chat.handle_key_event(KeyEvent::from(if accept_reasoning {
|
||||
KeyCode::Enter
|
||||
} else {
|
||||
KeyCode::Esc
|
||||
}));
|
||||
}
|
||||
if dismiss || accept_reasoning {
|
||||
assert!(chat.no_modal_or_popup_active());
|
||||
} else {
|
||||
insta::allow_duplicates! {
|
||||
assert_chatwidget_snapshot!(
|
||||
"model_selection_popup",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_refresh_dismisses_empty_choices() {
|
||||
for (explicit_all_models, reasoning_submenu) in
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
{
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
let mut preset = get_available_model(&chat, "gpt-5.2");
|
||||
chat.open_model_popup();
|
||||
if explicit_all_models {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
chat.open_all_models_popup();
|
||||
}
|
||||
if reasoning_submenu {
|
||||
chat.open_reasoning_popup(preset.clone());
|
||||
}
|
||||
let before = render_bottom_popup(&chat, /*width*/ 80);
|
||||
if explicit_all_models {
|
||||
preset.model = "codex-auto-test".to_string();
|
||||
} else {
|
||||
preset.show_in_picker = false;
|
||||
}
|
||||
while rx.try_recv().is_ok() {}
|
||||
apply_model_list_response(&mut chat, vec![preset]);
|
||||
if reasoning_submenu {
|
||||
assert_eq!(render_bottom_popup(&chat, /*width*/ 80), before);
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||||
}
|
||||
assert_eq!(chat.bottom_pane.active_view_id(), None);
|
||||
let cell = assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(cell)) => cell);
|
||||
insta::allow_duplicates! {
|
||||
insta::assert_snapshot!(
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 80)),
|
||||
@"• No additional models are available right now."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personality_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
|
||||
Reference in New Issue
Block a user