diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8aefbd0f47fe..d3881d24c77c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -709,7 +709,10 @@ impl App { if let Some(updated_model) = config.model.clone() { model = updated_model; } - let model_catalog = Arc::new(ModelCatalog::new(available_models.clone())); + let model_catalog = Arc::new(ModelCatalog::new( + available_models.clone(), + 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_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 73c4c807ef2a..ad2f4011e8e7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -18,6 +18,9 @@ use codex_app_server_protocol::Account; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::CollaborationModeMask as ApiCollaborationModeMask; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ExternalAgentConfigDetectParams; @@ -101,9 +104,11 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; @@ -120,10 +125,26 @@ use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +const JSONRPC_INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const JSONRPC_METHOD_NOT_FOUND_ERROR_CODE: i64 = -32601; + fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } +fn is_missing_collaboration_mode_list_error(err: &TypedRequestError) -> bool { + match err { + TypedRequestError::Server { method, source } => { + method == "collaborationMode/list" + && (source.code == JSONRPC_METHOD_NOT_FOUND_ERROR_CODE + || (source.code == JSONRPC_INVALID_REQUEST_ERROR_CODE + && source.message.contains("collaborationMode/list") + && source.message.contains("unknown variant"))) + } + TypedRequestError::Transport { .. } | TypedRequestError::Deserialize { .. } => false, + } +} + /// Data collected during the TUI bootstrap phase that the main event loop /// needs to configure the UI, telemetry, and initial rate-limit prefetch. /// @@ -143,6 +164,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 { @@ -215,6 +237,37 @@ impl AppServerSession { .into_iter() .map(model_preset_from_api_model) .collect::>(); + let collaboration_modes_request_id = self.next_request_id(); + let collaboration_modes_response: std::result::Result< + CollaborationModeListResponse, + TypedRequestError, + > = self + .client + .request_typed(ClientRequest::CollaborationModeList { + request_id: collaboration_modes_request_id, + params: CollaborationModeListParams::default(), + }) + .await; + let collaboration_modes = match collaboration_modes_response { + Ok(collaboration_modes) => collaboration_modes + .data + .into_iter() + .map(collaboration_mode_mask_from_api_mask) + .collect::>(), + Err(err) if self.is_remote() && is_missing_collaboration_mode_list_error(&err) => { + tracing::debug!( + %err, + "remote app-server does not support collaborationMode/list; using built-in collaboration modes" + ); + builtin_collaboration_mode_presets() + } + Err(err) => { + return Err(bootstrap_request_error( + "collaborationMode/list failed during TUI bootstrap", + err, + )); + } + }; let default_model = config .model .clone() @@ -276,6 +329,7 @@ impl AppServerSession { feedback_audience, has_chatgpt_account, available_models, + collaboration_modes, }) } @@ -1067,6 +1121,16 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { } } +fn collaboration_mode_mask_from_api_mask(mask: ApiCollaborationModeMask) -> CollaborationModeMask { + CollaborationModeMask { + name: mask.name, + mode: mask.mode, + model: mask.model, + reasoning_effort: mask.reasoning_effort, + developer_instructions: None, + } +} + fn approvals_reviewer_override_from_config( config: &Config, ) -> Option { @@ -1581,6 +1645,39 @@ mod tests { .expect("config should build") } + fn collaboration_mode_list_server_error(code: i64, message: &str) -> TypedRequestError { + TypedRequestError::Server { + method: "collaborationMode/list".to_string(), + source: JSONRPCErrorError { + code, + message: message.to_string(), + data: None, + }, + } + } + + #[test] + fn detects_missing_collaboration_mode_list_errors() { + assert!(is_missing_collaboration_mode_list_error( + &collaboration_mode_list_server_error( + JSONRPC_METHOD_NOT_FOUND_ERROR_CODE, + "Method not found" + ) + )); + assert!(is_missing_collaboration_mode_list_error( + &collaboration_mode_list_server_error( + JSONRPC_INVALID_REQUEST_ERROR_CODE, + "Invalid request: unknown variant `collaborationMode/list`" + ) + )); + assert!(!is_missing_collaboration_mode_list_error( + &collaboration_mode_list_server_error( + JSONRPC_INVALID_REQUEST_ERROR_CODE, + "Experimental API `collaborationMode/list` is not enabled" + ) + )); + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f11cd64843eb..5be04c3f2458 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1,5 +1,6 @@ use super::*; use codex_app_server_protocol::PluginAvailability; +use codex_protocol::config_types::CollaborationModeMask; use pretty_assertions::assert_eq; pub(super) async fn test_config() -> Config { @@ -136,9 +137,20 @@ 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::legacy_core::test_support::all_model_presets().clone(), + test_collaboration_mode_presets(), )) } +pub(super) fn test_collaboration_mode_presets() -> Vec { + crate::legacy_core::test_support::builtin_collaboration_mode_presets() + .into_iter() + .map(|mut preset| { + preset.developer_instructions = None; + preset + }) + .collect() +} + // --- Helpers for tests that need direct construction and event draining --- pub(super) async fn make_chatwidget_manual( model_override: Option<&str>, @@ -303,7 +315,7 @@ pub(crate) fn set_fast_mode_test_catalog(chat: &mut ChatWidget) { .map(Into::into) .collect(); - chat.model_catalog = Arc::new(ModelCatalog::new(models)); + chat.model_catalog = Arc::new(ModelCatalog::new(models, test_collaboration_mode_presets())); } pub(crate) async fn make_chatwidget_manual_with_sender() -> ( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a1bf28c807bf..3a255cfd69cf 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -68,7 +68,10 @@ async fn service_tier_commands_lowercase_catalog_names() { let mut preset = get_available_model(&chat, "gpt-5.4"); let expected_description = preset.service_tiers[0].description.clone(); preset.service_tiers[0].name = "Fast".to_string(); - chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new( + vec![preset], + test_collaboration_mode_presets(), + )); assert_eq!( chat.current_model_service_tier_commands(), diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index e0881bc9cf17..dc4cd8e89ad4 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,11 +1,11 @@ -use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use crate::model_catalog::ModelCatalog; -fn filtered_presets(_model_catalog: &ModelCatalog) -> Vec { - builtin_collaboration_mode_presets() +fn filtered_presets(model_catalog: &ModelCatalog) -> Vec { + model_catalog + .list_collaboration_modes() .into_iter() .filter(|mask| mask.mode.is_some_and(ModeKind::is_tui_visible)) .collect() diff --git a/codex-rs/tui/src/model_catalog.rs b/codex-rs/tui/src/model_catalog.rs index 69c9ded5669b..681d458132cf 100644 --- a/codex-rs/tui/src/model_catalog.rs +++ b/codex-rs/tui/src/model_catalog.rs @@ -1,17 +1,29 @@ +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelPreset; use std::convert::Infallible; #[derive(Debug, Clone)] pub(crate) struct ModelCatalog { models: Vec, + collaboration_modes: Vec, } impl ModelCatalog { - pub(crate) fn new(models: Vec) -> Self { - Self { models } + pub(crate) fn new( + models: Vec, + collaboration_modes: Vec, + ) -> Self { + Self { + models, + collaboration_modes, + } } pub(crate) fn try_list_models(&self) -> Result, Infallible> { Ok(self.models.clone()) } + + pub(crate) fn list_collaboration_modes(&self) -> Vec { + self.collaboration_modes.clone() + } }