From 02aa303f1148ee3f8dad1131279ec05b8240425c Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Tue, 28 Jul 2026 01:06:48 +1000 Subject: [PATCH 1/9] Search: gate the AI Answers toggle on the Jetpack AI master switch --- .../add-search-ai-answers-master-gate | 4 + .../packages/search/src/class-ai-answers.php | 102 ++++++++++++++++- projects/packages/search/src/class-helper.php | 1 + .../search/src/class-rest-controller.php | 12 ++ .../packages/search/src/class-settings.php | 20 ++-- .../components/sidebar/sidebar-options.jsx | 20 +++- .../components/ai-answers-tab/index.jsx | 47 +++++++- .../components/test/ai-answers-tab.test.jsx | 41 +++++++ .../dashboard/hooks/use-search-settings.js | 13 ++- .../store/selectors/jetpack-settings.js | 7 ++ .../search/tests/php/AI_Answers_Test.php | 105 ++++++++++++++++++ .../php/Initial_Javascript_State_Test.php | 38 +++++++ .../search/tests/php/REST_Controller_Test.php | 95 ++++++++++++++++ .../search/tests/php/Settings_Test.php | 46 ++++++++ .../packages/search/tests/php/bootstrap.php | 10 ++ .../tests/php/trait-toggles-ai-master.php | 73 ++++++++++++ 16 files changed, 610 insertions(+), 24 deletions(-) create mode 100644 projects/packages/search/changelog/add-search-ai-answers-master-gate create mode 100644 projects/packages/search/tests/php/Initial_Javascript_State_Test.php create mode 100644 projects/packages/search/tests/php/trait-toggles-ai-master.php diff --git a/projects/packages/search/changelog/add-search-ai-answers-master-gate b/projects/packages/search/changelog/add-search-ai-answers-master-gate new file mode 100644 index 000000000000..982adbf4afce --- /dev/null +++ b/projects/packages/search/changelog/add-search-ai-answers-master-gate @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +AI Answers: Keep the toggle unavailable while Jetpack AI is off, in internal testing environments ahead of release. diff --git a/projects/packages/search/src/class-ai-answers.php b/projects/packages/search/src/class-ai-answers.php index ff3790537176..8f93851bf03d 100644 --- a/projects/packages/search/src/class-ai-answers.php +++ b/projects/packages/search/src/class-ai-answers.php @@ -7,6 +7,9 @@ namespace Automattic\Jetpack\Search; +use Automattic\Jetpack\Modules; +use Automattic\Jetpack\Status\Host; + /** * Registers behavior meta on the Gutenberg Guidelines CPT and exposes the * jetpack_search_ai_answers_enabled option. @@ -14,6 +17,8 @@ class AI_Answers { const BEHAVIOR_META_KEY = '_guideline_block_jetpack_search-ai-summary'; const BEHAVIOR_OPTION_KEY = 'jetpack_search_ai_behavior_instructions'; + const AI_MODULE = 'ai'; + const AI_MASTER_OPTION = 'jetpack_ai_enabled'; /** * Hook up meta/setting registration. @@ -84,13 +89,108 @@ public static function get_behavior_instructions() { return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' ); } + /** + * Whether the site-wide Jetpack AI master switch is on. + * + * Mirrors `Jetpack_AI_Settings::is_master_enabled()` in the Jetpack plugin, + * which is the source of truth: this package ships in standalone plugins and + * cannot reference that class. The answer is computed rather than filtered so + * no plugin can flip a gate that must hold — the same reasoning as + * `Search_Blocks::supports_paid_search()`. + * + * The master lives in a different place depending on the platform. On + * WordPress.com Simple no Jetpack modules run, so the `jetpack_ai_enabled` + * option is the master. Everywhere else the `ai` module is, toggled through + * the standard Jetpack module machinery. + * + * @since $$next-version$$ + * + * @return bool True when Jetpack AI is on, or when the site has no master switch. + */ + public static function is_master_enabled() { + // The master switch is still an internal-only surface: both the AI settings + // view (`showFeaturesView`) and My Jetpack's module toggle + // (`showAiModuleToggle`) are gated on jetpack_is_internal_testing_environment(). + // Gating a public site on a switch its owner cannot see would strand the + // toggle behind an unexplainable notice, so the gate stays inert outside A8C + // testing environments — and outside the Jetpack plugin, where the helper and + // the master both live. + if ( ! function_exists( 'jetpack_is_internal_testing_environment' ) || ! jetpack_is_internal_testing_environment() ) { + return true; + } + + if ( ( new Host() )->is_wpcom_simple() ) { + return (bool) get_option( self::AI_MASTER_OPTION, true ); + } + + $modules = new Modules(); + + // Without the Jetpack plugin — a standalone Jetpack Search install — the + // `ai` module is not registered, so is_active() would report false for a + // master switch that was never installed. Don't gate those sites. + if ( ! in_array( self::AI_MODULE, $modules->get_available(), true ) ) { + return true; + } + + return $modules->is_active( self::AI_MODULE ); + } + + /** + * Keep the AI Answers option from being turned on while the master switch is off. + * + * Registered as the setting's sanitize_callback, so it covers writes that go + * straight to `/wp/v2/settings` — the path the Customberg sidebar uses — + * without passing through the Search REST controller. + * + * Blocking a write is not the same as clearing the setting: `update_option()` + * sanitizes before it reads the old value, so returning false here would + * overwrite a saved choice rather than leave it alone. The AI feature settings + * endpoint writes feature choices while the master is off precisely so they + * survive it, so keep the stored value instead. Turning the feature off stays + * allowed. + * + * @since $$next-version$$ + * + * @param mixed $value Incoming setting value. + * @return bool + */ + public static function sanitize_enabled_setting( $value ) { + if ( ! $value ) { + return false; + } + + if ( ! self::is_master_enabled() ) { + return self::is_saved_on(); + } + + return true; + } + + /** + * The stored AI Answers choice, ignoring every gate. + * + * The dashboard shows this while the master switch is off, so a saved choice + * isn't misreported back to the user as off. + * + * @since $$next-version$$ + * + * @return bool + */ + public static function is_saved_on() { + return (bool) get_option( 'jetpack_search_ai_answers_enabled', false ); + } + /** * Whether AI Answers is enabled for the current site. */ public static function is_enabled() { - return (bool) apply_filters( + $enabled = (bool) apply_filters( 'jetpack_search_ai_answers_enabled', (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) ); + + // The master gate is applied after the filter chain so it cannot be + // filtered back on, matching `Jetpack_AI_Settings::is_ai_enabled()`. + return $enabled && self::is_master_enabled(); } } diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index f99c5fe1d6cb..5eb697e5224b 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -992,6 +992,7 @@ public static function generate_initial_javascript_state() { */ 'disableTracking' => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ), 'aiAnswersEnabled' => AI_Answers::is_enabled(), + 'aiMasterEnabled' => AI_Answers::is_master_enabled(), ); /** diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php index fbbcefe51477..952c19035249 100644 --- a/projects/packages/search/src/class-rest-controller.php +++ b/projects/packages/search/src/class-rest-controller.php @@ -388,6 +388,16 @@ protected function validate_search_settings( $module_active, $instant_search_ena ); } + // AI Answers cannot be turned on while the site-wide Jetpack AI switch is + // off. Turning it off stays allowed, so a saved choice can still be cleared. + if ( true === $ai_answers_enabled && ! AI_Answers::is_master_enabled() ) { + return new WP_Error( + 'rest_invalid_arguments', + esc_html__( 'AI Answers cannot be enabled while Jetpack AI is turned off for this site.', 'jetpack-search-pkg' ), + array( 'status' => 400 ) + ); + } + // `experience` is the canonical source of truth and writes the legacy booleans in lockstep. // Reject requests that mix it with any other settings field so callers don't silently // lose those fields — the `experience` branch in update_settings() early-returns and @@ -442,6 +452,8 @@ public function get_settings() { 'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(), 'experience' => $this->search_module->get_experience(), 'ai_answers_enabled' => AI_Answers::is_enabled(), + 'ai_answers_saved' => AI_Answers::is_saved_on(), + 'ai_master_enabled' => AI_Answers::is_master_enabled(), 'search_suggestions_enabled' => (bool) get_option( 'jetpack_search_suggestions_enabled', false ), 'override_woocommerce_search_template' => Search_Blocks::woocommerce_search_template_override_enabled(), ); diff --git a/projects/packages/search/src/class-settings.php b/projects/packages/search/src/class-settings.php index d05f9ed0ccb2..10e7e734e574 100644 --- a/projects/packages/search/src/class-settings.php +++ b/projects/packages/search/src/class-settings.php @@ -52,19 +52,21 @@ public function settings_register() { array( $setting_prefix . 'show_post_date', 'boolean', true ), array( $setting_prefix . 'show_product_price', 'boolean', true ), array( $setting_prefix . 'show_powered_by', 'boolean', true ), - array( $setting_prefix . 'ai_answers_enabled', 'boolean', false ), + array( $setting_prefix . 'ai_answers_enabled', 'boolean', false, array( AI_Answers::class, 'sanitize_enabled_setting' ) ), array( $setting_prefix . 'suggestions_enabled', 'boolean', false ), ); foreach ( $settings as $value ) { - register_setting( - 'options', - $value[0], - array( - 'default' => $value[2], - 'show_in_rest' => true, - 'type' => $value[1], - ) + $args = array( + 'default' => $value[2], + 'show_in_rest' => true, + 'type' => $value[1], ); + // Optional fourth element: a sanitize_callback, for settings that carry + // a gate of their own. + if ( isset( $value[3] ) ) { + $args['sanitize_callback'] = $value[3]; + } + register_setting( 'options', $value[0], $args ); } } } diff --git a/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx b/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx index b32c56a655f5..430bf94eb7d6 100644 --- a/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx +++ b/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx @@ -15,7 +15,7 @@ import ColorControl from './color-control'; import ExcludedPostTypesControl from './excluded-post-types-control'; import ThemeControl from './theme-control'; -const { isFreePlan = false } = window[ SERVER_OBJECT_NAME ]; +const { isFreePlan = false, aiMasterEnabled = true } = window[ SERVER_OBJECT_NAME ]; /** * Customization/configuration tab for the sidebar. @@ -212,12 +212,20 @@ export default function SidebarOptions() { diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx index 2305baffad0d..712685a1cfb9 100644 --- a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx +++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx @@ -24,7 +24,17 @@ export default function AiAnswersTab() { const blogID = useSelect( select => select( STORE_ID ).getBlogId(), [] ); const siteAdminUrl = useSelect( select => select( STORE_ID ).getSiteAdminUrl(), [] ); - const { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled } = useSearchSettings(); + const { + isAiAnswersEnabled, + isInstantSearchEnabled, + isAiMasterEnabled, + isAiAnswersSaved, + setAiAnswersEnabled, + } = useSearchSettings(); + + // While the master switch is off the reported value is gated to false, so show + // the saved choice instead of misreporting it back as off. + const isToggleChecked = isAiMasterEnabled ? isAiAnswersEnabled : isAiAnswersSaved; const { run: sendToCart } = useProductCheckoutWorkflow( { productSlug: 'jetpack_search', @@ -94,7 +104,20 @@ export default function AiAnswersTab() { className="jp-search-ai-answers-tab__settings-inner lg-col-span-8 md-col-span-6 sm-col-span-4" > { isLoading &&

{ __( 'Loading…', 'jetpack-search-pkg' ) }

} - { supportsInstantSearch && ! isInstantSearchEnabled && ( + { supportsInstantSearch && ! isAiMasterEnabled && ( + + + { __( 'Jetpack AI is turned off for this site.', 'jetpack-search-pkg' ) } + + + { __( + 'Your AI Answers setting is saved and will apply again when AI is turned back on.', + 'jetpack-search-pkg' + ) } + + + ) } + { supportsInstantSearch && isAiMasterEnabled && ! isInstantSearchEnabled && ( { __( @@ -109,10 +132,12 @@ export default function AiAnswersTab() { ) } { ! isLoading && ! isUnavailable && ( @@ -124,13 +149,23 @@ export default function AiAnswersTab() { onChange={ setContent } placeholder={ DEFAULT_PERSONALITY } rows={ 10 } - disabled={ isSaving || ! isAiAnswersEnabled || ! isInstantSearchEnabled } + disabled={ + isSaving || + ! isAiMasterEnabled || + ! isAiAnswersEnabled || + ! isInstantSearchEnabled + } />
diff --git a/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx index 13b24e41386a..56c8b5a6a398 100644 --- a/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx +++ b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx @@ -81,12 +81,16 @@ const mockUpdateJetpackSettings = jest.fn(); * @param {boolean} root0.isInstantSearchEnabled - Whether instant search is enabled. * @param {boolean} root0.isFreePlan - Whether the site is on a free plan. * @param {boolean} root0.isAiAnswersEnabled - Whether AI Answers is enabled. + * @param {boolean} root0.isAiMasterEnabled - Whether the site-wide Jetpack AI switch is on. + * @param {boolean} root0.isAiAnswersSaved - The saved AI Answers choice, ignoring gates. */ function setupStore( { supportsInstantSearch = true, isInstantSearchEnabled = true, isFreePlan = false, isAiAnswersEnabled = false, + isAiMasterEnabled = true, + isAiAnswersSaved = false, } = {} ) { useDispatch.mockReturnValue( { updateJetpackSettings: mockUpdateJetpackSettings, @@ -97,6 +101,8 @@ function setupStore( { isInstantSearchEnabled: () => isInstantSearchEnabled, isFreePlan: () => isFreePlan, isAiAnswersEnabled: () => isAiAnswersEnabled, + isAiMasterEnabled: () => isAiMasterEnabled, + isAiAnswersSaved: () => isAiAnswersSaved, getBlogId: () => 1, getSiteAdminUrl: () => 'http://example.com/wp-admin/', } ) ) @@ -172,4 +178,39 @@ describe( 'AiAnswersTab', () => { const toggle = await screen.findByRole( 'checkbox' ); expect( toggle ).toBeDisabled(); } ); + + it( 'toggle is disabled when the site-wide AI switch is off', async () => { + // The back end reports ai_answers_enabled as the gated value, so it is + // false whenever the master is off — the saved choice comes separately. + setupStore( { isAiMasterEnabled: false, isAiAnswersEnabled: false, isAiAnswersSaved: true } ); + render( ); + const toggle = await screen.findByRole( 'checkbox' ); + expect( toggle ).toBeDisabled(); + } ); + + it( 'toggle keeps showing a saved choice while the site-wide AI switch is off', async () => { + setupStore( { isAiMasterEnabled: false, isAiAnswersEnabled: false, isAiAnswersSaved: true } ); + render( ); + const toggle = await screen.findByRole( 'checkbox' ); + expect( toggle ).toBeChecked(); + } ); + + it( 'explains why the toggle is unavailable when the site-wide AI switch is off', async () => { + setupStore( { isAiMasterEnabled: false } ); + render( ); + await expect( + screen.findByText( 'Jetpack AI is turned off for this site.' ) + ).resolves.toBeInTheDocument(); + expect( screen.getByText( /will apply again when AI is turned back on/ ) ).toBeInTheDocument(); + } ); + + it( 'does not explain the site-wide AI switch when it is on', async () => { + setupStore( { isAiMasterEnabled: true } ); + render( ); + await waitFor( () => { + expect( + screen.queryByText( 'Jetpack AI is turned off for this site.' ) + ).not.toBeInTheDocument(); + } ); + } ); } ); diff --git a/projects/packages/search/src/dashboard/hooks/use-search-settings.js b/projects/packages/search/src/dashboard/hooks/use-search-settings.js index 8966bb4578e0..2e7ee52ce9da 100644 --- a/projects/packages/search/src/dashboard/hooks/use-search-settings.js +++ b/projects/packages/search/src/dashboard/hooks/use-search-settings.js @@ -6,7 +6,7 @@ import { STORE_ID } from 'store'; * Provides AI Answers and Instant Search settings from the store, * along with a dispatcher for updating them. * - * @return {{ isAiAnswersEnabled: boolean, isInstantSearchEnabled: boolean, setAiAnswersEnabled: Function }} Settings state and updater. + * @return {{ isAiAnswersEnabled: boolean, isInstantSearchEnabled: boolean, isAiMasterEnabled: boolean, isAiAnswersSaved: boolean, setAiAnswersEnabled: Function }} Settings state and updater. */ export default function useSearchSettings() { const isAiAnswersEnabled = useSelect( select => select( STORE_ID ).isAiAnswersEnabled(), [] ); @@ -15,6 +15,9 @@ export default function useSearchSettings() { [] ); + const isAiMasterEnabled = useSelect( select => select( STORE_ID ).isAiMasterEnabled(), [] ); + const isAiAnswersSaved = useSelect( select => select( STORE_ID ).isAiAnswersSaved(), [] ); + const { updateJetpackSettings } = useDispatch( STORE_ID ); const setAiAnswersEnabled = useCallback( @@ -22,5 +25,11 @@ export default function useSearchSettings() { [ updateJetpackSettings ] ); - return { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled }; + return { + isAiAnswersEnabled, + isInstantSearchEnabled, + isAiMasterEnabled, + isAiAnswersSaved, + setAiAnswersEnabled, + }; } diff --git a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js index ea779b5b08c7..6a9ca8515e4e 100644 --- a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js +++ b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js @@ -22,6 +22,13 @@ const jetpackSettingSelectors = { Object.prototype.hasOwnProperty.call( state.jetpackSettings, 'reader_chat' ), isReaderChatEnabled: state => state.jetpackSettings.reader_chat, isAiAnswersEnabled: state => !! state.jetpackSettings.ai_answers_enabled, + // The stored choice, ungated — shown while the master switch is off so a + // saved setting isn't misreported back to the user as off. + isAiAnswersSaved: state => + !! ( state.jetpackSettings.ai_answers_saved ?? state.jetpackSettings.ai_answers_enabled ), + // Treat a missing value as on, so a back end that predates the field doesn't + // gate the toggle. + isAiMasterEnabled: state => state.jetpackSettings.ai_master_enabled !== false, isSearchSuggestionsEnabled: state => !! state.jetpackSettings.search_suggestions_enabled, isWooCommerceSearchTemplateOverrideEnabled: state => !! state.jetpackSettings.override_woocommerce_search_template, diff --git a/projects/packages/search/tests/php/AI_Answers_Test.php b/projects/packages/search/tests/php/AI_Answers_Test.php index c311e5645eda..dea432369414 100644 --- a/projects/packages/search/tests/php/AI_Answers_Test.php +++ b/projects/packages/search/tests/php/AI_Answers_Test.php @@ -7,12 +7,15 @@ namespace Automattic\Jetpack\Search; +use Automattic\Jetpack\Constants; use Automattic\Jetpack\Search\TestCase as Search_TestCase; /** * Unit tests for the AI_Answers class. */ class AI_Answers_Test extends Search_TestCase { + use Toggles_Ai_Master; + public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); ( new AI_Answers() )->init(); @@ -36,6 +39,10 @@ public function tearDown(): void { parent::tearDown(); + $this->remove_ai_master_filters(); + unset( $GLOBALS['jetpack_search_test_internal_env'] ); + Constants::clear_single_constant( 'IS_WPCOM' ); + if ( $this->posts_query_filter !== null ) { remove_filter( 'posts_pre_query', $this->posts_query_filter, 10 ); $this->posts_query_filter = null; @@ -61,6 +68,104 @@ public function test_is_enabled_filter_overrides_option() { remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); } + // ------------------------------------------------------------------------- + // Tests for the site-wide Jetpack AI master switch + // ------------------------------------------------------------------------- + + /** + * Off Simple, the `ai` module is the master switch. + */ + public function test_is_master_enabled_is_true_when_the_ai_module_is_active() { + $this->turn_ai_master_on(); + + $this->assertTrue( AI_Answers::is_master_enabled() ); + } + + public function test_is_master_enabled_is_false_when_the_ai_module_is_inactive() { + $this->turn_ai_master_off(); + + $this->assertFalse( AI_Answers::is_master_enabled() ); + } + + public function test_is_master_enabled_is_true_when_the_ai_module_is_not_registered() { + // Standalone Jetpack Search plugin: no Jetpack plugin, so no `ai` module and + // no master switch to obey. Sites that never had one must not be gated. + $this->assertTrue( AI_Answers::is_master_enabled() ); + } + + /** + * Turn the Simple master off. + * + * Stores the empty string rather than `false`, which is what WordPress + * persists for a false option — and what WorDBless can round-trip, since it + * returns the default for a stored `false`. + */ + private function disable_simple_master() { + update_option( AI_Answers::AI_MASTER_OPTION, '' ); + } + + public function test_is_master_enabled_reads_the_option_on_wpcom_simple() { + Constants::set_constant( 'IS_WPCOM', true ); + $this->disable_simple_master(); + + $this->assertFalse( AI_Answers::is_master_enabled() ); + } + + public function test_is_master_enabled_defaults_to_true_on_wpcom_simple() { + Constants::set_constant( 'IS_WPCOM', true ); + + $this->assertTrue( AI_Answers::is_master_enabled() ); + } + + public function test_is_master_enabled_ignores_the_ai_module_on_wpcom_simple() { + // Modules never run on Simple, and Modules::is_active() answers true there + // unconditionally — the option stays the master. + Constants::set_constant( 'IS_WPCOM', true ); + $this->turn_ai_master_on(); + $this->disable_simple_master(); + + $this->assertFalse( AI_Answers::is_master_enabled() ); + } + + public function test_is_master_enabled_is_true_outside_internal_testing_environments() { + // The master switch UI ships internal-only for now, so a public site must not be + // gated on a switch its owner cannot see. Module present but inactive, yet ungated. + $this->register_ai_module(); + $GLOBALS['jetpack_search_test_internal_env'] = false; + + $this->assertTrue( AI_Answers::is_master_enabled() ); + } + + public function test_is_enabled_ignores_the_master_outside_internal_testing_environments() { + update_option( 'jetpack_search_ai_answers_enabled', true ); + $this->register_ai_module(); + $GLOBALS['jetpack_search_test_internal_env'] = false; + + $this->assertTrue( AI_Answers::is_enabled() ); + } + + public function test_is_enabled_is_false_when_the_master_is_off() { + // Save the choice before the master goes off: once it is off, the setting's + // own sanitize callback refuses to turn it on, and this test would then + // pass for the wrong reason. + update_option( 'jetpack_search_ai_answers_enabled', true ); + $this->turn_ai_master_off(); + + $this->assertTrue( AI_Answers::is_saved_on() ); + $this->assertFalse( AI_Answers::is_enabled() ); + } + + public function test_is_enabled_master_gate_cannot_be_filtered_back_on() { + // The master gate is applied after the filter chain, so a filter cannot + // re-enable AI Answers while the site-wide switch is off. + $this->turn_ai_master_off(); + add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + + $this->assertFalse( AI_Answers::is_enabled() ); + + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + } + public function test_get_behavior_instructions_returns_empty_by_default() { // wp_guideline is not registered; option is unset — expect empty string. $this->assertSame( '', AI_Answers::get_behavior_instructions() ); diff --git a/projects/packages/search/tests/php/Initial_Javascript_State_Test.php b/projects/packages/search/tests/php/Initial_Javascript_State_Test.php new file mode 100644 index 000000000000..04555cad655d --- /dev/null +++ b/projects/packages/search/tests/php/Initial_Javascript_State_Test.php @@ -0,0 +1,38 @@ +remove_ai_master_filters(); + parent::tearDown(); + } + + public function test_it_reports_the_ai_master_switch_as_off() { + $this->turn_ai_master_off(); + + $state = Helper::generate_initial_javascript_state(); + + $this->assertFalse( $state['aiMasterEnabled'] ); + } + + public function test_it_reports_the_ai_master_switch_as_on() { + $this->turn_ai_master_on(); + + $state = Helper::generate_initial_javascript_state(); + + $this->assertTrue( $state['aiMasterEnabled'] ); + } +} diff --git a/projects/packages/search/tests/php/REST_Controller_Test.php b/projects/packages/search/tests/php/REST_Controller_Test.php index 05578a0558be..833d5ae4c429 100644 --- a/projects/packages/search/tests/php/REST_Controller_Test.php +++ b/projects/packages/search/tests/php/REST_Controller_Test.php @@ -14,6 +14,7 @@ * @package automattic/jetpack-search */ class REST_Controller_Test extends Search_TestCase { + use Toggles_Ai_Master; /** * REST Server object. @@ -62,6 +63,7 @@ public function tearDown(): void { unregister_setting( 'general', 'reader_chat' ); } delete_option( 'reader_chat' ); + $this->remove_ai_master_filters(); parent::tearDown(); } @@ -145,6 +147,8 @@ public function test_update_search_settings_success_both_enable() { 'experience' => 'overlay', 'search_suggestions_enabled' => false, 'override_woocommerce_search_template' => false, + 'ai_answers_saved' => false, + 'ai_master_enabled' => true, ) ); @@ -205,6 +209,8 @@ public function test_update_search_settings_success_both_disable() { 'experience' => 'off', 'search_suggestions_enabled' => false, 'override_woocommerce_search_template' => false, + 'ai_answers_saved' => false, + 'ai_master_enabled' => true, ) ); @@ -232,6 +238,8 @@ public function test_update_search_settings_success_disable_module_only() { 'ai_answers_enabled' => false, 'search_suggestions_enabled' => false, 'override_woocommerce_search_template' => false, + 'ai_answers_saved' => false, + 'ai_master_enabled' => true, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -258,6 +266,8 @@ public function test_update_search_settings_success_disable_instant_only() { 'ai_answers_enabled' => false, 'search_suggestions_enabled' => false, 'override_woocommerce_search_template' => false, + 'ai_answers_saved' => false, + 'ai_master_enabled' => true, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -284,6 +294,8 @@ public function test_update_search_settings_success_enable_inline_search() { 'ai_answers_enabled' => false, 'search_suggestions_enabled' => false, 'override_woocommerce_search_template' => false, + 'ai_answers_saved' => false, + 'ai_master_enabled' => true, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -310,6 +322,8 @@ public function test_update_search_settings_success_disable_inline_search() { 'ai_answers_enabled' => false, 'search_suggestions_enabled' => false, 'override_woocommerce_search_template' => false, + 'ai_answers_saved' => false, + 'ai_master_enabled' => true, ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -645,6 +659,87 @@ public function test_update_settings_ai_answers_enabled_false() { $this->assertFalse( $data['ai_answers_enabled'] ); } + /** + * Testing that ai_answers_enabled cannot be enabled while the site-wide + * Jetpack AI master switch is off. + */ + public function test_update_settings_cannot_enable_ai_answers_when_ai_master_is_off() { + wp_set_current_user( $this->admin_id ); + $this->turn_ai_master_off(); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'ai_answers_enabled' => true ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertFalse( (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) ); + } + + /** + * Testing that ai_answers_enabled can still be turned off while the site-wide + * Jetpack AI master switch is off. + */ + public function test_update_settings_can_disable_ai_answers_when_ai_master_is_off() { + wp_set_current_user( $this->admin_id ); + update_option( 'jetpack_search_ai_answers_enabled', true ); + $this->turn_ai_master_off(); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'ai_answers_enabled' => false ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) ); + } + + /** + * Testing that the settings payload reports the master switch state, so the + * dashboard can explain why the toggle is unavailable. + */ + public function test_get_settings_reports_the_ai_master_state() { + wp_set_current_user( $this->admin_id ); + $this->turn_ai_master_off(); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/search/settings' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertFalse( $response->get_data()['ai_master_enabled'] ); + } + + /** + * Testing that the settings payload reports the saved choice separately from + * the effective one, so the dashboard can show it while the master is off. + */ + public function test_get_settings_reports_the_saved_ai_answers_choice() { + wp_set_current_user( $this->admin_id ); + update_option( 'jetpack_search_ai_answers_enabled', true ); + $this->turn_ai_master_off(); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/search/settings' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertFalse( $data['ai_answers_enabled'] ); + $this->assertTrue( $data['ai_answers_saved'] ); + } + + /** + * The reported master state is true on a site that has no master switch. + */ + public function test_get_settings_reports_the_ai_master_on_without_a_master_switch() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/search/settings' ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertTrue( $response->get_data()['ai_master_enabled'] ); + } + /** * The override option round-trips through the settings endpoint on * its own and persists to its backing option. diff --git a/projects/packages/search/tests/php/Settings_Test.php b/projects/packages/search/tests/php/Settings_Test.php index 631dcf9ad3a8..8f77e71afffa 100644 --- a/projects/packages/search/tests/php/Settings_Test.php +++ b/projects/packages/search/tests/php/Settings_Test.php @@ -13,6 +13,13 @@ * Unit tests for the Settings class. */ class Settings_Test extends Search_TestCase { + use Toggles_Ai_Master; + + public function tearDown(): void { + $this->remove_ai_master_filters(); + parent::tearDown(); + } + public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); // Instantiating Settings hooks settings_register onto admin_init and @@ -30,6 +37,45 @@ public function test_settings_register_registers_ai_answers_enabled() { $this->assertFalse( $setting['default'] ); } + public function test_ai_answers_setting_cannot_be_turned_on_while_the_ai_master_is_off() { + // Customberg writes this option straight through /wp/v2/settings, so the + // registered setting has to hold the gate on its own. + $this->turn_ai_master_off(); + + update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); + + $this->assertFalse( (bool) get_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ) ); + } + + public function test_ai_answers_setting_keeps_a_saved_choice_while_the_ai_master_is_off() { + // The AI feature-settings endpoint writes feature choices even while the + // master is off, and update_option() sanitizes before it reads the old + // value — so a coercing callback would overwrite the saved choice. + update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); + $this->turn_ai_master_off(); + + update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); + + $this->assertTrue( (bool) get_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ) ); + } + + public function test_ai_answers_setting_can_still_be_turned_off_while_the_ai_master_is_off() { + update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); + $this->turn_ai_master_off(); + + update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ); + + $this->assertFalse( (bool) get_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ) ); + } + + public function test_ai_answers_setting_can_be_turned_on_while_the_ai_master_is_on() { + $this->turn_ai_master_on(); + + update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); + + $this->assertTrue( (bool) get_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ) ); + } + public function test_settings_register_registers_result_format() { $registered = get_registered_settings(); $this->assertArrayHasKey( Options::OPTION_PREFIX . 'result_format', $registered ); diff --git a/projects/packages/search/tests/php/bootstrap.php b/projects/packages/search/tests/php/bootstrap.php index afaa5e9e871f..ef55e66549aa 100644 --- a/projects/packages/search/tests/php/bootstrap.php +++ b/projects/packages/search/tests/php/bootstrap.php @@ -10,6 +10,7 @@ */ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/class-testcase.php'; +require_once __DIR__ . '/trait-toggles-ai-master.php'; use Automattic\Jetpack\Constants; use Automattic\Jetpack\Search\Helper; @@ -34,5 +35,14 @@ function dbless_default_options() { ); } +// The plugin defines this; the package cannot. Stub it so tests can drive both +// branches of the internal-environment guard in AI_Answers::is_master_enabled(). +// Defaults to true so the master-gate tests exercise the gate. +if ( ! function_exists( 'jetpack_is_internal_testing_environment' ) ) { + function jetpack_is_internal_testing_environment() { + return ! isset( $GLOBALS['jetpack_search_test_internal_env'] ) || (bool) $GLOBALS['jetpack_search_test_internal_env']; + } +} + // Initialize WordPress test environment \Automattic\Jetpack\Test_Environment::init(); diff --git a/projects/packages/search/tests/php/trait-toggles-ai-master.php b/projects/packages/search/tests/php/trait-toggles-ai-master.php new file mode 100644 index 000000000000..fd0702e21d97 --- /dev/null +++ b/projects/packages/search/tests/php/trait-toggles-ai-master.php @@ -0,0 +1,73 @@ +register_ai_module(); + add_filter( 'jetpack_options', array( $this, 'activate_ai_module' ), 10, 2 ); + } + + /** + * Site has a master switch and it is off. + */ + protected function turn_ai_master_off() { + $this->register_ai_module(); + } + + /** + * Drop everything the helpers added. + */ + protected function remove_ai_master_filters() { + remove_filter( 'jetpack_get_available_standalone_modules', array( $this, 'add_ai_module' ) ); + remove_filter( 'jetpack_options', array( $this, 'activate_ai_module' ) ); + } +} From 51b99d92a994705666fe4137bc5680fca356a0ad Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Wed, 12 Aug 2026 10:41:12 +1000 Subject: [PATCH 2/9] Search: gate the AI Answer block on the Jetpack AI master switch --- .../add-search-ai-answers-master-gate | 2 +- .../search-blocks/blocks/ai-answer/edit.jsx | 24 +++++++++++++ .../search-blocks/blocks/ai-answer/render.php | 10 ++++++ .../src/search-blocks/class-search-blocks.php | 1 + .../js/search-blocks/ai-answer-edit.test.jsx | 34 +++++++++++++++++++ .../tests/php/Ai_Answer_Render_Test.php | 34 +++++++++++++++++++ 6 files changed, 104 insertions(+), 1 deletion(-) diff --git a/projects/packages/search/changelog/add-search-ai-answers-master-gate b/projects/packages/search/changelog/add-search-ai-answers-master-gate index 982adbf4afce..821372715693 100644 --- a/projects/packages/search/changelog/add-search-ai-answers-master-gate +++ b/projects/packages/search/changelog/add-search-ai-answers-master-gate @@ -1,4 +1,4 @@ Significance: minor Type: changed -AI Answers: Keep the toggle unavailable while Jetpack AI is off, in internal testing environments ahead of release. +AI Answers: Follow the Jetpack AI master switch in the Search dashboard and the AI Answer block, in internal testing environments ahead of release. diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx index 6c8bc76ee45e..35006548876e 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx @@ -11,6 +11,11 @@ * gate in render.php so authors don't insert a block that won't render. The * paid-plan flag is localized onto `window.JetpackSearchBlocksConfig` * (`supportsPaidSearch`); the source of truth is the matching PHP gate. + * + * The Jetpack AI master switch gates the block the same way (`aiMasterEnabled` + * on the same config object). The master notice wins over the upgrade prompt: + * upselling a plan while the site has AI switched off would sell a feature + * that still wouldn't run. */ import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { Button, PanelBody, Placeholder, TextControl, ToggleControl } from '@wordpress/components'; @@ -29,6 +34,11 @@ const supportsPaidSearch = () => ! window.JetpackSearchBlocksConfig || window.JetpackSearchBlocksConfig.supportsPaidSearch !== false; +const aiMasterEnabled = () => + typeof window === 'undefined' || + ! window.JetpackSearchBlocksConfig || + window.JetpackSearchBlocksConfig.aiMasterEnabled !== false; + const UPGRADE_URL = 'https://jetpack.com/upgrade/search/?utm_source=ai-answer-block'; /** @@ -42,6 +52,20 @@ const UPGRADE_URL = 'https://jetpack.com/upgrade/search/?utm_source=ai-answer-bl export default function AiAnswerEdit( { attributes, setAttributes } ) { const blockProps = useBlockProps(); + if ( ! aiMasterEnabled() ) { + return ( +
+ +
+ ); + } + if ( ! supportsPaidSearch() ) { return (
diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php b/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php index da8e3671eda8..a39e3e0faed6 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php @@ -14,6 +14,12 @@ * saved block instance is silently hidden on the front end, matching how * WordAds / Premium Content behave when their plan check fails. * + * The render is also gated on the Jetpack AI master switch: a site that has + * turned Jetpack AI off must not stream from the WPCOM summariser, so the + * block emits nothing, same as a failed plan check. Sites where the switch + * was never installed (standalone Jetpack Search) are not gated — see + * `AI_Answers::is_master_enabled()`. + * * @package automattic/jetpack-search */ @@ -27,6 +33,10 @@ return; } +if ( ! AI_Answers::is_master_enabled() ) { + return; +} + // $attributes is injected by WordPress at block-render time via the // `render_callback` include scope; static analysis can't see the binding, // so both phpcs and Phan need a one-line suppression on the next statement. diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 5de0209ac922..0a9cafecab4c 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -683,6 +683,7 @@ public static function enqueue_editor_assets() { 'isWooCommerceBlocksEnabled' => self::woocommerce_blocks_enabled(), 'woocommerceOnlyBlocks' => self::woocommerce_only_block_names(), 'supportsPaidSearch' => self::supports_paid_search(), + 'aiMasterEnabled' => AI_Answers::is_master_enabled(), 'supportedCustomTaxonomies' => self::supported_custom_taxonomies(), 'customTaxonomyMap' => (object) self::custom_taxonomy_map(), // Resolved the same way `search-results/render.php` resolves the diff --git a/projects/packages/search/tests/js/search-blocks/ai-answer-edit.test.jsx b/projects/packages/search/tests/js/search-blocks/ai-answer-edit.test.jsx index 13a030612345..700baf157ccb 100644 --- a/projects/packages/search/tests/js/search-blocks/ai-answer-edit.test.jsx +++ b/projects/packages/search/tests/js/search-blocks/ai-answer-edit.test.jsx @@ -140,4 +140,38 @@ describe( 'AiAnswerEdit', () => { expect( screen.queryByTestId( 'placeholder' ) ).not.toBeInTheDocument(); expect( screen.getByText( 'AI answer' ) ).toBeInTheDocument(); } ); + + it( 'renders the disabled Placeholder instead of the preview when aiMasterEnabled is false', () => { + globalThis.JetpackSearchBlocksConfig = { aiMasterEnabled: false }; + render( {} } /> ); + + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + expect( + screen.getByText( + 'Jetpack AI is turned off for this site, so visitors won’t see this block. Turn Jetpack AI on to show AI-generated answers in your search results.' + ) + ).toBeInTheDocument(); + expect( screen.queryByText( 'Getting started with WordPress' ) ).not.toBeInTheDocument(); + expect( screen.queryByTestId( 'inspector' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows the disabled Placeholder, not the upgrade prompt, when the master is off on a free site', () => { + // Upselling a paid plan while the site has AI switched off would sell + // a feature that still wouldn't run — the master notice wins. + globalThis.JetpackSearchBlocksConfig = { aiMasterEnabled: false, supportsPaidSearch: false }; + render( {} } /> ); + + expect( screen.getByTestId( 'placeholder' ) ).toBeInTheDocument(); + expect( + screen.queryByRole( 'link', { name: 'Upgrade Jetpack Search' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders the preview when the config lacks aiMasterEnabled', () => { + globalThis.JetpackSearchBlocksConfig = {}; + render( {} } /> ); + + expect( screen.queryByTestId( 'placeholder' ) ).not.toBeInTheDocument(); + expect( screen.getByText( 'AI answer' ) ).toBeInTheDocument(); + } ); } ); diff --git a/projects/packages/search/tests/php/Ai_Answer_Render_Test.php b/projects/packages/search/tests/php/Ai_Answer_Render_Test.php index d186b039f8a5..27b05156c251 100644 --- a/projects/packages/search/tests/php/Ai_Answer_Render_Test.php +++ b/projects/packages/search/tests/php/Ai_Answer_Render_Test.php @@ -21,6 +21,8 @@ */ class Ai_Answer_Render_Test extends TestCase { + use Toggles_Ai_Master; + /** * Register the ai-answer block inline so `do_blocks()` can resolve it * without depending on `build/` artifacts referenced by block.json. @@ -84,6 +86,7 @@ public function setUp(): void { public function tearDown(): void { delete_option( Plan::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY ); Search_Blocks::reset_supports_paid_search_cache(); + $this->remove_ai_master_filters(); parent::tearDown(); } @@ -201,6 +204,37 @@ public function test_renders_nothing_on_free_search_plan() { $this->assertStringNotContainsString( 'data-wp-interactive', $markup ); } + public function test_renders_nothing_when_ai_master_is_off() { + // Paid plan, but the site-wide Jetpack AI master switch is off: the + // block must emit nothing, same as a failed plan check — the master + // is a site-wide off switch, not a preference the block may ignore. + $this->turn_ai_master_off(); + + $markup = $this->render(); + + $this->assertStringNotContainsString( 'jp-search-answers-panel', $markup ); + $this->assertStringNotContainsString( 'data-wp-interactive', $markup ); + } + + public function test_renders_when_ai_master_is_on() { + $this->turn_ai_master_on(); + + $markup = $this->render(); + + $this->assertStringContainsString( 'jp-search-answers-panel', $markup ); + $this->assertStringContainsString( 'data-wp-interactive="jetpack-search"', $markup ); + } + + public function test_renders_when_ai_module_is_not_registered() { + // Standalone Jetpack Search: no Jetpack plugin, so the `ai` module — + // and with it the master switch — was never installed. Those sites + // must keep rendering the block. + $markup = $this->render(); + + $this->assertStringContainsString( 'jp-search-answers-panel', $markup ); + $this->assertStringContainsString( 'data-wp-interactive="jetpack-search"', $markup ); + } + public function test_panel_is_hidden_until_status_changes() { $markup = $this->render(); // The panel binds to `state.aiPanelHidden` and also carries the bare From 046e4504aee207ff185e6c7ef7493596d92ed414 Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Wed, 12 Aug 2026 21:37:02 +1000 Subject: [PATCH 3/9] Search: tidy the AI master gate call sites --- .../packages/search/src/class-ai-answers.php | 11 ++++----- .../packages/search/src/class-settings.php | 20 ++++++++-------- .../components/sidebar/sidebar-options.jsx | 24 +++++++++---------- .../components/ai-answers-tab/index.jsx | 24 +++++++------------ .../store/selectors/jetpack-settings.js | 3 +-- .../search-blocks/blocks/ai-answer/edit.jsx | 13 ++++------ .../search/tests/php/AI_Answers_Test.php | 4 ++-- .../packages/search/tests/php/bootstrap.php | 2 +- .../tests/php/trait-toggles-ai-master.php | 13 +++------- 9 files changed, 47 insertions(+), 67 deletions(-) diff --git a/projects/packages/search/src/class-ai-answers.php b/projects/packages/search/src/class-ai-answers.php index 8f93851bf03d..41b18db4342b 100644 --- a/projects/packages/search/src/class-ai-answers.php +++ b/projects/packages/search/src/class-ai-answers.php @@ -19,6 +19,7 @@ class AI_Answers { const BEHAVIOR_OPTION_KEY = 'jetpack_search_ai_behavior_instructions'; const AI_MODULE = 'ai'; const AI_MASTER_OPTION = 'jetpack_ai_enabled'; + const ENABLED_OPTION = 'jetpack_search_ai_answers_enabled'; /** * Hook up meta/setting registration. @@ -132,7 +133,8 @@ public static function is_master_enabled() { return true; } - return $modules->is_active( self::AI_MODULE ); + // Availability is already proven above, so skip is_active()'s repeat intersect. + return $modules->is_active( self::AI_MODULE, false ); } /** @@ -177,17 +179,14 @@ public static function sanitize_enabled_setting( $value ) { * @return bool */ public static function is_saved_on() { - return (bool) get_option( 'jetpack_search_ai_answers_enabled', false ); + return (bool) get_option( self::ENABLED_OPTION, false ); } /** * Whether AI Answers is enabled for the current site. */ public static function is_enabled() { - $enabled = (bool) apply_filters( - 'jetpack_search_ai_answers_enabled', - (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) - ); + $enabled = (bool) apply_filters( 'jetpack_search_ai_answers_enabled', self::is_saved_on() ); // The master gate is applied after the filter chain so it cannot be // filtered back on, matching `Jetpack_AI_Settings::is_ai_enabled()`. diff --git a/projects/packages/search/src/class-settings.php b/projects/packages/search/src/class-settings.php index 10e7e734e574..481bba58f2f9 100644 --- a/projects/packages/search/src/class-settings.php +++ b/projects/packages/search/src/class-settings.php @@ -56,17 +56,17 @@ public function settings_register() { array( $setting_prefix . 'suggestions_enabled', 'boolean', false ), ); foreach ( $settings as $value ) { - $args = array( - 'default' => $value[2], - 'show_in_rest' => true, - 'type' => $value[1], + register_setting( + 'options', + $value[0], + array( + 'default' => $value[2], + 'show_in_rest' => true, + 'type' => $value[1], + // Optional fourth element, for settings that carry a gate of their own. + 'sanitize_callback' => $value[3] ?? null, + ) ); - // Optional fourth element: a sanitize_callback, for settings that carry - // a gate of their own. - if ( isset( $value[3] ) ) { - $args['sanitize_callback'] = $value[3]; - } - register_setting( 'options', $value[0], $args ); } } } diff --git a/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx b/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx index 430bf94eb7d6..6f91434b78cf 100644 --- a/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx +++ b/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx @@ -60,6 +60,17 @@ export default function SidebarOptions() { const { isLoading } = useSiteLoadingState(); const isDisabled = isSaving || isLoading; + const aiAnswersHelp = aiMasterEnabled + ? __( + 'Generate AI-powered answers to visitor queries using your site’s content.', + 'jetpack-search-pkg' + ) + : __( + 'Jetpack AI is turned off for this site. Your setting will apply again when AI is turned back on.', + 'jetpack-search-pkg', + /* dummy arg to avoid bad minification */ 0 + ); + const sortOptions = [ { label: __( 'Relevance (recommended)', 'jetpack-search-pkg' ), value: 'relevance' }, { label: __( 'Newest first', 'jetpack-search-pkg' ), value: 'newest' }, @@ -214,18 +225,7 @@ export default function SidebarOptions() { checked={ aiAnswersEnabled } disabled={ isDisabled || ! aiMasterEnabled } label={ __( 'Enable AI Answers', 'jetpack-search-pkg' ) } - help={ - aiMasterEnabled - ? __( - 'Generate AI-powered answers to visitor queries using your site’s content.', - 'jetpack-search-pkg' - ) - : __( - 'Jetpack AI is turned off for this site. Your setting will apply again when AI is turned back on.', - 'jetpack-search-pkg', - /* dummy arg to avoid bad minification */ 0 - ) - } + help={ aiAnswersHelp } onChange={ setAiAnswersEnabled } __nextHasNoMarginBottom={ true } /> diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx index 712685a1cfb9..763715c427d5 100644 --- a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx +++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx @@ -36,6 +36,12 @@ export default function AiAnswersTab() { // the saved choice instead of misreporting it back as off. const isToggleChecked = isAiMasterEnabled ? isAiAnswersEnabled : isAiAnswersSaved; + // The toggle stays usable to turn an already-enabled feature off; the personality + // controls need AI Answers actually running. + const isToggleDisabled = + ! isAiMasterEnabled || ( ! isInstantSearchEnabled && ! isAiAnswersEnabled ); + const isAiAnswersActive = isAiMasterEnabled && isAiAnswersEnabled && isInstantSearchEnabled; + const { run: sendToCart } = useProductCheckoutWorkflow( { productSlug: 'jetpack_search', adminUrl: siteAdminUrl, @@ -135,9 +141,7 @@ export default function AiAnswersTab() { checked={ isToggleChecked } onChange={ setAiAnswersEnabled } className="jp-search-dashboard-toggle lg-col-span-12 md-col-span-8 sm-col-span-4" - disabled={ - ! isAiMasterEnabled || ( ! isInstantSearchEnabled && ! isAiAnswersEnabled ) - } + disabled={ isToggleDisabled } /> { ! isLoading && ! isUnavailable && ( @@ -149,23 +153,13 @@ export default function AiAnswersTab() { onChange={ setContent } placeholder={ DEFAULT_PERSONALITY } rows={ 10 } - disabled={ - isSaving || - ! isAiMasterEnabled || - ! isAiAnswersEnabled || - ! isInstantSearchEnabled - } + disabled={ isSaving || ! isAiAnswersActive } />
diff --git a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js index 6a9ca8515e4e..e058958e33d8 100644 --- a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js +++ b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js @@ -24,8 +24,7 @@ const jetpackSettingSelectors = { isAiAnswersEnabled: state => !! state.jetpackSettings.ai_answers_enabled, // The stored choice, ungated — shown while the master switch is off so a // saved setting isn't misreported back to the user as off. - isAiAnswersSaved: state => - !! ( state.jetpackSettings.ai_answers_saved ?? state.jetpackSettings.ai_answers_enabled ), + isAiAnswersSaved: state => !! state.jetpackSettings.ai_answers_saved, // Treat a missing value as on, so a back end that predates the field doesn't // gate the toggle. isAiMasterEnabled: state => state.jetpackSettings.ai_master_enabled !== false, diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx index 35006548876e..58f4161eeee4 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx @@ -29,15 +29,10 @@ const SAMPLE_CITATIONS = [ // Editor preview defaults to "paid" when the localized config isn't present // (e.g. a Jest harness or a non-enqueued bundle context) so existing tests // see the full preview and the gate is opt-in via an explicit `false`. -const supportsPaidSearch = () => +const configFlagOn = key => typeof window === 'undefined' || ! window.JetpackSearchBlocksConfig || - window.JetpackSearchBlocksConfig.supportsPaidSearch !== false; - -const aiMasterEnabled = () => - typeof window === 'undefined' || - ! window.JetpackSearchBlocksConfig || - window.JetpackSearchBlocksConfig.aiMasterEnabled !== false; + window.JetpackSearchBlocksConfig[ key ] !== false; const UPGRADE_URL = 'https://jetpack.com/upgrade/search/?utm_source=ai-answer-block'; @@ -52,7 +47,7 @@ const UPGRADE_URL = 'https://jetpack.com/upgrade/search/?utm_source=ai-answer-bl export default function AiAnswerEdit( { attributes, setAttributes } ) { const blockProps = useBlockProps(); - if ( ! aiMasterEnabled() ) { + if ( ! configFlagOn( 'aiMasterEnabled' ) ) { return (
register_ai_module(); + $this->turn_ai_master_off(); $GLOBALS['jetpack_search_test_internal_env'] = false; $this->assertTrue( AI_Answers::is_master_enabled() ); @@ -138,7 +138,7 @@ public function test_is_master_enabled_is_true_outside_internal_testing_environm public function test_is_enabled_ignores_the_master_outside_internal_testing_environments() { update_option( 'jetpack_search_ai_answers_enabled', true ); - $this->register_ai_module(); + $this->turn_ai_master_off(); $GLOBALS['jetpack_search_test_internal_env'] = false; $this->assertTrue( AI_Answers::is_enabled() ); diff --git a/projects/packages/search/tests/php/bootstrap.php b/projects/packages/search/tests/php/bootstrap.php index ef55e66549aa..6a6c92665e8f 100644 --- a/projects/packages/search/tests/php/bootstrap.php +++ b/projects/packages/search/tests/php/bootstrap.php @@ -40,7 +40,7 @@ function dbless_default_options() { // Defaults to true so the master-gate tests exercise the gate. if ( ! function_exists( 'jetpack_is_internal_testing_environment' ) ) { function jetpack_is_internal_testing_environment() { - return ! isset( $GLOBALS['jetpack_search_test_internal_env'] ) || (bool) $GLOBALS['jetpack_search_test_internal_env']; + return (bool) ( $GLOBALS['jetpack_search_test_internal_env'] ?? true ); } } diff --git a/projects/packages/search/tests/php/trait-toggles-ai-master.php b/projects/packages/search/tests/php/trait-toggles-ai-master.php index fd0702e21d97..36ee47fac9f9 100644 --- a/projects/packages/search/tests/php/trait-toggles-ai-master.php +++ b/projects/packages/search/tests/php/trait-toggles-ai-master.php @@ -42,9 +42,9 @@ public function activate_ai_module( $value, $name ) { } /** - * Make the `ai` module available, so the master switch has somewhere to live. + * Site has a master switch and it is off: the `ai` module is available but inactive. */ - protected function register_ai_module() { + protected function turn_ai_master_off() { add_filter( 'jetpack_get_available_standalone_modules', array( $this, 'add_ai_module' ) ); } @@ -52,17 +52,10 @@ protected function register_ai_module() { * Site has a master switch and it is on. */ protected function turn_ai_master_on() { - $this->register_ai_module(); + $this->turn_ai_master_off(); add_filter( 'jetpack_options', array( $this, 'activate_ai_module' ), 10, 2 ); } - /** - * Site has a master switch and it is off. - */ - protected function turn_ai_master_off() { - $this->register_ai_module(); - } - /** * Drop everything the helpers added. */ From 527f93b9814a61a30cb908d510d99f605caf0660 Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Wed, 12 Aug 2026 22:06:41 +1000 Subject: [PATCH 4/9] Search: pin the ungated public render of the AI Answer block --- .../search/tests/php/Ai_Answer_Render_Test.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/projects/packages/search/tests/php/Ai_Answer_Render_Test.php b/projects/packages/search/tests/php/Ai_Answer_Render_Test.php index 27b05156c251..0e3f89bd3167 100644 --- a/projects/packages/search/tests/php/Ai_Answer_Render_Test.php +++ b/projects/packages/search/tests/php/Ai_Answer_Render_Test.php @@ -87,6 +87,7 @@ public function tearDown(): void { delete_option( Plan::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY ); Search_Blocks::reset_supports_paid_search_cache(); $this->remove_ai_master_filters(); + unset( $GLOBALS['jetpack_search_test_internal_env'] ); parent::tearDown(); } @@ -225,6 +226,20 @@ public function test_renders_when_ai_master_is_on() { $this->assertStringContainsString( 'data-wp-interactive="jetpack-search"', $markup ); } + public function test_renders_when_master_is_off_outside_internal_testing_environments() { + // The master switch UI ships internal-only for now, so on a public site + // the front-end gate must stay inert: even with the master off, the + // block keeps rendering. If the rollout scoping ever moves out of + // `is_master_enabled()`, this pin has to change deliberately with it. + $this->turn_ai_master_off(); + $GLOBALS['jetpack_search_test_internal_env'] = false; + + $markup = $this->render(); + + $this->assertStringContainsString( 'jp-search-answers-panel', $markup ); + $this->assertStringContainsString( 'data-wp-interactive="jetpack-search"', $markup ); + } + public function test_renders_when_ai_module_is_not_registered() { // Standalone Jetpack Search: no Jetpack plugin, so the `ai` module — // and with it the master switch — was never installed. Those sites From 7441349f16fed8f2d46f172451ea6a04ae1a6959 Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Wed, 12 Aug 2026 22:23:06 +1000 Subject: [PATCH 5/9] Search: state the AI master gate's internal-rollout scoping in its docblocks --- .../packages/search/src/class-ai-answers.php | 5 +++++ .../search-blocks/blocks/ai-answer/render.php | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/projects/packages/search/src/class-ai-answers.php b/projects/packages/search/src/class-ai-answers.php index 41b18db4342b..2a7710dc73e1 100644 --- a/projects/packages/search/src/class-ai-answers.php +++ b/projects/packages/search/src/class-ai-answers.php @@ -104,6 +104,11 @@ public static function get_behavior_instructions() { * option is the master. Everywhere else the `ai` module is, toggled through * the standard Jetpack module machinery. * + * While the master's own UI ships A8C-internal-only, the gate is inert + * outside internal testing environments: public sites get `true` whatever + * the master's state. That scoping is removed with the rest of the + * internal-testing gates at release. + * * @since $$next-version$$ * * @return bool True when Jetpack AI is on, or when the site has no master switch. diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php b/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php index a39e3e0faed6..6b4705de703a 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php @@ -4,21 +4,22 @@ * * Renders the panel scaffold that the Interactivity store hydrates with the * streaming brief / extended AI answer. The author's decision to insert the - * block in their post content is the only opt-in switch — there's no site-wide - * option gate here. The `jetpack_search_ai_answers_enabled` option still - * governs the instant-search overlay's AI Answers, which is the default UX - * on any search page; the embedded block is an explicit opt-in surface. + * block in their post content is its opt-in switch — the + * `jetpack_search_ai_answers_enabled` option does not apply to the block. That + * option governs the instant-search overlay's AI Answers, which is the default + * UX on any search page; the embedded block is an explicit opt-in surface. * * AI Answer is a paid feature, so the render is additionally gated on the * site having a paid Search plan. Free / no-plan sites emit nothing — the * saved block instance is silently hidden on the front end, matching how * WordAds / Premium Content behave when their plan check fails. * - * The render is also gated on the Jetpack AI master switch: a site that has - * turned Jetpack AI off must not stream from the WPCOM summariser, so the - * block emits nothing, same as a failed plan check. Sites where the switch - * was never installed (standalone Jetpack Search) are not gated — see - * `AI_Answers::is_master_enabled()`. + * The render is also gated on the Jetpack AI master switch: when the master + * is off the block emits nothing, same as a failed plan check. During the + * internal rollout `AI_Answers::is_master_enabled()` applies that gate only + * in internal testing environments — public sites keep rendering until the + * internal-testing scoping is removed at release. Sites where the switch was + * never installed (standalone Jetpack Search) are never gated. * * @package automattic/jetpack-search */ From e983481516cd0f8cc2b14aae0a0156ea8035a341 Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Wed, 12 Aug 2026 23:54:37 +1000 Subject: [PATCH 6/9] Search: let the AI Answers choice persist while the master is off --- .../packages/search/src/class-ai-answers.php | 31 ------------------- .../packages/search/src/class-settings.php | 10 +++--- .../search/tests/php/AI_Answers_Test.php | 4 +-- .../search/tests/php/Settings_Test.php | 18 +++-------- 4 files changed, 9 insertions(+), 54 deletions(-) diff --git a/projects/packages/search/src/class-ai-answers.php b/projects/packages/search/src/class-ai-answers.php index 2a7710dc73e1..ab1d8c171edc 100644 --- a/projects/packages/search/src/class-ai-answers.php +++ b/projects/packages/search/src/class-ai-answers.php @@ -142,37 +142,6 @@ public static function is_master_enabled() { return $modules->is_active( self::AI_MODULE, false ); } - /** - * Keep the AI Answers option from being turned on while the master switch is off. - * - * Registered as the setting's sanitize_callback, so it covers writes that go - * straight to `/wp/v2/settings` — the path the Customberg sidebar uses — - * without passing through the Search REST controller. - * - * Blocking a write is not the same as clearing the setting: `update_option()` - * sanitizes before it reads the old value, so returning false here would - * overwrite a saved choice rather than leave it alone. The AI feature settings - * endpoint writes feature choices while the master is off precisely so they - * survive it, so keep the stored value instead. Turning the feature off stays - * allowed. - * - * @since $$next-version$$ - * - * @param mixed $value Incoming setting value. - * @return bool - */ - public static function sanitize_enabled_setting( $value ) { - if ( ! $value ) { - return false; - } - - if ( ! self::is_master_enabled() ) { - return self::is_saved_on(); - } - - return true; - } - /** * The stored AI Answers choice, ignoring every gate. * diff --git a/projects/packages/search/src/class-settings.php b/projects/packages/search/src/class-settings.php index 481bba58f2f9..d05f9ed0ccb2 100644 --- a/projects/packages/search/src/class-settings.php +++ b/projects/packages/search/src/class-settings.php @@ -52,7 +52,7 @@ public function settings_register() { array( $setting_prefix . 'show_post_date', 'boolean', true ), array( $setting_prefix . 'show_product_price', 'boolean', true ), array( $setting_prefix . 'show_powered_by', 'boolean', true ), - array( $setting_prefix . 'ai_answers_enabled', 'boolean', false, array( AI_Answers::class, 'sanitize_enabled_setting' ) ), + array( $setting_prefix . 'ai_answers_enabled', 'boolean', false ), array( $setting_prefix . 'suggestions_enabled', 'boolean', false ), ); foreach ( $settings as $value ) { @@ -60,11 +60,9 @@ public function settings_register() { 'options', $value[0], array( - 'default' => $value[2], - 'show_in_rest' => true, - 'type' => $value[1], - // Optional fourth element, for settings that carry a gate of their own. - 'sanitize_callback' => $value[3] ?? null, + 'default' => $value[2], + 'show_in_rest' => true, + 'type' => $value[1], ) ); } diff --git a/projects/packages/search/tests/php/AI_Answers_Test.php b/projects/packages/search/tests/php/AI_Answers_Test.php index a803fbb45513..2e749a505a68 100644 --- a/projects/packages/search/tests/php/AI_Answers_Test.php +++ b/projects/packages/search/tests/php/AI_Answers_Test.php @@ -145,9 +145,7 @@ public function test_is_enabled_ignores_the_master_outside_internal_testing_envi } public function test_is_enabled_is_false_when_the_master_is_off() { - // Save the choice before the master goes off: once it is off, the setting's - // own sanitize callback refuses to turn it on, and this test would then - // pass for the wrong reason. + // The saved choice persists while the master is off; only is_enabled() gates it. update_option( 'jetpack_search_ai_answers_enabled', true ); $this->turn_ai_master_off(); diff --git a/projects/packages/search/tests/php/Settings_Test.php b/projects/packages/search/tests/php/Settings_Test.php index 8f77e71afffa..e63ca98342a7 100644 --- a/projects/packages/search/tests/php/Settings_Test.php +++ b/projects/packages/search/tests/php/Settings_Test.php @@ -37,26 +37,16 @@ public function test_settings_register_registers_ai_answers_enabled() { $this->assertFalse( $setting['default'] ); } - public function test_ai_answers_setting_cannot_be_turned_on_while_the_ai_master_is_off() { - // Customberg writes this option straight through /wp/v2/settings, so the - // registered setting has to hold the gate on its own. - $this->turn_ai_master_off(); - - update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); - - $this->assertFalse( (bool) get_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ) ); - } - - public function test_ai_answers_setting_keeps_a_saved_choice_while_the_ai_master_is_off() { + public function test_ai_answers_setting_persists_while_the_ai_master_is_off() { // The AI feature-settings endpoint writes feature choices even while the - // master is off, and update_option() sanitizes before it reads the old - // value — so a coercing callback would overwrite the saved choice. - update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); + // master is off so they survive it; this option follows the same contract + // as the other AI feature options. Enforcement lives in is_enabled(). $this->turn_ai_master_off(); update_option( Options::OPTION_PREFIX . 'ai_answers_enabled', true ); $this->assertTrue( (bool) get_option( Options::OPTION_PREFIX . 'ai_answers_enabled', false ) ); + $this->assertFalse( AI_Answers::is_enabled() ); } public function test_ai_answers_setting_can_still_be_turned_off_while_the_ai_master_is_off() { From 3010756c73a5266170cf50127753685db2d57924 Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Thu, 13 Aug 2026 00:08:19 +1000 Subject: [PATCH 7/9] Search: clarify what locks the AI Answers toggle --- .../search/src/dashboard/components/ai-answers-tab/index.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx index 763715c427d5..1a46d1824a56 100644 --- a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx +++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx @@ -36,8 +36,9 @@ export default function AiAnswersTab() { // the saved choice instead of misreporting it back as off. const isToggleChecked = isAiMasterEnabled ? isAiAnswersEnabled : isAiAnswersSaved; - // The toggle stays usable to turn an already-enabled feature off; the personality - // controls need AI Answers actually running. + // Locked while the master is off, matching the AI Features view: the saved + // choice is shown but not editable. With the master on but Instant Search + // off, the toggle stays usable only to turn an already-enabled feature off. const isToggleDisabled = ! isAiMasterEnabled || ( ! isInstantSearchEnabled && ! isAiAnswersEnabled ); const isAiAnswersActive = isAiMasterEnabled && isAiAnswersEnabled && isInstantSearchEnabled; From 9dcaefc46f9b4d7eedb5dcf579e37b568366f56d Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Thu, 13 Aug 2026 21:02:37 +1000 Subject: [PATCH 8/9] Search: cap the AI master gate comments at three lines --- .../packages/search/src/class-ai-answers.php | 28 ++++--------------- .../search-blocks/blocks/ai-answer/edit.jsx | 5 ++-- .../search-blocks/blocks/ai-answer/render.php | 21 ++++---------- .../tests/php/trait-toggles-ai-master.php | 8 ++---- 4 files changed, 16 insertions(+), 46 deletions(-) diff --git a/projects/packages/search/src/class-ai-answers.php b/projects/packages/search/src/class-ai-answers.php index ab1d8c171edc..54e7f37c7c5a 100644 --- a/projects/packages/search/src/class-ai-answers.php +++ b/projects/packages/search/src/class-ai-answers.php @@ -93,34 +93,18 @@ public static function get_behavior_instructions() { /** * Whether the site-wide Jetpack AI master switch is on. * - * Mirrors `Jetpack_AI_Settings::is_master_enabled()` in the Jetpack plugin, - * which is the source of truth: this package ships in standalone plugins and - * cannot reference that class. The answer is computed rather than filtered so - * no plugin can flip a gate that must hold — the same reasoning as - * `Search_Blocks::supports_paid_search()`. - * - * The master lives in a different place depending on the platform. On - * WordPress.com Simple no Jetpack modules run, so the `jetpack_ai_enabled` - * option is the master. Everywhere else the `ai` module is, toggled through - * the standard Jetpack module machinery. - * - * While the master's own UI ships A8C-internal-only, the gate is inert - * outside internal testing environments: public sites get `true` whatever - * the master's state. That scoping is removed with the rest of the - * internal-testing gates at release. + * Mirrors `Jetpack_AI_Settings::is_master_enabled()` in the Jetpack plugin — + * the source of truth, unreferenceable from standalone installs. Computed + * rather than filtered so no plugin can flip a gate that must hold. * * @since $$next-version$$ * * @return bool True when Jetpack AI is on, or when the site has no master switch. */ public static function is_master_enabled() { - // The master switch is still an internal-only surface: both the AI settings - // view (`showFeaturesView`) and My Jetpack's module toggle - // (`showAiModuleToggle`) are gated on jetpack_is_internal_testing_environment(). - // Gating a public site on a switch its owner cannot see would strand the - // toggle behind an unexplainable notice, so the gate stays inert outside A8C - // testing environments — and outside the Jetpack plugin, where the helper and - // the master both live. + // The master's own UI is internal-only, so never gate a public site on a + // switch its owner cannot see: outside internal testing environments the + // gate is inert. This scoping comes off at release. if ( ! function_exists( 'jetpack_is_internal_testing_environment' ) || ! jetpack_is_internal_testing_environment() ) { return true; } diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx index 58f4161eeee4..6f5b6ec6a0c0 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx @@ -13,9 +13,8 @@ * (`supportsPaidSearch`); the source of truth is the matching PHP gate. * * The Jetpack AI master switch gates the block the same way (`aiMasterEnabled` - * on the same config object). The master notice wins over the upgrade prompt: - * upselling a plan while the site has AI switched off would sell a feature - * that still wouldn't run. + * on the same config object), and its notice wins over the upgrade prompt — + * never upsell a plan while site-wide AI is off. */ import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { Button, PanelBody, Placeholder, TextControl, ToggleControl } from '@wordpress/components'; diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php b/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php index 6b4705de703a..10ad711530ba 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/render.php @@ -3,23 +3,12 @@ * AI Answer block render. * * Renders the panel scaffold that the Interactivity store hydrates with the - * streaming brief / extended AI answer. The author's decision to insert the - * block in their post content is its opt-in switch — the - * `jetpack_search_ai_answers_enabled` option does not apply to the block. That - * option governs the instant-search overlay's AI Answers, which is the default - * UX on any search page; the embedded block is an explicit opt-in surface. + * streaming AI answer. Inserting the block is its own opt-in switch — the + * `jetpack_search_ai_answers_enabled` option governs the overlay, not the block. * - * AI Answer is a paid feature, so the render is additionally gated on the - * site having a paid Search plan. Free / no-plan sites emit nothing — the - * saved block instance is silently hidden on the front end, matching how - * WordAds / Premium Content behave when their plan check fails. - * - * The render is also gated on the Jetpack AI master switch: when the master - * is off the block emits nothing, same as a failed plan check. During the - * internal rollout `AI_Answers::is_master_enabled()` applies that gate only - * in internal testing environments — public sites keep rendering until the - * internal-testing scoping is removed at release. Sites where the switch was - * never installed (standalone Jetpack Search) are never gated. + * The gates below (paid Search plan, Jetpack AI master switch) emit nothing + * when they fail, matching how WordAds / Premium Content hide on a failed + * plan check. * * @package automattic/jetpack-search */ diff --git a/projects/packages/search/tests/php/trait-toggles-ai-master.php b/projects/packages/search/tests/php/trait-toggles-ai-master.php index 36ee47fac9f9..3532aff736d4 100644 --- a/projects/packages/search/tests/php/trait-toggles-ai-master.php +++ b/projects/packages/search/tests/php/trait-toggles-ai-master.php @@ -8,11 +8,9 @@ namespace Automattic\Jetpack\Search; /** - * Off WordPress.com Simple the master switch is the `ai` Jetpack module, which - * the Jetpack plugin owns. These helpers stand in for it: they register `ai` as - * an available module and control whether it is active. - * - * Call remove_ai_master_filters() from tearDown(). + * Off WordPress.com Simple the master switch is the `ai` Jetpack module; these + * helpers stand in for the plugin that owns it, registering `ai` as available + * and controlling whether it is active. Call remove_ai_master_filters() in tearDown(). */ trait Toggles_Ai_Master { From 23069656613f34835586041a80bb01cd2241304f Mon Sep 17 00:00:00 2001 From: Ariana Kataoka Date: Thu, 13 Aug 2026 21:42:34 +1000 Subject: [PATCH 9/9] Search: feed the Customberg preview the enforced AI Answers value --- projects/packages/search/jest.config.js | 6 ++ .../components/app-wrapper/index.jsx | 7 +- .../components/test/app-wrapper.test.jsx | 79 +++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx diff --git a/projects/packages/search/jest.config.js b/projects/packages/search/jest.config.js index 453319f6ce02..b9b6b37fb0bd 100644 --- a/projects/packages/search/jest.config.js +++ b/projects/packages/search/jest.config.js @@ -27,6 +27,12 @@ module.exports = { 'tiny-lru/lib/tiny-lru.esm$': '/src/instant-search/lib/test-helpers/tiny-lru.mock.js', 'instant-search/components/gridicon': '/src/instant-search/components/gridicon/index.jsx', + // Mirror the customberg webpack resolution (tools/webpack.customberg.config.js): + // `instant-search` is an alias and `hooks/*` resolves against src/customberg. + // Exact-name hook entries so dashboard's own `hooks/*` resolution is untouched. + '^instant-search/(.*)$': '/src/instant-search/$1', + '^hooks/use-search-options$': '/src/customberg/hooks/use-search-options.js', + '^hooks/use-loading-state$': '/src/customberg/hooks/use-loading-state.js', }, moduleDirectories: [ 'node_modules', '/src/dashboard' ], setupFilesAfterEnv: [ ...baseConfig.setupFilesAfterEnv, '/tests/jest-globals.gui.js' ], diff --git a/projects/packages/search/src/customberg/components/app-wrapper/index.jsx b/projects/packages/search/src/customberg/components/app-wrapper/index.jsx index 98c32916a661..0e3417b01540 100644 --- a/projects/packages/search/src/customberg/components/app-wrapper/index.jsx +++ b/projects/packages/search/src/customberg/components/app-wrapper/index.jsx @@ -68,13 +68,14 @@ export default function AppWrapper() { }; // aiAnswersEnabled + searchSuggestionsEnabled live at the top level of the - // instant-search options object (not under `overlayOptions`). Override them - // here so the preview reacts to the sidebar toggles without a save round-trip. + // options object; overridden here so the preview reacts to the sidebar. While + // the master is off a saved choice persists unenforced — preview gets false. + const { aiMasterEnabled = true } = window[ SERVER_OBJECT_NAME ]; const options = { ...window[ SERVER_OBJECT_NAME ], ...Object.fromEntries( Object.entries( { - aiAnswersEnabled, + aiAnswersEnabled: aiMasterEnabled ? aiAnswersEnabled : false, searchSuggestionsEnabled, } ).filter( ( [ , v ] ) => typeof v !== 'undefined' ) ), diff --git a/projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx b/projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx new file mode 100644 index 000000000000..d2c9763b6a42 --- /dev/null +++ b/projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx @@ -0,0 +1,79 @@ +import { render } from '@testing-library/react'; +import useSearchOptions from 'hooks/use-search-options'; +import SearchApp from 'instant-search/components/search-app'; + +jest.mock( 'instant-search/components/search-app', () => jest.fn( () => null ) ); +jest.mock( 'hooks/use-search-options', () => jest.fn( () => ( {} ) ) ); +jest.mock( 'hooks/use-loading-state', () => jest.fn( () => ( { isLoading: false } ) ) ); +jest.mock( 'instant-search/store', () => ( { + __esModule: true, + default: { subscribe: jest.fn(), dispatch: jest.fn(), getState: () => ( {} ) }, +} ) ); +jest.mock( 'instant-search/lib/api', () => ( { buildFilterAggregations: () => ( {} ) } ) ); +jest.mock( 'instant-search/lib/dom', () => ( { getThemeOptions: () => ( {} ) } ) ); + +const makeServerObject = ( overrides = {} ) => ( { + webpackPublicPath: '/', + widgets: [], + widgetsOutsideOverlay: [], + overlayOptions: {}, + aiAnswersEnabled: false, + ...overrides, +} ); + +describe( 'AppWrapper AI Answers preview gating', () => { + let AppWrapper; + + const searchAppOptions = () => SearchApp.mock.calls.at( -1 )[ 0 ].options; + + beforeAll( () => { + // The module reads the server object at import time, so seed it first. + window.JetpackInstantSearchOptions = makeServerObject(); + AppWrapper = require( '../app-wrapper' ).default; + } ); + + beforeEach( () => { + SearchApp.mockClear(); + } ); + + it( 'feeds the preview the enforced value while the master is off', () => { + // A saved-on choice persists while the master is off, so the raw + // entity value can be true — the preview must not stream on it. + window.JetpackInstantSearchOptions = makeServerObject( { aiMasterEnabled: false } ); + useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( false ); + } ); + + it( 'lets the sidebar toggle drive the preview while the master is on', () => { + window.JetpackInstantSearchOptions = makeServerObject( { aiMasterEnabled: true } ); + useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( true ); + } ); + + it( 'defaults to ungated when the server object predates the master flag', () => { + window.JetpackInstantSearchOptions = makeServerObject(); + useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( true ); + } ); + + it( 'falls back to the server value when the sidebar has no local edit', () => { + window.JetpackInstantSearchOptions = makeServerObject( { + aiMasterEnabled: true, + aiAnswersEnabled: true, + } ); + useSearchOptions.mockReturnValue( {} ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( true ); + } ); +} );