Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.32% covered (warning)
89.32%
209 / 234
71.43% covered (warning)
71.43%
20 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Sidebar
89.70% covered (warning)
89.70%
209 / 233
71.43% covered (warning)
71.43%
20 / 28
131.96
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 maybe_enqueue_abilities_script
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
7.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
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.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_seo_suggestions_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_jetpack_ai_sidebar_preview_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_host_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 has_enabled_sidebar_features
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 should_expose_provider
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_agents_manager_disconnected
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_jetpack_ai_sidebar_preview_config
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
11
 is_toolbar_button_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_toolbar_button_extension
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 is_agent_notice_enabled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 is_wordpress_agent_enabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 is_wordpress_agent_eligible
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
6.17
 register_agent_notice_extension
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 is_agent_action_available
100.00% covered (success)
100.00%
1 / 1
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.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 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
 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\SEO\Ai_Seo;
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';
35const AI_SIDEBAR_AGENT_NOTICE_EXTENSION   = 'ai-sidebar-agent-notice';
36const AI_SIDEBAR_AGENT_SUPPORT_EXTENSION  = 'ai-sidebar-agent-support';
37
38/**
39 * Initializes the Agents Manager package and registers the Jetpack AI
40 * provider in the block editor.
41 */
42class Jetpack_AI_Sidebar {
43
44    /**
45     * Initialize hooks.
46     *
47     * @return void
48     */
49    public static function init(): void {
50        // Ahead of the sidebar gate: the notice also shows on sites merely eligible for the Agent.
51        add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_agent_notice_extension' ), 99 );
52
53        // Gate the whole sidebar entrypoint on the preview surface, which is
54        // itself overridable via the jetpack_ai_sidebar_enabled filter.
55        if ( ! self::is_jetpack_ai_sidebar_preview_enabled() ) {
56            return;
57        }
58
59        // Initialize the Agents Manager package so it owns loading the Agents
60        // Manager bundle and emitting the `agentsManagerData` payload. The call
61        // is idempotent: on WordPress.com and Atomic, jetpack-mu-wpcom may have
62        // already initialized it, in which case this is a no-op.
63        Agents_Manager::init();
64
65        // Register as Agents Manager provider. The filter fires inside
66        // Agents_Manager::enqueue_scripts(). Priority 20 so Jetpack loads
67        // AFTER Image Studio (priority 10).
68        add_filter( 'agents_manager_agent_providers', array( __CLASS__, 'register_provider' ), 20 );
69
70        add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( __CLASS__, 'add_agents_manager_data' ), 10, 1 );
71
72        // Ask the Agents Manager package to mount on Jetpack AI provider surfaces.
73        add_filter( 'agents_manager_enabled_in_block_editor', array( __CLASS__, 'enable_agents_manager_on_provider_surfaces' ) );
74
75        // Enqueue the IIFE bundle on supported editor surfaces — it registers
76        // Jetpack AI abilities via @wordpress/abilities, which Big Sky or the
77        // Agents Manager can discover regardless of which provider system is active.
78        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_abilities_script' ), 201 );
79
80        // Patch Jetpack AI Sidebar Preview data into agentsManagerData when the
81        // Agents Manager bundle was enqueued by an external host (Big Sky on
82        // Atomic, etc.) and the jetpack_ai_sidebar_agents_manager_data filter
83        // never fired. Priority 250 runs after both jetpack-mu-wpcom and the
84        // Agents Manager package enqueue (priority 101).
85        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_patch_jetpack_ai_sidebar_preview_data' ), 250 );
86
87        // Let editor JS know when the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar.
88        add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_toolbar_button_extension' ), 99 );
89    }
90
91    // ──────────────────────────────────────────────────
92    // Jetpack AI provider bundle
93    // ──────────────────────────────────────────────────
94
95    /**
96     * Enqueue the IIFE bundle that registers Jetpack AI abilities.
97     *
98     * This runs independently of AM/provider registration so preview abilities
99     * are available even when Big Sky standalone is the active UI.
100     *
101     * @return void
102     */
103    public static function maybe_enqueue_abilities_script(): void {
104        if ( ! self::should_expose_provider() ) {
105            return;
106        }
107
108        // CIAB (next-admin) has its own AM setup — don't enqueue alongside it.
109        if ( did_action( 'next_admin_init' ) ) {
110            return;
111        }
112
113        if ( self::is_agents_manager_disconnected() ) {
114            return;
115        }
116
117        // Guard against double-enqueue (e.g. hooked multiple times).
118        if ( wp_script_is( 'jetpack-ai-provider' ) ) {
119            return;
120        }
121
122        $asset_data = self::get_ai_sidebar_asset_data();
123        if ( ! $asset_data ) {
124            return;
125        }
126
127        $version      = $asset_data['version'] ?? false;
128        $dependencies = $asset_data['dependencies'] ?? array();
129
130        wp_enqueue_script(
131            'jetpack-ai-provider',
132            AI_SIDEBAR_JS_URL,
133            $dependencies,
134            $version,
135            true
136        );
137
138        wp_enqueue_style(
139            'jetpack-ai-provider',
140            is_rtl() ? AI_SIDEBAR_RTL_CSS_URL : AI_SIDEBAR_CSS_URL,
141            array(),
142            $version
143        );
144    }
145
146    // ──────────────────────────────────────────────────
147    // Asset manifest (Image Studio pattern)
148    // ──────────────────────────────────────────────────
149
150    /**
151     * Try to read the asset manifest from the local filesystem.
152     *
153     * On WordPress.com, widgets.wp.com assets are available at ABSPATH.
154     *
155     * @param string $relative_path The relative path to the JSON file.
156     * @return array|false The decoded asset data, or false if not available locally.
157     */
158    private static function get_asset_data_from_file( string $relative_path ) {
159        $local_path = ABSPATH . $relative_path;
160        if ( ! file_exists( $local_path ) ) {
161            return false;
162        }
163
164        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local file, not a remote URL.
165        $contents = file_get_contents( $local_path );
166        if ( false === $contents ) {
167            return false;
168        }
169
170        $data = json_decode( $contents, true );
171        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
172            return false;
173        }
174
175        return $data;
176    }
177
178    /**
179     * Fetch the asset manifest via HTTP.
180     *
181     * @param string $url The URL to fetch.
182     * @return array|false The decoded asset data, or false on failure.
183     */
184    private static function get_asset_data_from_remote( string $url ) {
185        $response = wp_safe_remote_get( $url );
186        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
187            return false;
188        }
189
190        $content_type = wp_remote_retrieve_header( $response, 'content-type' );
191        if ( is_string( $content_type ) && false === stripos( $content_type, 'json' ) ) {
192            return false;
193        }
194
195        $body = wp_remote_retrieve_body( $response );
196        $data = json_decode( $body, true );
197        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
198            return false;
199        }
200
201        return $data;
202    }
203
204    // ──────────────────────────────────────────────────
205    // Provider registration
206    // ──────────────────────────────────────────────────
207
208    /**
209     * Register Jetpack AI as an Agents Manager provider.
210     *
211     * Appends the CDN-hosted ESM wrapper URL to the providers list so AM
212     * can dynamically import it. Asset enqueueing is handled separately by
213     * maybe_enqueue_abilities_script.
214     *
215     * @param array $providers Existing provider URLs.
216     * @return array Updated providers.
217     */
218    public static function register_provider( array $providers ): array {
219        // CIAB (next-admin) has AM natively — skip to avoid duplicate agents.
220        if ( did_action( 'next_admin_init' ) ) {
221            return $providers;
222        }
223
224        // The provider IIFE is enqueued on the same surfaces where the ESM
225        // wrapper is registered. Avoid registering the wrapper when AM may
226        // import it before window.__JetpackAIProvider exists.
227        if ( ! self::should_expose_provider() ) {
228            return $providers;
229        }
230
231        // The providers filter still runs for disconnected variants.
232        if ( self::is_agents_manager_disconnected() ) {
233            return $providers;
234        }
235
236        // Don't register if the IIFE bundle cannot be loaded. The ESM wrapper
237        // re-exports from window.__JetpackAIProvider at import time; if the
238        // IIFE never ran, toolProvider is still a truthy Proxy and AM would
239        // call getAbilities() on it and get undefined, breaking the merge.
240        if ( ! self::get_ai_sidebar_asset_data() ) {
241            return $providers;
242        }
243
244        // Register as AM provider via CDN-hosted ESM wrapper.
245        // AM dynamically imports this module to merge tools, suggestions, and components.
246        // No ?ver= needed — the wrapper re-exports from window.__JetpackAIProvider
247        // at import time, so its behavior always matches the loaded IIFE bundle.
248        $providers[] = AI_SIDEBAR_PROVIDER_URL;
249
250        return $providers;
251    }
252
253    /**
254     * Fetch and cache the CDN asset manifest for the AI sidebar bundle.
255     *
256     * @return array|false The decoded asset data, or false on failure.
257     */
258    private static function get_ai_sidebar_asset_data() {
259        $skip_cache = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
260
261        if ( ! $skip_cache ) {
262            $cached = get_transient( AI_SIDEBAR_ASSET_TRANSIENT );
263            if ( false !== $cached ) {
264                return $cached;
265            }
266        }
267
268        $json_path = AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.asset.json';
269
270        // Try local file first (available on WordPress.com).
271        $data = self::get_asset_data_from_file( $json_path );
272
273        // Fallback to remote fetch.
274        if ( false === $data ) {
275            $data = self::get_asset_data_from_remote( 'https://' . $json_path );
276        }
277
278        if ( false === $data ) {
279            // The manifest is unavailable from both the local file and the CDN
280            // — for example when the server cannot reach widgets.wp.com. Skip
281            // rather than enqueue a provider whose bundle the browser may also
282            // be unable to load, which would break the Agents Manager merge.
283            return false;
284        }
285
286        if ( ! $skip_cache ) {
287            set_transient( AI_SIDEBAR_ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
288        }
289
290        return $data;
291    }
292
293    // ──────────────────────────────────────────────────
294    // Helper methods
295    // ──────────────────────────────────────────────────
296
297    /**
298     * UI feature flag for the SEO suggestions (SEO title and meta description).
299     *
300     * Two halves: the site can offer AI SEO at all — the package's shared gate,
301     * the same one the AI settings row uses, so the control and the surface it
302     * governs cannot drift — and the feature's own toggle is on. Kept independent
303     * of the Optimize Title suggestion: SEO suggestions target the SEO meta
304     * fields, not the visible post title.
305     *
306     * The SEO *feature* option is consulted, not the automatic-generation one:
307     * a switched-off feature must not offer suggestions, even user-initiated
308     * ones, while automatic generation governs only automatic runs.
309     *
310     * Guarded with class_exists: the autoloader can pick an older jetpack-seo
311     * copy from another plugin, predating the shared gate. Suggestions stay off
312     * without it.
313     *
314     * @return bool
315     */
316    private static function is_seo_suggestions_enabled(): bool {
317        return class_exists( Ai_Seo::class )
318            && Ai_Seo::is_available()
319            && \Jetpack_AI_Settings::is_ai_seo_enabled();
320    }
321
322    /**
323     * UI feature flag for the public Jetpack AI Sidebar Preview surface: this
324     * host loads the sidebar, and at least one feature it surfaces is on.
325     *
326     * @return bool
327     */
328    private static function is_jetpack_ai_sidebar_preview_enabled(): bool {
329        return self::is_host_enabled() && self::has_enabled_sidebar_features();
330    }
331
332    /**
333     * Whether this host loads the sidebar at all: a WordPress.com platform site
334     * with Big Sky enabled, past the filter and the master switch. Public and
335     * feature-free so the AI SEO gate can ask without asking back through it.
336     *
337     * @since 16.2
338     *
339     * @return bool
340     */
341    public static function is_host_enabled(): bool {
342        $host = new Host();
343
344        $enabled = false;
345        if ( $host->is_wpcom_platform() && class_exists( 'Big_Sky' ) ) {
346            $default = $host->is_wpcom_simple() ? '1' : '0';
347            $enabled = (bool) get_option( 'big_sky_enable', $default );
348        }
349
350        /**
351         * Filter to enable or disable the Jetpack AI sidebar feature.
352         *
353         * Defaults to true only on WordPress.com platform sites with Big Sky
354         * present and enabled. Acts as a host-level override that can force the
355         * sidebar on (e.g. for local development) or off, and is respected by
356         * init() and every sidebar surface. The override cannot force the
357         * sidebar on while the writing-assistant and SEO enhancer features are
358         * both off — a featureless sidebar never loads.
359         *
360         * @param bool $enabled Whether the Jetpack AI sidebar is enabled.
361         */
362        $enabled = (bool) apply_filters( 'jetpack_ai_sidebar_enabled', $enabled );
363
364        // Re-asserted after the filter so a later filter cannot surface a sidebar
365        // blocked by the host or master gates — the gates are final, as with
366        // is_ai_enabled().
367        return $enabled && \Jetpack_AI_Settings::apply_master_gates( true );
368    }
369
370    /**
371     * Whether any feature the sidebar surfaces is effectively enabled; with
372     * nothing to offer it must not load. Caveat: init() runs before seo-tools
373     * registers jetpack_disable_seo_tools, so that term reads its default here.
374     *
375     * @return bool
376     */
377    private static function has_enabled_sidebar_features(): bool {
378        return \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' )
379            || self::is_seo_suggestions_enabled();
380    }
381
382    /**
383     * Whether the Jetpack AI provider bundle should be exposed for this request.
384     *
385     * This is scoped to the supported editor surfaces: post editor, page editor,
386     * and site editor. Each surface remains subject to the preview and AI feature
387     * gates.
388     *
389     * @return bool
390     */
391    private static function should_expose_provider(): bool {
392        return self::is_jetpack_ai_sidebar_preview_enabled()
393            && self::is_supported_provider_surface()
394            && self::has_ai_features();
395    }
396
397    /**
398     * Whether the Agents Manager is loading a disconnected build, which it does
399     * when the current user has no linked WordPress.com account.
400     *
401     * @return bool
402     */
403    private static function is_agents_manager_disconnected(): bool {
404        $variant = Agents_Manager::get_active_variant();
405
406        // Null means the Agents Manager is not loading at all, which is not a disconnection.
407        return is_string( $variant ) && str_contains( $variant, 'disconnected' );
408    }
409
410    /**
411     * Preview configuration consumed by the Agents Manager and Jetpack AI provider bundles.
412     *
413     * @return array Preview mode and feature availability.
414     */
415    private static function get_jetpack_ai_sidebar_preview_config(): array {
416        // Ability permission callbacks enforce server-side access for these
417        // features. The writing suggestions additionally follow the writing
418        // assistant toggle on the AI settings page — a switched-off feature
419        // must not surface suggestions even when an external host (Big Sky,
420        // Woo) draws the sidebar — and SEO requires the site-side gates below.
421        $writing_on = \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
422
423        $features = array(
424            'aiEditorialReview'       => (bool) apply_filters( 'jetpack_ai_editorial_review_enabled', true ) && $writing_on,
425            'generateFeedback'        => $writing_on,
426            'proofreadContent'        => $writing_on,
427            'blockTransformations'    => $writing_on,
428            'blockToolbarButton'      => $writing_on,
429            'optimizeTitleSuggestion' => $writing_on,
430            'seoSuggestions'          => self::is_seo_suggestions_enabled(),
431            'excerptSuggestion'       => $writing_on,
432            'chatHistory'             => false,
433            'supportGuides'           => false,
434        );
435
436        /**
437         * Filter the feature set exposed in Jetpack AI Sidebar Preview.
438         *
439         * @param array $features Associative array of preview feature flags.
440         */
441        $filtered_features = apply_filters( 'jetpack_ai_sidebar_preview_features', $features );
442        $features          = is_array( $filtered_features ) ? array_merge( $features, $filtered_features ) : $features;
443
444        // Normalize the flags released here while leaving host-added values
445        // untouched. Keep the AI settings page toggles and SEO's site-side
446        // requirements final.
447        $features['aiEditorialReview']       = (bool) $features['aiEditorialReview'] && $writing_on;
448        $features['generateFeedback']        = (bool) $features['generateFeedback'] && $writing_on;
449        $features['proofreadContent']        = (bool) $features['proofreadContent'] && $writing_on;
450        $features['blockToolbarButton']      = (bool) $features['blockToolbarButton'] && $writing_on;
451        $features['optimizeTitleSuggestion'] = (bool) $features['optimizeTitleSuggestion'] && $writing_on;
452        $features['seoSuggestions']          = (bool) $features['seoSuggestions'] && self::is_seo_suggestions_enabled();
453        $features['excerptSuggestion']       = (bool) $features['excerptSuggestion'] && $writing_on;
454        // Block transformations (Translate, Change Tone, etc.) are writing features
455        // and follow the writing assistant toggle.
456        $features['blockTransformations'] = (bool) $features['blockTransformations'] && $writing_on;
457
458        return array(
459            'enabled'                    => self::is_jetpack_ai_sidebar_preview_enabled(),
460            'features'                   => $features,
461            // Not in $features, which is public and host-filterable.
462            'agentNoticeActionAvailable' => self::is_agent_action_available(),
463        );
464    }
465
466    /**
467     * Whether the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar.
468     *
469     * The button is a writing entry point, so it follows the writing assistant
470     * toggle. Switching it off cannot bring the legacy toolbar back in its place:
471     * the editor renders that toolbar only while this feature is unavailable AND
472     * the AI Assistant block and its ai-assistant-support extension are
473     * registered, and both of those are themselves gated on the writing assistant.
474     *
475     * @return bool
476     */
477    public static function is_toolbar_button_enabled(): bool {
478        $preview_config = self::get_jetpack_ai_sidebar_preview_config();
479
480        return self::should_expose_provider()
481            && ! self::is_agents_manager_disconnected()
482            && true === ( $preview_config['features']['blockToolbarButton'] ?? false );
483    }
484
485    /**
486     * Register the Jetpack AI Sidebar toolbar button feature.
487     *
488     * @return void
489     */
490    public static function register_toolbar_button_extension(): void {
491        if ( ! self::is_toolbar_button_enabled() ) {
492            \Jetpack_Gutenberg::set_extension_unavailable(
493                AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION,
494                'jetpack_ai_sidebar_feature_disabled'
495            );
496            return;
497        }
498
499        \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION );
500    }
501
502    /**
503     * Whether the legacy AI panel should point people at the WordPress Agent.
504     *
505     * True once there is a working agent to open, or earlier, once the site is
506     * eligible to turn one on.
507     *
508     * @return bool
509     */
510    public static function is_agent_notice_enabled(): bool {
511        if ( self::is_agent_action_available() ) {
512            return true;
513        }
514
515        return self::is_wordpress_agent_eligible()
516            && self::is_supported_provider_surface()
517            && self::has_ai_features()
518            && ! self::is_agents_manager_disconnected();
519    }
520
521    /**
522     * Whether the site has turned the WordPress Agent on, whatever Jetpack's own
523     * sidebar gate says.
524     *
525     * The Big Sky plugin's own Settings > Writing checkbox can turn it off while
526     * the plugin stays active, so the option counts as well as the class.
527     *
528     * @return bool
529     */
530    private static function is_wordpress_agent_enabled(): bool {
531        $host = new Host();
532
533        if ( ! $host->is_wpcom_platform() || ! class_exists( 'Big_Sky' ) ) {
534            return false;
535        }
536
537        $default = $host->is_wpcom_simple() ? '1' : '0';
538
539        return (bool) get_option( 'big_sky_enable', $default );
540    }
541
542    /**
543     * Whether the site is eligible to turn the WordPress Agent on, whether or not
544     * it has done so yet.
545     *
546     * Prefers the wpcom mu-plugin functions: the plan check cannot see the free
547     * trial. Not gated on the Big_Sky class, which on Simple only loads once the
548     * Agent is on.
549     *
550     * @return bool
551     */
552    private static function is_wordpress_agent_eligible(): bool {
553        $host = new Host();
554
555        if ( ! $host->is_wpcom_platform() ) {
556            return false;
557        }
558
559        if ( function_exists( 'big_sky_is_available_for_site' ) && function_exists( 'big_sky_is_enabled' ) ) {
560            // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by WPCOM's Big Sky mu-plugin; guarded by function_exists() above.
561            return big_sky_is_available_for_site() || big_sky_is_enabled();
562        }
563
564        // Off Simple the plugin is only installed once the site qualifies.
565        return class_exists( 'Big_Sky' ) || Current_Plan::supports( 'big-sky' );
566    }
567
568    /**
569     * Register the WordPress Agent notice feature.
570     *
571     * @return void
572     */
573    public static function register_agent_notice_extension(): void {
574        if ( ! self::is_agent_notice_enabled() ) {
575            // Its own reason, so the payload tells this apart from the toolbar button.
576            \Jetpack_Gutenberg::set_extension_unavailable(
577                AI_SIDEBAR_AGENT_NOTICE_EXTENSION,
578                'jetpack_ai_sidebar_agent_notice_disabled'
579            );
580            return;
581        }
582
583        \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_AGENT_NOTICE_EXTENSION );
584
585        // Tells the notice whether to offer to enable the Agent or to open it.
586        if ( self::is_wordpress_agent_enabled() ) {
587            \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_AGENT_SUPPORT_EXTENSION );
588            return;
589        }
590
591        \Jetpack_Gutenberg::set_extension_unavailable(
592            AI_SIDEBAR_AGENT_SUPPORT_EXTENSION,
593            'jetpack_ai_sidebar_agent_disabled'
594        );
595    }
596
597    /**
598     * Whether the notice's action button has a working agent to send people to.
599     *
600     * Narrower than is_agent_notice_enabled(): an eligible site shows the notice,
601     * but only an enabled one has a chat to open.
602     *
603     * @return bool
604     */
605    public static function is_agent_action_available(): bool {
606        return self::should_expose_provider() && ! self::is_agents_manager_disconnected();
607    }
608
609    /**
610     * Add Jetpack AI Sidebar-specific data to externally emitted Agents Manager payloads.
611     *
612     * @param mixed $data Data encoded into `agentsManagerData`.
613     * @return mixed Filtered data.
614     */
615    public static function add_agents_manager_data( $data ) {
616        if ( ! is_array( $data ) ) {
617            return $data;
618        }
619
620        $fields = self::get_agents_manager_data_fields( $data );
621        if ( ! $fields ) {
622            return $data;
623        }
624
625        // Set our fields in place, leaving the rest of $data (including agentProviders)
626        // untouched so the client-side gate can drop Jetpack AI Sidebar while keeping
627        // fallbacks such as the Big Sky provider. Hosts that need intentional overrides
628        // should use the sidebar and preview feature filters.
629        foreach ( $fields as $key => $value ) {
630            $data[ $key ] = $value;
631        }
632        return $data;
633    }
634
635    /**
636     * Fields Jetpack should add to `agentsManagerData` for the current screen.
637     *
638     * @param array $data Existing Agents Manager data.
639     * @return array
640     */
641    private static function get_agents_manager_data_fields( array $data = array() ): array {
642        if ( ! self::should_expose_provider() ) {
643            return array();
644        }
645
646        $fields = array();
647        if ( empty( $data['agentId'] ) ) {
648            $fields['agentId'] = AI_SIDEBAR_AGENT_ID;
649        }
650        $fields['jetpackAiSidebar'] = self::get_jetpack_ai_sidebar_preview_config();
651
652        return $fields;
653    }
654
655    /**
656     * Enable Agents Manager when the Jetpack AI provider can be exposed.
657     *
658     * @param mixed $enabled Existing Agents Manager block-editor gate value.
659     * @return bool
660     */
661    public static function enable_agents_manager_on_provider_surfaces( $enabled ): bool {
662        if ( $enabled ) {
663            return true;
664        }
665
666        return self::should_expose_provider();
667    }
668
669    /**
670     * Inject Jetpack AI Sidebar Preview data into an externally enqueued AM bundle.
671     *
672     * The design-intended hook is jetpack_ai_sidebar_agents_manager_data, applied
673     * by jetpack-agents-manager Agents_Manager::enqueue_scripts(). On Atomic the bundled
674     * mu-wpcom (via wpcomsh) lags this PR, so the filter never fires and the
675     * client gets agentsManagerData without our fields. This `before` script runs
676     * after the upstream `before` that declares the const (added earlier) but
677     * before the AM bundle reads it, so the field is set when AM initialises.
678     * Gives Atomic parity with Jurassic Ninja without depending on a wpcomsh
679     * redeploy.
680     *
681     * Skipped on WordPress.com Simple — wpcom's data extension owns the predicate
682     * there, including any WordPress.com-specific kill-switch override.
683     *
684     * @return void
685     */
686    public static function maybe_patch_jetpack_ai_sidebar_preview_data(): void {
687        if ( ( new Host() )->is_wpcom_simple() ) {
688            return;
689        }
690
691        // 'registered' rather than 'enqueued': wp_add_inline_script attaches to any
692        // registered handle and serializes correctly regardless of when the
693        // enqueue lands in the dependency graph.
694        if ( ! wp_script_is( 'agents-manager', 'registered' ) ) {
695            return;
696        }
697
698        // The fields getter carries the provider exposure gate.
699        $fields = self::get_agents_manager_data_fields();
700        if ( ! $fields ) {
701            return;
702        }
703
704        // The second path the provider URL can reach agentsManagerData by.
705        if ( self::is_agents_manager_disconnected() ) {
706            return;
707        }
708
709        // Build the assignments from the same field source as the data filter so the
710        // two emit paths cannot drift. agentId is guarded client-side because the
711        // externally emitted payload may already define the active agent.
712        $assignments = '';
713        foreach ( $fields as $key => $value ) {
714            $assignment_value = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
715
716            if ( 'agentId' === $key ) {
717                $assignments .= ' if ( ! agentsManagerData.agentId ) { agentsManagerData.agentId = ' . $assignment_value . '; }';
718                continue;
719            }
720
721            $assignments .= ' agentsManagerData.' . $key . ' = ' . $assignment_value . ';';
722        }
723
724        if ( self::get_ai_sidebar_asset_data() ) {
725            $assignments .= self::get_agent_provider_upsert_script();
726        }
727
728        wp_add_inline_script(
729            'agents-manager',
730            'if ( typeof agentsManagerData === "object" && agentsManagerData !== null ) {' . $assignments . ' }',
731            'before'
732        );
733    }
734
735    /**
736     * Build the inline script that upserts Jetpack AI Sidebar into agentProviders.
737     *
738     * @return string Inline JavaScript.
739     */
740    private static function get_agent_provider_upsert_script(): string {
741        $provider_url = wp_json_encode( AI_SIDEBAR_PROVIDER_URL, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
742
743        return ' agentsManagerData.agentProviders = Array.isArray( agentsManagerData.agentProviders ) ? agentsManagerData.agentProviders : [];'
744            . ' if ( agentsManagerData.agentProviders.indexOf( ' . $provider_url . ' ) === -1 ) { agentsManagerData.agentProviders.push( ' . $provider_url . ' ); }';
745    }
746
747    /**
748     * Check if the current screen is a block editor.
749     *
750     * @return bool
751     */
752    private static function is_block_editor(): bool {
753        if ( ! function_exists( 'get_current_screen' ) ) {
754            return false;
755        }
756
757        $screen = get_current_screen();
758        return $screen && $screen->is_block_editor();
759    }
760
761    /**
762     * Check if the current screen can consume the Jetpack AI provider bundle.
763     *
764     * @return bool
765     */
766    private static function is_supported_provider_surface(): bool {
767        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
768        if ( ! $screen instanceof \WP_Screen ) {
769            return false;
770        }
771
772        if ( 'site-editor' === $screen->base ) {
773            return true;
774        }
775
776        return self::is_block_editor()
777            && 'post' === $screen->base
778            && in_array( $screen->post_type, array( 'post', 'page' ), true );
779    }
780
781    /**
782     * Check whether AI features are available.
783     *
784     * - wpcom simple: available when Jetpack AI is enabled.
785     * - Atomic/self-hosted: requires Jetpack AI enabled, a connected owner, and non-offline mode.
786     *
787     * @return bool
788     */
789    private static function has_ai_features(): bool {
790        if ( ! \Jetpack_AI_Settings::is_ai_enabled() ) {
791            return false;
792        }
793
794        $host = new Host();
795
796        if ( $host->is_wpcom_simple() ) {
797            return true;
798        }
799
800        return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
801            && ! ( new Status() )->is_offline_mode();
802    }
803}