Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.54% covered (warning)
75.54%
315 / 417
60.61% covered (warning)
60.61%
20 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
Agents_Manager
75.54% covered (warning)
75.54%
315 / 417
60.61% covered (warning)
60.61%
20 / 33
512.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 should_display_menu_panel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_icon
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 add_help_menu
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
2.07
 add_menu_panel
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
2
 add_ai_chat_button
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_scripts
95.38% covered (success)
95.38%
62 / 65
0.00% covered (danger)
0.00%
0 / 1
23
 get_active_variant
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_plugin_information_iframe
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 get_variant
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
12
 is_unified_experience
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_enabled
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 passes_admin_checks
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 enqueue_script
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
9
 determine_iso_639_locale
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_assets_json
63.64% covered (warning)
63.64%
14 / 22
0.00% covered (danger)
0.00%
0 / 1
12.89
 calypso_preferences_update
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_instance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_proxied
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 is_tracking_automattician
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 is_dev_mode
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
12
 register_rest_api
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 should_use_unified_experience
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
8
 has_unified_chat_opt_in_enabled
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 fetch_unified_experience_preference
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
8.10
 is_loading_on_frontend
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
10.37
 is_block_editor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 is_admin_bar_in_editor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_ciab_environment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_jetpack_disconnected
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 get_current_user_data
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 get_current_site
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Agents manager
4 *
5 * @package automattic/jetpack-agents-manager
6 */
7
8namespace Automattic\Jetpack\Agents_Manager;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Constants;
12
13/**
14 * Class Agents_Manager
15 */
16class Agents_Manager {
17    /**
18     * The package version of the Agents Manager package.
19     *
20     * @var string
21     */
22    const PACKAGE_VERSION = '0.9.5';
23
24    /**
25     * Help Center URL for disconnected variants.
26     *
27     * @var string
28     */
29    private const HELP_CENTER_URL = 'https://wordpress.com/help?help-center=home';
30
31    /**
32     * Class instance.
33     *
34     * @var Agents_Manager
35     */
36    private static $instance = null;
37
38    /**
39     * Agents_Manager constructor.
40     */
41    private function __construct() {
42        add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
43        add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
44
45        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
46        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
47        add_action( 'next_admin_init', array( $this, 'enqueue_scripts' ), 1001 );
48        add_filter( 'agents_manager_use_unified_experience', array( $this, 'should_use_unified_experience' ) );
49
50        Sidebar_Open_Preservation::init();
51    }
52
53    /**
54     * Check if the agents manager menu panel should be displayed.
55     *
56     * @return bool True if the menu panel should be displayed.
57     */
58    public function should_display_menu_panel() {
59        return self::is_unified_experience();
60    }
61
62    /**
63     * Get the SVG icon markup for a given icon name.
64     *
65     * @param string $icon_name The name of the icon to retrieve.
66     * @return string The SVG markup.
67     */
68    private function get_icon( $icon_name ) {
69        $icons = array(
70            'comment' => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 4H6c-1.1 0-2 .9-2 2v12.9c0 .6.5 1.1 1.1 1.1.3 0 .5-.1.8-.3L8.5 17H18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm.5 11c0 .3-.2.5-.5.5H7.9l-2.4 2.4V6c0-.3.2-.5.5-.5h12c.3 0 .5.2.5.5v9z" /></svg>',
71            'backup'  => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.5 12h1.75l-2.5 3-2.5-3H4a8 8 0 113.134 6.35l.907-1.194A6.5 6.5 0 105.5 12zm9.53 1.97l-2.28-2.28V8.5a.75.75 0 00-1.5 0V12a.747.747 0 00.218.529l1.282-.84-1.28.842 2.5 2.5a.75.75 0 101.06-1.061z" /></svg>',
72            'page'    => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" /><path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" /></svg>',
73            'video'   => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.7 3H5.3C4 3 3 4 3 5.3v13.4C3 20 4 21 5.3 21h13.4c1.3 0 2.3-1 2.3-2.3V5.3C21 4 20 3 18.7 3zm.8 15.7c0 .4-.4.8-.8.8H5.3c-.4 0-.8-.4-.8-.8V5.3c0-.4.4-.8.8-.8h13.4c.4 0 .8.4.8.8v13.4zM10 15l5-3-5-3v6z" /></svg>',
74            'rss'     => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 10.2h-.8v1.5H5c1.9 0 3.8.8 5.1 2.1 1.4 1.4 2.1 3.2 2.1 5.1v.8h1.5V19c0-2.3-.9-4.5-2.6-6.2-1.6-1.6-3.8-2.6-6.1-2.6zm10.4-1.6C12.6 5.8 8.9 4.2 5 4.2h-.8v1.5H5c3.5 0 6.9 1.4 9.4 3.9s3.9 5.8 3.9 9.4v.8h1.5V19c0-3.9-1.6-7.6-4.4-10.4zM4 20h3v-3H4v3z" /></svg>',
75        );
76
77        return $icons[ $icon_name ] ?? '';
78    }
79
80    /**
81     * Add the Agents Manager Help "?" node (`agents-manager`) to the admin bar, replacing the
82     * legacy Help Center node (`help-center`).
83     *
84     * @param \WP_Admin_Bar $wp_admin_bar     The WP_Admin_Bar instance.
85     * @param bool          $use_disconnected Disconnected variants link straight to the Help Center instead of opening the dropdown.
86     */
87    public function add_help_menu( $wp_admin_bar, $use_disconnected ) {
88        $wp_admin_bar->remove_node( 'help-center' );
89
90        $menu_args = array(
91            'id'     => 'agents-manager',
92            'title'  => '<span title="' . esc_attr__( 'Help Center', 'jetpack-agents-manager' ) . '"><svg id="agents-manager-icon" class="ab-icon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
93                            <path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 16v-2h2v2h-2zm2-3v-1.141A3.991 3.991 0 0016 10a4 4 0 00-8 0h2c0-1.103.897-2 2-2s2 .897 2 2-.897 2-2 2a1 1 0 00-1 1v2h2z" />
94                        </svg></span>',
95            'parent' => 'top-secondary',
96        );
97
98        if ( $use_disconnected ) {
99            $menu_args['href'] = self::HELP_CENTER_URL;
100            $menu_args['meta'] = array(
101                'target' => '_blank',
102                'rel'    => 'noopener noreferrer',
103            );
104        } else {
105            $menu_args['meta'] = array(
106                'class'  => 'menupop',
107                'target' => '_blank',
108                'rel'    => 'noopener noreferrer',
109            );
110        }
111
112        $wp_admin_bar->add_menu( $menu_args );
113    }
114
115    /**
116     * Add the agents manager menu panel to the admin bar.
117     *
118     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
119     */
120    public function add_menu_panel( $wp_admin_bar ) {
121        // Add chat support group
122        $wp_admin_bar->add_group(
123            array(
124                'parent' => 'agents-manager',
125                'id'     => 'agents-manager-menu-panel-chat',
126                'meta'   => array(
127                    'class' => 'ab-sub-secondary',
128                ),
129            )
130        );
131
132        // Add chat support menu item
133        $wp_admin_bar->add_node(
134            array(
135                'parent' => 'agents-manager-menu-panel-chat',
136                'id'     => 'agents-manager-chat-support',
137                'title'  => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-agents-manager' ) . '</span>',
138            )
139        );
140
141        // Add chat history menu item
142        $wp_admin_bar->add_node(
143            array(
144                'parent' => 'agents-manager-menu-panel-chat',
145                'id'     => 'agents-manager-chat-history',
146                'title'  => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-agents-manager' ) . '</span>',
147            )
148        );
149
150        // Add links group
151        $wp_admin_bar->add_group(
152            array(
153                'parent' => 'agents-manager',
154                'id'     => 'agents-manager-menu-panel-links',
155                'meta'   => array(
156                    'class' => 'ab-sub-secondary',
157                ),
158            )
159        );
160
161        // Add support guides menu item
162        $wp_admin_bar->add_node(
163            array(
164                'parent' => 'agents-manager-menu-panel-links',
165                'id'     => 'agents-manager-support-guides',
166                'title'  => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-agents-manager' ) . '</span>',
167            )
168        );
169
170        // Add courses menu item
171        $wp_admin_bar->add_node(
172            array(
173                'parent' => 'agents-manager-menu-panel-links',
174                'id'     => 'agents-manager-courses',
175                'title'  => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-agents-manager' ) . '</span>',
176                'href'   => 'https://wordpress.com/support/courses/',
177                'meta'   => array(
178                    'target' => '_blank',
179                    'rel'    => 'noopener noreferrer',
180                ),
181            )
182        );
183
184        // Add product updates menu item
185        $wp_admin_bar->add_node(
186            array(
187                'parent' => 'agents-manager-menu-panel-links',
188                'id'     => 'agents-manager-product-updates',
189                'title'  => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-agents-manager' ) . '</span>',
190                'href'   => 'https://wordpress.com/blog/category/product-features/',
191                'meta'   => array(
192                    'target' => '_blank',
193                    'rel'    => 'noopener noreferrer',
194                ),
195            )
196        );
197    }
198
199    /**
200     * Add the standalone AI chat button to the admin bar.
201     *
202     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
203     */
204    public function add_ai_chat_button( $wp_admin_bar ) {
205        $wp_admin_bar->add_menu(
206            array(
207                'id'     => 'agents-manager-ai-chat',
208                'parent' => 'top-secondary',
209                'title'  => '<span title="' . esc_attr__( 'Ask AI', 'jetpack-agents-manager' ) . '"><svg class="ab-icon" role="img" aria-label="' . esc_attr__( 'Ask AI', 'jetpack-agents-manager' ) . '" width="24" height="24" viewBox="-45 -45 490 490" xmlns="http://www.w3.org/2000/svg">
210                                <path fill="currentColor" d="M391.528 188.061L309.455 159.75C276.997 148.597 251.403 123.003 240.25 90.5451L211.939 8.47185C208.079 -2.82395 191.921 -2.82395 188.061 8.47185L159.75 90.5451C148.597 123.003 123.003 148.597 90.5451 159.75L8.47185 188.061C-2.82395 191.921 -2.82395 208.079 8.47185 211.939L90.5451 240.25C123.003 251.403 148.597 276.997 159.75 309.455L188.061 391.528C191.921 402.824 208.079 402.824 211.939 391.528L240.25 309.455C251.403 276.997 276.997 251.403 309.455 240.25L391.528 211.939C402.824 208.079 402.824 191.921 391.528 188.061ZM295.728 206.077L254.692 220.232C238.391 225.809 225.666 238.677 220.089 254.835L205.934 295.871C203.932 301.591 195.925 301.591 193.923 295.871L179.768 254.835C174.191 238.534 161.323 225.809 145.165 220.232L104.129 206.077C98.4093 204.075 98.4093 196.068 104.129 194.066L145.165 179.911C161.466 174.334 174.191 161.466 179.768 145.308L193.923 104.272C195.925 98.5523 203.932 98.5523 205.934 104.272L220.089 145.308C225.666 161.609 238.534 174.334 254.692 179.911L295.728 194.066C301.448 196.068 301.448 204.075 295.728 206.077Z" />
211                            </svg></span>',
212                'meta'   => array(
213                    'html' => '<div id="agents-manager-masterbar"></div>',
214                ),
215            )
216        );
217    }
218
219    /**
220     * Enqueue Agents Manager scripts and add inline script data.
221     */
222    public function enqueue_scripts() {
223        // Early return for P2 frontend - don't add admin bar or enqueue scripts.
224        $stylesheet = get_stylesheet();
225        $is_p2      = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
226
227        if ( ! is_admin() && $is_p2 ) {
228            return;
229        }
230
231        // Determine which variant to load (null = don't load).
232        $variant = self::get_active_variant();
233        if ( null === $variant ) {
234            return;
235        }
236        $use_disconnected       = str_contains( $variant, 'disconnected' );
237        $is_gutenberg           = $this->is_block_editor();
238        $use_unified_experience = self::is_unified_experience();
239
240        // In Gutenberg, dequeue Help Center so we don't end up with two buttons â€” but only
241        // in the full unified experience, where Agents Manager takes over the Help Center.
242        // In block-editor-only mode (e.g. ?flags=unified-big-sky) Agents Manager replaces
243        // Big Sky's native UI and Help Center should remain available.
244        // Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
245        if ( $is_gutenberg && $use_unified_experience ) {
246            wp_dequeue_script( 'help-center' );
247            wp_dequeue_style( 'help-center-style' );
248        }
249
250        // For non-Gutenberg, non-CIAB environments, add to the admin bar. Gutenberg uses JS when
251        // no admin bar is visible and the server-side branch below when one is available. CIAB
252        // hides the admin bar and uses its own Site Hub.
253        $is_ciab = $this->is_ciab_environment();
254        if ( ! $is_gutenberg && ! $is_ciab ) {
255            // Disconnected variants are Help-only. Full variants take over Help
256            // only when the unified experience is active.
257            if ( $use_disconnected || $use_unified_experience ) {
258                add_action(
259                    'admin_bar_menu',
260                    function ( $wp_admin_bar ) use ( $use_disconnected ) {
261                        $this->add_help_menu( $wp_admin_bar, $use_disconnected );
262                    },
263                    // Add the agents manager icon after Help Center so it can replace that node.
264                    100
265                );
266            }
267
268            // Initialize the Help menu panel only for the full unified experience.
269            if ( ! $use_disconnected && $use_unified_experience ) {
270                add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
271            }
272
273            // Standalone AI chat button, shown whenever the full Agents Manager app is enabled.
274            if ( ! $use_disconnected && self::is_enabled() ) {
275                add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
276            }
277        }
278
279        // When the block editor exposes the WordPress admin bar, add the entry points there.
280        // CIAB is excluded â€” it has its own Site Hub UI.
281        // Mirroring wp-admin: the Help "?" dropdown shows only in the full unified experience;
282        // the Ask AI button shows whenever Agents Manager is enabled in this editor.
283        if ( ! $is_ciab && ! $use_disconnected && self::is_admin_bar_in_editor() ) {
284            // Help "?" node + dropdown panel first, matching the wp-admin admin bar order.
285            if ( $use_unified_experience ) {
286                add_action(
287                    'admin_bar_menu',
288                    function ( $wp_admin_bar ) {
289                        $this->add_help_menu( $wp_admin_bar, false );
290                    },
291                    100
292                );
293                add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
294            }
295
296            if ( self::is_enabled() ) {
297                add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
298            }
299        }
300
301        /**
302         * Filter to register agent provider modules for the Agents Manager.
303         *
304         * Plugins can hook into this filter to register script module IDs that export
305         * toolProvider and/or contextProvider. The Agents Manager JS will dynamically
306         * import these modules and merge their providers.
307         *
308         * @param array $providers Array of provider script module IDs.
309         */
310        $agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
311
312        /**
313         * Filter the default agent ID for the Agents Manager.
314         *
315         * Allows host applications (e.g., CIAB, WooCommerce AI) to specify a custom
316         * workflow agent instead of the default orchestrator. The value is passed to
317         * the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
318         *
319         * @param string|null $agent_id The agent ID to use, or null for default behavior.
320         */
321        $agent_id = apply_filters( 'agents_manager_agent_id', null );
322
323        $this->enqueue_script( $variant );
324
325        $inline_data = array(
326            'agentProviders'       => $agent_providers,
327            'useUnifiedExperience' => $use_unified_experience,
328            'isDevMode'            => self::is_dev_mode(),
329            'isA11n'               => self::is_tracking_automattician(),
330            'isWpcomPlatform'      => ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_platform(),
331            'sectionName'          => apply_filters( 'agents_manager_section_name', $variant ),
332            'currentUser'          => $this->get_current_user_data(),
333            'site'                 => $this->get_current_site(),
334            'helpCenterUrl'        => self::HELP_CENTER_URL,
335        );
336
337        if ( $agent_id ) {
338            $inline_data['agentId'] = $agent_id;
339        }
340
341        /**
342         * Filter the data exposed to the Agents Manager frontend.
343         *
344         * @param array $inline_data Data encoded into `agentsManagerData`.
345         */
346        $filtered    = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
347        $inline_data = is_array( $filtered ) ? $filtered : $inline_data;
348
349        wp_add_inline_script(
350            'agents-manager',
351            'const agentsManagerData = ' . wp_json_encode(
352                $inline_data,
353                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
354            ) . ';',
355            'before'
356        );
357    }
358
359    /**
360     * The script variant active for this request, or null if none.
361     *
362     * Single source of truth for "is the Agents Manager app loaded on this
363     * request?". Used both to enqueue the app and to gate the server-side
364     * sidebar pre-render, so the pre-rendered shell can never appear on a page
365     * where the app won't mount to reconcile it.
366     *
367     * @return string|null The variant name, or null if scripts should not be loaded.
368     */
369    public static function get_active_variant() {
370        if ( self::is_plugin_information_iframe() ) {
371            return null;
372        }
373
374        /**
375         * Filter the script variant the Agents Manager loads for this request.
376         *
377         * @since 0.1.0
378         *
379         * @param string|null $variant The resolved variant, or null to not load.
380         */
381        return apply_filters( 'agents_manager_variant', self::get_variant() );
382    }
383
384    /**
385     * Whether the current request renders the plugin information iframe.
386     *
387     * The parent plugin screen may load Agents Manager, but the iframe must not
388     * bootstrap a second copy of the app.
389     *
390     * @return bool
391     */
392    private static function is_plugin_information_iframe() {
393        global $current_screen;
394
395        if ( ! $current_screen || 'plugin-install' !== $current_screen->id ) {
396            return false;
397        }
398
399        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a request context check, not a form submission.
400        return isset( $_GET['tab'] ) && 'plugin-information' === sanitize_text_field( wp_unslash( $_GET['tab'] ) );
401    }
402
403    /**
404     * Determine which script variant to load, or null if none should be loaded.
405     *
406     * Combines the gating logic (should we load at all?) with variant selection
407     * (which build to use?) into a single method so the two cannot get out of sync.
408     *
409     * @return string|null The variant name, or null if scripts should not be loaded.
410     */
411    private static function get_variant() {
412        // CIAB: Load either the connected or disconnected variants if enabled.
413        if ( self::is_ciab_environment() && self::is_enabled() ) {
414            return self::is_jetpack_disconnected() ? 'ciab-disconnected' : 'ciab';
415        }
416
417        // Frontend: load disconnected variant for eligible logged-in editors.
418        if ( ! is_admin() ) {
419            if ( self::is_loading_on_frontend() && self::is_enabled() ) {
420                return 'wp-admin-disconnected';
421            }
422            return null;
423        }
424
425        // Apply wp-admin exclusions (customizer, asset, and preview contexts).
426        if ( ! self::passes_admin_checks() ) {
427            return null;
428        }
429
430        if ( ! self::is_enabled() ) {
431            return null;
432        }
433
434        $disconnected = self::is_jetpack_disconnected();
435
436        if ( self::is_block_editor() ) {
437            return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
438        }
439
440        return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
441    }
442
443    /**
444     * Whether the unified experience â€” the Help Center takeover â€” is active.
445     *
446     * "Unified" here means Agents Manager takes over the Help Center, unifying Odie and
447     * Dolly (the orchestrator) into a single chat experience. This is distinct from
448     * block-editor-only enablement, which replaces Big Sky's native UI without taking
449     * over the Help Center.
450     *
451     * @return bool
452     */
453    public static function is_unified_experience() {
454        /**
455         * Filter to determine if the user should see the unified chat experience.
456         *
457         * When true, Help Center will render UnifiedAIAgent instead of traditional UI.
458         * The filter is hooked by should_use_unified_experience() in this class.
459         *
460         * @param bool $use_unified_experience Whether to use unified experience. Default false.
461         */
462        return (bool) apply_filters( 'agents_manager_use_unified_experience', false );
463    }
464
465    /**
466     * Returns true if the Agents Manager should be loaded in the current context.
467     *
468     * @return bool
469     */
470    public static function is_enabled() {
471        $enabled = false;
472
473        // CIAB: Agents Manager is the default AI experience â€” enabled unless explicitly
474        // disabled via filter (e.g. for debugging or gradual rollout).
475        if ( self::is_ciab_environment() ) {
476            /**
477             * Filter whether Agents Manager is enabled in CIAB (Next Admin) environments.
478             *
479             * @param bool $enabled Whether Agents Manager should load. Default true.
480             */
481            $enabled = (bool) apply_filters( 'agents_manager_enabled_in_ciab', true );
482        } elseif ( self::is_unified_experience() ) {
483            // Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
484            $enabled = true;
485        } elseif ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
486            // Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
487            $enabled = true;
488        }
489
490        /**
491         * Filters whether an integration requests the Agents Manager shell on this request.
492         *
493         * Providers should preserve an existing true value so multiple integrations can
494         * request the shared shell independently.
495         *
496         * @since 0.9.1
497         *
498         * @param bool $should_load Whether another integration already requested the shell.
499         */
500        $should_load = (bool) apply_filters( 'agents_manager_should_load', false );
501
502        return $enabled || $should_load;
503    }
504
505    /**
506     * Returns true if the current wp-admin context passes all exclusion checks.
507     *
508     * Excludes customizer previews, Gutenberg asset requests, and preview query
509     * param contexts.
510     *
511     * @return bool
512     */
513    private static function passes_admin_checks() {
514        // Don't load in customizer preview iframe.
515        if ( is_customize_preview() ) {
516            return false;
517        }
518
519        // Don't load during Gutenberg asset requests or preview contexts.
520        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
521        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
522        $is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
523        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
524        $is_preview_overlay = isset( $_GET['preview_overlay'] );
525        if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
526            return false;
527        }
528
529        return true;
530    }
531
532    /**
533     * Enqueue Agents Manager script based on context.
534     *
535     * @param string $variant The variant of the asset file to get.
536     */
537    private function enqueue_script( $variant ) {
538        $cache_key  = 'agents-manager-asset-' . $variant . '.asset.json';
539        $asset_file = get_transient( $cache_key );
540
541        if ( ! $asset_file ) {
542            $asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
543            if ( ! $asset_file ) {
544                return;
545            }
546            set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
547        }
548
549        // When the request is dev mode, use a random cache buster as the version for easier debugging.
550        $version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
551
552        $script_dependencies = $asset_file['dependencies'] ?? array();
553
554        // Load translations for connected variants from widgets.wp.com.
555        // Disconnected variants have no translatable UI, so skip them (as Help
556        // Center does). English needs no translation file.
557        if ( ! str_contains( $variant, 'disconnected' ) ) {
558            $locale = self::determine_iso_639_locale();
559
560            if ( 'en' !== $locale ) {
561                wp_enqueue_script(
562                    'agents-manager-translations',
563                    'https://widgets.wp.com/agents-manager/languages/' . $locale . '-v1.js',
564                    array( 'wp-i18n' ),
565                    $version,
566                    true
567                );
568
569                $script_dependencies[] = 'agents-manager-translations';
570            }
571        }
572
573        wp_enqueue_script(
574            'agents-manager',
575            'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
576            $script_dependencies,
577            $version,
578            /**
579             * Filter the strategy to use when enqueuing the script.
580             *
581             * @param array|bool $args The arguments to pass to wp_enqueue_script. Default is true.
582             * @param string $handle The handle of the script.
583             */
584            apply_filters( 'agents_manager_enqueue_script_strategy', true, 'agents-manager' )
585        );
586
587        if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
588            wp_enqueue_style(
589                'agents-manager-style',
590                'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
591                array(),
592                $version
593            );
594        }
595    }
596
597    /**
598     * Returns the ISO 639 conforming locale string for the current user.
599     *
600     * Normalizes the WordPress user locale to match the widgets.wp.com translation
601     * file naming at languages/{code}-v1.js. Preserves the region for the few locales
602     * where it is meaningful (pt-br, zh-tw, zh-cn); strips the region for all others;
603     * falls back to 'en' when the locale is empty.
604     *
605     * @return string The ISO 639 locale string, e.g. "en".
606     */
607    private static function determine_iso_639_locale() {
608        $language = get_user_locale();
609        $language = strtolower( $language );
610
611        if ( in_array( $language, array( 'pt_br', 'pt-br', 'zh_tw', 'zh-tw', 'zh_cn', 'zh-cn' ), true ) ) {
612            $language = str_replace( '_', '-', $language );
613        } else {
614            $language = preg_replace( '/([-_].*)$/i', '', $language );
615        }
616
617        if ( empty( $language ) ) {
618            return 'en';
619        }
620
621        return $language;
622    }
623
624    /**
625     * Get the asset via file-system on wpcom and via network on Atomic sites.
626     *
627     * @param string $filepath The URL to download the asset file from.
628     * @return array|null The asset file data or null on failure.
629     */
630    private static function get_assets_json( $filepath ) {
631        $accessible_directly = file_exists( ABSPATH . $filepath );
632
633        if ( $accessible_directly ) {
634            $file_contents = file_get_contents( ABSPATH . $filepath );
635
636            if ( false === $file_contents ) {
637                return null;
638            }
639
640            return json_decode( $file_contents, true );
641        }
642
643        $request = wp_remote_get( 'https://' . $filepath );
644
645        if ( is_wp_error( $request ) ) {
646            return null;
647        }
648
649        $response_code = wp_remote_retrieve_response_code( $request );
650        if ( 200 !== $response_code ) {
651            return null;
652        }
653
654        $content_type = wp_remote_retrieve_header( $request, 'content-type' );
655        if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
656            return null;
657        }
658
659        $body = wp_remote_retrieve_body( $request );
660        if ( '' === $body ) {
661            return null;
662        }
663
664        $decoded = json_decode( $body, true );
665        if ( json_last_error() !== JSON_ERROR_NONE ) {
666            return null;
667        }
668
669        return $decoded;
670    }
671
672    /**
673     * Update the calypso preferences.
674     *
675     * @param \stdClass $preferences The preferences.
676     *
677     * @return \stdClass The preferences.
678     */
679    public function calypso_preferences_update( $preferences ) {
680        // Check if agents_manager_router_history exists and is a valid array structure
681        if ( ! isset( $preferences->agents_manager_router_history ) ||
682            ! is_array( $preferences->agents_manager_router_history ) ) {
683            return $preferences;
684        }
685
686        $router_history = $preferences->agents_manager_router_history;
687
688        // Check if entries exist and is an array
689        if ( ! isset( $router_history['entries'] ) ||
690            ! is_array( $router_history['entries'] ) ) {
691            return $preferences;
692        }
693
694        $entries = $router_history['entries'];
695
696        // Limit entries to 50 to prevent spamming entries in the router history.
697        if ( count( $entries ) > 50 ) {
698            // Keep only the last 49 entries and add the root entry at the beginning.
699            $entries = array_slice( $entries, -49 );
700            // Keep the start at root so the back button always works.
701            array_unshift(
702                $entries,
703                array(
704                    'pathname' => '/',
705                    'search'   => '',
706                    'hash'     => '',
707                    'key'      => 'default',
708                    'state'    => null,
709                )
710            );
711
712            // Update the preferences object directly
713            $preferences->agents_manager_router_history['entries'] = $entries;
714            $preferences->agents_manager_router_history['index']   = 49;
715        }
716
717        return $preferences;
718    }
719
720    /**
721     * Creates instance.
722     *
723     * @return Agents_Manager
724     */
725    public static function init() {
726        if ( did_action( 'jetpack_agents_manager_initialized' ) ) {
727            return self::get_instance();
728        }
729
730        self::$instance = new self();
731
732        /**
733         * Fires once the Agents Manager class has been instantiated.
734         *
735         * @since 0.5.0
736         */
737        do_action( 'jetpack_agents_manager_initialized' );
738
739        return self::$instance;
740    }
741
742    /**
743     * Returns the instance of the Agents Manager class.
744     *
745     * @return Agents_Manager
746     */
747    public static function get_instance() {
748        return self::$instance;
749    }
750
751    /**
752     * Returns whether the current request is coming from the A8C proxy.
753     *
754     * @return bool
755     */
756    private static function is_proxied() {
757        // On Simple sites, use the wpcom function if available.
758        if ( function_exists( 'wpcom_is_proxied_request' ) ) {
759            return wpcom_is_proxied_request();
760        }
761
762        // On WoA/Garden sites, check server variable or constant.
763        return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
764            ? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
765            : Constants::is_true( 'A8C_PROXIED_REQUEST' );
766    }
767
768    /**
769     * Returns whether the current visitor should be marked as an Automattician in tracking.
770     *
771     * @return bool
772     */
773    private static function is_tracking_automattician() {
774        $is_automattician = function_exists( 'is_automattician' ) && (bool) is_automattician();
775
776        return $is_automattician || self::is_proxied() || Constants::is_true( 'AT_PROXIED_REQUEST' );
777    }
778
779    /**
780     * Enables "Development" features that should be accessible only for admins.
781     */
782    private static function is_dev_mode() {
783        // Known local environments.
784        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
785        if (
786            $domain === 'localhost' ||
787            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
788            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
789        ) {
790            return true;
791        }
792
793        // A8C development.
794        if ( self::is_proxied() ) {
795            return true;
796        }
797
798        if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
799            switch ( Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
800                case 1:
801                case 2:
802                case 3: // Pressable
803                case 32:
804                case 118: // Commerce garden client (ciab)
805                    return true;
806            }
807        }
808
809        return false;
810    }
811
812    /**
813     * Register the Agents Manager endpoints.
814     */
815    public function register_rest_api() {
816        ( new WP_REST_Agents_Manager_Persisted_Open_State() )->register_rest_route();
817        ( new WP_REST_Jetpack_AI_JWT() )->register_rest_route();
818    }
819
820    /**
821     * Determine if user should see unified experience.
822     *
823     * @param bool $use_unified_experience Whether to use unified experience.
824     * @return bool
825     */
826    public function should_use_unified_experience( $use_unified_experience = false ) {
827        // Early return for non-proxied/dev mode requests.
828        // This feature is currently only available to Automattic employees testing via proxy.
829        if ( ! self::is_dev_mode() ) {
830            return false;
831        }
832
833        $user_id = get_current_user_id();
834
835        if ( ! $user_id ) {
836            return false;
837        }
838
839        $is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
840        if ( $is_simple_site ) {
841            // On Simple sites, evaluate locally.
842            // Check Automattician and opt-in setting.
843            $is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
844            if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
845                return true;
846            }
847        }
848
849        // On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
850        // This avoids duplicating rollout logic and handles cases where
851        // wpcom-specific functions (like get_user_attribute) aren't available.
852        if ( $this->fetch_unified_experience_preference() ) {
853            return true;
854        }
855
856        // Default to false, for now.
857        // In the future: users with a big sky site (similar to https://github.a8c.com/Automattic/wpcom/pull/196449/files), a big-sky free trial or a paid plan.
858        return $use_unified_experience;
859    }
860
861    /**
862     * Check if user has enabled unified chat opt-in in their Automattician options.
863     *
864     * This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
865     * Only used on Simple sites where get_user_attribute is available.
866     *
867     * @param int $user_id User ID.
868     *
869     * @return bool
870     */
871    private function has_unified_chat_opt_in_enabled( $user_id ) {
872        if ( ! function_exists( '\get_user_attribute' ) ) {
873            return false;
874        }
875
876        $calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
877        return ! empty( $calypso_prefs['unified_ai_chat'] );
878    }
879
880    /**
881     * Fetch unified experience preference from wpcom via Jetpack Connection.
882     *
883     * Used on Atomic sites to delegate the decision to wpcom, which has
884     * access to user attributes and can evaluate the rollout logic.
885     *
886     * Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
887     *
888     * @return bool Whether user should see unified experience.
889     */
890    private function fetch_unified_experience_preference() {
891        $user_id = get_current_user_id();
892        if ( ! $user_id ) {
893            return false;
894        }
895
896        // Check transient cache first (per-user cache).
897        $cache_key     = 'unified-experience-' . $user_id;
898        $cached_result = get_transient( $cache_key );
899        if ( false !== $cached_result ) {
900            return (bool) $cached_result;
901        }
902
903        // Check if user is connected before making API call.
904        if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
905            return false;
906        }
907
908        // Call dedicated agents-manager/state endpoint.
909        $wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
910            '/agents-manager/state?key=unified_ai_chat',
911            '2',
912            array( 'method' => 'GET' )
913        );
914
915        if ( is_wp_error( $wpcom_request ) ) {
916            // Cache failures too to avoid hammering the API.
917            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
918            return false;
919        }
920
921        $response_code = wp_remote_retrieve_response_code( $wpcom_request );
922        if ( 200 !== $response_code ) {
923            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
924            return false;
925        }
926
927        $body         = wp_remote_retrieve_body( $wpcom_request );
928        $decoded_body = json_decode( $body, true );
929
930        // The response is { "unified_ai_chat": true/false } when using key param.
931        $result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
932
933        // Cache for 1 minute.
934        set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
935
936        return $result;
937    }
938
939    /**
940     * Returns true if the current request is on the frontend and the user can edit posts.
941     *
942     * Mirrors Help_Center::is_loading_on_frontend().
943     *
944     * @return bool True if loading on the frontend for an eligible user.
945     */
946    private static function is_loading_on_frontend() {
947        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
948        if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
949            return false;
950        }
951
952        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
953        if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
954            return false;
955        }
956
957        $can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
958        return ! is_admin() && ! self::is_block_editor() && $can_edit_posts;
959    }
960
961    /**
962     * Returns true if the current screen is the block editor.
963     *
964     * @return bool True if the current screen is the block editor.
965     */
966    private static function is_block_editor() {
967        if ( ! function_exists( 'get_current_screen' ) ) {
968            return false;
969        }
970
971        $current_screen = get_current_screen();
972        // The widgets screen has the block editor but no Gutenberg top bar.
973        return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
974    }
975
976    /**
977     * Returns true when the WordPress admin bar is available in the block editor.
978     *
979     * The frontend uses the visible admin bar as its signal to move editor entry points out of the
980     * Gutenberg toolbar. Use WordPress's matching server-side signal so a classic editor admin bar
981     * receives those entry points too, not only Gutenberg's experimental omnibar.
982     *
983     * @return bool
984     */
985    private static function is_admin_bar_in_editor() {
986        return self::is_block_editor() && is_admin_bar_showing();
987    }
988
989    /**
990     * Check if current environment is CIAB (Commerce in a Box) / Next Admin.
991     *
992     * Uses the same detection method as Help Center: checks if next_admin_init has fired.
993     *
994     * @return bool True if CIAB/Next Admin environment.
995     */
996    private static function is_ciab_environment() {
997        return (bool) did_action( 'next_admin_init' );
998    }
999
1000    /**
1001     * Returns true if the current user is NOT connected through Jetpack.
1002     *
1003     * Mirrors the logic from Help_Center::is_jetpack_disconnected().
1004     *
1005     * @return bool True if the site uses Jetpack but the current user is not connected.
1006     */
1007    private static function is_jetpack_disconnected() {
1008        $user_id = get_current_user_id();
1009        $blog_id = get_current_blog_id();
1010
1011        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
1012            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
1013        }
1014
1015        if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
1016            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
1017        }
1018
1019        return false;
1020    }
1021
1022    /**
1023     * Get current user data for the agents manager.
1024     *
1025     * Mirrors the user data structure from Help Center's helpCenterData.
1026     *
1027     * @return array|null User data array or null if not logged in.
1028     */
1029    private function get_current_user_data() {
1030        $user_id = get_current_user_id();
1031        if ( ! $user_id ) {
1032            return null;
1033        }
1034
1035        $user_data = get_userdata( $user_id );
1036        if ( ! $user_data ) {
1037            return null;
1038        }
1039
1040        $user_email = $user_data->user_email;
1041
1042        // Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
1043        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1044            $avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
1045        } else {
1046            $avatar_url = get_avatar_url( $user_id );
1047        }
1048
1049        return array(
1050            'ID'           => $user_id,
1051            'username'     => $user_data->user_login,
1052            'display_name' => $user_data->display_name,
1053            'avatar_URL'   => $avatar_url,
1054            'email'        => $user_email,
1055        );
1056    }
1057
1058    /**
1059     * Get current site data for the agents manager.
1060     *
1061     * Returns minimal site data needed by AgentsManager (ID and domain only).
1062     * Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
1063     *
1064     * @return array Site data with ID and domain.
1065     */
1066    private function get_current_site() {
1067        /*
1068         * Atomic sites have the WP.com blog ID stored as a Jetpack option.
1069         * This code deliberately doesn't use `Jetpack_Options::get_option`
1070         * so it works even when Jetpack has not been loaded.
1071         */
1072        $jetpack_options = get_option( 'jetpack_options' );
1073        if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
1074            $site_id = (int) $jetpack_options['id'];
1075        } else {
1076            $site_id = get_current_blog_id();
1077        }
1078
1079        return array(
1080            'ID'     => $site_id,
1081            'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
1082        );
1083    }
1084}