Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.95% covered (warning)
67.95%
265 / 390
53.33% covered (warning)
53.33%
16 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Agents_Manager
67.95% covered (warning)
67.95%
265 / 390
53.33% covered (warning)
53.33%
16 / 30
805.92
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
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 add_menu_panel
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
2
 add_ai_chat_button
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_scripts
95.16% covered (success)
95.16%
59 / 62
0.00% covered (danger)
0.00%
0 / 1
20
 get_active_variant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 passes_admin_checks
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 enqueue_script
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
7
 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_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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 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.6.0';
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                'html'   => '<div id="agents-manager-masterbar"></div>',
107                'class'  => 'menupop',
108                'target' => '_blank',
109                'rel'    => 'noopener noreferrer',
110            );
111        }
112
113        $wp_admin_bar->add_menu( $menu_args );
114    }
115
116    /**
117     * Add the agents manager menu panel to the admin bar.
118     *
119     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
120     */
121    public function add_menu_panel( $wp_admin_bar ) {
122        // Add chat support group
123        $wp_admin_bar->add_group(
124            array(
125                'parent' => 'agents-manager',
126                'id'     => 'agents-manager-menu-panel-chat',
127                'meta'   => array(
128                    'class' => 'ab-sub-secondary',
129                ),
130            )
131        );
132
133        // Add chat support menu item
134        $wp_admin_bar->add_node(
135            array(
136                'parent' => 'agents-manager-menu-panel-chat',
137                'id'     => 'agents-manager-chat-support',
138                'title'  => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-agents-manager' ) . '</span>',
139            )
140        );
141
142        // Add chat history menu item
143        $wp_admin_bar->add_node(
144            array(
145                'parent' => 'agents-manager-menu-panel-chat',
146                'id'     => 'agents-manager-chat-history',
147                'title'  => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-agents-manager' ) . '</span>',
148            )
149        );
150
151        // Add links group
152        $wp_admin_bar->add_group(
153            array(
154                'parent' => 'agents-manager',
155                'id'     => 'agents-manager-menu-panel-links',
156                'meta'   => array(
157                    'class' => 'ab-sub-secondary',
158                ),
159            )
160        );
161
162        // Add support guides menu item
163        $wp_admin_bar->add_node(
164            array(
165                'parent' => 'agents-manager-menu-panel-links',
166                'id'     => 'agents-manager-support-guides',
167                'title'  => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-agents-manager' ) . '</span>',
168            )
169        );
170
171        // Add courses menu item
172        $wp_admin_bar->add_node(
173            array(
174                'parent' => 'agents-manager-menu-panel-links',
175                'id'     => 'agents-manager-courses',
176                'title'  => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-agents-manager' ) . '</span>',
177                'href'   => 'https://wordpress.com/support/courses/',
178                'meta'   => array(
179                    'target' => '_blank',
180                    'rel'    => 'noopener noreferrer',
181                ),
182            )
183        );
184
185        // Add product updates menu item
186        $wp_admin_bar->add_node(
187            array(
188                'parent' => 'agents-manager-menu-panel-links',
189                'id'     => 'agents-manager-product-updates',
190                'title'  => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-agents-manager' ) . '</span>',
191                'href'   => 'https://wordpress.com/blog/category/product-features/',
192                'meta'   => array(
193                    'target' => '_blank',
194                    'rel'    => 'noopener noreferrer',
195                ),
196            )
197        );
198    }
199
200    /**
201     * Add the standalone AI chat button to the admin bar.
202     *
203     * @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
204     */
205    public function add_ai_chat_button( $wp_admin_bar ) {
206        $wp_admin_bar->add_menu(
207            array(
208                'id'     => 'agents-manager-ai-chat',
209                'parent' => 'top-secondary',
210                '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">
211                                <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" />
212                            </svg></span>',
213            )
214        );
215    }
216
217    /**
218     * Enqueue Agents Manager scripts and add inline script data.
219     */
220    public function enqueue_scripts() {
221        // Early return for P2 frontend - don't add admin bar or enqueue scripts.
222        $stylesheet = get_stylesheet();
223        $is_p2      = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
224
225        if ( ! is_admin() && $is_p2 ) {
226            return;
227        }
228
229        // Determine which variant to load (null = don't load).
230        $variant = self::get_active_variant();
231        if ( null === $variant ) {
232            return;
233        }
234        $use_disconnected = str_contains( $variant, 'disconnected' );
235        $is_gutenberg     = $this->is_block_editor();
236
237        // In Gutenberg, dequeue Help Center so we don't end up with two buttons â€” but only
238        // in the full unified experience, where Agents Manager takes over the Help Center.
239        // In block-editor-only mode (e.g. ?flags=unified-big-sky) Agents Manager replaces
240        // Big Sky's native UI and Help Center should remain available.
241        // Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
242        if ( $is_gutenberg && self::is_unified_experience() ) {
243            wp_dequeue_script( 'help-center' );
244            wp_dequeue_style( 'help-center-style' );
245        }
246
247        // For non-Gutenberg, non-CIAB environments, add to the admin bar. The fullscreen Gutenberg
248        // editor has no admin bar, so JS handles UI insertion â€” except under the omnibar, which is
249        // handled below. CIAB hides the admin bar and uses its own Site Hub.
250        $is_ciab = $this->is_ciab_environment();
251        if ( ! $is_gutenberg && ! $is_ciab ) {
252            add_action(
253                'admin_bar_menu',
254                function ( $wp_admin_bar ) use ( $use_disconnected ) {
255                    $this->add_help_menu( $wp_admin_bar, $use_disconnected );
256                },
257                // Add the agents manager icon to the admin bar after the help center is added, so we can remove it.
258                100
259            );
260
261            // Initialize the agents manager menu panel (only for full variants, not disconnected)
262            if ( ! $use_disconnected ) {
263                add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
264            }
265
266            // Standalone AI chat button, shown only in the unified experience.
267            if ( ! $use_disconnected && self::is_unified_experience() ) {
268                add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
269            }
270        }
271
272        // When Gutenberg's "admin bar in editor" (omnibar) experiment is active, expose the entry
273        // points in that editor admin bar (CIAB is excluded â€” it has its own Site Hub UI). The Help
274        // "?" dropdown shows only in the full unified experience (mirroring wp-admin); the Ask AI
275        // button shows whenever Agents Manager is enabled in this editor. The wp-calypso admin-bar
276        // integration wires both, so no frontend change is needed.
277        if ( ! $is_ciab && ! $use_disconnected && self::is_admin_bar_in_editor() ) {
278            // Help "?" node + dropdown panel first, matching the wp-admin admin bar order.
279            if ( self::is_unified_experience() ) {
280                add_action(
281                    'admin_bar_menu',
282                    function ( $wp_admin_bar ) {
283                        $this->add_help_menu( $wp_admin_bar, false );
284                    },
285                    100
286                );
287                add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
288            }
289
290            // Ask AI button â€” shown whenever Agents Manager is enabled in this editor context.
291            if ( self::is_enabled() ) {
292                add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
293            }
294        }
295
296        /**
297         * Filter to register agent provider modules for the Agents Manager.
298         *
299         * Plugins can hook into this filter to register script module IDs that export
300         * toolProvider and/or contextProvider. The Agents Manager JS will dynamically
301         * import these modules and merge their providers.
302         *
303         * @param array $providers Array of provider script module IDs.
304         */
305        $agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
306
307        $use_unified_experience = self::is_unified_experience();
308
309        /**
310         * Filter the default agent ID for the Agents Manager.
311         *
312         * Allows host applications (e.g., CIAB, WooCommerce AI) to specify a custom
313         * workflow agent instead of the default orchestrator. The value is passed to
314         * the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
315         *
316         * @param string|null $agent_id The agent ID to use, or null for default behavior.
317         */
318        $agent_id = apply_filters( 'agents_manager_agent_id', null );
319
320        $this->enqueue_script( $variant );
321
322        $inline_data = array(
323            'agentProviders'       => $agent_providers,
324            'useUnifiedExperience' => $use_unified_experience,
325            'isDevMode'            => self::is_dev_mode(),
326            'sectionName'          => apply_filters( 'agents_manager_section_name', $variant ),
327            'currentUser'          => $this->get_current_user_data(),
328            'site'                 => $this->get_current_site(),
329            'helpCenterUrl'        => self::HELP_CENTER_URL,
330        );
331
332        if ( $agent_id ) {
333            $inline_data['agentId'] = $agent_id;
334        }
335
336        /**
337         * Filter the data exposed to the Agents Manager frontend.
338         *
339         * @param array $inline_data Data encoded into `agentsManagerData`.
340         */
341        $filtered    = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
342        $inline_data = is_array( $filtered ) ? $filtered : $inline_data;
343
344        wp_add_inline_script(
345            'agents-manager',
346            'const agentsManagerData = ' . wp_json_encode(
347                $inline_data,
348                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
349            ) . ';',
350            'before'
351        );
352    }
353
354    /**
355     * The script variant active for this request, or null if none.
356     *
357     * Single source of truth for "is the Agents Manager app loaded on this
358     * request?". Used both to enqueue the app and to gate the server-side
359     * sidebar pre-render, so the pre-rendered shell can never appear on a page
360     * where the app won't mount to reconcile it.
361     *
362     * @return string|null The variant name, or null if scripts should not be loaded.
363     */
364    public static function get_active_variant() {
365        /**
366         * Filter the script variant the Agents Manager loads for this request.
367         *
368         * @since 0.1.0
369         *
370         * @param string|null $variant The resolved variant, or null to not load.
371         */
372        return apply_filters( 'agents_manager_variant', self::get_variant() );
373    }
374
375    /**
376     * Determine which script variant to load, or null if none should be loaded.
377     *
378     * Combines the gating logic (should we load at all?) with variant selection
379     * (which build to use?) into a single method so the two cannot get out of sync.
380     *
381     * @return string|null The variant name, or null if scripts should not be loaded.
382     */
383    private static function get_variant() {
384        // CIAB: Load either the connected or disconnected variants if enabled.
385        if ( self::is_ciab_environment() && self::is_enabled() ) {
386            return self::is_jetpack_disconnected() ? 'ciab-disconnected' : 'ciab';
387        }
388
389        // Frontend: load disconnected variant for eligible logged-in editors.
390        if ( ! is_admin() ) {
391            if ( self::is_loading_on_frontend() && self::is_enabled() ) {
392                return 'wp-admin-disconnected';
393            }
394            return null;
395        }
396
397        // Apply wp-admin exclusions (WooCommerce, customizer, preview contexts).
398        if ( ! self::passes_admin_checks() ) {
399            return null;
400        }
401
402        if ( ! self::is_enabled() ) {
403            return null;
404        }
405
406        $disconnected = self::is_jetpack_disconnected();
407
408        if ( self::is_block_editor() ) {
409            return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
410        }
411
412        return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
413    }
414
415    /**
416     * Whether the unified experience â€” the Help Center takeover â€” is active.
417     *
418     * "Unified" here means Agents Manager takes over the Help Center, unifying Odie and
419     * Dolly (the orchestrator) into a single chat experience. This is distinct from
420     * block-editor-only enablement, which replaces Big Sky's native UI without taking
421     * over the Help Center.
422     *
423     * @return bool
424     */
425    public static function is_unified_experience() {
426        /**
427         * Filter to determine if the user should see the unified chat experience.
428         *
429         * When true, Help Center will render UnifiedAIAgent instead of traditional UI.
430         * The filter is hooked by should_use_unified_experience() in this class.
431         *
432         * @param bool $use_unified_experience Whether to use unified experience. Default false.
433         */
434        return (bool) apply_filters( 'agents_manager_use_unified_experience', false );
435    }
436
437    /**
438     * Returns true if the Agents Manager should be loaded in the current context.
439     *
440     * @return bool
441     */
442    public static function is_enabled() {
443        // CIAB: Agents Manager is the default AI experience â€” enabled unless explicitly
444        // disabled via filter (e.g. for debugging or gradual rollout).
445        if ( self::is_ciab_environment() ) {
446            /**
447             * Filter whether Agents Manager is enabled in CIAB (Next Admin) environments.
448             *
449             * @param bool $enabled Whether Agents Manager should load. Default true.
450             */
451            return apply_filters( 'agents_manager_enabled_in_ciab', true );
452        }
453
454        // Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
455        if ( self::is_unified_experience() ) {
456            return true;
457        }
458
459        // Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
460        if ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
461            return true;
462        }
463
464        return false;
465    }
466
467    /**
468     * Returns true if the current wp-admin context passes all exclusion checks.
469     *
470     * Excludes WooCommerce Admin home, customizer preview, Gutenberg asset requests,
471     * and preview query param contexts.
472     *
473     * @return bool
474     */
475    private static function passes_admin_checks() {
476        // Don't load on WooCommerce Admin home page to avoid UI conflicts.
477        global $current_screen;
478        if ( $current_screen && $current_screen->id === 'woocommerce_page_wc-admin' ) {
479            return false;
480        }
481
482        // Don't load in customizer preview iframe.
483        if ( is_customize_preview() ) {
484            return false;
485        }
486
487        // Don't load during Gutenberg asset requests or preview contexts.
488        $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
489        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
490        $is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
491        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
492        $is_preview_overlay = isset( $_GET['preview_overlay'] );
493        if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
494            return false;
495        }
496
497        return true;
498    }
499
500    /**
501     * Enqueue Agents Manager script based on context.
502     *
503     * @param string $variant The variant of the asset file to get.
504     */
505    private function enqueue_script( $variant ) {
506        $cache_key  = 'agents-manager-asset-' . $variant . '.asset.json';
507        $asset_file = get_transient( $cache_key );
508
509        if ( ! $asset_file ) {
510            $asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
511            if ( ! $asset_file ) {
512                return;
513            }
514            set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
515        }
516
517        // When the request is dev mode, use a random cache buster as the version for easier debugging.
518        $version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
519
520        $script_dependencies = $asset_file['dependencies'] ?? array();
521
522        wp_enqueue_script(
523            'agents-manager',
524            'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
525            $script_dependencies,
526            $version,
527            /**
528             * Filter the strategy to use when enqueuing the script.
529             *
530             * @param array|bool $args The arguments to pass to wp_enqueue_script. Default is true.
531             * @param string $handle The handle of the script.
532             */
533            apply_filters( 'agents_manager_enqueue_script_strategy', true, 'agents-manager' )
534        );
535
536        if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
537            wp_enqueue_style(
538                'agents-manager-style',
539                'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
540                array(),
541                $version
542            );
543        }
544    }
545
546    /**
547     * Get the asset via file-system on wpcom and via network on Atomic sites.
548     *
549     * @param string $filepath The URL to download the asset file from.
550     * @return array|null The asset file data or null on failure.
551     */
552    private static function get_assets_json( $filepath ) {
553        $accessible_directly = file_exists( ABSPATH . $filepath );
554
555        if ( $accessible_directly ) {
556            $file_contents = file_get_contents( ABSPATH . $filepath );
557
558            if ( false === $file_contents ) {
559                return null;
560            }
561
562            return json_decode( $file_contents, true );
563        }
564
565        $request = wp_remote_get( 'https://' . $filepath );
566
567        if ( is_wp_error( $request ) ) {
568            return null;
569        }
570
571        $response_code = wp_remote_retrieve_response_code( $request );
572        if ( 200 !== $response_code ) {
573            return null;
574        }
575
576        $content_type = wp_remote_retrieve_header( $request, 'content-type' );
577        if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
578            return null;
579        }
580
581        $body = wp_remote_retrieve_body( $request );
582        if ( '' === $body ) {
583            return null;
584        }
585
586        $decoded = json_decode( $body, true );
587        if ( json_last_error() !== JSON_ERROR_NONE ) {
588            return null;
589        }
590
591        return $decoded;
592    }
593
594    /**
595     * Update the calypso preferences.
596     *
597     * @param \stdClass $preferences The preferences.
598     *
599     * @return \stdClass The preferences.
600     */
601    public function calypso_preferences_update( $preferences ) {
602        // Check if agents_manager_router_history exists and is a valid array structure
603        if ( ! isset( $preferences->agents_manager_router_history ) ||
604            ! is_array( $preferences->agents_manager_router_history ) ) {
605            return $preferences;
606        }
607
608        $router_history = $preferences->agents_manager_router_history;
609
610        // Check if entries exist and is an array
611        if ( ! isset( $router_history['entries'] ) ||
612            ! is_array( $router_history['entries'] ) ) {
613            return $preferences;
614        }
615
616        $entries = $router_history['entries'];
617
618        // Limit entries to 50 to prevent spamming entries in the router history.
619        if ( count( $entries ) > 50 ) {
620            // Keep only the last 49 entries and add the root entry at the beginning.
621            $entries = array_slice( $entries, -49 );
622            // Keep the start at root so the back button always works.
623            array_unshift(
624                $entries,
625                array(
626                    'pathname' => '/',
627                    'search'   => '',
628                    'hash'     => '',
629                    'key'      => 'default',
630                    'state'    => null,
631                )
632            );
633
634            // Update the preferences object directly
635            $preferences->agents_manager_router_history['entries'] = $entries;
636            $preferences->agents_manager_router_history['index']   = 49;
637        }
638
639        return $preferences;
640    }
641
642    /**
643     * Creates instance.
644     *
645     * @return Agents_Manager
646     */
647    public static function init() {
648        if ( did_action( 'jetpack_agents_manager_initialized' ) ) {
649            return self::get_instance();
650        }
651
652        self::$instance = new self();
653
654        /**
655         * Fires once the Agents Manager class has been instantiated.
656         *
657         * @since 0.5.0
658         */
659        do_action( 'jetpack_agents_manager_initialized' );
660
661        return self::$instance;
662    }
663
664    /**
665     * Returns the instance of the Agents Manager class.
666     *
667     * @return Agents_Manager
668     */
669    public static function get_instance() {
670        return self::$instance;
671    }
672
673    /**
674     * Returns whether the current request is coming from the A8C proxy.
675     *
676     * @return bool
677     */
678    private static function is_proxied() {
679        // On Simple sites, use the wpcom function if available.
680        if ( function_exists( 'wpcom_is_proxied_request' ) ) {
681            return wpcom_is_proxied_request();
682        }
683
684        // On WoA/Garden sites, check server variable or constant.
685        return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
686            ? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
687            : Constants::is_true( 'A8C_PROXIED_REQUEST' );
688    }
689
690    /**
691     * Enables "Development" features that should be accessible only for admins.
692     */
693    private static function is_dev_mode() {
694        // Known local environments.
695        $domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
696        if (
697            $domain === 'localhost' ||
698            '.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
699            '.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
700        ) {
701            return true;
702        }
703
704        // A8C development.
705        if ( self::is_proxied() ) {
706            return true;
707        }
708
709        if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
710            switch ( Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
711                case 1:
712                case 2:
713                case 3: // Pressable
714                case 32:
715                case 118: // Commerce garden client (ciab)
716                    return true;
717            }
718        }
719
720        return false;
721    }
722
723    /**
724     * Register the Agents Manager endpoints.
725     */
726    public function register_rest_api() {
727        ( new WP_REST_Agents_Manager_Persisted_Open_State() )->register_rest_route();
728        ( new WP_REST_Jetpack_AI_JWT() )->register_rest_route();
729    }
730
731    /**
732     * Determine if user should see unified experience.
733     *
734     * @param bool $use_unified_experience Whether to use unified experience.
735     * @return bool
736     */
737    public function should_use_unified_experience( $use_unified_experience = false ) {
738        // Early return for non-proxied/dev mode requests.
739        // This feature is currently only available to Automattic employees testing via proxy.
740        if ( ! self::is_dev_mode() ) {
741            return false;
742        }
743
744        $user_id = get_current_user_id();
745
746        if ( ! $user_id ) {
747            return false;
748        }
749
750        $is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
751        if ( $is_simple_site ) {
752            // On Simple sites, evaluate locally.
753            // Check Automattician and opt-in setting.
754            $is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
755            if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
756                return true;
757            }
758        }
759
760        // On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
761        // This avoids duplicating rollout logic and handles cases where
762        // wpcom-specific functions (like get_user_attribute) aren't available.
763        if ( $this->fetch_unified_experience_preference() ) {
764            return true;
765        }
766
767        // Default to false, for now.
768        // 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.
769        return $use_unified_experience;
770    }
771
772    /**
773     * Check if user has enabled unified chat opt-in in their Automattician options.
774     *
775     * This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
776     * Only used on Simple sites where get_user_attribute is available.
777     *
778     * @param int $user_id User ID.
779     *
780     * @return bool
781     */
782    private function has_unified_chat_opt_in_enabled( $user_id ) {
783        if ( ! function_exists( '\get_user_attribute' ) ) {
784            return false;
785        }
786
787        $calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
788        return ! empty( $calypso_prefs['unified_ai_chat'] );
789    }
790
791    /**
792     * Fetch unified experience preference from wpcom via Jetpack Connection.
793     *
794     * Used on Atomic sites to delegate the decision to wpcom, which has
795     * access to user attributes and can evaluate the rollout logic.
796     *
797     * Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
798     *
799     * @return bool Whether user should see unified experience.
800     */
801    private function fetch_unified_experience_preference() {
802        $user_id = get_current_user_id();
803        if ( ! $user_id ) {
804            return false;
805        }
806
807        // Check transient cache first (per-user cache).
808        $cache_key     = 'unified-experience-' . $user_id;
809        $cached_result = get_transient( $cache_key );
810        if ( false !== $cached_result ) {
811            return (bool) $cached_result;
812        }
813
814        // Check if user is connected before making API call.
815        if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
816            return false;
817        }
818
819        // Call dedicated agents-manager/state endpoint.
820        $wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
821            '/agents-manager/state?key=unified_ai_chat',
822            '2',
823            array( 'method' => 'GET' )
824        );
825
826        if ( is_wp_error( $wpcom_request ) ) {
827            // Cache failures too to avoid hammering the API.
828            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
829            return false;
830        }
831
832        $response_code = wp_remote_retrieve_response_code( $wpcom_request );
833        if ( 200 !== $response_code ) {
834            set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
835            return false;
836        }
837
838        $body         = wp_remote_retrieve_body( $wpcom_request );
839        $decoded_body = json_decode( $body, true );
840
841        // The response is { "unified_ai_chat": true/false } when using key param.
842        $result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
843
844        // Cache for 1 minute.
845        set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
846
847        return $result;
848    }
849
850    /**
851     * Returns true if the current request is on the frontend and the user can edit posts.
852     *
853     * Mirrors Help_Center::is_loading_on_frontend().
854     *
855     * @return bool True if loading on the frontend for an eligible user.
856     */
857    private static function is_loading_on_frontend() {
858        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
859        if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
860            return false;
861        }
862
863        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
864        if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
865            return false;
866        }
867
868        $can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
869        return ! is_admin() && ! self::is_block_editor() && $can_edit_posts;
870    }
871
872    /**
873     * Returns true if the current screen is the block editor.
874     *
875     * @return bool True if the current screen is the block editor.
876     */
877    private static function is_block_editor() {
878        if ( ! function_exists( 'get_current_screen' ) ) {
879            return false;
880        }
881
882        $current_screen = get_current_screen();
883        // The widgets screen has the block editor but no Gutenberg top bar.
884        return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
885    }
886
887    /**
888     * Returns true when Gutenberg's "admin bar in editor" (omnibar) experiment is active.
889     *
890     * Mirrors Gutenberg core's gate in `lib/experimental/admin-bar-in-editor/load.php`, and fails
891     * safe when `gutenberg_is_experiment_enabled()` is unavailable.
892     *
893     * @return bool
894     */
895    private static function is_admin_bar_in_editor() {
896        return self::is_block_editor()
897            && is_admin_bar_showing()
898            && function_exists( 'gutenberg_is_experiment_enabled' )
899            // @phan-suppress-next-line PhanUndeclaredFunction -- Guarded by function_exists() above.
900            && \gutenberg_is_experiment_enabled( 'gutenberg-admin-bar-in-editor' );
901    }
902
903    /**
904     * Check if current environment is CIAB (Commerce in a Box) / Next Admin.
905     *
906     * Uses the same detection method as Help Center: checks if next_admin_init has fired.
907     *
908     * @return bool True if CIAB/Next Admin environment.
909     */
910    private static function is_ciab_environment() {
911        return (bool) did_action( 'next_admin_init' );
912    }
913
914    /**
915     * Returns true if the current user is NOT connected through Jetpack.
916     *
917     * Mirrors the logic from Help_Center::is_jetpack_disconnected().
918     *
919     * @return bool True if the site uses Jetpack but the current user is not connected.
920     */
921    private static function is_jetpack_disconnected() {
922        $user_id = get_current_user_id();
923        $blog_id = get_current_blog_id();
924
925        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
926            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
927        }
928
929        if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
930            return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
931        }
932
933        return false;
934    }
935
936    /**
937     * Get current user data for the agents manager.
938     *
939     * Mirrors the user data structure from Help Center's helpCenterData.
940     *
941     * @return array|null User data array or null if not logged in.
942     */
943    private function get_current_user_data() {
944        $user_id = get_current_user_id();
945        if ( ! $user_id ) {
946            return null;
947        }
948
949        $user_data = get_userdata( $user_id );
950        if ( ! $user_data ) {
951            return null;
952        }
953
954        $user_email = $user_data->user_email;
955
956        // Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
957        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
958            $avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
959        } else {
960            $avatar_url = get_avatar_url( $user_id );
961        }
962
963        return array(
964            'ID'           => $user_id,
965            'username'     => $user_data->user_login,
966            'display_name' => $user_data->display_name,
967            'avatar_URL'   => $avatar_url,
968            'email'        => $user_email,
969        );
970    }
971
972    /**
973     * Get current site data for the agents manager.
974     *
975     * Returns minimal site data needed by AgentsManager (ID and domain only).
976     * Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
977     *
978     * @return array Site data with ID and domain.
979     */
980    private function get_current_site() {
981        /*
982         * Atomic sites have the WP.com blog ID stored as a Jetpack option.
983         * This code deliberately doesn't use `Jetpack_Options::get_option`
984         * so it works even when Jetpack has not been loaded.
985         */
986        $jetpack_options = get_option( 'jetpack_options' );
987        if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
988            $site_id = (int) $jetpack_options['id'];
989        } else {
990            $site_id = get_current_blog_id();
991        }
992
993        return array(
994            'ID'     => $site_id,
995            'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
996        );
997    }
998}