diff --git a/codex-rs/exec/tests/suite/worktree.rs b/codex-rs/exec/tests/suite/worktree.rs index be472af2c7..d2c7afd5d9 100644 --- a/codex-rs/exec/tests/suite/worktree.rs +++ b/codex-rs/exec/tests/suite/worktree.rs @@ -414,8 +414,16 @@ fn worktree_rejects_disabled_features_and_ignored_config_before_allocation() -> vec!["--ignore-user-config", "prompt"], "--worktree cannot be combined with --ignore-user-config", ), - ("", vec!["prompt"], "--enable worktrees"), - ("", vec!["fork", "missing"], "--enable worktrees"), + ( + "features.worktrees = false", + vec!["prompt"], + "--enable worktrees", + ), + ( + "features.worktrees = false", + vec!["fork", "missing"], + "--enable worktrees", + ), ( "features.worktrees = true", vec!["-c", "features.worktrees=false", "prompt"], diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 0ae811690e..3b65a79183 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -1254,12 +1254,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Worktrees, key: "worktrees", - stage: Stage::Experimental { - name: "Worktrees", - menu_description: "Create isolated Git worktrees and group sessions by repository.", - announcement: "NEW: Worktrees can now be enabled from /experimental. Restart Codex after enabling it.", - }, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::RespectSystemProxy, diff --git a/codex-rs/tui/src/app/managed_worktree_creation.rs b/codex-rs/tui/src/app/managed_worktree_creation.rs index e17f9e4c73..f541836524 100644 --- a/codex-rs/tui/src/app/managed_worktree_creation.rs +++ b/codex-rs/tui/src/app/managed_worktree_creation.rs @@ -10,6 +10,28 @@ use crate::history_cell::McpInventoryLoadingCell as LoadingCell; use codex_app_server_protocol::ThreadBackgroundTerminalsListParams; use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse as ListResponse; +pub(super) fn background_terminals_blocker( + result: Result, + target: &AppServerTarget, +) -> Option<&'static str> { + match result { + Ok(response) if response.data.is_empty() => None, + Err(TypedRequestError::Server { source, .. }) + if matches!(target, AppServerTarget::LocalDaemon { .. }) + && (source.code == -32601 + || source.code == -32600 + && source.message.contains("thread/backgroundTerminals/list") + && (source.message.contains("unknown variant") + || source.message.contains("unknown method"))) => + { + Some( + "The local Codex service cannot check background terminals. Run `codex app-server daemon update`, then restart Codex.", + ) + } + _ => Some("Active background terminals block /cd."), + } +} + impl App { pub(super) async fn start_managed_worktree( &mut self, @@ -19,7 +41,7 @@ impl App { ) { if !self.config.features.enabled(Feature::Worktrees) { self.chat_widget.add_error_message( - "Enable worktrees in /experimental to create a worktree.".to_string(), + "Enable worktrees in your Codex configuration to create a worktree.".to_string(), ); } else if self.config.active_project.is_untrusted() { self.chat_widget.add_error_message( @@ -123,8 +145,9 @@ impl App { .request_handle() .request_typed::(request) .await; - if !matches!(result, Ok(response) if response.data.is_empty()) { - return self.working_directory_error("Active background terminals block /cd."); + if let Some(message) = background_terminals_blocker(result, &self.app_server_target) + { + return self.working_directory_error(message); } } let setup = async { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f8af487bbe..b5614c497f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -69,6 +69,8 @@ mod transcript_composer; mod turn_submission; #[path = "tests/user_verification_routes_tests.rs"] mod user_verification_routes; +#[path = "tests/worktree_background_terminals_tests.rs"] +mod worktree_background_terminals_tests; use super::*; use crate::app_backtrack::BacktrackSelection; diff --git a/codex-rs/tui/src/app/tests/turn_submission.rs b/codex-rs/tui/src/app/tests/turn_submission.rs index a81fdf02f1..6fd095fbb8 100644 --- a/codex-rs/tui/src/app/tests/turn_submission.rs +++ b/codex-rs/tui/src/app/tests/turn_submission.rs @@ -14,6 +14,7 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn worktree_creation_event_requires_feature() -> Result<()> { let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + app.config.features.disable(Feature::Worktrees)?; let mut tui = crate::tui::test_support::make_test_tui()?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; while events.try_recv().is_ok() {} @@ -35,7 +36,7 @@ async fn worktree_creation_event_requires_feature() -> Result<()> { _ => None, }) .expect("disabled feature message"); - insta::assert_snapshot!(message, @"■ Enable worktrees in /experimental to create a worktree."); + insta::assert_snapshot!(message, @"■ Enable worktrees in your Codex configuration to create a worktree."); assert!(!app.config.codex_home.join("worktrees").exists()); app_server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs b/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs new file mode 100644 index 0000000000..6f6e0e1db7 --- /dev/null +++ b/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse; +use pretty_assertions::assert_eq; + +#[test] +fn old_local_daemon_worktree_error_suggests_update() -> Result<()> { + let target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let missing_method = |code, message: &str| { + Err(TypedRequestError::Server { + method: "thread/backgroundTerminals/list".to_string(), + source: JSONRPCErrorError { + code, + message: message.to_string(), + data: None, + }, + }) + }; + let message = managed_worktree_creation::background_terminals_blocker( + missing_method(-32601, "method not found"), + &target, + ) + .expect("unsupported daemon must block worktree creation"); + let cell = crate::history_cell::new_error_event(message.to_string()); + insta::assert_snapshot!( + lines_to_single_string(&cell.display_lines(/*width*/ 120)), + @"■ The local Codex service cannot check background terminals. Run `codex app-server daemon update`, then restart Codex." + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + missing_method( + -32600, + "Invalid request: unknown variant `thread/backgroundTerminals/list`" + ), + &target, + ), + Some(message), + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + missing_method(-32600, "Invalid request: unknown variant `another/method`"), + &target, + ), + Some("Active background terminals block /cd."), + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + missing_method(-32601, "method not found"), + &AppServerTarget::Embedded, + ), + Some("Active background terminals block /cd."), + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + Ok(ThreadBackgroundTerminalsListResponse { + data: Vec::new(), + next_cursor: None, + }), + &target, + ), + None, + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 7f6375496e..bedcf8cdfa 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -317,8 +317,11 @@ impl App { }; let handle = app_server.request_handle(); let result = handle.request_typed::(request).await; - if !matches!(result, Ok(response) if response.data.is_empty()) { - return self.working_directory_error("Active background terminals block /cd."); + if let Some(message) = super::managed_worktree_creation::background_terminals_blocker( + result, + &self.app_server_target, + ) { + return self.working_directory_error(message); } } if is_new_worktree { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap index 1fdb880a0d..eff5dff649 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -11,8 +11,6 @@ expression: popup interactive website debugging and other inline JavaScript execution capabilities. [x] Shell tool Allow the model to run shell commands. - [ ] Worktrees Create isolated Git worktrees and group sessions by - repository. [ ] Voice conversations Talk with Codex using /voice. Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 6e538ad0c0..17ae637c2c 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3038,7 +3038,6 @@ async fn apps_popup_for_not_installed_app_uses_install_only_selected_description async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let worktrees = Feature::Worktrees.stage(); let voice = Feature::RealtimeConversation.stage(); let features = vec![ ExperimentalFeatureItem { @@ -3055,13 +3054,6 @@ async fn experimental_features_popup_snapshot() { description: "Allow the model to run shell commands.".to_string(), enabled: true, }, - ExperimentalFeatureItem { - key: Feature::Worktrees.key().to_string(), - writable: true, - name: worktrees.experimental_menu_name().unwrap().to_string(), - description: worktrees.experimental_menu_description().unwrap().to_string(), - enabled: false, - }, ExperimentalFeatureItem { key: Feature::RealtimeConversation.key().to_string(), writable: true, diff --git a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs index c60e12bc0e..b910f51339 100644 --- a/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/worktree_picker_tests.rs @@ -6,6 +6,7 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn slash_new_and_fork_offer_checkout_choices_inside_local_git_repository() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Worktrees, /*enabled*/ false); let checkout = tempdir().expect("temporary checkout"); std::fs::create_dir(checkout.path().join(".git")).expect("git directory"); std::fs::write(checkout.path().join(".git/HEAD"), "ref: refs/heads/main\n").expect("git HEAD"); diff --git a/codex-rs/tui/src/chatwidget/worktree_picker.rs b/codex-rs/tui/src/chatwidget/worktree_picker.rs index 7620aafdd1..ae2b01b6a9 100644 --- a/codex-rs/tui/src/chatwidget/worktree_picker.rs +++ b/codex-rs/tui/src/chatwidget/worktree_picker.rs @@ -82,7 +82,7 @@ impl ChatWidget { pub(super) fn show_managed_worktree_picker(&mut self) { if !self.config.features.enabled(Feature::Worktrees) { self.add_error_message( - "Enable worktrees in /experimental to create a worktree.".to_string(), + "Enable worktrees in your Codex configuration to create a worktree.".to_string(), ); return; } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 663b3e4875..5e922fb8ae 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -3265,6 +3265,9 @@ requires_openai_auth = {requires_openai_auth} }) .build() .await?; + config + .features + .set_enabled(codex_features::Feature::Worktrees, /*enabled*/ false)?; let model_provider = config.model_provider_id.as_str(); let project_thread_id = write_session_rollout( temp_dir.path(),