diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 881af9c52883..bb3074654932 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4208,12 +4208,18 @@ importers:
'@automattic/jetpack-connection':
specifier: workspace:*
version: link:../../js-packages/connection
+ '@automattic/jetpack-script-data':
+ specifier: workspace:*
+ version: link:../../js-packages/script-data
'@automattic/jetpack-shared-extension-utils':
specifier: workspace:*
version: link:../../js-packages/shared-extension-utils
'@automattic/number-formatters':
specifier: workspace:*
version: link:../../js-packages/number-formatters
+ '@microsoft/fetch-event-source':
+ specifier: 2.0.1
+ version: 2.0.1
'@wordpress/api-fetch':
specifier: 7.44.0
version: 7.44.0
@@ -4344,6 +4350,9 @@ importers:
'@testing-library/react':
specifier: 16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@wordpress/browserslist-config':
specifier: 6.44.0
version: 6.44.0
diff --git a/projects/packages/search/AGENTS.md b/projects/packages/search/AGENTS.md
new file mode 100644
index 000000000000..ce06d3d3b27b
--- /dev/null
+++ b/projects/packages/search/AGENTS.md
@@ -0,0 +1,54 @@
+# Jetpack Search
+
+## UI Components
+
+**Always use existing design system components instead of writing custom ones.**
+
+The Jetpack Search dashboard is actively migrating to standard WordPress components. When building UI, follow this priority order:
+
+### Priority 1: `@wordpress/ui`
+The primary component library for new Jetpack work. Use for:
+- `Button`, `Link`
+- `Notice.Root` / `Notice.Title` / `Notice.Description`
+- Tabs, dialogs, and layout primitives
+
+### Priority 2: `@wordpress/components`
+Fallback for anything not yet in `@wordpress/ui`:
+- `ToggleControl`, `TextareaControl`, `SelectControl`
+- `Tabs`, `TabPanel`
+- `Notice` (legacy)
+
+### Priority 3: `@automattic/jetpack-components`
+Jetpack-specific components (upsell cards, plan badges, etc.).
+
+### Never reinvent
+Do **not** create custom implementations of:
+- Tab bars → use `@wordpress/components` `Tabs` or `@wordpress/ui`
+- Buttons → use `@wordpress/ui` `Button`
+- Notices/alerts → use `@wordpress/ui` `Notice`
+- Toggles/checkboxes → use `@wordpress/components` `ToggleControl`
+
+When unsure which component to use, the `@wordpress/design-system-mcp` MCP is configured for this project — use it to look up the right component before building a custom one.
+
+## Data access
+
+- `@automattic/jetpack-shared-extension-utils` → `getSiteFragment()` for site domain
+- `@automattic/jetpack-script-data` → `isWpcomPlatformSite()` for WP.com detection
+- Store selectors via `useSelect( select => select( STORE_ID ).selectorName(), [] )` — always include `[]` dependency array
+
+## Testing
+
+Always run all three suites when changing search code:
+
+```bash
+# JS tests (from projects/packages/search/)
+pnpm test-scripts
+
+# Search PHP tests
+jetpack test php packages/search -v
+
+# Sync PHP tests — search changes often affect the sync whitelist
+jetpack test php packages/sync -v
+```
+
+- Mock external packages in tests; mock custom hooks (`hooks/use-*`) rather than their internals
diff --git a/projects/packages/search/changelog/jps3-ai-answers b/projects/packages/search/changelog/jps3-ai-answers
new file mode 100644
index 000000000000..41e16908b5e5
--- /dev/null
+++ b/projects/packages/search/changelog/jps3-ai-answers
@@ -0,0 +1,4 @@
+Significance: major
+Type: added
+
+Search: AI Answers — add streaming ai answers panel in the instant-search overlay. The AI can be customized from a new AI Answers dashboard. The feature is availabe for all paid plans.
diff --git a/projects/packages/search/docs/plans/local-testing-setup.md b/projects/packages/search/docs/plans/local-testing-setup.md
new file mode 100644
index 000000000000..406a8a0a963c
--- /dev/null
+++ b/projects/packages/search/docs/plans/local-testing-setup.md
@@ -0,0 +1,309 @@
+# Local Testing Setup — Jetpack Search AI Answers
+
+A checklist to verify your local environment can run tests and test the AI Answers feature end-to-end, including live calls to a WPcom sandbox.
+
+---
+
+## 1. Prerequisites
+
+- [x ] Docker Desktop is installed and running (`docker info` returns no error)
+- [x ] `jetpack` CLI is installed and on PATH:
+ ```bash
+ jetpack --version
+ # If missing: npm install -g @automattic/jetpack-cli (or pnpm add -g)
+ ```
+- [x ] Node and pnpm are available:
+ ```bash
+ node --version # 20+
+ pnpm --version # 9+
+ ```
+- [x ] PHP and Composer are available:
+ ```bash
+ php --version # 8.0+
+ composer --version
+ ```
+- [x ] You're on the right branch:
+ ```bash
+ git branch # should be jps3-answers-plan
+ ```
+
+---
+
+## 2. Install Monorepo Dependencies
+
+- [x ] Install all JS and PHP dependencies from the monorepo root:
+ ```bash
+ pnpm install
+ composer install
+ ```
+ This only needs to be done once (or after a `git pull` that changes lockfiles).
+
+---
+
+## 3. Run Unit Tests Without Docker
+
+The PHP unit tests for the Search and Sync packages use WorDBless (no WordPress needed) and run locally without Docker.
+
+- [x ] Run Search package PHP tests:
+ ```bash
+ jetpack test php packages/search -v
+ ```
+ Expected: all tests pass (including `AI_Answers_Test` once Task 1 is implemented).
+
+- [x ] Run Sync package PHP tests:
+ ```bash
+ jetpack test php packages/sync -v
+ ```
+ Expected: all tests pass (including the CPT sync tests once Task 2 is implemented).
+
+- [x ] Run Search package JS tests:
+ ```bash
+ cd projects/packages/search
+ pnpm test-scripts
+ ```
+ Expected: all tests pass.
+
+---
+
+## 4. Build the Search Package
+
+Build is required before the Docker site can serve the updated JS bundle.
+
+- [x ] Build all Search JS bundles:
+ ```bash
+ cd projects/packages/search
+ pnpm build
+ ```
+ Expected: `build/` directory updated with no errors.
+
+- [ ] For active development, use watch mode instead:
+ ```bash
+ pnpm watch
+ ```
+ Leave this running while you work — it rebuilds on file save.
+
+---
+
+## 5. Start Docker WordPress Environment
+
+- [x ] Start containers (first time or after `jetpack docker clean`):
+ ```bash
+ jetpack docker up -d
+ jetpack docker install
+ ```
+ WordPress is now at [http://localhost](http://localhost).
+ Default credentials: `jp_docker_acct` / `jp_docker_pass`
+
+- [x ] If containers were already installed previously, just start them:
+ ```bash
+ jetpack docker up -d
+ ```
+
+- [x ] Verify WordPress is reachable:
+ ```bash
+ open http://localhost/wp-admin
+ ```
+ You should see the WP login page.
+
+- [x ] Verify Jetpack plugin is active (it's auto-linked from the monorepo):
+ ```bash
+ jetpack docker wp plugin list | grep jetpack
+ ```
+ Expected: `jetpack` shows as `active`.
+
+---
+
+## 6. Connect Jetpack to WordPress.com via Tunnel
+
+Jetpack requires a publicly accessible URL to handshake with WPcom.
+
+**If you're an Automattician**, use Jurassic Tube (preferred):
+
+- [ ] Follow the setup instructions at PCYsg-GJ2-p2 to install and configure Jurassic Tube if you haven't already.
+- [ ] Start the tunnel:
+ ```bash
+ jetpack docker up --jurassic-tube
+ ```
+ Note your assigned domain (e.g. `yourname.jurassictube.example`).
+
+**Alternative**: Use ngrok (requires a paid account for a stable subdomain):
+- [ ] Start with ngrok:
+ ```bash
+ jetpack docker up --ngrok
+ ```
+
+- [ ] Once the tunnel is running, re-install WordPress with the tunnel domain:
+ ```bash
+ jetpack docker install
+ ```
+ (This updates `WP_SITEURL` and `WP_HOME` to the tunnel domain.)
+
+- [ ] Go to **Jetpack → Dashboard** in wp-admin and complete the WPcom connection flow. Sign in with your WPcom account.
+
+- [ ] Verify connection:
+ ```bash
+ jetpack docker wp jetpack status
+ ```
+ Expected: `Jetpack is connected to WordPress.com`.
+
+---
+
+## 7. Enable Instant Search
+
+Instant Search must be enabled for the overlay and AI Answers panel to appear.
+
+- [ ] Enable the Search module and Instant Search:
+ ```bash
+ jetpack docker wp option update jetpack_active_modules '["search"]' --format=json
+ jetpack docker wp option update instant_search_enabled 1
+ ```
+
+- [ ] Verify in wp-admin: **Jetpack → Search** should show the Search dashboard with "Instant Search" toggled on.
+
+---
+
+## 8. Enable the AI Answers Feature Flag
+
+- [ ] Enable the AI Answers feature:
+ ```bash
+ jetpack docker wp option update jetpack_search_ai_answers_enabled 1
+ ```
+
+- [ ] Verify the **Behavior** and **Topics** tabs appear at **Jetpack → Search**.
+
+- [ ] Add a test behavior post via the Behavior tab (type any text, click Save).
+
+- [ ] Add a test topic via **Jetpack → Search → Topics → Add Topic**.
+ Use this post content as a starting point:
+ ```
+ This topic covers questions about resetting passwords and account access.
+
+ Example questions:
+ - How do I reset my password?
+ - I forgot my login email.
+ - My account is locked.
+
+ Guidelines:
+ Direct users to the account recovery page. Do not speculate about lock durations.
+ ```
+
+---
+
+## 9. Point PHP→WPcom Calls to a Sandbox
+
+This routes Jetpack's server-side API calls (JWT issuance, sync, REST proxy) through your WPcom sandbox instead of production.
+
+- [ ] Create a mu-plugin file for sandbox config:
+ ```bash
+ cat > tools/docker/mu-plugins/sandbox.php << 'EOF'
+ true,
+ 'type' => 'string',
+ 'show_in_rest' => true,
+ 'default' => '',
+ 'sanitize_callback' => 'sanitize_textarea_field',
+ 'auth_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ )
+ );
+ return;
+ }
+
+ register_setting(
+ 'options',
+ self::BEHAVIOR_OPTION_KEY,
+ array(
+ 'type' => 'string',
+ 'default' => '',
+ 'sanitize_callback' => 'sanitize_textarea_field',
+ 'show_in_rest' => true,
+ )
+ );
+ }
+
+ /**
+ * Retrieve the behavior instructions.
+ *
+ * Reads from the Gutenberg Guidelines CPT when available, otherwise falls
+ * back to the site option.
+ *
+ * @return string Behavior instructions, or empty string if none saved.
+ */
+ public static function get_behavior_instructions() {
+ if ( post_type_exists( 'wp_guideline' ) ) {
+ $posts = get_posts(
+ array(
+ 'post_type' => 'wp_guideline',
+ 'posts_per_page' => 1,
+ 'post_status' => 'publish',
+ )
+ );
+ if ( ! empty( $posts ) ) {
+ $guidelines = get_post_meta( $posts[0]->ID, self::BEHAVIOR_META_KEY, true );
+ return is_string( $guidelines ) ? $guidelines : '';
+ }
+ }
+ return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' );
+ }
+
+ /**
+ * Whether AI Answers is enabled for the current site.
+ */
+ public static function is_enabled() {
+ return (bool) apply_filters(
+ 'jetpack_search_ai_answers_enabled',
+ (bool) get_option( 'jetpack_search_ai_answers_enabled', false )
+ );
+ }
+}
diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php
index 280f348e7aa2..27a1c75c4503 100644
--- a/projects/packages/search/src/class-helper.php
+++ b/projects/packages/search/src/class-helper.php
@@ -970,6 +970,7 @@ public static function generate_initial_javascript_state() {
* @param bool $disable_tracking Whether to disable tracking. Default false.
*/
'disableTracking' => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ),
+ 'aiAnswersEnabled' => AI_Answers::is_enabled(),
);
/**
diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php
index 731967cad4e8..be11b0b5544c 100644
--- a/projects/packages/search/src/class-rest-controller.php
+++ b/projects/packages/search/src/class-rest-controller.php
@@ -253,8 +253,9 @@ public function update_settings( $request ) {
? sanitize_text_field( $request_body['experience'] )
: null;
$reader_chat = array_key_exists( 'reader_chat', $request_body ) ? (bool) $request_body['reader_chat'] : null;
+ $ai_answers_enabled = isset( $request_body['ai_answers_enabled'] ) ? (bool) $request_body['ai_answers_enabled'] : null;
- $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience, $reader_chat );
+ $error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience, $reader_chat, $ai_answers_enabled );
if ( is_wp_error( $error ) ) {
return $error;
@@ -299,6 +300,10 @@ public function update_settings( $request ) {
update_option( 'reader_chat', $reader_chat );
}
+ if ( $ai_answers_enabled !== null ) {
+ update_option( 'jetpack_search_ai_answers_enabled', $ai_answers_enabled );
+ }
+
if ( ! empty( $errors ) ) {
return new WP_Error(
'some_updated',
@@ -325,8 +330,9 @@ public function update_settings( $request ) {
* @param boolean $swap_classic_to_inline_search - New inline search status.
* @param string|null $experience - Experience value.
* @param bool|null $reader_chat - Reader Chat status.
+ * @param bool|null $ai_answers_enabled - Whether Jetpack Search AI answers is enabled.
*/
- protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience = null, $reader_chat = null ) {
+ protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience = null, $reader_chat = null, $ai_answers_enabled = null ) {
if ( $reader_chat !== null && ! $this->is_reader_chat_setting_registered() ) {
return new WP_Error(
'rest_invalid_arguments',
@@ -357,6 +363,10 @@ protected function validate_search_settings( $module_active, $instant_search_ena
// Allow updating auxiliary settings without updating/validating the module settings.
return true;
}
+ if ( $module_active === null && $instant_search_enabled === null && $swap_classic_to_inline_search === null && $ai_answers_enabled !== null ) {
+ // allow updating 'ai_answers_enabled' without updating/validating other settings.
+ return true;
+ }
if ( ( true === $instant_search_enabled && false === $module_active ) || ( $module_active === null && $instant_search_enabled === null ) ) {
return new WP_Error(
'rest_invalid_arguments',
@@ -376,6 +386,7 @@ public function get_settings() {
'instant_search_enabled' => $this->search_module->is_instant_search_enabled(),
'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(),
);
if ( $this->is_reader_chat_setting_registered() ) {
diff --git a/projects/packages/search/src/class-settings.php b/projects/packages/search/src/class-settings.php
index 2b7e82a02c19..4ad664e3bc9f 100644
--- a/projects/packages/search/src/class-settings.php
+++ b/projects/packages/search/src/class-settings.php
@@ -52,6 +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 ),
);
foreach ( $settings as $value ) {
register_setting(
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
new file mode 100644
index 000000000000..0c9e20fc826a
--- /dev/null
+++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx
@@ -0,0 +1,161 @@
+import { isWpcomPlatformSite } from '@automattic/jetpack-script-data';
+import { getSiteFragment } from '@automattic/jetpack-shared-extension-utils';
+import { TextareaControl, ToggleControl } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { Button, Notice } from '@wordpress/ui';
+import useAiAnswersSettings, { DEFAULT_PERSONALITY } from 'hooks/use-ai-answers-settings';
+import useProductCheckoutWorkflow from 'hooks/use-product-checkout-workflow';
+import useSearchSettings from 'hooks/use-search-settings';
+import { STORE_ID } from 'store';
+import './style.scss';
+
+/**
+ * AiAnswersTab component for configuring AI Answers settings.
+ *
+ * @return {import('react').ReactElement} AiAnswersTab component.
+ */
+export default function AiAnswersTab() {
+ const supportsInstantSearch = useSelect(
+ select => select( STORE_ID ).supportsInstantSearch(),
+ []
+ );
+ const isFreePlan = useSelect( select => select( STORE_ID ).isFreePlan(), [] );
+ const blogID = useSelect( select => select( STORE_ID ).getBlogId(), [] );
+ const siteAdminUrl = useSelect( select => select( STORE_ID ).getSiteAdminUrl(), [] );
+
+ const { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled } = useSearchSettings();
+
+ const { run: sendToCart } = useProductCheckoutWorkflow( {
+ productSlug: 'jetpack_search',
+ adminUrl: siteAdminUrl,
+ redirectUri: 'admin.php?page=jetpack-search&just_upgraded=1',
+ from: 'jetpack-search',
+ siteSuffix: getSiteFragment(),
+ blogID,
+ isWpcom: isWpcomPlatformSite(),
+ } );
+
+ const { content, setContent, isSaving, isLoading, error, saved, isUnavailable, savePersonality } =
+ useAiAnswersSettings();
+
+ const settingsClassName = [
+ 'jp-search-ai-answers-tab__settings',
+ isFreePlan || ! supportsInstantSearch ? 'jp-search-ai-answers-tab__settings--gated' : '',
+ ]
+ .filter( Boolean )
+ .join( ' ' );
+
+ const savingLabel = __( 'Saving…', 'jetpack-search-pkg' );
+ const saveLabel = __( 'Save', 'jetpack-search-pkg' );
+
+ return (
+
+ { ( isFreePlan || ! supportsInstantSearch ) && (
+
+
+
+
+
+ { __( 'Upgrade to use AI Answers', 'jetpack-search-pkg' ) }
+
+
+ -
+ { __(
+ 'Give visitors real answers, not just search results.',
+ 'jetpack-search-pkg'
+ ) }
+
+ -
+ { __( "Fills gaps when your content doesn't match.", 'jetpack-search-pkg' ) }
+
+ -
+ { __(
+ 'Serious, silly, or snarky — your personality, your search.',
+ 'jetpack-search-pkg'
+ ) }
+
+
+
+
+
+
+
+ ) }
+
+
+
+
+
+ { isLoading &&
{ __( 'Loading…', 'jetpack-search-pkg' ) }
}
+ { supportsInstantSearch && ! isInstantSearchEnabled && (
+
+
+ { __(
+ 'Instant Search must be enabled for AI Answers to work.',
+ 'jetpack-search-pkg'
+ ) }
+
+
+ { __( 'Enable Instant Search on the Plan & Usage tab.', 'jetpack-search-pkg' ) }
+
+
+ ) }
+
+
+ { ! isLoading && ! isUnavailable && (
+ <>
+ { error &&
{ error }
}
+
+
+
+ { saved && (
+
+ { __( 'Saved.', 'jetpack-search-pkg' ) }
+
+ ) }
+
+ >
+ ) }
+
+ { ! isLoading && isUnavailable && (
+
+
+ { __(
+ 'Personality instructions are temporarily unavailable.',
+ 'jetpack-search-pkg'
+ ) }
+
+
+ { __( 'Please try again later or contact support.', 'jetpack-search-pkg' ) }
+
+
+ ) }
+
+
+
+
+
+ );
+}
diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss b/projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss
new file mode 100644
index 000000000000..07fc874bc32b
--- /dev/null
+++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss
@@ -0,0 +1,70 @@
+@use "scss/variables";
+
+.jp-search-ai-answers-tab {
+ width: 100%;
+}
+
+.jp-search-ai-answers-tab__upsell {
+ background-color: variables.$jp-white-off;
+ border-bottom: 1px solid variables.$jp-gray;
+ padding: 2.5em 0;
+
+ .jp-search-ai-answers-tab__upsell-heading {
+ font-size: variables.$font-title-small;
+ font-weight: 600;
+ line-height: 1.25;
+ margin: 0 0 1em;
+ color: variables.$jp-black;
+ }
+
+ .jp-search-ai-answers-tab__upsell-bullets {
+ list-style: disc;
+ margin: 0 0 1.5em 1.5em;
+ padding: 0;
+
+ li {
+ font-size: 1em;
+ line-height: 1.6;
+ margin-bottom: 0.4em;
+ color: variables.$jp-black-80;
+ }
+ }
+}
+
+.jp-search-ai-answers-tab__settings {
+ background-color: variables.$jp-white;
+ padding: 2.5em 0;
+
+ &.jp-search-ai-answers-tab__settings--gated {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+}
+
+.jp-search-ai-answers-tab__settings-inner {
+ display: flex;
+ flex-direction: column;
+
+ .jp-search-dashboard-toggle {
+ margin-bottom: 1.5em;
+ }
+}
+
+.jp-search-ai-answers-tab__actions {
+ display: flex;
+ align-items: center;
+ gap: 1em;
+ margin-top: 0.5em;
+}
+
+.jp-search-ai-answers-tab__error {
+ color: variables.$jp-red-error;
+ font-size: 0.875em;
+ margin: 0 0 1em;
+}
+
+.jp-search-ai-answers-tab__saved {
+ color: variables.$jp-green-primary;
+ font-size: 0.9375em;
+ font-weight: 500;
+}
diff --git a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx
index 47166db6858e..a88d6acdcc0f 100644
--- a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx
+++ b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx
@@ -1,13 +1,10 @@
-import {
- AdminPage,
- Button,
- Container,
- Col,
- getProductCheckoutUrl,
-} from '@automattic/jetpack-components';
+import { AdminPage, Button, getProductCheckoutUrl } from '@automattic/jetpack-components';
import { useConnectionErrorNotice, ConnectionError } from '@automattic/jetpack-connection';
import { useSelect, useDispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
+import { Stack, Tabs } from '@wordpress/ui';
+import { useState } from 'react';
+import AiAnswersTab from 'components/ai-answers-tab';
import FeatureSelector from 'components/feature-selector';
import NoticesList from 'components/global-notices';
import Loading from 'components/loading';
@@ -29,6 +26,8 @@ import './dashboard-page.scss';
* @return {import('react').Component} Search dashboard component.
*/
export default function DashboardPage( { isLoading = false } ) {
+ const [ activeTab, setActiveTab ] = useState( 'plan-usage' );
+
useSelect( select => select( STORE_ID ).getSearchPlanInfo(), [] );
useSelect( select => select( STORE_ID ).getSearchModuleStatus(), [] );
useSelect( select => select( STORE_ID ).getSearchStats(), [] );
@@ -133,72 +132,30 @@ export default function DashboardPage( { isLoading = false } ) {
apiNonce={ apiNonce }
className="uses-new-admin-ui"
>
-
- { /* Always in the DOM so JITM JS finds it immediately (Path A). */ }
-
- { isPageLoading &&
}
- { ! isPageLoading && (
-
- ) }
-
- { ! isPageLoading && (
- <>
- { hasConnectionError && (
-
-
-
-
-
- ) }
- { isNewPricing && supportsInstantSearch && (
-
- ) }
- { ! isNewPricing && supportsInstantSearch && (
-
- ) }
-
- { isSearchBlocksEnabled ? (
-
- ) : (
-
+
+ { __( 'Plan & Usage', 'jetpack-search-pkg' ) }
+
+ { __( 'AI Answers', 'jetpack-search-pkg' ) }{ ' ' }
+
+ { __( '(Preview)', 'jetpack-search-pkg' ) }
+
+
+
+
+
+ { /* Always in the DOM so JITM JS finds it immediately (Path A). */ }
+
+ { isPageLoading &&
}
+ { ! isPageLoading && (
+
) }
-
- >
- ) }
+ { ! isPageLoading && (
+ <>
+ { hasConnectionError && (
+
+
+
+ ) }
+ { isNewPricing && supportsInstantSearch && (
+
+ ) }
+ { ! isNewPricing && supportsInstantSearch && (
+
+ ) }
+
+ { isSearchBlocksEnabled ? (
+
+ ) : (
+
+ ) }
+
+
+ >
+ ) }
+
+
+
+
+
);
diff --git a/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx b/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx
index ac2279194ad8..51ea85ecedb3 100644
--- a/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx
+++ b/projects/packages/search/src/dashboard/components/pages/sections/plan-usage-section.jsx
@@ -207,6 +207,9 @@ const upgradeMessageFromAPIData = apiData => {
// apiData.currentUsage.upgrade_reason.requests
// apiData.currentUsage.months_over_plan_records_limit
// apiData.currentUsage.months_over_plan_requests_limit
+ if ( ! apiData.currentUsage ) {
+ return null;
+ }
if ( apiData.currentUsage.must_upgrade ) {
return upgradeMessageSearchDisabled();
}
@@ -237,7 +240,9 @@ const PlanUsageSection = ( { isFreePlan, planInfo, sendPaidPlanToCart, isPlanJus
// For complete plan, we only show the final CTA once search is already disabled.
// It's just because it was added later, and we didn't want to redesign existing CTAs at the time.
const upgradeMessage =
- isFreePlan || planInfo.currentUsage.must_upgrade ? upgradeMessageFromAPIData( planInfo ) : null;
+ isFreePlan || planInfo.currentUsage?.must_upgrade
+ ? upgradeMessageFromAPIData( planInfo )
+ : null;
const usageInfo = usageInfoFromAPIData( planInfo );
return (
diff --git a/projects/packages/search/src/dashboard/components/pages/sections/test/plan-summary.test.jsx b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-summary.test.jsx
new file mode 100644
index 000000000000..0981955bf298
--- /dev/null
+++ b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-summary.test.jsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react';
+import PlanSummary from '../plan-summary';
+
+describe( 'PlanSummary', () => {
+ const basePlanInfo = {
+ latestMonthRequests: {
+ start_date: '2026-02-01T00:00:00',
+ end_date: '2026-02-28T00:00:00',
+ },
+ };
+
+ it( 'renders the usage heading', () => {
+ render( );
+ expect( screen.getByRole( 'heading' ) ).toBeInTheDocument();
+ expect( screen.getByText( /your usage/i ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows "Free plan" label for free plan users', () => {
+ render( );
+ expect( screen.getByText( /Free plan/i ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows "Upgraded" label for paid plan users', () => {
+ render( );
+ expect( screen.getByText( /Upgraded/i ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows empty date range when latestMonthRequests is absent', () => {
+ render( );
+ // Heading and plan label should still render, just no date range text
+ expect( screen.getByText( /your usage/i ) ).toBeInTheDocument();
+ const span = screen.getByText( /Upgraded/i );
+ // The parent span should start with "(" — no dates means empty string before the bracket
+ expect( span ).toHaveTextContent( /^\s*\(Upgraded\)/ );
+ } );
+
+ it( 'renders a date range from latestMonthRequests', () => {
+ render( );
+ // The heading should contain at least one digit from the formatted date range.
+ expect( screen.getByRole( 'heading' ) ).toHaveTextContent( /\d/ );
+ } );
+} );
diff --git a/projects/packages/search/src/dashboard/components/pages/sections/test/plan-usage-section.test.jsx b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-usage-section.test.jsx
new file mode 100644
index 000000000000..6bfc782d8f10
--- /dev/null
+++ b/projects/packages/search/src/dashboard/components/pages/sections/test/plan-usage-section.test.jsx
@@ -0,0 +1,149 @@
+import { render, screen } from '@testing-library/react';
+import PlanUsageSection from '../plan-usage-section';
+
+jest.mock( '@automattic/jetpack-components', () => ( {
+ ContextualUpgradeTrigger: ( { description, cta } ) => (
+
+ { description }
+ { cta }
+
+ ),
+ ThemeProvider: ( { children } ) => <>{ children }>,
+} ) );
+
+jest.mock( '@wordpress/ui', () => ( {
+ Link: ( { children, href } ) => { children },
+} ) );
+
+jest.mock( '../../../donut-meter-container', () => {
+ const DonutMeter = ( { title, current, limit } ) => (
+
+ { title } { current }/{ limit }
+
+ );
+ DonutMeter.displayName = 'DonutMeterContainer';
+ return { __esModule: true, default: DonutMeter, formatNumber: n => String( n ) };
+} );
+
+jest.mock( '../plan-summary', () => ( { isFreePlan } ) => (
+ { isFreePlan ? 'Free' : 'Paid' }
+) );
+
+const makeUsage = ( overrides = {} ) => ( {
+ must_upgrade: false,
+ should_upgrade: false,
+ upgrade_reason: { records: false, requests: false },
+ months_over_plan_records_limit: 0,
+ months_over_plan_requests_limit: 0,
+ num_records: 100,
+ ...overrides,
+} );
+
+const makePlanInfo = ( usageOverrides = {} ) => ( {
+ currentUsage: makeUsage( usageOverrides ),
+ currentPlan: { record_limit: 500, monthly_search_request_limit: 1000 },
+ latestMonthRequests: { num_requests: 50, start_date: '2026-02-01', end_date: '2026-02-28' },
+} );
+
+const defaultProps = {
+ isFreePlan: false,
+ planInfo: makePlanInfo(),
+ sendPaidPlanToCart: jest.fn(),
+ isPlanJustUpgraded: false,
+};
+
+describe( 'PlanUsageSection', () => {
+ it( 'renders usage meters', () => {
+ render( );
+ const meters = screen.getAllByTestId( 'donut-meter' );
+ expect( meters ).toHaveLength( 2 );
+ } );
+
+ it( 'renders plan summary', () => {
+ render( );
+ expect( screen.getByTestId( 'plan-summary' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders "tell me more" link', () => {
+ render( );
+ expect( screen.getByRole( 'link', { name: /record indexing/i } ) ).toBeInTheDocument();
+ } );
+
+ it( 'does not render upgrade trigger for paid plan with no overage', () => {
+ render( );
+ expect( screen.queryByTestId( 'upgrade-trigger' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders upgrade trigger for free plan when should_upgrade is true', () => {
+ const planInfo = makePlanInfo( { should_upgrade: true } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-trigger' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders search-disabled message when must_upgrade is true', () => {
+ const planInfo = makePlanInfo( { must_upgrade: true } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent(
+ /exceeded the limits/i
+ );
+ } );
+
+ it( 'renders records overage message', () => {
+ const planInfo = makePlanInfo( {
+ should_upgrade: true,
+ upgrade_reason: { records: true, requests: false },
+ months_over_plan_records_limit: 1,
+ } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( /records/i );
+ } );
+
+ it( 'renders requests overage message', () => {
+ const planInfo = makePlanInfo( {
+ should_upgrade: true,
+ upgrade_reason: { records: false, requests: true },
+ months_over_plan_requests_limit: 1,
+ } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent( /requests/i );
+ } );
+
+ it( 'renders both-overage message when records and requests both over', () => {
+ const planInfo = makePlanInfo( {
+ should_upgrade: true,
+ upgrade_reason: { records: true, requests: true },
+ months_over_plan_records_limit: 1,
+ } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent(
+ /records and search requests/i
+ );
+ } );
+
+ it( 'renders no-overage upgrade message when should_upgrade with no specific reason', () => {
+ const planInfo = makePlanInfo( {
+ should_upgrade: true,
+ upgrade_reason: { records: false, requests: false },
+ } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-description' ) ).toHaveTextContent(
+ /increase your site records/i
+ );
+ } );
+
+ it( 'renders "continue using" CTA when records over limit for 3+ months', () => {
+ const planInfo = makePlanInfo( {
+ should_upgrade: true,
+ upgrade_reason: { records: true, requests: false },
+ months_over_plan_records_limit: 3,
+ } );
+ render( );
+ expect( screen.getByTestId( 'upgrade-cta' ) ).toHaveTextContent( /continue using/i );
+ } );
+
+ it( 'does not render upgrade trigger when planInfo has no currentUsage', () => {
+ const planInfo = { ...makePlanInfo(), currentUsage: undefined };
+ render( );
+ expect( screen.queryByTestId( 'upgrade-trigger' ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx b/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx
index 8f1c094dcb9d..ab569a785397 100644
--- a/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx
+++ b/projects/packages/search/src/dashboard/components/pages/test/dashboard-page.test.jsx
@@ -27,6 +27,7 @@ jest.mock( 'store', () => ( {
jest.mock( 'components/global-notices', () => () => );
jest.mock( 'components/loading', () => () => );
jest.mock( 'components/mocked-search', () => () => );
+jest.mock( 'components/ai-answers-tab', () => () => );
jest.mock( 'components/module-control', () => () => );
jest.mock( 'components/reader-chat-control', () => props => {
mockReaderChatControl( props );
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
new file mode 100644
index 000000000000..1f2e220be9d9
--- /dev/null
+++ b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx
@@ -0,0 +1,158 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { useSelect, useDispatch } from '@wordpress/data';
+import * as React from 'react';
+import AiAnswersTab from '../ai-answers-tab';
+
+jest.mock( 'store', () => ( { STORE_ID: 'jetpack-search-plugin' } ) );
+
+jest.mock( '@automattic/jetpack-script-data', () => ( {
+ isWpcomPlatformSite: () => false,
+} ) );
+
+jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( {
+ getSiteFragment: () => 'example.com',
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ useSelect: jest.fn(),
+ useDispatch: jest.fn(),
+ createRegistryControl: jest.fn(),
+ combineReducers: jest.fn( reducers => reducers ),
+ registerStore: jest.fn(),
+ createReduxStore: jest.fn(),
+ register: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+ TextareaControl: ( { label } ) => { label },
+ ToggleControl: ( { label, checked, onChange } ) => (
+
+ ),
+} ) );
+
+jest.mock( '@wordpress/ui', () => ( {
+ Button: ( { children, onClick } ) => ,
+ Link: ( { children, href } ) => { children },
+ Notice: {
+ Root: ( { children } ) => { children }
,
+ Title: ( { children } ) => { children }
,
+ Description: ( { children } ) => { children }
,
+ },
+} ) );
+
+jest.mock( 'hooks/use-product-checkout-workflow', () => () => ( {
+ run: jest.fn(),
+ hasCheckoutStarted: false,
+} ) );
+
+jest.mock( 'hooks/use-ai-answers-settings', () => ( {
+ __esModule: true,
+ default: () => ( {
+ content: '',
+ setContent: jest.fn(),
+ postId: null,
+ isSaving: false,
+ isLoading: false,
+ error: null,
+ saved: false,
+ isUnavailable: false,
+ savePersonality: jest.fn(),
+ } ),
+ DEFAULT_PERSONALITY: 'Default personality instructions.',
+} ) );
+
+const mockUpdateJetpackSettings = jest.fn();
+
+/**
+ * Set up mocked store state for testing.
+ *
+ * @param {object} root0 - Options.
+ * @param {boolean} root0.supportsInstantSearch - Whether the site supports instant search.
+ * @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.
+ */
+function setupStore( {
+ supportsInstantSearch = true,
+ isInstantSearchEnabled = true,
+ isFreePlan = false,
+ isAiAnswersEnabled = false,
+} = {} ) {
+ useDispatch.mockReturnValue( {
+ updateJetpackSettings: mockUpdateJetpackSettings,
+ } );
+ useSelect.mockImplementation( fn =>
+ fn( () => ( {
+ supportsInstantSearch: () => supportsInstantSearch,
+ isInstantSearchEnabled: () => isInstantSearchEnabled,
+ isFreePlan: () => isFreePlan,
+ isAiAnswersEnabled: () => isAiAnswersEnabled,
+ getBlogId: () => 1,
+ getSiteAdminUrl: () => 'http://example.com/wp-admin/',
+ } ) )
+ );
+}
+
+describe( 'AiAnswersTab', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'shows upsell banner for free plan users', async () => {
+ setupStore( { isFreePlan: true } );
+ render( );
+ await waitFor( () => {
+ expect( screen.getByText( 'Upgrade to use AI Answers' ) ).toBeInTheDocument();
+ } );
+ expect(
+ screen.getByText( 'Give visitors real answers, not just search results.' )
+ ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: /upgrade now/i } ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows upsell banner when instant search is not supported', async () => {
+ setupStore( { supportsInstantSearch: false } );
+ render( );
+ await waitFor( () => {
+ expect( screen.getByText( 'Upgrade to use AI Answers' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'does not show upsell banner for paid plan users with instant search', async () => {
+ setupStore( { supportsInstantSearch: true, isFreePlan: false } );
+ render( );
+ await waitFor( () => {
+ expect( screen.queryByText( 'Upgrade to use AI Answers' ) ).not.toBeInTheDocument();
+ } );
+ } );
+
+ it( 'settings section is present for paid plan users', async () => {
+ setupStore( { supportsInstantSearch: true, isAiAnswersEnabled: true } );
+ render( );
+ await expect( screen.findByText( 'Enable AI Answers' ) ).resolves.toBeInTheDocument();
+ } );
+
+ it( 'settings section is present but visually gated for free plan users', async () => {
+ setupStore( { isFreePlan: true } );
+ render( );
+ await expect( screen.findByText( 'Enable AI Answers' ) ).resolves.toBeInTheDocument();
+ const gated = screen.getByTestId( 'ai-answers-settings' );
+ expect( gated ).toHaveClass( 'jp-search-ai-answers-tab__settings--gated' );
+ } );
+
+ it( 'shows warning when instant search is supported but not enabled', async () => {
+ setupStore( { supportsInstantSearch: true, isInstantSearchEnabled: false } );
+ render( );
+ await expect(
+ screen.findByText( 'Instant Search must be enabled for AI Answers to work.' )
+ ).resolves.toBeInTheDocument();
+ } );
+} );
diff --git a/projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js b/projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js
new file mode 100644
index 000000000000..866c18a22b32
--- /dev/null
+++ b/projects/packages/search/src/dashboard/hooks/test/use-ai-answers-settings.test.js
@@ -0,0 +1,248 @@
+import { renderHook, act, waitFor } from '@testing-library/react';
+import apiFetch from '@wordpress/api-fetch';
+import useAiAnswersSettings, { DEFAULT_PERSONALITY } from '../use-ai-answers-settings';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+const SETTINGS_KEY = 'jetpack_search_ai_behavior_instructions';
+
+const BLOCK_NAME = 'jetpack/search-ai-summary';
+
+const makePost = ( overrides = {} ) => ( {
+ id: 42,
+ guideline_categories: {
+ blocks: { [ BLOCK_NAME ]: { guidelines: 'Be concise.' } },
+ },
+ ...overrides,
+} );
+
+describe( 'useAiAnswersSettings', () => {
+ afterEach( () => {
+ jest.resetAllMocks();
+ } );
+
+ describe( 'initial load — guidelines endpoint available', () => {
+ it( 'starts in loading state', () => {
+ apiFetch.mockReturnValue( new Promise( () => {} ) );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ expect( result.current.isLoading ).toBe( true );
+ expect( result.current.content ).toBe( '' );
+ expect( result.current.postId ).toBeNull();
+ expect( result.current.error ).toBeNull();
+ expect( result.current.isUnavailable ).toBe( false );
+ } );
+
+ it( 'sets content and postId from fetched post', async () => {
+ apiFetch.mockResolvedValue( [ makePost() ] );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.postId ).toBe( 42 );
+ expect( result.current.content ).toBe( 'Be concise.' );
+ } );
+
+ it( 'accepts a single post object (not an array)', async () => {
+ apiFetch.mockResolvedValue( makePost( { id: 7 } ) );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.postId ).toBe( 7 );
+ } );
+
+ it( 'leaves content empty when post has no guidelines', async () => {
+ apiFetch.mockResolvedValue( [ makePost( { guideline_categories: { blocks: {} } } ) ] );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.content ).toBe( '' );
+ } );
+
+ it( 'leaves content empty when no guidelines post exists', async () => {
+ apiFetch.mockResolvedValue( { id: 0, guideline_categories: {} } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.content ).toBe( '' );
+ expect( result.current.postId ).toBeNull();
+ } );
+
+ it( 'sets error on generic fetch failure', async () => {
+ apiFetch.mockRejectedValue( { message: 'Network failure' } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.error ).toBe( 'Network failure' );
+ expect( result.current.isUnavailable ).toBe( false );
+ } );
+ } );
+
+ describe( 'initial load — guidelines endpoint absent, settings fallback', () => {
+ it( 'falls back to /wp/v2/settings on rest_no_route', async () => {
+ apiFetch
+ .mockRejectedValueOnce( { code: 'rest_no_route' } )
+ .mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Answer in French.' } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.content ).toBe( 'Answer in French.' );
+ expect( result.current.isUnavailable ).toBe( false );
+ } );
+
+ it( 'falls back to /wp/v2/settings on 404 status error', async () => {
+ apiFetch
+ .mockRejectedValueOnce( { data: { status: 404 } } )
+ .mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Be brief.' } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.content ).toBe( 'Be brief.' );
+ expect( result.current.isUnavailable ).toBe( false );
+ } );
+
+ it( 'leaves content empty when option is not set', async () => {
+ apiFetch.mockRejectedValueOnce( { code: 'rest_no_route' } ).mockResolvedValueOnce( {} );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.content ).toBe( '' );
+ } );
+
+ it( 'sets isUnavailable when both endpoints fail', async () => {
+ apiFetch
+ .mockRejectedValueOnce( { code: 'rest_no_route' } )
+ .mockRejectedValueOnce( { message: 'Settings unavailable' } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+ expect( result.current.isUnavailable ).toBe( true );
+ expect( result.current.error ).toBeNull();
+ } );
+ } );
+
+ describe( 'savePersonality — guidelines path', () => {
+ it( 'POSTs when postId is null', async () => {
+ apiFetch.mockResolvedValueOnce( { id: 0 } ).mockResolvedValueOnce( { id: 99 } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ await act( async () => result.current.savePersonality() );
+
+ const saveCall = apiFetch.mock.calls[ 1 ][ 0 ];
+ expect( saveCall.method ).toBe( 'POST' );
+ expect( saveCall.path ).toBe( '/wp/v2/content-guidelines' );
+ } );
+
+ it( 'PATCHes when postId exists', async () => {
+ apiFetch.mockResolvedValueOnce( makePost() ).mockResolvedValueOnce( { id: 42 } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ await act( async () => result.current.savePersonality() );
+
+ const saveCall = apiFetch.mock.calls[ 1 ][ 0 ];
+ expect( saveCall.method ).toBe( 'PATCH' );
+ expect( saveCall.path ).toBe( '/wp/v2/content-guidelines/42' );
+ } );
+
+ it( 'sets saved and updates postId on success', async () => {
+ apiFetch.mockResolvedValueOnce( { id: 0 } ).mockResolvedValueOnce( { id: 55 } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ await act( async () => result.current.savePersonality() );
+
+ expect( result.current.saved ).toBe( true );
+ expect( result.current.postId ).toBe( 55 );
+ expect( result.current.isSaving ).toBe( false );
+ } );
+
+ it( 'sets error on save failure', async () => {
+ apiFetch
+ .mockResolvedValueOnce( { id: 0 } )
+ .mockRejectedValueOnce( { message: 'Save failed' } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ await act( async () => result.current.savePersonality() );
+
+ expect( result.current.error ).toBe( 'Save failed' );
+ expect( result.current.saved ).toBe( false );
+ expect( result.current.isSaving ).toBe( false );
+ } );
+
+ it( 'sends DEFAULT_PERSONALITY when content is empty', async () => {
+ apiFetch.mockResolvedValueOnce( { id: 0 } ).mockResolvedValueOnce( { id: 1 } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ await act( async () => result.current.savePersonality() );
+
+ const saveCall = apiFetch.mock.calls[ 1 ][ 0 ];
+ expect( saveCall.data.guideline_categories.blocks[ BLOCK_NAME ].guidelines ).toBe(
+ DEFAULT_PERSONALITY
+ );
+ } );
+
+ it( 'sends user content when content is non-empty', async () => {
+ apiFetch.mockResolvedValueOnce( makePost() ).mockResolvedValueOnce( { id: 42 } );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ act( () => result.current.setContent( 'Custom instructions.' ) );
+ await act( async () => result.current.savePersonality() );
+
+ const saveCall = apiFetch.mock.calls[ 1 ][ 0 ];
+ expect( saveCall.data.guideline_categories.blocks[ BLOCK_NAME ].guidelines ).toBe(
+ 'Custom instructions.'
+ );
+ } );
+ } );
+
+ describe( 'savePersonality — settings fallback path', () => {
+ const setupSettingsFallback = async () => {
+ apiFetch
+ .mockRejectedValueOnce( { code: 'rest_no_route' } )
+ .mockResolvedValueOnce( { [ SETTINGS_KEY ]: 'Be concise.' } );
+ const utils = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( utils.result.current.isLoading ).toBe( false ) );
+ return utils;
+ };
+
+ it( 'POSTs to /wp/v2/settings', async () => {
+ const { result } = await setupSettingsFallback();
+ apiFetch.mockResolvedValueOnce( {} );
+
+ await act( async () => result.current.savePersonality() );
+
+ const saveCall = apiFetch.mock.calls[ 2 ][ 0 ];
+ expect( saveCall.method ).toBe( 'POST' );
+ expect( saveCall.path ).toBe( '/wp/v2/settings' );
+ expect( saveCall.data[ SETTINGS_KEY ] ).toBe( 'Be concise.' );
+ } );
+
+ it( 'sends DEFAULT_PERSONALITY when content is empty', async () => {
+ apiFetch
+ .mockRejectedValueOnce( { code: 'rest_no_route' } )
+ .mockResolvedValueOnce( {} )
+ .mockResolvedValueOnce( {} );
+ const { result } = renderHook( () => useAiAnswersSettings() );
+ await waitFor( () => expect( result.current.isLoading ).toBe( false ) );
+
+ await act( async () => result.current.savePersonality() );
+
+ const saveCall = apiFetch.mock.calls[ 2 ][ 0 ];
+ expect( saveCall.data[ SETTINGS_KEY ] ).toBe( DEFAULT_PERSONALITY );
+ } );
+
+ it( 'sets saved on success', async () => {
+ const { result } = await setupSettingsFallback();
+ apiFetch.mockResolvedValueOnce( {} );
+
+ await act( async () => result.current.savePersonality() );
+
+ expect( result.current.saved ).toBe( true );
+ expect( result.current.isSaving ).toBe( false );
+ } );
+
+ it( 'sets error on save failure', async () => {
+ const { result } = await setupSettingsFallback();
+ apiFetch.mockRejectedValueOnce( { message: 'Settings save failed' } );
+
+ await act( async () => result.current.savePersonality() );
+
+ expect( result.current.error ).toBe( 'Settings save failed' );
+ expect( result.current.saved ).toBe( false );
+ } );
+ } );
+} );
diff --git a/projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js b/projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js
new file mode 100644
index 000000000000..75b612f317b8
--- /dev/null
+++ b/projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js
@@ -0,0 +1,104 @@
+import apiFetch from '@wordpress/api-fetch';
+import { __ } from '@wordpress/i18n';
+import { useEffect, useState } from 'react';
+
+const GUIDELINES_REST = '/wp/v2/content-guidelines';
+const SETTINGS_REST = '/wp/v2/settings';
+const SETTINGS_KEY = 'jetpack_search_ai_behavior_instructions';
+const BLOCK_NAME = 'jetpack/search-ai-summary';
+
+export const DEFAULT_PERSONALITY = __(
+ 'You are a search results summarizer for Jetpack Search. Your job is to summarize the best available successful search results in a succinct manner.',
+ 'jetpack-search-pkg'
+);
+
+/**
+ * Manages loading and saving the AI Answers personality instructions.
+ *
+ * Reads from /wp/v2/guidelines when the Gutenberg Guidelines CPT is available,
+ * falling back to /wp/v2/settings otherwise.
+ *
+ * @return {{ content: string, setContent: Function, postId: number|null, isSaving: boolean, isLoading: boolean, error: string|null, saved: boolean, isUnavailable: boolean, savePersonality: Function }} Hook state and actions for AI answers personality settings.
+ */
+export default function useAiAnswersSettings() {
+ const [ content, setContent ] = useState( '' );
+ const [ postId, setPostId ] = useState( null );
+ const [ useSettings, setUseSettings ] = useState( false );
+ const [ isSaving, setIsSaving ] = useState( false );
+ const [ isLoading, setIsLoading ] = useState( true );
+ const [ error, setError ] = useState( null );
+ const [ saved, setSaved ] = useState( false );
+ const [ isUnavailable, setIsUnavailable ] = useState( false );
+
+ useEffect( () => {
+ apiFetch( { path: GUIDELINES_REST } )
+ .then( posts => {
+ const post = Array.isArray( posts ) ? posts[ 0 ] : posts;
+ if ( post && post.id ) {
+ setPostId( post.id );
+ setContent( post.guideline_categories?.blocks?.[ BLOCK_NAME ]?.guidelines ?? '' );
+ }
+ } )
+ .catch( err => {
+ if ( err.code === 'rest_no_route' || err.data?.status === 404 ) {
+ setUseSettings( true );
+ return apiFetch( { path: SETTINGS_REST } )
+ .then( settings => {
+ setContent( settings[ SETTINGS_KEY ] ?? '' );
+ } )
+ .catch( () => {
+ setIsUnavailable( true );
+ } );
+ }
+ setError( err.message );
+ } )
+ .finally( () => setIsLoading( false ) );
+ }, [] );
+
+ const savePersonality = () => {
+ setIsSaving( true );
+ setSaved( false );
+ setError( null );
+
+ let promise;
+ if ( useSettings ) {
+ promise = apiFetch( {
+ path: SETTINGS_REST,
+ method: 'POST',
+ data: { [ SETTINGS_KEY ]: content || DEFAULT_PERSONALITY },
+ } );
+ } else {
+ const path = postId ? `${ GUIDELINES_REST }/${ postId }` : GUIDELINES_REST;
+ const method = postId ? 'PATCH' : 'POST';
+ promise = apiFetch( {
+ path,
+ method,
+ data: {
+ status: 'publish',
+ guideline_categories: {
+ blocks: { [ BLOCK_NAME ]: { guidelines: content || DEFAULT_PERSONALITY } },
+ },
+ },
+ } ).then( post => {
+ setPostId( post.id );
+ } );
+ }
+
+ promise
+ .then( () => setSaved( true ) )
+ .catch( err => setError( err.message ) )
+ .finally( () => setIsSaving( false ) );
+ };
+
+ return {
+ content,
+ setContent,
+ postId,
+ isSaving,
+ isLoading,
+ error,
+ saved,
+ isUnavailable,
+ savePersonality,
+ };
+}
diff --git a/projects/packages/search/src/dashboard/hooks/use-search-settings.js b/projects/packages/search/src/dashboard/hooks/use-search-settings.js
new file mode 100644
index 000000000000..8966bb4578e0
--- /dev/null
+++ b/projects/packages/search/src/dashboard/hooks/use-search-settings.js
@@ -0,0 +1,26 @@
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useCallback } from 'react';
+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.
+ */
+export default function useSearchSettings() {
+ const isAiAnswersEnabled = useSelect( select => select( STORE_ID ).isAiAnswersEnabled(), [] );
+ const isInstantSearchEnabled = useSelect(
+ select => select( STORE_ID ).isInstantSearchEnabled(),
+ []
+ );
+
+ const { updateJetpackSettings } = useDispatch( STORE_ID );
+
+ const setAiAnswersEnabled = useCallback(
+ value => updateJetpackSettings( { ai_answers_enabled: value } ),
+ [ updateJetpackSettings ]
+ );
+
+ return { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled };
+}
diff --git a/projects/packages/search/src/dashboard/scss/_variables.scss b/projects/packages/search/src/dashboard/scss/_variables.scss
index 249958b32e44..64d2e68ebbb2 100644
--- a/projects/packages/search/src/dashboard/scss/_variables.scss
+++ b/projects/packages/search/src/dashboard/scss/_variables.scss
@@ -18,6 +18,7 @@ $jp-gray-off: #e2e2df;
$jp-green-primary: #069e08;
$jp-green-secondary: #2fb41f;
+$jp-red-error: #d63638;
$jp-border-radius: 4px;
$jp-menu-border-height: 1px;
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 dd98ebeefd74..78e52de7138f 100644
--- a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js
+++ b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js
@@ -21,6 +21,7 @@ const jetpackSettingSelectors = {
isReaderChatAvailable: state =>
Object.prototype.hasOwnProperty.call( state.jetpackSettings, 'reader_chat' ),
isReaderChatEnabled: state => state.jetpackSettings.reader_chat,
+ isAiAnswersEnabled: state => !! state.jetpackSettings.ai_answers_enabled,
isUpdatingJetpackSettings: state => state.jetpackSettings.is_updating,
isTogglingModule: state => state.jetpackSettings.is_toggling_module,
isTogglingInstantSearch: state => state.jetpackSettings.is_toggling_instant_search,
diff --git a/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js b/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js
index 91ab31ede876..b6a9737fc44d 100644
--- a/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js
+++ b/projects/packages/search/src/dashboard/store/selectors/test/jetpack-settings.test.js
@@ -26,4 +26,21 @@ describe( 'jetpackSettingSelectors', () => {
} )
).toBe( true );
} );
+
+ describe( 'isAiAnswersEnabled', () => {
+ it( 'returns false when ai_answers_enabled is false', () => {
+ const state = { jetpackSettings: { ai_answers_enabled: false } };
+ expect( jetpackSettingSelectors.isAiAnswersEnabled( state ) ).toBe( false );
+ } );
+
+ it( 'returns true when ai_answers_enabled is true', () => {
+ const state = { jetpackSettings: { ai_answers_enabled: true } };
+ expect( jetpackSettingSelectors.isAiAnswersEnabled( state ) ).toBe( true );
+ } );
+
+ it( 'returns false when ai_answers_enabled is undefined', () => {
+ const state = { jetpackSettings: {} };
+ expect( jetpackSettingSelectors.isAiAnswersEnabled( state ) ).toBe( false );
+ } );
+ } );
} );
diff --git a/projects/packages/search/src/initializers/class-initializer.php b/projects/packages/search/src/initializers/class-initializer.php
index b185ddeffb4f..1a85e30f96f8 100644
--- a/projects/packages/search/src/initializers/class-initializer.php
+++ b/projects/packages/search/src/initializers/class-initializer.php
@@ -125,6 +125,8 @@ protected static function init_before_connection() {
if ( apply_filters( 'jetpack_search_blocks_enabled', false ) ) {
Search_Blocks::init();
}
+
+ ( new AI_Answers() )->init();
}
/**
diff --git a/projects/packages/search/src/instant-search/components/animated-ellipsis.jsx b/projects/packages/search/src/instant-search/components/animated-ellipsis.jsx
new file mode 100644
index 000000000000..66a09f7fbed0
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/animated-ellipsis.jsx
@@ -0,0 +1,33 @@
+import * as React from 'react';
+import { useEffect, useState } from 'react';
+
+// Duration of each dot appearing, in ms.
+const DOT_INTERVAL = 200;
+// Pause duration after all three dots are shown, in ms.
+const PAUSE_DURATION = 1000;
+
+const DELAYS = [ DOT_INTERVAL, DOT_INTERVAL, DOT_INTERVAL, PAUSE_DURATION ];
+const DOT_STRINGS = [ '', '.', '..', '...' ];
+
+/**
+ * Renders an animated ellipsis that types out one period at a time,
+ * pauses, then resets.
+ *
+ * @return {React.ReactElement} Animated ellipsis span.
+ */
+export default function AnimatedEllipsis() {
+ const [ step, setStep ] = useState( 0 );
+
+ useEffect( () => {
+ const timeout = setTimeout( () => {
+ setStep( s => ( s + 1 ) % 4 );
+ }, DELAYS[ step ] );
+ return () => clearTimeout( timeout );
+ }, [ step ] );
+
+ return (
+
+ { DOT_STRINGS[ step ] }
+
+ );
+}
diff --git a/projects/packages/search/src/instant-search/components/answers-panel.jsx b/projects/packages/search/src/instant-search/components/answers-panel.jsx
new file mode 100644
index 000000000000..e1e506993f67
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/answers-panel.jsx
@@ -0,0 +1,193 @@
+import { __, sprintf } from '@wordpress/i18n';
+import * as React from 'react';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { markdownToHtml } from '../lib/markdown';
+import AnimatedEllipsis from './animated-ellipsis';
+import './answers-panel.scss';
+
+const ExternalLinkIcon = () => (
+
+);
+
+/**
+ * AI Answers panel displayed above search results.
+ *
+ * @param {object} props - Component props.
+ * @param {string} props.status - 'idle' | 'loading' | 'streaming' | 'done' | 'error'
+ * @param {string} props.text - Accumulated answer text (markdown).
+ * @param {Array} props.citations - Array of { title, url, excerpt } citation objects.
+ * @param {object} props.error - Error info: { message, code, source } or null.
+ * @param {string|null} props.loadingHint - Placeholder text shown while the extended answer is loading.
+ * @param {Function|null} props.onShowMore - Function: show a "Show more" button to switch to the extended answer. null: extended mode active, render full content. undefined: standard overflow toggle.
+ * @return {React.ReactElement|null} The rendered panel or null.
+ */
+export default function AnswersPanel( {
+ status,
+ text,
+ citations = [],
+ error = null,
+ loadingHint = null,
+ onShowMore,
+} ) {
+ const [ expanded, setExpanded ] = useState( false );
+ const [ overflows, setOverflows ] = useState( false );
+ const contentRef = useRef( null );
+
+ // Reset collapse state when the displayed answer switches (e.g. brief → extended loading).
+ useEffect( () => {
+ if ( status === 'loading' || status === 'streaming' ) {
+ setExpanded( false );
+ setOverflows( false );
+ }
+ }, [ status ] );
+
+ // useLayoutEffect fires synchronously after DOM update but before paint,
+ // so overflow detection and button render happen in the same frame as the collapse.
+ useLayoutEffect( () => {
+ if ( status === 'done' && contentRef.current ) {
+ setOverflows( contentRef.current.scrollHeight > contentRef.current.clientHeight );
+ }
+ }, [ status, text ] );
+
+ if ( status === 'idle' ) {
+ return null;
+ }
+
+ if ( status === 'error' ) {
+ return (
+
+
+ { __( 'AI answer', 'jetpack-search-pkg' ) }
+
+
+
+ { __( 'Sorry, an error occurred while generating an answer.', 'jetpack-search-pkg' ) }
+
+ { error && (
+
+ { error.message }
+ { error.code !== null && (
+ <>
+
+ {
+ /* translators: %s: numeric error code */ sprintf(
+ __( 'Error code: %s', 'jetpack-search-pkg' ),
+ error.code
+ )
+ }
+ >
+ ) }
+
+ ) }
+
+
+ );
+ }
+
+ // onShowMore semantics:
+ // function → brief answer is done; show a "Show more" button to load the extended answer
+ // null → extended mode is active; render full combined content without collapse
+ // undefined → no dual-answer flow; use standard overflow expand/collapse toggle
+ const isCollapsible = status === 'done';
+ // In extended mode (null) the panel is always fully expanded.
+ const isCollapsed = isCollapsible && onShowMore === undefined && ! expanded;
+ // Fixed height only during the loading placeholder and when collapsed after done.
+ // During streaming the panel grows naturally with the incoming content.
+ const isFixedHeight = status === 'loading' || isCollapsed;
+ const showMoreLabel = __( 'Show more', 'jetpack-search-pkg' );
+ const showLessLabel = __( 'Show less', 'jetpack-search-pkg' );
+
+ return (
+
+
+ { __( 'AI answer', 'jetpack-search-pkg' ) }
+
+ { status === 'loading' && (
+
+ { __( 'Finding an answer', 'jetpack-search-pkg' ) }
+
+
+ ) }
+ { ( status === 'streaming' || status === 'done' ) && (
+
+
+ { status === 'done' && citations.length > 0 && (
+
+ { citations.map( ( { title, url }, i ) => (
+ -
+
+ { title }
+
+
+
+ ) ) }
+
+ ) }
+
+ ) }
+ { onShowMore === null && loadingHint && (
+
+ { loadingHint.endsWith( '…' ) ? loadingHint.slice( 0, -1 ) : loadingHint }
+
+
+ ) }
+ { isCollapsible && typeof onShowMore === 'function' && (
+
+ ) }
+ { isCollapsible && onShowMore === undefined && overflows && (
+
+ ) }
+
+ );
+}
diff --git a/projects/packages/search/src/instant-search/components/answers-panel.scss b/projects/packages/search/src/instant-search/components/answers-panel.scss
new file mode 100644
index 000000000000..2ce6655483ac
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/answers-panel.scss
@@ -0,0 +1,210 @@
+@use "../lib/styles/helper";
+@use "@wordpress/base-styles/mixins" as wp-mixins;
+
+.jp-search-answers-panel {
+ background: helper.$color-modal-background;
+ border: 1px solid helper.$color-border;
+ border-radius: helper.$border-radius-panel;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+ margin: 0 helper.$results-margin-lg 16px;
+ overflow: hidden;
+
+ &--fixed-height {
+ display: flex;
+ flex-direction: column;
+ height: 160px;
+
+ .jp-search-answers-panel__content {
+ flex: 1;
+ max-height: none; // flex constrains height; max-height: 5em from --collapsed is overridden
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .jp-search-answers-panel__toggle {
+ flex-shrink: 0;
+ margin-top: auto;
+ }
+ }
+
+ @include helper.multiple-breaks-for-customberg( " {
+ const keys = {
+ status: statePrefix + 'Status',
+ text: statePrefix + 'Text',
+ citations: statePrefix + 'Citations',
+ error: statePrefix + 'Error',
+ };
+
+ this.setState( {
+ [ keys.status ]: 'loading',
+ [ keys.text ]: '',
+ [ keys.citations ]: [],
+ [ keys.error ]: null,
+ } );
+
+ const url =
+ 'https://public-api.wordpress.com/wpcom/v2/ai/agent/jetpack-workflow-search_summarizer';
+
+ const HTTP_STATUS_NAMES = {
+ 400: 'Bad Request',
+ 401: 'Unauthorized',
+ 403: 'Forbidden',
+ 404: 'Not Found',
+ 429: 'Too Many Requests',
+ 500: 'Internal Server Error',
+ 502: 'Bad Gateway',
+ 503: 'Service Unavailable',
+ 504: 'Gateway Timeout',
+ };
+
+ // Captured by onopen before onerror fires, so .catch sees the real HTTP error.
+ let httpError = null;
+
+ fetchEventSource( url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify( {
+ jsonrpc: '2.0',
+ id: `req-${ format }`,
+ method: 'message/stream',
+ constructor_arguments: {},
+ params: {
+ ...( sessionId ? { sessionId } : {} ),
+ message: {
+ role: 'user',
+ parts: [
+ { type: 'text', text: query },
+ {
+ type: 'data',
+ data: {
+ clientContext: {
+ selectedSiteId: siteId,
+ site_url: options.homeUrl || '',
+ filters: this.props.filters,
+ locale: options.locale || 'en',
+ aiAnswersFormat: format,
+ },
+ },
+ metadata: {},
+ },
+ ],
+ kind: 'message',
+ messageId: `msg-${ format }`,
+ },
+ },
+ tokenStreaming: true,
+ } ),
+ signal: controller.signal,
+ onopen: async response => {
+ if ( ! response.ok ) {
+ httpError = {
+ message: HTTP_STATUS_NAMES[ response.status ] || `HTTP ${ response.status }`,
+ code: response.status,
+ source: 'http',
+ };
+ throw new Error( `HTTP ${ response.status }` );
+ }
+ },
+ onmessage: event => {
+ try {
+ const data = JSON.parse( event.data );
+ if ( data.result?.sessionId && onSessionId ) {
+ onSessionId( data.result.sessionId );
+ }
+ if ( data.method === 'message/delta' && data.params?.delta?.deltaType === 'content' ) {
+ // flushSync forces a synchronous render for each token so the
+ // text streams visibly rather than batching into one update
+ // (React 18 automatic batching would otherwise collapse rapid
+ // SSE events into a single render).
+ flushSync( () => {
+ this.setState( state => ( {
+ [ keys.status ]: 'streaming',
+ [ keys.text ]: state[ keys.text ] + ( data.params.delta.content ?? '' ),
+ } ) );
+ } );
+ } else if (
+ data.result?.type === 'TaskStatusUpdateEvent' &&
+ data.result?.status?.state === 'completed'
+ ) {
+ const parts = data.result.status.message?.parts || [];
+ const dataPart = parts.find( p => p.type === 'data' );
+ const citations = dataPart?.data?.sources || dataPart?.data?.strict_sources || [];
+ this.setState( { [ keys.status ]: 'done', [ keys.citations ]: citations } );
+ } else if ( data.result?.status?.state === 'failed' || data.error ) {
+ const textPart = data.result?.status?.message?.parts?.find( p => p.type === 'text' );
+ const message = data.error?.message || textPart?.text || 'Request failed';
+ const code = data.error?.code ?? null;
+ this.setState( {
+ [ keys.status ]: 'error',
+ [ keys.error ]: { message, code, source: 'api' },
+ } );
+ }
+ } catch {
+ // Ignore unparseable events.
+ }
+ },
+ onerror: () => {
+ // Rethrow without setting state — .catch handles all error state
+ // so httpError captured in onopen isn't overwritten.
+ throw new Error( 'onerror' );
+ },
+ } ).catch( () => {
+ if ( ! controller.signal.aborted ) {
+ this.setState( {
+ [ keys.status ]: 'error',
+ [ keys.error ]: httpError ?? {
+ message: 'Network request error',
+ code: null,
+ source: 'network',
+ },
+ } );
+ }
+ } );
+ };
+
+ getAiAnswer = () => {
+ const query = this.props.searchQuery;
+ const options = window[ SERVER_OBJECT_NAME ] || {};
+ const siteId = options.siteId;
+
+ const idleState = {
+ aiBriefStatus: 'idle',
+ aiBriefText: '',
+ aiBriefCitations: [],
+ aiBriefError: null,
+ aiExtendedStatus: 'idle',
+ aiExtendedText: '',
+ aiExtendedCitations: [],
+ aiExtendedError: null,
+ aiExtendedLoadingText: '',
+ aiShowExtended: false,
+ aiSessionId: null,
+ };
+
+ if ( ! query || query.length < 3 ) {
+ this.setState( idleState );
+ return;
+ }
+
+ // Respect the admin's AI Answers toggle.
+ if ( options.aiAnswersEnabled === false ) {
+ this.setState( idleState );
+ return;
+ }
+
+ // Abort any in-flight requests from the previous query.
+ if ( this.aiBriefController ) {
+ this.aiBriefController.abort();
+ }
+ if ( this.aiExtendedController ) {
+ this.aiExtendedController.abort();
+ this.aiExtendedController = null;
+ }
+ this.aiBriefController = new AbortController();
+
+ this.setState( { aiShowExtended: false } );
+
+ this.streamAiAnswer( {
+ controller: this.aiBriefController,
+ statePrefix: 'aiBrief',
+ query,
+ siteId,
+ options,
+ format: 'brief',
+ onSessionId: id => this.setState( { aiSessionId: id } ),
+ } );
+ };
+
+ handleShowMore = () => {
+ const query = this.props.searchQuery;
+ const options = window[ SERVER_OBJECT_NAME ] || {};
+ const siteId = options.siteId;
+
+ const loadingMessages = [
+ __( 'Searching harder…', 'jetpack-search-pkg' ),
+ __( 'Looking deeper into this…', 'jetpack-search-pkg' ),
+ __( 'Finding a more complete answer…', 'jetpack-search-pkg' ),
+ __( 'Analyzing additional sources…', 'jetpack-search-pkg' ),
+ __( 'Gathering more details…', 'jetpack-search-pkg' ),
+ __( 'Pulling in more context…', 'jetpack-search-pkg' ),
+ __( 'Expanding the search…', 'jetpack-search-pkg' ),
+ __( 'Rolling up my virtual sleeves…', 'jetpack-search-pkg' ),
+ __( 'Digging through the archives…', 'jetpack-search-pkg' ),
+ __( 'Putting on my reading glasses…', 'jetpack-search-pkg' ),
+ __( 'Checking under the digital couch cushions…', 'jetpack-search-pkg' ),
+ __( 'Consulting the oracle…', 'jetpack-search-pkg' ),
+ __( 'Asking a smarter algorithm…', 'jetpack-search-pkg' ),
+ __( 'Brewing a fresh batch of insights…', 'jetpack-search-pkg' ),
+ __( 'Unleashing the full power of search…', 'jetpack-search-pkg' ),
+ ];
+ const aiExtendedLoadingText =
+ loadingMessages[ Math.floor( Math.random() * loadingMessages.length ) ];
+
+ this.aiExtendedController = new AbortController();
+ this.setState( { aiShowExtended: true, aiExtendedLoadingText } );
+ this.streamAiAnswer( {
+ controller: this.aiExtendedController,
+ statePrefix: 'aiExtended',
+ query,
+ siteId,
+ options,
+ format: 'extended',
+ sessionId: this.state.aiSessionId,
+ } );
+ };
+
updateOverlayOptions = ( newOverlayOptions, callback ) => {
this.setState(
state => ( {
@@ -248,6 +532,58 @@ class SearchApp extends Component {
const noop = input => input;
const resultFormat = this.getResultFormat();
+ const {
+ aiBriefStatus,
+ aiBriefText,
+ aiBriefCitations,
+ aiBriefError,
+ aiExtendedStatus,
+ aiExtendedText,
+ aiExtendedCitations,
+ aiExtendedError,
+ aiExtendedLoadingText,
+ aiShowExtended,
+ } = this.state;
+
+ // When showing extended: brief text stays visible; extended text is appended as it arrives.
+ // Use 'streaming' instead of 'loading' so the text area renders while extended is fetching.
+ let displayStatus;
+ if ( aiShowExtended ) {
+ displayStatus = aiExtendedStatus === 'loading' ? 'streaming' : aiExtendedStatus;
+ } else {
+ displayStatus = aiBriefStatus;
+ }
+
+ let displayText;
+ if ( aiShowExtended ) {
+ displayText = aiExtendedText ? aiBriefText + '\n\n' + aiExtendedText : aiBriefText;
+ } else {
+ displayText = aiBriefText;
+ }
+
+ let displayCitations;
+ if ( aiShowExtended ) {
+ displayCitations = aiExtendedStatus === 'done' ? aiExtendedCitations : aiBriefCitations;
+ } else {
+ displayCitations = aiBriefCitations;
+ }
+
+ const displayError = aiShowExtended ? aiExtendedError : aiBriefError;
+
+ // Show loading hint below brief answer while extended request hasn't started streaming yet.
+ const aiLoadingHint =
+ aiShowExtended && aiExtendedStatus === 'loading' ? aiExtendedLoadingText : null;
+
+ // null = extended mode active — render full content without collapse
+ // fn = brief is done and extended not yet shown — render "Show more" button
+ // undef = no dual-answer flow — render standard overflow toggle
+ let onShowMoreAiAnswer;
+ if ( aiShowExtended ) {
+ onShowMoreAiAnswer = null;
+ } else if ( aiBriefStatus === 'done' ) {
+ onShowMoreAiAnswer = this.handleShowMore;
+ }
+
const portalFn = this.props.shouldCreatePortal ? createPortal : noop;
return (
@@ -275,10 +611,19 @@ class SearchApp extends Component {
closeColor={ this.state.overlayOptions.closeColor }
closeOverlay={ this.hideResults }
colorTheme={ this.state.overlayOptions.colorTheme }
- hasOverlayWidgets={ this.props.hasOverlayWidgets }
+ hasOverlayWidgets={
+ this.props.hasOverlayWidgets ||
+ ( aiShowExtended ? aiExtendedCitations.length > 0 : aiBriefCitations.length > 0 )
+ }
isVisible={ this.state.isVisible }
>
l", ">xl" ) {
- position: absolute;
- right: helper.$sidebar-width;
- }
-
- .jetpack-instant-search__overlay--no-sidebar & {
- right: 0;
}
}
diff --git a/projects/packages/search/src/instant-search/components/search-results.jsx b/projects/packages/search/src/instant-search/components/search-results.jsx
index 16962f9a57dd..2240f6bc5771 100644
--- a/projects/packages/search/src/instant-search/components/search-results.jsx
+++ b/projects/packages/search/src/instant-search/components/search-results.jsx
@@ -6,6 +6,7 @@ import { getConstrastingColor } from '../lib/colors';
import { MULTISITE_NO_GROUP_VALUE, OVERLAY_FOCUS_ANCHOR_ID } from '../lib/constants';
import { getErrorMessage } from '../lib/errors';
import { getAvailableStaticFilters } from '../lib/filters';
+import AnswersPanel from './answers-panel';
import Gridicon from './gridicon';
import JetpackColophon from './jetpack-colophon';
import Notice from './notice';
@@ -152,9 +153,50 @@ class SearchResults extends Component {
`,
} }
/>
+
- { this.getSearchTitle() }
+
+
+ { this.getSearchTitle() }
+
+
+ { ( this.hasFilterOptions() || this.props.hasNonSearchWidgets ) && (
+
+ { __( 'Filters', 'jetpack-search-pkg' ) }
+
+
+ { this.state.shouldShowMobileSecondary
+ ? __( 'Hide filters', 'jetpack-search-pkg' )
+ : __( 'Show filters', 'jetpack-search-pkg' ) }
+
+
+ ) }
+
+
{ hasResults && hasCorrectedQuery && (
@@ -264,36 +306,6 @@ class SearchResults extends Component {
-
- { ( this.hasFilterOptions() || this.props.hasNonSearchWidgets ) && (
-
- { __( 'Filters', 'jetpack-search-pkg' ) }
-
-
- { this.state.shouldShowMobileSecondary
- ? __( 'Hide filters', 'jetpack-search-pkg' )
- : __( 'Show filters', 'jetpack-search-pkg' ) }
-
-
- ) }
-
-
l", ">xl" ) {
- // Account for .jetpack-instant-search__search-form-controls.
- padding-right: 210px;
- }
-}
-
.jetpack-instant-search__search-results-list {
color: helper.$color-text;
list-style: none;
diff --git a/projects/packages/search/src/instant-search/components/sidebar.jsx b/projects/packages/search/src/instant-search/components/sidebar.jsx
index 5423c3bbdfbb..cdcd9419d74b 100644
--- a/projects/packages/search/src/instant-search/components/sidebar.jsx
+++ b/projects/packages/search/src/instant-search/components/sidebar.jsx
@@ -8,6 +8,32 @@ import './sidebar.scss';
const Sidebar = props => {
return (
+ { props.citations?.length > 0 && (
+
+ ) }
{ /* If widgetOutsideOverlay doesn't contain any filters,
this component will just show the title and clear filters button. */ }
{
+ it( 'renders nothing when idle', () => {
+ const { container } = render( );
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'shows loading message', () => {
+ render( );
+ expect( screen.getByText( 'Finding an answer', { exact: false } ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows streamed text', () => {
+ render( );
+ expect( screen.getByText( 'Here is how to…' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows full text and citations when done', () => {
+ const citations = [ { title: 'Reset Password', url: '/reset', excerpt: '' } ];
+ render( );
+ expect( screen.getByText( 'Reset here.' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Reset Password' ) ).toBeInTheDocument();
+ } );
+
+ describe( 'error state', () => {
+ it( 'shows error message when error prop is null', () => {
+ render( );
+ expect(
+ screen.getByText( 'Sorry, an error occurred while generating an answer.' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows no detail line when error prop is null', () => {
+ render( );
+ expect( screen.queryByTestId( 'answers-panel-error-detail' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows network error message', () => {
+ const error = { message: 'Network request error', code: null, source: 'network' };
+ render( );
+ expect( screen.getByText( 'Network request error' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows API error message', () => {
+ const error = {
+ message: 'An error occurred while processing the request. Please try again later.',
+ code: -32000,
+ source: 'api',
+ };
+ render( );
+ expect(
+ screen.getByText( /An error occurred while processing the request/ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'shows "Error code: X" when code is present', () => {
+ const error = { message: 'Request failed', code: -32000, source: 'api' };
+ render( );
+ expect( screen.getByText( /Error code: -32000/ ) ).toBeInTheDocument();
+ } );
+
+ it( 'omits error code line when code is null', () => {
+ const error = { message: 'Network request error', code: null, source: 'network' };
+ render( );
+ expect( screen.queryByText( /Error code:/ ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows HTTP friendly name as message', () => {
+ const error = { message: 'Service Unavailable', code: 503, source: 'http' };
+ render( );
+ expect( screen.getByText( /Service Unavailable/ ) ).toBeInTheDocument();
+ expect( screen.getByText( /Error code: 503/ ) ).toBeInTheDocument();
+ } );
+
+ it( 'still shows the AI answer heading in error state', () => {
+ render( );
+ expect( screen.getByText( 'AI answer' ) ).toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx b/projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx
new file mode 100644
index 000000000000..7dfdd9078ba1
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx
@@ -0,0 +1,119 @@
+/**
+ * Smoke tests for the AI Answers SSE wiring in SearchApp.
+ *
+ * These tests verify that when aiAnswersEnabled is absent/false,
+ * fetchEventSource is never called, and the component mounts without errors.
+ */
+
+// Mock fetchEventSource so no real network calls are made.
+jest.mock( '@microsoft/fetch-event-source', () => ( {
+ fetchEventSource: jest.fn(),
+} ) );
+
+// Mock heavy child components with PALETTE / other build-time globals.
+jest.mock( '../search-results', () => () => );
+jest.mock( '../overlay', () => ( { children } ) => { children }
);
+jest.mock( '../customizer-event-handler', () => () => null );
+jest.mock( '../dom-event-handler', () => () => null );
+
+// Mock react-redux connect to pass through as a no-op wrapper so we can
+// test the inner SearchApp class directly without a full Redux store.
+jest.mock( 'react-redux', () => ( {
+ connect: () => Component => Component,
+} ) );
+
+// Mock store actions — they are injected via connect; with connect mocked they
+// won't be passed, so we supply them as props below.
+jest.mock( '../../store/actions', () => ( {} ) );
+jest.mock( '../../store/selectors', () => ( {} ) );
+
+import { fetchEventSource } from '@microsoft/fetch-event-source';
+import { render } from '@testing-library/react';
+import * as React from 'react';
+import SearchApp from '../search-app';
+
+const noop = () => {};
+
+const defaultProps = {
+ enableAnalytics: false,
+ shouldIntegrateWithDom: false,
+ shouldCreatePortal: false,
+ isInCustomizer: false,
+ initialIsVisible: false,
+ initialHref: '/',
+ options: {
+ siteId: 123,
+ locale: 'en',
+ postTypes: [],
+ widgets: [],
+ additionalBlogIds: [],
+ isPhotonEnabled: false,
+ isPrivateSite: false,
+ postsPerPage: 10,
+ adminQueryFilter: '',
+ highlightFields: [],
+ customResults: {},
+ hasNonSearchWidgets: false,
+ },
+ overlayOptions: {
+ colorTheme: 'light',
+ enableInfScroll: false,
+ enableFilteringOpensOverlay: false,
+ enableSort: false,
+ enablePostDate: false,
+ enableProductPrice: false,
+ overlayTrigger: 'immediate',
+ resultFormat: 'minimal',
+ showPoweredBy: false,
+ highlightColor: '#000',
+ closeColor: '#000',
+ defaultSort: 'relevance',
+ excludedPostTypes: [],
+ },
+ themeOptions: { searchInputSelector: '.search-field' },
+ aggregations: {},
+ hasOverlayWidgets: false,
+ // Redux-injected props (normally provided by connect).
+ filters: {},
+ staticFilters: {},
+ hasActiveQuery: false,
+ hasError: false,
+ isHistoryNavigation: false,
+ hasNextPage: false,
+ isLoading: false,
+ response: {},
+ searchQuery: '',
+ sort: 'relevance',
+ widgetOutsideOverlay: null,
+ // Redux-injected action creators.
+ clearQueryValues: noop,
+ disableQueryStringIntegration: noop,
+ initializeQueryValues: noop,
+ makeSearchRequest: noop,
+ setStaticFilter: noop,
+ setFilter: noop,
+ setSearchQuery: noop,
+ setSort: noop,
+};
+
+describe( 'SearchApp AI Answers SSE wiring', () => {
+ beforeEach( () => {
+ fetchEventSource.mockClear();
+ window.JetpackInstantSearchOptions = { siteId: 123 };
+ } );
+
+ afterEach( () => {
+ delete window.JetpackInstantSearchOptions;
+ } );
+
+ it( 'mounts without errors when aiAnswersEnabled is absent', () => {
+ expect( () => {
+ render( );
+ } ).not.toThrow();
+ } );
+
+ it( 'does not call fetchEventSource when aiAnswersEnabled is absent', () => {
+ render( );
+ expect( fetchEventSource ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx b/projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx
new file mode 100644
index 000000000000..b2905b8379fd
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx
@@ -0,0 +1,38 @@
+const makeOptions = ( overrides = {} ) => ( {
+ siteId: 1,
+ aiAnswersEnabled: true,
+ ...overrides,
+} );
+
+describe( 'getAiAnswer aiAnswersEnabled gate', () => {
+ let originalOptions;
+
+ beforeEach( () => {
+ originalOptions = window.JetpackInstantSearchOptions;
+ } );
+
+ afterEach( () => {
+ window.JetpackInstantSearchOptions = originalOptions;
+ } );
+
+ it( 'does not call fetchEventSource when aiAnswersEnabled is false', () => {
+ window.JetpackInstantSearchOptions = makeOptions( { aiAnswersEnabled: false } );
+ const options = window.JetpackInstantSearchOptions;
+ const shouldSkip = options.aiAnswersEnabled === false;
+ expect( shouldSkip ).toBe( true );
+ } );
+
+ it( 'allows fetchEventSource when aiAnswersEnabled is true', () => {
+ window.JetpackInstantSearchOptions = makeOptions( { aiAnswersEnabled: true } );
+ const options = window.JetpackInstantSearchOptions;
+ const shouldSkip = options.aiAnswersEnabled === false;
+ expect( shouldSkip ).toBe( false );
+ } );
+
+ it( 'allows fetchEventSource when aiAnswersEnabled is undefined (legacy)', () => {
+ window.JetpackInstantSearchOptions = makeOptions( { aiAnswersEnabled: undefined } );
+ const options = window.JetpackInstantSearchOptions;
+ const shouldSkip = options.aiAnswersEnabled === false;
+ expect( shouldSkip ).toBe( false );
+ } );
+} );
diff --git a/projects/packages/search/src/instant-search/components/test/search-app-ai-state.test.jsx b/projects/packages/search/src/instant-search/components/test/search-app-ai-state.test.jsx
new file mode 100644
index 000000000000..67c853484c50
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/test/search-app-ai-state.test.jsx
@@ -0,0 +1,492 @@
+/**
+ * Tests for the AI Answers state management in SearchApp:
+ * - getAiAnswer guard conditions
+ * - streamAiAnswer SSE event handling
+ * - handleShowMore extended-answer flow
+ * - render() display-state derivation (brief vs. extended)
+ */
+
+jest.mock( '@microsoft/fetch-event-source', () => ( {
+ fetchEventSource: jest.fn(),
+} ) );
+
+let lastSearchResultsProps = {};
+jest.mock( '../search-results', () => props => {
+ lastSearchResultsProps = props;
+ return ;
+} );
+jest.mock( '../overlay', () => ( { children } ) => { children }
);
+jest.mock( '../customizer-event-handler', () => () => null );
+jest.mock( '../dom-event-handler', () => () => null );
+
+jest.mock( 'react-redux', () => ( {
+ connect: () => Component => Component,
+} ) );
+
+jest.mock( '../../store/actions', () => ( {} ) );
+jest.mock( '../../store/selectors', () => ( {} ) );
+
+import { fetchEventSource } from '@microsoft/fetch-event-source';
+import { act, render } from '@testing-library/react';
+import * as React from 'react';
+import SearchApp from '../search-app';
+
+const noop = () => {};
+
+const BASE_OPTIONS = {
+ siteId: 123,
+ aiAnswersEnabled: true,
+};
+
+const defaultProps = {
+ enableAnalytics: false,
+ shouldIntegrateWithDom: false,
+ shouldCreatePortal: false,
+ isInCustomizer: false,
+ initialIsVisible: false,
+ initialHref: '/',
+ options: {
+ siteId: 123,
+ locale: 'en',
+ postTypes: [],
+ widgets: [],
+ additionalBlogIds: [],
+ isPhotonEnabled: false,
+ isPrivateSite: false,
+ postsPerPage: 10,
+ adminQueryFilter: '',
+ highlightFields: [],
+ customResults: {},
+ hasNonSearchWidgets: false,
+ },
+ overlayOptions: {
+ colorTheme: 'light',
+ enableInfScroll: false,
+ enableFilteringOpensOverlay: false,
+ enableSort: false,
+ enablePostDate: false,
+ enableProductPrice: false,
+ overlayTrigger: 'immediate',
+ resultFormat: 'minimal',
+ showPoweredBy: false,
+ highlightColor: '#000',
+ closeColor: '#000',
+ defaultSort: 'relevance',
+ excludedPostTypes: [],
+ },
+ themeOptions: { searchInputSelector: '.search-field' },
+ aggregations: {},
+ hasOverlayWidgets: false,
+ filters: {},
+ staticFilters: {},
+ hasActiveQuery: false,
+ hasError: false,
+ isHistoryNavigation: false,
+ hasNextPage: false,
+ isLoading: false,
+ response: {},
+ searchQuery: '',
+ sort: 'relevance',
+ widgetOutsideOverlay: null,
+ clearQueryValues: noop,
+ disableQueryStringIntegration: noop,
+ initializeQueryValues: noop,
+ makeSearchRequest: noop,
+ setStaticFilter: noop,
+ setFilter: noop,
+ setSearchQuery: noop,
+ setSort: noop,
+};
+
+// Renders with an initial empty query then re-renders with the given query,
+// advancing fake timers past the 500 ms getAiAnswer debounce.
+const renderAndTriggerQuery = ( query, windowOpts = BASE_OPTIONS ) => {
+ window.JetpackInstantSearchOptions = windowOpts;
+ const utils = render( );
+ utils.rerender( );
+ act( () => {
+ jest.advanceTimersByTime( 600 );
+ } );
+ return utils;
+};
+
+// Returns the options object passed to the most recent fetchEventSource call.
+const lastFetchOpts = () => fetchEventSource.mock.calls.at( -1 )[ 1 ];
+
+// Manually fires an SSE onmessage event via the captured fetchEventSource callback.
+const fireMessage = data =>
+ act( () => {
+ lastFetchOpts().onmessage( { data: JSON.stringify( data ) } );
+ } );
+
+describe( 'SearchApp — getAiAnswer guard conditions', () => {
+ beforeEach( () => {
+ jest.useFakeTimers();
+ fetchEventSource.mockClear();
+ fetchEventSource.mockReturnValue( new Promise( () => {} ) );
+ lastSearchResultsProps = {};
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ delete window.JetpackInstantSearchOptions;
+ } );
+
+ it( 'does not call fetchEventSource when query is empty', () => {
+ window.JetpackInstantSearchOptions = BASE_OPTIONS;
+ render( );
+ act( () => jest.advanceTimersByTime( 600 ) );
+ expect( fetchEventSource ).not.toHaveBeenCalled();
+ } );
+
+ it( 'does not call fetchEventSource when query is shorter than 3 characters', () => {
+ renderAndTriggerQuery( 'hi' );
+ expect( fetchEventSource ).not.toHaveBeenCalled();
+ } );
+
+ it( 'does not call fetchEventSource when aiAnswersEnabled is false', () => {
+ renderAndTriggerQuery( 'reset my password', { ...BASE_OPTIONS, aiAnswersEnabled: false } );
+ expect( fetchEventSource ).not.toHaveBeenCalled();
+ } );
+
+ it( 'calls fetchEventSource with the brief format for a valid query', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ expect( fetchEventSource ).toHaveBeenCalledTimes( 1 );
+ const body = JSON.parse( lastFetchOpts().body );
+ expect( body.params.message.parts[ 1 ].data.clientContext.aiAnswersFormat ).toBe( 'brief' );
+ } );
+
+ it( 'posts the search query text to the AI endpoint', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ const body = JSON.parse( lastFetchOpts().body );
+ expect( body.params.message.parts[ 0 ].text ).toBe( 'reset my password' );
+ } );
+
+ it( 'passes the site ID from JetpackInstantSearchOptions', () => {
+ renderAndTriggerQuery( 'reset my password', { ...BASE_OPTIONS, siteId: 999 } );
+ const body = JSON.parse( lastFetchOpts().body );
+ expect( body.params.message.parts[ 1 ].data.clientContext.selectedSiteId ).toBe( 999 );
+ } );
+} );
+
+describe( 'SearchApp — streamAiAnswer SSE event handling', () => {
+ beforeEach( () => {
+ jest.useFakeTimers();
+ fetchEventSource.mockClear();
+ fetchEventSource.mockReturnValue( new Promise( () => {} ) );
+ lastSearchResultsProps = {};
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ delete window.JetpackInstantSearchOptions;
+ } );
+
+ it( 'sets aiStatus to streaming and accumulates text on token delta events', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ fireMessage( {
+ method: 'message/delta',
+ params: { delta: { deltaType: 'content', content: 'Here is how ' } },
+ } );
+ fireMessage( {
+ method: 'message/delta',
+ params: { delta: { deltaType: 'content', content: 'to reset.' } },
+ } );
+
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'streaming' );
+ expect( lastSearchResultsProps.aiText ).toBe( 'Here is how to reset.' );
+ } );
+
+ it( 'sets aiStatus to done and populates citations on TaskStatusUpdateEvent completed', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: {
+ state: 'completed',
+ message: {
+ parts: [
+ {
+ type: 'data',
+ data: {
+ sources: [ { title: 'Reset guide', url: 'https://example.com/reset' } ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ } );
+
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'done' );
+ expect( lastSearchResultsProps.aiCitations ).toHaveLength( 1 );
+ expect( lastSearchResultsProps.aiCitations[ 0 ].title ).toBe( 'Reset guide' );
+ } );
+
+ it( 'uses strict_sources when sources is absent', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: {
+ state: 'completed',
+ message: {
+ parts: [
+ {
+ type: 'data',
+ data: {
+ strict_sources: [ { title: 'Guide', url: 'https://example.com' } ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ } );
+
+ expect( lastSearchResultsProps.aiCitations ).toHaveLength( 1 );
+ } );
+
+ it( 'sets error state on task failed status', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ fireMessage( {
+ result: {
+ status: {
+ state: 'failed',
+ message: {
+ parts: [ { type: 'text', text: 'Service unavailable' } ],
+ },
+ },
+ },
+ } );
+
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'error' );
+ expect( lastSearchResultsProps.aiError.message ).toBe( 'Service unavailable' );
+ expect( lastSearchResultsProps.aiError.source ).toBe( 'api' );
+ } );
+
+ it( 'sets error state when data.error is present', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ fireMessage( { error: { message: 'Bad request', code: 400 } } );
+
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'error' );
+ expect( lastSearchResultsProps.aiError.code ).toBe( 400 );
+ } );
+
+ it( 'silently ignores unparseable SSE events', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ // Should not throw.
+ act( () => {
+ lastFetchOpts().onmessage( { data: 'not-json{{' } );
+ } );
+
+ // Status remains at loading (initial value set by streamAiAnswer).
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'loading' );
+ } );
+
+ it( 'sets network error state when fetchEventSource promise rejects', async () => {
+ fetchEventSource.mockReturnValueOnce( Promise.reject( new Error( 'net' ) ) );
+ renderAndTriggerQuery( 'reset my password' );
+
+ // Let the rejected promise propagate through the microtask queue.
+ await act( async () => {
+ await Promise.resolve();
+ } );
+
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'error' );
+ expect( lastSearchResultsProps.aiError.source ).toBe( 'network' );
+ expect( lastSearchResultsProps.aiError.message ).toBe( 'Network request error' );
+ } );
+} );
+
+describe( 'SearchApp — handleShowMore extended-answer flow', () => {
+ beforeEach( () => {
+ jest.useFakeTimers();
+ fetchEventSource.mockClear();
+ fetchEventSource.mockReturnValue( new Promise( () => {} ) );
+ lastSearchResultsProps = {};
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ delete window.JetpackInstantSearchOptions;
+ } );
+
+ it( 'calls fetchEventSource a second time with extended format on Show More', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ expect( fetchEventSource ).toHaveBeenCalledTimes( 1 );
+
+ // Simulate brief answer completing, which makes onShowMoreAiAnswer a function.
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+
+ // onShowMoreAiAnswer should be a function at this point (brief is done).
+ act( () => {
+ lastSearchResultsProps.onShowMoreAiAnswer();
+ } );
+
+ expect( fetchEventSource ).toHaveBeenCalledTimes( 2 );
+ const extendedBody = JSON.parse( fetchEventSource.mock.calls[ 1 ][ 1 ].body );
+ expect( extendedBody.params.message.parts[ 1 ].data.clientContext.aiAnswersFormat ).toBe(
+ 'extended'
+ );
+ } );
+
+ it( 'passes the session ID from the brief request to the extended request', () => {
+ renderAndTriggerQuery( 'reset my password' );
+
+ // Simulate brief answer returning a session ID.
+ fireMessage( { result: { sessionId: 'sess-abc-123' } } );
+
+ // Finish brief answer.
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+
+ act( () => {
+ lastSearchResultsProps.onShowMoreAiAnswer();
+ } );
+
+ const extendedBody = JSON.parse( fetchEventSource.mock.calls[ 1 ][ 1 ].body );
+ expect( extendedBody.params.sessionId ).toBe( 'sess-abc-123' );
+ } );
+} );
+
+describe( 'SearchApp — render display-state derivation', () => {
+ beforeEach( () => {
+ jest.useFakeTimers();
+ fetchEventSource.mockClear();
+ fetchEventSource.mockReturnValue( new Promise( () => {} ) );
+ lastSearchResultsProps = {};
+ } );
+
+ afterEach( () => {
+ jest.useRealTimers();
+ delete window.JetpackInstantSearchOptions;
+ } );
+
+ it( 'passes brief status and text to SearchResults before Show More is clicked', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ fireMessage( {
+ method: 'message/delta',
+ params: { delta: { deltaType: 'content', content: 'Brief answer.' } },
+ } );
+
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'streaming' );
+ expect( lastSearchResultsProps.aiText ).toBe( 'Brief answer.' );
+ expect( typeof lastSearchResultsProps.onShowMoreAiAnswer ).toBe( 'undefined' );
+ } );
+
+ it( 'sets onShowMoreAiAnswer to a function when brief answer is done', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+
+ expect( typeof lastSearchResultsProps.onShowMoreAiAnswer ).toBe( 'function' );
+ } );
+
+ it( 'sets onShowMoreAiAnswer to null (extended mode) after Show More is clicked', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+ act( () => lastSearchResultsProps.onShowMoreAiAnswer() );
+
+ expect( lastSearchResultsProps.onShowMoreAiAnswer ).toBeNull();
+ } );
+
+ it( 'shows brief text while extended answer is loading', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ fireMessage( {
+ method: 'message/delta',
+ params: { delta: { deltaType: 'content', content: 'Brief text.' } },
+ } );
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+ act( () => lastSearchResultsProps.onShowMoreAiAnswer() );
+
+ // Extended request just started — status should be 'streaming' (converted from 'loading')
+ // and text should still be the brief text.
+ expect( lastSearchResultsProps.aiStatus ).toBe( 'streaming' );
+ expect( lastSearchResultsProps.aiText ).toBe( 'Brief text.' );
+ } );
+
+ it( 'combines brief and extended text once extended starts streaming', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ fireMessage( {
+ method: 'message/delta',
+ params: { delta: { deltaType: 'content', content: 'Brief.' } },
+ } );
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+ act( () => lastSearchResultsProps.onShowMoreAiAnswer() );
+
+ // Simulate extended token arriving.
+ fireMessage( {
+ method: 'message/delta',
+ params: { delta: { deltaType: 'content', content: ' Extended.' } },
+ } );
+
+ expect( lastSearchResultsProps.aiText ).toBe( 'Brief.\n\n Extended.' );
+ } );
+
+ it( 'uses extended citations when extended answer is done', () => {
+ renderAndTriggerQuery( 'reset my password' );
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: { state: 'completed', message: { parts: [] } },
+ },
+ } );
+ act( () => lastSearchResultsProps.onShowMoreAiAnswer() );
+
+ fireMessage( {
+ result: {
+ type: 'TaskStatusUpdateEvent',
+ status: {
+ state: 'completed',
+ message: {
+ parts: [
+ {
+ type: 'data',
+ data: {
+ sources: [ { title: 'Extended source', url: 'https://example.com' } ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ } );
+
+ expect( lastSearchResultsProps.aiCitations[ 0 ].title ).toBe( 'Extended source' );
+ } );
+} );
diff --git a/projects/packages/search/src/instant-search/components/test/sidebar.test.jsx b/projects/packages/search/src/instant-search/components/test/sidebar.test.jsx
new file mode 100644
index 000000000000..c1949f8e55bd
--- /dev/null
+++ b/projects/packages/search/src/instant-search/components/test/sidebar.test.jsx
@@ -0,0 +1,76 @@
+import { render, screen } from '@testing-library/react';
+import * as React from 'react';
+import Sidebar from '../sidebar';
+
+jest.mock( '../search-filters', () => props => (
+
+) );
+jest.mock( '../widget-area-container', () => () => );
+jest.mock( '../sidebar.scss', () => {} );
+
+const defaultProps = {
+ citations: [],
+ filters: [],
+ staticFilters: {},
+ isLoading: false,
+ locale: 'en',
+ postTypes: [],
+ response: {},
+ widgetOutsideOverlay: null,
+ widgets: [],
+};
+
+describe( 'Sidebar', () => {
+ it( 'renders SearchFilters and WidgetAreaContainer', () => {
+ render( );
+ expect( screen.getByTestId( 'search-filters' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'widget-area-container' ) ).toBeInTheDocument();
+ } );
+
+ it( 'does not render citations section when citations is empty', () => {
+ render( );
+ expect( screen.queryAllByRole( 'link' ) ).toHaveLength( 0 );
+ } );
+
+ it( 'renders citation cards when citations are provided', () => {
+ const citations = [
+ { title: 'How to reset your password', url: 'https://example.com/reset' },
+ { title: 'Getting started guide', url: 'https://example.com/guide' },
+ ];
+ render( );
+ expect( screen.getByText( 'How to reset your password' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Getting started guide' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows the hostname of each citation URL', () => {
+ const citations = [ { title: 'Docs', url: 'https://docs.example.com/page' } ];
+ render( );
+ expect( screen.getByText( 'docs.example.com' ) ).toBeInTheDocument();
+ } );
+
+ it( 'citation links open in a new tab with noopener', () => {
+ const citations = [ { title: 'Article', url: 'https://example.com/article' } ];
+ render( );
+ const link = screen.getByRole( 'link', { name: /Article/ } );
+ expect( link ).toHaveAttribute( 'target', '_blank' );
+ expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' );
+ expect( link ).toHaveAttribute( 'href', 'https://example.com/article' );
+ } );
+
+ it( 'creates a portal for each widget', () => {
+ const widgetId = 'jetpack-search-widget-1';
+ const wrapper = document.createElement( 'div' );
+ wrapper.id = `${ widgetId }-wrapper`;
+ document.body.appendChild( wrapper );
+
+ const widgets = [ { widget_id: widgetId } ];
+ render( );
+
+ // The portaled SearchFilters mock carries the widget id as data-widget.
+ const allFilters = screen.getAllByTestId( 'search-filters' );
+ const portaled = allFilters.find( el => el.getAttribute( 'data-widget' ) === widgetId );
+ expect( portaled ).toBeInTheDocument();
+
+ document.body.removeChild( wrapper );
+ } );
+} );
diff --git a/projects/packages/search/src/instant-search/lib/markdown.js b/projects/packages/search/src/instant-search/lib/markdown.js
new file mode 100644
index 000000000000..5e0ce7c3c390
--- /dev/null
+++ b/projects/packages/search/src/instant-search/lib/markdown.js
@@ -0,0 +1,126 @@
+/**
+ * Minimal markdown-to-HTML converter for AI answer text.
+ * Handles: headings, bold, italic, inline links, bullet lists, paragraphs.
+ * All text content is HTML-escaped before insertion — safe for dangerouslySetInnerHTML.
+ */
+
+/**
+ * Escape special HTML characters in a plain-text string.
+ *
+ * @param {string} text - Plain text to escape.
+ * @return {string} HTML-safe string.
+ */
+function escapeHtml( text ) {
+ return text
+ .replace( /&/g, '&' )
+ .replace( //g, '>' )
+ .replace( /"/g, '"' );
+}
+
+/**
+ * Format inline markdown within a single line: bold, italic, links.
+ *
+ * @param {string} text - Raw text that may contain inline markdown.
+ * @return {string} HTML string with inline elements applied.
+ */
+function inlineFormat( text ) {
+ const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|\[([^\]]+)\]\(([^)]+)\)|(https?:\/\/[^\s<>"')\]]+))/g;
+ const result = [];
+ let lastIndex = 0;
+ let match;
+
+ while ( ( match = pattern.exec( text ) ) !== null ) {
+ if ( match.index > lastIndex ) {
+ result.push( escapeHtml( text.slice( lastIndex, match.index ) ) );
+ }
+ if ( match[ 2 ] !== undefined ) {
+ result.push( `${ inlineFormat( match[ 2 ] ) }` );
+ } else if ( match[ 3 ] !== undefined ) {
+ result.push( `${ inlineFormat( match[ 3 ] ) }` );
+ } else if ( match[ 4 ] !== undefined && match[ 5 ] !== undefined ) {
+ const url = /^https?:\/\//.test( match[ 5 ] ) ? escapeHtml( match[ 5 ] ) : '#';
+ result.push(
+ `${ escapeHtml(
+ match[ 4 ]
+ ) }`
+ );
+ } else if ( match[ 6 ] !== undefined ) {
+ const url = escapeHtml( match[ 6 ] );
+ result.push( `${ url }` );
+ }
+ lastIndex = match.index + match[ 0 ].length;
+ }
+
+ if ( lastIndex < text.length ) {
+ result.push( escapeHtml( text.slice( lastIndex ) ) );
+ }
+
+ return result.join( '' );
+}
+
+/**
+ * Convert a markdown string to an HTML string.
+ *
+ * @param {string} markdown - Markdown text from the AI answer stream.
+ * @return {string} Safe HTML string.
+ */
+export function markdownToHtml( markdown ) {
+ if ( ! markdown ) {
+ return '';
+ }
+
+ let html = '';
+ const lines = markdown.split( '\n' );
+ let inList = false;
+ const pendingParagraph = [];
+
+ const flushParagraph = () => {
+ if ( pendingParagraph.length > 0 ) {
+ const content = pendingParagraph.join( ' ' ).trim();
+ if ( content ) {
+ html += `${ inlineFormat( content ) }
`;
+ }
+ pendingParagraph.length = 0;
+ }
+ };
+
+ const closeList = () => {
+ if ( inList ) {
+ html += '';
+ inList = false;
+ }
+ };
+
+ for ( const line of lines ) {
+ const headingMatch = line.match( /^(#{1,6})\s+(.+)$/ );
+ const listMatch = line.match( /^[-*]\s+(.+)$/ );
+
+ if ( headingMatch ) {
+ flushParagraph();
+ closeList();
+ const level = Math.min( headingMatch[ 1 ].length + 1, 6 );
+ html += `${ inlineFormat( headingMatch[ 2 ] ) }`;
+ } else if ( listMatch ) {
+ flushParagraph();
+ if ( ! inList ) {
+ html += '';
+ inList = true;
+ }
+ html += `- ${ inlineFormat( listMatch[ 1 ] ) }
`;
+ } else if ( line.trim() === '' ) {
+ flushParagraph();
+ closeList();
+ } else {
+ if ( inList ) {
+ closeList();
+ }
+ pendingParagraph.push( line );
+ }
+ }
+
+ flushParagraph();
+ closeList();
+
+ return html;
+}
diff --git a/projects/packages/search/src/instant-search/lib/styles/_variables.scss b/projects/packages/search/src/instant-search/lib/styles/_variables.scss
index 051cb946a512..2305c53f758f 100644
--- a/projects/packages/search/src/instant-search/lib/styles/_variables.scss
+++ b/projects/packages/search/src/instant-search/lib/styles/_variables.scss
@@ -25,6 +25,9 @@ $breakpoints: (
xxlarge: $break-xxl,
);
+// Border radii
+$border-radius-panel: 6px;
+
// Side margins for search results container
$results-margin-sm: 20px;
$results-margin-md: 40px;
@@ -43,6 +46,10 @@ $color-layout-borders: studio.$studio-wordpress-blue-0;
$color-mark-background-default: #ffc;
$color-modal-background: studio.$studio-white;
$color-modal-backdrop-background: rgba(studio.$studio-gray-90, 0.7);
+$color-border: studio.$studio-gray-5;
+$color-error: studio.$studio-red-50;
+$color-error-border: studio.$studio-red-5;
+$color-surface-alt: studio.$studio-gray-0;
$color-dark-text: studio.$studio-wordpress-blue-0;
$color-dark-text-light: studio.$studio-gray-20;
diff --git a/projects/packages/search/tests/jest-globals.gui.js b/projects/packages/search/tests/jest-globals.gui.js
index 8a95837f69de..a439a35c6e2e 100644
--- a/projects/packages/search/tests/jest-globals.gui.js
+++ b/projects/packages/search/tests/jest-globals.gui.js
@@ -1,3 +1,9 @@
+// @wordpress packages are webpack externals in some bundled dependencies (e.g.
+// jetpack-boost-score-api). They resolve to window.wp.* at runtime, so jest
+// needs the global to exist.
+window.wp = window.wp || {};
+window.wp.i18n = require( '@wordpress/i18n' );
+
window.JP_CONNECTION_INITIAL_STATE = {
userConnectionData: {
currentUser: {
diff --git a/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx b/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx
index 5eaf5acefc89..9fe0103a71dd 100644
--- a/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx
+++ b/projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx
@@ -21,9 +21,11 @@ jest.mock( '@automattic/jetpack-connection', () => ( {
// Stub heavy sub-components that aren't relevant to the branching test.
jest.mock( 'components/mocked-search', () => () => );
jest.mock( 'components/module-control', () => () => );
+jest.mock( 'components/feature-selector', () => () => );
jest.mock( 'components/record-meter', () => () => );
jest.mock( 'components/global-notices', () => () => null );
jest.mock( 'components/loading', () => () => );
+jest.mock( 'components/ai-answers-tab', () => () => );
const renderWith = ( { searchBlocksEnabled, jetpackSettings } ) => {
const registry = createRegistry();
@@ -64,19 +66,21 @@ const settings = {
};
describe( ' branch', () => {
+ test( 'renders Plan & Usage and AI Answers tabs', () => {
+ renderWith( { searchBlocksEnabled: false, jetpackSettings: settings } );
+ expect( screen.getByRole( 'tab', { name: /plan & usage/i } ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'tab', { name: /ai answers/i } ) ).toBeInTheDocument();
+ } );
+
test( 'renders FeatureSelector when searchBlocksEnabled is true', () => {
renderWith( { searchBlocksEnabled: true, jetpackSettings: settings } );
- expect(
- screen.getByRole( 'group', { name: /select a search experience for your visitors/i } )
- ).toBeInTheDocument();
+ expect( screen.getByTestId( 'feature-selector' ) ).toBeInTheDocument();
expect( screen.queryByTestId( 'module-control' ) ).not.toBeInTheDocument();
} );
test( 'renders ModuleControl when searchBlocksEnabled is false', () => {
renderWith( { searchBlocksEnabled: false, jetpackSettings: settings } );
- expect(
- screen.queryByRole( 'group', { name: /select a search experience for your visitors/i } )
- ).not.toBeInTheDocument();
+ expect( screen.queryByTestId( 'feature-selector' ) ).not.toBeInTheDocument();
expect( screen.getByTestId( 'module-control' ) ).toBeInTheDocument();
} );
} );
diff --git a/projects/packages/search/tests/php/AI_Answers_Test.php b/projects/packages/search/tests/php/AI_Answers_Test.php
new file mode 100644
index 000000000000..c311e5645eda
--- /dev/null
+++ b/projects/packages/search/tests/php/AI_Answers_Test.php
@@ -0,0 +1,194 @@
+init();
+ do_action( 'init' );
+ }
+
+ /** @var callable|null */
+ private $posts_query_filter = null;
+
+ /** @var int[] */
+ private $test_post_ids = array();
+
+ public function tearDown(): void {
+ // Delete test posts BEFORE parent::tearDown() empties the WorDBless store.
+ // wp_delete_post() must find the post to call clean_post_cache(), which
+ // invalidates the WP query cache and prevents stale cache hits in later tests.
+ foreach ( $this->test_post_ids as $id ) {
+ wp_delete_post( $id, true );
+ }
+ $this->test_post_ids = array();
+
+ parent::tearDown();
+
+ if ( $this->posts_query_filter !== null ) {
+ remove_filter( 'posts_pre_query', $this->posts_query_filter, 10 );
+ $this->posts_query_filter = null;
+ }
+ if ( post_type_exists( 'wp_guideline' ) ) {
+ unregister_post_type( 'wp_guideline' );
+ }
+ }
+
+ public function test_is_enabled_defaults_to_false() {
+ $this->assertFalse( AI_Answers::is_enabled() );
+ }
+
+ public function test_is_enabled_reads_option() {
+ update_option( 'jetpack_search_ai_answers_enabled', true );
+ $this->assertTrue( AI_Answers::is_enabled() );
+ delete_option( 'jetpack_search_ai_answers_enabled' );
+ }
+
+ public function test_is_enabled_filter_overrides_option() {
+ add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' );
+ $this->assertTrue( 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() );
+ }
+
+ public function test_get_behavior_instructions_reads_option_when_no_cpt() {
+ update_option( AI_Answers::BEHAVIOR_OPTION_KEY, 'Answer only in English.' );
+ $this->assertSame( 'Answer only in English.', AI_Answers::get_behavior_instructions() );
+ }
+
+ public function test_register_behavior_meta_registers_setting_when_cpt_absent() {
+ // wp_guideline is not registered in the bare test environment — the fallback
+ // path should register the site option via register_setting() without error.
+ $ai = new AI_Answers();
+ $ai->register_behavior_meta();
+ $this->assertNotFalse( get_registered_settings()[ AI_Answers::BEHAVIOR_OPTION_KEY ] ?? false );
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests for the wp_guideline CPT path
+ // -------------------------------------------------------------------------
+
+ /**
+ * Register a queryable variant of wp_guideline for test isolation.
+ */
+ private function register_guideline_cpt() {
+ register_post_type(
+ 'wp_guideline', // phpcs:ignore WordPress.NamingConventions.ValidPostTypeSlug.ReservedPrefix
+ array(
+ 'public' => true,
+ 'supports' => array( 'custom-fields' ),
+ )
+ );
+ }
+
+ /**
+ * WorDBless dbless mode only intercepts SELECT * FROM posts WHERE ID = N.
+ * All other WP_Query SQL returns empty. Hook posts_pre_query (which runs
+ * regardless of suppress_filters) to short-circuit the query and return
+ * our test post when the query targets wp_guideline posts.
+ */
+ private function hook_wordbless_posts_query( int $post_id ): void {
+ $this->posts_query_filter = static function ( $posts, $query ) use ( $post_id ) {
+ if ( 'wp_guideline' === $query->get( 'post_type' ) ) {
+ return array( get_post( $post_id ) );
+ }
+ return $posts;
+ };
+ add_filter( 'posts_pre_query', $this->posts_query_filter, 10, 2 );
+ }
+
+ public function test_register_behavior_meta_registers_post_meta_when_wp_guideline_exists() {
+ $this->register_guideline_cpt();
+ $ai = new AI_Answers();
+ $ai->register_behavior_meta();
+ $registered = get_registered_meta_keys( 'post', 'wp_guideline' );
+ $this->assertArrayHasKey( AI_Answers::BEHAVIOR_META_KEY, $registered );
+ }
+
+ public function test_register_behavior_meta_does_not_register_option_when_cpt_exists() {
+ $this->register_guideline_cpt();
+ $before = get_registered_settings();
+ $ai = new AI_Answers();
+ $ai->register_behavior_meta();
+ $after = get_registered_settings();
+ // The fallback option key should NOT have been freshly registered.
+ $this->assertEquals( isset( $before[ AI_Answers::BEHAVIOR_OPTION_KEY ] ), isset( $after[ AI_Answers::BEHAVIOR_OPTION_KEY ] ) );
+ }
+
+ public function test_get_behavior_instructions_reads_from_published_post_meta() {
+ $this->register_guideline_cpt();
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'wp_guideline',
+ 'post_status' => 'publish',
+ 'post_title' => 'Guidelines',
+ )
+ );
+ $this->test_post_ids[] = $post_id;
+ update_post_meta( $post_id, AI_Answers::BEHAVIOR_META_KEY, 'Answer only in English.' );
+ $this->hook_wordbless_posts_query( $post_id );
+
+ $this->assertSame( 'Answer only in English.', AI_Answers::get_behavior_instructions() );
+ }
+
+ public function test_get_behavior_instructions_falls_back_to_option_when_cpt_has_no_published_posts() {
+ $this->register_guideline_cpt();
+ update_option( AI_Answers::BEHAVIOR_OPTION_KEY, 'Fallback instructions.' );
+
+ // CPT registered but no published posts exist — WP_Query returns empty in WorDBless.
+ $this->assertSame( 'Fallback instructions.', AI_Answers::get_behavior_instructions() );
+ }
+
+ public function test_get_behavior_instructions_ignores_draft_posts() {
+ $this->register_guideline_cpt();
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'wp_guideline',
+ 'post_status' => 'draft',
+ 'post_title' => 'Draft Guidelines',
+ )
+ );
+ $this->test_post_ids[] = $post_id;
+ update_post_meta( $post_id, AI_Answers::BEHAVIOR_META_KEY, 'Draft instructions.' );
+
+ // Only published posts should be read; draft posts are ignored.
+ // (No wordbless hook — WP_Query returns empty, so get_option fallback returns ''.)
+ $this->assertSame( '', AI_Answers::get_behavior_instructions() );
+ }
+
+ public function test_get_behavior_instructions_prefers_cpt_over_option_when_both_exist() {
+ $this->register_guideline_cpt();
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'wp_guideline',
+ 'post_status' => 'publish',
+ 'post_title' => 'Guidelines',
+ )
+ );
+ $this->test_post_ids[] = $post_id;
+ update_post_meta( $post_id, AI_Answers::BEHAVIOR_META_KEY, 'From CPT.' );
+ update_option( AI_Answers::BEHAVIOR_OPTION_KEY, 'From option.' );
+ $this->hook_wordbless_posts_query( $post_id );
+
+ $this->assertSame( 'From CPT.', AI_Answers::get_behavior_instructions() );
+ }
+
+ public function test_behavior_option_key_constant() {
+ $this->assertSame( 'jetpack_search_ai_behavior_instructions', AI_Answers::BEHAVIOR_OPTION_KEY );
+ }
+}
diff --git a/projects/packages/search/tests/php/Initializer_Test.php b/projects/packages/search/tests/php/Initializer_Test.php
new file mode 100644
index 000000000000..24f3ac14a116
--- /dev/null
+++ b/projects/packages/search/tests/php/Initializer_Test.php
@@ -0,0 +1,54 @@
+assertContains( 'jetpack_search_init_search_package_filter', $abort_reasons );
+ }
+
+ public function test_init_does_not_proceed_past_abort_when_filter_returns_false() {
+ // Verify that init() bails early and never reaches is_connected() / is_search_supported(),
+ // which would require a live connection. We confirm by checking that no
+ // additional abort actions fire (those come from later guard clauses).
+ $reasons = array();
+ add_action(
+ 'jetpack_search_abort',
+ function ( $reason ) use ( &$reasons ) {
+ $reasons[] = $reason;
+ }
+ );
+ add_filter( 'jetpack_search_init_search_package', '__return_false' );
+
+ Initializer::init();
+
+ remove_filter( 'jetpack_search_init_search_package', '__return_false' );
+
+ // Only the filter-abort reason should have fired.
+ $this->assertSame( array( 'jetpack_search_init_search_package_filter' ), $reasons );
+ }
+}
diff --git a/projects/packages/search/tests/php/REST_Controller_Test.php b/projects/packages/search/tests/php/REST_Controller_Test.php
index 09d532945049..4a6165f03496 100644
--- a/projects/packages/search/tests/php/REST_Controller_Test.php
+++ b/projects/packages/search/tests/php/REST_Controller_Test.php
@@ -138,6 +138,7 @@ public function test_update_search_settings_success_both_enable() {
'module_active' => true,
'instant_search_enabled' => true,
'swap_classic_to_inline_search' => false,
+ 'ai_answers_enabled' => false,
);
$expected = array_merge( $new_settings, array( 'experience' => 'overlay' ) );
@@ -190,6 +191,7 @@ public function test_update_search_settings_success_both_disable() {
'module_active' => false,
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => false,
+ 'ai_answers_enabled' => false,
);
$expected = array_merge( $new_settings, array( 'experience' => 'off' ) );
@@ -214,6 +216,7 @@ public function test_update_search_settings_success_disable_module_only() {
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => false,
'experience' => 'off',
+ 'ai_answers_enabled' => false,
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -237,6 +240,7 @@ public function test_update_search_settings_success_disable_instant_only() {
'instant_search_enabled' => true,
'swap_classic_to_inline_search' => false,
'experience' => 'overlay',
+ 'ai_answers_enabled' => false,
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -260,6 +264,7 @@ public function test_update_search_settings_success_enable_inline_search() {
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => true,
'experience' => 'off',
+ 'ai_answers_enabled' => false,
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -283,6 +288,7 @@ public function test_update_search_settings_success_disable_inline_search() {
'instant_search_enabled' => false,
'swap_classic_to_inline_search' => false,
'experience' => 'off',
+ 'ai_answers_enabled' => false,
);
$request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' );
@@ -573,6 +579,39 @@ public function test_get_settings_returns_persisted_experience() {
$this->assertEquals( 'embedded', $response->get_data()['experience'] );
}
+ /**
+ * Testing that ai_answers_enabled can be toggled on via the settings endpoint.
+ */
+ public function test_update_settings_ai_answers_enabled_true() {
+ wp_set_current_user( $this->admin_id );
+
+ $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( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertTrue( $data['ai_answers_enabled'] );
+ }
+
+ /**
+ * Testing that ai_answers_enabled can be toggled off via the settings endpoint.
+ */
+ public function test_update_settings_ai_answers_enabled_false() {
+ wp_set_current_user( $this->admin_id );
+
+ // Enable first.
+ update_option( 'jetpack_search_ai_answers_enabled', true );
+
+ $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() );
+ $data = $response->get_data();
+ $this->assertFalse( $data['ai_answers_enabled'] );
+ }
+
/**
* Testing the `POST /jetpack/v4/search/plan/activate` endpoint with no user.
*/
diff --git a/projects/packages/search/tests/php/Settings_Test.php b/projects/packages/search/tests/php/Settings_Test.php
new file mode 100644
index 000000000000..631dcf9ad3a8
--- /dev/null
+++ b/projects/packages/search/tests/php/Settings_Test.php
@@ -0,0 +1,73 @@
+assertArrayHasKey( Options::OPTION_PREFIX . 'ai_answers_enabled', $registered );
+ $setting = $registered[ Options::OPTION_PREFIX . 'ai_answers_enabled' ];
+ $this->assertEquals( 'boolean', $setting['type'] );
+ $this->assertFalse( $setting['default'] );
+ }
+
+ public function test_settings_register_registers_result_format() {
+ $registered = get_registered_settings();
+ $this->assertArrayHasKey( Options::OPTION_PREFIX . 'result_format', $registered );
+ $setting = $registered[ Options::OPTION_PREFIX . 'result_format' ];
+ $this->assertEquals( 'string', $setting['type'] );
+ $this->assertEquals( 'minimal', $setting['default'] );
+ }
+
+ public function test_settings_register_registers_enable_sort() {
+ $registered = get_registered_settings();
+ $this->assertArrayHasKey( Options::OPTION_PREFIX . 'enable_sort', $registered );
+ $setting = $registered[ Options::OPTION_PREFIX . 'enable_sort' ];
+ $this->assertEquals( 'boolean', $setting['type'] );
+ $this->assertTrue( $setting['default'] );
+ }
+
+ public function test_settings_register_all_settings_have_show_in_rest() {
+ $registered = get_registered_settings();
+ $prefix = Options::OPTION_PREFIX;
+ $expected_keys = array(
+ $prefix . 'ai_prompt_override',
+ $prefix . 'color_theme',
+ $prefix . 'result_format',
+ $prefix . 'default_sort',
+ $prefix . 'overlay_trigger',
+ $prefix . 'excluded_post_types',
+ $prefix . 'highlight_color',
+ $prefix . 'enable_sort',
+ $prefix . 'inf_scroll',
+ $prefix . 'filtering_opens_overlay',
+ $prefix . 'show_post_date',
+ $prefix . 'show_product_price',
+ $prefix . 'show_powered_by',
+ $prefix . 'ai_answers_enabled',
+ );
+ foreach ( $expected_keys as $key ) {
+ $this->assertArrayHasKey( $key, $registered, "Setting $key not registered" );
+ $this->assertTrue( $registered[ $key ]['show_in_rest'], "Setting $key missing show_in_rest" );
+ }
+ }
+}
diff --git a/projects/packages/sync/changelog/jps3-ai-answers b/projects/packages/sync/changelog/jps3-ai-answers
new file mode 100644
index 000000000000..26c6d420a7e6
--- /dev/null
+++ b/projects/packages/sync/changelog/jps3-ai-answers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Sync: sync wp_guideline CPT when Jetpack Search AI Answers is enabled.
diff --git a/projects/packages/sync/src/modules/class-search.php b/projects/packages/sync/src/modules/class-search.php
index 0691174bc5fe..04a9937dc737 100644
--- a/projects/packages/sync/src/modules/class-search.php
+++ b/projects/packages/sync/src/modules/class-search.php
@@ -51,6 +51,8 @@ public function __construct() {
add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_search_post_meta_whitelist' ), 10 );
// Add options
add_filter( 'jetpack_sync_options_whitelist', array( $this, 'add_search_options_whitelist' ), 10 );
+ // AI Answers CPTs and post meta (gated by feature flag).
+ add_filter( 'jetpack_sync_post_types_whitelist', array( $this, 'add_ai_answer_post_types' ), 10 );
}
/**
@@ -73,378 +75,379 @@ public function __construct() {
*/
private static $postmeta_to_sync = array(
// jetpack.
- 'jetpack-search-meta0' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta1' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta2' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta3' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta4' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta5' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta6' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta7' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta8' => array( 'searchable_in_all_content' => true ),
- 'jetpack-search-meta9' => array( 'searchable_in_all_content' => true ),
+ '_guideline_block_jetpack_search-ai-summary' => array(), // AI Answers personality (Gutenberg Guidelines CPT).
+ 'jetpack-search-meta0' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta1' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta2' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta3' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta4' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta5' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta6' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta7' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta8' => array( 'searchable_in_all_content' => true ),
+ 'jetpack-search-meta9' => array( 'searchable_in_all_content' => true ),
// woocommerce.
- 'exclude_product_categories' => array(),
- 'exclude_product_ids' => array(),
- 'free_shipping' => array(),
- 'id_field' => array(),
- 'individual_use' => array(),
- 'limit_usage_to_x_items' => array(),
- 'maximum_amount' => array(),
- 'minimum_amount' => array(),
- 'post_id' => array(),
- 'product_categories' => array( 'searchable_in_all_content' => true ),
- 'product_ids' => array(),
- 'total_sales' => array(
+ 'exclude_product_categories' => array(),
+ 'exclude_product_ids' => array(),
+ 'free_shipping' => array(),
+ 'id_field' => array(),
+ 'individual_use' => array(),
+ 'limit_usage_to_x_items' => array(),
+ 'maximum_amount' => array(),
+ 'minimum_amount' => array(),
+ 'post_id' => array(),
+ 'product_categories' => array( 'searchable_in_all_content' => true ),
+ 'product_ids' => array(),
+ 'total_sales' => array(
'available' => false,
'alternatives' => array(
'wc.percent_of_sales',
),
),
- 'usage_limit' => array(),
- 'usage_limit_per_user' => array(),
- '_crosssell_ids' => array(),
- '_downloadable' => array(),
- '_featured' => array(),
- '_height' => array(),
- '_length' => array(),
- '_price' => array(
+ 'usage_limit' => array(),
+ 'usage_limit_per_user' => array(),
+ '_crosssell_ids' => array(),
+ '_downloadable' => array(),
+ '_featured' => array(),
+ '_height' => array(),
+ '_length' => array(),
+ '_price' => array(
'alternatives' => array(
'wc.price',
'wc.min_price',
'wc.max_price',
),
),
- '_prices_include_tax' => array(),
- '_product_attributes' => array(),
- '_product_version' => array(),
- '_regular_price' => array(
+ '_prices_include_tax' => array(),
+ '_product_attributes' => array(),
+ '_product_version' => array(),
+ '_regular_price' => array(
'alternatives' => array(
'wc.regular_price',
),
),
- '_sale_price' => array(
+ '_sale_price' => array(
'alternatives' => array(
'wc.sale_price',
),
),
- '_sale_price_dates_from' => array(),
- '_sale_price_dates_to' => array(),
- '_sku' => array( 'searchable_in_all_content' => true ),
- '_stock_status' => array(),
- '_wc_average_rating' => array(
+ '_sale_price_dates_from' => array(),
+ '_sale_price_dates_to' => array(),
+ '_sku' => array( 'searchable_in_all_content' => true ),
+ '_stock_status' => array(),
+ '_wc_average_rating' => array(
'alternatives' => array(
'wc.ave_rating_score',
),
),
- '_wc_rating_count' => array(
+ '_wc_rating_count' => array(
'alternatives' => array(
'wc.rating', // wc.rating.count_1, wc.rating.count_2, ...
),
),
- '_wc_review_count' => array(),
- '_weight' => array(),
- '_width' => array(),
+ '_wc_review_count' => array(),
+ '_weight' => array(),
+ '_width' => array(),
// co-authors plus.
- 'cap-description' => array( 'searchable_in_all_content' => true ),
- 'cap-user_login' => array( 'searchable_in_all_content' => true ),
- 'cap-user_email' => array(),
- 'cap-last_name' => array( 'searchable_in_all_content' => true ),
- 'cap-first_name' => array( 'searchable_in_all_content' => true ),
- 'cap-display_name' => array( 'searchable_in_all_content' => true ),
- 'cap-website' => array(),
- 'cap-jabber' => array(),
- 'cap-aim' => array(),
- 'cap-twitter' => array(),
- 'cap-facebook' => array(),
- 'cap-google_plus' => array(),
- 'cap-job_title' => array( 'searchable_in_all_content' => true ),
+ 'cap-description' => array( 'searchable_in_all_content' => true ),
+ 'cap-user_login' => array( 'searchable_in_all_content' => true ),
+ 'cap-user_email' => array(),
+ 'cap-last_name' => array( 'searchable_in_all_content' => true ),
+ 'cap-first_name' => array( 'searchable_in_all_content' => true ),
+ 'cap-display_name' => array( 'searchable_in_all_content' => true ),
+ 'cap-website' => array(),
+ 'cap-jabber' => array(),
+ 'cap-aim' => array(),
+ 'cap-twitter' => array(),
+ 'cap-facebook' => array(),
+ 'cap-google_plus' => array(),
+ 'cap-job_title' => array( 'searchable_in_all_content' => true ),
// bbpress.
- 'bbpl_like' => array(),
- 'bbpress_discussion_comments_copied' => array(),
- 'bbpress_discussion_tags_copied' => array(),
- 'bbpress_discussion_topic_id' => array(),
- 'bbpress_discussion_use_defaults' => array(),
- 'bbpress_page_header_bg' => array(),
- 'bbpress_title_bg' => array(),
- 'use_bbpress_discussion_topic' => array(),
+ 'bbpl_like' => array(),
+ 'bbpress_discussion_comments_copied' => array(),
+ 'bbpress_discussion_tags_copied' => array(),
+ 'bbpress_discussion_topic_id' => array(),
+ 'bbpress_discussion_use_defaults' => array(),
+ 'bbpress_page_header_bg' => array(),
+ 'bbpress_title_bg' => array(),
+ 'use_bbpress_discussion_topic' => array(),
// wpml.
- 'tm_meta_wpml' => array(),
- 'wpml_language' => array(),
- 'wpml_media_lang' => array(),
- 'wpml_media_processed' => array(),
+ 'tm_meta_wpml' => array(),
+ 'wpml_language' => array(),
+ 'wpml_media_lang' => array(),
+ 'wpml_media_processed' => array(),
// blogger import.
- 'blogger_author' => array( 'searchable_in_all_content' => true ),
- 'blogger_blog' => array( 'searchable_in_all_content' => true ),
- 'blogger_permalink' => array( 'searchable_in_all_content' => true ),
+ 'blogger_author' => array( 'searchable_in_all_content' => true ),
+ 'blogger_blog' => array( 'searchable_in_all_content' => true ),
+ 'blogger_permalink' => array( 'searchable_in_all_content' => true ),
// geo.
- 'geo_address' => array( 'searchable_in_all_content' => true ),
- 'geo_latitude' => array(),
- 'geo_longitude' => array(),
- 'geo_public' => array(),
- 'geolocated' => array(),
- 'geolocation_city' => array( 'searchable_in_all_content' => true ),
- 'geolocation_country_long' => array( 'searchable_in_all_content' => true ),
- 'geolocation_country_short' => array( 'searchable_in_all_content' => true ),
- 'geolocation_formatted_address' => array( 'searchable_in_all_content' => true ),
- 'geolocation_lat' => array(),
- 'geolocation_long' => array(),
- 'geolocation_postcode' => array( 'searchable_in_all_content' => true ),
- 'geolocation_state_long' => array( 'searchable_in_all_content' => true ),
- 'geolocation_state_short' => array( 'searchable_in_all_content' => true ),
+ 'geo_address' => array( 'searchable_in_all_content' => true ),
+ 'geo_latitude' => array(),
+ 'geo_longitude' => array(),
+ 'geo_public' => array(),
+ 'geolocated' => array(),
+ 'geolocation_city' => array( 'searchable_in_all_content' => true ),
+ 'geolocation_country_long' => array( 'searchable_in_all_content' => true ),
+ 'geolocation_country_short' => array( 'searchable_in_all_content' => true ),
+ 'geolocation_formatted_address' => array( 'searchable_in_all_content' => true ),
+ 'geolocation_lat' => array(),
+ 'geolocation_long' => array(),
+ 'geolocation_postcode' => array( 'searchable_in_all_content' => true ),
+ 'geolocation_state_long' => array( 'searchable_in_all_content' => true ),
+ 'geolocation_state_short' => array( 'searchable_in_all_content' => true ),
// wp-ultimate-recipe.
- 'recipe_alternate_image' => array(),
- 'recipe_cook_time' => array(),
- 'recipe_cook_time_text' => array(),
- 'recipe_description' => array( 'searchable_in_all_content' => true ),
- 'recipe_ingredients' => array( 'searchable_in_all_content' => true ),
- 'recipe_instructions' => array( 'searchable_in_all_content' => true ),
- 'recipe_notes' => array( 'searchable_in_all_content' => true ),
- 'recipe_nutritional' => array( 'searchable_in_all_content' => true ),
- 'recipe_passive_time' => array(),
- 'recipe_passive_time_text' => array(),
- 'recipe_prep_time' => array(),
- 'recipe_prep_time_text' => array(),
- 'recipe_rating' => array(),
- 'recipe_servings' => array(),
- 'recipe_servings_normalized' => array(),
- 'recipe_servings_type' => array(),
- 'recipe_terms' => array( 'searchable_in_all_content' => true ),
- 'recipe_terms_with_parents' => array(),
- 'recipe_title' => array( 'searchable_in_all_content' => true ),
- 'recipe_user_ratings' => array(),
- 'recipe_user_ratings_rating' => array(),
+ 'recipe_alternate_image' => array(),
+ 'recipe_cook_time' => array(),
+ 'recipe_cook_time_text' => array(),
+ 'recipe_description' => array( 'searchable_in_all_content' => true ),
+ 'recipe_ingredients' => array( 'searchable_in_all_content' => true ),
+ 'recipe_instructions' => array( 'searchable_in_all_content' => true ),
+ 'recipe_notes' => array( 'searchable_in_all_content' => true ),
+ 'recipe_nutritional' => array( 'searchable_in_all_content' => true ),
+ 'recipe_passive_time' => array(),
+ 'recipe_passive_time_text' => array(),
+ 'recipe_prep_time' => array(),
+ 'recipe_prep_time_text' => array(),
+ 'recipe_rating' => array(),
+ 'recipe_servings' => array(),
+ 'recipe_servings_normalized' => array(),
+ 'recipe_servings_type' => array(),
+ 'recipe_terms' => array( 'searchable_in_all_content' => true ),
+ 'recipe_terms_with_parents' => array(),
+ 'recipe_title' => array( 'searchable_in_all_content' => true ),
+ 'recipe_user_ratings' => array(),
+ 'recipe_user_ratings_rating' => array(),
// generic fields.
// from advanced-custom-fields and metabox.io .
- 'Link' => array(),
- 'Location' => array(),
- 'Title' => array( 'searchable_in_all_content' => true ),
- 'ad_code' => array(),
- 'address' => array(),
- 'admin_mail' => array(),
- 'admin_only' => array(),
- 'advertisers' => array( 'searchable_in_all_content' => true ),
- 'age' => array(),
- 'aliases' => array(),
- 'alternate_title' => array(),
- 'ama_content' => array(),
- 'amazon' => array(),
- 'answer' => array( 'searchable_in_all_content' => true ),
- 'area' => array(),
- 'attention' => array(),
- 'attr' => array(),
- 'author' => array( 'searchable_in_all_content' => true ),
- 'author_name' => array( 'searchable_in_all_content' => true ),
- 'blog' => array(),
- 'blog_id' => array(),
- 'call_to_action' => array(),
- 'campaign_preview' => array(),
- 'canonical_url' => array(),
- 'catch_text' => array(),
- 'category' => array( 'searchable_in_all_content' => true ),
- 'classificacao' => array(),
- 'classification' => array(),
- 'code' => array(),
- 'codigo' => array(),
- 'company' => array( 'searchable_in_all_content' => true ),
- 'company_website' => array(),
- 'config' => array(),
- 'construction' => array(),
- 'container_ids' => array(),
- 'content' => array( 'searchable_in_all_content' => true ),
- 'content_body-full_content' => array( 'searchable_in_all_content' => true ),
- 'copyright' => array(),
- 'custom_page_title' => array( 'searchable_in_all_content' => true ),
- 'custom_permalink' => array(),
- 'customize' => array(),
- 'data' => array(),
- 'date' => array(),
- 'day' => array(),
- 'descripcion' => array( 'searchable_in_all_content' => true ),
- 'description' => array( 'searchable_in_all_content' => true ),
- 'display_settings' => array(),
- 'display_type' => array(),
- 'duration' => array(),
- 'embed' => array(),
- 'entity_ids' => array(),
- 'entity_types' => array(),
- 'event_subtitle' => array( 'searchable_in_all_content' => true ),
- 'excluded_container_ids' => array(),
- 'exclusions' => array(),
- 'experience' => array(),
- 'external_url' => array(),
- 'featured' => array(),
- 'featured_image' => array(),
- 'featured_post' => array(),
- 'featured_story' => array(),
- 'fee' => array(),
- 'filter' => array(),
- 'follow' => array(),
- 'footer_text' => array(),
- 'from_header' => array(),
- 'fullscreen_view' => array(),
- 'gallery' => array(),
- 'genre' => array( 'searchable_in_all_content' => true ),
- 'guest_bio' => array(),
- 'guest_name' => array(),
- 'guests' => array( 'searchable_in_all_content' => true ),
- 'has_variations' => array(),
- 'hashtag' => array(),
- 'header_image' => array(),
- 'hidden_from_ui' => array(),
- 'hide_on_screen' => array(),
- 'homepage_order' => array(),
- 'hours' => array(),
- 'i18n' => array(),
- 'id' => array(),
- 'image' => array(),
- 'image_size' => array(),
- 'image_source' => array(),
- 'index' => array(),
- 'intro_text' => array( 'searchable_in_all_content' => true ),
- 'job_mention' => array( 'searchable_in_all_content' => true ),
- 'keywords' => array( 'searchable_in_all_content' => true ),
- 'latest_news' => array(),
- 'layout' => array(),
- 'link' => array(),
- 'link_dump' => array( 'searchable_in_all_content' => true ),
- 'link_url' => array(),
- 'location' => array(),
- 'logo' => array(),
- 'main_title' => array( 'searchable_in_all_content' => true ),
- 'maximum_entity_count' => array(),
- 'media' => array(),
- 'mentions' => array(),
- 'messages' => array(),
- 'meta_description' => array( 'searchable_in_all_content' => true ),
- 'meta_id' => array(),
- 'meta_index' => array(),
- 'meta_key' => array(),
- 'meta_value' => array(),
- 'modal-dialog-id' => array(),
- 'name' => array( 'searchable_in_all_content' => true ),
- 'nombre' => array( 'searchable_in_all_content' => true ),
- 'notes' => array( 'searchable_in_all_content' => true ),
- 'options' => array(),
- 'order_by' => array(),
- 'order_direction' => array(),
- 'original_cats' => array(),
- 'original_headers' => array(),
- 'original_link' => array(),
- 'original_message' => array(),
- 'original_subject' => array(),
- 'original_title' => array(),
- 'original_to' => array(),
- 'other_setting' => array(),
- 'page_canonical' => array(),
- 'page_layout' => array(),
- 'page_sidebar' => array(),
- 'page_tags' => array(),
- 'panels_data' => array(),
- 'parking' => array(),
- 'pdf_upload' => array(),
- 'people_mentioned' => array(),
- 'photo' => array(),
- 'play_time' => array(),
- 'position' => array(),
- 'post-rating' => array(),
- 'post_background' => array(),
- 'post_color' => array(),
- 'post_sidebar' => array(),
- 'post_subtitle' => array( 'searchable_in_all_content' => true ),
- 'price' => array(),
- 'publication' => array(),
- 'rating' => array(),
- 'ratings_average' => array(),
- 'ratings_score' => array(),
- 'ratings_users' => array(),
- 'relation' => array(),
- 'reply_to_header' => array(),
- 'required' => array(),
- 'returns' => array(),
- 'review_post' => array(),
- 'rule' => array(),
- 'section' => array( 'searchable_in_all_content' => true ),
- 'selected_links' => array(),
- 'session_transcript' => array(),
- 'settings' => array(),
- 'sex' => array(),
- 'shares_count' => array(),
- 'show_description' => array( 'searchable_in_all_content' => true ),
- 'show_page_title' => array(),
- 'show_notes' => array(),
- 'show_notes_preview' => array(),
- 'side' => array(),
- 'sidebar' => array(),
- 'site' => array(),
- 'situation' => array(),
- 'slide_template' => array(),
- 'slug' => array(),
- 'sortorder' => array(),
- 'source' => array(),
- 'start_date' => array(),
- 'status' => array(),
- 'styles' => array(),
- 'subtitle' => array( 'searchable_in_all_content' => true ),
- 'subtitulo' => array(),
- 'success' => array(),
- 'summary' => array( 'searchable_in_all_content' => true ),
- 'synopsis' => array( 'searchable_in_all_content' => true ),
- 'tel' => array(),
- 'tema' => array(),
- 'testimonial' => array(),
- 'testimonial_author' => array(),
- 'text_already_subscribed' => array(),
- 'text_error' => array(),
- 'text_invalid_email' => array(),
- 'text_not_subscribed' => array(),
- 'text_required_field_missing' => array(),
- 'text_subscribed' => array(),
- 'text_unsubscribed' => array(),
- 'thumbnail' => array(),
- 'time' => array(),
- 'time_jump_list' => array( 'searchable_in_all_content' => true ),
- 'title' => array( 'searchable_in_all_content' => true ),
- 'title_view' => array(),
- 'titre' => array( 'searchable_in_all_content' => true ),
- 'titulo' => array( 'searchable_in_all_content' => true ),
- 'to_header' => array(),
- 'toc' => array(),
- 'transcript' => array( 'searchable_in_all_content' => true ),
- 'transport_uri' => array(),
- 'type' => array(),
- 'url' => array(),
- 'validation' => array(),
- 'value' => array(),
- 'values' => array(),
- 'variation' => array(),
- 'video' => array(),
- 'video_type' => array(),
- 'video_url' => array(),
- 'videopress_guid' => array(),
- 'website' => array(),
- 'weight' => array(),
- 'year' => array(),
+ 'Link' => array(),
+ 'Location' => array(),
+ 'Title' => array( 'searchable_in_all_content' => true ),
+ 'ad_code' => array(),
+ 'address' => array(),
+ 'admin_mail' => array(),
+ 'admin_only' => array(),
+ 'advertisers' => array( 'searchable_in_all_content' => true ),
+ 'age' => array(),
+ 'aliases' => array(),
+ 'alternate_title' => array(),
+ 'ama_content' => array(),
+ 'amazon' => array(),
+ 'answer' => array( 'searchable_in_all_content' => true ),
+ 'area' => array(),
+ 'attention' => array(),
+ 'attr' => array(),
+ 'author' => array( 'searchable_in_all_content' => true ),
+ 'author_name' => array( 'searchable_in_all_content' => true ),
+ 'blog' => array(),
+ 'blog_id' => array(),
+ 'call_to_action' => array(),
+ 'campaign_preview' => array(),
+ 'canonical_url' => array(),
+ 'catch_text' => array(),
+ 'category' => array( 'searchable_in_all_content' => true ),
+ 'classificacao' => array(),
+ 'classification' => array(),
+ 'code' => array(),
+ 'codigo' => array(),
+ 'company' => array( 'searchable_in_all_content' => true ),
+ 'company_website' => array(),
+ 'config' => array(),
+ 'construction' => array(),
+ 'container_ids' => array(),
+ 'content' => array( 'searchable_in_all_content' => true ),
+ 'content_body-full_content' => array( 'searchable_in_all_content' => true ),
+ 'copyright' => array(),
+ 'custom_page_title' => array( 'searchable_in_all_content' => true ),
+ 'custom_permalink' => array(),
+ 'customize' => array(),
+ 'data' => array(),
+ 'date' => array(),
+ 'day' => array(),
+ 'descripcion' => array( 'searchable_in_all_content' => true ),
+ 'description' => array( 'searchable_in_all_content' => true ),
+ 'display_settings' => array(),
+ 'display_type' => array(),
+ 'duration' => array(),
+ 'embed' => array(),
+ 'entity_ids' => array(),
+ 'entity_types' => array(),
+ 'event_subtitle' => array( 'searchable_in_all_content' => true ),
+ 'excluded_container_ids' => array(),
+ 'exclusions' => array(),
+ 'experience' => array(),
+ 'external_url' => array(),
+ 'featured' => array(),
+ 'featured_image' => array(),
+ 'featured_post' => array(),
+ 'featured_story' => array(),
+ 'fee' => array(),
+ 'filter' => array(),
+ 'follow' => array(),
+ 'footer_text' => array(),
+ 'from_header' => array(),
+ 'fullscreen_view' => array(),
+ 'gallery' => array(),
+ 'genre' => array( 'searchable_in_all_content' => true ),
+ 'guest_bio' => array(),
+ 'guest_name' => array(),
+ 'guests' => array( 'searchable_in_all_content' => true ),
+ 'has_variations' => array(),
+ 'hashtag' => array(),
+ 'header_image' => array(),
+ 'hidden_from_ui' => array(),
+ 'hide_on_screen' => array(),
+ 'homepage_order' => array(),
+ 'hours' => array(),
+ 'i18n' => array(),
+ 'id' => array(),
+ 'image' => array(),
+ 'image_size' => array(),
+ 'image_source' => array(),
+ 'index' => array(),
+ 'intro_text' => array( 'searchable_in_all_content' => true ),
+ 'job_mention' => array( 'searchable_in_all_content' => true ),
+ 'keywords' => array( 'searchable_in_all_content' => true ),
+ 'latest_news' => array(),
+ 'layout' => array(),
+ 'link' => array(),
+ 'link_dump' => array( 'searchable_in_all_content' => true ),
+ 'link_url' => array(),
+ 'location' => array(),
+ 'logo' => array(),
+ 'main_title' => array( 'searchable_in_all_content' => true ),
+ 'maximum_entity_count' => array(),
+ 'media' => array(),
+ 'mentions' => array(),
+ 'messages' => array(),
+ 'meta_description' => array( 'searchable_in_all_content' => true ),
+ 'meta_id' => array(),
+ 'meta_index' => array(),
+ 'meta_key' => array(),
+ 'meta_value' => array(),
+ 'modal-dialog-id' => array(),
+ 'name' => array( 'searchable_in_all_content' => true ),
+ 'nombre' => array( 'searchable_in_all_content' => true ),
+ 'notes' => array( 'searchable_in_all_content' => true ),
+ 'options' => array(),
+ 'order_by' => array(),
+ 'order_direction' => array(),
+ 'original_cats' => array(),
+ 'original_headers' => array(),
+ 'original_link' => array(),
+ 'original_message' => array(),
+ 'original_subject' => array(),
+ 'original_title' => array(),
+ 'original_to' => array(),
+ 'other_setting' => array(),
+ 'page_canonical' => array(),
+ 'page_layout' => array(),
+ 'page_sidebar' => array(),
+ 'page_tags' => array(),
+ 'panels_data' => array(),
+ 'parking' => array(),
+ 'pdf_upload' => array(),
+ 'people_mentioned' => array(),
+ 'photo' => array(),
+ 'play_time' => array(),
+ 'position' => array(),
+ 'post-rating' => array(),
+ 'post_background' => array(),
+ 'post_color' => array(),
+ 'post_sidebar' => array(),
+ 'post_subtitle' => array( 'searchable_in_all_content' => true ),
+ 'price' => array(),
+ 'publication' => array(),
+ 'rating' => array(),
+ 'ratings_average' => array(),
+ 'ratings_score' => array(),
+ 'ratings_users' => array(),
+ 'relation' => array(),
+ 'reply_to_header' => array(),
+ 'required' => array(),
+ 'returns' => array(),
+ 'review_post' => array(),
+ 'rule' => array(),
+ 'section' => array( 'searchable_in_all_content' => true ),
+ 'selected_links' => array(),
+ 'session_transcript' => array(),
+ 'settings' => array(),
+ 'sex' => array(),
+ 'shares_count' => array(),
+ 'show_description' => array( 'searchable_in_all_content' => true ),
+ 'show_page_title' => array(),
+ 'show_notes' => array(),
+ 'show_notes_preview' => array(),
+ 'side' => array(),
+ 'sidebar' => array(),
+ 'site' => array(),
+ 'situation' => array(),
+ 'slide_template' => array(),
+ 'slug' => array(),
+ 'sortorder' => array(),
+ 'source' => array(),
+ 'start_date' => array(),
+ 'status' => array(),
+ 'styles' => array(),
+ 'subtitle' => array( 'searchable_in_all_content' => true ),
+ 'subtitulo' => array(),
+ 'success' => array(),
+ 'summary' => array( 'searchable_in_all_content' => true ),
+ 'synopsis' => array( 'searchable_in_all_content' => true ),
+ 'tel' => array(),
+ 'tema' => array(),
+ 'testimonial' => array(),
+ 'testimonial_author' => array(),
+ 'text_already_subscribed' => array(),
+ 'text_error' => array(),
+ 'text_invalid_email' => array(),
+ 'text_not_subscribed' => array(),
+ 'text_required_field_missing' => array(),
+ 'text_subscribed' => array(),
+ 'text_unsubscribed' => array(),
+ 'thumbnail' => array(),
+ 'time' => array(),
+ 'time_jump_list' => array( 'searchable_in_all_content' => true ),
+ 'title' => array( 'searchable_in_all_content' => true ),
+ 'title_view' => array(),
+ 'titre' => array( 'searchable_in_all_content' => true ),
+ 'titulo' => array( 'searchable_in_all_content' => true ),
+ 'to_header' => array(),
+ 'toc' => array(),
+ 'transcript' => array( 'searchable_in_all_content' => true ),
+ 'transport_uri' => array(),
+ 'type' => array(),
+ 'url' => array(),
+ 'validation' => array(),
+ 'value' => array(),
+ 'values' => array(),
+ 'variation' => array(),
+ 'video' => array(),
+ 'video_type' => array(),
+ 'video_url' => array(),
+ 'videopress_guid' => array(),
+ 'website' => array(),
+ 'weight' => array(),
+ 'year' => array(),
// wp.com martketplace search - @see https://wp.me/pdh6GB-Ax#comment-2104
- '_app_icon' => array(),
- '_featured_product_video' => array(),
- 'wpcom_marketplace_org_slug' => array(),
- '_wc_general_product_dependency_theme' => array(),
- '_wc_general_product_dependency_plugin' => array(),
- 'wpcom_marketplace_product_extra_fields' => array(),
- 'wccom_product_search_keywords' => array( 'searchable_in_all_content' => true ),
- '_wccom_product_faqs' => array( 'searchable_in_all_content' => true ),
- 'wccom_product_features' => array( 'searchable_in_all_content' => true ),
- 'wccom_product_compatibility' => array( 'searchable_in_all_content' => true ),
+ '_app_icon' => array(),
+ '_featured_product_video' => array(),
+ 'wpcom_marketplace_org_slug' => array(),
+ '_wc_general_product_dependency_theme' => array(),
+ '_wc_general_product_dependency_plugin' => array(),
+ 'wpcom_marketplace_product_extra_fields' => array(),
+ 'wccom_product_search_keywords' => array( 'searchable_in_all_content' => true ),
+ '_wccom_product_faqs' => array( 'searchable_in_all_content' => true ),
+ 'wccom_product_features' => array( 'searchable_in_all_content' => true ),
+ 'wccom_product_compatibility' => array( 'searchable_in_all_content' => true ),
); // end indexed post meta.
@@ -1809,11 +1812,23 @@ public function add_search_options_whitelist( $list ) {
return array_merge( $list, static::get_all_option_keys() );
}
- //
+ /**
+ * Add AI Answer CPTs to the post types sync whitelist when AI Answers is enabled.
+ *
+ * @param array $list Existing post types whitelist.
+ * @return array Updated whitelist.
+ */
+ public function add_ai_answer_post_types( $list ) {
+ if ( ! apply_filters( 'jetpack_search_ai_answers_enabled', (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) ) ) {
+ return $list;
+ }
+ $list[] = 'wp_guideline';
+ return $list;
+ }
+
// Indexing functions for wp.com.
/**
- *
* Check whether a postmeta or taxonomy 'key' is in the indexable
* list. This is called by the indexing code on wp.com to decide
* whether to include something in the index.
diff --git a/projects/packages/sync/tests/php/modules/Search_Module_Test.php b/projects/packages/sync/tests/php/modules/Search_Module_Test.php
new file mode 100644
index 000000000000..a84b24e37a29
--- /dev/null
+++ b/projects/packages/sync/tests/php/modules/Search_Module_Test.php
@@ -0,0 +1,74 @@
+search_module = new Modules\Search();
+ }
+
+ /**
+ * Runs after every test in this class.
+ */
+ protected function tearDown(): void {
+ remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' );
+ parent::tearDown();
+ }
+
+ /**
+ * The guideline CPT should NOT be in the whitelist when AI Answers is off (default).
+ */
+ public function test_ai_cpts_not_in_whitelist_when_search_disabled() {
+ $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() );
+ $this->assertNotContains( 'wp_guideline', $list );
+ $this->assertNotContains( 'jetpack_search_topic', $list );
+ }
+
+ /**
+ * The guideline CPT should be in the whitelist when AI Answers is enabled via filter.
+ */
+ public function test_ai_cpts_in_whitelist_when_search_enabled() {
+ add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' );
+ $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() );
+ remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' );
+ $this->assertContains( 'wp_guideline', $list );
+ $this->assertNotContains( 'jetpack_search_topic', $list );
+ }
+
+ /**
+ * The guideline CPT should be in the whitelist when enabled via WP option only (no filter).
+ */
+ public function test_ai_cpts_in_whitelist_when_option_set() {
+ update_option( 'jetpack_search_ai_answers_enabled', 1 );
+ $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() );
+ delete_option( 'jetpack_search_ai_answers_enabled' );
+ $this->assertContains( 'wp_guideline', $list );
+ $this->assertNotContains( 'jetpack_search_topic', $list );
+ }
+}
diff --git a/tools/e2e-commons/setup-specs/auth.setup.ts b/tools/e2e-commons/setup-specs/auth.setup.ts
index 725d79f8b5eb..16a68dede552 100644
--- a/tools/e2e-commons/setup-specs/auth.setup.ts
+++ b/tools/e2e-commons/setup-specs/auth.setup.ts
@@ -35,13 +35,18 @@ setup(
await setup.step( 'verify wordpress.com user authentication', async () => {
// use the browser to ensure JS routing is executed
const page = await authenticatedContext.newPage();
- await page.goto( 'https://wordpress.com/me' );
+ await page.goto( 'https://wordpress.com/me', { waitUntil: 'load' } );
+
+ await expect
+ .soft( page, 'User remains on an authenticated WordPress.com page' )
+ .toHaveURL( /wordpress\.com\/me/ );
+
await expect
.soft(
- page.getByRole( 'link', { name: `Howdy, ${ dotComCredentials.username }` } ),
- 'User is logged in and username is visible'
+ page.getByRole( 'link', { name: /log in/i } ),
+ 'Login link should not be visible for authenticated user'
)
- .toBeVisible();
+ .toHaveCount( 0 );
} );
}
);