From cc55cbb357df4afece4a6110a413f670e9bad2dd Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 14:09:20 -0800 Subject: [PATCH 1/8] async --- codex-rs/core/src/config/edit.rs | 30 +++++ codex-rs/core/src/config/types.rs | 8 ++ codex-rs/core/src/models_manager/manager.rs | 31 ++++- .../core/src/models_manager/model_family.rs | 27 ++++ codex-rs/tui/src/app.rs | 123 ++++++++++++++---- codex-rs/tui/src/chatwidget.rs | 30 +++-- codex-rs/tui/src/chatwidget/tests.rs | 21 +++ codex-rs/tui2/src/app.rs | 123 ++++++++++++++---- codex-rs/tui2/src/chatwidget.rs | 30 +++-- codex-rs/tui2/src/chatwidget/tests.rs | 21 +++ codex-rs/tui2/src/test_backend.rs | 3 +- 11 files changed, 380 insertions(+), 67 deletions(-) diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index a24c09e36b..3940adf40b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -631,6 +631,36 @@ impl ConfigEditsBuilder { self } + pub fn set_pending_model_migration_notice(mut self, from_model: &str, to_model: &str) -> Self { + self.edits.push(ConfigEdit::SetPath { + segments: vec![ + Notice::TABLE_KEY.to_string(), + "pending_model_migration".to_string(), + "from_model".to_string(), + ], + value: value(from_model.to_string()), + }); + self.edits.push(ConfigEdit::SetPath { + segments: vec![ + Notice::TABLE_KEY.to_string(), + "pending_model_migration".to_string(), + "to_model".to_string(), + ], + value: value(to_model.to_string()), + }); + self + } + + pub fn clear_pending_model_migration_notice(mut self) -> Self { + self.edits.push(ConfigEdit::ClearPath { + segments: vec![ + Notice::TABLE_KEY.to_string(), + "pending_model_migration".to_string(), + ], + }); + self + } + pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 3aa72d5ce5..4dcf96c7a2 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -514,6 +514,12 @@ const fn default_true() -> bool { /// 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. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct PendingModelMigrationNotice { + pub from_model: String, + pub to_model: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. @@ -530,6 +536,8 @@ pub struct Notice { /// Tracks acknowledged model migrations as old->new model slug mappings. #[serde(default)] pub model_migrations: BTreeMap, + /// Persisted "show on next run" model migration notice. + pub pending_model_migration: Option, } impl Notice { diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 315380ade1..8ed41a6072 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -10,6 +10,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use tokio::sync::Mutex; use tokio::sync::RwLock; use tokio::sync::TryLockError; use tracing::error; @@ -29,6 +30,7 @@ use crate::models_manager::model_presets::builtin_model_presets; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); +const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.1-codex-max"; const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "gpt-5.2-codex"; const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; @@ -39,6 +41,7 @@ pub struct ModelsManager { // todo(aibrahim) merge available_models and model family creation into one struct local_models: Vec, remote_models: RwLock>, + refresh_lock: Mutex<()>, auth_manager: Arc, etag: RwLock>, codex_home: PathBuf, @@ -53,6 +56,7 @@ impl ModelsManager { Self { local_models: builtin_model_presets(auth_manager.get_auth_mode()), remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), + refresh_lock: Mutex::new(()), auth_manager, etag: RwLock::new(None), codex_home, @@ -68,6 +72,7 @@ impl ModelsManager { Self { local_models: builtin_model_presets(auth_manager.get_auth_mode()), remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()), + refresh_lock: Mutex::new(()), auth_manager, etag: RwLock::new(None), codex_home, @@ -83,6 +88,11 @@ impl ModelsManager { { return Ok(()); } + + // Prevent duplicate `/models` refreshes when multiple callers try to refresh + // concurrently during startup (or when multiple features request models). + let _guard = self.refresh_lock.lock().await; + if self.try_load_cache().await { return Ok(()); } @@ -94,10 +104,23 @@ impl ModelsManager { let client = ModelsClient::new(transport, api_provider, api_auth); let client_version = format_client_version_to_whole(); - let ModelsResponse { models, etag } = client - .list_models(&client_version, HeaderMap::new()) - .await - .map_err(map_api_error)?; + let response = tokio::time::timeout( + MODELS_REFRESH_TIMEOUT, + client.list_models(&client_version, HeaderMap::new()), + ) + .await; + + let ModelsResponse { models, etag } = match response { + Ok(response) => response.map_err(map_api_error)?, + Err(_) => { + error!( + "timed out refreshing /models after {}s", + MODELS_REFRESH_TIMEOUT.as_secs() + ); + // Leave `remote_models` unchanged so the preloaded fallback remains available. + return Ok(()); + } + }; let etag = (!etag.is_empty()).then_some(etag); diff --git a/codex-rs/core/src/models_manager/model_family.rs b/codex-rs/core/src/models_manager/model_family.rs index 9e2fb92468..453286d98f 100644 --- a/codex-rs/core/src/models_manager/model_family.rs +++ b/codex-rs/core/src/models_manager/model_family.rs @@ -84,6 +84,33 @@ pub struct ModelFamily { } impl ModelFamily { + /// Placeholder model family for UI startup before Codex has selected the real model. + /// + /// This must not be treated as an actual model slug; it only exists to allow + /// consumers (e.g. the TUI) to render while the session is still starting. + pub fn placeholder(config: &Config) -> Self { + let mf = Self { + slug: String::new(), + family: "unknown".to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: None, + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: BASE_INSTRUCTIONS.to_string(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + }; + mf.with_config_overrides(config) + } + pub(super) fn with_config_overrides(mut self, config: &Config) -> Self { if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { self.supports_reasoning_summaries = supports_reasoning_summaries; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 14b785ec95..e1804b769e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -327,6 +327,94 @@ impl App { } } + async fn on_session_configured_model(&mut self, model: String) { + self.maybe_emit_pending_model_migration_notice(model.as_str()) + .await; + self.spawn_schedule_model_migration_notice(model); + } + + async fn maybe_emit_pending_model_migration_notice(&mut self, used_model: &str) { + let Some(pending) = self.config.notices.pending_model_migration.take() else { + return; + }; + + let should_show = pending.from_model == used_model; + if should_show { + let message = format!( + "Recommended model upgrade: switch from {} to {}. Run /model to change.", + pending.from_model, pending.to_model + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_warning_event(message), + ))); + + self.config + .notices + .model_migrations + .insert(pending.from_model.clone(), pending.to_model.clone()); + } + + let mut edits = ConfigEditsBuilder::new(&self.config.codex_home); + if should_show { + edits = edits.record_model_migration_seen( + pending.from_model.as_str(), + pending.to_model.as_str(), + ); + } + if let Err(err) = edits.clear_pending_model_migration_notice().apply().await { + tracing::error!( + error = %err, + "failed to clear pending model migration notice" + ); + self.chat_widget.add_error_message(format!( + "Failed to persist model migration notice state: {err}" + )); + } + } + + fn spawn_schedule_model_migration_notice(&self, used_model: String) { + let config = self.config.clone(); + let models_manager = self.server.get_models_manager(); + + tokio::spawn(async move { + // Build the candidate migration notice using the current models list (remote if + // refreshed, otherwise the startup fallback seeded in `ModelsManager`). + let available_models = models_manager.list_models(&config).await; + let upgrade = available_models + .iter() + .find(|preset| preset.model == used_model) + .and_then(|preset| preset.upgrade.as_ref()); + let Some(upgrade) = upgrade else { + return; + }; + + if migration_prompt_hidden(&config, upgrade.migration_config_key.as_str()) { + return; + } + + let target_model = upgrade.id.clone(); + if !should_show_model_migration_prompt( + used_model.as_str(), + target_model.as_str(), + &config.notices.model_migrations, + &available_models, + ) { + return; + } + + if let Err(err) = ConfigEditsBuilder::new(&config.codex_home) + .set_pending_model_migration_notice(used_model.as_str(), target_model.as_str()) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist pending model migration notice" + ); + } + }); + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -347,30 +435,10 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); - let mut model = conversation_manager - .get_models_manager() - .get_model(&config.model, &config) - .await; - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - conversation_manager.get_models_manager(), - ) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } let enhanced_keys_supported = tui.enhanced_keys_supported(); - let model_family = conversation_manager - .get_models_manager() - .construct_model_family(model.as_str(), &config) - .await; + let model_family = + codex_core::models_manager::model_family::ModelFamily::placeholder(&config); let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -432,7 +500,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), + current_model: String::new(), active_profile, file_search, enhanced_keys_supported, @@ -712,12 +780,21 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } + + let configured_model = match &event.msg { + EventMsg::SessionConfigured(ev) => Some(ev.model.clone()), + _ => None, + }; if let EventMsg::ListSkillsResponse(response) = &event.msg { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, response); emit_skill_load_warnings(&self.app_event_tx, &errors); } self.chat_widget.handle_codex_event(event); + + if let Some(model) = configured_model { + self.on_session_configured_model(model).await; + } } AppEvent::ConversationHistory(ev) => { self.on_conversation_history_for_backtrack(tui, ev).await?; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f0afed3bf2..70820f3b89 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -425,6 +425,9 @@ impl ChatWidget { let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); + // Now that Codex has selected the actual model, update the model family used for UI. + self.app_event_tx + .send(AppEvent::UpdateModel(model_for_header.clone())); self.add_to_history(history_cell::new_session_info( &self.config, &model_for_header, @@ -440,9 +443,15 @@ impl ChatWidget { cwds: Vec::new(), force_reload: false, }); + let had_initial_message = self.initial_user_message.is_some(); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } + if !had_initial_message { + // If there are queued inputs from startup, begin the first turn now. + // Subsequent queued inputs are sent turn-by-turn via `maybe_send_next_queued_input`. + self.maybe_send_next_queued_input(); + } if !self.suppress_session_configured_redraw { self.request_redraw(); } @@ -1409,9 +1418,7 @@ impl ChatWidget { is_first_run, model_family, } = common; - let model_slug = model_family.get_model_slug().to_string(); - let mut config = config; - config.model = Some(model_slug.clone()); + let config = config; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -1435,7 +1442,7 @@ impl ChatWidget { model_family, auth_manager, models_manager, - session_header: SessionHeader::new(model_slug), + session_header: SessionHeader::new("Starting...".to_string()), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1687,7 +1694,7 @@ impl ChatWidget { return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); + self.queue_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); @@ -1697,7 +1704,14 @@ impl ChatWidget { self.open_review_popup(); } SlashCommand::Model => { - self.open_model_popup(); + if self.conversation_id.is_none() { + self.add_info_message( + "`/model` is unavailable until startup finishes.".to_string(), + None, + ); + } else { + self.open_model_popup(); + } } SlashCommand::Approvals => { self.open_approvals_popup(); @@ -1868,7 +1882,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if self.bottom_pane.is_task_running() { + if self.conversation_id.is_none() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { @@ -2177,7 +2191,7 @@ impl ChatWidget { // If idle and there are queued inputs, submit exactly one to start the next turn. fn maybe_send_next_queued_input(&mut self) { - if self.bottom_pane.is_task_running() { + if self.conversation_id.is_none() || self.bottom_pane.is_task_running() { return; } if let Some(user_message) = self.queued_user_messages.pop_front() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5e595d330f..313a5252be 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1022,6 +1022,27 @@ async fn alt_up_edits_most_recent_queued_message() { async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + // Ensure the session is configured so the first submission is sent (not queued), + // which seeds the prompt history for the subsequent Up-arrow recalls. + let conversation_id = ConversationId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }), + }); + // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 9175b7d24a..742ba8f491 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -390,6 +390,94 @@ impl App { } } + async fn on_session_configured_model(&mut self, model: String) { + self.maybe_emit_pending_model_migration_notice(model.as_str()) + .await; + self.spawn_schedule_model_migration_notice(model); + } + + async fn maybe_emit_pending_model_migration_notice(&mut self, used_model: &str) { + let Some(pending) = self.config.notices.pending_model_migration.take() else { + return; + }; + + let should_show = pending.from_model == used_model; + if should_show { + let message = format!( + "Recommended model upgrade: switch from {} to {}. Run /model to change.", + pending.from_model, pending.to_model + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + crate::history_cell::new_warning_event(message), + ))); + + self.config + .notices + .model_migrations + .insert(pending.from_model.clone(), pending.to_model.clone()); + } + + let mut edits = ConfigEditsBuilder::new(&self.config.codex_home); + if should_show { + edits = edits.record_model_migration_seen( + pending.from_model.as_str(), + pending.to_model.as_str(), + ); + } + if let Err(err) = edits.clear_pending_model_migration_notice().apply().await { + tracing::error!( + error = %err, + "failed to clear pending model migration notice" + ); + self.chat_widget.add_error_message(format!( + "Failed to persist model migration notice state: {err}" + )); + } + } + + fn spawn_schedule_model_migration_notice(&self, used_model: String) { + let config = self.config.clone(); + let models_manager = self.server.get_models_manager(); + + tokio::spawn(async move { + // Build the candidate migration notice using the current models list (remote if + // refreshed, otherwise the startup fallback seeded in `ModelsManager`). + let available_models = models_manager.list_models(&config).await; + let upgrade = available_models + .iter() + .find(|preset| preset.model == used_model) + .and_then(|preset| preset.upgrade.as_ref()); + let Some(upgrade) = upgrade else { + return; + }; + + if migration_prompt_hidden(&config, upgrade.migration_config_key.as_str()) { + return; + } + + let target_model = upgrade.id.clone(); + if !should_show_model_migration_prompt( + used_model.as_str(), + target_model.as_str(), + &config.notices.model_migrations, + &available_models, + ) { + return; + } + + if let Err(err) = ConfigEditsBuilder::new(&config.codex_home) + .set_pending_model_migration_notice(used_model.as_str(), target_model.as_str()) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist pending model migration notice" + ); + } + }); + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -410,30 +498,10 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); - let mut model = conversation_manager - .get_models_manager() - .get_model(&config.model, &config) - .await; - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - conversation_manager.get_models_manager(), - ) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } let enhanced_keys_supported = tui.enhanced_keys_supported(); - let model_family = conversation_manager - .get_models_manager() - .construct_model_family(model.as_str(), &config) - .await; + let model_family = + codex_core::models_manager::model_family::ModelFamily::placeholder(&config); let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -509,7 +577,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), + current_model: String::new(), active_profile, file_search, enhanced_keys_supported, @@ -1715,12 +1783,21 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } + + let configured_model = match &event.msg { + EventMsg::SessionConfigured(ev) => Some(ev.model.clone()), + _ => None, + }; if let EventMsg::ListSkillsResponse(response) = &event.msg { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, response); emit_skill_load_warnings(&self.app_event_tx, &errors); } self.chat_widget.handle_codex_event(event); + + if let Some(model) = configured_model { + self.on_session_configured_model(model).await; + } } AppEvent::ConversationHistory(ev) => { self.on_conversation_history_for_backtrack(tui, ev).await?; diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index b128efab69..4b96f2456d 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -400,6 +400,9 @@ impl ChatWidget { let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); + // Now that Codex has selected the actual model, update the model family used for UI. + self.app_event_tx + .send(AppEvent::UpdateModel(model_for_header.clone())); self.add_to_history(history_cell::new_session_info( &self.config, &model_for_header, @@ -415,9 +418,15 @@ impl ChatWidget { cwds: Vec::new(), force_reload: false, }); + let had_initial_message = self.initial_user_message.is_some(); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } + if !had_initial_message { + // If there are queued inputs from startup, begin the first turn now. + // Subsequent queued inputs are sent turn-by-turn via `maybe_send_next_queued_input`. + self.maybe_send_next_queued_input(); + } if !self.suppress_session_configured_redraw { self.request_redraw(); } @@ -1278,9 +1287,7 @@ impl ChatWidget { is_first_run, model_family, } = common; - let model_slug = model_family.get_model_slug().to_string(); - let mut config = config; - config.model = Some(model_slug.clone()); + let config = config; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -1304,7 +1311,7 @@ impl ChatWidget { model_family, auth_manager, models_manager, - session_header: SessionHeader::new(model_slug), + session_header: SessionHeader::new("Starting...".to_string()), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1554,7 +1561,7 @@ impl ChatWidget { return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); + self.queue_user_message(INIT_PROMPT.to_string().into()); } SlashCommand::Compact => { self.clear_token_usage(); @@ -1564,7 +1571,14 @@ impl ChatWidget { self.open_review_popup(); } SlashCommand::Model => { - self.open_model_popup(); + if self.conversation_id.is_none() { + self.add_info_message( + "`/model` is unavailable until startup finishes.".to_string(), + None, + ); + } else { + self.open_model_popup(); + } } SlashCommand::Approvals => { self.open_approvals_popup(); @@ -1707,7 +1721,7 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if self.bottom_pane.is_task_running() { + if self.conversation_id.is_none() || self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { @@ -2016,7 +2030,7 @@ impl ChatWidget { // If idle and there are queued inputs, submit exactly one to start the next turn. fn maybe_send_next_queued_input(&mut self) { - if self.bottom_pane.is_task_running() { + if self.conversation_id.is_none() || self.bottom_pane.is_task_running() { return; } if let Some(user_message) = self.queued_user_messages.pop_front() { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index ff6b79b891..7c9e916757 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -983,6 +983,27 @@ async fn alt_up_edits_most_recent_queued_message() { async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + // Ensure the session is configured so the first submission is sent (not queued), + // which seeds the prompt history for the subsequent Up-arrow recalls. + let conversation_id = ConversationId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }), + }); + // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); diff --git a/codex-rs/tui2/src/test_backend.rs b/codex-rs/tui2/src/test_backend.rs index 47f17e6b93..7fa0e3fdb7 100644 --- a/codex-rs/tui2/src/test_backend.rs +++ b/codex-rs/tui2/src/test_backend.rs @@ -25,7 +25,8 @@ pub struct VT100Backend { impl VT100Backend { /// Creates a new `TestBackend` with the specified width and height. pub fn new(width: u16, height: u16) -> Self { - crossterm::style::Colored::set_ansi_color_disabled(false); + // Force ANSI color output even when the writer isn't a real TTY (e.g., vt100::Parser in tests). + crossterm::style::force_color_output(true); Self { crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)), } From a01096500521fb2bbe8eb74d241748073576b9c1 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 14:46:29 -0800 Subject: [PATCH 2/8] progress --- codex-rs/tui/src/app.rs | 144 ++------------------------- codex-rs/tui/src/app_event.rs | 6 -- codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/model_migration.rs | 3 + codex-rs/tui2/src/app.rs | 134 +------------------------ codex-rs/tui2/src/app_event.rs | 6 -- codex-rs/tui2/src/lib.rs | 2 + codex-rs/tui2/src/model_migration.rs | 3 + 8 files changed, 18 insertions(+), 282 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e1804b769e..4270fd30e2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,9 +7,6 @@ use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; -use crate::model_migration::ModelMigrationOutcome; -use crate::model_migration::migration_copy_for_models; -use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -25,7 +22,6 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; -use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::protocol::EventMsg; @@ -37,7 +33,6 @@ use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -168,118 +163,6 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool } } -fn target_preset_for_upgrade<'a>( - available_models: &'a [ModelPreset], - target_model: &str, -) -> Option<&'a ModelPreset> { - available_models - .iter() - .find(|preset| preset.model == target_model) -} - -async fn handle_model_migration_prompt_if_needed( - tui: &mut tui::Tui, - config: &mut Config, - model: &str, - app_event_tx: &AppEventSender, - models_manager: Arc, -) -> Option { - let available_models = models_manager.list_models(config).await; - let upgrade = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()); - - if let Some(ModelUpgrade { - id: target_model, - reasoning_effort_mapping, - migration_config_key, - model_link, - upgrade_copy, - }) = upgrade - { - if migration_prompt_hidden(config, migration_config_key.as_str()) { - return None; - } - - let target_model = target_model.to_string(); - if !should_show_model_migration_prompt( - model, - &target_model, - &config.notices.model_migrations, - &available_models, - ) { - return None; - } - - let current_preset = available_models.iter().find(|preset| preset.model == model); - let target_preset = target_preset_for_upgrade(&available_models, &target_model); - let target_preset = target_preset?; - let target_display_name = target_preset.display_name.clone(); - let heading_label = if target_display_name == model { - target_model.clone() - } else { - target_display_name.clone() - }; - let target_description = - (!target_preset.description.is_empty()).then(|| target_preset.description.clone()); - let can_opt_out = current_preset.is_some(); - let prompt_copy = migration_copy_for_models( - model, - &target_model, - model_link.clone(), - upgrade_copy.clone(), - heading_label, - target_description, - can_opt_out, - ); - match run_model_migration_prompt(tui, prompt_copy).await { - ModelMigrationOutcome::Accepted => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - config.model = Some(target_model.clone()); - - let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping - && let Some(reasoning_effort) = config.model_reasoning_effort - { - reasoning_effort_mapping - .get(&reasoning_effort) - .cloned() - .or(config.model_reasoning_effort) - } else { - config.model_reasoning_effort - }; - - config.model_reasoning_effort = mapped_effort; - - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); - app_event_tx.send(AppEvent::PersistModelSelection { - model: target_model.clone(), - effort: mapped_effort, - }); - } - ModelMigrationOutcome::Rejected => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - } - ModelMigrationOutcome::Exit => { - return Some(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - }); - } - } - } - - None -} - pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, @@ -419,7 +302,7 @@ impl App { pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, - mut config: Config, + config: Config, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -1128,24 +1011,6 @@ impl App { )); } } - AppEvent::PersistModelMigrationPromptAcknowledged { - from_model, - to_model, - } => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .record_model_migration_seen(from_model.as_str(), to_model.as_str()) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist model migration prompt acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save model migration prompt preference: {err}" - )); - } - } AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } @@ -1334,6 +1199,7 @@ mod tests { use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; + use codex_protocol::openai_models::ModelUpgrade; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; @@ -1499,7 +1365,11 @@ mod tests { &available, )); - assert!(target_preset_for_upgrade(&available, "missing-target").is_none()); + assert!( + !available + .iter() + .any(|preset| preset.model == "missing-target") + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0be556de8b..9c51c04c51 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -143,12 +143,6 @@ pub(crate) enum AppEvent { /// Persist the acknowledgement flag for the rate limit switch prompt. PersistRateLimitSwitchPromptHidden, - /// Persist the acknowledgement flag for the model migration prompt. - PersistModelMigrationPromptAcknowledged { - from_model: String, - to_model: String, - }, - /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] SkipNextWorldWritableScan, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b18754cab5..ba10c147e4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -57,6 +57,8 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +// Model migration prompt UI is no longer used in production; keep it for snapshot tests. +#[cfg(test)] mod model_migration; mod notifications; pub mod onboarding; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index cf2208109a..3f46d962c7 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -1,9 +1,12 @@ +#![allow(dead_code)] + use crate::key_hint; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; use crate::selection_list::selection_option_row; + use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 742ba8f491..69aef667f1 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -10,9 +10,6 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; -use crate::model_migration::ModelMigrationOutcome; -use crate::model_migration::migration_copy_for_models; -use crate::model_migration::run_model_migration_prompt; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -37,7 +34,6 @@ use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; -use codex_core::models_manager::manager::ModelsManager; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::protocol::EventMsg; @@ -50,7 +46,6 @@ use codex_core::protocol::TokenUsage; use codex_core::terminal::terminal_info; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; @@ -203,115 +198,6 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool } } -async fn handle_model_migration_prompt_if_needed( - tui: &mut tui::Tui, - config: &mut Config, - model: &str, - app_event_tx: &AppEventSender, - models_manager: Arc, -) -> Option { - let available_models = models_manager.list_models(config).await; - let upgrade = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()); - - if let Some(ModelUpgrade { - id: target_model, - reasoning_effort_mapping, - migration_config_key, - .. - }) = upgrade - { - if migration_prompt_hidden(config, migration_config_key.as_str()) { - return None; - } - - let target_model = target_model.to_string(); - if !should_show_model_migration_prompt( - model, - &target_model, - &config.notices.model_migrations, - &available_models, - ) { - return None; - } - - let current_preset = available_models.iter().find(|preset| preset.model == model); - let target_preset = available_models - .iter() - .find(|preset| preset.model == target_model); - let target_display_name = target_preset - .map(|preset| preset.display_name.clone()) - .unwrap_or_else(|| target_model.clone()); - let heading_label = if target_display_name == model { - target_model.clone() - } else { - target_display_name.clone() - }; - let target_description = target_preset.and_then(|preset| { - if preset.description.is_empty() { - None - } else { - Some(preset.description.clone()) - } - }); - let can_opt_out = current_preset.is_some(); - let prompt_copy = migration_copy_for_models( - model, - &target_model, - heading_label, - target_description, - can_opt_out, - ); - match run_model_migration_prompt(tui, prompt_copy).await { - ModelMigrationOutcome::Accepted => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - config.model = Some(target_model.clone()); - - let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping - && let Some(reasoning_effort) = config.model_reasoning_effort - { - reasoning_effort_mapping - .get(&reasoning_effort) - .cloned() - .or(config.model_reasoning_effort) - } else { - config.model_reasoning_effort - }; - - config.model_reasoning_effort = mapped_effort; - - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); - app_event_tx.send(AppEvent::PersistModelSelection { - model: target_model.clone(), - effort: mapped_effort, - }); - } - ModelMigrationOutcome::Rejected => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - } - ModelMigrationOutcome::Exit => { - return Some(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - session_lines: Vec::new(), - }); - } - } - } - - None -} - pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, @@ -482,7 +368,7 @@ impl App { pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, - mut config: Config, + config: Config, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -2095,24 +1981,6 @@ impl App { )); } } - AppEvent::PersistModelMigrationPromptAcknowledged { - from_model, - to_model, - } => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .record_model_migration_seen(from_model.as_str(), to_model.as_str()) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist model migration prompt acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save model migration prompt preference: {err}" - )); - } - } AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index adb9c1308e..e4492f7ffc 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -137,12 +137,6 @@ pub(crate) enum AppEvent { /// Persist the acknowledgement flag for the rate limit switch prompt. PersistRateLimitSwitchPromptHidden, - /// Persist the acknowledgement flag for the model migration prompt. - PersistModelMigrationPromptAcknowledged { - from_model: String, - to_model: String, - }, - /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] SkipNextWorldWritableScan, diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index e31194b5b8..7ab9fa7751 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -58,6 +58,8 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +// Model migration prompt UI is no longer used in production; keep it for snapshot tests. +#[cfg(test)] mod model_migration; mod notifications; pub mod onboarding; diff --git a/codex-rs/tui2/src/model_migration.rs b/codex-rs/tui2/src/model_migration.rs index 0b74ad40a5..6e1b7e3c13 100644 --- a/codex-rs/tui2/src/model_migration.rs +++ b/codex-rs/tui2/src/model_migration.rs @@ -1,9 +1,12 @@ +#![allow(dead_code)] + use crate::key_hint; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; use crate::selection_list::selection_option_row; + use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; From 9cc4748524686ab19018b0d45fd1dbd413de846d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 15:25:00 -0800 Subject: [PATCH 3/8] progress --- codex-rs/core/src/config/edit.rs | 30 ----------------- codex-rs/core/src/config/types.rs | 8 ----- codex-rs/tui/src/app.rs | 54 ++++++++++++++++++++++--------- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui2/src/app.rs | 53 ++++++++++++++++++++++-------- 5 files changed, 80 insertions(+), 66 deletions(-) diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 3940adf40b..a24c09e36b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -631,36 +631,6 @@ impl ConfigEditsBuilder { self } - pub fn set_pending_model_migration_notice(mut self, from_model: &str, to_model: &str) -> Self { - self.edits.push(ConfigEdit::SetPath { - segments: vec![ - Notice::TABLE_KEY.to_string(), - "pending_model_migration".to_string(), - "from_model".to_string(), - ], - value: value(from_model.to_string()), - }); - self.edits.push(ConfigEdit::SetPath { - segments: vec![ - Notice::TABLE_KEY.to_string(), - "pending_model_migration".to_string(), - "to_model".to_string(), - ], - value: value(to_model.to_string()), - }); - self - } - - pub fn clear_pending_model_migration_notice(mut self) -> Self { - self.edits.push(ConfigEdit::ClearPath { - segments: vec![ - Notice::TABLE_KEY.to_string(), - "pending_model_migration".to_string(), - ], - }); - self - } - pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 4dcf96c7a2..3aa72d5ce5 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -514,12 +514,6 @@ const fn default_true() -> bool { /// 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. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct PendingModelMigrationNotice { - pub from_model: String, - pub to_model: String, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. @@ -536,8 +530,6 @@ pub struct Notice { /// Tracks acknowledged model migrations as old->new model slug mappings. #[serde(default)] pub model_migrations: BTreeMap, - /// Persisted "show on next run" model migration notice. - pub pending_model_migration: Option, } impl Notice { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4270fd30e2..d7be885ebd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -217,9 +217,20 @@ impl App { } async fn maybe_emit_pending_model_migration_notice(&mut self, used_model: &str) { - let Some(pending) = self.config.notices.pending_model_migration.take() else { - return; - }; + let pending = + match crate::model_migration_notice::read_pending_model_migration_notice(&self.config) + .await + { + Ok(Some(pending)) => pending, + Ok(None) => return, + Err(err) => { + tracing::error!( + error = %err, + "failed to read pending model migration notice" + ); + return; + } + }; let should_show = pending.from_model == used_model; if should_show { @@ -237,20 +248,31 @@ impl App { .insert(pending.from_model.clone(), pending.to_model.clone()); } - let mut edits = ConfigEditsBuilder::new(&self.config.codex_home); if should_show { - edits = edits.record_model_migration_seen( - pending.from_model.as_str(), - pending.to_model.as_str(), - ); + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .record_model_migration_seen(pending.from_model.as_str(), pending.to_model.as_str()) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist model migration notice acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to persist model migration notice acknowledgement: {err}" + )); + } } - if let Err(err) = edits.clear_pending_model_migration_notice().apply().await { + + if let Err(err) = + crate::model_migration_notice::clear_pending_model_migration_notice(&self.config).await + { tracing::error!( error = %err, - "failed to clear pending model migration notice" + "failed to clear pending model migration notice file" ); self.chat_widget.add_error_message(format!( - "Failed to persist model migration notice state: {err}" + "Failed to clear pending model migration notice: {err}" )); } } @@ -285,10 +307,12 @@ impl App { return; } - if let Err(err) = ConfigEditsBuilder::new(&config.codex_home) - .set_pending_model_migration_notice(used_model.as_str(), target_model.as_str()) - .apply() - .await + if let Err(err) = crate::model_migration_notice::write_pending_model_migration_notice( + &config, + used_model.as_str(), + target_model.as_str(), + ) + .await { tracing::error!( error = %err, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ba10c147e4..42f3f4a07b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -57,6 +57,7 @@ pub mod live_wrap; mod markdown; mod markdown_render; mod markdown_stream; +pub mod model_migration_notice; // Model migration prompt UI is no longer used in production; keep it for snapshot tests. #[cfg(test)] mod model_migration; diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 69aef667f1..7f4fe4ea89 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -283,8 +283,20 @@ impl App { } async fn maybe_emit_pending_model_migration_notice(&mut self, used_model: &str) { - let Some(pending) = self.config.notices.pending_model_migration.take() else { - return; + let pending = match codex_tui::model_migration_notice::read_pending_model_migration_notice( + &self.config, + ) + .await + { + Ok(Some(pending)) => pending, + Ok(None) => return, + Err(err) => { + tracing::error!( + error = %err, + "failed to read pending model migration notice" + ); + return; + } }; let should_show = pending.from_model == used_model; @@ -303,20 +315,32 @@ impl App { .insert(pending.from_model.clone(), pending.to_model.clone()); } - let mut edits = ConfigEditsBuilder::new(&self.config.codex_home); if should_show { - edits = edits.record_model_migration_seen( - pending.from_model.as_str(), - pending.to_model.as_str(), - ); + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .record_model_migration_seen(pending.from_model.as_str(), pending.to_model.as_str()) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist model migration notice acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to persist model migration notice acknowledgement: {err}" + )); + } } - if let Err(err) = edits.clear_pending_model_migration_notice().apply().await { + + if let Err(err) = + codex_tui::model_migration_notice::clear_pending_model_migration_notice(&self.config) + .await + { tracing::error!( error = %err, - "failed to clear pending model migration notice" + "failed to clear pending model migration notice file" ); self.chat_widget.add_error_message(format!( - "Failed to persist model migration notice state: {err}" + "Failed to clear pending model migration notice: {err}" )); } } @@ -351,9 +375,12 @@ impl App { return; } - if let Err(err) = ConfigEditsBuilder::new(&config.codex_home) - .set_pending_model_migration_notice(used_model.as_str(), target_model.as_str()) - .apply() + if let Err(err) = + codex_tui::model_migration_notice::write_pending_model_migration_notice( + &config, + used_model.as_str(), + target_model.as_str(), + ) .await { tracing::error!( From 3e5015f51ccea8340ebb30e40621854a85b6681b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 15:25:11 -0800 Subject: [PATCH 4/8] progress --- codex-rs/tui/src/model_migration_notice.rs | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 codex-rs/tui/src/model_migration_notice.rs diff --git a/codex-rs/tui/src/model_migration_notice.rs b/codex-rs/tui/src/model_migration_notice.rs new file mode 100644 index 0000000000..ee447434ae --- /dev/null +++ b/codex-rs/tui/src/model_migration_notice.rs @@ -0,0 +1,109 @@ +use codex_core::config::Config; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; + +/// Pending "show on next run" model migration notice. +/// +/// This is persisted outside `config.toml` to avoid growing the config file with +/// ephemeral UI state. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct PendingModelMigrationNotice { + pub from_model: String, + pub to_model: String, +} + +const MODEL_MIGRATION_NOTICE_FILENAME: &str = "model_migration_notice.json"; + +fn pending_model_migration_notice_filepath(config: &Config) -> PathBuf { + config.codex_home.join(MODEL_MIGRATION_NOTICE_FILENAME) +} + +/// Read a pending notice if present. +/// +/// Returns `Ok(None)` if no pending notice is scheduled. +pub async fn read_pending_model_migration_notice( + config: &Config, +) -> anyhow::Result> { + let path = pending_model_migration_notice_filepath(config); + let contents = match tokio::fs::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + Ok(Some(serde_json::from_str(contents.trim())?)) +} + +/// Persist a pending notice to be displayed on the next run. +pub async fn write_pending_model_migration_notice( + config: &Config, + from_model: &str, + to_model: &str, +) -> anyhow::Result<()> { + let path = pending_model_migration_notice_filepath(config); + let notice = PendingModelMigrationNotice { + from_model: from_model.to_string(), + to_model: to_model.to_string(), + }; + let json_line = format!("{}\n", serde_json::to_string(¬ice)?); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, json_line).await?; + Ok(()) +} + +/// Clear any pending notice. +pub async fn clear_pending_model_migration_notice(config: &Config) -> anyhow::Result<()> { + let path = pending_model_migration_notice_filepath(config); + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::config::ConfigBuilder; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn write_read_clear_round_trips() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("config"); + + assert_eq!( + read_pending_model_migration_notice(&config).await.unwrap(), + None + ); + + write_pending_model_migration_notice(&config, "gpt-5", "gpt-5.1") + .await + .expect("write"); + + assert_eq!( + read_pending_model_migration_notice(&config) + .await + .expect("read"), + Some(PendingModelMigrationNotice { + from_model: "gpt-5".to_string(), + to_model: "gpt-5.1".to_string(), + }) + ); + + clear_pending_model_migration_notice(&config) + .await + .expect("clear"); + + assert_eq!( + read_pending_model_migration_notice(&config).await.unwrap(), + None + ); + } +} From bed4d268c567e8c0c74e026c0c23a1fdb55b2b63 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 15:39:09 -0800 Subject: [PATCH 5/8] tui: record model migration ack and document startup plan --- codex-rs/MODELS_STARTUP_PLAN.md | 135 ++++++++++++++++++++++++++++++++ codex-rs/tui/src/app.rs | 21 +++-- codex-rs/tui2/src/app.rs | 21 +++-- 3 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 codex-rs/MODELS_STARTUP_PLAN.md diff --git a/codex-rs/MODELS_STARTUP_PLAN.md b/codex-rs/MODELS_STARTUP_PLAN.md new file mode 100644 index 0000000000..09fbf3339f --- /dev/null +++ b/codex-rs/MODELS_STARTUP_PLAN.md @@ -0,0 +1,135 @@ +# Models Startup + TUI Gating Plan + +Goal: let the TUI render immediately and accept input, while ensuring we do not +lie about the model actually used. Model selection (`/model`) and turn submission +are gated until the session is configured. If `/models` refresh fails, core +keeps using the fallback models seeded at startup (from `models.json`) via the +existing `RwLock`. + +This plan is intended to be executed top-to-bottom. Each step is checked off +when completed. + +--- + +## Step 1 - Core: Bound `/models` refresh + prevent duplicate refreshes + +- [x] Add a 5s timeout to the remote `/models` request in + `core/src/models_manager/manager.rs::refresh_available_models`. + - On timeout/error, do not overwrite `remote_models` (fallback seeded at + startup remains available automatically). + - Continue to log failures for visibility. +- [x] Add a `refresh_lock: tokio::sync::Mutex<()>` field to `ModelsManager`. + - Initialize it in `ModelsManager::new` and `ModelsManager::with_provider`. + - Acquire it around the refresh logic so concurrent calls do not issue + duplicate `/models` requests. + +Acceptance: +- `refresh_available_models` returns promptly (<= ~5s) even when `/models` hangs. +- Concurrent `list_models` / `get_model` callers do not duplicate the refresh. + +--- + +## Step 2 - Core: Provide a placeholder `ModelFamily` for UI startup + +- [x] Add a public placeholder constructor in `core/src/models_manager/model_family.rs` + (e.g. `ModelFamily::placeholder(&Config) -> ModelFamily`). + - Must not claim a real model slug; it is only used for pre-session rendering. + - Uses safe defaults (no reasoning summaries, no parallel tool calls, etc), + then applies config overrides. + +Acceptance: +- TUI can construct a `ModelFamily` without resolving a real model slug. + +--- + +## Step 3 - TUI: Boot without blocking on models + +Files: +- `tui/src/app.rs` +- `tui2/src/app.rs` + +- [x] Remove startup awaits that currently block the first render: + - Do not call `ModelsManager::get_model(...).await` during startup. + - Do not call `ModelsManager::list_models(...).await` during startup. + - Do not run the existing model migration prompt at startup. +- [x] Construct `ChatWidget` immediately using the placeholder `ModelFamily`. + +Acceptance: +- The TUI event loop begins immediately (frame scheduled before any `/models` IO). + +--- + +## Step 4 - TUI: Truthful readiness gating (Loading/Ready only) + +Design: +- Ready is defined as "we have received `EventMsg::SessionConfigured`", which + includes the model actually used. +- While Loading, allow typing, but queue submissions and disable `/model`. + +Files: +- `tui/src/chatwidget.rs` +- `tui2/src/chatwidget.rs` + +- [x] Stop mutating `config.model` in `ChatWidget::new` based on the + (placeholder) `ModelFamily`. + - Start session header with a non-model value like `"Starting..."`. + - Once `SessionConfigured` arrives, update header from `event.model` (already + happens in `on_session_configured`). +- [x] Gate turn submission: + - While not configured, pressing Enter enqueues into the existing + `queued_user_messages` queue and updates the queued display. + - Prevent `maybe_send_next_queued_input` from sending anything until the + session is configured. + - After session configured: + - If there is no CLI-provided `initial_user_message`, start draining the + queued inputs by submitting exactly one (the existing turn-completion loop + will send the rest sequentially). + - If there *is* an `initial_user_message`, do not start draining immediately + (avoid sending queued inputs before the initial message begins a turn). +- [x] Gate `/model`: + - While not configured, show an info message explaining `/model` is disabled + until startup completes. + +Acceptance: +- Users can type immediately. +- Users can press Enter multiple times during startup; submits are queued and + later executed in order. +- `/model` is unavailable until the actual model is known (SessionConfigured). + +--- + +## Step 5 - Migration: Schedule for next run (like update) + +Goal: never interrupt the user during startup. Migration UX becomes a "pending +notice" rendered on the next run. + +- [x] After `SessionConfigured`, compute whether a migration notice should be + scheduled using the current `ModelsManager` model list (from the `RwLock`). +- [x] Persist a "pending migration notice" to a separate file under `codex_home` + (similar to `version.json`) so we don’t overcrowd `config.toml`. +- [x] On next run, display the notice as a history cell (non-modal), then clear + the pending notice (and record it as seen so it won't reappear). + +Acceptance: +- No migration prompt blocks startup. +- If migration is relevant, the user sees it next run similarly to update + notices. + +--- + +## Step 6 - Formatting, lint, and tests + +- [x] Run `just fmt` (required after Rust changes). +- [x] Run `just fix -p codex-core` (core changes). + - Note: in this environment, `just fix` needs to run outside the sandbox + (`clippy --fix` uses a TCP listener to manage locking). +- [x] Run `just fix -p codex-tui` / `just fix -p codex-tui2` (if those crates changed). +- [x] Run targeted tests: + - `cargo test -p codex-core` + - `cargo test -p codex-tui` + - `cargo test -p codex-tui2` (if changed) +- [ ] Ask before running `cargo test --all-features` (since core changed). + +Notes: +- `cargo test -p codex-core` must be run outside the sandbox in this environment + (wiremock binds an OS port; some seatbelt tests invoke sandbox-exec). diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d7be885ebd..29977882a5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -248,20 +248,19 @@ impl App { .insert(pending.from_model.clone(), pending.to_model.clone()); } - if should_show { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + if should_show + && let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .record_model_migration_seen(pending.from_model.as_str(), pending.to_model.as_str()) .apply() .await - { - tracing::error!( - error = %err, - "failed to persist model migration notice acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to persist model migration notice acknowledgement: {err}" - )); - } + { + tracing::error!( + error = %err, + "failed to persist model migration notice acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to persist model migration notice acknowledgement: {err}" + )); } if let Err(err) = diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 7f4fe4ea89..b2c34427f0 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -315,20 +315,19 @@ impl App { .insert(pending.from_model.clone(), pending.to_model.clone()); } - if should_show { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + if should_show + && let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .record_model_migration_seen(pending.from_model.as_str(), pending.to_model.as_str()) .apply() .await - { - tracing::error!( - error = %err, - "failed to persist model migration notice acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to persist model migration notice acknowledgement: {err}" - )); - } + { + tracing::error!( + error = %err, + "failed to persist model migration notice acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to persist model migration notice acknowledgement: {err}" + )); } if let Err(err) = From d0516eac5d548361adda2d608f58e287c9fe0f1f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 18:06:47 -0800 Subject: [PATCH 6/8] fix --- codex-rs/MODELS_STARTUP_PLAN.md | 32 ++- .../app-server/src/codex_message_processor.rs | 12 +- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/client_common.rs | 2 +- codex-rs/core/src/codex.rs | 16 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/context_manager/history.rs | 4 +- .../core/src/context_manager/history_tests.rs | 2 +- codex-rs/core/src/error.rs | 2 +- codex-rs/core/src/models_manager/manager.rs | 15 +- .../core/src/models_manager/model_family.rs | 179 ++-------------- codex-rs/core/src/state/session.rs | 2 +- codex-rs/core/src/tools/mod.rs | 2 +- codex-rs/core/src/tools/spec.rs | 2 +- codex-rs/core/src/truncate.rs | 115 ++++------- codex-rs/core/src/unified_exec/session.rs | 2 +- .../core/src/unified_exec/session_manager.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 2 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 14 +- codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/core/tests/suite/compact_remote.rs | 2 +- codex-rs/core/tests/suite/exec_policy.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 4 +- codex-rs/core/tests/suite/rmcp_client.rs | 12 +- codex-rs/core/tests/suite/shell_snapshot.rs | 6 +- codex-rs/core/tests/suite/skills.rs | 2 +- codex-rs/core/tests/suite/tool_harness.rs | 10 +- codex-rs/core/tests/suite/tool_parallelism.rs | 4 +- codex-rs/core/tests/suite/truncation.rs | 2 +- codex-rs/core/tests/suite/unified_exec.rs | 46 ++--- codex-rs/core/tests/suite/view_image.rs | 12 +- codex-rs/docs/protocol_v1.md | 14 ++ .../src/event_processor_with_human_output.rs | 10 +- .../tests/event_processor_with_json_output.rs | 25 ++- codex-rs/mcp-server/src/outgoing_message.rs | 32 ++- codex-rs/protocol/src/openai_models.rs | 193 ++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 28 ++- codex-rs/tui/src/app.rs | 63 ++++-- codex-rs/tui/src/app_backtrack.rs | 5 +- codex-rs/tui/src/chatwidget.rs | 76 +++++-- codex-rs/tui/src/chatwidget/tests.rs | 40 +++- codex-rs/tui/src/history_cell.rs | 9 +- codex-rs/tui/src/status/card.rs | 2 +- codex-rs/tui/src/status/tests.rs | 2 +- codex-rs/tui2/src/app.rs | 63 ++++-- codex-rs/tui2/src/app_backtrack.rs | 5 +- codex-rs/tui2/src/chatwidget.rs | 76 +++++-- codex-rs/tui2/src/chatwidget/tests.rs | 40 +++- codex-rs/tui2/src/history_cell.rs | 9 +- codex-rs/tui2/src/status/card.rs | 2 +- codex-rs/tui2/src/status/tests.rs | 2 +- 51 files changed, 744 insertions(+), 465 deletions(-) diff --git a/codex-rs/MODELS_STARTUP_PLAN.md b/codex-rs/MODELS_STARTUP_PLAN.md index 09fbf3339f..e815ab5ea7 100644 --- a/codex-rs/MODELS_STARTUP_PLAN.md +++ b/codex-rs/MODELS_STARTUP_PLAN.md @@ -29,16 +29,17 @@ Acceptance: --- -## Step 2 - Core: Provide a placeholder `ModelFamily` for UI startup +## Step 2 - Protocol: SessionConfigured includes the selected model family metadata -- [x] Add a public placeholder constructor in `core/src/models_manager/model_family.rs` - (e.g. `ModelFamily::placeholder(&Config) -> ModelFamily`). - - Must not claim a real model slug; it is only used for pre-session rendering. - - Uses safe defaults (no reasoning summaries, no parallel tool calls, etc), - then applies config overrides. +- [x] Change `SessionConfiguredEvent` to carry `model_family` metadata (not a plain model string). + - Implemented as `codex_protocol::openai_models::ModelFamily` (a type alias of `ModelInfo`, + matching the `/models` payload shape). + - Consumers derive the model slug from `event.model_family.slug`. Acceptance: -- TUI can construct a `ModelFamily` without resolving a real model slug. +- UIs can render the selected model name (and gate features) without making additional `/models` + calls after startup completes. + - No placeholder model family exists or is required. --- @@ -52,7 +53,7 @@ Files: - Do not call `ModelsManager::get_model(...).await` during startup. - Do not call `ModelsManager::list_models(...).await` during startup. - Do not run the existing model migration prompt at startup. -- [x] Construct `ChatWidget` immediately using the placeholder `ModelFamily`. +- [x] Construct `ChatWidget` immediately with `model_family: None` (no placeholder). Acceptance: - The TUI event loop begins immediately (frame scheduled before any `/models` IO). @@ -70,11 +71,9 @@ Files: - `tui/src/chatwidget.rs` - `tui2/src/chatwidget.rs` -- [x] Stop mutating `config.model` in `ChatWidget::new` based on the - (placeholder) `ModelFamily`. - - Start session header with a non-model value like `"Starting..."`. - - Once `SessionConfigured` arrives, update header from `event.model` (already - happens in `on_session_configured`). +- [x] Keep any model-family-dependent behavior gated until `SessionConfigured`. + - No placeholder: `ChatWidget` stores `model_family: Option`. + - Once `SessionConfigured` arrives, hydrate `model_family` from `event.model_family`. - [x] Gate turn submission: - While not configured, pressing Enter enqueues into the existing `queued_user_messages` queue and updates the queued display. @@ -89,6 +88,8 @@ Files: - [x] Gate `/model`: - While not configured, show an info message explaining `/model` is disabled until startup completes. +- [x] Gate other model-family-dependent flows (e.g. popups/status helpers) so they + early-return instead of assuming a model family exists. Acceptance: - Users can type immediately. @@ -120,10 +121,7 @@ Acceptance: ## Step 6 - Formatting, lint, and tests - [x] Run `just fmt` (required after Rust changes). -- [x] Run `just fix -p codex-core` (core changes). - - Note: in this environment, `just fix` needs to run outside the sandbox - (`clippy --fix` uses a TCP listener to manage locking). -- [x] Run `just fix -p codex-tui` / `just fix -p codex-tui2` (if those crates changed). +- [ ] Ask before running `just fix -p codex-core` / `just fix -p codex-tui` / `just fix -p codex-tui2`. - [x] Run targeted tests: - `cargo test -p codex-core` - `cargo test -p codex-tui` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d1804801d5..0b99d88a5d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1300,7 +1300,7 @@ impl CodexMessageProcessor { } = conversation_id; let response = NewConversationResponse { conversation_id, - model: session_configured.model, + model: session_configured.model_family.slug, reasoning_effort: session_configured.reasoning_effort, rollout_path: session_configured.rollout_path, }; @@ -1374,7 +1374,7 @@ impl CodexMessageProcessor { }; let SessionConfiguredEvent { - model, + model_family, model_provider_id, cwd, approval_policy, @@ -1383,7 +1383,7 @@ impl CodexMessageProcessor { } = session_configured; let response = ThreadStartResponse { thread: thread.clone(), - model, + model: model_family.slug, model_provider: model_provider_id, cwd, approval_policy: approval_policy.into(), @@ -1717,7 +1717,7 @@ impl CodexMessageProcessor { let response = ThreadResumeResponse { thread, - model: session_configured.model, + model: session_configured.model_family.slug, model_provider: session_configured.model_provider_id, cwd: session_configured.cwd, approval_policy: session_configured.approval_policy.into(), @@ -2330,7 +2330,7 @@ impl CodexMessageProcessor { .send_server_notification(ServerNotification::SessionConfigured( SessionConfiguredNotification { session_id: session_configured.session_id, - model: session_configured.model.clone(), + model: session_configured.model_family.slug.clone(), reasoning_effort: session_configured.reasoning_effort, history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, @@ -2346,7 +2346,7 @@ impl CodexMessageProcessor { // Reply with conversation id + model and initial messages (when present) let response = ResumeConversationResponse { conversation_id, - model: session_configured.model.clone(), + model: session_configured.model_family.slug.clone(), initial_messages, rollout_path: session_configured.rollout_path.clone(), }; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 11a3c5c65f..cb4eddff36 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -49,7 +49,7 @@ use crate::features::FEATURES; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; -use crate::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 913bb22321..e375a2d8b8 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,9 +1,9 @@ use crate::client_common::tools::ToolSpec; use crate::error::Result; -use crate::models_manager::model_family::ModelFamily; pub use codex_api::common::ResponseEvent; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelFamily; use futures::Stream; use serde::Deserialize; use serde_json::Value; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cf4c30f184..b4ed57da7c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -17,14 +17,12 @@ use crate::exec_policy::ExecPolicyManager; use crate::features::Feature; use crate::features::Features; use crate::models_manager::manager::ModelsManager; -use crate::models_manager::model_family::ModelFamily; use crate::parse_command::parse_command; use crate::parse_turn_item; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; use crate::terminal; -use crate::truncate::TruncationPolicy; use crate::user_notification::UserNotifier; use crate::util::error_or_panic; use async_channel::Receiver; @@ -32,6 +30,8 @@ use async_channel::Sender; use codex_protocol::ConversationId; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::items::TurnItem; +use codex_protocol::openai_models::ModelFamily; +use codex_protocol::openai_models::TruncationPolicy; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; use codex_protocol::protocol::ItemCompletedEvent; @@ -527,7 +527,7 @@ impl Session { final_output_json_schema: None, codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), - truncation_policy: TruncationPolicy::new( + truncation_policy: crate::truncate::new_truncation_policy( per_turn_config.as_ref(), model_family.truncation_policy, ), @@ -681,11 +681,14 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); + let session_model_family = models_manager + .construct_model_family(session_configuration.model.as_str(), config.as_ref()) + .await; let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model: session_configuration.model.clone(), + model_family: session_model_family, model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), sandbox_policy: session_configuration.sandbox_policy.get().clone(), @@ -2144,7 +2147,10 @@ async fn spawn_review_thread( final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), - truncation_policy: TruncationPolicy::new(&per_turn_config, model_family.truncation_policy), + truncation_policy: crate::truncate::new_truncation_policy( + &per_turn_config, + model_family.truncation_policy, + ), }; // Seed the child task with the review prompt as the initial user message. diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1a90b7b223..af13b944c3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,7 +15,7 @@ use crate::protocol::EventMsg; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; use crate::protocol::WarningEvent; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; use crate::util::backoff; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index c18ad7df8e..d50bf9d5f1 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,6 +1,6 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; use crate::truncate::truncate_function_output_items_with_policy; @@ -225,7 +225,7 @@ impl ContextManager { } fn process_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem { - let policy_with_serialization_budget = policy.mul(1.2); + let policy_with_serialization_budget = policy * 1.2; match item { ResponseItem::FunctionCallOutput { call_id, output } => { let truncated = diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index d121b7dc63..71e747968b 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,6 +1,5 @@ use super::*; use crate::truncate; -use crate::truncate::TruncationPolicy; use codex_git::GhostCommit; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; @@ -9,6 +8,7 @@ use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::openai_models::TruncationPolicy; use pretty_assertions::assert_eq; use regex_lite::Regex; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index e8fa91d26e..bc7554efb7 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,7 +1,7 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::truncate_text; use chrono::DateTime; use chrono::Datelike; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 8ed41a6072..2d730d0832 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -25,7 +25,7 @@ use crate::default_client::build_reqwest_client; use crate::error::Result as CoreResult; use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; -use crate::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use crate::models_manager::model_presets::builtin_model_presets; const MODEL_CACHE_FILE: &str = "models_cache.json"; @@ -149,9 +149,11 @@ impl ModelsManager { /// Look up the requested model family while applying remote metadata overrides. pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { - Self::find_family_for_model(model) - .with_remote_overrides(self.remote_models(config).await) - .with_config_overrides(config) + crate::models_manager::model_family::with_config_overrides( + Self::find_family_for_model(model) + .with_remote_overrides(self.remote_models(config).await), + config, + ) } pub async fn get_model(&self, model: &Option, config: &Config) -> String { @@ -185,7 +187,10 @@ impl ModelsManager { #[cfg(any(test, feature = "test-support"))] /// Offline helper that builds a `ModelFamily` without consulting remote state. pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { - Self::find_family_for_model(model).with_config_overrides(config) + crate::models_manager::model_family::with_config_overrides( + Self::find_family_for_model(model), + config, + ) } /// Replace the cached remote models and rebuild the derived presets list. diff --git a/codex-rs/core/src/models_manager/model_family.rs b/codex-rs/core/src/models_manager/model_family.rs index 453286d98f..0a975cd6a2 100644 --- a/codex-rs/core/src/models_manager/model_family.rs +++ b/codex-rs/core/src/models_manager/model_family.rs @@ -1,12 +1,12 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelFamily; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningSummaryFormat; +use codex_protocol::openai_models::TruncationPolicy; use crate::config::Config; -use crate::truncate::TruncationPolicy; /// The `instructions` field in the payload sent to a model should always start /// with this content. @@ -19,174 +19,20 @@ const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-m const GPT_5_2_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt-5.2-codex_prompt.md"); pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; -/// A model family is a group of models that share certain characteristics. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ModelFamily { - /// The full model slug used to derive this model family, e.g. - /// "gpt-4.1-2025-04-14". - pub slug: String, - - /// The model family name, e.g. "gpt-4.1". This string is used when deriving - /// default metadata for the family, such as context windows. - pub family: String, - - /// True if the model needs additional instructions on how to use the - /// "virtual" `apply_patch` CLI. - pub needs_special_apply_patch_instructions: bool, - - /// Maximum supported context window, if known. - pub context_window: Option, - - /// Token threshold for automatic compaction if config does not override it. - auto_compact_token_limit: Option, - - // Whether the `reasoning` field can be set when making a request to this - // model family. Note it has `effort` and `summary` subfields (though - // `summary` is optional). - pub supports_reasoning_summaries: bool, - - // The reasoning effort to use for this model family when none is explicitly chosen. - pub default_reasoning_effort: Option, - - // Define if we need a special handling of reasoning summary - pub reasoning_summary_format: ReasoningSummaryFormat, - - /// Whether this model supports parallel tool calls when using the - /// Responses API. - pub supports_parallel_tool_calls: bool, - - /// Present if the model performs better when `apply_patch` is provided as - /// a tool call instead of just a bash command - pub apply_patch_tool_type: Option, - - // Instructions to use for querying the model - pub base_instructions: String, - - /// Names of beta tools that should be exposed to this model family. - pub experimental_supported_tools: Vec, - - /// Percentage of the context window considered usable for inputs, after - /// reserving headroom for system prompts, tool overhead, and model output. - /// This is applied when computing the effective context window seen by - /// consumers. - pub effective_context_window_percent: i64, - - /// If the model family supports setting the verbosity level when using Responses API. - pub support_verbosity: bool, - - // The default verbosity level for this model family when using Responses API. - pub default_verbosity: Option, - - /// Preferred shell tool type for this model family when features do not override it. - pub shell_type: ConfigShellToolType, - - pub truncation_policy: TruncationPolicy, -} - -impl ModelFamily { - /// Placeholder model family for UI startup before Codex has selected the real model. - /// - /// This must not be treated as an actual model slug; it only exists to allow - /// consumers (e.g. the TUI) to render while the session is still starting. - pub fn placeholder(config: &Config) -> Self { - let mf = Self { - slug: String::new(), - family: "unknown".to_string(), - needs_special_apply_patch_instructions: false, - context_window: None, - auto_compact_token_limit: None, - supports_reasoning_summaries: false, - default_reasoning_effort: None, - reasoning_summary_format: ReasoningSummaryFormat::None, - supports_parallel_tool_calls: false, - apply_patch_tool_type: None, - base_instructions: BASE_INSTRUCTIONS.to_string(), - experimental_supported_tools: Vec::new(), - effective_context_window_percent: 95, - support_verbosity: false, - default_verbosity: None, - shell_type: ConfigShellToolType::Default, - truncation_policy: TruncationPolicy::Bytes(10_000), - }; - mf.with_config_overrides(config) +pub fn with_config_overrides(mut mf: ModelFamily, config: &Config) -> ModelFamily { + if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { + mf.supports_reasoning_summaries = supports_reasoning_summaries; } - - pub(super) fn with_config_overrides(mut self, config: &Config) -> Self { - if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries { - self.supports_reasoning_summaries = supports_reasoning_summaries; - } - if let Some(reasoning_summary_format) = config.model_reasoning_summary_format.as_ref() { - self.reasoning_summary_format = reasoning_summary_format.clone(); - } - if let Some(context_window) = config.model_context_window { - self.context_window = Some(context_window); - } - if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit { - self.auto_compact_token_limit = Some(auto_compact_token_limit); - } - self + if let Some(reasoning_summary_format) = config.model_reasoning_summary_format.as_ref() { + mf.reasoning_summary_format = reasoning_summary_format.clone(); } - pub(super) fn with_remote_overrides(mut self, remote_models: Vec) -> Self { - for model in remote_models { - if model.slug == self.slug { - self.apply_remote_overrides(model); - } - } - self + if let Some(context_window) = config.model_context_window { + mf.context_window = Some(context_window); } - - fn apply_remote_overrides(&mut self, model: ModelInfo) { - let ModelInfo { - slug: _, - display_name: _, - description: _, - default_reasoning_level, - supported_reasoning_levels: _, - shell_type, - visibility: _, - supported_in_api: _, - priority: _, - upgrade: _, - base_instructions, - supports_reasoning_summaries, - support_verbosity, - default_verbosity, - apply_patch_tool_type, - truncation_policy, - supports_parallel_tool_calls, - context_window, - reasoning_summary_format, - experimental_supported_tools, - } = model; - - self.default_reasoning_effort = Some(default_reasoning_level); - self.shell_type = shell_type; - if let Some(base) = base_instructions { - self.base_instructions = base; - } - self.supports_reasoning_summaries = supports_reasoning_summaries; - self.support_verbosity = support_verbosity; - self.default_verbosity = default_verbosity; - self.apply_patch_tool_type = apply_patch_tool_type; - self.truncation_policy = truncation_policy.into(); - self.supports_parallel_tool_calls = supports_parallel_tool_calls; - self.context_window = context_window; - self.reasoning_summary_format = reasoning_summary_format; - self.experimental_supported_tools = experimental_supported_tools; - } - - pub fn auto_compact_token_limit(&self) -> Option { - self.auto_compact_token_limit - .or(self.context_window.map(Self::default_auto_compact_limit)) - } - - const fn default_auto_compact_limit(context_window: i64) -> i64 { - (context_window * 9) / 10 - } - - pub fn get_model_slug(&self) -> &str { - &self.slug + if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit { + mf.auto_compact_token_limit = Some(auto_compact_token_limit); } + mf } macro_rules! model_family { @@ -460,6 +306,7 @@ fn derive_default_model_family(model: &str) -> ModelFamily { #[cfg(test)] mod tests { use super::*; + use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index c61d188373..2d7e394ed2 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -7,7 +7,7 @@ use crate::context_manager::ContextManager; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 9536eedafe..0d24513983 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -10,7 +10,7 @@ pub mod sandboxing; pub mod spec; use crate::exec::ExecToolCallOutput; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::formatted_truncate_text; use crate::truncate::truncate_text; pub use router::ToolRouter; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 0ac91755c2..9309bcadcc 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2,7 +2,7 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::features::Feature; use crate::features::Features; -use crate::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index 9f0672363e..f8ac690aa2 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -4,89 +4,60 @@ use crate::config::Config; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::openai_models::TruncationMode; -use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::openai_models::TruncationPolicy; const APPROX_BYTES_PER_TOKEN: usize = 4; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TruncationPolicy { - Bytes(usize), - Tokens(usize), -} +/// Create a new `TruncationPolicy` with config overrides applied. +pub fn new_truncation_policy(config: &Config, truncation_policy: TruncationPolicy) -> TruncationPolicy { + let config_token_limit = config.tool_output_token_limit; -impl From for TruncationPolicy { - fn from(config: TruncationPolicyConfig) -> Self { - match config.mode { - TruncationMode::Bytes => Self::Bytes(config.limit as usize), - TruncationMode::Tokens => Self::Tokens(config.limit as usize), + match truncation_policy { + TruncationPolicy::Bytes(family_bytes) => { + if let Some(token_limit) = config_token_limit { + TruncationPolicy::Bytes(approx_bytes_for_tokens(token_limit)) + } else { + TruncationPolicy::Bytes(family_bytes) + } + } + TruncationPolicy::Tokens(family_tokens) => { + if let Some(token_limit) = config_token_limit { + TruncationPolicy::Tokens(token_limit) + } else { + TruncationPolicy::Tokens(family_tokens) + } } } } -impl TruncationPolicy { - /// Scale the underlying budget by `multiplier`, rounding up to avoid under-budgeting. - pub fn mul(self, multiplier: f64) -> Self { - match self { - TruncationPolicy::Bytes(bytes) => { - TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) - } - TruncationPolicy::Tokens(tokens) => { - TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) - } +/// Returns a token budget derived from this policy. +/// +/// - For `Tokens`, this is the explicit token limit. +/// - For `Bytes`, this is an approximate token budget using the global +/// bytes-per-token heuristic. +pub fn token_budget(policy: &TruncationPolicy) -> usize { + match policy { + TruncationPolicy::Bytes(bytes) => { + usize::try_from(approx_tokens_from_byte_count(*bytes)).unwrap_or(usize::MAX) } + TruncationPolicy::Tokens(tokens) => *tokens, } +} - pub fn new(config: &Config, truncation_policy: TruncationPolicy) -> Self { - let config_token_limit = config.tool_output_token_limit; - - match truncation_policy { - TruncationPolicy::Bytes(family_bytes) => { - if let Some(token_limit) = config_token_limit { - Self::Bytes(approx_bytes_for_tokens(token_limit)) - } else { - Self::Bytes(family_bytes) - } - } - TruncationPolicy::Tokens(family_tokens) => { - if let Some(token_limit) = config_token_limit { - Self::Tokens(token_limit) - } else { - Self::Tokens(family_tokens) - } - } - } - } - - /// Returns a token budget derived from this policy. - /// - /// - For `Tokens`, this is the explicit token limit. - /// - For `Bytes`, this is an approximate token budget using the global - /// bytes-per-token heuristic. - pub fn token_budget(&self) -> usize { - match self { - TruncationPolicy::Bytes(bytes) => { - usize::try_from(approx_tokens_from_byte_count(*bytes)).unwrap_or(usize::MAX) - } - TruncationPolicy::Tokens(tokens) => *tokens, - } - } - - /// Returns a byte budget derived from this policy. - /// - /// - For `Bytes`, this is the explicit byte limit. - /// - For `Tokens`, this is an approximate byte budget using the global - /// bytes-per-token heuristic. - pub fn byte_budget(&self) -> usize { - match self { - TruncationPolicy::Bytes(bytes) => *bytes, - TruncationPolicy::Tokens(tokens) => approx_bytes_for_tokens(*tokens), - } +/// Returns a byte budget derived from this policy. +/// +/// - For `Bytes`, this is the explicit byte limit. +/// - For `Tokens`, this is an approximate byte budget using the global +/// bytes-per-token heuristic. +pub fn byte_budget(policy: &TruncationPolicy) -> usize { + match policy { + TruncationPolicy::Bytes(bytes) => *bytes, + TruncationPolicy::Tokens(tokens) => approx_bytes_for_tokens(*tokens), } } pub(crate) fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String { - if content.len() <= policy.byte_budget() { + if content.len() <= byte_budget(&policy) { return content.to_string(); } let total_lines = content.lines().count(); @@ -112,8 +83,8 @@ pub(crate) fn truncate_function_output_items_with_policy( ) -> Vec { let mut out: Vec = Vec::with_capacity(items.len()); let mut remaining_budget = match policy { - TruncationPolicy::Bytes(_) => policy.byte_budget(), - TruncationPolicy::Tokens(_) => policy.token_budget(), + TruncationPolicy::Bytes(_) => byte_budget(&policy), + TruncationPolicy::Tokens(_) => token_budget(&policy), }; let mut omitted_text_items = 0usize; @@ -172,7 +143,7 @@ fn truncate_with_token_budget(s: &str, policy: TruncationPolicy) -> (String, Opt if s.is_empty() { return (String::new(), None); } - let max_tokens = policy.token_budget(); + let max_tokens = token_budget(&policy); let byte_len = s.len(); if max_tokens > 0 && byte_len <= approx_bytes_for_tokens(max_tokens) { @@ -198,7 +169,7 @@ fn truncate_with_byte_estimate(s: &str, policy: TruncationPolicy) -> String { } let total_chars = s.chars().count(); - let max_bytes = policy.byte_budget(); + let max_bytes = byte_budget(&policy); if max_bytes == 0 { // No budget to show content; just report that everything was truncated. diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 4973a1a641..c85f879d58 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -14,7 +14,7 @@ use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; use crate::exec::StreamOutput; use crate::exec::is_likely_sandbox_denied; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::formatted_truncate_text; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::SpawnedPty; diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index c97820e464..871e3a6fd4 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -22,7 +22,7 @@ use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::sandboxing::ToolCtx; -use crate::truncate::TruncationPolicy; +use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 1e574cdef1..1ba596ad29 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -250,7 +250,7 @@ impl TestCodex { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, ) -> Result<()> { - let session_model = self.session_configured.model.clone(); + let session_model = self.session_configured.model_family.slug.clone(); self.codex .submit(Op::UserTurn { items: vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 1f99c846af..1ca0805bfc 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -297,7 +297,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( let call_id = "apply-move-no-change"; mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -883,7 +883,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( ]; mount_sse_sequence(harness.server(), bodies).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -960,7 +960,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> ]; mount_sse_sequence(harness.server(), bodies).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -1107,7 +1107,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( let patch = format!("*** Begin Patch\n*** Add File: {file}\n+hello\n*** End Patch\n"); mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -1167,7 +1167,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( let patch = "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch"; mount_apply_patch(&harness, call_id, patch, "ok", model_output).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -1235,7 +1235,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> ]); mount_sse_sequence(harness.server(), vec![s1, s2, s3]).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { @@ -1303,7 +1303,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result ]; mount_sse_sequence(harness.server(), responses).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 74e38534bd..6121b7b392 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -486,7 +486,7 @@ async fn submit_turn( approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, ) -> Result<()> { - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); test.codex .submit(Op::UserTurn { diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 34e44419b4..406b13a479 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -103,7 +103,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let compact_body = compact_request.body_json(); assert_eq!( compact_body.get("model").and_then(|v| v.as_str()), - Some(harness.test().session_configured.model.as_str()) + Some(harness.test().session_configured.model_family.slug.as_str()) ); let compact_body_text = compact_body.to_string(); assert!( diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 470478ad75..edbd5fbbd8 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -67,7 +67,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { ) .await; - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); test.codex .submit(Op::UserTurn { items: vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index c21174014d..0427ddb659 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -606,7 +606,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy.value(); let default_sandbox_policy = config.sandbox_policy.get(); - let default_model = session_configured.model; + let default_model = session_configured.model_family.slug; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -696,7 +696,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy.value(); let default_sandbox_policy = config.sandbox_policy.get(); - let default_model = session_configured.model; + let default_model = session_configured.model_family.slug; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 617b3b8a21..a413eb92a8 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -101,7 +101,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { }) .build(&server) .await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex @@ -238,7 +238,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { }) .build(&server) .await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex @@ -433,7 +433,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { }) .build(&server) .await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex @@ -576,7 +576,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { }) .build(&server) .await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex @@ -724,7 +724,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { }) .build(&server) .await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex @@ -904,7 +904,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { }) .build(&server) .await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 8357fb8a95..91d47e32a0 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -64,7 +64,7 @@ async fn run_snapshot_command(command: &str) -> Result { let test = harness.test(); let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); let cwd = test.cwd_path().to_path_buf(); codex @@ -140,7 +140,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result { let test = harness.test(); let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); let cwd = test.cwd_path().to_path_buf(); codex @@ -279,7 +279,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { ]; mount_sse_sequence(harness.server(), responses).await; - let model = test.session_configured.model.clone(); + let model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { items: vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index e64b5db3e7..13d683314e 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -63,7 +63,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { ) .await; - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); test.codex .submit(Op::UserTurn { items: vec![ diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a36ab03a41..1dfa975510 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -76,7 +76,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> ]); let second_mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -142,7 +142,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { ]); let second_mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -218,7 +218,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { ]); let second_mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -306,7 +306,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() ]); let second_mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -402,7 +402,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { ]); let second_mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 5a5b443209..4aa233e317 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -32,7 +32,7 @@ use serde_json::json; use tokio::sync::oneshot; async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); test.codex .submit(Op::UserTurn { @@ -345,7 +345,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an .build_with_streaming_server(&streaming_server) .await?; - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); test.codex .submit(Op::UserTurn { items: vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 4b916e51b4..2a80eb53d3 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -531,7 +531,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { ); }); let fixture = builder.build(&server).await?; - let session_model = fixture.session_configured.model.clone(); + let session_model = fixture.session_configured.model_family.slug.clone(); fixture .codex diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 2ca62a602f..5fed7126a1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -194,7 +194,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); let cwd = test.cwd_path().to_path_buf(); - let session_model = test.session_configured.model.clone(); + let session_model = test.session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -320,7 +320,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -395,7 +395,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -473,7 +473,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -563,7 +563,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -635,7 +635,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -708,7 +708,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -834,7 +834,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -967,7 +967,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1124,7 +1124,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1219,7 +1219,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1323,7 +1323,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1448,7 +1448,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1610,7 +1610,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1684,7 +1684,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1802,7 +1802,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -1931,7 +1931,7 @@ PY ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -2040,7 +2040,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -2131,7 +2131,7 @@ PY ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -2207,7 +2207,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -2306,7 +2306,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -2396,7 +2396,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -2524,7 +2524,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { let response_mock = mount_sse_sequence(&server, vec![first_response, completion_response]).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 6c0f6dcc80..12be018266 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -72,7 +72,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { ]); let mock = responses::mount_sse_once(&server, response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -163,7 +163,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { ]); let mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -273,7 +273,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { ]); let mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -345,7 +345,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> ]); let mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -436,7 +436,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { ]); let mock = responses::mount_sse_once(&server, second_response).await; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { @@ -517,7 +517,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> let image = ImageBuffer::from_pixel(1024, 512, Rgba([10u8, 20, 30, 255])); image.save(&abs_path)?; - let session_model = session_configured.model.clone(); + let session_model = session_configured.model_family.slug.clone(); codex .submit(Op::UserTurn { diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 805abb0ea8..7b373a34fc 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -173,3 +173,17 @@ sequenceDiagram task2->>user: Event::TurnCompleted task2->>-user: Event::TaskCompleted ``` + +## Key Events + +### SessionConfigured + +`Event::SessionConfigured` is emitted once after `Op::ConfigureSession`. + +Notable fields: +- `session_id`: the backend-assigned session identifier (historically named `session_id` for + backwards compatibility). +- `model_family`: model metadata for the model family selected for the session. This matches the + `/models` payload shape; the selected model slug is `model_family.slug`. +- `approval_policy`, `sandbox_policy`, `cwd`: the effective execution and sandbox settings for the + session. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a43718d569..4159338c60 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -140,8 +140,10 @@ impl EventProcessor for EventProcessorWithHumanOutput { VERSION ); - let mut entries = - create_config_summary_entries(config, session_configured_event.model.as_str()); + let mut entries = create_config_summary_entries( + config, + session_configured_event.model_family.slug.as_str(), + ); entries.push(( "session id", session_configured_event.session_id.to_string(), @@ -494,7 +496,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::SessionConfigured(session_configured_event) => { let SessionConfiguredEvent { session_id: conversation_id, - model, + model_family, .. } = session_configured_event; @@ -505,7 +507,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { conversation_id.to_string().style(self.dimmed) ); - ts_msg!(self, "model: {}", model); + ts_msg!(self, "model: {}", model_family.slug); eprintln!(); } EventMsg::PlanUpdate(plan_update_event) => { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2b3673f5a6..1e78273799 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -44,6 +44,11 @@ use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelFamily; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningSummaryFormat; +use codex_protocol::openai_models::TruncationPolicy; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -76,7 +81,25 @@ fn session_configured_produces_thread_started_event() { "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, - model: "codex-mini-latest".to_string(), + model_family: ModelFamily { + slug: "codex-mini-latest".to_string(), + family: "codex-mini-latest".to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(ReasoningEffort::default()), + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: String::new(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + }, model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 83ac25fdfd..76f56f021f 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -239,13 +239,39 @@ mod tests { use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; + use codex_protocol::openai_models::ConfigShellToolType; + use codex_protocol::openai_models::ModelFamily; use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::openai_models::ReasoningSummaryFormat; + use codex_protocol::openai_models::TruncationPolicy; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; use super::*; + fn test_model_family(slug: &str) -> ModelFamily { + ModelFamily { + slug: slug.to_string(), + family: slug.to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(ReasoningEffort::default()), + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: String::new(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + } + } + #[tokio::test] async fn test_send_event_as_notification() -> Result<()> { let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); @@ -257,7 +283,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model: "gpt-4o".to_string(), + model_family: test_model_family("gpt-4o"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -296,7 +322,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, - model: "gpt-4o".to_string(), + model_family: test_model_family("gpt-4o"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -332,7 +358,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, - "model": "gpt-4o", + "model_family": serde_json::to_value(&session_configured_event.model_family)?, "model_provider_id": "test-provider", "approval_policy": "never", "sandbox_policy": { diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index fc7f0b8ce0..696c6cda75 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -163,6 +163,199 @@ impl TruncationPolicyConfig { } } +#[derive(Debug, Clone, Deserialize, Serialize, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] +pub enum TruncationPolicy { + Bytes(usize), + Tokens(usize), +} + +impl From for TruncationPolicy { + fn from(config: TruncationPolicyConfig) -> Self { + match config.mode { + TruncationMode::Bytes => Self::Bytes(config.limit as usize), + TruncationMode::Tokens => Self::Tokens(config.limit as usize), + } + } +} + +impl std::ops::Mul for TruncationPolicy { + type Output = Self; + + /// Scale the underlying budget by `multiplier`, rounding up to avoid under-budgeting. + fn mul(self, multiplier: f64) -> Self::Output { + match self { + TruncationPolicy::Bytes(bytes) => { + TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) + } + TruncationPolicy::Tokens(tokens) => { + TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) + } + } + } +} + +/// A model family is a group of models that share certain characteristics. +#[derive(Debug, Clone, Deserialize, Serialize, Hash, JsonSchema, TS)] +pub struct ModelFamily { + /// The full model slug used to derive this model family, e.g. + /// "gpt-4.1-2025-04-14". + pub slug: String, + + /// The model family name, e.g. "gpt-4.1". This string is used when deriving + /// default metadata for the family, such as context windows. + pub family: String, + + /// True if the model needs additional instructions on how to use the + /// "virtual" `apply_patch` CLI. + pub needs_special_apply_patch_instructions: bool, + + /// Maximum supported context window, if known. + pub context_window: Option, + + /// Token threshold for automatic compaction if config does not override it. + pub auto_compact_token_limit: Option, + + // Whether the `reasoning` field can be set when making a request to this + // model family. Note it has `effort` and `summary` subfields (though + // `summary` is optional). + pub supports_reasoning_summaries: bool, + + // The reasoning effort to use for this model family when none is explicitly chosen. + pub default_reasoning_effort: Option, + + // Define if we need a special handling of reasoning summary + pub reasoning_summary_format: ReasoningSummaryFormat, + + /// Whether this model supports parallel tool calls when using the + /// Responses API. + pub supports_parallel_tool_calls: bool, + + /// Present if the model performs better when `apply_patch` is provided as + /// a tool call instead of just a bash command + pub apply_patch_tool_type: Option, + + // Instructions to use for querying the model + pub base_instructions: String, + + /// Names of beta tools that should be exposed to this model family. + pub experimental_supported_tools: Vec, + + /// Percentage of the context window considered usable for inputs, after + /// reserving headroom for system prompts, tool overhead, and model output. + /// This is applied when computing the effective context window seen by + /// consumers. + pub effective_context_window_percent: i64, + + /// If the model family supports setting the verbosity level when using Responses API. + pub support_verbosity: bool, + + // The default verbosity level for this model family when using Responses API. + pub default_verbosity: Option, + + /// Preferred shell tool type for this model family when features do not override it. + pub shell_type: ConfigShellToolType, + + pub truncation_policy: TruncationPolicy, +} + +impl ModelFamily { + /// Convert a `ModelFamily` into the protocol's `ModelInfo` shape for inclusion in events. + /// + /// This intentionally omits fields that are not needed for session bootstrapping + /// (e.g. `priority`, `visibility`, and `base_instructions`). + pub fn to_session_configured_model_info(&self) -> ModelInfo { + let default_reasoning_level = self.default_reasoning_effort.unwrap_or_default(); + let truncation_policy = match self.truncation_policy { + TruncationPolicy::Bytes(limit) => TruncationPolicyConfig::bytes(limit as i64), + TruncationPolicy::Tokens(limit) => TruncationPolicyConfig::tokens(limit as i64), + }; + + ModelInfo { + slug: self.slug.clone(), + display_name: self.slug.clone(), + description: None, + default_reasoning_level, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: default_reasoning_level, + description: default_reasoning_level.to_string(), + }], + shell_type: self.shell_type, + visibility: ModelVisibility::None, + supported_in_api: true, + priority: 0, + upgrade: None, + base_instructions: None, + supports_reasoning_summaries: self.supports_reasoning_summaries, + support_verbosity: self.support_verbosity, + default_verbosity: self.default_verbosity, + apply_patch_tool_type: self.apply_patch_tool_type.clone(), + truncation_policy, + supports_parallel_tool_calls: self.supports_parallel_tool_calls, + context_window: self.context_window, + reasoning_summary_format: self.reasoning_summary_format.clone(), + experimental_supported_tools: self.experimental_supported_tools.clone(), + } + } + + pub fn auto_compact_token_limit(&self) -> Option { + self.auto_compact_token_limit + .or(self.context_window.map(|cw| (cw * 9) / 10)) + } + + pub fn get_model_slug(&self) -> &str { + &self.slug + } + + pub fn with_remote_overrides(mut self, remote_models: Vec) -> Self { + for model in remote_models { + if model.slug == self.slug { + self.apply_remote_overrides(model); + } + } + self + } + + fn apply_remote_overrides(&mut self, model: ModelInfo) { + let ModelInfo { + slug: _, + display_name: _, + description: _, + default_reasoning_level, + supported_reasoning_levels: _, + shell_type, + visibility: _, + supported_in_api: _, + priority: _, + upgrade: _, + base_instructions, + supports_reasoning_summaries, + support_verbosity, + default_verbosity, + apply_patch_tool_type, + truncation_policy, + supports_parallel_tool_calls, + context_window, + reasoning_summary_format, + experimental_supported_tools, + } = model; + + self.default_reasoning_effort = Some(default_reasoning_level); + self.shell_type = shell_type; + if let Some(base) = base_instructions { + self.base_instructions = base; + } + self.supports_reasoning_summaries = supports_reasoning_summaries; + self.support_verbosity = support_verbosity; + self.default_verbosity = default_verbosity; + self.apply_patch_tool_type = apply_patch_tool_type; + self.truncation_policy = truncation_policy.into(); + self.supports_parallel_tool_calls = supports_parallel_tool_calls; + self.context_window = context_window; + self.reasoning_summary_format = reasoning_summary_format; + self.experimental_supported_tools = experimental_supported_tools; + } +} + /// Semantic version triple encoded as an array in JSON (e.g. [0, 62, 0]). #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] pub struct ClientVersion(pub i32, pub i32, pub i32); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1e03f5ce11..5d9485f466 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -19,6 +19,7 @@ use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; +use crate::openai_models::ModelFamily; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; @@ -1753,8 +1754,8 @@ pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, - /// Tell the client what model is being queried. - pub model: String, + /// Model metadata for the model family that Codex selected for this session. + pub model_family: ModelFamily, pub model_provider_id: String, @@ -1912,11 +1913,30 @@ mod tests { fn serialize_event() -> Result<()> { let conversation_id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let rollout_file = NamedTempFile::new()?; + let model_family = crate::openai_models::ModelFamily { + slug: "codex-mini-latest".to_string(), + family: "codex-mini-latest".to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(crate::openai_models::ReasoningEffort::Medium), + reasoning_summary_format: crate::openai_models::ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: "".to_string(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: crate::openai_models::ConfigShellToolType::Default, + truncation_policy: crate::openai_models::TruncationPolicy::Bytes(10_000), + }; let event = Event { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model: "codex-mini-latest".to_string(), + model_family: model_family.clone(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -1934,7 +1954,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", - "model": "codex-mini-latest", + "model_family": serde_json::to_value(model_family)?, "model_provider_id": "openai", "approval_policy": "never", "sandbox_policy": { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 29977882a5..7bb9b956b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -343,8 +343,6 @@ impl App { )); let enhanced_keys_supported = tui.enhanced_keys_supported(); - let model_family = - codex_core::models_manager::model_family::ModelFamily::placeholder(&config); let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -358,7 +356,6 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model_family: model_family.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -384,7 +381,6 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model_family: model_family.clone(), }; ChatWidget::new_from_existing( init, @@ -523,11 +519,6 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_family = self - .server - .get_models_manager() - .construct_model_family(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { let summary = session_summary( @@ -546,10 +537,9 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_family.get_model_slug().to_string(); + self.current_model.clear(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -596,14 +586,17 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new_from_existing( init, resumed.conversation, resumed.session_configured, ); - self.current_model = model_family.get_model_slug().to_string(); + self.current_model = self + .chat_widget + .get_model_family() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -688,7 +681,7 @@ impl App { } let configured_model = match &event.msg { - EventMsg::SessionConfigured(ev) => Some(ev.model.clone()), + EventMsg::SessionConfigured(ev) => Some(ev.model_family.slug.clone()), _ => None, }; if let EventMsg::ListSkillsResponse(response) = &event.msg { @@ -1228,10 +1221,41 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicBool; + fn test_model_family(slug: &str) -> codex_protocol::openai_models::ModelFamily { + use codex_protocol::openai_models::ConfigShellToolType; + use codex_protocol::openai_models::ModelFamily; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::openai_models::ReasoningSummaryFormat; + use codex_protocol::openai_models::TruncationPolicy; + + ModelFamily { + slug: slug.to_string(), + family: slug.to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(ReasoningEffort::Medium), + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: String::new(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + } + } + async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let current_model = chat_widget + .get_model_family() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); let server = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1270,7 +1294,10 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let current_model = chat_widget + .get_model_family() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); let server = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1433,7 +1460,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ConversationId::new(), - model: "gpt-test".to_string(), + model_family: test_model_family("gpt-test"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -1488,7 +1515,7 @@ mod tests { let conversation_id = ConversationId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, - model: "gpt-test".to_string(), + model_family: test_model_family("gpt-test"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 671702d308..64c6689642 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -338,10 +338,9 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; - let model_family = self.chat_widget.get_model_family(); + let current_model = session_configured.model_family.slug.clone(); let init = crate::chatwidget::ChatWidgetInit { config: cfg, - model_family: model_family.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, @@ -354,7 +353,7 @@ impl App { }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); - self.current_model = model_family.get_model_slug().to_string(); + self.current_model = current_model; // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 70820f3b89..222c3b9b1d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -15,7 +15,6 @@ use codex_core::features::Feature; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; -use codex_core::models_manager::model_family::ModelFamily; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -67,6 +66,7 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::openai_models::ModelFamily; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; @@ -291,7 +291,6 @@ pub(crate) struct ChatWidgetInit { pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, - pub(crate) model_family: ModelFamily, } #[derive(Default)] @@ -308,7 +307,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, active_cell: Option>, config: Config, - model_family: ModelFamily, + model_family: Option, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -423,11 +422,13 @@ impl ChatWidget { self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); - let model_for_header = event.model.clone(); + let model_family = codex_core::models_manager::model_family::with_config_overrides( + event.model_family.clone(), + &self.config, + ); + let model_for_header = model_family.get_model_slug().to_string(); + self.model_family = Some(model_family); self.session_header.set_model(&model_for_header); - // Now that Codex has selected the actual model, update the model family used for UI. - self.app_event_tx - .send(AppEvent::UpdateModel(model_for_header.clone())); self.add_to_history(history_cell::new_session_info( &self.config, &model_for_header, @@ -530,7 +531,11 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { - let reasoning_summary_format = self.get_model_family().reasoning_summary_format; + let reasoning_summary_format = self + .model_family + .as_ref() + .map(|mf| mf.reasoning_summary_format.clone()) + .unwrap_or_default(); // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { @@ -605,7 +610,7 @@ impl ChatWidget { fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window - .or(self.model_family.context_window) + .or(self.model_family.as_ref().and_then(|mf| mf.context_window)) .map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) @@ -677,7 +682,10 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG + && self + .model_family + .as_ref() + .is_some_and(|mf| mf.get_model_slug() != NUDGE_MODEL_SLUG) && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -711,7 +719,7 @@ impl ChatWidget { self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } - pub(crate) fn get_model_family(&self) -> ModelFamily { + pub(crate) fn get_model_family(&self) -> Option { self.model_family.clone() } @@ -1416,7 +1424,6 @@ impl ChatWidget { models_manager, feedback, is_first_run, - model_family, } = common; let config = config; let mut rng = rand::rng(); @@ -1439,7 +1446,7 @@ impl ChatWidget { }), active_cell: None, config, - model_family, + model_family: None, auth_manager, models_manager, session_header: SessionHeader::new("Starting...".to_string()), @@ -1499,9 +1506,12 @@ impl ChatWidget { auth_manager, models_manager, feedback, - model_family, .. } = common; + let model_family = codex_core::models_manager::model_family::with_config_overrides( + session_configured.model_family.clone(), + &config, + ); let model_slug = model_family.get_model_slug().to_string(); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1525,7 +1535,7 @@ impl ChatWidget { }), active_cell: None, config, - model_family, + model_family: Some(model_family), auth_manager, models_manager, session_header: SessionHeader::new(model_slug), @@ -1704,7 +1714,7 @@ impl ChatWidget { self.open_review_popup(); } SlashCommand::Model => { - if self.conversation_id.is_none() { + if self.model_family.is_none() { self.add_info_message( "`/model` is unavailable until startup finishes.".to_string(), None, @@ -2220,6 +2230,14 @@ impl ChatWidget { } pub(crate) fn add_status_output(&mut self) { + let Some(model_family) = self.model_family.as_ref() else { + self.add_info_message( + "`/status` is unavailable until startup finishes.".to_string(), + None, + ); + return; + }; + let default_usage = TokenUsage::default(); let (total_usage, context_usage) = if let Some(ti) = &self.token_info { (&ti.total_token_usage, Some(&ti.last_token_usage)) @@ -2229,14 +2247,14 @@ impl ChatWidget { self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), - &self.model_family, + model_family, total_usage, context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), - self.model_family.get_model_slug(), + model_family.get_model_slug(), )); } @@ -2389,7 +2407,14 @@ impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { - let current_model = self.model_family.get_model_slug().to_string(); + let Some(model_family) = self.model_family.as_ref() else { + self.add_info_message( + "`/model` is unavailable until startup finishes.".to_string(), + None, + ); + return; + }; + let current_model = model_family.get_model_slug().to_string(); let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models(&self.config) { @@ -2497,7 +2522,11 @@ impl ChatWidget { return; } - let current_model = self.model_family.get_model_slug().to_string(); + let current_model = self + .model_family + .as_ref() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = @@ -2628,7 +2657,10 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.model_family.get_model_slug() == preset.model; + let is_current_model = self + .model_family + .as_ref() + .is_some_and(|mf| mf.get_model_slug() == preset.model); let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { @@ -3220,7 +3252,7 @@ impl ChatWidget { /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); - self.model_family = model_family; + self.model_family = Some(model_family); } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 313a5252be..3d7aaf47a1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -97,6 +97,34 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { } } +fn test_model_family(slug: &str) -> ModelFamily { + use codex_protocol::openai_models::ConfigShellToolType; + use codex_protocol::openai_models::ModelFamily; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::openai_models::ReasoningSummaryFormat; + use codex_protocol::openai_models::TruncationPolicy; + + ModelFamily { + slug: slug.to_string(), + family: slug.to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(ReasoningEffort::default()), + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: String::new(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + } +} + #[tokio::test] async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -105,7 +133,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, - model: "test-model".to_string(), + model_family: test_model_family("test-model"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -312,8 +340,6 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; - let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); - let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); let conversation_manager = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), @@ -330,7 +356,6 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, - model_family, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -372,7 +397,10 @@ async fn make_chatwidget_manual( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), + model_family: Some(ModelsManager::construct_model_family_offline( + &resolved_model, + &cfg, + )), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), session_header: SessionHeader::new(resolved_model.clone()), @@ -1030,7 +1058,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { id: "configured".into(), msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, - model: "test-model".to_string(), + model_family: test_model_family("test-model"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 0b768dfb20..767af16b72 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -868,13 +868,14 @@ pub(crate) fn new_session_info( is_first_event: bool, ) -> SessionInfoCell { let SessionConfiguredEvent { - model, + model_family, reasoning_effort, .. } = event; + let used_model = model_family.slug; // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( - model.clone(), + used_model.clone(), reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, @@ -922,11 +923,11 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if requested_model != model { + if requested_model != used_model { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {requested_model}").into(), - format!("used: {model}").into(), + format!("used: {used_model}").into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 429134362a..ea3eb47e05 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,7 +7,7 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; -use codex_core::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 317a3d3270..95ca5a3774 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,7 +8,7 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::models_manager::manager::ModelsManager; -use codex_core::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index b2c34427f0..2a04e8b8db 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -412,8 +412,6 @@ impl App { )); let enhanced_keys_supported = tui.enhanced_keys_supported(); - let model_family = - codex_core::models_manager::model_family::ModelFamily::placeholder(&config); let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -427,7 +425,6 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model_family: model_family.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -453,7 +450,6 @@ impl App { models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model_family: model_family.clone(), }; ChatWidget::new_from_existing( init, @@ -1534,11 +1530,6 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_family = self - .server - .get_models_manager() - .construct_model_family(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { let summary = session_summary( @@ -1557,10 +1548,9 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_family.get_model_slug().to_string(); + self.current_model.clear(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -1607,14 +1597,17 @@ impl App { models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new_from_existing( init, resumed.conversation, resumed.session_configured, ); - self.current_model = model_family.get_model_slug().to_string(); + self.current_model = self + .chat_widget + .get_model_family() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -1697,7 +1690,7 @@ impl App { } let configured_model = match &event.msg { - EventMsg::SessionConfigured(ev) => Some(ev.model.clone()), + EventMsg::SessionConfigured(ev) => Some(ev.model_family.slug.clone()), _ => None, }; if let EventMsg::ListSkillsResponse(response) = &event.msg { @@ -2280,10 +2273,41 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicBool; + fn test_model_family(slug: &str) -> codex_protocol::openai_models::ModelFamily { + use codex_protocol::openai_models::ConfigShellToolType; + use codex_protocol::openai_models::ModelFamily; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::openai_models::ReasoningSummaryFormat; + use codex_protocol::openai_models::TruncationPolicy; + + ModelFamily { + slug: slug.to_string(), + family: slug.to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(ReasoningEffort::Medium), + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: String::new(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + } + } + async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let current_model = chat_widget + .get_model_family() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); let server = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2328,7 +2352,10 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let current_model = chat_widget + .get_model_family() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); let server = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -2465,7 +2492,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ConversationId::new(), - model: "gpt-test".to_string(), + model_family: test_model_family("gpt-test"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -2582,7 +2609,7 @@ mod tests { let conversation_id = ConversationId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, - model: "gpt-test".to_string(), + model_family: test_model_family("gpt-test"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index 671702d308..64c6689642 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -338,10 +338,9 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; - let model_family = self.chat_widget.get_model_family(); + let current_model = session_configured.model_family.slug.clone(); let init = crate::chatwidget::ChatWidgetInit { config: cfg, - model_family: model_family.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, @@ -354,7 +353,7 @@ impl App { }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); - self.current_model = model_family.get_model_slug().to_string(); + self.current_model = current_model; // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 4b96f2456d..09ca261d2c 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -13,7 +13,7 @@ use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; -use codex_core::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -267,7 +267,6 @@ pub(crate) struct ChatWidgetInit { pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, - pub(crate) model_family: ModelFamily, } #[derive(Default)] @@ -284,7 +283,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, active_cell: Option>, config: Config, - model_family: ModelFamily, + model_family: Option, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -398,11 +397,13 @@ impl ChatWidget { self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); - let model_for_header = event.model.clone(); + let model_family = codex_core::models_manager::model_family::with_config_overrides( + event.model_family.clone(), + &self.config, + ); + let model_for_header = model_family.get_model_slug().to_string(); + self.model_family = Some(model_family); self.session_header.set_model(&model_for_header); - // Now that Codex has selected the actual model, update the model family used for UI. - self.app_event_tx - .send(AppEvent::UpdateModel(model_for_header.clone())); self.add_to_history(history_cell::new_session_info( &self.config, &model_for_header, @@ -505,7 +506,11 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { - let reasoning_summary_format = self.get_model_family().reasoning_summary_format; + let reasoning_summary_format = self + .model_family + .as_ref() + .map(|mf| mf.reasoning_summary_format.clone()) + .unwrap_or_default(); // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { @@ -579,7 +584,7 @@ impl ChatWidget { fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window - .or(self.model_family.context_window) + .or(self.model_family.as_ref().and_then(|mf| mf.context_window)) .map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) @@ -651,7 +656,10 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG + && self + .model_family + .as_ref() + .is_some_and(|mf| mf.get_model_slug() != NUDGE_MODEL_SLUG) && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -685,7 +693,7 @@ impl ChatWidget { self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); } - pub(crate) fn get_model_family(&self) -> ModelFamily { + pub(crate) fn get_model_family(&self) -> Option { self.model_family.clone() } @@ -1285,7 +1293,6 @@ impl ChatWidget { models_manager, feedback, is_first_run, - model_family, } = common; let config = config; let mut rng = rand::rng(); @@ -1308,7 +1315,7 @@ impl ChatWidget { }), active_cell: None, config, - model_family, + model_family: None, auth_manager, models_manager, session_header: SessionHeader::new("Starting...".to_string()), @@ -1367,9 +1374,12 @@ impl ChatWidget { auth_manager, models_manager, feedback, - model_family, .. } = common; + let model_family = codex_core::models_manager::model_family::with_config_overrides( + session_configured.model_family.clone(), + &config, + ); let model_slug = model_family.get_model_slug().to_string(); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1393,7 +1403,7 @@ impl ChatWidget { }), active_cell: None, config, - model_family, + model_family: Some(model_family), auth_manager, models_manager, session_header: SessionHeader::new(model_slug), @@ -1571,7 +1581,7 @@ impl ChatWidget { self.open_review_popup(); } SlashCommand::Model => { - if self.conversation_id.is_none() { + if self.model_family.is_none() { self.add_info_message( "`/model` is unavailable until startup finishes.".to_string(), None, @@ -2059,6 +2069,14 @@ impl ChatWidget { } pub(crate) fn add_status_output(&mut self) { + let Some(model_family) = self.model_family.as_ref() else { + self.add_info_message( + "`/status` is unavailable until startup finishes.".to_string(), + None, + ); + return; + }; + let default_usage = TokenUsage::default(); let (total_usage, context_usage) = if let Some(ti) = &self.token_info { (&ti.total_token_usage, Some(&ti.last_token_usage)) @@ -2068,14 +2086,14 @@ impl ChatWidget { self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), - &self.model_family, + model_family, total_usage, context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), - self.model_family.get_model_slug(), + model_family.get_model_slug(), )); } fn stop_rate_limit_poller(&mut self) { @@ -2218,7 +2236,14 @@ impl ChatWidget { /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { - let current_model = self.model_family.get_model_slug().to_string(); + let Some(model_family) = self.model_family.as_ref() else { + self.add_info_message( + "`/model` is unavailable until startup finishes.".to_string(), + None, + ); + return; + }; + let current_model = model_family.get_model_slug().to_string(); let presets: Vec = // todo(aibrahim): make this async function match self.models_manager.try_list_models(&self.config) { @@ -2326,7 +2351,11 @@ impl ChatWidget { return; } - let current_model = self.model_family.get_model_slug().to_string(); + let current_model = self + .model_family + .as_ref() + .map(|mf| mf.get_model_slug().to_string()) + .unwrap_or_default(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = @@ -2457,7 +2486,10 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.model_family.get_model_slug() == preset.model; + let is_current_model = self + .model_family + .as_ref() + .is_some_and(|mf| mf.get_model_slug() == preset.model); let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { @@ -3018,7 +3050,7 @@ impl ChatWidget { /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); - self.model_family = model_family; + self.model_family = Some(model_family); } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 7c9e916757..982792ae38 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -95,6 +95,34 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { } } +fn test_model_family(slug: &str) -> ModelFamily { + use codex_protocol::openai_models::ConfigShellToolType; + use codex_protocol::openai_models::ModelFamily; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::openai_models::ReasoningSummaryFormat; + use codex_protocol::openai_models::TruncationPolicy; + + ModelFamily { + slug: slug.to_string(), + family: slug.to_string(), + needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, + supports_reasoning_summaries: false, + default_reasoning_effort: Some(ReasoningEffort::default()), + reasoning_summary_format: ReasoningSummaryFormat::None, + supports_parallel_tool_calls: false, + apply_patch_tool_type: None, + base_instructions: String::new(), + experimental_supported_tools: Vec::new(), + effective_context_window_percent: 95, + support_verbosity: false, + default_verbosity: None, + shell_type: ConfigShellToolType::Default, + truncation_policy: TruncationPolicy::Bytes(10_000), + } +} + #[tokio::test] async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -103,7 +131,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, - model: "test-model".to_string(), + model_family: test_model_family("test-model"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, @@ -310,8 +338,6 @@ async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; - let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); - let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); let conversation_manager = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), @@ -328,7 +354,6 @@ async fn helpers_are_available_and_do_not_panic() { models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, - model_family, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -370,7 +395,10 @@ async fn make_chatwidget_manual( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), + model_family: Some(ModelsManager::construct_model_family_offline( + &resolved_model, + &cfg, + )), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), session_header: SessionHeader::new(resolved_model.clone()), @@ -991,7 +1019,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { id: "configured".into(), msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, - model: "test-model".to_string(), + model_family: test_model_family("test-model"), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::ReadOnly, diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index f9fd36b92e..5db4acde28 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -626,13 +626,14 @@ pub(crate) fn new_session_info( is_first_event: bool, ) -> SessionInfoCell { let SessionConfiguredEvent { - model, + model_family, reasoning_effort, .. } = event; + let used_model = model_family.slug; // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( - model.clone(), + used_model.clone(), reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, @@ -680,11 +681,11 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if requested_model != model { + if requested_model != used_model { let lines = vec![ "model changed:".magenta().bold().into(), format!("requested: {requested_model}").into(), - format!("used: {model}").into(), + format!("used: {used_model}").into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); } diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index 429134362a..ea3eb47e05 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -7,7 +7,7 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; -use codex_core::models_manager::model_family::ModelFamily; +use codex_protocol::openai_models::ModelFamily; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; diff --git a/codex-rs/tui2/src/status/tests.rs b/codex-rs/tui2/src/status/tests.rs index 317a3d3270..7920949048 100644 --- a/codex-rs/tui2/src/status/tests.rs +++ b/codex-rs/tui2/src/status/tests.rs @@ -8,13 +8,13 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::models_manager::manager::ModelsManager; -use codex_core::models_manager::model_family::ModelFamily; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ModelFamily; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; use ratatui::prelude::*; From 958d106983216bb6242385ea91494aa6c6ba86f0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 18:07:51 -0800 Subject: [PATCH 7/8] fix --- codex-rs/MODELS_STARTUP_PLAN.md | 133 -------------------------------- 1 file changed, 133 deletions(-) delete mode 100644 codex-rs/MODELS_STARTUP_PLAN.md diff --git a/codex-rs/MODELS_STARTUP_PLAN.md b/codex-rs/MODELS_STARTUP_PLAN.md deleted file mode 100644 index e815ab5ea7..0000000000 --- a/codex-rs/MODELS_STARTUP_PLAN.md +++ /dev/null @@ -1,133 +0,0 @@ -# Models Startup + TUI Gating Plan - -Goal: let the TUI render immediately and accept input, while ensuring we do not -lie about the model actually used. Model selection (`/model`) and turn submission -are gated until the session is configured. If `/models` refresh fails, core -keeps using the fallback models seeded at startup (from `models.json`) via the -existing `RwLock`. - -This plan is intended to be executed top-to-bottom. Each step is checked off -when completed. - ---- - -## Step 1 - Core: Bound `/models` refresh + prevent duplicate refreshes - -- [x] Add a 5s timeout to the remote `/models` request in - `core/src/models_manager/manager.rs::refresh_available_models`. - - On timeout/error, do not overwrite `remote_models` (fallback seeded at - startup remains available automatically). - - Continue to log failures for visibility. -- [x] Add a `refresh_lock: tokio::sync::Mutex<()>` field to `ModelsManager`. - - Initialize it in `ModelsManager::new` and `ModelsManager::with_provider`. - - Acquire it around the refresh logic so concurrent calls do not issue - duplicate `/models` requests. - -Acceptance: -- `refresh_available_models` returns promptly (<= ~5s) even when `/models` hangs. -- Concurrent `list_models` / `get_model` callers do not duplicate the refresh. - ---- - -## Step 2 - Protocol: SessionConfigured includes the selected model family metadata - -- [x] Change `SessionConfiguredEvent` to carry `model_family` metadata (not a plain model string). - - Implemented as `codex_protocol::openai_models::ModelFamily` (a type alias of `ModelInfo`, - matching the `/models` payload shape). - - Consumers derive the model slug from `event.model_family.slug`. - -Acceptance: -- UIs can render the selected model name (and gate features) without making additional `/models` - calls after startup completes. - - No placeholder model family exists or is required. - ---- - -## Step 3 - TUI: Boot without blocking on models - -Files: -- `tui/src/app.rs` -- `tui2/src/app.rs` - -- [x] Remove startup awaits that currently block the first render: - - Do not call `ModelsManager::get_model(...).await` during startup. - - Do not call `ModelsManager::list_models(...).await` during startup. - - Do not run the existing model migration prompt at startup. -- [x] Construct `ChatWidget` immediately with `model_family: None` (no placeholder). - -Acceptance: -- The TUI event loop begins immediately (frame scheduled before any `/models` IO). - ---- - -## Step 4 - TUI: Truthful readiness gating (Loading/Ready only) - -Design: -- Ready is defined as "we have received `EventMsg::SessionConfigured`", which - includes the model actually used. -- While Loading, allow typing, but queue submissions and disable `/model`. - -Files: -- `tui/src/chatwidget.rs` -- `tui2/src/chatwidget.rs` - -- [x] Keep any model-family-dependent behavior gated until `SessionConfigured`. - - No placeholder: `ChatWidget` stores `model_family: Option`. - - Once `SessionConfigured` arrives, hydrate `model_family` from `event.model_family`. -- [x] Gate turn submission: - - While not configured, pressing Enter enqueues into the existing - `queued_user_messages` queue and updates the queued display. - - Prevent `maybe_send_next_queued_input` from sending anything until the - session is configured. - - After session configured: - - If there is no CLI-provided `initial_user_message`, start draining the - queued inputs by submitting exactly one (the existing turn-completion loop - will send the rest sequentially). - - If there *is* an `initial_user_message`, do not start draining immediately - (avoid sending queued inputs before the initial message begins a turn). -- [x] Gate `/model`: - - While not configured, show an info message explaining `/model` is disabled - until startup completes. -- [x] Gate other model-family-dependent flows (e.g. popups/status helpers) so they - early-return instead of assuming a model family exists. - -Acceptance: -- Users can type immediately. -- Users can press Enter multiple times during startup; submits are queued and - later executed in order. -- `/model` is unavailable until the actual model is known (SessionConfigured). - ---- - -## Step 5 - Migration: Schedule for next run (like update) - -Goal: never interrupt the user during startup. Migration UX becomes a "pending -notice" rendered on the next run. - -- [x] After `SessionConfigured`, compute whether a migration notice should be - scheduled using the current `ModelsManager` model list (from the `RwLock`). -- [x] Persist a "pending migration notice" to a separate file under `codex_home` - (similar to `version.json`) so we don’t overcrowd `config.toml`. -- [x] On next run, display the notice as a history cell (non-modal), then clear - the pending notice (and record it as seen so it won't reappear). - -Acceptance: -- No migration prompt blocks startup. -- If migration is relevant, the user sees it next run similarly to update - notices. - ---- - -## Step 6 - Formatting, lint, and tests - -- [x] Run `just fmt` (required after Rust changes). -- [ ] Ask before running `just fix -p codex-core` / `just fix -p codex-tui` / `just fix -p codex-tui2`. -- [x] Run targeted tests: - - `cargo test -p codex-core` - - `cargo test -p codex-tui` - - `cargo test -p codex-tui2` (if changed) -- [ ] Ask before running `cargo test --all-features` (since core changed). - -Notes: -- `cargo test -p codex-core` must be run outside the sandbox in this environment - (wiremock binds an OS port; some seatbelt tests invoke sandbox-exec). From 49490e4ca3894c89f356964c2d5a805e43e95851 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Dec 2025 18:36:18 -0800 Subject: [PATCH 8/8] fix --- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/context_manager/history.rs | 2 +- codex-rs/core/src/error.rs | 2 +- codex-rs/core/src/models_manager/manager.rs | 2 +- codex-rs/core/src/tools/mod.rs | 2 +- codex-rs/core/src/tools/spec.rs | 2 +- codex-rs/core/src/truncate.rs | 5 ++++- codex-rs/core/src/unified_exec/session.rs | 2 +- codex-rs/core/src/unified_exec/session_manager.rs | 2 +- codex-rs/tui/src/status/card.rs | 2 +- codex-rs/tui/src/status/tests.rs | 2 +- codex-rs/tui2/src/chatwidget.rs | 2 +- codex-rs/tui2/src/status/card.rs | 2 +- 14 files changed, 17 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index cb4eddff36..9426cbac0d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -49,9 +49,9 @@ use crate::features::FEATURES; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; -use codex_protocol::openai_models::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; +use codex_protocol::openai_models::ModelFamily; #[derive(Debug, Clone)] pub struct ModelClient { diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index af13b944c3..19683f18f1 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,7 +15,6 @@ use crate::protocol::EventMsg; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; use crate::protocol::WarningEvent; -use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; use crate::util::backoff; @@ -23,6 +22,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::TruncationPolicy; use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; use futures::prelude::*; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index d50bf9d5f1..5ad389f6c5 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,6 +1,5 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; -use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; use crate::truncate::truncate_function_output_items_with_policy; @@ -9,6 +8,7 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::TruncationPolicy; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use std::ops::Deref; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index bc7554efb7..0a741c7b7f 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,7 +1,6 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; -use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::truncate_text; use chrono::DateTime; use chrono::Datelike; @@ -9,6 +8,7 @@ use chrono::Local; use chrono::Utc; use codex_async_utils::CancelErr; use codex_protocol::ConversationId; +use codex_protocol::openai_models::TruncationPolicy; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::RateLimitSnapshot; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 2d730d0832..f233919b81 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -25,8 +25,8 @@ use crate::default_client::build_reqwest_client; use crate::error::Result as CoreResult; use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; -use codex_protocol::openai_models::ModelFamily; use crate::models_manager::model_presets::builtin_model_presets; +use codex_protocol::openai_models::ModelFamily; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 0d24513983..897a5a9420 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -10,9 +10,9 @@ pub mod sandboxing; pub mod spec; use crate::exec::ExecToolCallOutput; -use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::formatted_truncate_text; use crate::truncate::truncate_text; +use codex_protocol::openai_models::TruncationPolicy; pub use router::ToolRouter; use serde::Serialize; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 9309bcadcc..30321e2687 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2,13 +2,13 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::features::Feature; use crate::features::Features; -use codex_protocol::openai_models::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelFamily; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs index f8ac690aa2..35129a5570 100644 --- a/codex-rs/core/src/truncate.rs +++ b/codex-rs/core/src/truncate.rs @@ -9,7 +9,10 @@ use codex_protocol::openai_models::TruncationPolicy; const APPROX_BYTES_PER_TOKEN: usize = 4; /// Create a new `TruncationPolicy` with config overrides applied. -pub fn new_truncation_policy(config: &Config, truncation_policy: TruncationPolicy) -> TruncationPolicy { +pub fn new_truncation_policy( + config: &Config, + truncation_policy: TruncationPolicy, +) -> TruncationPolicy { let config_token_limit = config.tool_output_token_limit; match truncation_policy { diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index c85f879d58..16293a40fb 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -14,8 +14,8 @@ use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; use crate::exec::StreamOutput; use crate::exec::is_likely_sandbox_denied; -use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::formatted_truncate_text; +use codex_protocol::openai_models::TruncationPolicy; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::SpawnedPty; diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 871e3a6fd4..738147de3c 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -22,9 +22,9 @@ use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::sandboxing::ToolCtx; -use codex_protocol::openai_models::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; +use codex_protocol::openai_models::TruncationPolicy; use super::CommandTranscript; use super::ExecCommandRequest; diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index ea3eb47e05..0db74ea4b4 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,12 +7,12 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; -use codex_protocol::openai_models::ModelFamily; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ModelFamily; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 95ca5a3774..7920949048 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,13 +8,13 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::models_manager::manager::ModelsManager; -use codex_protocol::openai_models::ModelFamily; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ModelFamily; use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; use ratatui::prelude::*; diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 09ca261d2c..a6eb4ca26c 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -13,7 +13,6 @@ use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; -use codex_protocol::openai_models::ModelFamily; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -65,6 +64,7 @@ use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::openai_models::ModelFamily; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index ea3eb47e05..0db74ea4b4 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -7,12 +7,12 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; -use codex_protocol::openai_models::ModelFamily; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ModelFamily; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet;