Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.52% covered (warning)
88.52%
185 / 209
75.86% covered (warning)
75.86%
22 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Sidebar
88.94% covered (warning)
88.94%
185 / 208
75.86% covered (warning)
75.86%
22 / 29
117.34
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 maybe_enqueue_abilities_script
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_asset_data_from_file
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
13.57
 get_asset_data_from_remote
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
25.85
 register_provider
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 get_ai_sidebar_asset_data
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 is_ai_editorial_review_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_generate_feedback_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_optimize_title_suggestion_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_proofread_content_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_seo_suggestions_enabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 is_excerpt_suggestion_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_block_toolbar_button_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_seo_feature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_seo_tools_usable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_jetpack_ai_sidebar_preview_enabled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 should_expose_provider
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_jetpack_ai_sidebar_preview_config
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 is_toolbar_button_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_toolbar_button_extension
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 add_agents_manager_data
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_agents_manager_data_fields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 enable_agents_manager_on_provider_surfaces
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_patch_jetpack_ai_sidebar_preview_data
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
7
 get_agent_provider_upsert_script
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_block_editor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 is_supported_provider_surface
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 is_provider_rollout_enabled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 has_ai_features
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Jetpack AI Sidebar — Agents Manager integration and provider registration.
4 *
5 * Initializes the jetpack-agents-manager package so it loads the Agents
6 * Manager bundle and emits its `agentsManagerData` payload, registers Jetpack
7 * AI as an Agents Manager provider, and loads the Jetpack AI provider bundle
8 * that exposes Jetpack AI abilities in the block editor.
9 *
10 * @package automattic/jetpack
11 */
12
13namespace Automattic\Jetpack\Extensions\AiAssistantPlugin;
14
15use Automattic\Jetpack\Agents_Manager\Agents_Manager;
16use Automattic\Jetpack\Connection\Manager as Connection_Manager;
17use Automattic\Jetpack\Current_Plan;
18use Automattic\Jetpack\Modules;
19use Automattic\Jetpack\Status;
20use Automattic\Jetpack\Status\Host;
21
22// Required directly rather than relying on the plugin bootstrap: on
23// WordPress.com Simple the extension files load through wpcom's own loader
24// and load-jetpack.php never runs.
25require_once __DIR__ . '/../../../../_inc/lib/class-jetpack-ai-settings.php';
26
27const AM_ASSET_BASE_PATH                  = 'widgets.wp.com/agents-manager/';
28const AI_SIDEBAR_ASSET_TRANSIENT          = 'jetpack_ai_sidebar_asset';
29const AI_SIDEBAR_JS_URL                   = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.min.js';
30const AI_SIDEBAR_CSS_URL                  = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.css';
31const AI_SIDEBAR_RTL_CSS_URL              = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.rtl.css';
32const AI_SIDEBAR_PROVIDER_URL             = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.provider.mjs';
33const AI_SIDEBAR_AGENT_ID                 = 'wp-orchestrator';
34const AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION = 'ai-sidebar-toolbar-button';
35
36/**
37 * Initializes the Agents Manager package and registers the Jetpack AI
38 * provider in the block editor.
39 */
40class Jetpack_AI_Sidebar {
41
42    /**
43     * Initialize hooks.
44     *
45     * @return void
46     */
47    public static function init(): void {
48        // Gate the whole sidebar entrypoint on the preview surface, which is
49        // itself overridable via the jetpack_ai_sidebar_enabled filter.
50        if ( ! self::is_jetpack_ai_sidebar_preview_enabled() ) {
51            return;
52        }
53
54        // Initialize the Agents Manager package so it owns loading the Agents
55        // Manager bundle and emitting the `agentsManagerData` payload. The call
56        // is idempotent: on WordPress.com and Atomic, jetpack-mu-wpcom may have
57        // already initialized it, in which case this is a no-op.
58        Agents_Manager::init();
59
60        // Register as Agents Manager provider. The filter fires inside
61        // Agents_Manager::enqueue_scripts(). Priority 20 so Jetpack loads
62        // AFTER Image Studio (priority 10).
63        add_filter( 'agents_manager_agent_providers', array( __CLASS__, 'register_provider' ), 20 );
64
65        add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( __CLASS__, 'add_agents_manager_data' ), 10, 1 );
66
67        // Ask the Agents Manager package to mount on Jetpack AI provider surfaces.
68        add_filter( 'agents_manager_enabled_in_block_editor', array( __CLASS__, 'enable_agents_manager_on_provider_surfaces' ) );
69
70        // Enqueue the IIFE bundle on supported editor surfaces — it registers
71        // Jetpack AI abilities via @wordpress/abilities, which Big Sky or the
72        // Agents Manager can discover regardless of which provider system is active.
73        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_abilities_script' ), 201 );
74
75        // Patch Jetpack AI Sidebar Preview data into agentsManagerData when the
76        // Agents Manager bundle was enqueued by an external host (Big Sky on
77        // Atomic, etc.) and the jetpack_ai_sidebar_agents_manager_data filter
78        // never fired. Priority 250 runs after both jetpack-mu-wpcom and the
79        // Agents Manager package enqueue (priority 101).
80        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_patch_jetpack_ai_sidebar_preview_data' ), 250 );
81
82        // Let editor JS know when the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar.
83        add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_toolbar_button_extension' ), 99 );
84    }
85
86    // ──────────────────────────────────────────────────
87    // Jetpack AI provider bundle
88    // ──────────────────────────────────────────────────
89
90    /**
91     * Enqueue the IIFE bundle that registers Jetpack AI abilities.
92     *
93     * This runs independently of AM/provider registration so preview abilities
94     * are available even when Big Sky standalone is the active UI.
95     *
96     * @return void
97     */
98    public static function maybe_enqueue_abilities_script(): void {
99        if ( ! self::should_expose_provider() ) {
100            return;
101        }
102
103        // CIAB (next-admin) has its own AM setup — don't enqueue alongside it.
104        if ( did_action( 'next_admin_init' ) ) {
105            return;
106        }
107
108        // Guard against double-enqueue (e.g. hooked multiple times).
109        if ( wp_script_is( 'jetpack-ai-provider' ) ) {
110            return;
111        }
112
113        $asset_data = self::get_ai_sidebar_asset_data();
114        if ( ! $asset_data ) {
115            return;
116        }
117
118        $version      = $asset_data['version'] ?? false;
119        $dependencies = $asset_data['dependencies'] ?? array();
120
121        wp_enqueue_script(
122            'jetpack-ai-provider',
123            AI_SIDEBAR_JS_URL,
124            $dependencies,
125            $version,
126            true
127        );
128
129        wp_enqueue_style(
130            'jetpack-ai-provider',
131            is_rtl() ? AI_SIDEBAR_RTL_CSS_URL : AI_SIDEBAR_CSS_URL,
132            array(),
133            $version
134        );
135    }
136
137    // ──────────────────────────────────────────────────
138    // Asset manifest (Image Studio pattern)
139    // ──────────────────────────────────────────────────
140
141    /**
142     * Try to read the asset manifest from the local filesystem.
143     *
144     * On WordPress.com, widgets.wp.com assets are available at ABSPATH.
145     *
146     * @param string $relative_path The relative path to the JSON file.
147     * @return array|false The decoded asset data, or false if not available locally.
148     */
149    private static function get_asset_data_from_file( string $relative_path ) {
150        $local_path = ABSPATH . $relative_path;
151        if ( ! file_exists( $local_path ) ) {
152            return false;
153        }
154
155        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local file, not a remote URL.
156        $contents = file_get_contents( $local_path );
157        if ( false === $contents ) {
158            return false;
159        }
160
161        $data = json_decode( $contents, true );
162        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
163            return false;
164        }
165
166        return $data;
167    }
168
169    /**
170     * Fetch the asset manifest via HTTP.
171     *
172     * @param string $url The URL to fetch.
173     * @return array|false The decoded asset data, or false on failure.
174     */
175    private static function get_asset_data_from_remote( string $url ) {
176        $response = wp_safe_remote_get( $url );
177        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
178            return false;
179        }
180
181        $content_type = wp_remote_retrieve_header( $response, 'content-type' );
182        if ( is_string( $content_type ) && false === stripos( $content_type, 'json' ) ) {
183            return false;
184        }
185
186        $body = wp_remote_retrieve_body( $response );
187        $data = json_decode( $body, true );
188        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
189            return false;
190        }
191
192        return $data;
193    }
194
195    // ──────────────────────────────────────────────────
196    // Provider registration
197    // ──────────────────────────────────────────────────
198
199    /**
200     * Register Jetpack AI as an Agents Manager provider.
201     *
202     * Appends the CDN-hosted ESM wrapper URL to the providers list so AM
203     * can dynamically import it. Asset enqueueing is handled separately by
204     * maybe_enqueue_abilities_script.
205     *
206     * @param array $providers Existing provider URLs.
207     * @return array Updated providers.
208     */
209    public static function register_provider( array $providers ): array {
210        // CIAB (next-admin) has AM natively — skip to avoid duplicate agents.
211        if ( did_action( 'next_admin_init' ) ) {
212            return $providers;
213        }
214
215        // The provider IIFE is enqueued on the same surfaces where the ESM
216        // wrapper is registered. Avoid registering the wrapper when AM may
217        // import it before window.__JetpackAIProvider exists.
218        if ( ! self::should_expose_provider() ) {
219            return $providers;
220        }
221
222        // Don't register if the IIFE bundle cannot be loaded. The ESM wrapper
223        // re-exports from window.__JetpackAIProvider at import time; if the
224        // IIFE never ran, toolProvider is still a truthy Proxy and AM would
225        // call getAbilities() on it and get undefined, breaking the merge.
226        if ( ! self::get_ai_sidebar_asset_data() ) {
227            return $providers;
228        }
229
230        // Register as AM provider via CDN-hosted ESM wrapper.
231        // AM dynamically imports this module to merge tools, suggestions, and components.
232        // No ?ver= needed — the wrapper re-exports from window.__JetpackAIProvider
233        // at import time, so its behavior always matches the loaded IIFE bundle.
234        $providers[] = AI_SIDEBAR_PROVIDER_URL;
235
236        return $providers;
237    }
238
239    /**
240     * Fetch and cache the CDN asset manifest for the AI sidebar bundle.
241     *
242     * @return array|false The decoded asset data, or false on failure.
243     */
244    private static function get_ai_sidebar_asset_data() {
245        $skip_cache = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
246
247        if ( ! $skip_cache ) {
248            $cached = get_transient( AI_SIDEBAR_ASSET_TRANSIENT );
249            if ( false !== $cached ) {
250                return $cached;
251            }
252        }
253
254        $json_path = AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.asset.json';
255
256        // Try local file first (available on WordPress.com).
257        $data = self::get_asset_data_from_file( $json_path );
258
259        // Fallback to remote fetch.
260        if ( false === $data ) {
261            $data = self::get_asset_data_from_remote( 'https://' . $json_path );
262        }
263
264        if ( false === $data ) {
265            // The manifest is unavailable from both the local file and the CDN
266            // — for example when the server cannot reach widgets.wp.com. Skip
267            // rather than enqueue a provider whose bundle the browser may also
268            // be unable to load, which would break the Agents Manager merge.
269            return false;
270        }
271
272        if ( ! $skip_cache ) {
273            set_transient( AI_SIDEBAR_ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
274        }
275
276        return $data;
277    }
278
279    // ──────────────────────────────────────────────────
280    // Helper methods
281    // ──────────────────────────────────────────────────
282
283    /**
284     * UI feature flag for AI Editorial Review.
285     *
286     * Server-side permission checks still gate execution. This site-side flag
287     * controls whether the sidebar suggestion is exposed, while keeping a
288     * feature-specific filter available as a kill switch. It also follows the
289     * writing assistant toggle on the AI settings page: editorial review is a
290     * writing suggestion, so writing off must switch it off too.
291     *
292     * @return bool
293     */
294    private static function is_ai_editorial_review_enabled(): bool {
295        return (bool) apply_filters(
296            'jetpack_ai_editorial_review_enabled',
297            true
298        ) && \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
299    }
300
301    /**
302     * UI feature flag for the Generate Feedback suggestion.
303     *
304     * Exposed only in internal testing environments while the feature is in
305     * development, and only while the writing assistant toggle on the AI
306     * settings page is on — feedback on a draft is a writing suggestion.
307     *
308     * @return bool
309     */
310    private static function is_generate_feedback_enabled(): bool {
311        return jetpack_is_internal_testing_environment()
312            && \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
313    }
314
315    /**
316     * UI feature flag for the Optimize Title suggestion.
317     *
318     * Exposed only in internal testing environments while the feature is in
319     * development, and only while the writing assistant toggle on the AI
320     * settings page is on — a switched-off feature must not surface suggestions
321     * even when an external host (Big Sky, Woo) draws the sidebar.
322     *
323     * @return bool
324     */
325    private static function is_optimize_title_suggestion_enabled(): bool {
326        return jetpack_is_internal_testing_environment()
327            && \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
328    }
329
330    /**
331     * UI feature flag for Proofreader (spelling and grammar).
332     *
333     * Server-side permission checks still gate execution. This site-side flag
334     * controls whether the Jetpack AI Sidebar exposes the Proofreader
335     * suggestion. It follows Image Studio's internal rollout pattern, and also
336     * honors the writing assistant toggle on the AI settings page.
337     *
338     * @return bool
339     */
340    private static function is_proofread_content_enabled(): bool {
341        return jetpack_is_internal_testing_environment()
342            && \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
343    }
344
345    /**
346     * UI feature flag for the SEO Enhancer suggestions (SEO title and meta description).
347     *
348     * Exposed only in internal testing environments while the feature is in development,
349     * and only where the suggestions can actually be used: the SEO Enhancer is not
350     * killed via its filter, the site's plan includes the Jetpack SEO feature (the
351     * suggestions write to the plan-gated SEO title and meta description fields), and
352     * SEO tools are usable on the site. Kept independent of the Optimize Title flag:
353     * SEO suggestions target the SEO meta fields, not the visible post title.
354     *
355     * The user-facing ai_seo_enhancer_enabled *option* is consulted since the AI
356     * settings page surfaced it as the SEO feature toggle: a switched-off feature
357     * must not offer suggestions, even user-initiated ones, and even when an
358     * external host (Big Sky, Woo) draws the sidebar.
359     *
360     * @return bool
361     */
362    private static function is_seo_suggestions_enabled(): bool {
363        return jetpack_is_internal_testing_environment()
364            && (bool) apply_filters( 'ai_seo_enhancer_enabled', true )
365            && \Jetpack_AI_Settings::is_feature_enabled( 'seo_enhancer' )
366            && self::has_seo_feature()
367            && self::is_seo_tools_usable();
368    }
369
370    /**
371     * UI feature flag for the Generate Excerpt suggestion.
372     *
373     * Exposed only in internal testing environments while the feature is in development,
374     * and only while the writing toggle on the AI settings page is on.
375     * No plan gate: the excerpt is a core editorial field, and the ability's own
376     * permission callback (edit_posts) gates execution server-side.
377     *
378     * @return bool
379     */
380    private static function is_excerpt_suggestion_enabled(): bool {
381        return jetpack_is_internal_testing_environment()
382            && \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
383    }
384
385    /**
386     * UI feature flag for the block toolbar button that replaces the legacy AI toolbar.
387     *
388     * Exposed only in internal testing environments while the feature is in development.
389     *
390     * @return bool
391     */
392    private static function is_block_toolbar_button_enabled(): bool {
393        return jetpack_is_internal_testing_environment();
394    }
395
396    /**
397     * Whether the site's plan includes the Jetpack SEO feature.
398     *
399     * Same predicate the SEO editor panel uses to decide between the SEO fields and
400     * the "Optimize SEO" upgrade nudge: extensions/plugins/seo/seo.php registers
401     * availability via Jetpack_Gutenberg::set_availability_for_plan( 'advanced-seo' ),
402     * which resolves through Current_Plan::supports(). On WordPress.com Simple and
403     * Atomic this delegates to wpcom_site_has_feature( 'advanced-seo' ) — Business
404     * and higher plans; on self-hosted sites every plan includes the feature.
405     *
406     * @return bool
407     */
408    private static function has_seo_feature(): bool {
409        return Current_Plan::supports( 'advanced-seo' );
410    }
411
412    /**
413     * Whether Jetpack SEO tools are usable on this site: SEO is not disabled via the
414     * jetpack_disable_seo_tools filter — which the seo-tools module enables itself
415     * when a conflicting SEO plugin (Yoast, AIOSEO, Rank Math, …) owns the site's
416     * SEO — and the seo-tools module is active, since the module registers the SEO
417     * meta fields the suggestions write to. On WordPress.com Simple the module always
418     * reports active, so there this reduces to the filter check.
419     *
420     * @return bool
421     */
422    private static function is_seo_tools_usable(): bool {
423        /** This filter is documented in modules/seo-tools/class-jetpack-seo-utils.php */
424        return ! apply_filters( 'jetpack_disable_seo_tools', false )
425            && ( new Modules() )->is_active( 'seo-tools' );
426    }
427
428    /**
429     * UI feature flag for the public Jetpack AI Sidebar Preview surface.
430     *
431     * Defaults to enabled only on WordPress.com platform sites (Simple or WoA)
432     * that have the Big Sky plugin present and enabled. Big Sky defaults on for
433     * Simple sites and off on WoA/Atomic. The jetpack_ai_sidebar_enabled filter
434     * is a host-level override of that default, respected by init() and every
435     * sidebar surface that gates on this method.
436     *
437     * @return bool
438     */
439    private static function is_jetpack_ai_sidebar_preview_enabled(): bool {
440        $host = new Host();
441
442        $enabled = false;
443        if ( $host->is_wpcom_platform() && class_exists( 'Big_Sky' ) ) {
444            $default = $host->is_wpcom_simple() ? '1' : '0';
445            $enabled = (bool) get_option( 'big_sky_enable', $default );
446        }
447
448        /**
449         * Filter to enable or disable the Jetpack AI sidebar feature.
450         *
451         * Defaults to true only on WordPress.com platform sites with Big Sky
452         * present and enabled. Acts as a host-level override that can force the
453         * sidebar on (e.g. for local development) or off, and is respected by
454         * init() and every sidebar surface.
455         *
456         * @param bool $enabled Whether the Jetpack AI sidebar is enabled.
457         */
458        return (bool) apply_filters( 'jetpack_ai_sidebar_enabled', $enabled );
459    }
460
461    /**
462     * Whether the Jetpack AI provider bundle should be exposed for this request.
463     *
464     * This is scoped to the supported editor surfaces: post editor, page editor,
465     * and site editor. The existing post editor surface remains available when
466     * preview and AI feature gates pass, while the new page and site editor
467     * surfaces require an internal testing environment.
468     *
469     * @return bool
470     */
471    private static function should_expose_provider(): bool {
472        return self::is_jetpack_ai_sidebar_preview_enabled()
473            && self::is_supported_provider_surface()
474            && self::is_provider_rollout_enabled()
475            && self::has_ai_features();
476    }
477
478    /**
479     * Preview configuration consumed by the Agents Manager and Jetpack AI provider bundles.
480     *
481     * @return array Preview mode and feature availability.
482     */
483    private static function get_jetpack_ai_sidebar_preview_config(): array {
484        $features = array(
485            'aiEditorialReview'       => self::is_ai_editorial_review_enabled(),
486            'generateFeedback'        => self::is_generate_feedback_enabled(),
487            'proofreadContent'        => self::is_proofread_content_enabled(),
488            'blockTransformations'    => true,
489            'blockToolbarButton'      => self::is_block_toolbar_button_enabled(),
490            'optimizeTitleSuggestion' => self::is_optimize_title_suggestion_enabled(),
491            'seoSuggestions'          => self::is_seo_suggestions_enabled(),
492            'excerptSuggestion'       => self::is_excerpt_suggestion_enabled(),
493            'chatHistory'             => false,
494            'supportGuides'           => false,
495        );
496
497        /**
498         * Filter the feature set exposed in Jetpack AI Sidebar Preview.
499         *
500         * @param array $features Associative array of preview feature flags.
501         */
502        $filtered_features = apply_filters( 'jetpack_ai_sidebar_preview_features', $features );
503        $features          = is_array( $filtered_features ) ? array_merge( $features, $filtered_features ) : $features;
504
505        // Re-assert the testing-environment gates so the generic features filter cannot
506        // expose in-development suggestions outside internal testing environments.
507        $features['generateFeedback']        = self::is_generate_feedback_enabled();
508        $features['proofreadContent']        = self::is_proofread_content_enabled();
509        $features['optimizeTitleSuggestion'] = (bool) $features['optimizeTitleSuggestion'] && self::is_optimize_title_suggestion_enabled();
510        $features['blockToolbarButton']      = (bool) $features['blockToolbarButton'] && self::is_block_toolbar_button_enabled();
511        $features['seoSuggestions']          = (bool) $features['seoSuggestions'] && self::is_seo_suggestions_enabled();
512        $features['excerptSuggestion']       = (bool) $features['excerptSuggestion'] && self::is_excerpt_suggestion_enabled();
513
514        return array(
515            'enabled'  => self::is_jetpack_ai_sidebar_preview_enabled(),
516            'features' => $features,
517        );
518    }
519
520    /**
521     * Whether the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar.
522     *
523     * @return bool
524     */
525    public static function is_toolbar_button_enabled(): bool {
526        $preview_config = self::get_jetpack_ai_sidebar_preview_config();
527
528        return self::should_expose_provider()
529            && true === ( $preview_config['features']['blockToolbarButton'] ?? false );
530    }
531
532    /**
533     * Register the Jetpack AI Sidebar toolbar button feature.
534     *
535     * @return void
536     */
537    public static function register_toolbar_button_extension(): void {
538        if ( ! self::is_toolbar_button_enabled() ) {
539            \Jetpack_Gutenberg::set_extension_unavailable(
540                AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION,
541                'jetpack_ai_sidebar_feature_disabled'
542            );
543            return;
544        }
545
546        \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION );
547    }
548
549    /**
550     * Add Jetpack AI Sidebar-specific data to externally emitted Agents Manager payloads.
551     *
552     * @param mixed $data Data encoded into `agentsManagerData`.
553     * @return mixed Filtered data.
554     */
555    public static function add_agents_manager_data( $data ) {
556        if ( ! is_array( $data ) ) {
557            return $data;
558        }
559
560        $fields = self::get_agents_manager_data_fields( $data );
561        if ( ! $fields ) {
562            return $data;
563        }
564
565        // Set our fields in place, leaving the rest of $data (including agentProviders)
566        // untouched so the client-side gate can drop Jetpack AI Sidebar while keeping
567        // fallbacks such as the Big Sky provider. Hosts that need intentional overrides
568        // should use the AI Editorial Review and preview filters.
569        foreach ( $fields as $key => $value ) {
570            $data[ $key ] = $value;
571        }
572        return $data;
573    }
574
575    /**
576     * Fields Jetpack should add to `agentsManagerData` for the current screen.
577     *
578     * @param array $data Existing Agents Manager data.
579     * @return array
580     */
581    private static function get_agents_manager_data_fields( array $data = array() ): array {
582        if ( ! self::should_expose_provider() ) {
583            return array();
584        }
585
586        $fields = array();
587        if ( empty( $data['agentId'] ) ) {
588            $fields['agentId'] = AI_SIDEBAR_AGENT_ID;
589        }
590        $fields['jetpackAiSidebar'] = self::get_jetpack_ai_sidebar_preview_config();
591
592        return $fields;
593    }
594
595    /**
596     * Enable Agents Manager when the Jetpack AI provider can be exposed.
597     *
598     * @param mixed $enabled Existing Agents Manager block-editor gate value.
599     * @return bool
600     */
601    public static function enable_agents_manager_on_provider_surfaces( $enabled ): bool {
602        if ( $enabled ) {
603            return true;
604        }
605
606        return self::should_expose_provider();
607    }
608
609    /**
610     * Inject Jetpack AI Sidebar Preview data into an externally enqueued AM bundle.
611     *
612     * The design-intended hook is jetpack_ai_sidebar_agents_manager_data, applied
613     * by jetpack-agents-manager Agents_Manager::enqueue_scripts(). On Atomic the bundled
614     * mu-wpcom (via wpcomsh) lags this PR, so the filter never fires and the
615     * client gets agentsManagerData without our fields. This `before` script runs
616     * after the upstream `before` that declares the const (added earlier) but
617     * before the AM bundle reads it, so the field is set when AM initialises.
618     * Gives Atomic parity with Jurassic Ninja without depending on a wpcomsh
619     * redeploy.
620     *
621     * Skipped on WordPress.com Simple — wpcom's data extension owns the predicate
622     * there, including any WordPress.com-specific kill-switch override.
623     *
624     * @return void
625     */
626    public static function maybe_patch_jetpack_ai_sidebar_preview_data(): void {
627        if ( ( new Host() )->is_wpcom_simple() ) {
628            return;
629        }
630
631        // 'registered' rather than 'enqueued': wp_add_inline_script attaches to any
632        // registered handle and serializes correctly regardless of when the
633        // enqueue lands in the dependency graph.
634        if ( ! wp_script_is( 'agents-manager', 'registered' ) ) {
635            return;
636        }
637
638        // The fields getter carries the provider exposure gate.
639        $fields = self::get_agents_manager_data_fields();
640        if ( ! $fields ) {
641            return;
642        }
643
644        // Build the assignments from the same field source as the data filter so the
645        // two emit paths cannot drift. agentId is guarded client-side because the
646        // externally emitted payload may already define the active agent.
647        $assignments = '';
648        foreach ( $fields as $key => $value ) {
649            $assignment_value = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
650
651            if ( 'agentId' === $key ) {
652                $assignments .= ' if ( ! agentsManagerData.agentId ) { agentsManagerData.agentId = ' . $assignment_value . '; }';
653                continue;
654            }
655
656            $assignments .= ' agentsManagerData.' . $key . ' = ' . $assignment_value . ';';
657        }
658
659        if ( self::get_ai_sidebar_asset_data() ) {
660            $assignments .= self::get_agent_provider_upsert_script();
661        }
662
663        wp_add_inline_script(
664            'agents-manager',
665            'if ( typeof agentsManagerData === "object" && agentsManagerData !== null ) {' . $assignments . ' }',
666            'before'
667        );
668    }
669
670    /**
671     * Build the inline script that upserts Jetpack AI Sidebar into agentProviders.
672     *
673     * @return string Inline JavaScript.
674     */
675    private static function get_agent_provider_upsert_script(): string {
676        $provider_url = wp_json_encode( AI_SIDEBAR_PROVIDER_URL, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
677
678        return ' agentsManagerData.agentProviders = Array.isArray( agentsManagerData.agentProviders ) ? agentsManagerData.agentProviders : [];'
679            . ' if ( agentsManagerData.agentProviders.indexOf( ' . $provider_url . ' ) === -1 ) { agentsManagerData.agentProviders.push( ' . $provider_url . ' ); }';
680    }
681
682    /**
683     * Check if the current screen is a block editor.
684     *
685     * @return bool
686     */
687    private static function is_block_editor(): bool {
688        if ( ! function_exists( 'get_current_screen' ) ) {
689            return false;
690        }
691
692        $screen = get_current_screen();
693        return $screen && $screen->is_block_editor();
694    }
695
696    /**
697     * Check if the current screen can consume the Jetpack AI provider bundle.
698     *
699     * @return bool
700     */
701    private static function is_supported_provider_surface(): bool {
702        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
703        if ( ! $screen instanceof \WP_Screen ) {
704            return false;
705        }
706
707        if ( 'site-editor' === $screen->base ) {
708            return true;
709        }
710
711        return self::is_block_editor()
712            && 'post' === $screen->base
713            && in_array( $screen->post_type, array( 'post', 'page' ), true );
714    }
715
716    /**
717     * Keep the existing post editor rollout while gating newly supported surfaces.
718     *
719     * @return bool
720     */
721    private static function is_provider_rollout_enabled(): bool {
722        if ( jetpack_is_internal_testing_environment() ) {
723            return true;
724        }
725
726        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
727        return $screen instanceof \WP_Screen
728            && 'post' === $screen->base
729            && 'post' === $screen->post_type;
730    }
731
732    /**
733     * Check whether AI features are available.
734     *
735     * - wpcom simple: available when Jetpack AI is enabled.
736     * - Atomic/self-hosted: requires Jetpack AI enabled, a connected owner, and non-offline mode.
737     *
738     * @return bool
739     */
740    private static function has_ai_features(): bool {
741        if ( ! \Jetpack_AI_Settings::is_ai_enabled() ) {
742            return false;
743        }
744
745        $host = new Host();
746
747        if ( $host->is_wpcom_simple() ) {
748            return true;
749        }
750
751        return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
752            && ! ( new Status() )->is_offline_mode();
753    }
754}