diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 4831fbb995..7bb9cd043c 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -258,7 +258,10 @@ impl App { ))); self.file_search = FileSearchManager::new(self.config.cwd.to_path_buf(), self.app_event_tx.clone()); - self.model_catalog = Arc::new(ModelCatalog::new(bootstrap.available_models)); + self.model_catalog = Arc::new( + ModelCatalog::new(bootstrap.available_models) + .with_collaboration_modes(bootstrap.collaboration_modes), + ); self.pending_app_server_requests.clear(); self.pending_primary_events.clear(); self.pending_plugin_enabled_writes.clear(); diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index e08e3fad6f..1434b908fd 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -212,7 +212,10 @@ impl App { { tracing::warn!(%error, "TUI task delegation is unavailable without its MCP server"); } - let model_catalog = Arc::new(ModelCatalog::new(available_models.clone())); + let model_catalog = Arc::new( + ModelCatalog::new(available_models.clone()) + .with_collaboration_modes(bootstrap.collaboration_modes), + ); let feedback_audience = bootstrap.feedback_audience; let auth_mode = bootstrap.auth_mode; let has_chatgpt_account = bootstrap.has_chatgpt_account; diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index 8b9ca10b9b..786efd734d 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -53,6 +53,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> /*developer_instructions*/ None, ); let expected_submitted_mode = expected_mode.clone(); + assert!(!app.model_catalog.collaboration_modes.is_empty()); if edit_offline { app.chat_widget .restore_user_message_to_composer("unacknowledged prompt".into()); @@ -260,6 +261,13 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> app.chat_widget.effective_collaboration_mode(), expected_mode ); + assert!(app.model_catalog.collaboration_modes.is_empty()); + assert!( + app.chat_widget + .model_catalog() + .collaboration_modes + .is_empty() + ); assert_eq!( app.chat_widget.composer_text_with_pending(), if edit_offline { diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index 07c64bb8cd..68fee463da 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -269,6 +269,7 @@ where Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}})) } "model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "collaborationMode/list" => Some(json!({"result": {"data": []}})), "configRequirements/read" => Some(json!({"result": {"requirements": null}})), _ => respond(request).await, }; diff --git a/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs index 9545a95469..dcfb3f227f 100644 --- a/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs +++ b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs @@ -42,7 +42,7 @@ pub(super) fn configure_reserve_catalog(app: &mut App) { let mut high = original.supported_reasoning_efforts[0].clone(); high.effort = ReasoningEffortConfig::High; original.supported_reasoning_efforts.push(high); - app.model_catalog = Arc::new(ModelCatalog::new(models)); + Arc::make_mut(&mut app.model_catalog).models = models; let mut tui = crate::tui::test_support::make_test_tui().unwrap(); let mut init = app.chatwidget_init_for_forked_or_resumed_thread( &mut tui, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 16d171954b..b90e88cff1 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -8,6 +8,10 @@ mod history; mod models; mod rollout_history; +#[cfg(test)] +#[path = "app_server_session/collaboration_catalog_tests.rs"] +mod collaboration_catalog_tests; + pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT; pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT; pub(crate) use history::HistoryHydrationScope; @@ -290,6 +294,7 @@ pub(crate) struct AppServerBootstrap { pub(crate) feedback_audience: FeedbackAudience, pub(crate) has_chatgpt_account: bool, pub(crate) available_models: Vec, + pub(crate) collaboration_modes: Vec, } pub(crate) struct AppServerSession { @@ -560,7 +565,7 @@ impl AppServerSession { // requirements together so an uncached model fetch can overlap both config requests. let model_request_id = self.next_request_id(); let requirements_request_id = self.next_request_id(); - let (models, requirements) = tokio::try_join!( + let (models, requirements, collaboration_modes) = tokio::try_join!( async { self.client .request_typed::(ClientRequest::ModelList { @@ -592,6 +597,7 @@ impl AppServerSession { ) }) }, + async { Ok(crate::collaboration_modes::list(self.request_handle()).await) }, )?; self.managed_new_thread_defaults = requirements .requirements @@ -669,6 +675,7 @@ impl AppServerSession { feedback_audience, has_chatgpt_account, available_models, + collaboration_modes, }) } diff --git a/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs b/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs new file mode 100644 index 0000000000..3312ae7324 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/collaboration_catalog_tests.rs @@ -0,0 +1,92 @@ +//! Optional catalog discovery uses the public RPC and never prevents ordinary bootstrap. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use codex_app_server_protocol::JSONRPCMessage; +use codex_protocol::config_types::CollaborationModeMask; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn collaboration_catalog_is_optional_and_refetched_on_bootstrap() -> Result<()> { + for initial_reply in [ + Some(json!({"error": {"code": -32601, "message": "Method not found"}})), + Some(json!({"error": {"code": -32600, "message": "Experimental API unavailable"}})), + Some(json!({"error": {"code": -32603, "message": "Discovery failed"}})), + Some(json!({"result": {"data": []}})), + Some(json!({"result": {"data": "invalid"}})), + None, + ] { + let codex_home = tempfile::tempdir()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + config.model = Some("task-model".into()); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut socket = tokio_tungstenite::accept_async(stream).await?; + let mut catalog_requests = 0; + while let Some(Ok(Message::Text(text))) = socket.next().await { + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + let mut reply = match request.method.as_str() { + "initialize" => json!({"result": {"userAgent": "catalog-test/1.0.0"}}), + "account/read" => { + json!({"result": {"account": null, "requiresOpenaiAuth": false}}) + } + "model/list" => json!({"result": {"data": [], "nextCursor": null}}), + "configRequirements/read" => json!({"result": {"requirements": null}}), + "collaborationMode/list" => { + assert_eq!(request.params, Some(json!({}))); + catalog_requests += 1; + if catalog_requests == 1 { + let Some(reply) = initial_reply.clone() else { + continue; + }; + reply + } else { + json!({"result": {"data": [{"name": "Server plan", "mode": "plan", "model": "server-model", "reasoning_effort": "high"}]}}) + } + } + method => panic!("unexpected request: {method}"), + }; + reply["id"] = json!(request.id); + socket.send(Message::Text(reply.to_string().into())).await?; + } + Ok::<_, color_eyre::Report>(catalog_requests) + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + let initial = session.bootstrap(&config).await?; + assert_eq!( + (initial.default_model, initial.collaboration_modes), + ("task-model".into(), vec![]) + ); + let refreshed = session.bootstrap(&config).await?; + assert_eq!( + refreshed.collaboration_modes, + vec![CollaborationModeMask { + name: "Server plan".into(), + mode: Some(ModeKind::Plan), + model: Some("server-model".into()), + reasoning_effort: Some(Some(ReasoningEffort::High)), + developer_instructions: Some(None), + }] + ); + session.shutdown().await?; + assert_eq!(server.await??, 2); + } + Ok(()) +} diff --git a/codex-rs/tui/src/chatwidget/model_popup_state.rs b/codex-rs/tui/src/chatwidget/model_popup_state.rs index a85480e1bb..7e74217680 100644 --- a/codex-rs/tui/src/chatwidget/model_popup_state.rs +++ b/codex-rs/tui/src/chatwidget/model_popup_state.rs @@ -36,7 +36,7 @@ impl ChatWidget { { return false; } - self.model_catalog = Arc::new(ModelCatalog::new(presets)); + Arc::make_mut(&mut self.model_catalog).models = presets; self.refresh_effective_service_tier(); self.refresh_model_dependent_surfaces(); true diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 51996da624..08ccdfd66c 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -801,9 +801,7 @@ impl ChatWidget { }); } SlashCommand::Plan if !trimmed.is_empty() => { - if !self.apply_plan_slash_command() { - return; - } + let plan_available = self.apply_plan_slash_command(); let mut user_message = self.prepared_inline_user_message( args, text_elements, @@ -812,7 +810,8 @@ impl ChatWidget { mention_bindings, source, ); - if !self.is_session_configured() + if !plan_available + || !self.is_session_configured() || self.current_model().trim().is_empty() || (!self.current_model_supports_images() && (!user_message.local_images.is_empty() @@ -825,6 +824,10 @@ impl ChatWidget { element.byte_range.end += PLAN_PREFIX.len(); } } + if !plan_available { + self.restore_user_message_to_composer(user_message); + return; + } if self.is_session_configured() { self.reasoning_buffer.clear(); self.reasoning_header = None; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index af379dc148..d440077dc6 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,6 +235,8 @@ mod app_server; mod approval_requests; #[path = "tests/backend_banners_tests.rs"] mod backend_banners_tests; +#[path = "tests/collaboration_catalog_tests.rs"] +mod collaboration_catalog_tests; #[path = "tests/compaction_tests.rs"] mod compaction_tests; mod composer_submission; diff --git a/codex-rs/tui/src/chatwidget/tests/collaboration_catalog_tests.rs b/codex-rs/tui/src/chatwidget/tests/collaboration_catalog_tests.rs new file mode 100644 index 0000000000..4f65396ca0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/collaboration_catalog_tests.rs @@ -0,0 +1,93 @@ +//! Server choices are independent of model refresh and restored task settings. + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn collaboration_catalog_selection_clears_restored_prompt_and_survives_model_refresh() { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await; + let restored = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.2".into(), + reasoning_effort: Some(ReasoningEffortConfig::Low), + developer_instructions: Some("stale restored prompt".into()), + }, + }; + chat.set_effective_collaboration_mode(restored.clone()); + let plan = CollaborationModeMask { + name: "Server plan".into(), + mode: Some(ModeKind::Plan), + model: None, + reasoning_effort: Some(Some(ReasoningEffortConfig::High)), + developer_instructions: Some(None), + }; + Arc::make_mut(&mut chat.model_catalog).collaboration_modes = vec![plan.clone()]; + let mut models = chat.model_catalog.try_list_models().unwrap(); + models[0].description = "Refreshed model description".into(); + let request_id = uuid::Uuid::new_v4(); + chat.model_popup_request_id = Some(request_id); + assert!(chat.on_models_loaded(request_id, Ok(models))); + assert_eq!(chat.model_catalog.collaboration_modes, vec![plan]); + assert_eq!(chat.effective_collaboration_mode(), restored); + assert!(collaboration_modes::default_mode_mask(&chat.model_catalog).is_none()); + + chat.cycle_collaboration_mode(); + assert_eq!( + chat.effective_collaboration_mode(), + CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-5.2".into(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + } + ); + chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Ultra)); + chat.cycle_collaboration_mode(); + assert_eq!( + chat.effective_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + chat.set_plan_mode_reasoning_effort(/*effort*/ None); + assert_eq!( + chat.effective_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); +} + +#[tokio::test] +async fn collaboration_catalog_unavailable_preserves_task_and_plan_input() { + let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await; + Arc::make_mut(&mut chat.model_catalog) + .collaboration_modes + .clear(); + for mode in [ModeKind::Default, ModeKind::Plan] { + let restored = CollaborationMode { + mode, + settings: Settings { + model: "task-model".into(), + reasoning_effort: Some(ReasoningEffortConfig::Ultra), + developer_instructions: Some("restored prompt".into()), + }, + }; + chat.set_effective_collaboration_mode(restored.clone()); + chat.cycle_collaboration_mode(); + assert_eq!(chat.effective_collaboration_mode(), restored); + } + chat.set_effective_collaboration_mode(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "task-model".into(), + reasoning_effort: Some(ReasoningEffortConfig::Low), + developer_instructions: None, + }, + }); + chat.thread_id = Some(ThreadId::new()); + chat.bottom_pane + .set_composer_text("/plan keep my draft".into(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(chat.bottom_pane.composer_text(), "/plan keep my draft"); + assert_eq!(chat.active_mode_kind(), ModeKind::Default); +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9ac2070bec..47da9b3922 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -140,9 +140,12 @@ pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTel } pub(super) fn test_model_catalog(_config: &Config) -> Arc { - Arc::new(ModelCatalog::new( - crate::test_support::TEST_MODEL_PRESETS.clone(), - )) + Arc::new( + ModelCatalog::new(crate::test_support::TEST_MODEL_PRESETS.clone()) + .with_collaboration_modes( + codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets(), + ), + ) } // --- Helpers for tests that need direct construction and event draining --- @@ -309,7 +312,7 @@ pub(crate) fn set_fast_mode_test_catalog(chat: &mut ChatWidget) { .map(Into::into) .collect(); - chat.model_catalog = Arc::new(ModelCatalog::new(models)); + Arc::make_mut(&mut chat.model_catalog).models = models; } pub(crate) async fn make_chatwidget_manual_with_sender() -> ( diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index bdd23d1638..9f5fc3da34 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1483,7 +1483,7 @@ async fn plan_slash_command_with_hidden_shell_paste_rejected_image_remains_liter .expect("current model") .input_modalities .retain(|modality| *modality != InputModality::Image); - chat.model_catalog = Arc::new(ModelCatalog::new(models)); + Arc::make_mut(&mut chat.model_catalog).models = models; let payload = paste_hidden_plan_shell_payload(&mut chat); chat.set_remote_image_urls(vec!["https://example.com/image.png".to_string()]); @@ -1558,7 +1558,7 @@ async fn rejected_initial_image_does_not_submit_later_queued_prompt() { .expect("current model") .input_modalities .retain(|modality| *modality != InputModality::Image); - chat.model_catalog = Arc::new(ModelCatalog::new(models)); + Arc::make_mut(&mut chat.model_catalog).models = models; let mut initial_message = UserMessage::from("initial prompt"); initial_message.remote_image_urls = vec!["https://example.com/image.png".to_string()]; chat.initial_user_message = Some(initial_message); diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index b81f4bf4dc..612a2752e8 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,13 +1,22 @@ -use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +//! Server collaboration-mode discovery and TUI-visible selection. Missing discovery adds no presets. + +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::RequestId; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; +use std::time::Duration; use crate::model_catalog::ModelCatalog; -fn filtered_presets(_model_catalog: &ModelCatalog) -> Vec { - builtin_collaboration_mode_presets() - .into_iter() +fn filtered_presets(model_catalog: &ModelCatalog) -> Vec { + model_catalog + .collaboration_modes + .iter() .filter(|mask| mask.mode.is_some_and(ModeKind::is_tui_visible)) + .cloned() .collect() } @@ -56,3 +65,39 @@ pub(crate) fn default_mode_mask(model_catalog: &ModelCatalog) -> Option Option { mask_for_kind(model_catalog, ModeKind::Plan) } + +/// Discovery is optional even on servers that accept the rest of TUI bootstrap. +pub(crate) async fn list(request_handle: AppServerRequestHandle) -> Vec { + let response = tokio::time::timeout( + Duration::from_secs(/*secs*/ 2), + request_handle.request_typed::( + ClientRequest::CollaborationModeList { + request_id: RequestId::String(format!( + "collaboration-mode-list-{}", + uuid::Uuid::new_v4() + )), + params: CollaborationModeListParams::default(), + }, + ), + ) + .await; + match response { + Ok(Ok(response)) => response + .data + .into_iter() + .map(|mask| CollaborationModeMask { + name: mask.name, + mode: mask.mode, + model: mask.model, + reasoning_effort: mask.reasoning_effort, + // The RPC intentionally omits prompts. Clear restored overrides so the server + // supplies its current instructions when this mode is selected. + developer_instructions: Some(None), + }) + .collect(), + _ => { + tracing::warn!("optional collaborationMode/list discovery unavailable"); + Vec::new() + } + } +} diff --git a/codex-rs/tui/src/model_catalog.rs b/codex-rs/tui/src/model_catalog.rs index bbef579e81..6b89c9aeb0 100644 --- a/codex-rs/tui/src/model_catalog.rs +++ b/codex-rs/tui/src/model_catalog.rs @@ -1,5 +1,6 @@ -//! TUI model inventory and user-facing labels; wire requests retain their original model slugs. +//! TUI model and collaboration inventories; refreshing models preserves the server mode catalog. +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelPreset; use std::convert::Infallible; @@ -16,12 +17,21 @@ pub(crate) fn model_display_name(model: &str) -> &str { #[derive(Debug, Clone)] pub(crate) struct ModelCatalog { - models: Vec, + pub(crate) models: Vec, + pub(crate) collaboration_modes: Vec, } impl ModelCatalog { pub(crate) fn new(models: Vec) -> Self { - Self { models } + Self { + models, + collaboration_modes: Vec::new(), + } + } + + pub(crate) fn with_collaboration_modes(mut self, modes: Vec) -> Self { + self.collaboration_modes = modes; + self } pub(crate) fn try_list_models(&self) -> Result, Infallible> {