diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b832da2f80..7322488bad 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -791,6 +791,9 @@ "chronicle": { "type": "boolean" }, + "cli_daybreak": { + "type": "boolean" + }, "code_mode": { "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, @@ -6961,6 +6964,9 @@ "chronicle": { "type": "boolean" }, + "cli_daybreak": { + "type": "boolean" + }, "code_mode": { "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ac3c2c6aff..9899772fe2 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -875,7 +875,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { cyber_access_program, } = args; - if config.daybreak_enabled && !matches!(&command, Some(ExecCommand::Review(_))) { + if config.features.enabled(Feature::CliDaybreak) + && config.daybreak_enabled + && !matches!(&command, Some(ExecCommand::Review(_))) + { anyhow::ensure!( !oss || matches!( &command, @@ -1208,7 +1211,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { } => { let cyber_access_program = match cyber_access_program { Some(program) => Some(program), - None => { + None if config.features.enabled(Feature::CliDaybreak) => { daybreak::program_for_turn( &client, &mut request_ids, @@ -1218,6 +1221,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await? } + None => None, }; let response: TurnStartResponse = send_request_with_response( &client, diff --git a/codex-rs/exec/tests/suite/daybreak.rs b/codex-rs/exec/tests/suite/daybreak.rs index 2f021e4b9f..f9db9888da 100644 --- a/codex-rs/exec/tests/suite/daybreak.rs +++ b/codex-rs/exec/tests/suite/daybreak.rs @@ -56,7 +56,10 @@ async fn exec_rejects_daybreak_for_api_keys_and_allows_an_override() -> anyhow:: async fn exec_rejects_daybreak_for_non_openai_provider() -> anyhow::Result<()> { let test = test_codex_exec(); let server = responses::start_mock_server().await; - std::fs::write(test.home_path().join("config.toml"), "daybreak = true\n")?; + std::fs::write( + test.home_path().join("config.toml"), + "daybreak = true\nfeatures.cli_daybreak = true\n", + )?; let response = responses::mount_sse_once(&server, responses::sse_completed("response")).await; test.cmd_with_server(&server) @@ -110,7 +113,7 @@ async fn configured_daybreak_exec() -> anyhow::Result<(TestCodexExecBuilder, wir std::fs::write( test.home_path().join("config.toml"), format!( - "cli_auth_credentials_store = 'file'\nchatgpt_base_url = '{}/backend-api'\nmodel_catalog_json = '{}'\nmodel = 'gpt-test'\ndaybreak = true\n", + "cli_auth_credentials_store = 'file'\nchatgpt_base_url = '{}/backend-api'\nmodel_catalog_json = '{}'\nmodel = 'gpt-test'\ndaybreak = true\nfeatures.cli_daybreak = true\n", server.uri(), catalog.display() ), @@ -154,7 +157,7 @@ async fn exec_resumes_and_forks_the_saved_daybreak_choice() -> anyhow::Result<() std::fs::write( test.home_path().join("config.toml"), format!( - "cli_auth_credentials_store = 'file'\nchatgpt_base_url = '{}/backend-api'\nmodel_catalog_json = '{}'\nmodel = 'gpt-test'\n", + "cli_auth_credentials_store = 'file'\nchatgpt_base_url = '{}/backend-api'\nmodel_catalog_json = '{}'\nmodel = 'gpt-test'\nfeatures.cli_daybreak = true\n", server.uri(), test.home_path().join("catalog.json").display() ), @@ -168,19 +171,26 @@ async fn exec_resumes_and_forks_the_saved_daybreak_choice() -> anyhow::Result<() thread_id.as_str(), "continue", ], - "standard", + Some("standard"), ), ( vec!["resume", thread_id.as_str(), "continue"], - "daybreak_blue", + Some("daybreak_blue"), ), ( vec!["fork", thread_id.as_str(), "continue"], - "daybreak_blue", + Some("daybreak_blue"), ), ( - vec!["--ephemeral", "fork", thread_id.as_str(), "continue"], - "daybreak_blue", + vec![ + "-c", + "features.cli_daybreak=false", + "--ephemeral", + "fork", + thread_id.as_str(), + "continue", + ], + None, ), ( vec![ @@ -191,7 +201,7 @@ async fn exec_resumes_and_forks_the_saved_daybreak_choice() -> anyhow::Result<() thread_id.as_str(), "continue", ], - "standard", + Some("standard"), ), ] { let response = @@ -205,8 +215,8 @@ async fn exec_resumes_and_forks_the_saved_daybreak_choice() -> anyhow::Result<() .assert() .success(); assert_eq!( - response.single_request().body_json()["access_programs"], - json!({"cyber": expected}) + response.single_request().body_json().get("access_programs"), + expected.map(|program| json!({"cyber": program})).as_ref() ); } Ok(()) @@ -225,6 +235,8 @@ async fn exec_explicit_cyber_program_overrides_daybreak_for_one_turn() -> anyhow .args([ "--skip-git-repo-check", "--json", + "-c", + "features.cli_daybreak=false", "--cyber-access-program", "daybreak_red", "hello", @@ -266,14 +278,16 @@ async fn exec_resume_override_does_not_change_a_standard_thread() -> anyhow::Res "--skip-git-repo-check", "--json", "-c", + "features.cli_daybreak=false", + "-c", "daybreak=false", "hello", ]) .output()?; let thread_id = started_thread(output)?; assert_eq!( - response.single_request().body_json()["access_programs"], - json!({"cyber": "standard"}) + response.single_request().body_json().get("access_programs"), + None ); for (args, expected) in [ ( diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 55b36ea5dc..d1713bd384 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -98,6 +98,8 @@ pub enum Feature { ApiKeyModelDiscovery, /// Forward explicit programs with builtin OpenAI API keys. ApiKeyCyberAccessPrograms, + /// Enable Daybreak controls and automatic access-program selection in CLI clients. + CliDaybreak, /// Deprecated no-op; use `tui.fullscreen_transcript` instead. TranscriptV2, // Stable. @@ -1338,6 +1340,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::CliDaybreak, + key: "cli_daybreak", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ApiKeyCyberAccessPrograms, key: "api_key_cyber_access_programs", diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 6d79b7e0c3..e775a70e1c 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -622,11 +622,13 @@ impl App { app_server_client .thread_tool_transport() .configure(&mut thread_start_params); + let features = self.config.features.get().clone(); let task = tokio::spawn(async move { let response = crate::dynamic_tools::execute( request_handle, params, thread_start_params, + features, status_updates, Some(&app_event_tx), ) diff --git a/codex-rs/tui/src/app/daybreak.rs b/codex-rs/tui/src/app/daybreak.rs index 5287a738cb..89cc191e42 100644 --- a/codex-rs/tui/src/app/daybreak.rs +++ b/codex-rs/tui/src/app/daybreak.rs @@ -9,7 +9,13 @@ impl App { thread_id: ThreadId, enabled: bool, ) { - if self.active_thread_id != Some(thread_id) { + if !self + .chat_widget + .config_ref() + .features + .enabled(Feature::CliDaybreak) + || self.active_thread_id != Some(thread_id) + { return; } if enabled 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 8894121c94..18fbde768d 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -184,6 +184,7 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re crate::dynamic_tools_mcp::DynamicToolMcpServer::start( session.request_handle(), codex_app_server_protocol::ThreadStartParams::default(), + app.config.features.get().clone(), app.app_event_tx.clone(), app.dynamic_tool_status_updates.clone(), /*managed_requirement*/ None, diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs index 6d4af54fa3..926e70748a 100644 --- a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -39,12 +39,16 @@ fn error_notification( #[tokio::test] async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { - for (reject, preserve, daybreak) in [ - (false, false, false), - (false, true, true), - (true, false, false), + for (reject, preserve, daybreak, rollout_enabled) in [ + (false, false, false, true), + (false, true, true, true), + (true, false, false, true), + (false, false, false, false), + (false, true, true, false), ] { let (mut app, mut rx, _) = make_test_app_with_channels().await; + app.chat_widget + .set_feature_enabled(Feature::CliDaybreak, rollout_enabled); let (mut server, requests, proxy) = start_recording_app_server( &app.config, /*blocked_thread_list*/ None, @@ -226,7 +230,7 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(config.approvals_reviewer.into()), sandbox_policy: (!preserve).then(|| config.legacy_sandbox_policy().into()), - cyber_access_program: Some(if daybreak { + cyber_access_program: rollout_enabled.then_some(if daybreak { CyberAccessProgram::DaybreakBlue.into() } else { CyberAccessProgram::Standard.into() diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index eb7fb96ad0..1d72fae291 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -784,6 +784,8 @@ goals = true /*status_account_display*/ None, /*plan_type*/ None, /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ false, ); + app.chat_widget + .set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); app.chat_widget.set_daybreak_enabled(/*enabled*/ true); Box::pin(app.retry_safety_buffered_turn( &mut tui, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 8214c6882e..cd366ae49f 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -49,6 +49,9 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { use codex_protocol::turn_input::CyberAccessProgram; let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + app.config.features.enable(Feature::CliDaybreak)?; + app.chat_widget + .set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); let (mut server, requests, proxy) = start_recording_app_server( &app.config, /*blocked_thread_list*/ None, @@ -175,6 +178,18 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { assert_eq!(turns[0]["cyberAccessProgram"], "standard"); assert_eq!(turns[1]["cyberAccessProgram"], "daybreakBlue"); assert_eq!(turns[2]["cyberAccessProgram"], "standard"); + app.chat_widget + .set_feature_enabled(Feature::CliDaybreak, /*enabled*/ false); + for target in [thread_id, background_thread_id] { + app.submit_thread_op(&mut server, target, turn.clone()) + .await?; + } + let turns = recorded_params(&requests, "turn/start"); + assert!(turns[3]["cyberAccessProgram"].is_null()); + assert!(turns[4]["cyberAccessProgram"].is_null()); + app.chat_widget + .set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); + app.chat_widget.set_daybreak_enabled(/*enabled*/ true); app.chat_widget.update_account_state( Some(crate::status::StatusAccountDisplay::ApiKey), /*plan_type*/ None, @@ -190,8 +205,8 @@ async fn daybreak_command_persists_and_confirms_each_selection() -> Result<()> { app.chat_widget.set_daybreak_enabled(/*enabled*/ false); app.submit_thread_op(&mut server, thread_id, turn).await?; let turns = recorded_params(&requests, "turn/start"); - assert_eq!(turns[3]["cyberAccessProgram"], "daybreakBlue"); - assert!(turns[4]["cyberAccessProgram"].is_null()); + assert_eq!(turns[5]["cyberAccessProgram"], "daybreakBlue"); + assert!(turns[6]["cyberAccessProgram"].is_null()); while events.try_recv().is_ok() {} let missing_thread_id = ThreadId::new(); @@ -1004,11 +1019,13 @@ fn spawn_approved_task_tool_call( app_server .thread_tool_transport() .configure(&mut thread_start_params); + let features = app.config.features.get().clone(); tokio::spawn(async move { let response = crate::dynamic_tools::execute( request_handle, params, thread_start_params, + features, status_updates, Some(&app_event_tx), ) @@ -1772,7 +1789,15 @@ async fn embedded_server_rejects_unowned_dynamic_tool_calls() -> Result<()> { #[tokio::test] async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespace() -> Result<()> { + check_dynamic_tool_requests(/*rollout_enabled*/ true).await?; + check_dynamic_tool_requests(/*rollout_enabled*/ false).await +} + +async fn check_dynamic_tool_requests(rollout_enabled: bool) -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config + .features + .set_enabled(Feature::CliDaybreak, rollout_enabled)?; let codex_home = tempdir()?; let backend = wiremock::MockServer::start().await; let backend_url = format!("{}/backend-api", backend.uri()); @@ -2085,7 +2110,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac ); assert_eq!( recorded_params(&requests, "thread/start").last().unwrap()["daybreakEnabled"], - true + serde_json::json!(rollout_enabled.then_some(true)) ); assert_eq!( recorded_params(&requests, "thread/start") @@ -2100,7 +2125,10 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac let turn = recorded_params(&requests, "turn/start") .pop() .expect("background task turn request"); - assert_eq!(turn["cyberAccessProgram"], "daybreakBlue"); + assert_eq!( + turn["cyberAccessProgram"], + serde_json::json!(rollout_enabled.then_some("daybreakBlue")) + ); assert_eq!(turn["input"], serde_json::json!([])); assert_eq!( turn["toolOutput"], @@ -2182,7 +2210,10 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac }; assert!(response.success, "{response:?}"); let turn = &recorded_params(&requests, "turn/start")[1]; - assert_eq!(turn["cyberAccessProgram"], "daybreakBlue"); + assert_eq!( + turn["cyberAccessProgram"], + serde_json::json!(rollout_enabled.then_some("daybreakBlue")) + ); assert_eq!(turn["input"], serde_json::json!([])); assert_eq!( turn["toolOutput"], @@ -2686,6 +2717,7 @@ async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Re }), }, codex_app_server_protocol::ThreadStartParams::default(), + app.config.features.get().clone(), status_updates, /*app_event_tx*/ None, ) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 021f11af57..44fb8373d7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -547,6 +547,7 @@ impl AppServerSession { DynamicToolMcpServer::start( self.request_handle(), thread_start_params, + config.features.get().clone(), app_event_tx, status_updates, managed_requirement, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index a25db18fb3..97c7de5be3 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -72,7 +72,8 @@ impl ChatWidget { let connector_scope_changed = previous_thread_id != Some(session.thread_id) || self.config.cwd.as_path() != session.cwd.as_path(); self.thread_id = Some(session.thread_id); - self.daybreak_enabled = session.daybreak_enabled + self.daybreak_enabled = self.config.features.enabled(Feature::CliDaybreak) + && session.daybreak_enabled && !matches!(display, SessionConfiguredDisplay::SideConversation); #[cfg(target_os = "windows")] if self.windows_sandbox_local_server diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index c612d098b0..c85b798769 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -6,14 +6,15 @@ use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID; impl ChatWidget { pub(crate) fn set_daybreak_enabled(&mut self, enabled: bool) { - self.daybreak_enabled = enabled; + self.daybreak_enabled = enabled && self.config.features.enabled(Feature::CliDaybreak); self.bottom_pane .set_daybreak_command_description(self.daybreak_command_description()); } /// UI eligibility only; the catalog and server still determine model/program access. pub(crate) fn daybreak_account_eligible(&self) -> bool { - self.config.model_provider_id == "openai" + self.config.features.enabled(Feature::CliDaybreak) + && self.config.model_provider_id == "openai" && (self.has_chatgpt_account || matches!( self.status_account_display, @@ -111,6 +112,10 @@ impl ChatWidget { ); } let enabled = self.config.features.enabled(feature); + if feature == Feature::CliDaybreak { + self.set_daybreak_enabled(self.daybreak_enabled); + self.refresh_status_surfaces(); + } if feature == Feature::FastMode { self.refresh_effective_service_tier(); self.sync_service_tier_commands(); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 967c1aa087..d45776cbb9 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -189,6 +189,9 @@ impl ChatWidget { match cmd { SlashCommand::Daybreak => { + if !self.config.features.enabled(Feature::CliDaybreak) { + return; + } if !self.daybreak_enabled { if !self.daybreak_account_eligible() { self.add_error_message("Daybreak requires the OpenAI provider with either ChatGPT sign-in or an API key with Daybreak support enabled.".into()); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index dd9415967c..25dd196a46 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -19,7 +19,7 @@ async fn resumed_initial_messages_render_history() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let configured = crate::session_state::ThreadSessionState { + let mut configured = crate::session_state::ThreadSessionState { daybreak_enabled: true, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -43,6 +43,18 @@ async fn resumed_initial_messages_render_history() { rollout_path: Some(rollout_file.path().to_path_buf()), }; + chat.handle_thread_session(configured.clone()); + assert!(!chat.daybreak_enabled); + insta::assert_snapshot!( + drain_insert_history(&mut rx).into_iter().flatten() + .map(|line| line.to_string()) + .filter(|line| line.contains("Daybreak")) + .collect::>().join("\n"), + @"" + ); + + chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); + configured.thread_id = ThreadId::new(); chat.handle_thread_session(configured); assert!(chat.daybreak_enabled); replay_user_message_text( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index f6b7774ef5..439a368784 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2168,6 +2168,7 @@ async fn slash_copy_picker_remains_available_from_parent_owned_threads() { #[tokio::test] async fn slash_daybreak_offers_an_application_when_unavailable() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); chat.has_chatgpt_account = true; chat.config.model_provider_id = "openai".into(); let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); @@ -2250,6 +2251,12 @@ async fn slash_daybreak_offers_an_application_when_unavailable() { .unwrap() ); } + chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ false); + chat.set_daybreak_enabled(/*enabled*/ true); + assert!(!chat.daybreak_enabled); + assert!(chat.daybreak_command_description().is_none()); + chat.dispatch_command(SlashCommand::Daybreak); + assert!(drain_insert_history(&mut rx).is_empty()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 8a6681ad61..523722086e 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -148,6 +148,7 @@ async fn daybreak_refusal_offers_enable_for_the_next_turn() { FrameRequester::test_dummy(), ) .await; + chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone(); diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 86fb1cbd1c..8c9358de94 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -446,6 +446,7 @@ async fn status_line_setup_popup_rate_limits_snapshot() { #[tokio::test] async fn status_line_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); chat.status_line_branch = Some("feature/mixed-preview".to_string()); chat.thread_name = Some("Mixed preview thread".to_string()); chat.set_daybreak_enabled(/*enabled*/ true); @@ -500,6 +501,7 @@ async fn terminal_title_setup_popup_hardcoded_only_snapshot() { #[tokio::test] async fn terminal_title_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true); chat.thread_name = Some("Mixed preview thread".to_string()); chat.set_daybreak_enabled(/*enabled*/ true); chat.local_settings.tui.terminal_title = Some(vec![ diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index c2a3fa8d15..547a8264bf 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -54,6 +54,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnToolOutput; use codex_app_server_protocol::UserInput; +use codex_features::Feature; +use codex_features::Features; use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputBody; use serde::Deserialize; @@ -290,6 +292,7 @@ pub(crate) async fn execute( request_handle: AppServerRequestHandle, params: DynamicToolCallParams, thread_start_params: ThreadStartParams, + features: Features, status_updates: broadcast::Receiver, app_event_tx: Option<&AppEventSender>, ) -> DynamicToolCallResponse { @@ -297,6 +300,7 @@ pub(crate) async fn execute( request_handle, params, thread_start_params, + features, status_updates, app_event_tx, ) @@ -320,6 +324,7 @@ async fn execute_inner( handle: AppServerRequestHandle, params: DynamicToolCallParams, mut thread_start_params: ThreadStartParams, + features: Features, mut status_updates: broadcast::Receiver, app_event_tx: Option<&AppEventSender>, ) -> Result { @@ -485,7 +490,9 @@ async fn execute_inner( thread_start_params.cwd = Some(source_thread.cwd.to_string_lossy().into_owned()); thread_start_params.project_id = source_thread.project_id.clone(); thread_start_params.ephemeral = Some(source_thread.ephemeral); - if thread_start_params.daybreak_enabled.is_none() { + if features.enabled(Feature::CliDaybreak) + && thread_start_params.daybreak_enabled.is_none() + { let defaults = crate::config_update::read_effective_config( handle.clone(), source_thread.cwd.to_string_lossy().into_owned(), @@ -554,6 +561,7 @@ async fn execute_inner( let daybreak_enabled = started.thread.daybreak_enabled.unwrap_or(false); register_background_thread(app_event_tx, started.thread, task_tools_available).await?; let cyber_access_program = background_turn_program( + &features, &handle, &started.model, &started.model_provider, @@ -704,6 +712,7 @@ async fn execute_inner( ) .await?; let cyber_access_program = background_turn_program( + &features, &handle, arguments.model.as_deref().unwrap_or(&resumed.model), &resumed.model_provider, @@ -1247,11 +1256,15 @@ async fn start_turn( } async fn background_turn_program( + features: &Features, handle: &AppServerRequestHandle, model: &str, provider: &str, enabled: bool, ) -> Result, String> { + if !features.enabled(Feature::CliDaybreak) { + return Ok(None); + } let (account, models) = tokio::join!( request::(handle, |request_id| ClientRequest::GetAccount { request_id, diff --git a/codex-rs/tui/src/dynamic_tools_mcp.rs b/codex-rs/tui/src/dynamic_tools_mcp.rs index f683204879..67fed87b4c 100644 --- a/codex-rs/tui/src/dynamic_tools_mcp.rs +++ b/codex-rs/tui/src/dynamic_tools_mcp.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_config::McpServerConfig; use codex_config::McpServerRequirement; use codex_config::RawMcpServerConfig; +use codex_features::Features; use rmcp::ErrorData as McpError; use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParams; @@ -139,6 +140,7 @@ impl DynamicToolMcpServer { pub(crate) async fn start( request_handle: AppServerRequestHandle, mut thread_start_params: ThreadStartParams, + features: Features, app_event_tx: AppEventSender, status_updates: broadcast::Sender, managed_requirement: Option<&McpServerRequirement>, @@ -180,6 +182,7 @@ impl DynamicToolMcpServer { let handler = DynamicToolMcpHandler { connection: Arc::clone(&connection), thread_start_params, + features, status_updates, server_config: server_config.clone(), services, @@ -236,6 +239,7 @@ async fn require_authorization( struct DynamicToolMcpHandler { connection: ToolConnection, thread_start_params: ThreadStartParams, + features: Features, status_updates: broadcast::Sender, server_config: Value, services: ToolServices, @@ -370,6 +374,7 @@ impl ServerHandler for DynamicToolMcpHandler { request_handle, params, thread_start_params, + self.features.clone(), self.status_updates.subscribe(), Some(&app_event_tx), ) diff --git a/codex-rs/tui/src/dynamic_tools_tests.rs b/codex-rs/tui/src/dynamic_tools_tests.rs index d80c9e6bb5..95ed8a3df2 100644 --- a/codex-rs/tui/src/dynamic_tools_tests.rs +++ b/codex-rs/tui/src/dynamic_tools_tests.rs @@ -125,6 +125,7 @@ async fn call_tool( ephemeral: Some(true), ..ThreadStartParams::default() }, + Features::with_defaults(), status_receiver, /*app_event_tx*/ None, )