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